All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 2m16s
The Répartition (split) button of the Transactions page now belongs to
the `adjustments` feature, as the unlock screen already promised
(upsell.features.adjustments). On the Free edition the button keeps its
place and its icon, carries a lock and opens the locked screen in a
dialog instead of the split window, for every row, split or not. While
the license is not ready it shows no lock and does nothing. Existing
splits stay in the data, counted by the reports and flagged by the
orange icon.
- splitGate.ts: pure splitAccessFor(gate) -> open | locked | pending,
stricter than profileGate: `allowed` does not short-circuit `ready`.
- SplitButton: stateless button that decides what a click opens.
- UpsellDialog: UpsellGate in a named modal dialog, closed by its X and
by "I already have a key".
- TransactionTable: required splitAccess prop. The split window is not
rendered once the edition resolves to Free, and stays mounted through
a license refresh; the upsell is not rendered once it resolves to Base.
- TransactionsPage reads useEntitlement("adjustments").
- ADR 0017 amendment and matrix, user guide, in-app docs (FR, EN),
architecture, entitlements header comment, both changelogs.
Tests: the predicate, the button's markup and click, the dialog, the
table's wiring through the shared hook harness (#409), the page under
the three gate states, and the new key in both locales.
Fixes #369
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
132 lines
5.3 KiB
TypeScript
132 lines
5.3 KiB
TypeScript
// TransactionTable — the getting-started target (#399). No jsdom:
|
|
// react-dom/server renders in vitest's node environment, and the markup says
|
|
// which element carries the highlight: a wrapper around the category of the
|
|
// first row shown, CategoryCombobox itself left as it is.
|
|
import { describe, it, expect, vi } from "vitest";
|
|
import { renderToStaticMarkup } from "react-dom/server";
|
|
|
|
vi.mock("react-i18next", async () => (await import("../../test/i18nMock")).i18nMock());
|
|
|
|
import TransactionTable, { TRANSACTION_CATEGORY_TARGET } from "./TransactionTable";
|
|
import type { Category, TransactionRow } from "../../shared/types";
|
|
import type { SplitAccess } from "../../shared/splitGate";
|
|
|
|
const HIGHLIGHT = 'data-onboarding-highlight="true"';
|
|
const count = (html: string, needle: string) => html.split(needle).length - 1;
|
|
|
|
const GROCERIES: Category = {
|
|
id: 7,
|
|
name: "Épicerie",
|
|
type: "expense",
|
|
is_active: true,
|
|
is_inputable: true,
|
|
sort_order: 1,
|
|
created_at: "",
|
|
};
|
|
|
|
const row = (
|
|
id: number,
|
|
description: string,
|
|
category: Category | null = null,
|
|
isSplit = false,
|
|
): TransactionRow => ({
|
|
id,
|
|
date: "2026-09-12",
|
|
description,
|
|
amount: -42.5,
|
|
category_id: category?.id ?? null,
|
|
category_name: category?.name ?? null,
|
|
category_color: null,
|
|
source_name: "Exemple Desjardins",
|
|
notes: null,
|
|
is_manually_categorized: false,
|
|
is_split: isSplit,
|
|
});
|
|
|
|
/** The guided entry: uncategorized rows only. */
|
|
const UNCATEGORIZED = [row(1, "IGA 123"), row(2, "METRO 456"), row(3, "SAQ 789")];
|
|
|
|
const markup = (rows: TransactionRow[], onboardingTarget?: boolean, splitAccess: SplitAccess = "open") =>
|
|
renderToStaticMarkup(
|
|
<TransactionTable
|
|
rows={rows}
|
|
sort={{ column: "date", direction: "desc" }}
|
|
categories={[GROCERIES]}
|
|
onSort={vi.fn()}
|
|
onCategoryChange={vi.fn()}
|
|
onNotesChange={vi.fn()}
|
|
onAddKeyword={vi.fn(async () => undefined)}
|
|
onLoadSplitChildren={vi.fn(async () => [])}
|
|
onSaveSplit={vi.fn(async () => undefined)}
|
|
onDeleteSplit={vi.fn(async () => undefined)}
|
|
splitAccess={splitAccess}
|
|
onboardingTarget={onboardingTarget}
|
|
/>,
|
|
);
|
|
|
|
/** The rows of the table's body, in order. */
|
|
const bodyRows = (html: string) => html.split("<tbody")[1].split("<tr").slice(1);
|
|
|
|
/** The highlighted wrapper around a closed combobox, whose input shows `value` (escaped). */
|
|
const ringedCategory = (value: string) =>
|
|
new RegExp(
|
|
`<div class="rounded-lg" data-onboarding-target="${TRANSACTION_CATEGORY_TARGET}" ${HIGHLIGHT}>` +
|
|
`<div class="relative"><input [^>]*role="combobox"[^>]*value="${value.replace(/[()[\]]/g, "\\$&")}"[^>]*/></div></div>`,
|
|
);
|
|
|
|
describe("TransactionTable — getting-started target (#399)", () => {
|
|
it("highlights exactly one element: a wrapper around the first row's category", () => {
|
|
const html = markup(UNCATEGORIZED, true);
|
|
expect(count(html, HIGHLIGHT)).toBe(1);
|
|
const [first, ...others] = bodyRows(html);
|
|
expect(first).toContain("IGA 123");
|
|
expect(first).toMatch(ringedCategory("t(transactions.table.noCategory)"));
|
|
expect(others).toHaveLength(2);
|
|
for (const other of others) {
|
|
expect(other).not.toContain("data-onboarding");
|
|
// Every row has the same wrapper, without the ring.
|
|
expect(other).toContain('<div class="rounded-lg"><div class="relative"><input');
|
|
}
|
|
});
|
|
|
|
it("rings the category only: a categorized first row keeps its keyword and split buttons outside", () => {
|
|
const html = markup([row(1, "IGA 123", GROCERIES), row(2, "METRO 456")], true);
|
|
expect(count(html, HIGHLIGHT)).toBe(1);
|
|
const [first] = bodyRows(html);
|
|
expect(first).toMatch(new RegExp(`${ringedCategory("Épicerie").source}<button`));
|
|
});
|
|
|
|
it("highlights nothing without the property", () => {
|
|
expect(markup(UNCATEGORIZED)).not.toContain("data-onboarding");
|
|
expect(markup(UNCATEGORIZED, false)).not.toContain("data-onboarding");
|
|
});
|
|
|
|
it("highlights nothing without a row: the card stays, without a ring", () => {
|
|
const html = markup([], true);
|
|
expect(html).toContain("t(transactions.noTransactions)");
|
|
expect(html).not.toContain("data-onboarding");
|
|
});
|
|
});
|
|
|
|
describe("TransactionTable — the Répartition button (#369)", () => {
|
|
/** A split row, then a plain one, both categorized (the button needs a category). */
|
|
const CATEGORIZED = [row(1, "COSTCO 123", GROCERIES, true), row(2, "IGA 456", GROCERIES)];
|
|
/** The rows' split buttons, in order (their title names them). */
|
|
const splitButtons = (html: string) =>
|
|
html.match(/<button type="button"[^>]*title="t\(transactions\.split(?:Adjustment|Locked\[[^"]*\])\)"[^>]*>.*?<\/button>/g) ?? [];
|
|
|
|
it("Free: every categorized row's button carries the lock, the split row keeps its orange", () => {
|
|
const buttons = splitButtons(markup(CATEGORIZED, undefined, "locked"));
|
|
expect(buttons).toHaveLength(2);
|
|
expect(buttons.every((b) => b.includes("lucide-lock"))).toBe(true);
|
|
expect(buttons[0]).toContain("text-orange-500");
|
|
expect(buttons[1]).not.toContain("text-orange-500");
|
|
});
|
|
|
|
it("Base and Premium: no lock, the split row keeps its orange", () => {
|
|
const buttons = splitButtons(markup(CATEGORIZED));
|
|
expect(buttons).toHaveLength(2);
|
|
expect(buttons.some((b) => b.includes("lucide-lock"))).toBe(false);
|
|
expect(buttons[0]).toContain("text-orange-500");
|
|
});
|
|
});
|