feat(onboarding): getting-started banner on top of every page #403

Merged
maximus merged 3 commits from issue-397-onboarding-banner into main 2026-10-09 01:10:32 +00:00
2 changed files with 169 additions and 41 deletions
Showing only changes of commit 2388ad62fa - Show all commits

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")}