feat(onboarding): visit steps foundation, eight rows, "Got it" and one-time initialization (#425) #444
32 changed files with 1391 additions and 138 deletions
|
|
@ -15,11 +15,18 @@ import { computeChecklist } from "../../utils/onboardingChecklist";
|
||||||
|
|
||||||
const checklist = computeChecklist(
|
const checklist = computeChecklist(
|
||||||
{ sources: 0, imports: 0, manualCategorized: 0, budgetEntries: 0, transactions: 0 },
|
{ sources: 0, imports: 0, manualCategorized: 0, budgetEntries: 0, transactions: 0 },
|
||||||
{ importFolderSet: false, budgetAllowed: true },
|
{ importFolderSet: false, budgetAllowed: true, visitStepsInitialized: true },
|
||||||
);
|
);
|
||||||
|
|
||||||
function cardProps(hidden = false) {
|
function cardProps(hidden = false) {
|
||||||
return { checklist, budgetReady: true, hidden, onHide: vi.fn(), onResume: vi.fn() };
|
return {
|
||||||
|
checklist,
|
||||||
|
budgetReady: true,
|
||||||
|
visitStepsInitialized: true,
|
||||||
|
hidden,
|
||||||
|
onHide: vi.fn(),
|
||||||
|
onResume: vi.fn(),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const markup = (hidden = false) =>
|
const markup = (hidden = false) =>
|
||||||
|
|
@ -36,7 +43,7 @@ describe("DashboardEmptyState (#352, #397)", () => {
|
||||||
expect(html).toContain("t(dashboard.empty.description)");
|
expect(html).toContain("t(dashboard.empty.description)");
|
||||||
expect(html).toMatch(/<a [^>]*href="\/import"[^>]*>t\(dashboard\.empty\.cta\)/);
|
expect(html).toMatch(/<a [^>]*href="\/import"[^>]*>t\(dashboard\.empty\.cta\)/);
|
||||||
expect(html).toContain('aria-label="t(onboarding.checklist.title)"');
|
expect(html).toContain('aria-label="t(onboarding.checklist.title)"');
|
||||||
expect(html).toContain("t(onboarding.checklist.counter[done=1,total=6])");
|
expect(html).toContain("t(onboarding.checklist.counter[done=1,total=8])");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps the card and drops the list once the path is hidden", () => {
|
it("keeps the card and drops the list once the path is hidden", () => {
|
||||||
|
|
@ -52,6 +59,13 @@ describe("DashboardEmptyState (#352, #397)", () => {
|
||||||
expect(list.type).toBe(OnboardingChecklist);
|
expect(list.type).toBe(OnboardingChecklist);
|
||||||
expect(list.props.items).toBe(checklist.items);
|
expect(list.props.items).toBe(checklist.items);
|
||||||
expect(list.props.budgetReady).toBe(true);
|
expect(list.props.budgetReady).toBe(true);
|
||||||
|
expect(list.props.visitStepsInitialized).toBe(true);
|
||||||
|
const waiting = DashboardEmptyState({ ...props, visitStepsInitialized: false }) as ReactElement<{
|
||||||
|
children?: ReactNode;
|
||||||
|
}>;
|
||||||
|
expect(
|
||||||
|
(waiting.props.children as ReactElement<Record<string, unknown>>).props.visitStepsInitialized,
|
||||||
|
).toBe(false);
|
||||||
expect(list.props.onHide).toBe(props.onHide);
|
expect(list.props.onHide).toBe(props.onHide);
|
||||||
expect(list.props.onResume).toBe(props.onResume);
|
expect(list.props.onResume).toBe(props.onResume);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@ import type { ChecklistItemKey } from "../../shared/types";
|
||||||
interface DashboardEmptyStateProps {
|
interface DashboardEmptyStateProps {
|
||||||
checklist: Checklist;
|
checklist: Checklist;
|
||||||
budgetReady: boolean;
|
budgetReady: boolean;
|
||||||
|
/** The visit steps are initialized (#425): their rows stay "pending" until then. */
|
||||||
|
visitStepsInitialized: boolean;
|
||||||
/** The user dismissed the checklist: the welcome card stays, the list goes. */
|
/** The user dismissed the checklist: the welcome card stays, the list goes. */
|
||||||
hidden: boolean;
|
hidden: boolean;
|
||||||
onHide: () => void;
|
onHide: () => void;
|
||||||
|
|
@ -24,6 +26,7 @@ interface DashboardEmptyStateProps {
|
||||||
export default function DashboardEmptyState({
|
export default function DashboardEmptyState({
|
||||||
checklist,
|
checklist,
|
||||||
budgetReady,
|
budgetReady,
|
||||||
|
visitStepsInitialized,
|
||||||
hidden,
|
hidden,
|
||||||
onHide,
|
onHide,
|
||||||
onResume,
|
onResume,
|
||||||
|
|
@ -43,6 +46,7 @@ export default function DashboardEmptyState({
|
||||||
done={checklist.done}
|
done={checklist.done}
|
||||||
total={checklist.total}
|
total={checklist.total}
|
||||||
budgetReady={budgetReady}
|
budgetReady={budgetReady}
|
||||||
|
visitStepsInitialized={visitStepsInitialized}
|
||||||
onHide={onHide}
|
onHide={onHide}
|
||||||
onResume={onResume}
|
onResume={onResume}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -67,8 +67,14 @@ function render(
|
||||||
counters = ZERO,
|
counters = ZERO,
|
||||||
budgetAllowed = true,
|
budgetAllowed = true,
|
||||||
skipped: ChecklistItemKey[] = [],
|
skipped: ChecklistItemKey[] = [],
|
||||||
|
visitStepsInitialized = true,
|
||||||
) {
|
) {
|
||||||
const checklist = computeChecklist(counters, { importFolderSet: false, budgetAllowed, skipped });
|
const checklist = computeChecklist(counters, {
|
||||||
|
importFolderSet: false,
|
||||||
|
budgetAllowed,
|
||||||
|
skipped,
|
||||||
|
visitStepsInitialized,
|
||||||
|
});
|
||||||
const onHide = vi.fn();
|
const onHide = vi.fn();
|
||||||
const onResume = vi.fn();
|
const onResume = vi.fn();
|
||||||
const props: OnboardingChecklistProps = {
|
const props: OnboardingChecklistProps = {
|
||||||
|
|
@ -76,6 +82,7 @@ function render(
|
||||||
done: checklist.done,
|
done: checklist.done,
|
||||||
total: checklist.total,
|
total: checklist.total,
|
||||||
budgetReady: true,
|
budgetReady: true,
|
||||||
|
visitStepsInitialized,
|
||||||
onHide,
|
onHide,
|
||||||
onResume,
|
onResume,
|
||||||
...overrides,
|
...overrides,
|
||||||
|
|
@ -97,12 +104,12 @@ function stateTextsOf(row: AnyElement): string[] {
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("OnboardingChecklist", () => {
|
describe("OnboardingChecklist", () => {
|
||||||
it("renders the title, the counter 1 / 6 and the hide button", () => {
|
it("renders the title, the counter 1 / 8 and the hide button", () => {
|
||||||
const { element, onHide } = render();
|
const { element, onHide } = render();
|
||||||
const text = texts(element);
|
const text = texts(element);
|
||||||
expect(text).toContain("t(onboarding.checklist.title)");
|
expect(text).toContain("t(onboarding.checklist.title)");
|
||||||
expect((element as AnyElement).props["aria-label"]).toBe("t(onboarding.checklist.title)");
|
expect((element as AnyElement).props["aria-label"]).toBe("t(onboarding.checklist.title)");
|
||||||
expect(text).toContain("t(onboarding.checklist.counter[done=1,total=6])");
|
expect(text).toContain("t(onboarding.checklist.counter[done=1,total=8])");
|
||||||
const [hide] = find(element, (e) => e.type === "button");
|
const [hide] = find(element, (e) => e.type === "button");
|
||||||
expect(hide.props.type).toBe("button");
|
expect(hide.props.type).toBe("button");
|
||||||
expect(texts(hide.props.children)).toContain("t(onboarding.checklist.hide)");
|
expect(texts(hide.props.children)).toContain("t(onboarding.checklist.hide)");
|
||||||
|
|
@ -110,11 +117,22 @@ describe("OnboardingChecklist", () => {
|
||||||
expect(onHide).toHaveBeenCalledTimes(1);
|
expect(onHide).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders six rows with their i18n labels", () => {
|
it("renders eight rows, in order, with their i18n labels (#425)", () => {
|
||||||
const { element } = render();
|
const { element } = render();
|
||||||
expect(find(element, (e) => e.type === "li")).toHaveLength(6);
|
const rows = find(element, (e) => e.type === "li");
|
||||||
|
const order = [
|
||||||
|
"profile",
|
||||||
|
"importFolder",
|
||||||
|
"source",
|
||||||
|
"import",
|
||||||
|
"categories",
|
||||||
|
"manualCategory",
|
||||||
|
"budget",
|
||||||
|
"reports",
|
||||||
|
];
|
||||||
|
expect(rows.map((r) => r.key)).toEqual(order);
|
||||||
const text = texts(element);
|
const text = texts(element);
|
||||||
for (const key of ["profile", "importFolder", "source", "import", "manualCategory", "budget"]) {
|
for (const key of order) {
|
||||||
expect(text).toContain(`t(onboarding.checklist.items.${key})`);
|
expect(text).toContain(`t(onboarding.checklist.items.${key})`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -154,7 +172,7 @@ describe("OnboardingChecklist", () => {
|
||||||
expect(link.props.to).toBe("/settings/users");
|
expect(link.props.to).toBe("/settings/users");
|
||||||
// The license card carries no step card: nothing to focus there.
|
// The license card carries no step card: nothing to focus there.
|
||||||
expect(link.props.state).toBeUndefined();
|
expect(link.props.state).toBeUndefined();
|
||||||
expect(texts(element)).toContain("t(onboarding.checklist.counter[done=1,total=5])");
|
expect(texts(element)).toContain("t(onboarding.checklist.counter[done=1,total=7])");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("while the entitlement is unknown, the budget row is neutral and the counter is absent", () => {
|
it("while the entitlement is unknown, the budget row is neutral and the counter is absent", () => {
|
||||||
|
|
@ -209,7 +227,7 @@ describe("OnboardingChecklist — skipped rows (#397)", () => {
|
||||||
);
|
);
|
||||||
// The hidden "(skipped)" text already says it: the badge is not read twice.
|
// The hidden "(skipped)" text already says it: the badge is not read twice.
|
||||||
expect(badge.props["aria-hidden"]).toBe("true");
|
expect(badge.props["aria-hidden"]).toBe("true");
|
||||||
expect(texts(element)).toContain("t(onboarding.checklist.counter[done=2,total=6])");
|
expect(texts(element)).toContain("t(onboarding.checklist.counter[done=2,total=8])");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("'Resume' takes the step back, then opens its page with the step's navigation state", () => {
|
it("'Resume' takes the step back, then opens its page with the step's navigation state", () => {
|
||||||
|
|
@ -250,3 +268,51 @@ describe("OnboardingChecklist — skipped rows (#397)", () => {
|
||||||
expect(link.props.to).toBe("/settings/users");
|
expect(link.props.to).toBe("/settings/users");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("OnboardingChecklist — visit steps (#425)", () => {
|
||||||
|
it("a visit row to do links to its page with the step's navigation state", () => {
|
||||||
|
const { element } = render();
|
||||||
|
const [categories] = find(rowOf(element, "categories"), (e) => e.type === Link);
|
||||||
|
expect(categories.props.to).toBe("/categories");
|
||||||
|
expect(categories.props.state).toEqual({ onboarding: { focus: true } });
|
||||||
|
const [reports] = find(rowOf(element, "reports"), (e) => e.type === Link);
|
||||||
|
expect(reports.props.to).toBe("/reports");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("before the initialization, both visit rows are pending, without the edition badge, and the counter waits", () => {
|
||||||
|
const { element } = render({}, ZERO, false, [], false);
|
||||||
|
for (const key of ["categories", "reports"]) {
|
||||||
|
const row = rowOf(element, key);
|
||||||
|
expect(find(row, (e) => e.type === Lock), key).toHaveLength(0);
|
||||||
|
expect(find(row, (e) => e.type === Circle), key).toHaveLength(1);
|
||||||
|
expect(find(row, (e) => e.type === Link), key).toHaveLength(0);
|
||||||
|
expect(texts(row), key).not.toContain("t(onboarding.checklist.lockedBudget)");
|
||||||
|
expect(stateTextsOf(row), key).toEqual(["t(onboarding.state.pending)"]);
|
||||||
|
}
|
||||||
|
// The Budget row keeps its own rule: entitlement known, Free → locked.
|
||||||
|
expect(stateTextsOf(rowOf(element, "budget"))).toEqual(["t(onboarding.state.locked)"]);
|
||||||
|
expect(texts(element).some((s) => s.startsWith("t(onboarding.checklist.counter"))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the Budget row's pending state follows budgetReady alone", () => {
|
||||||
|
const { element } = render({ budgetReady: false }, ZERO, true, [], true);
|
||||||
|
expect(stateTextsOf(rowOf(element, "budget"))).toEqual(["t(onboarding.state.pending)"]);
|
||||||
|
expect(stateTextsOf(rowOf(element, "categories"))).toEqual([]);
|
||||||
|
const initializedOnly = render({ budgetReady: true }, ZERO, true, [], false).element;
|
||||||
|
expect(stateTextsOf(rowOf(initializedOnly, "budget"))).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an acknowledged visit row shows as done", () => {
|
||||||
|
const checklist = computeChecklist(ZERO, {
|
||||||
|
importFolderSet: false,
|
||||||
|
budgetAllowed: true,
|
||||||
|
visitStepsInitialized: true,
|
||||||
|
acknowledged: ["reports"],
|
||||||
|
});
|
||||||
|
const { element } = render({ items: checklist.items, done: checklist.done, total: checklist.total });
|
||||||
|
const reports = rowOf(element, "reports");
|
||||||
|
expect(find(reports, (e) => e.type === CheckCircle2)).toHaveLength(1);
|
||||||
|
expect(find(reports, (e) => e.type === Link)).toHaveLength(0);
|
||||||
|
expect(texts(element)).toContain("t(onboarding.checklist.counter[done=2,total=8])");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,13 @@ import { useTranslation } from "react-i18next";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { CheckCircle2, Circle, Lock, SkipForward } from "lucide-react";
|
import { CheckCircle2, Circle, Lock, SkipForward } from "lucide-react";
|
||||||
import { stepNavState } from "../../utils/onboardingPath";
|
import { stepNavState } from "../../utils/onboardingPath";
|
||||||
import type { ChecklistItem, ChecklistItemKey } from "../../shared/types";
|
import { VISIT_STEP_KEYS, type ChecklistItem, type ChecklistItemKey } from "../../shared/types";
|
||||||
|
|
||||||
export interface OnboardingChecklistProps {
|
export interface OnboardingChecklistProps {
|
||||||
items: ChecklistItem[];
|
items: ChecklistItem[];
|
||||||
/** Number of unlocked rows that are done or skipped. */
|
/** Number of unlocked rows that are done or skipped. */
|
||||||
done: number;
|
done: number;
|
||||||
/** Number of unlocked rows (6, or 5 when Budget is locked). */
|
/** Number of unlocked rows (8, or 7 when Budget is locked). */
|
||||||
total: number;
|
total: number;
|
||||||
/**
|
/**
|
||||||
* False until the Budget entitlement is known. The budget row is then
|
* False until the Budget entitlement is known. The budget row is then
|
||||||
|
|
@ -16,6 +16,12 @@ export interface OnboardingChecklistProps {
|
||||||
* `total` depends on the lock.
|
* `total` depends on the lock.
|
||||||
*/
|
*/
|
||||||
budgetReady: boolean;
|
budgetReady: boolean;
|
||||||
|
/**
|
||||||
|
* False until the profile's visit steps are initialized (#425). Their two
|
||||||
|
* rows are then neutral ("pending", never "locked", which would show the
|
||||||
|
* Base edition badge) and the counter is hidden, `total` depending on them.
|
||||||
|
*/
|
||||||
|
visitStepsInitialized: boolean;
|
||||||
onHide: () => void;
|
onHide: () => void;
|
||||||
/** "Resume" on a skipped row: the provider's `resume`, before its link opens the step's page. */
|
/** "Resume" on a skipped row: the provider's `resume`, before its link opens the step's page. */
|
||||||
onResume: (key: ChecklistItemKey) => void;
|
onResume: (key: ChecklistItemKey) => void;
|
||||||
|
|
@ -28,8 +34,13 @@ type RowState = "done" | "skipped" | "todo" | "locked" | "pending";
|
||||||
* done shows as done even if it was skipped too: "Resume" would bring back
|
* done shows as done even if it was skipped too: "Resume" would bring back
|
||||||
* nothing to do.
|
* nothing to do.
|
||||||
*/
|
*/
|
||||||
function rowState(item: ChecklistItem, budgetReady: boolean): RowState {
|
function rowState(
|
||||||
|
item: ChecklistItem,
|
||||||
|
budgetReady: boolean,
|
||||||
|
visitStepsInitialized: boolean,
|
||||||
|
): RowState {
|
||||||
if (item.key === "budget" && !budgetReady) return "pending";
|
if (item.key === "budget" && !budgetReady) return "pending";
|
||||||
|
if (VISIT_STEP_KEYS.includes(item.key) && !visitStepsInitialized) return "pending";
|
||||||
if (item.locked) return "locked";
|
if (item.locked) return "locked";
|
||||||
if (item.done) return "done";
|
if (item.done) return "done";
|
||||||
return item.skipped ? "skipped" : "todo";
|
return item.skipped ? "skipped" : "todo";
|
||||||
|
|
@ -75,6 +86,7 @@ export default function OnboardingChecklist({
|
||||||
done,
|
done,
|
||||||
total,
|
total,
|
||||||
budgetReady,
|
budgetReady,
|
||||||
|
visitStepsInitialized,
|
||||||
onHide,
|
onHide,
|
||||||
onResume,
|
onResume,
|
||||||
}: OnboardingChecklistProps) {
|
}: OnboardingChecklistProps) {
|
||||||
|
|
@ -88,7 +100,7 @@ export default function OnboardingChecklist({
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3 mb-3">
|
<div className="flex items-center gap-3 mb-3">
|
||||||
<h3 className="font-semibold">{t("onboarding.checklist.title")}</h3>
|
<h3 className="font-semibold">{t("onboarding.checklist.title")}</h3>
|
||||||
{budgetReady && (
|
{budgetReady && visitStepsInitialized && (
|
||||||
<span className="text-sm font-medium text-[var(--muted-foreground)] tabular-nums">
|
<span className="text-sm font-medium text-[var(--muted-foreground)] tabular-nums">
|
||||||
{t("onboarding.checklist.counter", { done, total })}
|
{t("onboarding.checklist.counter", { done, total })}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -105,7 +117,7 @@ export default function OnboardingChecklist({
|
||||||
</div>
|
</div>
|
||||||
<ul className="space-y-2">
|
<ul className="space-y-2">
|
||||||
{items.map((item) => {
|
{items.map((item) => {
|
||||||
const state = rowState(item, budgetReady);
|
const state = rowState(item, budgetReady, visitStepsInitialized);
|
||||||
const label = t(`onboarding.checklist.items.${item.key}`);
|
const label = t(`onboarding.checklist.items.${item.key}`);
|
||||||
const stateText =
|
const stateText =
|
||||||
state === "todo" ? null : (
|
state === "todo" ? null : (
|
||||||
|
|
|
||||||
|
|
@ -39,12 +39,19 @@ const NEW_PROFILE: OnboardingSnapshot = {
|
||||||
importFolderSet: false,
|
importFolderSet: false,
|
||||||
hidden: false,
|
hidden: false,
|
||||||
skipped: [],
|
skipped: [],
|
||||||
|
acknowledged: [],
|
||||||
|
visitStepsInitialized: true,
|
||||||
};
|
};
|
||||||
/** Folder, source and import done: the next step is the manual category, on /transactions. */
|
/**
|
||||||
|
* Folder, source and import done: the next step is the manual category, on
|
||||||
|
* /transactions. Stopped after the import, so the visit steps are
|
||||||
|
* acknowledged (rule of the templates, #425).
|
||||||
|
*/
|
||||||
const IMPORTED: OnboardingSnapshot = {
|
const IMPORTED: OnboardingSnapshot = {
|
||||||
...NEW_PROFILE,
|
...NEW_PROFILE,
|
||||||
counters: { sources: 1, imports: 1, manualCategorized: 0, budgetEntries: 0, transactions: 40 },
|
counters: { sources: 1, imports: 1, manualCategorized: 0, budgetEntries: 0, transactions: 40 },
|
||||||
importFolderSet: true,
|
importFolderSet: true,
|
||||||
|
acknowledged: ["categories", "reports"],
|
||||||
};
|
};
|
||||||
/** Everything but the budget: the next step is on /budget. */
|
/** Everything but the budget: the next step is on /budget. */
|
||||||
const BUDGET_LEFT: OnboardingSnapshot = {
|
const BUDGET_LEFT: OnboardingSnapshot = {
|
||||||
|
|
@ -75,6 +82,8 @@ function pathAt(
|
||||||
importFolderSet: state.importFolderSet,
|
importFolderSet: state.importFolderSet,
|
||||||
budgetAllowed,
|
budgetAllowed,
|
||||||
skipped: state.skipped,
|
skipped: state.skipped,
|
||||||
|
acknowledged: state.acknowledged,
|
||||||
|
visitStepsInitialized: state.visitStepsInitialized,
|
||||||
})
|
})
|
||||||
: null;
|
: null;
|
||||||
const { nextStep, cardPage, mode } = deriveOnboardingView({ state, checklist, pathname, budgetReady });
|
const { nextStep, cardPage, mode } = deriveOnboardingView({ state, checklist, pathname, budgetReady });
|
||||||
|
|
@ -84,6 +93,7 @@ function pathAt(
|
||||||
counters: state.counters,
|
counters: state.counters,
|
||||||
hidden: state.hidden,
|
hidden: state.hidden,
|
||||||
budgetReady,
|
budgetReady,
|
||||||
|
visitStepsInitialized: state.visitStepsInitialized,
|
||||||
nextStep,
|
nextStep,
|
||||||
mode,
|
mode,
|
||||||
cardPage,
|
cardPage,
|
||||||
|
|
@ -91,6 +101,7 @@ function pathAt(
|
||||||
show: vi.fn(async () => undefined),
|
show: vi.fn(async () => undefined),
|
||||||
skip: vi.fn(async () => undefined),
|
skip: vi.fn(async () => undefined),
|
||||||
resume: vi.fn(async () => undefined),
|
resume: vi.fn(async () => undefined),
|
||||||
|
acknowledge: vi.fn(async () => undefined),
|
||||||
refresh: vi.fn(async () => undefined),
|
refresh: vi.fn(async () => undefined),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -182,9 +193,9 @@ describe("AppShell — the getting-started banner (#397)", () => {
|
||||||
expect(renderAt("/reports", loaded(BUDGET_LEFT))).toContain('href="/budget"');
|
expect(renderAt("/reports", loaded(BUDGET_LEFT))).toContain('href="/budget"');
|
||||||
});
|
});
|
||||||
|
|
||||||
it("in the Free edition, /budget never gets a card and the path ends at 5/5 (#400)", () => {
|
it("in the Free edition, /budget never gets a card and the path ends at 7/7 (#400)", () => {
|
||||||
const value = pathAt("/budget", loaded(BUDGET_LEFT), true, false);
|
const value = pathAt("/budget", loaded(BUDGET_LEFT), true, false);
|
||||||
expect(value.checklist?.total).toBe(5);
|
expect(value.checklist?.total).toBe(7);
|
||||||
expect(value.nextStep).toBeNull();
|
expect(value.nextStep).toBeNull();
|
||||||
expect(value.cardPage).toBeNull();
|
expect(value.cardPage).toBeNull();
|
||||||
expect(value.mode).toBe("complete");
|
expect(value.mode).toBe("complete");
|
||||||
|
|
@ -209,10 +220,19 @@ describe("AppShell — the getting-started banner (#397)", () => {
|
||||||
expect(waiting).toContain(BANNER);
|
expect(waiting).toContain(BANNER);
|
||||||
expect(waiting).not.toContain("onboarding.banner.progress");
|
expect(waiting).not.toContain("onboarding.banner.progress");
|
||||||
expect(renderAt("/reports", loaded(IMPORTED))).toContain(
|
expect(renderAt("/reports", loaded(IMPORTED))).toContain(
|
||||||
"t(onboarding.banner.progress[done=4,total=6])",
|
"t(onboarding.banner.progress[done=6,total=8])",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("before the visit steps' initialization: no counter, and no end for a path that was complete (#425)", () => {
|
||||||
|
const waiting = renderAt("/reports", loaded({ ...IMPORTED, acknowledged: [], visitStepsInitialized: false }));
|
||||||
|
expect(waiting).toContain(BANNER);
|
||||||
|
expect(waiting).not.toContain("onboarding.banner.progress");
|
||||||
|
const complete = renderAt("/reports", loaded({ ...ALL_DONE, acknowledged: [], visitStepsInitialized: false }));
|
||||||
|
expect(complete).not.toContain(BANNER);
|
||||||
|
expect(complete).not.toContain("onboarding.banner.next");
|
||||||
|
});
|
||||||
|
|
||||||
it("starts with the list folded", () => {
|
it("starts with the list folded", () => {
|
||||||
const html = renderAt("/reports", loaded(IMPORTED));
|
const html = renderAt("/reports", loaded(IMPORTED));
|
||||||
expect(html).toContain('aria-expanded="false"');
|
expect(html).toContain('aria-expanded="false"');
|
||||||
|
|
|
||||||
|
|
@ -43,13 +43,20 @@ function find(node: ReactNode, pred: (el: AnyElement) => boolean): AnyElement[]
|
||||||
|
|
||||||
function onboardingValue(): OnboardingValue {
|
function onboardingValue(): OnboardingValue {
|
||||||
const counters = { sources: 1, imports: 1, manualCategorized: 0, budgetEntries: 0, transactions: 40 };
|
const counters = { sources: 1, imports: 1, manualCategorized: 0, budgetEntries: 0, transactions: 40 };
|
||||||
const checklist = computeChecklist(counters, { importFolderSet: true, budgetAllowed: true });
|
// Stopped after the import: the visit steps are acknowledged (rule of the templates, #425).
|
||||||
|
const checklist = computeChecklist(counters, {
|
||||||
|
importFolderSet: true,
|
||||||
|
budgetAllowed: true,
|
||||||
|
visitStepsInitialized: true,
|
||||||
|
acknowledged: ["categories", "reports"],
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
status: "ready",
|
status: "ready",
|
||||||
checklist,
|
checklist,
|
||||||
counters,
|
counters,
|
||||||
hidden: false,
|
hidden: false,
|
||||||
budgetReady: true,
|
budgetReady: true,
|
||||||
|
visitStepsInitialized: true,
|
||||||
nextStep: nextStep(checklist),
|
nextStep: nextStep(checklist),
|
||||||
mode: "banner",
|
mode: "banner",
|
||||||
cardPage: null,
|
cardPage: null,
|
||||||
|
|
@ -57,6 +64,7 @@ function onboardingValue(): OnboardingValue {
|
||||||
show: vi.fn(async () => undefined),
|
show: vi.fn(async () => undefined),
|
||||||
skip: vi.fn(async () => undefined),
|
skip: vi.fn(async () => undefined),
|
||||||
resume: vi.fn(async () => undefined),
|
resume: vi.fn(async () => undefined),
|
||||||
|
acknowledge: vi.fn(async () => undefined),
|
||||||
refresh: vi.fn(async () => undefined),
|
refresh: vi.fn(async () => undefined),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -82,16 +90,23 @@ describe("OnboardingBannerSlot (#397)", () => {
|
||||||
expect(props.checklist).toBe(value.checklist);
|
expect(props.checklist).toBe(value.checklist);
|
||||||
expect(props.nextStep).toBe(value.nextStep);
|
expect(props.nextStep).toBe(value.nextStep);
|
||||||
expect(props.budgetReady).toBe(true);
|
expect(props.budgetReady).toBe(true);
|
||||||
|
expect(props.visitStepsInitialized).toBe(true);
|
||||||
expect(props.listOpen).toBe(false);
|
expect(props.listOpen).toBe(false);
|
||||||
expect(props.onHide).toBe(value.hide);
|
expect(props.onHide).toBe(value.hide);
|
||||||
expect(props.onResume).toBe(value.resume);
|
expect(props.onResume).toBe(value.resume);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("passes the mode and the entitlement as they come: the banner decides what to show", () => {
|
it("passes the mode and the entitlement as they come: the banner decides what to show", () => {
|
||||||
ctx.onboarding = { ...onboardingValue(), mode: "none", budgetReady: false };
|
ctx.onboarding = {
|
||||||
|
...onboardingValue(),
|
||||||
|
mode: "none",
|
||||||
|
budgetReady: false,
|
||||||
|
visitStepsInitialized: false,
|
||||||
|
};
|
||||||
const props = renderSlot();
|
const props = renderSlot();
|
||||||
expect(props.mode).toBe("none");
|
expect(props.mode).toBe("none");
|
||||||
expect(props.budgetReady).toBe(false);
|
expect(props.budgetReady).toBe(false);
|
||||||
|
expect(props.visitStepsInitialized).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("'Show the list' unfolds it, 'Close the list' folds it back", () => {
|
it("'Show the list' unfolds it, 'Close the list' folds it back", () => {
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ export function OnboardingBannerSlot() {
|
||||||
checklist={onboarding.checklist}
|
checklist={onboarding.checklist}
|
||||||
nextStep={onboarding.nextStep}
|
nextStep={onboarding.nextStep}
|
||||||
budgetReady={onboarding.budgetReady}
|
budgetReady={onboarding.budgetReady}
|
||||||
|
visitStepsInitialized={onboarding.visitStepsInitialized}
|
||||||
listOpen={listOpen}
|
listOpen={listOpen}
|
||||||
onToggleList={() => setListOpen((open) => !open)}
|
onToggleList={() => setListOpen((open) => !open)}
|
||||||
onHide={onboarding.hide}
|
onHide={onboarding.hide}
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,7 @@ const ZERO: OnboardingCounters = {
|
||||||
budgetEntries: 0,
|
budgetEntries: 0,
|
||||||
transactions: 0,
|
transactions: 0,
|
||||||
};
|
};
|
||||||
/** Profile, folder and source done: 3/6, the next step is "import" on /import. */
|
/** Profile, folder and source done: 3/8, the next step is "import" on /import. */
|
||||||
const THREE_DONE: OnboardingCounters = { ...ZERO, sources: 1 };
|
const THREE_DONE: OnboardingCounters = { ...ZERO, sources: 1 };
|
||||||
/** Up to the import: the next step is "manualCategory" on /transactions. */
|
/** Up to the import: the next step is "manualCategory" on /transactions. */
|
||||||
const FOUR_DONE: OnboardingCounters = { ...ZERO, sources: 1, imports: 1, transactions: 40 };
|
const FOUR_DONE: OnboardingCounters = { ...ZERO, sources: 1, imports: 1, transactions: 40 };
|
||||||
|
|
@ -60,12 +60,19 @@ function bannerProps(
|
||||||
overrides: Partial<OnboardingBannerProps> = {},
|
overrides: Partial<OnboardingBannerProps> = {},
|
||||||
counters: OnboardingCounters = THREE_DONE,
|
counters: OnboardingCounters = THREE_DONE,
|
||||||
): OnboardingBannerProps {
|
): OnboardingBannerProps {
|
||||||
const checklist = computeChecklist(counters, { importFolderSet: true, budgetAllowed: true });
|
// A template stopped after the import has its visit steps acknowledged (#425).
|
||||||
|
const checklist = computeChecklist(counters, {
|
||||||
|
importFolderSet: true,
|
||||||
|
budgetAllowed: true,
|
||||||
|
visitStepsInitialized: true,
|
||||||
|
acknowledged: counters.imports >= 1 ? ["categories", "reports"] : [],
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
mode: checklist.complete ? "complete" : "banner",
|
mode: checklist.complete ? "complete" : "banner",
|
||||||
checklist,
|
checklist,
|
||||||
nextStep: nextStep(checklist),
|
nextStep: nextStep(checklist),
|
||||||
budgetReady: true,
|
budgetReady: true,
|
||||||
|
visitStepsInitialized: true,
|
||||||
listOpen: false,
|
listOpen: false,
|
||||||
onToggleList: vi.fn(),
|
onToggleList: vi.fn(),
|
||||||
onHide: vi.fn(),
|
onHide: vi.fn(),
|
||||||
|
|
@ -94,7 +101,7 @@ describe("OnboardingBanner — banner mode (#397)", () => {
|
||||||
const html = markup(bannerProps());
|
const html = markup(bannerProps());
|
||||||
expect(html).toContain('role="region"');
|
expect(html).toContain('role="region"');
|
||||||
expect(html).toContain('aria-label="t(onboarding.banner.label)"');
|
expect(html).toContain('aria-label="t(onboarding.banner.label)"');
|
||||||
expect(html).toContain("t(onboarding.banner.progress[done=3,total=6])");
|
expect(html).toContain("t(onboarding.banner.progress[done=3,total=8])");
|
||||||
expect(html).toContain("t(onboarding.banner.next)");
|
expect(html).toContain("t(onboarding.banner.next)");
|
||||||
expect(html).toMatch(/<a [^>]*href="\/import"[^>]*>t\(onboarding\.checklist\.items\.import\)/);
|
expect(html).toMatch(/<a [^>]*href="\/import"[^>]*>t\(onboarding\.checklist\.items\.import\)/);
|
||||||
expect(html).toContain("t(onboarding.checklist.hide)");
|
expect(html).toContain("t(onboarding.checklist.hide)");
|
||||||
|
|
@ -117,6 +124,12 @@ describe("OnboardingBanner — banner mode (#397)", () => {
|
||||||
expect(html).toMatch(/href="\/import"/);
|
expect(html).toMatch(/href="\/import"/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("the counter waits for the visit steps' initialization too, the next step does not (#425)", () => {
|
||||||
|
const html = markup(bannerProps({ visitStepsInitialized: false }));
|
||||||
|
expect(html).not.toContain("onboarding.banner.progress");
|
||||||
|
expect(html).toMatch(/href="\/import"/);
|
||||||
|
});
|
||||||
|
|
||||||
it("folded: 'Show the list' with aria-expanded false, and no list", () => {
|
it("folded: 'Show the list' with aria-expanded false, and no list", () => {
|
||||||
const html = markup(bannerProps());
|
const html = markup(bannerProps());
|
||||||
expect(html).toMatch(
|
expect(html).toMatch(
|
||||||
|
|
@ -134,7 +147,16 @@ describe("OnboardingBanner — banner mode (#397)", () => {
|
||||||
const container = html.indexOf(`id="${ONBOARDING_BANNER_LIST_ID}"`);
|
const container = html.indexOf(`id="${ONBOARDING_BANNER_LIST_ID}"`);
|
||||||
expect(container).toBeGreaterThan(-1);
|
expect(container).toBeGreaterThan(-1);
|
||||||
expect(html.indexOf("t(onboarding.checklist.title)")).toBeGreaterThan(container);
|
expect(html.indexOf("t(onboarding.checklist.title)")).toBeGreaterThan(container);
|
||||||
for (const key of ["profile", "importFolder", "source", "import", "manualCategory", "budget"]) {
|
for (const key of [
|
||||||
|
"profile",
|
||||||
|
"importFolder",
|
||||||
|
"source",
|
||||||
|
"import",
|
||||||
|
"categories",
|
||||||
|
"manualCategory",
|
||||||
|
"budget",
|
||||||
|
"reports",
|
||||||
|
]) {
|
||||||
expect(html).toContain(`t(onboarding.checklist.items.${key})`);
|
expect(html).toContain(`t(onboarding.checklist.items.${key})`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -144,8 +166,16 @@ describe("OnboardingBanner — banner mode (#397)", () => {
|
||||||
const [list] = find(tree(props), (e) => e.type === OnboardingChecklist);
|
const [list] = find(tree(props), (e) => e.type === OnboardingChecklist);
|
||||||
expect(list.props.items).toBe(props.checklist!.items);
|
expect(list.props.items).toBe(props.checklist!.items);
|
||||||
expect(list.props.done).toBe(3);
|
expect(list.props.done).toBe(3);
|
||||||
expect(list.props.total).toBe(6);
|
expect(list.props.total).toBe(8);
|
||||||
expect(list.props.budgetReady).toBe(true);
|
expect(list.props.budgetReady).toBe(true);
|
||||||
|
expect(list.props.visitStepsInitialized).toBe(true);
|
||||||
|
// Each flag reaches the list on its own: the Budget row's pending state follows budgetReady alone.
|
||||||
|
const [waiting] = find(
|
||||||
|
tree(bannerProps({ listOpen: true, visitStepsInitialized: false })),
|
||||||
|
(e) => e.type === OnboardingChecklist,
|
||||||
|
);
|
||||||
|
expect(waiting.props.budgetReady).toBe(true);
|
||||||
|
expect(waiting.props.visitStepsInitialized).toBe(false);
|
||||||
expect(list.props.onHide).toBe(props.onHide);
|
expect(list.props.onHide).toBe(props.onHide);
|
||||||
expect(list.props.onResume).toBe(props.onResume);
|
expect(list.props.onResume).toBe(props.onResume);
|
||||||
expect(find(tree(bannerProps()), (e) => e.type === OnboardingChecklist)).toHaveLength(0);
|
expect(find(tree(bannerProps()), (e) => e.type === OnboardingChecklist)).toHaveLength(0);
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,8 @@ export interface OnboardingBannerProps {
|
||||||
nextStep: ChecklistItem | null;
|
nextStep: ChecklistItem | null;
|
||||||
/** False until the Budget entitlement is known: the counter waits, its total depends on the lock. */
|
/** False until the Budget entitlement is known: the counter waits, its total depends on the lock. */
|
||||||
budgetReady: boolean;
|
budgetReady: boolean;
|
||||||
|
/** False until the visit steps are initialized (#425): the counter waits too, its total depends on them. */
|
||||||
|
visitStepsInitialized: boolean;
|
||||||
/** The detailed list is unfolded under the banner. */
|
/** The detailed list is unfolded under the banner. */
|
||||||
listOpen: boolean;
|
listOpen: boolean;
|
||||||
onToggleList: () => void;
|
onToggleList: () => void;
|
||||||
|
|
@ -51,7 +53,7 @@ function BannerIcon() {
|
||||||
*
|
*
|
||||||
* - `banner`: "Getting started n/N · Next step: <step> →", "Show the list",
|
* - `banner`: "Getting started n/N · Next step: <step> →", "Show the list",
|
||||||
* which unfolds the detailed list in place, and "Hide". The counter waits
|
* which unfolds the detailed list in place, and "Hide". The counter waits
|
||||||
* for `budgetReady`, like the list's own counter.
|
* for `budgetReady && visitStepsInitialized`, like the list's own counter.
|
||||||
* - `complete` (D4): the end message, "Open the guide" and "Close", which
|
* - `complete` (D4): the end message, "Open the guide" and "Close", which
|
||||||
* hides the path until it is shown again from Settings → Users.
|
* hides the path until it is shown again from Settings → Users.
|
||||||
* - Anything else (`none`, `card`) renders nothing.
|
* - Anything else (`none`, `card`) renders nothing.
|
||||||
|
|
@ -61,12 +63,14 @@ export default function OnboardingBanner({
|
||||||
checklist,
|
checklist,
|
||||||
nextStep,
|
nextStep,
|
||||||
budgetReady,
|
budgetReady,
|
||||||
|
visitStepsInitialized,
|
||||||
listOpen,
|
listOpen,
|
||||||
onToggleList,
|
onToggleList,
|
||||||
onHide,
|
onHide,
|
||||||
onResume,
|
onResume,
|
||||||
}: OnboardingBannerProps) {
|
}: OnboardingBannerProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const totalKnown = budgetReady && visitStepsInitialized;
|
||||||
if (checklist === null || (mode !== "banner" && mode !== "complete")) return null;
|
if (checklist === null || (mode !== "banner" && mode !== "complete")) return null;
|
||||||
|
|
||||||
if (mode === "complete") {
|
if (mode === "complete") {
|
||||||
|
|
@ -92,12 +96,12 @@ export default function OnboardingBanner({
|
||||||
<div role="region" aria-label={t("onboarding.banner.label")} className={REGION_CLASS}>
|
<div role="region" aria-label={t("onboarding.banner.label")} className={REGION_CLASS}>
|
||||||
<div className={ROW_CLASS}>
|
<div className={ROW_CLASS}>
|
||||||
<BannerIcon />
|
<BannerIcon />
|
||||||
{budgetReady && (
|
{totalKnown && (
|
||||||
<span className="font-semibold text-[var(--foreground)] tabular-nums">
|
<span className="font-semibold text-[var(--foreground)] tabular-nums">
|
||||||
{t("onboarding.banner.progress", { done: checklist.done, total: checklist.total })}
|
{t("onboarding.banner.progress", { done: checklist.done, total: checklist.total })}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{budgetReady && nextStep && (
|
{totalKnown && nextStep && (
|
||||||
<span aria-hidden="true" className="h-1 w-1 shrink-0 rounded-full bg-[var(--muted-foreground)]" />
|
<span aria-hidden="true" className="h-1 w-1 shrink-0 rounded-full bg-[var(--muted-foreground)]" />
|
||||||
)}
|
)}
|
||||||
{nextStep && (
|
{nextStep && (
|
||||||
|
|
@ -133,6 +137,7 @@ export default function OnboardingBanner({
|
||||||
done={checklist.done}
|
done={checklist.done}
|
||||||
total={checklist.total}
|
total={checklist.total}
|
||||||
budgetReady={budgetReady}
|
budgetReady={budgetReady}
|
||||||
|
visitStepsInitialized={visitStepsInitialized}
|
||||||
onHide={onHide}
|
onHide={onHide}
|
||||||
onResume={onResume}
|
onResume={onResume}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,21 @@ vi.mock("react-router-dom", async (importOriginal) => ({
|
||||||
useNavigate: () => ctx.navigate,
|
useNavigate: () => ctx.navigate,
|
||||||
}));
|
}));
|
||||||
vi.mock("../../contexts/OnboardingContext", () => ({ useOnboarding: () => ctx.onboarding }));
|
vi.mock("../../contexts/OnboardingContext", () => ({ useOnboarding: () => ctx.onboarding }));
|
||||||
|
// /categories guides no step before its card ships (#432): a stand-in page
|
||||||
|
// that guides the categories step, so the wiring of "Got it" is exercised now.
|
||||||
|
vi.mock("../../utils/onboardingPath", async (importOriginal) => {
|
||||||
|
const orig = await importOriginal<typeof import("../../utils/onboardingPath")>();
|
||||||
|
return {
|
||||||
|
...orig,
|
||||||
|
cardStep: (...args: Parameters<typeof orig.cardStep>) => {
|
||||||
|
const [checklist, pathname] = args;
|
||||||
|
if (pathname !== "/stand-in-categories") return orig.cardStep(checklist, pathname);
|
||||||
|
const unlocked = checklist.items.filter((item) => !item.locked);
|
||||||
|
const step = unlocked.find((item) => item.key === "categories")!;
|
||||||
|
return { step, n: unlocked.indexOf(step) + 1, total: unlocked.length };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
import { hookHarness } from "../../test/hookHarness";
|
import { hookHarness } from "../../test/hookHarness";
|
||||||
import OnboardingStepCard from "./OnboardingStepCard";
|
import OnboardingStepCard from "./OnboardingStepCard";
|
||||||
|
|
@ -46,13 +61,20 @@ const IMPORTED: OnboardingCounters = { ...ZERO, sources: 1, imports: 1, transact
|
||||||
|
|
||||||
/** The path latched on /import, as the provider shares it. */
|
/** The path latched on /import, as the provider shares it. */
|
||||||
function path(counters: OnboardingCounters, overrides: Partial<OnboardingValue> = {}): OnboardingValue {
|
function path(counters: OnboardingCounters, overrides: Partial<OnboardingValue> = {}): OnboardingValue {
|
||||||
const checklist = computeChecklist(counters, { importFolderSet: true, budgetAllowed: true });
|
// A template stopped after the import has its visit steps acknowledged (#425).
|
||||||
|
const checklist = computeChecklist(counters, {
|
||||||
|
importFolderSet: true,
|
||||||
|
budgetAllowed: true,
|
||||||
|
visitStepsInitialized: true,
|
||||||
|
acknowledged: counters.imports >= 1 ? ["categories", "reports"] : [],
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
status: "ready",
|
status: "ready",
|
||||||
checklist,
|
checklist,
|
||||||
counters,
|
counters,
|
||||||
hidden: false,
|
hidden: false,
|
||||||
budgetReady: true,
|
budgetReady: true,
|
||||||
|
visitStepsInitialized: true,
|
||||||
nextStep: nextStep(checklist),
|
nextStep: nextStep(checklist),
|
||||||
mode: "card",
|
mode: "card",
|
||||||
cardPage: "/import",
|
cardPage: "/import",
|
||||||
|
|
@ -60,6 +82,7 @@ function path(counters: OnboardingCounters, overrides: Partial<OnboardingValue>
|
||||||
show: vi.fn(async () => undefined),
|
show: vi.fn(async () => undefined),
|
||||||
skip: vi.fn(async () => undefined),
|
skip: vi.fn(async () => undefined),
|
||||||
resume: vi.fn(async () => undefined),
|
resume: vi.fn(async () => undefined),
|
||||||
|
acknowledge: vi.fn(async () => undefined),
|
||||||
refresh: vi.fn(async () => undefined),
|
refresh: vi.fn(async () => undefined),
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
|
|
@ -125,7 +148,7 @@ describe("OnboardingStepCard — what it hands the card (#398)", () => {
|
||||||
it("the step it shows, its place among the unlocked rows, the model's sentence and verdict", () => {
|
it("the step it shows, its place among the unlocked rows, the model's sentence and verdict", () => {
|
||||||
const props = render()!.props;
|
const props = render()!.props;
|
||||||
expect(props.step.key).toBe("source");
|
expect(props.step.key).toBe("source");
|
||||||
expect(props.position).toEqual({ n: 3, total: 6 });
|
expect(props.position).toEqual({ n: 3, total: 8 });
|
||||||
expect(props.text).toBe(SOURCE_LIST.text);
|
expect(props.text).toBe(SOURCE_LIST.text);
|
||||||
expect(props.done).toBe(false);
|
expect(props.done).toBe(false);
|
||||||
expect(props.titleRef).toBe(titleRef());
|
expect(props.titleRef).toBe(titleRef());
|
||||||
|
|
@ -147,6 +170,39 @@ describe("OnboardingStepCard — what it hands the card (#398)", () => {
|
||||||
expect(render()!.props.position).toBeNull();
|
expect(render()!.props.position).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("no 'Step n of N' and no end until the visit steps are initialized (#425)", () => {
|
||||||
|
ctx.onboarding = path(FOLDER_SET, { visitStepsInitialized: false });
|
||||||
|
expect(render()!.props.position).toBeNull();
|
||||||
|
ctx.onboarding = path(IMPORTED, { visitStepsInitialized: false, nextStep: null });
|
||||||
|
expect(render(DONE)!.props.next).toEqual({ kind: "wait" });
|
||||||
|
ctx.onboarding = path(IMPORTED, { nextStep: null });
|
||||||
|
expect(render(DONE)!.props.next).toEqual({ kind: "end" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("no 'Got it' on a data step", () => {
|
||||||
|
expect(render()!.props.onAcknowledge).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("'Got it' on a visit step acknowledges the step the card shows (#425)", () => {
|
||||||
|
ctx.location = { pathname: "/stand-in-categories", state: null, key: "arrival-1" };
|
||||||
|
ctx.onboarding = path(IMPORTED, {
|
||||||
|
cardPage: "/stand-in-categories",
|
||||||
|
checklist: computeChecklist(IMPORTED, {
|
||||||
|
importFolderSet: true,
|
||||||
|
budgetAllowed: true,
|
||||||
|
visitStepsInitialized: true,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const value = ctx.onboarding as OnboardingValue;
|
||||||
|
const props = render()!.props;
|
||||||
|
expect(props.step.key).toBe("categories");
|
||||||
|
expect(props.position).toEqual({ n: 5, total: 8 });
|
||||||
|
expect(props.onAcknowledge).toBeTypeOf("function");
|
||||||
|
props.onAcknowledge!();
|
||||||
|
expect(value.acknowledge).toHaveBeenCalledWith("categories");
|
||||||
|
expect(value.skip).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it("'Skip this step' skips the step the card shows; 'Close' hides the path", () => {
|
it("'Skip this step' skips the step the card shows; 'Close' hides the path", () => {
|
||||||
const value = ctx.onboarding as OnboardingValue;
|
const value = ctx.onboarding as OnboardingValue;
|
||||||
const props = render()!.props;
|
const props = render()!.props;
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import {
|
||||||
showsStepCard,
|
showsStepCard,
|
||||||
type StepCardModel,
|
type StepCardModel,
|
||||||
} from "../../utils/onboardingPath";
|
} from "../../utils/onboardingPath";
|
||||||
|
import { VISIT_STEP_KEYS } from "../../shared/types";
|
||||||
|
|
||||||
export interface OnboardingStepCardProps {
|
export interface OnboardingStepCardProps {
|
||||||
/** The page's card model; null for no card yet (the page's own data is not read). */
|
/** The page's card model; null for no card yet (the page's own data is not read). */
|
||||||
|
|
@ -28,8 +29,11 @@ export interface OnboardingStepCardProps {
|
||||||
* then consumes the navigation state, once per arrival: StrictMode runs the
|
* then consumes the navigation state, once per arrival: StrictMode runs the
|
||||||
* effect twice on mount, a guard on the location key keeps it to one focus
|
* effect twice on mount, a guard on the location key keeps it to one focus
|
||||||
* and one replace. An ordinary visit carries no state and does nothing.
|
* and one replace. An ordinary visit carries no state and does nothing.
|
||||||
* - It wires "Skip this step" to the step it shows, "Close" to `hide`, and
|
* - It wires "Skip this step" to the step it shows, "Got it" to
|
||||||
* the next step's link to the path's next step.
|
* `acknowledge` when that step is a visit step (#425), "Close" to `hide`,
|
||||||
|
* and the next step's link to the path's next step.
|
||||||
|
* - Its rank "Step n of N" and its end wait for the Budget entitlement and
|
||||||
|
* the visit steps' initialization (#425): N depends on both.
|
||||||
*/
|
*/
|
||||||
export default function OnboardingStepCard({ model }: OnboardingStepCardProps) {
|
export default function OnboardingStepCard({ model }: OnboardingStepCardProps) {
|
||||||
const onboarding = useOnboarding();
|
const onboarding = useOnboarding();
|
||||||
|
|
@ -55,15 +59,18 @@ export default function OnboardingStepCard({ model }: OnboardingStepCardProps) {
|
||||||
|
|
||||||
if (!shown) return null;
|
if (!shown) return null;
|
||||||
|
|
||||||
const { skip, hide } = onboarding;
|
const { skip, acknowledge, hide } = onboarding;
|
||||||
|
const totalKnown = onboarding.budgetReady && onboarding.visitStepsInitialized;
|
||||||
|
const isVisitStep = VISIT_STEP_KEYS.includes(current.step.key);
|
||||||
return (
|
return (
|
||||||
<StepCard
|
<StepCard
|
||||||
step={current.step}
|
step={current.step}
|
||||||
position={onboarding.budgetReady ? { n: current.n, total: current.total } : null}
|
position={totalKnown ? { n: current.n, total: current.total } : null}
|
||||||
text={model.text}
|
text={model.text}
|
||||||
done={model.done}
|
done={model.done}
|
||||||
next={cardNext(onboarding.nextStep, pathname, onboarding.budgetReady)}
|
next={cardNext(onboarding.nextStep, pathname, totalKnown)}
|
||||||
onSkip={() => void skip(current.step.key)}
|
onSkip={() => void skip(current.step.key)}
|
||||||
|
onAcknowledge={isVisitStep ? () => void acknowledge(current.step.key) : undefined}
|
||||||
onHide={() => void hide()}
|
onHide={() => void hide()}
|
||||||
titleRef={titleRef}
|
titleRef={titleRef}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -40,15 +40,19 @@ const ZERO: OnboardingCounters = {
|
||||||
transactions: 0,
|
transactions: 0,
|
||||||
};
|
};
|
||||||
const rows = (counters: OnboardingCounters = ZERO) =>
|
const rows = (counters: OnboardingCounters = ZERO) =>
|
||||||
computeChecklist(counters, { importFolderSet: true, budgetAllowed: true }).items;
|
computeChecklist(counters, {
|
||||||
|
importFolderSet: true,
|
||||||
|
budgetAllowed: true,
|
||||||
|
visitStepsInitialized: true,
|
||||||
|
}).items;
|
||||||
const row = (key: ChecklistItemKey, counters?: OnboardingCounters): ChecklistItem =>
|
const row = (key: ChecklistItemKey, counters?: OnboardingCounters): ChecklistItem =>
|
||||||
rows(counters).find((item) => item.key === key)!;
|
rows(counters).find((item) => item.key === key)!;
|
||||||
|
|
||||||
/** The source step on /import, step 3 of 6, not done yet. */
|
/** The source step on /import, step 3 of 8, not done yet. */
|
||||||
function cardProps(overrides: Partial<StepCardProps> = {}): StepCardProps {
|
function cardProps(overrides: Partial<StepCardProps> = {}): StepCardProps {
|
||||||
return {
|
return {
|
||||||
step: row("source"),
|
step: row("source"),
|
||||||
position: { n: 3, total: 6 },
|
position: { n: 3, total: 8 },
|
||||||
text: { key: "onboarding.card.import.sourceList" },
|
text: { key: "onboarding.card.import.sourceList" },
|
||||||
done: false,
|
done: false,
|
||||||
next: { kind: "wait" },
|
next: { kind: "wait" },
|
||||||
|
|
@ -62,7 +66,7 @@ function cardProps(overrides: Partial<StepCardProps> = {}): StepCardProps {
|
||||||
const doneProps = (overrides: Partial<StepCardProps> = {}) =>
|
const doneProps = (overrides: Partial<StepCardProps> = {}) =>
|
||||||
cardProps({
|
cardProps({
|
||||||
step: row("import", { ...ZERO, sources: 1, imports: 1 }),
|
step: row("import", { ...ZERO, sources: 1, imports: 1 }),
|
||||||
position: { n: 4, total: 6 },
|
position: { n: 4, total: 8 },
|
||||||
text: null,
|
text: null,
|
||||||
done: true,
|
done: true,
|
||||||
next: { kind: "step", step: row("manualCategory") },
|
next: { kind: "step", step: row("manualCategory") },
|
||||||
|
|
@ -92,7 +96,7 @@ describe("StepCard — the card (#398)", () => {
|
||||||
expect(heading).not.toBeNull();
|
expect(heading).not.toBeNull();
|
||||||
expect(heading![0]).toContain('tabindex="-1"');
|
expect(heading![0]).toContain('tabindex="-1"');
|
||||||
const title = heading![1];
|
const title = heading![1];
|
||||||
expect(title).toContain("t(onboarding.card.stepOf[n=3,total=6])");
|
expect(title).toContain("t(onboarding.card.stepOf[n=3,total=8])");
|
||||||
expect(title.indexOf("onboarding.card.stepOf")).toBeLessThan(
|
expect(title.indexOf("onboarding.card.stepOf")).toBeLessThan(
|
||||||
title.indexOf("t(onboarding.checklist.items.source)"),
|
title.indexOf("t(onboarding.checklist.items.source)"),
|
||||||
);
|
);
|
||||||
|
|
@ -204,3 +208,52 @@ describe("StepCard — end of the path (#398)", () => {
|
||||||
expect(html).toContain("t(onboarding.card.skip)");
|
expect(html).toContain("t(onboarding.card.skip)");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("StepCard — \"Got it\" on a visit step (#425)", () => {
|
||||||
|
/** The categories step, step 5 of 8, not acknowledged yet. */
|
||||||
|
const visitProps = (overrides: Partial<StepCardProps> = {}) =>
|
||||||
|
cardProps({
|
||||||
|
step: row("categories"),
|
||||||
|
position: { n: 5, total: 8 },
|
||||||
|
text: null,
|
||||||
|
onAcknowledge: vi.fn(),
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
it("offers 'Got it' before 'Skip this step', wired to onAcknowledge", () => {
|
||||||
|
const props = visitProps();
|
||||||
|
const html = markup(props);
|
||||||
|
expect(html).toContain("t(onboarding.card.acknowledge)");
|
||||||
|
expect(html.indexOf("t(onboarding.card.acknowledge)")).toBeLessThan(
|
||||||
|
html.indexOf("t(onboarding.card.skip)"),
|
||||||
|
);
|
||||||
|
const buttons = find(tree(props), (e) => e.type === "button");
|
||||||
|
expect(buttons.map((b) => texts(b.props.children))).toEqual([
|
||||||
|
["t(onboarding.card.acknowledge)"],
|
||||||
|
["t(onboarding.card.skip)"],
|
||||||
|
]);
|
||||||
|
(buttons[0].props.onClick as () => void)();
|
||||||
|
expect(props.onAcknowledge).toHaveBeenCalledTimes(1);
|
||||||
|
expect(props.onSkip).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is never there without onAcknowledge", () => {
|
||||||
|
expect(markup(cardProps())).not.toContain("onboarding.card.acknowledge");
|
||||||
|
expect(markup(visitProps({ onAcknowledge: undefined }))).not.toContain(
|
||||||
|
"onboarding.card.acknowledge",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is never there on a done step, nor once the page's steps are done", () => {
|
||||||
|
const acknowledged = computeChecklist(ZERO, {
|
||||||
|
importFolderSet: true,
|
||||||
|
budgetAllowed: true,
|
||||||
|
visitStepsInitialized: true,
|
||||||
|
acknowledged: ["categories"],
|
||||||
|
}).items.find((item) => item.key === "categories")!;
|
||||||
|
expect(markup(visitProps({ step: acknowledged }))).not.toContain("onboarding.card.acknowledge");
|
||||||
|
const done = markup(visitProps({ done: true, next: { kind: "step", step: row("manualCategory") } }));
|
||||||
|
expect(done).not.toContain("onboarding.card.acknowledge");
|
||||||
|
expect(done).toContain("t(onboarding.card.done)");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ import type { ChecklistItem } from "../../shared/types";
|
||||||
export interface StepCardProps {
|
export interface StepCardProps {
|
||||||
/** The step the card shows (`cardStep`): its label is the title. */
|
/** The step the card shows (`cardStep`): its label is the title. */
|
||||||
step: ChecklistItem;
|
step: ChecklistItem;
|
||||||
/** "Step n of N", null until the Budget entitlement is known: N depends on its lock. */
|
/** "Step n of N", null until the Budget entitlement is known and the visit steps initialized: N depends on both. */
|
||||||
position: { n: number; total: number } | null;
|
position: { n: number; total: number } | null;
|
||||||
/** The page's sentence, null when "Done." says it all. */
|
/** The page's sentence, null when "Done." says it all. */
|
||||||
text: StepCardText | null;
|
text: StepCardText | null;
|
||||||
|
|
@ -23,6 +23,8 @@ export interface StepCardProps {
|
||||||
next: StepCardNext;
|
next: StepCardNext;
|
||||||
/** "Skip this step": the provider's `skip`, for `step`. */
|
/** "Skip this step": the provider's `skip`, for `step`. */
|
||||||
onSkip: () => void;
|
onSkip: () => void;
|
||||||
|
/** "Got it" on a visit step (#425): the provider's `acknowledge`, for `step`. No button when omitted. */
|
||||||
|
onAcknowledge?: () => void;
|
||||||
/** "Close" at the end of the path: the provider's `hide`. */
|
/** "Close" at the end of the path: the provider's `hide`. */
|
||||||
onHide: () => void;
|
onHide: () => void;
|
||||||
/** The title, which takes the focus after an arrival by "Next step". */
|
/** The title, which takes the focus after an arrival by "Next step". */
|
||||||
|
|
@ -33,6 +35,8 @@ const REGION_CLASS =
|
||||||
"rounded-xl border border-[var(--primary)]/30 bg-[var(--primary)]/5 px-4 py-3 mb-6";
|
"rounded-xl border border-[var(--primary)]/30 bg-[var(--primary)]/5 px-4 py-3 mb-6";
|
||||||
const TEXT_BUTTON_CLASS =
|
const TEXT_BUTTON_CLASS =
|
||||||
"text-[var(--muted-foreground)] hover:text-[var(--foreground)] hover:underline transition-colors";
|
"text-[var(--muted-foreground)] hover:text-[var(--foreground)] hover:underline transition-colors";
|
||||||
|
const ACKNOWLEDGE_BUTTON_CLASS =
|
||||||
|
"rounded-lg bg-[var(--primary)] px-3 py-1 font-medium text-white hover:opacity-90 transition-opacity";
|
||||||
const LINK_CLASS = "inline-flex items-center gap-1 font-medium text-[var(--primary)] hover:underline";
|
const LINK_CLASS = "inline-flex items-center gap-1 font-medium text-[var(--primary)] hover:underline";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -48,7 +52,8 @@ const LINK_CLASS = "inline-flex items-center gap-1 font-medium text-[var(--prima
|
||||||
* "Done." it receives once the page's steps are done is announced.
|
* "Done." it receives once the page's steps are done is announced.
|
||||||
* - "Skip this step" shows until the page's steps are done, and only while
|
* - "Skip this step" shows until the page's steps are done, and only while
|
||||||
* the step itself is not: skipping a done step would only leave a stale
|
* the step itself is not: skipping a done step would only leave a stale
|
||||||
* "skipped" mark behind.
|
* "skipped" mark behind. A visit step (#425) gets "Got it" before it, on
|
||||||
|
* the same condition, when `onAcknowledge` is given.
|
||||||
* - Once done: "Next step: <step> →", linked to its page with the step's
|
* - Once done: "Next step: <step> →", linked to its page with the step's
|
||||||
* navigation state; the end message, "Open the guide" and "Close" when no
|
* navigation state; the end message, "Open the guide" and "Close" when no
|
||||||
* step is left; nothing yet while `next` waits.
|
* step is left; nothing yet while `next` waits.
|
||||||
|
|
@ -60,6 +65,7 @@ export default function StepCard({
|
||||||
done,
|
done,
|
||||||
next,
|
next,
|
||||||
onSkip,
|
onSkip,
|
||||||
|
onAcknowledge,
|
||||||
onHide,
|
onHide,
|
||||||
titleRef,
|
titleRef,
|
||||||
}: StepCardProps) {
|
}: StepCardProps) {
|
||||||
|
|
@ -105,6 +111,11 @@ export default function StepCard({
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
|
{!done && !step.done && onAcknowledge && (
|
||||||
|
<button type="button" onClick={onAcknowledge} className={ACKNOWLEDGE_BUTTON_CLASS}>
|
||||||
|
{t("onboarding.card.acknowledge")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{!done && !step.done && (
|
{!done && !step.done && (
|
||||||
<button type="button" onClick={onSkip} className={TEXT_BUTTON_CLASS}>
|
<button type="button" onClick={onSkip} className={TEXT_BUTTON_CLASS}>
|
||||||
{t("onboarding.card.skip")}
|
{t("onboarding.card.skip")}
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,8 @@ function Probe() {
|
||||||
String(onboarding.checklist),
|
String(onboarding.checklist),
|
||||||
typeof onboarding.skip,
|
typeof onboarding.skip,
|
||||||
typeof onboarding.refresh,
|
typeof onboarding.refresh,
|
||||||
|
typeof onboarding.acknowledge,
|
||||||
|
String(onboarding.visitStepsInitialized),
|
||||||
].join("|")}
|
].join("|")}
|
||||||
</output>
|
</output>
|
||||||
);
|
);
|
||||||
|
|
@ -51,7 +53,7 @@ describe("OnboardingProvider (#396)", () => {
|
||||||
</OnboardingProvider>
|
</OnboardingProvider>
|
||||||
</MemoryRouter>,
|
</MemoryRouter>,
|
||||||
);
|
);
|
||||||
expect(html).toContain("loading|none|null|null|null|function|function");
|
expect(html).toContain("loading|none|null|null|null|function|function|function|false");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("AppShell mounts the provider around the routed page", () => {
|
it("AppShell mounts the provider around the routed page", () => {
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,9 @@ import {
|
||||||
onboardingChecklistReducer,
|
onboardingChecklistReducer,
|
||||||
initialOnboardingChecklistState,
|
initialOnboardingChecklistState,
|
||||||
createOnboardingLoader,
|
createOnboardingLoader,
|
||||||
|
createVisitStepsInitializer,
|
||||||
deriveOnboardingView,
|
deriveOnboardingView,
|
||||||
|
needsVisitStepsInitialization,
|
||||||
followDataChanges,
|
followDataChanges,
|
||||||
DATA_CHANGE_DELAY_MS,
|
DATA_CHANGE_DELAY_MS,
|
||||||
type OnboardingChecklistAction,
|
type OnboardingChecklistAction,
|
||||||
|
|
@ -29,7 +31,7 @@ import {
|
||||||
type OnboardingLoaderDeps,
|
type OnboardingLoaderDeps,
|
||||||
} from "./useOnboardingChecklist";
|
} from "./useOnboardingChecklist";
|
||||||
import { notifyDataChanged } from "../services/dataEvents";
|
import { notifyDataChanged } from "../services/dataEvents";
|
||||||
import { computeChecklist } from "../utils/onboardingChecklist";
|
import { computeChecklist, shouldAcknowledgeVisitSteps } from "../utils/onboardingChecklist";
|
||||||
import type { OnboardingSnapshot } from "../services/onboardingService";
|
import type { OnboardingSnapshot } from "../services/onboardingService";
|
||||||
import type { OnboardingCounters } from "../shared/types";
|
import type { OnboardingCounters } from "../shared/types";
|
||||||
|
|
||||||
|
|
@ -38,6 +40,10 @@ const snapshot: OnboardingSnapshot = {
|
||||||
importFolderSet: true,
|
importFolderSet: true,
|
||||||
hidden: false,
|
hidden: false,
|
||||||
skipped: [],
|
skipped: [],
|
||||||
|
// Stopped after the import: the visit steps are acknowledged and initialized
|
||||||
|
// (rule of the templates, #425).
|
||||||
|
acknowledged: ["categories", "reports"],
|
||||||
|
visitStepsInitialized: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
const reduce = (
|
const reduce = (
|
||||||
|
|
@ -60,9 +66,12 @@ describe("onboardingChecklistReducer — load lifecycle (#351, #396)", () => {
|
||||||
importFolderSet: false,
|
importFolderSet: false,
|
||||||
hidden: false,
|
hidden: false,
|
||||||
skipped: [],
|
skipped: [],
|
||||||
|
acknowledged: [],
|
||||||
|
visitStepsInitialized: false,
|
||||||
loadSeq: 0,
|
loadSeq: 0,
|
||||||
hiddenSetAt: 0,
|
hiddenSetAt: 0,
|
||||||
skippedSetAt: 0,
|
skippedSetAt: 0,
|
||||||
|
acknowledgedSetAt: 0,
|
||||||
cardPage: null,
|
cardPage: null,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -318,6 +327,76 @@ describe("onboardingChecklistReducer — skipped steps and the card latch (#396)
|
||||||
).toBe("/import");
|
).toBe("/import");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("ACKNOWLEDGE keeps checklist order, never duplicates a step, and keeps the card latch (#425)", () => {
|
||||||
|
const latched = reduce(
|
||||||
|
[{ type: "SET_CARD_PAGE", cardPage: "/categories" }],
|
||||||
|
ready({ ...snapshot, acknowledged: [] }),
|
||||||
|
);
|
||||||
|
const s = reduce(
|
||||||
|
[
|
||||||
|
{ type: "ACKNOWLEDGE", key: "reports" },
|
||||||
|
{ type: "ACKNOWLEDGE", key: "categories" },
|
||||||
|
{ type: "ACKNOWLEDGE", key: "reports" },
|
||||||
|
],
|
||||||
|
latched,
|
||||||
|
);
|
||||||
|
expect(s.acknowledged).toEqual(["categories", "reports"]);
|
||||||
|
expect(s.cardPage).toBe("/categories");
|
||||||
|
expect(s.skipped).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ACKNOWLEDGE during a load wins over that load's snapshot; a later load brings the stored value back (#425)", () => {
|
||||||
|
const base = ready({ ...snapshot, acknowledged: [] });
|
||||||
|
const s = reduce(
|
||||||
|
[
|
||||||
|
{ type: "LOAD_START", seq: 2 },
|
||||||
|
{ type: "ACKNOWLEDGE", key: "categories" },
|
||||||
|
{ type: "LOAD_DONE", seq: 2, snapshot: { ...snapshot, acknowledged: [] } },
|
||||||
|
],
|
||||||
|
base,
|
||||||
|
);
|
||||||
|
expect(s.acknowledged).toEqual(["categories"]);
|
||||||
|
const later = reduce(
|
||||||
|
[
|
||||||
|
{ type: "LOAD_START", seq: 3 },
|
||||||
|
{ type: "LOAD_DONE", seq: 3, snapshot: { ...snapshot, acknowledged: ["categories"] } },
|
||||||
|
],
|
||||||
|
s,
|
||||||
|
);
|
||||||
|
expect(later.acknowledged).toEqual(["categories"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ACKNOWLEDGE does not shield the skipped steps, nor SKIP the acknowledged ones (#425)", () => {
|
||||||
|
const base = ready({ ...snapshot, acknowledged: [] });
|
||||||
|
const s = reduce(
|
||||||
|
[
|
||||||
|
{ type: "LOAD_START", seq: 2 },
|
||||||
|
{ type: "ACKNOWLEDGE", key: "categories" },
|
||||||
|
{
|
||||||
|
type: "LOAD_DONE",
|
||||||
|
seq: 2,
|
||||||
|
snapshot: { ...snapshot, acknowledged: [], skipped: ["budget"] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
base,
|
||||||
|
);
|
||||||
|
expect(s.skipped).toEqual(["budget"]);
|
||||||
|
const t = reduce(
|
||||||
|
[
|
||||||
|
{ type: "LOAD_START", seq: 3 },
|
||||||
|
{ type: "SKIP", key: "budget" },
|
||||||
|
{ type: "LOAD_DONE", seq: 3, snapshot: { ...snapshot, acknowledged: ["reports"] } },
|
||||||
|
],
|
||||||
|
s,
|
||||||
|
);
|
||||||
|
expect(t.acknowledged).toEqual(["reports"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("LOAD_DONE takes visitStepsInitialized from the snapshot (#425)", () => {
|
||||||
|
expect(ready({ ...snapshot, visitStepsInitialized: false }).visitStepsInitialized).toBe(false);
|
||||||
|
expect(ready(snapshot).visitStepsInitialized).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it("SET_CARD_PAGE with the same page returns the same state object", () => {
|
it("SET_CARD_PAGE with the same page returns the same state object", () => {
|
||||||
const latched = reduce([{ type: "SET_CARD_PAGE", cardPage: "/import" }], ready());
|
const latched = reduce([{ type: "SET_CARD_PAGE", cardPage: "/import" }], ready());
|
||||||
expect(onboardingChecklistReducer(latched, { type: "SET_CARD_PAGE", cardPage: "/import" })).toBe(
|
expect(onboardingChecklistReducer(latched, { type: "SET_CARD_PAGE", cardPage: "/import" })).toBe(
|
||||||
|
|
@ -465,6 +544,82 @@ describe("createOnboardingLoader (#396)", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Visit steps' one-time initialization (#425)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("createVisitStepsInitializer (#425)", () => {
|
||||||
|
it("runs initializeVisitSteps once, then re-reads, whatever the number of calls", async () => {
|
||||||
|
const init = deferred<void>();
|
||||||
|
const deps = {
|
||||||
|
initializeVisitSteps: vi.fn(() => init.promise),
|
||||||
|
refresh: vi.fn(async () => {}),
|
||||||
|
logError: vi.fn(),
|
||||||
|
};
|
||||||
|
const run = createVisitStepsInitializer(deps);
|
||||||
|
// StrictMode's double mount, then renders before the re-read lands.
|
||||||
|
const first = run(true);
|
||||||
|
const second = run(false);
|
||||||
|
expect(deps.refresh).not.toHaveBeenCalled();
|
||||||
|
init.resolve();
|
||||||
|
await Promise.all([first, second]);
|
||||||
|
await run(true);
|
||||||
|
expect(deps.initializeVisitSteps).toHaveBeenCalledTimes(1);
|
||||||
|
expect(deps.initializeVisitSteps).toHaveBeenCalledWith(true);
|
||||||
|
expect(deps.refresh).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a failure is logged, without a re-read, and not retried by the same instance", async () => {
|
||||||
|
const deps = {
|
||||||
|
initializeVisitSteps: vi.fn(async () => {
|
||||||
|
throw new Error("database is locked");
|
||||||
|
}),
|
||||||
|
refresh: vi.fn(async () => {}),
|
||||||
|
logError: vi.fn(),
|
||||||
|
};
|
||||||
|
const run = createVisitStepsInitializer(deps);
|
||||||
|
await run(false);
|
||||||
|
await run(false);
|
||||||
|
expect(deps.initializeVisitSteps).toHaveBeenCalledTimes(1);
|
||||||
|
expect(deps.refresh).not.toHaveBeenCalled();
|
||||||
|
expect(deps.logError).toHaveBeenCalledWith(
|
||||||
|
"Onboarding: failed to initialize the visit steps",
|
||||||
|
"database is locked",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("needsVisitStepsInitialization (#425)", () => {
|
||||||
|
const notYet = { ...snapshot, acknowledged: [], visitStepsInitialized: false };
|
||||||
|
const listOf = (state: OnboardingChecklistState) =>
|
||||||
|
computeChecklist(state.counters!, {
|
||||||
|
importFolderSet: state.importFolderSet,
|
||||||
|
budgetAllowed: true,
|
||||||
|
visitStepsInitialized: state.visitStepsInitialized,
|
||||||
|
});
|
||||||
|
|
||||||
|
it("true after a successful load, with the entitlement known and the flag unset", () => {
|
||||||
|
const s = ready(notYet);
|
||||||
|
expect(needsVisitStepsInitialization(s, listOf(s), true)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("false before budgetReady: the checklist would count Budget as locked", () => {
|
||||||
|
const s = ready(notYet);
|
||||||
|
expect(needsVisitStepsInitialization(s, listOf(s), false)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("false before any load, after a failed first load, and once the flag is set", () => {
|
||||||
|
expect(needsVisitStepsInitialization(initialOnboardingChecklistState, null, true)).toBe(false);
|
||||||
|
const failed = reduce([
|
||||||
|
{ type: "LOAD_START", seq: 1 },
|
||||||
|
{ type: "LOAD_ERROR", seq: 1 },
|
||||||
|
]);
|
||||||
|
expect(needsVisitStepsInitialization(failed, null, true)).toBe(false);
|
||||||
|
const done = ready(snapshot);
|
||||||
|
expect(needsVisitStepsInitialization(done, listOf(done), true)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Provider view: the path end to end, without rendering
|
// Provider view: the path end to end, without rendering
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -492,6 +647,8 @@ describe("deriveOnboardingView — the path end to end (#396)", () => {
|
||||||
importFolderSet: state.importFolderSet,
|
importFolderSet: state.importFolderSet,
|
||||||
budgetAllowed,
|
budgetAllowed,
|
||||||
skipped: state.skipped,
|
skipped: state.skipped,
|
||||||
|
acknowledged: state.acknowledged,
|
||||||
|
visitStepsInitialized: state.visitStepsInitialized,
|
||||||
})
|
})
|
||||||
: null;
|
: null;
|
||||||
const view = deriveOnboardingView({ state, checklist, pathname, budgetReady, cardPages });
|
const view = deriveOnboardingView({ state, checklist, pathname, budgetReady, cardPages });
|
||||||
|
|
@ -517,7 +674,16 @@ describe("deriveOnboardingView — the path end to end (#396)", () => {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const fresh: OnboardingSnapshot = { counters: empty, importFolderSet: false, hidden: false, skipped: [] };
|
// The walks below cover the six data steps: the visit steps are acknowledged
|
||||||
|
// and initialized (rule of the templates, #425); their own walk follows.
|
||||||
|
const fresh: OnboardingSnapshot = {
|
||||||
|
counters: empty,
|
||||||
|
importFolderSet: false,
|
||||||
|
hidden: false,
|
||||||
|
skipped: [],
|
||||||
|
acknowledged: ["categories", "reports"],
|
||||||
|
visitStepsInitialized: true,
|
||||||
|
};
|
||||||
|
|
||||||
it("shows nothing before the first load", () => {
|
it("shows nothing before the first load", () => {
|
||||||
const { view } = render(initialOnboardingChecklistState, "/import");
|
const { view } = render(initialOnboardingChecklistState, "/import");
|
||||||
|
|
@ -652,7 +818,7 @@ describe("deriveOnboardingView — the path end to end (#396)", () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("Free edition: five steps, never the budget card, and the end at 5/5", () => {
|
it("Free edition: seven steps, never the budget card, and the end at 7/7", () => {
|
||||||
const snap = {
|
const snap = {
|
||||||
...fresh,
|
...fresh,
|
||||||
importFolderSet: true,
|
importFolderSet: true,
|
||||||
|
|
@ -660,7 +826,7 @@ describe("deriveOnboardingView — the path end to end (#396)", () => {
|
||||||
};
|
};
|
||||||
const s = load(initialOnboardingChecklistState, snap);
|
const s = load(initialOnboardingChecklistState, snap);
|
||||||
const r = render(s, "/budget", { budgetAllowed: false });
|
const r = render(s, "/budget", { budgetAllowed: false });
|
||||||
expect(r.checklist?.total).toBe(5);
|
expect(r.checklist?.total).toBe(7);
|
||||||
expect(r.view.nextStep).toBeNull();
|
expect(r.view.nextStep).toBeNull();
|
||||||
expect(r.state.cardPage).toBeNull();
|
expect(r.state.cardPage).toBeNull();
|
||||||
expect(r.view.mode).toBe("complete");
|
expect(r.view.mode).toBe("complete");
|
||||||
|
|
@ -677,7 +843,74 @@ describe("deriveOnboardingView — the path end to end (#396)", () => {
|
||||||
);
|
);
|
||||||
r = render(s, "/import");
|
r = render(s, "/import");
|
||||||
expect(r.view.mode).toBe("card");
|
expect(r.view.mode).toBe("card");
|
||||||
expect(r.checklist?.total).toBe(6);
|
expect(r.checklist?.total).toBe(8);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The visit steps (#425): until they are initialized, a profile whose path
|
||||||
|
// was complete must not flash a "Next step" towards them.
|
||||||
|
const sixDone = { ...empty, sources: 1, imports: 1, manualCategorized: 1, budgetEntries: 1, transactions: 20 };
|
||||||
|
const beforeInit: OnboardingSnapshot = {
|
||||||
|
...fresh,
|
||||||
|
importFolderSet: true,
|
||||||
|
counters: sixDone,
|
||||||
|
acknowledged: [],
|
||||||
|
visitStepsInitialized: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
it("first render of a profile whose path was complete: no banner before the initialization, then the end", () => {
|
||||||
|
let s = load(initialOnboardingChecklistState, beforeInit);
|
||||||
|
let r = render(s, "/reports");
|
||||||
|
expect(r.view.nextStep).toBeNull();
|
||||||
|
expect(r.view.mode).toBe("none");
|
||||||
|
expect(r.view.mode).not.toBe("banner");
|
||||||
|
expect(needsVisitStepsInitialization(s, r.checklist, true)).toBe(true);
|
||||||
|
expect(shouldAcknowledgeVisitSteps(r.checklist!)).toBe(true);
|
||||||
|
// The initializer wrote both acknowledgements and the flag, then re-read.
|
||||||
|
s = load(r.state, { ...beforeInit, acknowledged: ["categories", "reports"], visitStepsInitialized: true });
|
||||||
|
r = render(s, "/reports");
|
||||||
|
expect(r.checklist?.total).toBe(8);
|
||||||
|
expect(r.view.mode).toBe("complete");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an incomplete path keeps its visit steps to do: after the import, the next step is to browse the categories", () => {
|
||||||
|
const imported = { ...empty, sources: 1, imports: 1, transactions: 20 };
|
||||||
|
let s = load(initialOnboardingChecklistState, {
|
||||||
|
...beforeInit,
|
||||||
|
counters: imported,
|
||||||
|
});
|
||||||
|
let r = render(s, "/import");
|
||||||
|
expect(shouldAcknowledgeVisitSteps(r.checklist!)).toBe(false);
|
||||||
|
s = load(r.state, { ...beforeInit, counters: imported, visitStepsInitialized: true });
|
||||||
|
r = render(s, "/import");
|
||||||
|
expect(r.view.nextStep?.key).toBe("categories");
|
||||||
|
expect(r.checklist?.total).toBe(8);
|
||||||
|
// The six other steps done: still two to do.
|
||||||
|
s = load(r.state, { ...beforeInit, visitStepsInitialized: true });
|
||||||
|
r = render(s, "/");
|
||||||
|
expect(r.view.nextStep?.key).toBe("categories");
|
||||||
|
expect(r.view.mode).toBe("banner");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('"Got it" on the last step ends the path; the latched card stays to say so', () => {
|
||||||
|
let s = load(initialOnboardingChecklistState, {
|
||||||
|
...beforeInit,
|
||||||
|
acknowledged: ["categories"],
|
||||||
|
visitStepsInitialized: true,
|
||||||
|
});
|
||||||
|
s = reduce([{ type: "SET_CARD_PAGE", cardPage: "/reports" }], s);
|
||||||
|
let r = render(s, "/reports", {});
|
||||||
|
expect(r.view.nextStep?.key).toBe("reports");
|
||||||
|
r = render(reduce([{ type: "ACKNOWLEDGE", key: "reports" }], r.state), "/reports");
|
||||||
|
expect(r.view.nextStep).toBeNull();
|
||||||
|
expect(r.checklist?.complete).toBe(true);
|
||||||
|
expect(r.state.cardPage).toBe("/reports");
|
||||||
|
expect(r.view.mode).toBe("card");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the end waits for the visit steps' initialization even with the entitlement known", () => {
|
||||||
|
const s = load(initialOnboardingChecklistState, beforeInit);
|
||||||
|
expect(render(s, "/").view.mode).toBe("none");
|
||||||
|
expect(render(load(s, { ...beforeInit, visitStepsInitialized: true }), "/").view.mode).toBe("banner");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
import { useCallback, useEffect, useMemo, useReducer, useState } from "react";
|
import { useCallback, useEffect, useMemo, useReducer, useState } from "react";
|
||||||
import { useLocation } from "react-router-dom";
|
import { useLocation } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
|
acknowledgeStep,
|
||||||
getOnboardingSnapshot,
|
getOnboardingSnapshot,
|
||||||
hideChecklist,
|
hideChecklist,
|
||||||
initializeGuidedPath,
|
initializeGuidedPath,
|
||||||
|
initializeVisitSteps,
|
||||||
resumeStep,
|
resumeStep,
|
||||||
showChecklist,
|
showChecklist,
|
||||||
skipStep,
|
skipStep,
|
||||||
|
|
@ -11,7 +13,11 @@ import {
|
||||||
} from "../services/onboardingService";
|
} from "../services/onboardingService";
|
||||||
import { subscribeDataChanged } from "../services/dataEvents";
|
import { subscribeDataChanged } from "../services/dataEvents";
|
||||||
import { logError } from "../services/logService";
|
import { logError } from "../services/logService";
|
||||||
import { computeChecklist, type Checklist } from "../utils/onboardingChecklist";
|
import {
|
||||||
|
computeChecklist,
|
||||||
|
shouldAcknowledgeVisitSteps,
|
||||||
|
type Checklist,
|
||||||
|
} from "../utils/onboardingChecklist";
|
||||||
import {
|
import {
|
||||||
bannerMode,
|
bannerMode,
|
||||||
latchCardPage,
|
latchCardPage,
|
||||||
|
|
@ -44,12 +50,18 @@ export interface OnboardingChecklistState {
|
||||||
hidden: boolean;
|
hidden: boolean;
|
||||||
/** Skipped steps, in checklist order. */
|
/** Skipped steps, in checklist order. */
|
||||||
skipped: ChecklistItemKey[];
|
skipped: ChecklistItemKey[];
|
||||||
|
/** Visit steps done by "Got it" (#425), in checklist order. */
|
||||||
|
acknowledged: ChecklistItemKey[];
|
||||||
|
/** The profile's visit steps are initialized (#425); read from the snapshot only. */
|
||||||
|
visitStepsInitialized: boolean;
|
||||||
/** Number of the last load started: the outcome of any other load is stale. */
|
/** Number of the last load started: the outcome of any other load is stale. */
|
||||||
loadSeq: number;
|
loadSeq: number;
|
||||||
/** `loadSeq` at the last HIDE / SHOW: the snapshot of a load numbered up to it predates the action. */
|
/** `loadSeq` at the last HIDE / SHOW: the snapshot of a load numbered up to it predates the action. */
|
||||||
hiddenSetAt: number;
|
hiddenSetAt: number;
|
||||||
/** Same, for SKIP / RESUME and the skipped steps. */
|
/** Same, for SKIP / RESUME and the skipped steps. */
|
||||||
skippedSetAt: number;
|
skippedSetAt: number;
|
||||||
|
/** Same, for ACKNOWLEDGE and the acknowledged steps (#425). */
|
||||||
|
acknowledgedSetAt: number;
|
||||||
/** The page that carries a step card (`latchCardPage`), null otherwise. */
|
/** The page that carries a step card (`latchCardPage`), null otherwise. */
|
||||||
cardPage: string | null;
|
cardPage: string | null;
|
||||||
}
|
}
|
||||||
|
|
@ -62,6 +74,7 @@ export type OnboardingChecklistAction =
|
||||||
| { type: "SHOW" }
|
| { type: "SHOW" }
|
||||||
| { type: "SKIP"; key: ChecklistItemKey }
|
| { type: "SKIP"; key: ChecklistItemKey }
|
||||||
| { type: "RESUME"; key: ChecklistItemKey }
|
| { type: "RESUME"; key: ChecklistItemKey }
|
||||||
|
| { type: "ACKNOWLEDGE"; key: ChecklistItemKey }
|
||||||
| { type: "SET_CARD_PAGE"; cardPage: string | null };
|
| { type: "SET_CARD_PAGE"; cardPage: string | null };
|
||||||
|
|
||||||
export const initialOnboardingChecklistState: OnboardingChecklistState = {
|
export const initialOnboardingChecklistState: OnboardingChecklistState = {
|
||||||
|
|
@ -70,9 +83,12 @@ export const initialOnboardingChecklistState: OnboardingChecklistState = {
|
||||||
importFolderSet: false,
|
importFolderSet: false,
|
||||||
hidden: false,
|
hidden: false,
|
||||||
skipped: [],
|
skipped: [],
|
||||||
|
acknowledged: [],
|
||||||
|
visitStepsInitialized: false,
|
||||||
loadSeq: 0,
|
loadSeq: 0,
|
||||||
hiddenSetAt: 0,
|
hiddenSetAt: 0,
|
||||||
skippedSetAt: 0,
|
skippedSetAt: 0,
|
||||||
|
acknowledgedSetAt: 0,
|
||||||
cardPage: null,
|
cardPage: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -106,6 +122,9 @@ export function onboardingChecklistReducer(
|
||||||
// snapshot, read before the action's write (#384).
|
// snapshot, read before the action's write (#384).
|
||||||
hidden: action.seq <= state.hiddenSetAt ? state.hidden : snapshot.hidden,
|
hidden: action.seq <= state.hiddenSetAt ? state.hidden : snapshot.hidden,
|
||||||
skipped: action.seq <= state.skippedSetAt ? state.skipped : snapshot.skipped,
|
skipped: action.seq <= state.skippedSetAt ? state.skipped : snapshot.skipped,
|
||||||
|
acknowledged:
|
||||||
|
action.seq <= state.acknowledgedSetAt ? state.acknowledged : snapshot.acknowledged,
|
||||||
|
visitStepsInitialized: snapshot.visitStepsInitialized,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
case "LOAD_ERROR":
|
case "LOAD_ERROR":
|
||||||
|
|
@ -132,6 +151,14 @@ export function onboardingChecklistReducer(
|
||||||
skipped: state.skipped.filter((key) => key !== action.key),
|
skipped: state.skipped.filter((key) => key !== action.key),
|
||||||
skippedSetAt: state.loadSeq,
|
skippedSetAt: state.loadSeq,
|
||||||
};
|
};
|
||||||
|
case "ACKNOWLEDGE":
|
||||||
|
// Wins over an older snapshot like SKIP, but keeps the card latch: the
|
||||||
|
// card stays to say "Done." and offer the next step (#425).
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
acknowledged: withStep(state.acknowledged, action.key),
|
||||||
|
acknowledgedSetAt: state.loadSeq,
|
||||||
|
};
|
||||||
case "SET_CARD_PAGE":
|
case "SET_CARD_PAGE":
|
||||||
return state.cardPage === action.cardPage ? state : { ...state, cardPage: action.cardPage };
|
return state.cardPage === action.cardPage ? state : { ...state, cardPage: action.cardPage };
|
||||||
}
|
}
|
||||||
|
|
@ -199,6 +226,53 @@ export function createOnboardingLoader(deps: OnboardingLoaderDeps): () => Promis
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface VisitStepsInitializerDeps {
|
||||||
|
initializeVisitSteps: (acknowledge: boolean) => Promise<void>;
|
||||||
|
/** The provider's loader: the initializer re-reads once it wrote. */
|
||||||
|
refresh: () => Promise<void>;
|
||||||
|
logError: (...args: unknown[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one-time initialization of the visit steps (#425), kept outside React
|
||||||
|
* like the loader. The provider calls the returned function after each render
|
||||||
|
* where a load succeeded, the Budget entitlement is known and the flag is
|
||||||
|
* unset; it runs `initializeVisitSteps` once per instance, whatever the number
|
||||||
|
* of calls (the promise is shared, so the double mount of StrictMode and the
|
||||||
|
* renders before the re-read lands do not run it twice), then re-reads. A
|
||||||
|
* failure is logged and the flag stays unset: the next opening retries.
|
||||||
|
*/
|
||||||
|
export function createVisitStepsInitializer(
|
||||||
|
deps: VisitStepsInitializerDeps,
|
||||||
|
): (acknowledge: boolean) => Promise<void> {
|
||||||
|
let initialization: Promise<void> | null = null;
|
||||||
|
return (acknowledge) => {
|
||||||
|
if (initialization === null) {
|
||||||
|
initialization = deps.initializeVisitSteps(acknowledge).then(
|
||||||
|
() => deps.refresh(),
|
||||||
|
(e: unknown) => {
|
||||||
|
deps.logError("Onboarding: failed to initialize the visit steps", errorMessage(e));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return initialization;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this render must start the visit steps' initialization (#425): a
|
||||||
|
* load succeeded, the Budget entitlement is known (the checklist counts Budget
|
||||||
|
* as locked until then, and a Base profile would be judged complete one step
|
||||||
|
* early) and the snapshot says the flag is unset.
|
||||||
|
*/
|
||||||
|
export function needsVisitStepsInitialization(
|
||||||
|
state: OnboardingChecklistState,
|
||||||
|
checklist: Checklist | null,
|
||||||
|
budgetReady: boolean,
|
||||||
|
): checklist is Checklist {
|
||||||
|
return state.status === "ready" && checklist !== null && budgetReady && !state.visitStepsInitialized;
|
||||||
|
}
|
||||||
|
|
||||||
/** Wait after a data event before re-reading: a burst of writes costs one read (ADR 0022). */
|
/** Wait after a data event before re-reading: a burst of writes costs one read (ADR 0022). */
|
||||||
export const DATA_CHANGE_DELAY_MS = 150;
|
export const DATA_CHANGE_DELAY_MS = 150;
|
||||||
|
|
||||||
|
|
@ -255,6 +329,9 @@ export function deriveOnboardingView({
|
||||||
budgetReady,
|
budgetReady,
|
||||||
cardPages,
|
cardPages,
|
||||||
}: OnboardingViewInput): OnboardingView {
|
}: OnboardingViewInput): OnboardingView {
|
||||||
|
// The end of the path waits for the visit steps too (#425): before their
|
||||||
|
// initialization, a profile whose path was complete is two steps short.
|
||||||
|
const totalKnown = budgetReady && state.visitStepsInitialized;
|
||||||
const nextStep = checklist ? findNextStep(checklist) : null;
|
const nextStep = checklist ? findNextStep(checklist) : null;
|
||||||
const cardPage = latchCardPage(state.cardPage, {
|
const cardPage = latchCardPage(state.cardPage, {
|
||||||
pathname,
|
pathname,
|
||||||
|
|
@ -268,7 +345,7 @@ export function deriveOnboardingView({
|
||||||
hidden: state.hidden,
|
hidden: state.hidden,
|
||||||
pathname,
|
pathname,
|
||||||
isEmptyDashboard: pathname === "/" && state.counters?.transactions === 0,
|
isEmptyDashboard: pathname === "/" && state.counters?.transactions === 0,
|
||||||
budgetReady,
|
budgetReady: totalKnown,
|
||||||
cardPage,
|
cardPage,
|
||||||
});
|
});
|
||||||
return { nextStep, cardPage, mode };
|
return { nextStep, cardPage, mode };
|
||||||
|
|
@ -282,6 +359,11 @@ export interface OnboardingValue {
|
||||||
counters: OnboardingCounters | null;
|
counters: OnboardingCounters | null;
|
||||||
hidden: boolean;
|
hidden: boolean;
|
||||||
budgetReady: boolean;
|
budgetReady: boolean;
|
||||||
|
/**
|
||||||
|
* The profile's visit steps are initialized (#425). The counters, the rank
|
||||||
|
* of a card and the end of the path wait for `budgetReady && visitStepsInitialized`.
|
||||||
|
*/
|
||||||
|
visitStepsInitialized: boolean;
|
||||||
/** First unlocked step neither done nor skipped; its link is `nextStep.to`. */
|
/** First unlocked step neither done nor skipped; its link is `nextStep.to`. */
|
||||||
nextStep: ChecklistItem | null;
|
nextStep: ChecklistItem | null;
|
||||||
/** What the current page shows of the path (`bannerMode`). */
|
/** What the current page shows of the path (`bannerMode`). */
|
||||||
|
|
@ -292,18 +374,22 @@ export interface OnboardingValue {
|
||||||
show: () => Promise<void>;
|
show: () => Promise<void>;
|
||||||
skip: (key: ChecklistItemKey) => Promise<void>;
|
skip: (key: ChecklistItemKey) => Promise<void>;
|
||||||
resume: (key: ChecklistItemKey) => Promise<void>;
|
resume: (key: ChecklistItemKey) => Promise<void>;
|
||||||
|
/** "Got it" on a visit step's card (#425). */
|
||||||
|
acknowledge: (key: ChecklistItemKey) => Promise<void>;
|
||||||
refresh: () => Promise<void>;
|
refresh: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Getting-started path: counters read from the profile database, hidden and
|
* Getting-started path: counters read from the profile database, hidden and
|
||||||
* skipped preferences, the Budget entitlement that decides whether the sixth
|
* skipped and acknowledged preferences, the visit-steps flag (#425), the
|
||||||
|
* Budget entitlement that decides whether the seventh
|
||||||
* row is locked, and what each page shows of it. Called once, by
|
* row is locked, and what each page shows of it. Called once, by
|
||||||
* `OnboardingProvider`; pages read it through `useOnboarding()`.
|
* `OnboardingProvider`; pages read it through `useOnboarding()`.
|
||||||
*
|
*
|
||||||
* Re-read at mount, at every route change and 150 ms after a burst of data
|
* Re-read at mount, at every route change and 150 ms after a burst of data
|
||||||
* events (ADR 0022). The provider is the only writer of the onboarding
|
* events (ADR 0022). The provider is the only writer of the onboarding
|
||||||
* preferences: its actions update the state themselves, without a re-read.
|
* preferences: its actions update the state themselves, without a re-read;
|
||||||
|
* only the visit steps' one-time initialization (#425) re-reads after it wrote.
|
||||||
*/
|
*/
|
||||||
export function useOnboardingChecklist(): OnboardingValue {
|
export function useOnboardingChecklist(): OnboardingValue {
|
||||||
const [state, dispatch] = useReducer(
|
const [state, dispatch] = useReducer(
|
||||||
|
|
@ -319,6 +405,11 @@ export function useOnboardingChecklist(): OnboardingValue {
|
||||||
createOnboardingLoader({ initializeGuidedPath, getOnboardingSnapshot, dispatch, logError }),
|
createOnboardingLoader({ initializeGuidedPath, getOnboardingSnapshot, dispatch, logError }),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Same lifetime: one visit-steps initialization per profile opening (#425).
|
||||||
|
const [initializeVisitStepsOnce] = useState(() =>
|
||||||
|
createVisitStepsInitializer({ initializeVisitSteps, refresh, logError }),
|
||||||
|
);
|
||||||
|
|
||||||
// Read at mount, then again at every route change.
|
// Read at mount, then again at every route change.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void refresh();
|
void refresh();
|
||||||
|
|
@ -363,6 +454,15 @@ export function useOnboardingChecklist(): OnboardingValue {
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const acknowledge = useCallback(async (key: ChecklistItemKey) => {
|
||||||
|
try {
|
||||||
|
await acknowledgeStep(key);
|
||||||
|
dispatch({ type: "ACKNOWLEDGE", key });
|
||||||
|
} catch (e) {
|
||||||
|
logError("Onboarding: failed to acknowledge a step", errorMessage(e));
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const checklist: Checklist | null = useMemo(
|
const checklist: Checklist | null = useMemo(
|
||||||
() =>
|
() =>
|
||||||
state.counters
|
state.counters
|
||||||
|
|
@ -370,11 +470,28 @@ export function useOnboardingChecklist(): OnboardingValue {
|
||||||
importFolderSet: state.importFolderSet,
|
importFolderSet: state.importFolderSet,
|
||||||
budgetAllowed,
|
budgetAllowed,
|
||||||
skipped: state.skipped,
|
skipped: state.skipped,
|
||||||
|
acknowledged: state.acknowledged,
|
||||||
|
visitStepsInitialized: state.visitStepsInitialized,
|
||||||
})
|
})
|
||||||
: null,
|
: null,
|
||||||
[state.counters, state.importFolderSet, state.skipped, budgetAllowed],
|
[
|
||||||
|
state.counters,
|
||||||
|
state.importFolderSet,
|
||||||
|
state.skipped,
|
||||||
|
state.acknowledged,
|
||||||
|
state.visitStepsInitialized,
|
||||||
|
budgetAllowed,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// The visit steps' one-time initialization (#425), after a successful load
|
||||||
|
// with the Budget entitlement known; the initializer re-reads itself.
|
||||||
|
const needsInitialization = needsVisitStepsInitialization(state, checklist, budgetReady);
|
||||||
|
const acknowledgeVisitSteps = checklist !== null && shouldAcknowledgeVisitSteps(checklist);
|
||||||
|
useEffect(() => {
|
||||||
|
if (needsInitialization) void initializeVisitStepsOnce(acknowledgeVisitSteps);
|
||||||
|
}, [needsInitialization, acknowledgeVisitSteps, initializeVisitStepsOnce]);
|
||||||
|
|
||||||
const { nextStep, cardPage, mode } = deriveOnboardingView({
|
const { nextStep, cardPage, mode } = deriveOnboardingView({
|
||||||
state,
|
state,
|
||||||
checklist,
|
checklist,
|
||||||
|
|
@ -396,6 +513,7 @@ export function useOnboardingChecklist(): OnboardingValue {
|
||||||
counters: state.counters,
|
counters: state.counters,
|
||||||
hidden: state.hidden,
|
hidden: state.hidden,
|
||||||
budgetReady,
|
budgetReady,
|
||||||
|
visitStepsInitialized: state.visitStepsInitialized,
|
||||||
nextStep,
|
nextStep,
|
||||||
mode,
|
mode,
|
||||||
cardPage,
|
cardPage,
|
||||||
|
|
@ -403,6 +521,7 @@ export function useOnboardingChecklist(): OnboardingValue {
|
||||||
show,
|
show,
|
||||||
skip,
|
skip,
|
||||||
resume,
|
resume,
|
||||||
|
acknowledge,
|
||||||
refresh,
|
refresh,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
|
|
@ -411,6 +530,7 @@ export function useOnboardingChecklist(): OnboardingValue {
|
||||||
state.counters,
|
state.counters,
|
||||||
state.hidden,
|
state.hidden,
|
||||||
budgetReady,
|
budgetReady,
|
||||||
|
state.visitStepsInitialized,
|
||||||
nextStep,
|
nextStep,
|
||||||
mode,
|
mode,
|
||||||
cardPage,
|
cardPage,
|
||||||
|
|
@ -418,6 +538,7 @@ export function useOnboardingChecklist(): OnboardingValue {
|
||||||
show,
|
show,
|
||||||
skip,
|
skip,
|
||||||
resume,
|
resume,
|
||||||
|
acknowledge,
|
||||||
refresh,
|
refresh,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -11,9 +11,11 @@
|
||||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
|
||||||
vi.mock("react", async (orig) => (await import("../test/hookHarness")).fakeReact(await orig()));
|
vi.mock("react", async (orig) => (await import("../test/hookHarness")).fakeReact(await orig()));
|
||||||
const ctx = vi.hoisted(() => ({ pathname: "/" }));
|
const ctx = vi.hoisted(() => ({ pathname: "/", budgetReady: true }));
|
||||||
vi.mock("react-router-dom", () => ({ useLocation: () => ({ pathname: ctx.pathname }) }));
|
vi.mock("react-router-dom", () => ({ useLocation: () => ({ pathname: ctx.pathname }) }));
|
||||||
vi.mock("./useEntitlement", () => ({ useEntitlement: () => ({ allowed: true, ready: true }) }));
|
vi.mock("./useEntitlement", () => ({
|
||||||
|
useEntitlement: () => ({ allowed: true, ready: ctx.budgetReady }),
|
||||||
|
}));
|
||||||
vi.mock("../services/logService", () => ({ logInfo: vi.fn(), logWarn: vi.fn(), logError: vi.fn() }));
|
vi.mock("../services/logService", () => ({ logInfo: vi.fn(), logWarn: vi.fn(), logError: vi.fn() }));
|
||||||
vi.mock("../services/onboardingService", () => ({
|
vi.mock("../services/onboardingService", () => ({
|
||||||
getOnboardingSnapshot: vi.fn(),
|
getOnboardingSnapshot: vi.fn(),
|
||||||
|
|
@ -22,6 +24,8 @@ vi.mock("../services/onboardingService", () => ({
|
||||||
showChecklist: vi.fn(),
|
showChecklist: vi.fn(),
|
||||||
skipStep: vi.fn(),
|
skipStep: vi.fn(),
|
||||||
resumeStep: vi.fn(),
|
resumeStep: vi.fn(),
|
||||||
|
acknowledgeStep: vi.fn(),
|
||||||
|
initializeVisitSteps: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { hookHarness } from "../test/hookHarness";
|
import { hookHarness } from "../test/hookHarness";
|
||||||
|
|
@ -34,9 +38,11 @@ import {
|
||||||
import { notifyDataChanged } from "../services/dataEvents";
|
import { notifyDataChanged } from "../services/dataEvents";
|
||||||
import { logError } from "../services/logService";
|
import { logError } from "../services/logService";
|
||||||
import {
|
import {
|
||||||
|
acknowledgeStep,
|
||||||
getOnboardingSnapshot,
|
getOnboardingSnapshot,
|
||||||
hideChecklist,
|
hideChecklist,
|
||||||
initializeGuidedPath,
|
initializeGuidedPath,
|
||||||
|
initializeVisitSteps,
|
||||||
resumeStep,
|
resumeStep,
|
||||||
showChecklist,
|
showChecklist,
|
||||||
skipStep,
|
skipStep,
|
||||||
|
|
@ -50,6 +56,10 @@ const snapshot: OnboardingSnapshot = {
|
||||||
importFolderSet: true,
|
importFolderSet: true,
|
||||||
hidden: false,
|
hidden: false,
|
||||||
skipped: [],
|
skipped: [],
|
||||||
|
// Stopped after the import: the visit steps are acknowledged and initialized
|
||||||
|
// (rule of the templates, #425).
|
||||||
|
acknowledged: ["categories", "reports"],
|
||||||
|
visitStepsInitialized: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
const ready: OnboardingChecklistState = [
|
const ready: OnboardingChecklistState = [
|
||||||
|
|
@ -76,9 +86,17 @@ const actionsOfType = (type: string) =>
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
harness.clear();
|
harness.clear();
|
||||||
ctx.pathname = "/";
|
ctx.pathname = "/";
|
||||||
|
ctx.budgetReady = true;
|
||||||
vi.mocked(getOnboardingSnapshot).mockReset().mockResolvedValue(snapshot);
|
vi.mocked(getOnboardingSnapshot).mockReset().mockResolvedValue(snapshot);
|
||||||
vi.mocked(initializeGuidedPath).mockReset().mockResolvedValue({ hidden: false });
|
vi.mocked(initializeGuidedPath).mockReset().mockResolvedValue({ hidden: false });
|
||||||
for (const write of [hideChecklist, showChecklist, skipStep, resumeStep]) {
|
for (const write of [
|
||||||
|
hideChecklist,
|
||||||
|
showChecklist,
|
||||||
|
skipStep,
|
||||||
|
resumeStep,
|
||||||
|
acknowledgeStep,
|
||||||
|
initializeVisitSteps,
|
||||||
|
]) {
|
||||||
vi.mocked(write).mockReset().mockResolvedValue(undefined);
|
vi.mocked(write).mockReset().mockResolvedValue(undefined);
|
||||||
}
|
}
|
||||||
vi.mocked(logError).mockReset();
|
vi.mocked(logError).mockReset();
|
||||||
|
|
@ -192,7 +210,7 @@ describe("useOnboardingChecklist — wiring (#396)", () => {
|
||||||
expect(value.nextStep?.key).toBe("manualCategory");
|
expect(value.nextStep?.key).toBe("manualCategory");
|
||||||
expect(value.mode).toBe("banner");
|
expect(value.mode).toBe("banner");
|
||||||
expect(value.cardPage).toBeNull();
|
expect(value.cardPage).toBeNull();
|
||||||
expect(value.checklist?.done).toBe(4);
|
expect(value.checklist?.done).toBe(6);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("each action writes its preference first, then updates the state", async () => {
|
it("each action writes its preference first, then updates the state", async () => {
|
||||||
|
|
@ -217,6 +235,32 @@ describe("useOnboardingChecklist — wiring (#396)", () => {
|
||||||
expect(vi.mocked(hideChecklist).mock.invocationCallOrder[0]).toBeLessThan(hideDispatch);
|
expect(vi.mocked(hideChecklist).mock.invocationCallOrder[0]).toBeLessThan(hideDispatch);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('"Got it" writes the acknowledgement first, then updates the state, keeping the card latch (#425)', async () => {
|
||||||
|
mountWith({ ...ready, acknowledged: [], cardPage: "/import" });
|
||||||
|
ctx.pathname = "/import";
|
||||||
|
const value = render();
|
||||||
|
await value.acknowledge("categories");
|
||||||
|
expect(acknowledgeStep).toHaveBeenCalledWith("categories");
|
||||||
|
expect(harness.dispatch.mock.calls.map((c) => c[0])).toEqual([
|
||||||
|
{ type: "ACKNOWLEDGE", key: "categories" },
|
||||||
|
]);
|
||||||
|
expect(vi.mocked(acknowledgeStep).mock.invocationCallOrder[0]).toBeLessThan(
|
||||||
|
harness.dispatch.mock.invocationCallOrder[0],
|
||||||
|
);
|
||||||
|
const after = render();
|
||||||
|
expect(after.cardPage).toBe("/import");
|
||||||
|
expect(after.checklist?.items.find((i) => i.key === "categories")?.done).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a failed "Got it" leaves the state alone and logs (#425)', async () => {
|
||||||
|
mountWith({ ...ready, acknowledged: [] });
|
||||||
|
const value = render();
|
||||||
|
vi.mocked(acknowledgeStep).mockRejectedValueOnce(new Error("database is locked"));
|
||||||
|
await value.acknowledge("reports");
|
||||||
|
expect(harness.dispatch).not.toHaveBeenCalled();
|
||||||
|
expect(logError).toHaveBeenCalledWith("Onboarding: failed to acknowledge a step", "database is locked");
|
||||||
|
});
|
||||||
|
|
||||||
it("an action whose write fails leaves the state alone and logs", async () => {
|
it("an action whose write fails leaves the state alone and logs", async () => {
|
||||||
mountWith(ready);
|
mountWith(ready);
|
||||||
const value = render();
|
const value = render();
|
||||||
|
|
@ -283,3 +327,85 @@ describe("useOnboardingChecklist — what survives a render (#409)", () => {
|
||||||
expect(reports.mode).toBe("banner");
|
expect(reports.mode).toBe("banner");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("useOnboardingChecklist — the visit steps' initialization (#425)", () => {
|
||||||
|
const sixDone = { sources: 1, imports: 1, manualCategorized: 1, budgetEntries: 1, transactions: 20 };
|
||||||
|
const notInitialized: OnboardingChecklistState = {
|
||||||
|
...ready,
|
||||||
|
counters: sixDone,
|
||||||
|
acknowledged: [],
|
||||||
|
visitStepsInitialized: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
it("runs once, after a successful load with the entitlement known, then re-reads", async () => {
|
||||||
|
mountWith(notInitialized);
|
||||||
|
ctx.pathname = "/reports";
|
||||||
|
render();
|
||||||
|
harness.commit();
|
||||||
|
await flush();
|
||||||
|
expect(initializeVisitSteps).toHaveBeenCalledTimes(1);
|
||||||
|
expect(initializeVisitSteps).toHaveBeenCalledWith(true);
|
||||||
|
// The read of the mount plus the re-read of the initializer.
|
||||||
|
expect(getOnboardingSnapshot).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
|
// Later renders, the re-read not landed yet (flag still unset in state): no second run.
|
||||||
|
harness.slots[0] = notInitialized;
|
||||||
|
render();
|
||||||
|
harness.commit();
|
||||||
|
await flush();
|
||||||
|
expect(initializeVisitSteps).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an incomplete path initializes without acknowledging", async () => {
|
||||||
|
mountWith({ ...notInitialized, counters: { ...sixDone, budgetEntries: 0 } });
|
||||||
|
render();
|
||||||
|
harness.commit();
|
||||||
|
await flush();
|
||||||
|
expect(initializeVisitSteps).toHaveBeenCalledWith(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not run before budgetReady, then runs once it is known", async () => {
|
||||||
|
ctx.budgetReady = false;
|
||||||
|
mountWith(notInitialized);
|
||||||
|
render();
|
||||||
|
harness.commit();
|
||||||
|
await flush();
|
||||||
|
expect(initializeVisitSteps).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
ctx.budgetReady = true;
|
||||||
|
harness.slots[0] = notInitialized;
|
||||||
|
render();
|
||||||
|
harness.commit();
|
||||||
|
await flush();
|
||||||
|
expect(initializeVisitSteps).toHaveBeenCalledTimes(1);
|
||||||
|
expect(initializeVisitSteps).toHaveBeenCalledWith(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not run before the first successful load, nor once the flag is set", async () => {
|
||||||
|
vi.mocked(getOnboardingSnapshot).mockReturnValue(new Promise(() => {}));
|
||||||
|
render();
|
||||||
|
harness.commit();
|
||||||
|
await flush();
|
||||||
|
render();
|
||||||
|
harness.commit();
|
||||||
|
await flush();
|
||||||
|
expect(initializeVisitSteps).not.toHaveBeenCalled();
|
||||||
|
harness.unmount();
|
||||||
|
harness.clear();
|
||||||
|
vi.mocked(getOnboardingSnapshot).mockResolvedValue(snapshot);
|
||||||
|
mountWith(ready);
|
||||||
|
render();
|
||||||
|
harness.commit();
|
||||||
|
await flush();
|
||||||
|
expect(initializeVisitSteps).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shares visitStepsInitialized; before it, a complete path shows no 'Next step' banner", () => {
|
||||||
|
mountWith(notInitialized);
|
||||||
|
ctx.pathname = "/reports";
|
||||||
|
const value = render();
|
||||||
|
expect(value.visitStepsInitialized).toBe(false);
|
||||||
|
expect(value.nextStep).toBeNull();
|
||||||
|
expect(value.mode).toBe("none");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -77,6 +77,7 @@
|
||||||
"card": {
|
"card": {
|
||||||
"stepOf": "Step {{n}} of {{total}}",
|
"stepOf": "Step {{n}} of {{total}}",
|
||||||
"skip": "Skip this step",
|
"skip": "Skip this step",
|
||||||
|
"acknowledge": "Got it",
|
||||||
"done": "Done.",
|
"done": "Done.",
|
||||||
"next": "Next step: {{step}} →",
|
"next": "Next step: {{step}} →",
|
||||||
"import": {
|
"import": {
|
||||||
|
|
@ -117,8 +118,10 @@
|
||||||
"importFolder": "Choose an import folder",
|
"importFolder": "Choose an import folder",
|
||||||
"source": "Set up an import source",
|
"source": "Set up an import source",
|
||||||
"import": "Import a first file",
|
"import": "Import a first file",
|
||||||
|
"categories": "Browse categories and keywords",
|
||||||
"manualCategory": "Categorize a transaction by hand",
|
"manualCategory": "Categorize a transaction by hand",
|
||||||
"budget": "Enter a first budget"
|
"budget": "Enter a first budget",
|
||||||
|
"reports": "Browse the reports"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -77,6 +77,7 @@
|
||||||
"card": {
|
"card": {
|
||||||
"stepOf": "Étape {{n}} sur {{total}}",
|
"stepOf": "Étape {{n}} sur {{total}}",
|
||||||
"skip": "Passer cette étape",
|
"skip": "Passer cette étape",
|
||||||
|
"acknowledge": "J'ai compris",
|
||||||
"done": "Fait.",
|
"done": "Fait.",
|
||||||
"next": "Étape suivante : {{step}} →",
|
"next": "Étape suivante : {{step}} →",
|
||||||
"import": {
|
"import": {
|
||||||
|
|
@ -117,8 +118,10 @@
|
||||||
"importFolder": "Choisir un dossier d'import",
|
"importFolder": "Choisir un dossier d'import",
|
||||||
"source": "Configurer une source d'import",
|
"source": "Configurer une source d'import",
|
||||||
"import": "Importer un premier fichier",
|
"import": "Importer un premier fichier",
|
||||||
|
"categories": "Parcourir les catégories et les mots-clés",
|
||||||
"manualCategory": "Classer une transaction à la main",
|
"manualCategory": "Classer une transaction à la main",
|
||||||
"budget": "Saisir un premier budget"
|
"budget": "Saisir un premier budget",
|
||||||
|
"reports": "Parcourir les rapports"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -78,13 +78,20 @@ function budget(overrides: Partial<BudgetState> = {}) {
|
||||||
|
|
||||||
/** The path latched on /budget (card mode), as the provider shares it. */
|
/** The path latched on /budget (card mode), as the provider shares it. */
|
||||||
function path(counters: OnboardingCounters, overrides: Partial<OnboardingValue> = {}): OnboardingValue {
|
function path(counters: OnboardingCounters, overrides: Partial<OnboardingValue> = {}): OnboardingValue {
|
||||||
const checklist = computeChecklist(counters, { importFolderSet: true, budgetAllowed: true });
|
// Stopped after the import: the visit steps are acknowledged (rule of the templates, #425).
|
||||||
|
const checklist = computeChecklist(counters, {
|
||||||
|
importFolderSet: true,
|
||||||
|
budgetAllowed: true,
|
||||||
|
visitStepsInitialized: true,
|
||||||
|
acknowledged: ["categories", "reports"],
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
status: "ready",
|
status: "ready",
|
||||||
checklist,
|
checklist,
|
||||||
counters,
|
counters,
|
||||||
hidden: false,
|
hidden: false,
|
||||||
budgetReady: true,
|
budgetReady: true,
|
||||||
|
visitStepsInitialized: true,
|
||||||
nextStep: nextStep(checklist),
|
nextStep: nextStep(checklist),
|
||||||
mode: "card",
|
mode: "card",
|
||||||
cardPage: "/budget",
|
cardPage: "/budget",
|
||||||
|
|
@ -92,6 +99,7 @@ function path(counters: OnboardingCounters, overrides: Partial<OnboardingValue>
|
||||||
show: vi.fn(async () => undefined),
|
show: vi.fn(async () => undefined),
|
||||||
skip: vi.fn(async () => undefined),
|
skip: vi.fn(async () => undefined),
|
||||||
resume: vi.fn(async () => undefined),
|
resume: vi.fn(async () => undefined),
|
||||||
|
acknowledge: vi.fn(async () => undefined),
|
||||||
refresh: vi.fn(async () => undefined),
|
refresh: vi.fn(async () => undefined),
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
|
|
@ -132,7 +140,7 @@ describe("BudgetPage — the step card (#400)", () => {
|
||||||
const html = render();
|
const html = render();
|
||||||
expect(count(html, CARD)).toBe(1);
|
expect(count(html, CARD)).toBe(1);
|
||||||
expect(html.indexOf(CARD)).toBeLessThan(html.indexOf("<h1"));
|
expect(html.indexOf(CARD)).toBeLessThan(html.indexOf("<h1"));
|
||||||
expect(html).toContain("t(onboarding.card.stepOf[n=6,total=6])");
|
expect(html).toContain("t(onboarding.card.stepOf[n=7,total=8])");
|
||||||
expect(html).toContain("t(onboarding.checklist.items.budget)");
|
expect(html).toContain("t(onboarding.checklist.items.budget)");
|
||||||
expect(html).toContain("t(onboarding.card.skip)");
|
expect(html).toContain("t(onboarding.card.skip)");
|
||||||
// The grid is there, below the card.
|
// The grid is there, below the card.
|
||||||
|
|
|
||||||
|
|
@ -63,13 +63,20 @@ const BUDGET_LEFT: OnboardingCounters = {
|
||||||
|
|
||||||
/** The path latched on /budget (card mode), as the provider shares it. */
|
/** The path latched on /budget (card mode), as the provider shares it. */
|
||||||
function path(counters: OnboardingCounters, overrides: Partial<OnboardingValue> = {}): OnboardingValue {
|
function path(counters: OnboardingCounters, overrides: Partial<OnboardingValue> = {}): OnboardingValue {
|
||||||
const checklist = computeChecklist(counters, { importFolderSet: true, budgetAllowed: true });
|
// Stopped after the import: the visit steps are acknowledged (rule of the templates, #425).
|
||||||
|
const checklist = computeChecklist(counters, {
|
||||||
|
importFolderSet: true,
|
||||||
|
budgetAllowed: true,
|
||||||
|
visitStepsInitialized: true,
|
||||||
|
acknowledged: ["categories", "reports"],
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
status: "ready",
|
status: "ready",
|
||||||
checklist,
|
checklist,
|
||||||
counters,
|
counters,
|
||||||
hidden: false,
|
hidden: false,
|
||||||
budgetReady: true,
|
budgetReady: true,
|
||||||
|
visitStepsInitialized: true,
|
||||||
nextStep: nextStep(checklist),
|
nextStep: nextStep(checklist),
|
||||||
mode: "card",
|
mode: "card",
|
||||||
cardPage: "/budget",
|
cardPage: "/budget",
|
||||||
|
|
@ -77,6 +84,7 @@ function path(counters: OnboardingCounters, overrides: Partial<OnboardingValue>
|
||||||
show: vi.fn(async () => undefined),
|
show: vi.fn(async () => undefined),
|
||||||
skip: vi.fn(async () => undefined),
|
skip: vi.fn(async () => undefined),
|
||||||
resume: vi.fn(async () => undefined),
|
resume: vi.fn(async () => undefined),
|
||||||
|
acknowledge: vi.fn(async () => undefined),
|
||||||
refresh: vi.fn(async () => undefined),
|
refresh: vi.fn(async () => undefined),
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
|
|
@ -172,6 +180,8 @@ describe("BudgetPage — the grid's target and the card (#400)", () => {
|
||||||
importFolderSet: true,
|
importFolderSet: true,
|
||||||
budgetAllowed: true,
|
budgetAllowed: true,
|
||||||
skipped: ["source", "import", "manualCategory"],
|
skipped: ["source", "import", "manualCategory"],
|
||||||
|
visitStepsInitialized: true,
|
||||||
|
acknowledged: ["categories", "reports"],
|
||||||
});
|
});
|
||||||
ctx.onboarding = path(counters, { checklist, nextStep: nextStep(checklist) });
|
ctx.onboarding = path(counters, { checklist, nextStep: nextStep(checklist) });
|
||||||
const budget = ctx.budget as { state: BudgetState };
|
const budget = ctx.budget as { state: BudgetState };
|
||||||
|
|
|
||||||
|
|
@ -82,10 +82,16 @@ function path(overrides: Partial<OnboardingValue> & { transactions?: number } =
|
||||||
const c = counters(transactions);
|
const c = counters(transactions);
|
||||||
return {
|
return {
|
||||||
status: "ready",
|
status: "ready",
|
||||||
checklist: computeChecklist(c, { importFolderSet: true, budgetAllowed: true }),
|
checklist: computeChecklist(c, {
|
||||||
|
importFolderSet: true,
|
||||||
|
budgetAllowed: true,
|
||||||
|
visitStepsInitialized: true,
|
||||||
|
acknowledged: c.imports >= 1 ? ["categories", "reports"] : [],
|
||||||
|
}),
|
||||||
counters: c,
|
counters: c,
|
||||||
hidden: false,
|
hidden: false,
|
||||||
budgetReady: true,
|
budgetReady: true,
|
||||||
|
visitStepsInitialized: true,
|
||||||
nextStep: null,
|
nextStep: null,
|
||||||
mode: "banner",
|
mode: "banner",
|
||||||
cardPage: null,
|
cardPage: null,
|
||||||
|
|
@ -93,6 +99,7 @@ function path(overrides: Partial<OnboardingValue> & { transactions?: number } =
|
||||||
show: vi.fn(async () => undefined),
|
show: vi.fn(async () => undefined),
|
||||||
skip: vi.fn(async () => undefined),
|
skip: vi.fn(async () => undefined),
|
||||||
resume: vi.fn(async () => undefined),
|
resume: vi.fn(async () => undefined),
|
||||||
|
acknowledge: vi.fn(async () => undefined),
|
||||||
refresh: vi.fn(async () => undefined),
|
refresh: vi.fn(async () => undefined),
|
||||||
...rest,
|
...rest,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -98,6 +98,7 @@ export default function DashboardPage() {
|
||||||
<DashboardEmptyState
|
<DashboardEmptyState
|
||||||
checklist={checklist}
|
checklist={checklist}
|
||||||
budgetReady={onboarding.budgetReady}
|
budgetReady={onboarding.budgetReady}
|
||||||
|
visitStepsInitialized={onboarding.visitStepsInitialized}
|
||||||
hidden={onboarding.hidden}
|
hidden={onboarding.hidden}
|
||||||
onHide={onboarding.hide}
|
onHide={onboarding.hide}
|
||||||
onResume={onboarding.resume}
|
onResume={onboarding.resume}
|
||||||
|
|
|
||||||
|
|
@ -82,13 +82,20 @@ function path(
|
||||||
importFolderSet: boolean,
|
importFolderSet: boolean,
|
||||||
overrides: Partial<OnboardingValue> = {},
|
overrides: Partial<OnboardingValue> = {},
|
||||||
): OnboardingValue {
|
): OnboardingValue {
|
||||||
const checklist = computeChecklist(counters, { importFolderSet, budgetAllowed: true });
|
// A template stopped after the import has its visit steps acknowledged (#425).
|
||||||
|
const checklist = computeChecklist(counters, {
|
||||||
|
importFolderSet,
|
||||||
|
budgetAllowed: true,
|
||||||
|
visitStepsInitialized: true,
|
||||||
|
acknowledged: counters.imports >= 1 ? ["categories", "reports"] : [],
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
status: "ready",
|
status: "ready",
|
||||||
checklist,
|
checklist,
|
||||||
counters,
|
counters,
|
||||||
hidden: false,
|
hidden: false,
|
||||||
budgetReady: true,
|
budgetReady: true,
|
||||||
|
visitStepsInitialized: true,
|
||||||
nextStep: nextStep(checklist),
|
nextStep: nextStep(checklist),
|
||||||
mode: "card",
|
mode: "card",
|
||||||
cardPage: "/import",
|
cardPage: "/import",
|
||||||
|
|
@ -96,6 +103,7 @@ function path(
|
||||||
show: vi.fn(async () => undefined),
|
show: vi.fn(async () => undefined),
|
||||||
skip: vi.fn(async () => undefined),
|
skip: vi.fn(async () => undefined),
|
||||||
resume: vi.fn(async () => undefined),
|
resume: vi.fn(async () => undefined),
|
||||||
|
acknowledge: vi.fn(async () => undefined),
|
||||||
refresh: vi.fn(async () => undefined),
|
refresh: vi.fn(async () => undefined),
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
|
|
@ -136,7 +144,7 @@ describe("ImportPage — the step card (#398)", () => {
|
||||||
const html = render();
|
const html = render();
|
||||||
expect(count(html, CARD)).toBe(1);
|
expect(count(html, CARD)).toBe(1);
|
||||||
expect(html.indexOf(CARD)).toBeLessThan(html.indexOf("<h1"));
|
expect(html.indexOf(CARD)).toBeLessThan(html.indexOf("<h1"));
|
||||||
expect(html).toContain("t(onboarding.card.stepOf[n=2,total=6])");
|
expect(html).toContain("t(onboarding.card.stepOf[n=2,total=8])");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("a new profile: 'Choose a folder' is the one target, and the sentence names it", () => {
|
it("a new profile: 'Choose a folder' is the one target, and the sentence names it", () => {
|
||||||
|
|
@ -155,7 +163,7 @@ describe("ImportPage — the step card (#398)", () => {
|
||||||
harness.wizard = wizard({ importFolder: "/home/max/Relevés" });
|
harness.wizard = wizard({ importFolder: "/home/max/Relevés" });
|
||||||
harness.onboarding = path(ZERO, true);
|
harness.onboarding = path(ZERO, true);
|
||||||
const html = render();
|
const html = render();
|
||||||
expect(html).toContain("t(onboarding.card.stepOf[n=3,total=6])");
|
expect(html).toContain("t(onboarding.card.stepOf[n=3,total=8])");
|
||||||
expect(sentence(html)).toBe(
|
expect(sentence(html)).toBe(
|
||||||
"t(onboarding.card.import.noSources[folder=Relevés,refresh=t(import.empty.refresh),sample=t(import.empty.createSample)])",
|
"t(onboarding.card.import.noSources[folder=Relevés,refresh=t(import.empty.refresh),sample=t(import.empty.createSample)])",
|
||||||
);
|
);
|
||||||
|
|
@ -217,7 +225,7 @@ describe("ImportPage — the step card (#398)", () => {
|
||||||
const html = render();
|
const html = render();
|
||||||
expect(html).toMatch(/<p role="status"[^>]*>.*t\(onboarding\.card\.done\)/);
|
expect(html).toMatch(/<p role="status"[^>]*>.*t\(onboarding\.card\.done\)/);
|
||||||
expect(html).toMatch(/href="\/transactions"[^>]*>t\(onboarding\.card\.next\[step=/);
|
expect(html).toMatch(/href="\/transactions"[^>]*>t\(onboarding\.card\.next\[step=/);
|
||||||
expect(html).toContain("t(onboarding.card.stepOf[n=4,total=6])");
|
expect(html).toContain("t(onboarding.card.stepOf[n=4,total=8])");
|
||||||
expect(count(html, HIGHLIGHT)).toBe(0);
|
expect(count(html, HIGHLIGHT)).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -243,7 +251,7 @@ describe("ImportPage — the step card (#398)", () => {
|
||||||
harness.wizard = wizard({ importFolder: "/home/max/Relevés", scannedSources: SOURCES });
|
harness.wizard = wizard({ importFolder: "/home/max/Relevés", scannedSources: SOURCES });
|
||||||
harness.onboarding = path({ ...ZERO, sources: 1 }, true);
|
harness.onboarding = path({ ...ZERO, sources: 1 }, true);
|
||||||
const html = render();
|
const html = render();
|
||||||
expect(html).toContain("t(onboarding.card.stepOf[n=4,total=6])");
|
expect(html).toContain("t(onboarding.card.stepOf[n=4,total=8])");
|
||||||
expect(html).not.toContain("t(onboarding.card.done)");
|
expect(html).not.toContain("t(onboarding.card.done)");
|
||||||
expect(sentence(html)).toBe("t(onboarding.card.import.sourceList[])");
|
expect(sentence(html)).toBe("t(onboarding.card.import.sourceList[])");
|
||||||
const target = highlighted(html);
|
const target = highlighted(html);
|
||||||
|
|
|
||||||
|
|
@ -59,13 +59,20 @@ function path(
|
||||||
overrides: Partial<OnboardingValue> = {},
|
overrides: Partial<OnboardingValue> = {},
|
||||||
budgetAllowed = true,
|
budgetAllowed = true,
|
||||||
): OnboardingValue {
|
): OnboardingValue {
|
||||||
const checklist = computeChecklist(counters, { importFolderSet: true, budgetAllowed });
|
// Stopped after the import: the visit steps are acknowledged (rule of the templates, #425).
|
||||||
|
const checklist = computeChecklist(counters, {
|
||||||
|
importFolderSet: true,
|
||||||
|
budgetAllowed,
|
||||||
|
visitStepsInitialized: true,
|
||||||
|
acknowledged: ["categories", "reports"],
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
status: "ready",
|
status: "ready",
|
||||||
checklist,
|
checklist,
|
||||||
counters,
|
counters,
|
||||||
hidden: false,
|
hidden: false,
|
||||||
budgetReady: true,
|
budgetReady: true,
|
||||||
|
visitStepsInitialized: true,
|
||||||
nextStep: nextStep(checklist),
|
nextStep: nextStep(checklist),
|
||||||
mode: "card",
|
mode: "card",
|
||||||
cardPage: "/transactions",
|
cardPage: "/transactions",
|
||||||
|
|
@ -73,6 +80,7 @@ function path(
|
||||||
show: vi.fn(async () => undefined),
|
show: vi.fn(async () => undefined),
|
||||||
skip: vi.fn(async () => undefined),
|
skip: vi.fn(async () => undefined),
|
||||||
resume: vi.fn(async () => undefined),
|
resume: vi.fn(async () => undefined),
|
||||||
|
acknowledge: vi.fn(async () => undefined),
|
||||||
refresh: vi.fn(async () => undefined),
|
refresh: vi.fn(async () => undefined),
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
|
|
@ -152,7 +160,7 @@ describe("TransactionsPage — the step card (#399)", () => {
|
||||||
const html = render(GUIDED);
|
const html = render(GUIDED);
|
||||||
expect(count(html, CARD)).toBe(1);
|
expect(count(html, CARD)).toBe(1);
|
||||||
expect(html.indexOf(CARD)).toBeLessThan(html.indexOf("<h1"));
|
expect(html.indexOf(CARD)).toBeLessThan(html.indexOf("<h1"));
|
||||||
expect(html).toContain("t(onboarding.card.stepOf[n=5,total=6])");
|
expect(html).toContain("t(onboarding.card.stepOf[n=6,total=8])");
|
||||||
expect(html).toContain("t(onboarding.checklist.items.manualCategory)");
|
expect(html).toContain("t(onboarding.checklist.items.manualCategory)");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -221,7 +229,7 @@ describe("TransactionsPage — the step card (#399)", () => {
|
||||||
it("in the Free edition, the step checked ends the path", () => {
|
it("in the Free edition, the step checked ends the path", () => {
|
||||||
harness.onboarding = path(CATEGORIZED, {}, false);
|
harness.onboarding = path(CATEGORIZED, {}, false);
|
||||||
const html = render();
|
const html = render();
|
||||||
expect(html).toContain("t(onboarding.card.stepOf[n=5,total=5])");
|
expect(html).toContain("t(onboarding.card.stepOf[n=6,total=7])");
|
||||||
expect(html).toContain("t(onboarding.card.done)");
|
expect(html).toContain("t(onboarding.card.done)");
|
||||||
expect(html).toContain("t(onboarding.banner.complete)");
|
expect(html).toContain("t(onboarding.banner.complete)");
|
||||||
expect(html).not.toContain('href="/budget"');
|
expect(html).not.toContain('href="/budget"');
|
||||||
|
|
|
||||||
|
|
@ -20,9 +20,13 @@ import { subscribeDataChanged } from "./dataEvents";
|
||||||
import {
|
import {
|
||||||
ONBOARDING_CHECKLIST_HIDDEN_KEY,
|
ONBOARDING_CHECKLIST_HIDDEN_KEY,
|
||||||
ONBOARDING_GUIDED_INITIALIZED_KEY,
|
ONBOARDING_GUIDED_INITIALIZED_KEY,
|
||||||
|
ONBOARDING_VISIT_STEPS_INITIALIZED_KEY,
|
||||||
|
acknowledgeStep,
|
||||||
|
acknowledgedStepKey,
|
||||||
getOnboardingSnapshot,
|
getOnboardingSnapshot,
|
||||||
hideChecklist,
|
hideChecklist,
|
||||||
initializeGuidedPath,
|
initializeGuidedPath,
|
||||||
|
initializeVisitSteps,
|
||||||
resumeStep,
|
resumeStep,
|
||||||
showChecklist,
|
showChecklist,
|
||||||
skipStep,
|
skipStep,
|
||||||
|
|
@ -66,6 +70,11 @@ function makeFakeDb(options: FakeDbOptions = {}) {
|
||||||
.filter(([key]) => /^onboarding.skipped./.test(key))
|
.filter(([key]) => /^onboarding.skipped./.test(key))
|
||||||
.map(([key, value]) => ({ key, value }));
|
.map(([key, value]) => ({ key, value }));
|
||||||
}
|
}
|
||||||
|
if (sql.includes("LIKE 'onboarding_acknowledged_%'")) {
|
||||||
|
return Object.entries(prefs)
|
||||||
|
.filter(([key]) => /^onboarding.acknowledged./.test(key))
|
||||||
|
.map(([key, value]) => ({ key, value }));
|
||||||
|
}
|
||||||
if (sql.includes("FROM user_preferences WHERE key = $1")) {
|
if (sql.includes("FROM user_preferences WHERE key = $1")) {
|
||||||
const key = String(params?.[0]);
|
const key = String(params?.[0]);
|
||||||
return key in prefs ? [{ key, value: prefs[key], updated_at: "" }] : [];
|
return key in prefs ? [{ key, value: prefs[key], updated_at: "" }] : [];
|
||||||
|
|
@ -125,10 +134,10 @@ describe("getOnboardingSnapshot (#351, #396)", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("runs the five sub-counts in one query, plus the two preference reads and the skipped steps", async () => {
|
it("runs the five sub-counts in one query, plus the three preference reads and the skipped and acknowledged steps", async () => {
|
||||||
const { db } = useFakeDb();
|
const { db } = useFakeDb();
|
||||||
await getOnboardingSnapshot();
|
await getOnboardingSnapshot();
|
||||||
expect(db.select).toHaveBeenCalledTimes(4);
|
expect(db.select).toHaveBeenCalledTimes(6);
|
||||||
const sqls = db.select.mock.calls.map((c) => String(c[0]));
|
const sqls = db.select.mock.calls.map((c) => String(c[0]));
|
||||||
const aggregate = sqls.find((s) => s.includes("import_sources"))!;
|
const aggregate = sqls.find((s) => s.includes("import_sources"))!;
|
||||||
expect(aggregate).toContain("FROM transactions WHERE is_manually_categorized = 1");
|
expect(aggregate).toContain("FROM transactions WHERE is_manually_categorized = 1");
|
||||||
|
|
@ -138,9 +147,14 @@ describe("getOnboardingSnapshot (#351, #396)", () => {
|
||||||
.filter((c) => String(c[0]).includes("WHERE key = $1"))
|
.filter((c) => String(c[0]).includes("WHERE key = $1"))
|
||||||
.map((c) => (c[1] as unknown[])[0]);
|
.map((c) => (c[1] as unknown[])[0]);
|
||||||
expect(prefKeys).toEqual(
|
expect(prefKeys).toEqual(
|
||||||
expect.arrayContaining(["import_folder", ONBOARDING_CHECKLIST_HIDDEN_KEY]),
|
expect.arrayContaining([
|
||||||
|
"import_folder",
|
||||||
|
ONBOARDING_CHECKLIST_HIDDEN_KEY,
|
||||||
|
ONBOARDING_VISIT_STEPS_INITIALIZED_KEY,
|
||||||
|
]),
|
||||||
);
|
);
|
||||||
expect(sqls.filter((s) => s.includes("LIKE 'onboarding_skipped_%'"))).toHaveLength(1);
|
expect(sqls.filter((s) => s.includes("LIKE 'onboarding_skipped_%'"))).toHaveLength(1);
|
||||||
|
expect(sqls.filter((s) => s.includes("LIKE 'onboarding_acknowledged_%'"))).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
// The tests run without SQLite, so the rule is pinned on the query itself.
|
// The tests run without SQLite, so the rule is pinned on the query itself.
|
||||||
|
|
@ -208,6 +222,40 @@ describe("getOnboardingSnapshot (#351, #396)", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("reports no acknowledged step and visitStepsInitialized=false without preferences (#425)", async () => {
|
||||||
|
useFakeDb();
|
||||||
|
const snapshot = await getOnboardingSnapshot();
|
||||||
|
expect(snapshot.acknowledged).toEqual([]);
|
||||||
|
expect(snapshot.visitStepsInitialized).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports visitStepsInitialized=true only for the value "1" (#425)', async () => {
|
||||||
|
useFakeDb({ prefs: { [ONBOARDING_VISIT_STEPS_INITIALIZED_KEY]: "1" } });
|
||||||
|
expect((await getOnboardingSnapshot()).visitStepsInitialized).toBe(true);
|
||||||
|
useFakeDb({ prefs: { [ONBOARDING_VISIT_STEPS_INITIALIZED_KEY]: "true" } });
|
||||||
|
expect((await getOnboardingSnapshot()).visitStepsInitialized).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads the acknowledged steps in checklist order, only the visit keys at \"1\" (#425)", async () => {
|
||||||
|
useFakeDb({
|
||||||
|
prefs: {
|
||||||
|
[acknowledgedStepKey("reports")]: "1",
|
||||||
|
[acknowledgedStepKey("categories")]: "1",
|
||||||
|
[acknowledgedStepKey("budget")]: "1",
|
||||||
|
onboarding_acknowledged_dragons: "1",
|
||||||
|
onboarding_acknowledged_reports_extra: "1",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect((await getOnboardingSnapshot()).acknowledged).toEqual(["categories", "reports"]);
|
||||||
|
useFakeDb({
|
||||||
|
prefs: {
|
||||||
|
[acknowledgedStepKey("categories")]: "0",
|
||||||
|
[acknowledgedStepKey("reports")]: "1",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect((await getOnboardingSnapshot()).acknowledged).toEqual(["reports"]);
|
||||||
|
});
|
||||||
|
|
||||||
it("reads the skipped steps in checklist order", async () => {
|
it("reads the skipped steps in checklist order", async () => {
|
||||||
useFakeDb({
|
useFakeDb({
|
||||||
prefs: {
|
prefs: {
|
||||||
|
|
@ -218,7 +266,7 @@ describe("getOnboardingSnapshot (#351, #396)", () => {
|
||||||
expect((await getOnboardingSnapshot()).skipped).toEqual(["source", "budget"]);
|
expect((await getOnboardingSnapshot()).skipped).toEqual(["source", "budget"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps only the six keys: an unknown step, or what LIKE's `_` wildcard lets through, is ignored", async () => {
|
it("keeps only the eight keys: an unknown step, or what LIKE's `_` wildcard lets through, is ignored", async () => {
|
||||||
useFakeDb({
|
useFakeDb({
|
||||||
prefs: {
|
prefs: {
|
||||||
onboarding_skipped_dragons: "1",
|
onboarding_skipped_dragons: "1",
|
||||||
|
|
@ -468,3 +516,97 @@ describe("skipStep / resumeStep (#396, D3)", () => {
|
||||||
expect(listener).not.toHaveBeenCalled();
|
expect(listener).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("acknowledgeStep / initializeVisitSteps (#425)", () => {
|
||||||
|
it('acknowledgeStep writes "1" under onboarding_acknowledged_<key>, without reading first', async () => {
|
||||||
|
const fake = useFakeDb();
|
||||||
|
await acknowledgeStep("categories");
|
||||||
|
expect(fake.db.select).not.toHaveBeenCalled();
|
||||||
|
expect(fake.db.execute).toHaveBeenCalledTimes(1);
|
||||||
|
expect(fake.db.execute.mock.calls[0][1]).toEqual(["onboarding_acknowledged_categories", "1"]);
|
||||||
|
expect(fake.prefs()).toEqual({ onboarding_acknowledged_categories: "1" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("acknowledgeStep round-trips through the snapshot, as a done step, not a skipped one", async () => {
|
||||||
|
useFakeDb();
|
||||||
|
await acknowledgeStep("reports");
|
||||||
|
const snapshot = await getOnboardingSnapshot();
|
||||||
|
expect(snapshot.acknowledged).toEqual(["reports"]);
|
||||||
|
expect(snapshot.skipped).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("initializeVisitSteps(true) writes both acknowledgements, then the flag", async () => {
|
||||||
|
const fake = useFakeDb();
|
||||||
|
await initializeVisitSteps(true);
|
||||||
|
expect(fake.db.execute.mock.calls.map((c) => (c[1] as unknown[])[0])).toEqual([
|
||||||
|
"onboarding_acknowledged_categories",
|
||||||
|
"onboarding_acknowledged_reports",
|
||||||
|
ONBOARDING_VISIT_STEPS_INITIALIZED_KEY,
|
||||||
|
]);
|
||||||
|
expect(fake.prefs()).toEqual({
|
||||||
|
onboarding_acknowledged_categories: "1",
|
||||||
|
onboarding_acknowledged_reports: "1",
|
||||||
|
[ONBOARDING_VISIT_STEPS_INITIALIZED_KEY]: "1",
|
||||||
|
});
|
||||||
|
const snapshot = await getOnboardingSnapshot();
|
||||||
|
expect(snapshot.acknowledged).toEqual(["categories", "reports"]);
|
||||||
|
expect(snapshot.visitStepsInitialized).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("initializeVisitSteps(false) writes the flag only", async () => {
|
||||||
|
const fake = useFakeDb();
|
||||||
|
await initializeVisitSteps(false);
|
||||||
|
expect(fake.prefs()).toEqual({ [ONBOARDING_VISIT_STEPS_INITIALIZED_KEY]: "1" });
|
||||||
|
expect(logInfo).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("runs without any transaction: no withTransaction, no BEGIN (D16)", async () => {
|
||||||
|
const fake = useFakeDb();
|
||||||
|
await initializeVisitSteps(true);
|
||||||
|
await initializeVisitSteps(false);
|
||||||
|
expect(withTransaction).not.toHaveBeenCalled();
|
||||||
|
expect(fake.statements.filter((sql) => /^(BEGIN|COMMIT|ROLLBACK)/.test(sql))).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a failed acknowledgement leaves the flag unset, so the next opening computes again", async () => {
|
||||||
|
const fake = useFakeDb({
|
||||||
|
failOn: (_sql, params) => params[0] === "onboarding_acknowledged_reports",
|
||||||
|
});
|
||||||
|
await expect(initializeVisitSteps(true)).rejects.toThrow("database is locked");
|
||||||
|
expect(fake.prefs()).toEqual({ onboarding_acknowledged_categories: "1" });
|
||||||
|
expect((await getOnboardingSnapshot()).visitStepsInitialized).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is idempotent: run again after a stop, it ends in the same state", async () => {
|
||||||
|
const fake = useFakeDb({ prefs: { onboarding_acknowledged_categories: "1" } });
|
||||||
|
await initializeVisitSteps(true);
|
||||||
|
await initializeVisitSteps(true);
|
||||||
|
expect(fake.prefs()).toEqual({
|
||||||
|
onboarding_acknowledged_categories: "1",
|
||||||
|
onboarding_acknowledged_reports: "1",
|
||||||
|
[ONBOARDING_VISIT_STEPS_INITIALIZED_KEY]: "1",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("logs a fixed line only when it marks the visit steps done", async () => {
|
||||||
|
useFakeDb();
|
||||||
|
await initializeVisitSteps(true);
|
||||||
|
expect(vi.mocked(logInfo).mock.calls.map((c) => c[0])).toContain(
|
||||||
|
"Onboarding: the path was already complete, visit steps marked done",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("neither announces on the data channel", async () => {
|
||||||
|
useFakeDb();
|
||||||
|
const listener = vi.fn();
|
||||||
|
const unsubscribe = subscribeDataChanged(listener);
|
||||||
|
try {
|
||||||
|
await acknowledgeStep("categories");
|
||||||
|
await initializeVisitSteps(true);
|
||||||
|
await initializeVisitSteps(false);
|
||||||
|
} finally {
|
||||||
|
unsubscribe();
|
||||||
|
}
|
||||||
|
expect(listener).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import {
|
||||||
import { logInfo } from "./logService";
|
import { logInfo } from "./logService";
|
||||||
import {
|
import {
|
||||||
CHECKLIST_ITEM_KEYS,
|
CHECKLIST_ITEM_KEYS,
|
||||||
|
VISIT_STEP_KEYS,
|
||||||
type ChecklistItemKey,
|
type ChecklistItemKey,
|
||||||
type OnboardingCounters,
|
type OnboardingCounters,
|
||||||
} from "../shared/types";
|
} from "../shared/types";
|
||||||
|
|
@ -17,9 +18,11 @@ import {
|
||||||
*
|
*
|
||||||
* `getOnboardingSnapshot` reads everything the path needs in one round of
|
* `getOnboardingSnapshot` reads everything the path needs in one round of
|
||||||
* parallel reads, and writes nothing: the counters, the import folder, the
|
* parallel reads, and writes nothing: the counters, the import folder, the
|
||||||
* "hidden" preference and the skipped steps. The writes are separate, and the
|
* "hidden" preference, the skipped and acknowledged steps, and the visit-steps
|
||||||
* onboarding provider is their only caller: `initializeGuidedPath` (rule D9),
|
* flag. The writes are separate, and the onboarding provider is their only
|
||||||
* `hideChecklist` / `showChecklist`, `skipStep` / `resumeStep`. None of them is
|
* caller: `initializeGuidedPath` (rule D9), `initializeVisitSteps` (#425),
|
||||||
|
* `hideChecklist` / `showChecklist`, `skipStep` / `resumeStep`,
|
||||||
|
* `acknowledgeStep`. None of them is
|
||||||
* announced on the data channel: the provider updates its own state. No UI
|
* announced on the data channel: the provider updates its own state. No UI
|
||||||
* lives here; `computeChecklist` turns the snapshot into rows and
|
* lives here; `computeChecklist` turns the snapshot into rows and
|
||||||
* `useOnboardingChecklist` holds the state.
|
* `useOnboardingChecklist` holds the state.
|
||||||
|
|
@ -36,14 +39,26 @@ export function skippedStepKey(key: ChecklistItemKey): string {
|
||||||
return `onboarding_skipped_${key}`;
|
return `onboarding_skipped_${key}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** `user_preferences` key of a visit step done by "Got it" (#425): `onboarding_acknowledged_<key>`, value "1". */
|
||||||
|
export function acknowledgedStepKey(key: ChecklistItemKey): string {
|
||||||
|
return `onboarding_acknowledged_${key}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `user_preferences` key set to "1" once the visit steps were initialized for the profile (#425). */
|
||||||
|
export const ONBOARDING_VISIT_STEPS_INITIALIZED_KEY = "onboarding_visit_steps_initialized";
|
||||||
|
|
||||||
export interface OnboardingSnapshot {
|
export interface OnboardingSnapshot {
|
||||||
counters: OnboardingCounters;
|
counters: OnboardingCounters;
|
||||||
/** True when an import folder has been chosen. */
|
/** True when an import folder has been chosen. */
|
||||||
importFolderSet: boolean;
|
importFolderSet: boolean;
|
||||||
/** True when the path is hidden (by the user, or by rule D9). */
|
/** True when the path is hidden (by the user, or by rule D9). */
|
||||||
hidden: boolean;
|
hidden: boolean;
|
||||||
/** Skipped steps, in checklist order: only the six keys, only the value "1". */
|
/** Skipped steps, in checklist order: only the eight keys, only the value "1". */
|
||||||
skipped: ChecklistItemKey[];
|
skipped: ChecklistItemKey[];
|
||||||
|
/** Visit steps done by "Got it" (#425), in checklist order: only the visit keys, only the value "1". */
|
||||||
|
acknowledged: ChecklistItemKey[];
|
||||||
|
/** True once `initializeVisitSteps` ran for the profile (only the value "1" counts). */
|
||||||
|
visitStepsInitialized: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CountersRow {
|
interface CountersRow {
|
||||||
|
|
@ -74,6 +89,10 @@ const COUNTERS_SQL = `
|
||||||
const SKIPPED_SQL =
|
const SKIPPED_SQL =
|
||||||
"SELECT key, value FROM user_preferences WHERE key LIKE 'onboarding_skipped_%'";
|
"SELECT key, value FROM user_preferences WHERE key LIKE 'onboarding_skipped_%'";
|
||||||
|
|
||||||
|
// Same caveat: `readAcknowledgedSteps` keeps only the exact keys of the visit steps.
|
||||||
|
const ACKNOWLEDGED_SQL =
|
||||||
|
"SELECT key, value FROM user_preferences WHERE key LIKE 'onboarding_acknowledged_%'";
|
||||||
|
|
||||||
const UPSERT_PREFERENCE_SQL = `INSERT INTO user_preferences (key, value, updated_at)
|
const UPSERT_PREFERENCE_SQL = `INSERT INTO user_preferences (key, value, updated_at)
|
||||||
VALUES ($1, $2, CURRENT_TIMESTAMP)
|
VALUES ($1, $2, CURRENT_TIMESTAMP)
|
||||||
ON CONFLICT(key) DO UPDATE SET value = $2, updated_at = CURRENT_TIMESTAMP`;
|
ON CONFLICT(key) DO UPDATE SET value = $2, updated_at = CURRENT_TIMESTAMP`;
|
||||||
|
|
@ -99,23 +118,37 @@ async function readSkippedSteps(): Promise<ChecklistItemKey[]> {
|
||||||
return CHECKLIST_ITEM_KEYS.filter((key) => marked.has(skippedStepKey(key)));
|
return CHECKLIST_ITEM_KEYS.filter((key) => marked.has(skippedStepKey(key)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Acknowledged visit steps, read in one query and kept only for a visit key holding "1". */
|
||||||
|
async function readAcknowledgedSteps(): Promise<ChecklistItemKey[]> {
|
||||||
|
const db = await getDb();
|
||||||
|
const rows = await db.select<Array<{ key: string; value: string }>>(ACKNOWLEDGED_SQL);
|
||||||
|
const marked = new Set(rows.filter((row) => row.value === "1").map((row) => row.key));
|
||||||
|
return VISIT_STEP_KEYS.filter((key) => marked.has(acknowledgedStepKey(key)));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reads the counters, the import folder, the hidden flag and the skipped steps
|
* Reads the counters, the import folder, the hidden flag, the skipped and
|
||||||
* in parallel. A pure read: it never writes (rule D9 lives in
|
* acknowledged steps and the visit-steps flag in parallel. A pure read: it
|
||||||
* `initializeGuidedPath`).
|
* never writes (rule D9 lives in `initializeGuidedPath`, the visit steps'
|
||||||
|
* initialization in `initializeVisitSteps`).
|
||||||
*/
|
*/
|
||||||
export async function getOnboardingSnapshot(): Promise<OnboardingSnapshot> {
|
export async function getOnboardingSnapshot(): Promise<OnboardingSnapshot> {
|
||||||
const [counters, importFolder, hiddenPref, skipped] = await Promise.all([
|
const [counters, importFolder, hiddenPref, skipped, acknowledged, visitFlag] =
|
||||||
|
await Promise.all([
|
||||||
readCounters(),
|
readCounters(),
|
||||||
getImportFolder(),
|
getImportFolder(),
|
||||||
getPreference(ONBOARDING_CHECKLIST_HIDDEN_KEY),
|
getPreference(ONBOARDING_CHECKLIST_HIDDEN_KEY),
|
||||||
readSkippedSteps(),
|
readSkippedSteps(),
|
||||||
|
readAcknowledgedSteps(),
|
||||||
|
getPreference(ONBOARDING_VISIT_STEPS_INITIALIZED_KEY),
|
||||||
]);
|
]);
|
||||||
return {
|
return {
|
||||||
counters,
|
counters,
|
||||||
importFolderSet: importFolder !== null && importFolder !== "",
|
importFolderSet: importFolder !== null && importFolder !== "",
|
||||||
hidden: hiddenPref === "1",
|
hidden: hiddenPref === "1",
|
||||||
skipped,
|
skipped,
|
||||||
|
acknowledged,
|
||||||
|
visitStepsInitialized: visitFlag === "1",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -201,3 +234,34 @@ export async function resumeStep(key: ChecklistItemKey): Promise<void> {
|
||||||
await deletePreference(skippedStepKey(key));
|
await deletePreference(skippedStepKey(key));
|
||||||
logInfo(`Onboarding: step ${key} resumed`);
|
logInfo(`Onboarding: step ${key} resumed`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Marks a visit step done ("Got it", #425). Idempotent: an UPSERT, no read before the write. */
|
||||||
|
export async function acknowledgeStep(key: ChecklistItemKey): Promise<void> {
|
||||||
|
await setPreference(acknowledgedStepKey(key), "1");
|
||||||
|
logInfo(`Onboarding: step ${key} acknowledged`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The visit steps' initialization (#425), once per profile: the provider calls
|
||||||
|
* it after the first successful load where the Budget entitlement is known and
|
||||||
|
* the flag is unset, with `acknowledge` = `shouldAcknowledgeVisitSteps`, then
|
||||||
|
* re-reads. A profile whose other steps were all done or skipped gets both
|
||||||
|
* visit steps acknowledged, so it keeps "Getting started complete".
|
||||||
|
*
|
||||||
|
* Deliberately without a transaction (D16): under the strict lock of `db.ts`
|
||||||
|
* (#408), a service call made from a transaction's callback would wait behind
|
||||||
|
* that very lock, and every write here is idempotent anyway. The two
|
||||||
|
* acknowledgements come first, then the flag: a stop between two writes leaves
|
||||||
|
* the flag unset, and the next opening makes the same computation again.
|
||||||
|
* Not announced on the data channel.
|
||||||
|
*/
|
||||||
|
export async function initializeVisitSteps(acknowledge: boolean): Promise<void> {
|
||||||
|
if (acknowledge) {
|
||||||
|
await acknowledgeStep("categories");
|
||||||
|
await acknowledgeStep("reports");
|
||||||
|
}
|
||||||
|
await setPreference(ONBOARDING_VISIT_STEPS_INITIALIZED_KEY, "1");
|
||||||
|
if (acknowledge) {
|
||||||
|
logInfo("Onboarding: the path was already complete, visit steps marked done");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -982,19 +982,30 @@ export type ChecklistItemKey =
|
||||||
| "importFolder"
|
| "importFolder"
|
||||||
| "source"
|
| "source"
|
||||||
| "import"
|
| "import"
|
||||||
|
| "categories"
|
||||||
| "manualCategory"
|
| "manualCategory"
|
||||||
| "budget";
|
| "budget"
|
||||||
|
| "reports";
|
||||||
|
|
||||||
/** The six checklist keys, in checklist order (#396: the only valid skipped-step keys). */
|
/** The eight checklist keys, in checklist order (#396: the only valid skipped-step keys; #425: eight). */
|
||||||
export const CHECKLIST_ITEM_KEYS: readonly ChecklistItemKey[] = [
|
export const CHECKLIST_ITEM_KEYS: readonly ChecklistItemKey[] = [
|
||||||
"profile",
|
"profile",
|
||||||
"importFolder",
|
"importFolder",
|
||||||
"source",
|
"source",
|
||||||
"import",
|
"import",
|
||||||
|
"categories",
|
||||||
"manualCategory",
|
"manualCategory",
|
||||||
"budget",
|
"budget",
|
||||||
|
"reports",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The visit steps (#425): nothing in the database counts them, they are done
|
||||||
|
* by "Got it" on their card (`onboarding_acknowledged_<key>`), and the only
|
||||||
|
* valid acknowledged-step keys.
|
||||||
|
*/
|
||||||
|
export const VISIT_STEP_KEYS: readonly ChecklistItemKey[] = ["categories", "reports"];
|
||||||
|
|
||||||
/** One row of the getting-started checklist. `to` is the route the row links to. */
|
/** One row of the getting-started checklist. `to` is the route the row links to. */
|
||||||
export interface ChecklistItem {
|
export interface ChecklistItem {
|
||||||
key: ChecklistItemKey;
|
key: ChecklistItemKey;
|
||||||
|
|
@ -1002,7 +1013,10 @@ export interface ChecklistItem {
|
||||||
done: boolean;
|
done: boolean;
|
||||||
/** The user skipped the step (#396, D3). A row can be done and skipped. */
|
/** The user skipped the step (#396, D3). A row can be done and skipped. */
|
||||||
skipped: boolean;
|
skipped: boolean;
|
||||||
/** True when the feature behind the row is not part of the current edition. */
|
/**
|
||||||
|
* True when the feature behind the row is not part of the current edition,
|
||||||
|
* or for a visit step (#425) until the profile's visit steps are initialized.
|
||||||
|
*/
|
||||||
locked: boolean;
|
locked: boolean;
|
||||||
to: string;
|
to: string;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { computeChecklist } from "./onboardingChecklist";
|
import { computeChecklist, shouldAcknowledgeVisitSteps } from "./onboardingChecklist";
|
||||||
import type { ChecklistItemKey, OnboardingCounters } from "../shared/types";
|
import type { ChecklistItemKey, OnboardingCounters } from "../shared/types";
|
||||||
|
|
||||||
const zero: OnboardingCounters = {
|
const zero: OnboardingCounters = {
|
||||||
|
|
@ -10,7 +10,9 @@ const zero: OnboardingCounters = {
|
||||||
transactions: 0,
|
transactions: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
const open = { importFolderSet: false, budgetAllowed: true };
|
const open = { importFolderSet: false, budgetAllowed: true, visitStepsInitialized: true };
|
||||||
|
/** Both visit steps acknowledged (#425): the old six-step fixtures, complete again at 8/8. */
|
||||||
|
const visited: ChecklistItemKey[] = ["categories", "reports"];
|
||||||
|
|
||||||
function keys(counters: OnboardingCounters, opts = open): ChecklistItemKey[] {
|
function keys(counters: OnboardingCounters, opts = open): ChecklistItemKey[] {
|
||||||
return computeChecklist(counters, opts).items.map((i) => i.key);
|
return computeChecklist(counters, opts).items.map((i) => i.key);
|
||||||
|
|
@ -23,22 +25,24 @@ function doneKeys(counters: OnboardingCounters, opts = open): ChecklistItemKey[]
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("computeChecklist — rows (#351)", () => {
|
describe("computeChecklist — rows (#351)", () => {
|
||||||
it("lists the six rows in order", () => {
|
it("lists the eight rows in order (#425)", () => {
|
||||||
expect(keys(zero)).toEqual([
|
expect(keys(zero)).toEqual([
|
||||||
"profile",
|
"profile",
|
||||||
"importFolder",
|
"importFolder",
|
||||||
"source",
|
"source",
|
||||||
"import",
|
"import",
|
||||||
|
"categories",
|
||||||
"manualCategory",
|
"manualCategory",
|
||||||
"budget",
|
"budget",
|
||||||
|
"reports",
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("zero counters → only profile is done (1/6)", () => {
|
it("zero counters → only profile is done (1/8)", () => {
|
||||||
const list = computeChecklist(zero, open);
|
const list = computeChecklist(zero, open);
|
||||||
expect(doneKeys(zero)).toEqual(["profile"]);
|
expect(doneKeys(zero)).toEqual(["profile"]);
|
||||||
expect(list.done).toBe(1);
|
expect(list.done).toBe(1);
|
||||||
expect(list.total).toBe(6);
|
expect(list.total).toBe(8);
|
||||||
expect(list.complete).toBe(false);
|
expect(list.complete).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -79,8 +83,10 @@ describe("computeChecklist — rows (#351)", () => {
|
||||||
importFolder: "/import",
|
importFolder: "/import",
|
||||||
source: "/import",
|
source: "/import",
|
||||||
import: "/import",
|
import: "/import",
|
||||||
|
categories: "/categories",
|
||||||
manualCategory: "/transactions",
|
manualCategory: "/transactions",
|
||||||
budget: "/budget",
|
budget: "/budget",
|
||||||
|
reports: "/reports",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -90,11 +96,11 @@ describe("computeChecklist — rows (#351)", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("computeChecklist — budget gating (#351)", () => {
|
describe("computeChecklist — budget gating (#351)", () => {
|
||||||
const locked = { importFolderSet: false, budgetAllowed: false };
|
const locked = { importFolderSet: false, budgetAllowed: false, visitStepsInitialized: true };
|
||||||
|
|
||||||
it("budgetAllowed=false → total=5 and the budget row is locked", () => {
|
it("budgetAllowed=false → total=7 and the budget row is locked", () => {
|
||||||
const list = computeChecklist(zero, locked);
|
const list = computeChecklist(zero, locked);
|
||||||
expect(list.total).toBe(5);
|
expect(list.total).toBe(7);
|
||||||
const budget = list.items.find((i) => i.key === "budget")!;
|
const budget = list.items.find((i) => i.key === "budget")!;
|
||||||
expect(budget.locked).toBe(true);
|
expect(budget.locked).toBe(true);
|
||||||
expect(budget.to).toBe("/settings/users");
|
expect(budget.to).toBe("/settings/users");
|
||||||
|
|
@ -106,27 +112,27 @@ describe("computeChecklist — budget gating (#351)", () => {
|
||||||
expect(list.done).toBe(1);
|
expect(list.done).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("complete is true at 5/5 when Budget is locked", () => {
|
it("complete is true at 7/7 when Budget is locked", () => {
|
||||||
const all = { ...zero, sources: 1, imports: 1, manualCategorized: 1 };
|
const all = { ...zero, sources: 1, imports: 1, manualCategorized: 1 };
|
||||||
const list = computeChecklist(all, { ...locked, importFolderSet: true });
|
const list = computeChecklist(all, { ...locked, importFolderSet: true, acknowledged: visited });
|
||||||
expect(list.done).toBe(5);
|
expect(list.done).toBe(7);
|
||||||
expect(list.total).toBe(5);
|
expect(list.total).toBe(7);
|
||||||
expect(list.complete).toBe(true);
|
expect(list.complete).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("complete is false at 5/6 when Budget is open but empty", () => {
|
it("complete is false at 7/8 when Budget is open but empty", () => {
|
||||||
const all = { ...zero, sources: 1, imports: 1, manualCategorized: 1 };
|
const all = { ...zero, sources: 1, imports: 1, manualCategorized: 1 };
|
||||||
const list = computeChecklist(all, { ...open, importFolderSet: true });
|
const list = computeChecklist(all, { ...open, importFolderSet: true, acknowledged: visited });
|
||||||
expect(list.done).toBe(5);
|
expect(list.done).toBe(7);
|
||||||
expect(list.total).toBe(6);
|
expect(list.total).toBe(8);
|
||||||
expect(list.complete).toBe(false);
|
expect(list.complete).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("complete is true at 6/6 when Budget is open", () => {
|
it("complete is true at 8/8 when Budget is open", () => {
|
||||||
const all = { ...zero, sources: 1, imports: 1, manualCategorized: 1, budgetEntries: 1 };
|
const all = { ...zero, sources: 1, imports: 1, manualCategorized: 1, budgetEntries: 1 };
|
||||||
const list = computeChecklist(all, { ...open, importFolderSet: true });
|
const list = computeChecklist(all, { ...open, importFolderSet: true, acknowledged: visited });
|
||||||
expect(list.done).toBe(6);
|
expect(list.done).toBe(8);
|
||||||
expect(list.total).toBe(6);
|
expect(list.total).toBe(8);
|
||||||
expect(list.complete).toBe(true);
|
expect(list.complete).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -147,21 +153,31 @@ describe("computeChecklist — skipped steps (#396, D3)", () => {
|
||||||
expect(row.skipped).toBe(true);
|
expect(row.skipped).toBe(true);
|
||||||
expect(row.done).toBe(false);
|
expect(row.done).toBe(false);
|
||||||
expect(list.done).toBe(2); // profile + the skipped row
|
expect(list.done).toBe(2); // profile + the skipped row
|
||||||
expect(list.total).toBe(6);
|
expect(list.total).toBe(8);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("complete with done and skipped rows", () => {
|
it("complete with done and skipped rows", () => {
|
||||||
const list = computeChecklist(fiveDone, { ...open, importFolderSet: true, skipped: ["budget"] });
|
const list = computeChecklist(fiveDone, {
|
||||||
expect(list.done).toBe(6);
|
...open,
|
||||||
expect(list.total).toBe(6);
|
importFolderSet: true,
|
||||||
|
skipped: ["budget"],
|
||||||
|
acknowledged: visited,
|
||||||
|
});
|
||||||
|
expect(list.done).toBe(8);
|
||||||
|
expect(list.total).toBe(8);
|
||||||
expect(list.complete).toBe(true);
|
expect(list.complete).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("a locked row stays out of the total, even skipped", () => {
|
it("a locked row stays out of the total, even skipped", () => {
|
||||||
const list = computeChecklist(zero, { importFolderSet: false, budgetAllowed: false, skipped: ["budget"] });
|
const list = computeChecklist(zero, {
|
||||||
|
importFolderSet: false,
|
||||||
|
budgetAllowed: false,
|
||||||
|
visitStepsInitialized: true,
|
||||||
|
skipped: ["budget"],
|
||||||
|
});
|
||||||
const budget = list.items.find((i) => i.key === "budget")!;
|
const budget = list.items.find((i) => i.key === "budget")!;
|
||||||
expect(budget.locked).toBe(true);
|
expect(budget.locked).toBe(true);
|
||||||
expect(list.total).toBe(5);
|
expect(list.total).toBe(7);
|
||||||
expect(list.done).toBe(1);
|
expect(list.done).toBe(1);
|
||||||
expect(list.complete).toBe(false);
|
expect(list.complete).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
@ -171,12 +187,13 @@ describe("computeChecklist — skipped steps (#396, D3)", () => {
|
||||||
...open,
|
...open,
|
||||||
importFolderSet: true,
|
importFolderSet: true,
|
||||||
skipped: ["source", "import", "budget"],
|
skipped: ["source", "import", "budget"],
|
||||||
|
acknowledged: visited,
|
||||||
});
|
});
|
||||||
const source = list.items.find((i) => i.key === "source")!;
|
const source = list.items.find((i) => i.key === "source")!;
|
||||||
expect(source.done).toBe(true);
|
expect(source.done).toBe(true);
|
||||||
expect(source.skipped).toBe(true);
|
expect(source.skipped).toBe(true);
|
||||||
expect(list.done).toBe(6);
|
expect(list.done).toBe(8);
|
||||||
expect(list.total).toBe(6);
|
expect(list.total).toBe(8);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("skipping does not change the rows' links", () => {
|
it("skipping does not change the rows' links", () => {
|
||||||
|
|
@ -186,3 +203,115 @@ describe("computeChecklist — skipped steps (#396, D3)", () => {
|
||||||
expect(to).toEqual(computeChecklist(zero, open).items.map((i) => i.to));
|
expect(to).toEqual(computeChecklist(zero, open).items.map((i) => i.to));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("computeChecklist — visit steps (#425)", () => {
|
||||||
|
const sixDone = { ...zero, sources: 1, imports: 1, manualCategorized: 1, budgetEntries: 1 };
|
||||||
|
const row = (list: ReturnType<typeof computeChecklist>, key: ChecklistItemKey) =>
|
||||||
|
list.items.find((i) => i.key === key)!;
|
||||||
|
|
||||||
|
it("a visit step is to do until acknowledged; nothing in the counters marks it done", () => {
|
||||||
|
const list = computeChecklist(
|
||||||
|
{ ...sixDone, transactions: 50 },
|
||||||
|
{ ...open, importFolderSet: true },
|
||||||
|
);
|
||||||
|
expect(row(list, "categories").done).toBe(false);
|
||||||
|
expect(row(list, "reports").done).toBe(false);
|
||||||
|
expect(list.done).toBe(6);
|
||||||
|
expect(list.total).toBe(8);
|
||||||
|
expect(list.complete).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an acknowledged step is done, not skipped", () => {
|
||||||
|
const list = computeChecklist(zero, { ...open, acknowledged: ["categories"] });
|
||||||
|
expect(row(list, "categories").done).toBe(true);
|
||||||
|
expect(row(list, "categories").skipped).toBe(false);
|
||||||
|
expect(row(list, "reports").done).toBe(false);
|
||||||
|
expect(list.done).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("only a visit step can be acknowledged: another key in the option changes nothing", () => {
|
||||||
|
const list = computeChecklist(zero, { ...open, acknowledged: ["budget", "source"] });
|
||||||
|
expect(row(list, "budget").done).toBe(false);
|
||||||
|
expect(row(list, "source").done).toBe(false);
|
||||||
|
expect(list.done).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a visit step can be skipped", () => {
|
||||||
|
const list = computeChecklist(zero, { ...open, skipped: ["reports"] });
|
||||||
|
expect(row(list, "reports").skipped).toBe(true);
|
||||||
|
expect(row(list, "reports").done).toBe(false);
|
||||||
|
expect(list.done).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("before the initialization, both visit rows are locked: out of the total and of `complete`, links kept", () => {
|
||||||
|
const list = computeChecklist(sixDone, {
|
||||||
|
importFolderSet: true,
|
||||||
|
budgetAllowed: true,
|
||||||
|
visitStepsInitialized: false,
|
||||||
|
});
|
||||||
|
expect(row(list, "categories").locked).toBe(true);
|
||||||
|
expect(row(list, "reports").locked).toBe(true);
|
||||||
|
expect(row(list, "categories").to).toBe("/categories");
|
||||||
|
expect(row(list, "reports").to).toBe("/reports");
|
||||||
|
expect(list.items.filter((i) => i.locked).map((i) => i.key)).toEqual(["categories", "reports"]);
|
||||||
|
expect(list.total).toBe(6);
|
||||||
|
expect(list.done).toBe(6);
|
||||||
|
expect(list.complete).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("before the initialization, a visit row is out of the total even when acknowledged or skipped", () => {
|
||||||
|
const list = computeChecklist(zero, {
|
||||||
|
importFolderSet: false,
|
||||||
|
budgetAllowed: false,
|
||||||
|
visitStepsInitialized: false,
|
||||||
|
acknowledged: ["categories"],
|
||||||
|
skipped: ["reports"],
|
||||||
|
});
|
||||||
|
expect(list.total).toBe(5);
|
||||||
|
expect(list.done).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("shouldAcknowledgeVisitSteps (#425)", () => {
|
||||||
|
const sixDone = { ...zero, sources: 1, imports: 1, manualCategorized: 1, budgetEntries: 1 };
|
||||||
|
const before = { importFolderSet: true, budgetAllowed: true, visitStepsInitialized: false };
|
||||||
|
|
||||||
|
it("true when the six other rows are done (Base+)", () => {
|
||||||
|
expect(shouldAcknowledgeVisitSteps(computeChecklist(sixDone, before))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("true when the six other rows are done or skipped", () => {
|
||||||
|
const counters = { ...zero, sources: 1, imports: 1 };
|
||||||
|
const list = computeChecklist(counters, { ...before, skipped: ["manualCategory", "budget"] });
|
||||||
|
expect(shouldAcknowledgeVisitSteps(list)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("true in the Free edition with Budget locked and the five others done", () => {
|
||||||
|
const counters = { ...zero, sources: 1, imports: 1, manualCategorized: 1 };
|
||||||
|
const list = computeChecklist(counters, { ...before, budgetAllowed: false });
|
||||||
|
expect(shouldAcknowledgeVisitSteps(list)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("false while one row is left", () => {
|
||||||
|
const counters = { ...zero, sources: 1, imports: 1, manualCategorized: 1 };
|
||||||
|
expect(shouldAcknowledgeVisitSteps(computeChecklist(counters, before))).toBe(false);
|
||||||
|
expect(
|
||||||
|
shouldAcknowledgeVisitSteps(computeChecklist(sixDone, { ...before, importFolderSet: false })),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the visit rows do not enter the computation, locked or not", () => {
|
||||||
|
// Unlocked and to do: still true.
|
||||||
|
expect(
|
||||||
|
shouldAcknowledgeVisitSteps(computeChecklist(sixDone, { ...before, visitStepsInitialized: true })),
|
||||||
|
).toBe(true);
|
||||||
|
// Acknowledged or skipped: does not make an incomplete path complete.
|
||||||
|
const list = computeChecklist(zero, {
|
||||||
|
...before,
|
||||||
|
visitStepsInitialized: true,
|
||||||
|
acknowledged: ["categories"],
|
||||||
|
skipped: ["reports"],
|
||||||
|
});
|
||||||
|
expect(shouldAcknowledgeVisitSteps(list)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,24 @@
|
||||||
import type { ChecklistItem, ChecklistItemKey, OnboardingCounters } from "../shared/types";
|
import {
|
||||||
|
VISIT_STEP_KEYS,
|
||||||
|
type ChecklistItem,
|
||||||
|
type ChecklistItemKey,
|
||||||
|
type OnboardingCounters,
|
||||||
|
} from "../shared/types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pure computation of the getting-started checklist (#351).
|
* Pure computation of the getting-started checklist (#351, #425).
|
||||||
*
|
*
|
||||||
* Six rows in a fixed order. The `profile` row is always done (the checklist
|
* Eight rows in a fixed order. The `profile` row is always done (the checklist
|
||||||
* is only reachable from an open profile). The `budget` row is locked when the
|
* is only reachable from an open profile). The `budget` row is locked when the
|
||||||
* edition does not include Budget; a locked row is excluded from `total` and
|
* edition does not include Budget; a locked row is excluded from `total` and
|
||||||
* from `complete`, and links to the Users settings (license card) instead.
|
* from `complete`, and links to the Users settings (license card) instead.
|
||||||
*
|
*
|
||||||
|
* The two visit steps, `categories` and `reports` (#425), are done when the
|
||||||
|
* user acknowledged them ("Got it"): nothing in the database counts them.
|
||||||
|
* Until the profile's visit steps are initialized (`visitStepsInitialized`),
|
||||||
|
* they are locked the same way, so a profile whose path was complete does not
|
||||||
|
* flash a "Next step" before they are marked done; their `to` stays the page.
|
||||||
|
*
|
||||||
* A row can also be skipped by the user (#396, D3). A skipped row counts like a
|
* A row can also be skipped by the user (#396, D3). A skipped row counts like a
|
||||||
* done one, once even when it is both, unless it is locked: a locked row stays
|
* done one, once even when it is both, unless it is locked: a locked row stays
|
||||||
* out of the total whatever its state.
|
* out of the total whatever its state.
|
||||||
|
|
@ -20,13 +31,17 @@ export interface ChecklistOptions {
|
||||||
budgetAllowed: boolean;
|
budgetAllowed: boolean;
|
||||||
/** Steps the user skipped (#396). None when omitted. */
|
/** Steps the user skipped (#396). None when omitted. */
|
||||||
skipped?: readonly ChecklistItemKey[];
|
skipped?: readonly ChecklistItemKey[];
|
||||||
|
/** Visit steps the user acknowledged with "Got it" (#425). None when omitted. */
|
||||||
|
acknowledged?: readonly ChecklistItemKey[];
|
||||||
|
/** The profile's visit steps are initialized (#425): until then, they are locked. */
|
||||||
|
visitStepsInitialized: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Checklist {
|
export interface Checklist {
|
||||||
items: ChecklistItem[];
|
items: ChecklistItem[];
|
||||||
/** Number of unlocked rows that are done or skipped. */
|
/** Number of unlocked rows that are done or skipped. */
|
||||||
done: number;
|
done: number;
|
||||||
/** Number of unlocked rows (6, or 5 when Budget is locked). */
|
/** Number of unlocked rows (8, or 7 when Budget is locked; 2 fewer before the visit steps are initialized). */
|
||||||
total: number;
|
total: number;
|
||||||
/** True when every unlocked row is done or skipped. */
|
/** True when every unlocked row is done or skipped. */
|
||||||
complete: boolean;
|
complete: boolean;
|
||||||
|
|
@ -37,7 +52,9 @@ export function computeChecklist(
|
||||||
options: ChecklistOptions
|
options: ChecklistOptions
|
||||||
): Checklist {
|
): Checklist {
|
||||||
const budgetLocked = !options.budgetAllowed;
|
const budgetLocked = !options.budgetAllowed;
|
||||||
|
const visitLocked = !options.visitStepsInitialized;
|
||||||
const skipped = new Set(options.skipped ?? []);
|
const skipped = new Set(options.skipped ?? []);
|
||||||
|
const acknowledged = new Set(options.acknowledged ?? []);
|
||||||
const row = (
|
const row = (
|
||||||
key: ChecklistItemKey,
|
key: ChecklistItemKey,
|
||||||
done: boolean,
|
done: boolean,
|
||||||
|
|
@ -52,6 +69,7 @@ export function computeChecklist(
|
||||||
row("importFolder", options.importFolderSet, "/import"),
|
row("importFolder", options.importFolderSet, "/import"),
|
||||||
row("source", counters.sources >= 1, "/import"),
|
row("source", counters.sources >= 1, "/import"),
|
||||||
row("import", counters.imports >= 1, "/import"),
|
row("import", counters.imports >= 1, "/import"),
|
||||||
|
row("categories", acknowledged.has("categories"), "/categories", visitLocked),
|
||||||
row("manualCategory", counters.manualCategorized >= 1, "/transactions"),
|
row("manualCategory", counters.manualCategorized >= 1, "/transactions"),
|
||||||
row(
|
row(
|
||||||
"budget",
|
"budget",
|
||||||
|
|
@ -59,6 +77,7 @@ export function computeChecklist(
|
||||||
budgetLocked ? "/settings/users" : "/budget",
|
budgetLocked ? "/settings/users" : "/budget",
|
||||||
budgetLocked,
|
budgetLocked,
|
||||||
),
|
),
|
||||||
|
row("reports", acknowledged.has("reports"), "/reports", visitLocked),
|
||||||
];
|
];
|
||||||
|
|
||||||
const unlocked = items.filter((item) => !item.locked);
|
const unlocked = items.filter((item) => !item.locked);
|
||||||
|
|
@ -67,3 +86,15 @@ export function computeChecklist(
|
||||||
|
|
||||||
return { items, done, total, complete: done === total };
|
return { items, done, total, complete: done === total };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the one-time initialization of the visit steps (#425) marks them
|
||||||
|
* done: true when every unlocked row other than the visit steps is done or
|
||||||
|
* skipped, so a profile that had finished the path before they existed keeps
|
||||||
|
* "Getting started complete". The visit rows themselves do not count.
|
||||||
|
*/
|
||||||
|
export function shouldAcknowledgeVisitSteps(checklist: Checklist): boolean {
|
||||||
|
return checklist.items
|
||||||
|
.filter((item) => !item.locked && !VISIT_STEP_KEYS.includes(item.key))
|
||||||
|
.every((item) => item.done || item.skipped);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -47,14 +47,27 @@ const upToManual: OnboardingCounters = {
|
||||||
transactions: 20,
|
transactions: 20,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The checklist of `counters`. The visit steps are acknowledged and initialized
|
||||||
|
* unless told otherwise (rule of the templates, #425): the fixtures below are
|
||||||
|
* about the six data steps.
|
||||||
|
*/
|
||||||
const checklistOf = (
|
const checklistOf = (
|
||||||
counters: OnboardingCounters,
|
counters: OnboardingCounters,
|
||||||
options: { importFolderSet?: boolean; budgetAllowed?: boolean; skipped?: ChecklistItemKey[] } = {},
|
options: {
|
||||||
|
importFolderSet?: boolean;
|
||||||
|
budgetAllowed?: boolean;
|
||||||
|
skipped?: ChecklistItemKey[];
|
||||||
|
acknowledged?: ChecklistItemKey[];
|
||||||
|
visitStepsInitialized?: boolean;
|
||||||
|
} = {},
|
||||||
): Checklist =>
|
): Checklist =>
|
||||||
computeChecklist(counters, {
|
computeChecklist(counters, {
|
||||||
importFolderSet: options.importFolderSet ?? false,
|
importFolderSet: options.importFolderSet ?? false,
|
||||||
budgetAllowed: options.budgetAllowed ?? true,
|
budgetAllowed: options.budgetAllowed ?? true,
|
||||||
skipped: options.skipped,
|
skipped: options.skipped,
|
||||||
|
acknowledged: options.acknowledged ?? ["categories", "reports"],
|
||||||
|
visitStepsInitialized: options.visitStepsInitialized ?? true,
|
||||||
});
|
});
|
||||||
|
|
||||||
/** A row of the given step, as `computeChecklist` builds it. */
|
/** A row of the given step, as `computeChecklist` builds it. */
|
||||||
|
|
@ -112,6 +125,22 @@ describe("nextStep (#396)", () => {
|
||||||
expect(nextStep(checklistOf({ ...zero, sources: 1, imports: 1 }, { importFolderSet: true })))
|
expect(nextStep(checklistOf({ ...zero, sources: 1, imports: 1 }, { importFolderSet: true })))
|
||||||
.toMatchObject({ key: "manualCategory", to: "/transactions" });
|
.toMatchObject({ key: "manualCategory", to: "/transactions" });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("leads to the visit steps once initialized and not acknowledged (#425)", () => {
|
||||||
|
const imported = { ...zero, sources: 1, imports: 1 };
|
||||||
|
expect(nextStep(checklistOf(imported, { importFolderSet: true, acknowledged: [] })))
|
||||||
|
.toMatchObject({ key: "categories", to: "/categories" });
|
||||||
|
const allButReports = { ...upToManual, budgetEntries: 1 };
|
||||||
|
expect(
|
||||||
|
nextStep(checklistOf(allButReports, { importFolderSet: true, acknowledged: ["categories"] })),
|
||||||
|
).toMatchObject({ key: "reports", to: "/reports" });
|
||||||
|
// Before the initialization, they are passed over.
|
||||||
|
expect(
|
||||||
|
nextStep(
|
||||||
|
checklistOf(imported, { importFolderSet: true, acknowledged: [], visitStepsInitialized: false }),
|
||||||
|
)?.key,
|
||||||
|
).toBe("manualCategory");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("stepsOfPage (#396)", () => {
|
describe("stepsOfPage (#396)", () => {
|
||||||
|
|
@ -286,7 +315,7 @@ describe("bannerMode (#396)", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("waits for the Budget entitlement before announcing the end", () => {
|
it("waits for the Budget entitlement before announcing the end", () => {
|
||||||
// Until the license is known, Budget counts as locked: 5/5 could be 5/6.
|
// Until the license is known, Budget counts as locked: 7/7 could be 7/8.
|
||||||
const failClosed = checklistOf(upToManual, { importFolderSet: true, budgetAllowed: false });
|
const failClosed = checklistOf(upToManual, { importFolderSet: true, budgetAllowed: false });
|
||||||
expect(failClosed.complete).toBe(true);
|
expect(failClosed.complete).toBe(true);
|
||||||
expect(mode({ checklist: failClosed, budgetReady: false })).toBe("none");
|
expect(mode({ checklist: failClosed, budgetReady: false })).toBe("none");
|
||||||
|
|
@ -296,6 +325,17 @@ describe("bannerMode (#396)", () => {
|
||||||
it("shows the banner before the Budget entitlement is known when steps remain", () => {
|
it("shows the banner before the Budget entitlement is known when steps remain", () => {
|
||||||
expect(mode({ budgetReady: false })).toBe("banner");
|
expect(mode({ budgetReady: false })).toBe("banner");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("before the visit steps' initialization, a complete path is complete without them (#425)", () => {
|
||||||
|
// The provider passes `budgetReady && visitStepsInitialized`: until then, no end.
|
||||||
|
const beforeInit = checklistOf(
|
||||||
|
{ ...upToManual, budgetEntries: 1 },
|
||||||
|
{ importFolderSet: true, acknowledged: [], visitStepsInitialized: false },
|
||||||
|
);
|
||||||
|
expect(beforeInit.complete).toBe(true);
|
||||||
|
expect(nextStep(beforeInit)).toBeNull();
|
||||||
|
expect(mode({ checklist: beforeInit, budgetReady: false })).toBe("none");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("readOnboardingNavState (#396)", () => {
|
describe("readOnboardingNavState (#396)", () => {
|
||||||
|
|
@ -391,12 +431,12 @@ describe("cardStep (#398)", () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
it("on /import, shows the first of the page's steps not done yet, ranked among the unlocked rows", () => {
|
it("on /import, shows the first of the page's steps not done yet, ranked among the unlocked rows", () => {
|
||||||
expect(at(checklistOf(zero))).toEqual({ key: "importFolder", n: 2, total: 6 });
|
expect(at(checklistOf(zero))).toEqual({ key: "importFolder", n: 2, total: 8 });
|
||||||
expect(at(checklistOf(zero, { importFolderSet: true }))).toEqual({ key: "source", n: 3, total: 6 });
|
expect(at(checklistOf(zero, { importFolderSet: true }))).toEqual({ key: "source", n: 3, total: 8 });
|
||||||
expect(at(checklistOf({ ...zero, sources: 1 }, { importFolderSet: true }))).toEqual({
|
expect(at(checklistOf({ ...zero, sources: 1 }, { importFolderSet: true }))).toEqual({
|
||||||
key: "import",
|
key: "import",
|
||||||
n: 4,
|
n: 4,
|
||||||
total: 6,
|
total: 8,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -404,7 +444,7 @@ describe("cardStep (#398)", () => {
|
||||||
expect(at(checklistOf(upToManual, { importFolderSet: true }))).toEqual({
|
expect(at(checklistOf(upToManual, { importFolderSet: true }))).toEqual({
|
||||||
key: "import",
|
key: "import",
|
||||||
n: 4,
|
n: 4,
|
||||||
total: 6,
|
total: 8,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -418,17 +458,23 @@ describe("cardStep (#398)", () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("counts the unlocked rows only: five in the Free edition", () => {
|
it("counts the unlocked rows only: seven in the Free edition", () => {
|
||||||
expect(at(checklistOf(zero, { budgetAllowed: false }))).toEqual({
|
expect(at(checklistOf(zero, { budgetAllowed: false }))).toEqual({
|
||||||
key: "importFolder",
|
key: "importFolder",
|
||||||
n: 2,
|
n: 2,
|
||||||
total: 5,
|
total: 7,
|
||||||
});
|
});
|
||||||
expect(at(checklistOf(upToManual, { importFolderSet: true }), "/transactions")).toEqual({
|
expect(at(checklistOf(upToManual, { importFolderSet: true }), "/transactions")).toEqual({
|
||||||
key: "manualCategory",
|
key: "manualCategory",
|
||||||
n: 5,
|
n: 6,
|
||||||
total: 6,
|
total: 8,
|
||||||
});
|
});
|
||||||
|
expect(at(checklistOf(zero), "/budget")).toEqual({ key: "budget", n: 7, total: 8 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ranks the steps without the visit steps until they are initialized (#425)", () => {
|
||||||
|
const beforeInit = checklistOf(upToManual, { importFolderSet: true, visitStepsInitialized: false });
|
||||||
|
expect(at(beforeInit, "/transactions")).toEqual({ key: "manualCategory", n: 5, total: 6 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("is null on a page that guides no unlocked step", () => {
|
it("is null on a page that guides no unlocked step", () => {
|
||||||
|
|
|
||||||
|
|
@ -88,7 +88,7 @@ export interface BannerModeInput {
|
||||||
pathname: string;
|
pathname: string;
|
||||||
/** The dashboard of a profile without any transaction (D11). */
|
/** The dashboard of a profile without any transaction (D11). */
|
||||||
isEmptyDashboard: boolean;
|
isEmptyDashboard: boolean;
|
||||||
/** False until the Budget entitlement is known. */
|
/** False until the Budget entitlement is known (and, from the provider, the visit steps initialized, #425). */
|
||||||
budgetReady: boolean;
|
budgetReady: boolean;
|
||||||
/** The latched card page (`latchCardPage`). */
|
/** The latched card page (`latchCardPage`). */
|
||||||
cardPage: string | null;
|
cardPage: string | null;
|
||||||
|
|
@ -102,7 +102,9 @@ export interface BannerModeInput {
|
||||||
* then says so itself);
|
* then says so itself);
|
||||||
* - `complete` when every unlocked step is done or skipped (D4), and only once
|
* - `complete` when every unlocked step is done or skipped (D4), and only once
|
||||||
* Budget's entitlement is known: until then the checklist is computed with
|
* Budget's entitlement is known: until then the checklist is computed with
|
||||||
* Budget locked, and "complete" could be a sixth step away;
|
* Budget locked, and "complete" could be one step away; the provider passes
|
||||||
|
* `budgetReady && visitStepsInitialized` as `budgetReady` (#425), the visit
|
||||||
|
* steps being locked the same way until their initialization;
|
||||||
* - `banner` everywhere else. Its counter waits for `budgetReady` too.
|
* - `banner` everywhere else. Its counter waits for `budgetReady` too.
|
||||||
*
|
*
|
||||||
* A failed re-read after a first success keeps status `ready` (reducer), so
|
* A failed re-read after a first success keeps status `ready` (reducer), so
|
||||||
|
|
@ -209,7 +211,8 @@ export type StepCardNext =
|
||||||
* - `step`: the next step, which lives on another page;
|
* - `step`: the next step, which lives on another page;
|
||||||
* - `end`: no step left (end mode), once the Budget entitlement is known:
|
* - `end`: no step left (end mode), once the Budget entitlement is known:
|
||||||
* until then the checklist counts Budget as locked, and the end could be a
|
* until then the checklist counts Budget as locked, and the end could be a
|
||||||
* sixth step away (the rule of `bannerMode`);
|
* step away (the rule of `bannerMode`); the caller passes
|
||||||
|
* `budgetReady && visitStepsInitialized` (#425);
|
||||||
* - `wait`: nothing yet, while the entitlement is unknown at the end, or
|
* - `wait`: nothing yet, while the entitlement is unknown at the end, or
|
||||||
* while the next step is still one of this page's own: the re-read that
|
* while the next step is still one of this page's own: the re-read that
|
||||||
* follows the write that completed them (ADR 0022) has not landed, and a
|
* follows the write that completed them (ADR 0022) has not landed, and a
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue