From 2388ad62faa379db435addc5ed4123c9899f7334 Mon Sep 17 00:00:00 2001 From: le king fu Date: Thu, 8 Oct 2026 08:02:43 -0400 Subject: [PATCH] feat(settings): read the getting-started path from its provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings → Users no longer reads the hidden preference at mount: it takes hidden and show from useOnboarding(), the provider being the only writer of the path's preferences. Hiding the path from the banner of this very page now offers "Show the getting-started checklist" at once, and a local flag keeps the confirmation in place of the button until the path is hidden again. Co-Authored-By: Claude Opus 5.5 --- src/pages/settings/UsersSettingsPage.test.tsx | 153 ++++++++++++++++++ src/pages/settings/UsersSettingsPage.tsx | 57 ++----- 2 files changed, 169 insertions(+), 41 deletions(-) create mode 100644 src/pages/settings/UsersSettingsPage.test.tsx 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) && (