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:
parent
75c1708bbe
commit
2388ad62fa
2 changed files with 169 additions and 41 deletions
153
src/pages/settings/UsersSettingsPage.test.tsx
Normal file
153
src/pages/settings/UsersSettingsPage.test.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,51 +1,26 @@
|
||||||
import { useEffect, useState } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { ArrowLeft, ListChecks } from "lucide-react";
|
import { ArrowLeft, ListChecks } from "lucide-react";
|
||||||
import AccountCard from "../../components/settings/AccountCard";
|
import AccountCard from "../../components/settings/AccountCard";
|
||||||
import LicenseCard from "../../components/settings/LicenseCard";
|
import LicenseCard from "../../components/settings/LicenseCard";
|
||||||
import DocsContent from "../../components/settings/DocsContent";
|
import DocsContent from "../../components/settings/DocsContent";
|
||||||
import { getPreference } from "../../services/userPreferenceService";
|
import { useOnboarding } from "../../contexts/OnboardingContext";
|
||||||
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";
|
|
||||||
|
|
||||||
export default function UsersSettingsPage() {
|
export default function UsersSettingsPage() {
|
||||||
const { t } = useTranslation();
|
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(() => {
|
const handleShowPath = async () => {
|
||||||
let cancelled = false;
|
// `show` logs its own failure and leaves the path hidden, which keeps the button.
|
||||||
(async () => {
|
await show();
|
||||||
try {
|
setRestored(true);
|
||||||
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);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -78,14 +53,14 @@ export default function UsersSettingsPage() {
|
||||||
<h2 className="text-sm font-semibold uppercase tracking-wider text-[var(--muted-foreground)]">
|
<h2 className="text-sm font-semibold uppercase tracking-wider text-[var(--muted-foreground)]">
|
||||||
{t("settings.users.sections.userGuide")}
|
{t("settings.users.sections.userGuide")}
|
||||||
</h2>
|
</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">
|
<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" />
|
<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>
|
<span className="flex-1 min-w-[12rem]">{t("settings.users.onboarding.label")}</span>
|
||||||
{checklistPref === "dismissed" ? (
|
{hidden ? (
|
||||||
<button
|
<button
|
||||||
type="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"
|
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")}
|
{t("settings.users.onboarding.show")}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue