feat(settings): read the getting-started path from its provider

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 <noreply@anthropic.com>
This commit is contained in:
le king fu 2026-10-08 08:02:43 -04:00
parent 75c1708bbe
commit 2388ad62fa
2 changed files with 169 additions and 41 deletions

View file

@ -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<typeof import("react")>();
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<Record<string, unknown> & { 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<void>)();
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<void>)();
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<void>)();
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();
});
});

View file

@ -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<ChecklistPreference>("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() {
<h2 className="text-sm font-semibold uppercase tracking-wider text-[var(--muted-foreground)]">
{t("settings.users.sections.userGuide")}
</h2>
{(checklistPref === "dismissed" || checklistPref === "restored") && (
{(hidden || restored) && (
<div className="flex flex-wrap items-center gap-3 rounded-xl border border-[var(--border)] bg-[var(--card)] p-4 text-sm">
<ListChecks size={18} className="shrink-0 text-[var(--muted-foreground)]" aria-hidden="true" />
<span className="flex-1 min-w-[12rem]">{t("settings.users.onboarding.label")}</span>
{checklistPref === "dismissed" ? (
{hidden ? (
<button
type="button"
onClick={handleShowChecklist}
onClick={handleShowPath}
className="inline-flex items-center justify-center px-3 py-1.5 rounded-md border border-[var(--border)] font-medium hover:bg-[var(--muted)] transition-colors"
>
{t("settings.users.onboarding.show")}