diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/components/NamingInstructionsField/NamingInstructionsField.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/components/NamingInstructionsField/NamingInstructionsField.tsx new file mode 100644 index 00000000000..a97d8c4e8bc --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/components/NamingInstructionsField/NamingInstructionsField.tsx @@ -0,0 +1,166 @@ +import { Trans } from "@lingui/react/macro"; +import { Textarea } from "@superset/ui/textarea"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { HiCheckCircle } from "react-icons/hi2"; + +interface NamingInstructionsFieldProps { + id: string; + /** `null` means no instructions are set. */ + savedValue: string | null; + placeholder: string; + /** Blank saves as `null`, which clears the setting. */ + save: (instructions: string | null) => Promise; + onSaved?: () => void; +} + +type SaveStatus = "idle" | "saving" | "saved" | "error"; + +const SAVE_DEBOUNCE_MS = 500; +const SAVED_INDICATOR_MS = 2000; + +export function NamingInstructionsField({ + id, + savedValue: savedInstructions, + placeholder, + save, + onSaved, +}: NamingInstructionsFieldProps) { + const savedValue = savedInstructions ?? ""; + const [value, setValue] = useState(savedValue); + const [saveStatus, setSaveStatus] = useState("idle"); + + // Ask the DOM instead of tracking focus in a ref: a focus-tracking ref + // wedges stuck-true if the field unmounts while focused (same rationale + // as SparseCheckoutSection). + const textareaRef = useRef(null); + const latestValueRef = useRef(value); + const lastSavedRef = useRef(savedValue); + const debounceTimerRef = useRef | null>(null); + const savedTimerRef = useRef | null>(null); + const saveInFlightRef = useRef(false); + const queuedValueRef = useRef(null); + const isMountedRef = useRef(true); + const saveRef = useRef(save); + saveRef.current = save; + const onSavedRef = useRef(onSaved); + onSavedRef.current = onSaved; + + useEffect(() => { + // Don't clobber an in-progress edit when the saved value refetches. + const isFocused = + !!textareaRef.current && document.activeElement === textareaRef.current; + if (isFocused || debounceTimerRef.current || saveInFlightRef.current) { + return; + } + setValue(savedValue); + latestValueRef.current = savedValue; + lastSavedRef.current = savedValue; + }, [savedValue]); + + useEffect(() => { + isMountedRef.current = true; + return () => { + isMountedRef.current = false; + if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current); + if (savedTimerRef.current) clearTimeout(savedTimerRef.current); + }; + }, []); + + const flushSave = useCallback(async (next: string): Promise => { + // Remember the latest intent while a request is in flight; the + // `finally` below drains it once this attempt settles. + if (saveInFlightRef.current) { + queuedValueRef.current = next; + return; + } + if (next.trim() === lastSavedRef.current.trim()) { + queuedValueRef.current = null; + return; + } + + if (savedTimerRef.current) { + clearTimeout(savedTimerRef.current); + savedTimerRef.current = null; + } + setSaveStatus("saving"); + saveInFlightRef.current = true; + try { + await saveRef.current(next.trim() || null); + lastSavedRef.current = next; + if (!isMountedRef.current) return; + setSaveStatus("saved"); + savedTimerRef.current = setTimeout(() => { + setSaveStatus("idle"); + savedTimerRef.current = null; + }, SAVED_INDICATOR_MS); + onSavedRef.current?.(); + } catch (err) { + console.warn("[naming-instructions/save] failed", err); + if (isMountedRef.current) setSaveStatus("error"); + } finally { + saveInFlightRef.current = false; + const pending = queuedValueRef.current; + queuedValueRef.current = null; + if (pending !== null && isMountedRef.current) { + void flushSave(pending); + } + } + }, []); + + const handleChange = useCallback( + (nextValue: string) => { + setValue(nextValue); + latestValueRef.current = nextValue; + if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current); + debounceTimerRef.current = setTimeout(() => { + debounceTimerRef.current = null; + void flushSave(latestValueRef.current); + }, SAVE_DEBOUNCE_MS); + }, + [flushSave], + ); + + const handleBlur = useCallback(() => { + // Commit immediately on the way out instead of leaving the last + // keystrokes sitting in the debounce window. + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); + debounceTimerRef.current = null; + } + void flushSave(latestValueRef.current); + }, [flushSave]); + + return ( + <> + ", - ); + expect( + renderEditor({ instructions: "Use the ticket id as the branch name" }), + ).toContain(">Use the ticket id as the branch name"); }); test("opens empty on a host that has none", () => { - expect(renderEditor(null)).toContain(">"); + expect(renderEditor()).toContain(">"); + }); + + test("shows the global instructions it inherits", () => { + const html = renderEditor({ globalInstructions: "Prefix with feat/" }); + expect(html).toContain('placeholder="Prefix with feat/"'); + expect(html).toContain("Empty uses the global instructions."); + expect(html).toContain("Ignore global naming instructions"); + }); + + test("does not show the global instructions when the project ignores them", () => { + const html = renderEditor({ + globalInstructions: "Prefix with feat/", + ignoreGlobalInstructions: true, + }); + expect(html).not.toContain("Prefix with feat/"); + expect(html).toContain("Empty uses the default naming."); + }); + + test("a host without global instructions gets no toggle and no inherited text", () => { + const html = renderEditor({ + globalInstructions: "Prefix with feat/", + ignoreGlobalInstructions: undefined, + }); + expect(html).not.toContain("Prefix with feat/"); + expect(html).not.toContain("Ignore global naming instructions"); }); }); diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/NamingInstructionsSection/NamingInstructionsSection.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/NamingInstructionsSection/NamingInstructionsSection.tsx index 90565fa803e..edf92cee4c9 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/NamingInstructionsSection/NamingInstructionsSection.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/NamingInstructionsSection/NamingInstructionsSection.tsx @@ -1,145 +1,60 @@ import { Trans, useLingui } from "@lingui/react/macro"; +import { errorMessage } from "@superset/i18n/errors"; import { Label } from "@superset/ui/label"; -import { Textarea } from "@superset/ui/textarea"; +import { toast } from "@superset/ui/sonner"; +import { Switch } from "@superset/ui/switch"; import { useMutation } from "@tanstack/react-query"; -import { useCallback, useEffect, useRef, useState } from "react"; -import { HiCheckCircle } from "react-icons/hi2"; +import { useState } from "react"; import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; +import { NamingInstructionsField } from "../../../../../../components/NamingInstructionsField"; interface NamingInstructionsSectionProps { projectId: string; hostUrl: string; /** Current instructions; `null` means default AI naming behavior. */ instructions: string | null; + /** The person's account-wide instructions. */ + globalInstructions: string | null; + /** `undefined` on hosts that predate global instructions. */ + ignoreGlobalInstructions: boolean | undefined; onChanged: () => void; } -type SaveStatus = "idle" | "saving" | "saved" | "error"; - -const SAVE_DEBOUNCE_MS = 500; -const SAVED_INDICATOR_MS = 2000; - export function NamingInstructionsSection({ projectId, hostUrl, instructions, + globalInstructions, + ignoreGlobalInstructions, onChanged, }: NamingInstructionsSectionProps) { const { t } = useLingui(); - const savedValue = instructions ?? ""; - const [value, setValue] = useState(savedValue); - const [saveStatus, setSaveStatus] = useState("idle"); - - // Ask the DOM instead of tracking focus in a ref: a focus-tracking ref - // wedges stuck-true if the field unmounts while focused (same rationale - // as SparseCheckoutSection). - const textareaRef = useRef(null); - const latestValueRef = useRef(value); - const lastSavedRef = useRef(savedValue); - const debounceTimerRef = useRef | null>(null); - const savedTimerRef = useRef | null>(null); - const saveInFlightRef = useRef(false); - const queuedValueRef = useRef(null); - const isMountedRef = useRef(true); - - useEffect(() => { - // Don't clobber an in-progress edit when the host row refetches. - const isFocused = - !!textareaRef.current && document.activeElement === textareaRef.current; - if (isFocused || debounceTimerRef.current || saveInFlightRef.current) { - return; - } - setValue(savedValue); - latestValueRef.current = savedValue; - lastSavedRef.current = savedValue; - }, [savedValue]); - - useEffect(() => { - isMountedRef.current = true; - return () => { - isMountedRef.current = false; - if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current); - if (savedTimerRef.current) clearTimeout(savedTimerRef.current); - }; - }, []); + const hostSupportsGlobal = ignoreGlobalInstructions !== undefined; + const [ignoreGlobal, setIgnoreGlobal] = useState( + ignoreGlobalInstructions ?? false, + ); + const inherited = + hostSupportsGlobal && !ignoreGlobal ? globalInstructions?.trim() : ""; - const saveMutation = useMutation({ - mutationFn: (next: string) => - getHostServiceClientByUrl(hostUrl).project.setNamingInstructions.mutate({ + const ignoreMutation = useMutation({ + mutationFn: (ignore: boolean) => + getHostServiceClientByUrl( + hostUrl, + ).project.setIgnoreGlobalNamingInstructions.mutate({ projectId, - // Blank clears the setting back to default naming. - instructions: next.trim() || null, + ignore, }), - }); - - const flushSaveRef = useRef<((next: string) => Promise) | null>(null); - const flushSave = useCallback( - async (next: string) => { - // Remember the latest intent while a request is in flight; the - // `finally` below drains it once this attempt settles. - if (saveInFlightRef.current) { - queuedValueRef.current = next; - return; - } - if (next.trim() === lastSavedRef.current.trim()) { - queuedValueRef.current = null; - return; - } - - if (savedTimerRef.current) { - clearTimeout(savedTimerRef.current); - savedTimerRef.current = null; - } - setSaveStatus("saving"); - saveInFlightRef.current = true; - try { - await saveMutation.mutateAsync(next); - lastSavedRef.current = next; - if (!isMountedRef.current) return; - setSaveStatus("saved"); - savedTimerRef.current = setTimeout(() => { - setSaveStatus("idle"); - savedTimerRef.current = null; - }, SAVED_INDICATOR_MS); - onChanged(); - } catch (err) { - console.warn("[naming-instructions/save] failed", err); - if (isMountedRef.current) setSaveStatus("error"); - } finally { - saveInFlightRef.current = false; - const pending = queuedValueRef.current; - queuedValueRef.current = null; - if (pending !== null && isMountedRef.current) { - void flushSaveRef.current?.(pending); - } - } + onSuccess: () => onChanged(), + onError: (err, ignore) => { + setIgnoreGlobal(!ignore); + toast.error( + errorMessage( + err, + t({ message: "Couldn't update the global naming setting" }), + ), + ); }, - [onChanged, saveMutation], - ); - flushSaveRef.current = flushSave; - - const handleChange = useCallback( - (nextValue: string) => { - setValue(nextValue); - latestValueRef.current = nextValue; - if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current); - debounceTimerRef.current = setTimeout(() => { - debounceTimerRef.current = null; - void flushSave(latestValueRef.current); - }, SAVE_DEBOUNCE_MS); - }, - [flushSave], - ); - - const handleBlur = useCallback(() => { - // Commit immediately on the way out instead of leaving the last - // keystrokes sitting in the debounce window. - if (debounceTimerRef.current) { - clearTimeout(debounceTimerRef.current); - debounceTimerRef.current = null; - } - void flushSave(latestValueRef.current); - }, [flushSave]); + }); return (
@@ -151,44 +66,59 @@ export function NamingInstructionsSection({ Naming instructions

- - Guides AI-generated workspace and branch names for this project. - Empty uses the default naming. - + {inherited ? ( + + Guides AI-generated workspace and branch names for this project. + These are added after your global instructions and win where the + two conflict. Empty uses the global instructions. + + ) : ( + + Guides AI-generated workspace and branch names for this project. + Empty uses the default naming. + + )}

-