diff --git a/src/components/insights/coach-panel/__tests__/coach-settings-sheet.test.tsx b/src/components/insights/coach-panel/__tests__/coach-settings-sheet.test.tsx new file mode 100644 index 000000000..9cce6fedf --- /dev/null +++ b/src/components/insights/coach-panel/__tests__/coach-settings-sheet.test.tsx @@ -0,0 +1,107 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { toast } from "sonner"; + +import { I18nProvider } from "@/lib/i18n/context"; +import { DEFAULT_COACH_PREFS } from "@/lib/validations/coach-prefs"; + +// v1.4.23 W6 (Design-H3 + H4) — verify the settings sheet renders a +// skeleton placeholder while the persisted prefs are loading (no +// `DEFAULT_COACH_PREFS` ghost form) and emits a sonner toast on save +// success. +// +// The shadcn `` wraps Radix Dialog which uses Portals — those +// don't materialise under `renderToStaticMarkup`. We mock the sheet +// primitives down to plain wrappers so the form/skeleton body is +// reachable in the static markup. + +const queryState: { data: unknown } = { data: undefined }; +const mutationCallbacks: { onSuccess: ((env: unknown) => void) | null } = { + onSuccess: null, +}; + +vi.mock("@tanstack/react-query", () => ({ + useQuery: () => ({ data: queryState.data }), + useMutation: (opts: { onSuccess?: (env: unknown) => void }) => { + mutationCallbacks.onSuccess = opts.onSuccess ?? null; + return { + mutate: (next: unknown) => { + mutationCallbacks.onSuccess?.({ data: next }); + }, + isPending: false, + }; + }, + useQueryClient: () => ({ + setQueryData: vi.fn(), + }), +})); + +vi.mock("sonner", () => ({ + toast: { success: vi.fn() }, +})); + +vi.mock("@/components/ui/sheet", () => ({ + Sheet: ({ children, open }: { children: React.ReactNode; open: boolean }) => + open ?
{children}
: null, + SheetContent: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + SheetHeader: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + SheetTitle: ({ children }: { children: React.ReactNode }) => ( +

{children}

+ ), + SheetDescription: ({ children }: { children: React.ReactNode }) => ( +

{children}

+ ), +})); + +import { CoachSettingsSheet } from "../coach-settings-sheet"; + +function render(node: React.ReactNode) { + return renderToStaticMarkup( + {node}, + ); +} + +beforeEach(() => { + queryState.data = undefined; + mutationCallbacks.onSuccess = null; + vi.mocked(toast.success).mockClear(); +}); + +describe("", () => { + it("renders a skeleton shell while persisted prefs are loading (Design-H3)", () => { + queryState.data = undefined; + const html = render( + {}} />, + ); + expect(html).toContain('data-slot="coach-prefs-skeleton"'); + // Form body markers must be absent during the skeleton state so + // the user never sees the DEFAULT_COACH_PREFS ghost form. + expect(html).not.toContain('data-slot="coach-prefs-tone"'); + expect(html).not.toContain('data-slot="coach-prefs-evidence"'); + }); + + it("renders the form once persisted prefs land", () => { + queryState.data = DEFAULT_COACH_PREFS; + const html = render( + {}} />, + ); + expect(html).not.toContain('data-slot="coach-prefs-skeleton"'); + expect(html).toContain('data-slot="coach-prefs-tone"'); + expect(html).toContain('data-slot="coach-prefs-evidence"'); + }); + + it("fires a sonner toast on save success (Design-H4)", () => { + queryState.data = DEFAULT_COACH_PREFS; + render( {}} />); + // Static markup doesn't dispatch click events; trigger the captured + // mutation success path directly. Verifies the wired-up onSuccess + // emits the toast with the localised "Saved." string. + mutationCallbacks.onSuccess?.({ data: DEFAULT_COACH_PREFS }); + expect(vi.mocked(toast.success)).toHaveBeenCalledTimes(1); + expect(vi.mocked(toast.success)).toHaveBeenCalledWith("Saved."); + }); +}); diff --git a/src/components/insights/coach-panel/coach-settings-sheet.tsx b/src/components/insights/coach-panel/coach-settings-sheet.tsx index 9fe8374c0..1428e6986 100644 --- a/src/components/insights/coach-panel/coach-settings-sheet.tsx +++ b/src/components/insights/coach-panel/coach-settings-sheet.tsx @@ -1,8 +1,9 @@ "use client"; import { useState } from "react"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; import { Loader2 } from "lucide-react"; +import { toast } from "sonner"; import { Sheet, @@ -21,7 +22,9 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { Skeleton } from "@/components/ui/skeleton"; import { useTranslations } from "@/lib/i18n/context"; +import { useCoachPrefs } from "@/hooks/use-coach-prefs"; import { DEFAULT_COACH_PREFS, type CoachExcludeMetric, @@ -43,6 +46,13 @@ import { * The four controls match the v1.4.23 H4 spec exactly: tone (3-state), * verbosity (3-state), excludeMetrics (multi-toggle), and * showEvidenceByDefault (single toggle). + * + * v1.4.23 W6 design-pass: + * - Skeleton placeholder while the prefs are loading (Design-H3) so + * the user never sees the `DEFAULT_COACH_PREFS` ghost form snap to + * their saved values mid-paint. + * - On save success the sheet emits a sonner toast (Design-H4) before + * closing so the action is visibly acknowledged. */ export interface CoachSettingsSheetProps { open: boolean; @@ -68,16 +78,7 @@ export function CoachSettingsSheet({ const { t } = useTranslations(); const queryClient = useQueryClient(); - const { data: persisted } = useQuery({ - queryKey: ["coach-prefs"], - queryFn: async () => { - const res = await fetch("/api/auth/me/coach-prefs"); - if (!res.ok) throw new Error("coach-prefs.fetch_failed"); - const env = (await res.json()) as { data: CoachPrefs }; - return env.data; - }, - enabled: open, - }); + const { data: persisted } = useCoachPrefs({ enabled: open }); // Local working copy edited by the form. Re-syncs to the persisted // shape on every fetch via the same in-render setState pattern as @@ -105,6 +106,7 @@ export function CoachSettingsSheet({ }, onSuccess: (envelope) => { queryClient.setQueryData(["coach-prefs"], envelope.data); + toast.success(t("insights.coach.settingsSaved")); onOpenChange(false); }, }); @@ -118,6 +120,11 @@ export function CoachSettingsSheet({ })); } + // Render a skeleton shell while the persisted prefs are loading so + // we never render the form against `DEFAULT_COACH_PREFS` and snap + // the controls when the fetch resolves a moment later. + const isLoading = open && !persisted; + return ( -
- {/* Tone */} -
- - + {isLoading ? ( +
+
+ + +
+
+ + +
+
+ + + +
+
+
+ + +
+ +
- - {/* Verbosity */} -
- - -
- - {/* Exclude metrics */} -
- -

- {t("insights.coach.settingsExcludeHint")} -

-
    - {EXCLUDE_OPTIONS.map((metric) => { - const id = `coach-prefs-exclude-${metric}`; - const checked = draft.excludeMetrics.includes(metric); - return ( -
  • - - toggleExclude(metric, next)} - /> -
  • - ); - })} -
-
- - {/* Show evidence by default */} -
-
+ ) : ( +
+ {/* Tone */} +
+ +
+ + {/* Verbosity */} +
+ + +
+ + {/* Exclude metrics */} +
+

- {t("insights.coach.settingsEvidenceHint")} + {t("insights.coach.settingsExcludeHint")}

+
    + {EXCLUDE_OPTIONS.map((metric) => { + const id = `coach-prefs-exclude-${metric}`; + const checked = draft.excludeMetrics.includes(metric); + return ( +
  • + + + toggleExclude(metric, next) + } + /> +
  • + ); + })} +
+
+ + {/* Show evidence by default */} +
+
+ +

+ {t("insights.coach.settingsEvidenceHint")} +

+
+ + setDraft((prev) => ({ + ...prev, + showEvidenceByDefault: next, + })) + } + />
- - setDraft((prev) => ({ - ...prev, - showEvidenceByDefault: next, - })) - } - />
-
+ )}