feat(onboarding): getting-started banner on top of every page #403
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