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>
60 lines
2.9 KiB
TypeScript
60 lines
2.9 KiB
TypeScript
// UpsellDialog — the locked screen in a modal (#369). No jsdom: react-dom/server
|
|
// renders the markup in vitest's node environment, and calling the component as
|
|
// a function (UpsellCtas.render.test.tsx pattern) exposes the props it hands
|
|
// to UpsellGate and to its close button.
|
|
import { describe, it, expect, vi } from "vitest";
|
|
import { isValidElement, type ReactElement, type ReactNode } from "react";
|
|
import { renderToStaticMarkup } from "react-dom/server";
|
|
import { MemoryRouter } from "react-router-dom";
|
|
|
|
vi.mock("@tauri-apps/plugin-opener", () => ({ openUrl: vi.fn(() => Promise.resolve()) }));
|
|
vi.mock("react-i18next", async () => (await import("../../test/i18nMock")).i18nMock());
|
|
// The locked screen reads one thing from the license context: a revoked key (#365).
|
|
vi.mock("../../contexts/LicenseContext", () => ({ useLicenseContext: () => ({ revoked: false }) }));
|
|
|
|
import UpsellDialog from "./UpsellDialog";
|
|
import UpsellGate from "./UpsellGate";
|
|
|
|
type Props = Record<string, unknown> & { children?: ReactNode };
|
|
|
|
function find(node: ReactNode, pred: (el: ReactElement<Props>) => boolean): ReactElement<Props>[] {
|
|
if (Array.isArray(node)) return node.flatMap((n) => find(n, pred));
|
|
if (!isValidElement(node)) return [];
|
|
const el = node as ReactElement<Props>;
|
|
return [...(pred(el) ? [el] : []), ...find(el.props.children, pred)];
|
|
}
|
|
|
|
describe("UpsellDialog (#369)", () => {
|
|
it("shows the locked screen of the feature in a named modal dialog", () => {
|
|
const html = renderToStaticMarkup(
|
|
<MemoryRouter>
|
|
<UpsellDialog feature="adjustments" onClose={vi.fn()} />
|
|
</MemoryRouter>,
|
|
);
|
|
expect(html).toContain('role="dialog"');
|
|
expect(html).toContain('aria-modal="true"');
|
|
expect(html).toContain('aria-label="t(upsell.title[tier=t(license.editions.base)])"');
|
|
expect(html).toContain("t(upsell.features.adjustments)");
|
|
expect(html).toContain("t(upsell.ctaGet[tier=t(license.editions.base)])");
|
|
expect(html).toContain("t(upsell.ctaHaveKey)");
|
|
expect(html).toContain('aria-label="t(common.close)"');
|
|
});
|
|
|
|
it("closes on its X button and after 'I already have a key'", () => {
|
|
const onClose = vi.fn();
|
|
const tree = UpsellDialog({ feature: "adjustments", onClose });
|
|
const gates = find(tree, (el) => el.type === UpsellGate);
|
|
expect(gates).toHaveLength(1);
|
|
expect(gates[0].props).toMatchObject({ feature: "adjustments", requiredTier: "base" });
|
|
expect(gates[0].props.onNavigate).toBe(onClose);
|
|
const close = find(tree, (el) => el.type === "button" && el.props["aria-label"] === "t(common.close)");
|
|
expect(close).toHaveLength(1);
|
|
(close[0].props.onClick as () => void)();
|
|
expect(onClose).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("asks for the tier the feature needs, not always Base", () => {
|
|
const tree = UpsellDialog({ feature: "balance", onClose: vi.fn() });
|
|
expect(find(tree, (el) => el.type === UpsellGate)[0].props.requiredTier).toBe("premium");
|
|
});
|
|
});
|