diff --git a/src/pages/settings/UsersSettingsPage.test.tsx b/src/pages/settings/UsersSettingsPage.test.tsx new file mode 100644 index 0000000..947cb7d --- /dev/null +++ b/src/pages/settings/UsersSettingsPage.test.tsx @@ -0,0 +1,153 @@ +// UsersSettingsPage — the re-show button reads the shared path (#397). +// +// React's `useState` is stubbed (same technique as +// useOnboardingChecklist.wiring.test.ts): the page runs as a plain function +// and its state survives from one call to the next, as between two renders of +// the mounted page. So "the path got hidden by the banner of this very page" +// is a new `useOnboarding()` value on the next render, without remounting. +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { isValidElement, type ReactElement, type ReactNode } from "react"; + +const harness = vi.hoisted(() => ({ + slots: [] as unknown[], + cursor: 0, + onboarding: null as unknown, +})); + +vi.mock("react", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useState: (init: unknown) => { + const index = harness.cursor++; + if (!(index in harness.slots)) { + harness.slots[index] = typeof init === "function" ? (init as () => unknown)() : init; + } + const set = (next: unknown) => { + harness.slots[index] = + typeof next === "function" ? (next as (previous: unknown) => unknown)(harness.slots[index]) : next; + }; + return [harness.slots[index], set]; + }, + }; +}); +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (k: string) => `t(${k})`, i18n: { language: "fr" } }), +})); +vi.mock("../../contexts/OnboardingContext", () => ({ + useOnboarding: () => harness.onboarding, +})); +// The page's cards are not under test, and they reach tauri. +vi.mock("../../components/settings/AccountCard", () => ({ default: () => null })); +vi.mock("../../components/settings/LicenseCard", () => ({ default: () => null })); +vi.mock("../../components/settings/DocsContent", () => ({ default: () => null })); +// The provider is the only writer of the path's preferences: the page must +// neither read the preference nor write it itself. +vi.mock("../../services/userPreferenceService", () => ({ getPreference: vi.fn() })); +vi.mock("../../services/onboardingService", () => ({ showChecklist: vi.fn() })); + +import UsersSettingsPage from "./UsersSettingsPage"; +import { getPreference } from "../../services/userPreferenceService"; +import { showChecklist } from "../../services/onboardingService"; + +type AnyElement = ReactElement & { children?: ReactNode }>; + +function find(node: ReactNode, pred: (el: AnyElement) => boolean): AnyElement[] { + if (Array.isArray(node)) return node.flatMap((n) => find(n, pred)); + if (!isValidElement(node)) return []; + const el = node as AnyElement; + return [...(pred(el) ? [el] : []), ...find(el.props.children, pred)]; +} + +function texts(node: ReactNode): string[] { + if (Array.isArray(node)) return node.flatMap(texts); + if (typeof node === "string") return [node]; + if (!isValidElement(node)) return []; + return texts((node as AnyElement).props.children); +} + +/** What the page reads from the provider: only `hidden` and `show` matter here. */ +function path(hidden: boolean) { + return { hidden, show: vi.fn(async () => undefined) }; +} + +/** One render of the mounted page. */ +function renderPage(): ReactNode { + harness.cursor = 0; + return UsersSettingsPage() as ReactNode; +} + +const showButton = (page: ReactNode) => + find(page, (e) => e.type === "button" && texts(e.props.children).includes("t(settings.users.onboarding.show)")); +const confirmation = (page: ReactNode) => + find(page, (e) => e.props.role === "status" && texts(e.props.children).includes("t(settings.users.onboarding.confirmation)")); + +beforeEach(() => { + harness.slots = []; + harness.onboarding = path(false); + vi.mocked(getPreference).mockReset(); + vi.mocked(showChecklist).mockReset(); +}); + +describe("UsersSettingsPage — showing the path again (#397)", () => { + it("offers nothing while the path is shown", () => { + const page = renderPage(); + expect(showButton(page)).toHaveLength(0); + expect(confirmation(page)).toHaveLength(0); + expect(texts(page)).not.toContain("t(settings.users.onboarding.label)"); + }); + + it("offers the button as soon as the path is hidden from the banner of this page, without remounting", () => { + expect(showButton(renderPage())).toHaveLength(0); + harness.onboarding = path(true); + const page = renderPage(); + expect(texts(page)).toContain("t(settings.users.onboarding.label)"); + expect(showButton(page)).toHaveLength(1); + expect(confirmation(page)).toHaveLength(0); + }); + + it("the button shows the path through the provider, then confirms in place", async () => { + const hiddenPath = path(true); + harness.onboarding = hiddenPath; + const [button] = showButton(renderPage()); + await (button.props.onClick as () => Promise)(); + expect(hiddenPath.show).toHaveBeenCalledTimes(1); + expect(showChecklist).not.toHaveBeenCalled(); + + // The provider applied SHOW: the next render reads the path shown. + harness.onboarding = path(false); + const page = renderPage(); + expect(showButton(page)).toHaveLength(0); + expect(confirmation(page)).toHaveLength(1); + expect(texts(page)).toContain("t(settings.users.onboarding.label)"); + }); + + it("hidden again after a restore, it offers the button again", async () => { + harness.onboarding = path(true); + const [button] = showButton(renderPage()); + await (button.props.onClick as () => Promise)(); + harness.onboarding = path(false); + expect(confirmation(renderPage())).toHaveLength(1); + + harness.onboarding = path(true); + const page = renderPage(); + expect(showButton(page)).toHaveLength(1); + expect(confirmation(page)).toHaveLength(0); + }); + + it("keeps the button when showing failed: the provider left the path hidden", async () => { + const hiddenPath = path(true); + harness.onboarding = hiddenPath; + const [button] = showButton(renderPage()); + await (button.props.onClick as () => Promise)(); + const page = renderPage(); + expect(showButton(page)).toHaveLength(1); + expect(confirmation(page)).toHaveLength(0); + }); + + it("never reads the preference itself", () => { + harness.onboarding = path(true); + renderPage(); + expect(getPreference).not.toHaveBeenCalled(); + }); +}); diff --git a/src/pages/settings/UsersSettingsPage.tsx b/src/pages/settings/UsersSettingsPage.tsx index de75c2e..0942fcf 100644 --- a/src/pages/settings/UsersSettingsPage.tsx +++ b/src/pages/settings/UsersSettingsPage.tsx @@ -1,51 +1,26 @@ -import { useEffect, useState } from "react"; +import { useState } from "react"; import { useTranslation } from "react-i18next"; import { Link } from "react-router-dom"; import { ArrowLeft, ListChecks } from "lucide-react"; import AccountCard from "../../components/settings/AccountCard"; import LicenseCard from "../../components/settings/LicenseCard"; import DocsContent from "../../components/settings/DocsContent"; -import { getPreference } from "../../services/userPreferenceService"; -import { - ONBOARDING_CHECKLIST_HIDDEN_KEY, - showChecklist, -} from "../../services/onboardingService"; -import { logError } from "../../services/logService"; - -// Getting-started checklist preference, for its re-show button (#352). -// `dismissed` = the preference was "1" at mount, the button is offered; -// `restored` = the button was clicked and the preference removed, so a -// confirmation line replaces it; `shown` = nothing to offer. -type ChecklistPreference = "unknown" | "shown" | "dismissed" | "restored"; +import { useOnboarding } from "../../contexts/OnboardingContext"; export default function UsersSettingsPage() { const { t } = useTranslation(); - const [checklistPref, setChecklistPref] = useState("unknown"); + // The getting-started path comes from its provider, the only writer of its + // preferences (#397): hiding it from the banner of this very page offers + // the re-show button at once, without reading the preference here. + const { hidden, show } = useOnboarding(); + // Set once the path was shown again from here: the confirmation then + // replaces the button, until the path is hidden again. + const [restored, setRestored] = useState(false); - useEffect(() => { - let cancelled = false; - (async () => { - try { - const value = await getPreference(ONBOARDING_CHECKLIST_HIDDEN_KEY); - if (!cancelled) setChecklistPref(value === "1" ? "dismissed" : "shown"); - } catch (e) { - // Informational control: without the preference, keep it out of the way. - if (!cancelled) setChecklistPref("shown"); - logError("Settings: failed to read the checklist preference", e); - } - })(); - return () => { - cancelled = true; - }; - }, []); - - const handleShowChecklist = async () => { - try { - await showChecklist(); - setChecklistPref("restored"); - } catch (e) { - logError("Settings: failed to show the checklist", e); - } + const handleShowPath = async () => { + // `show` logs its own failure and leaves the path hidden, which keeps the button. + await show(); + setRestored(true); }; return ( @@ -78,14 +53,14 @@ export default function UsersSettingsPage() {

{t("settings.users.sections.userGuide")}

- {(checklistPref === "dismissed" || checklistPref === "restored") && ( + {(hidden || restored) && (