@carlonicora/nextjs-jsonapi 3.10.0 → 3.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{BlockNoteEditor-4UAIVF3G.js → BlockNoteEditor-6G27S6P3.js} +19 -19
- package/dist/{BlockNoteEditor-4UAIVF3G.js.map → BlockNoteEditor-6G27S6P3.js.map} +1 -1
- package/dist/{BlockNoteEditor-PFCHUFTD.mjs → BlockNoteEditor-CR5KH4AI.mjs} +4 -4
- package/dist/{BlockNoteViewer-C2UUOBAF.js → BlockNoteViewer-5MITJ7AD.js} +3 -3
- package/dist/{BlockNoteViewer-C2UUOBAF.js.map → BlockNoteViewer-5MITJ7AD.js.map} +1 -1
- package/dist/{BlockNoteViewer-TYLVTIUD.mjs → BlockNoteViewer-NJKFUNXP.mjs} +2 -2
- package/dist/billing/index.d.mts +27 -0
- package/dist/billing/index.d.ts +27 -0
- package/dist/billing/index.js +697 -450
- package/dist/billing/index.js.map +1 -1
- package/dist/billing/index.mjs +282 -35
- package/dist/billing/index.mjs.map +1 -1
- package/dist/{chunk-V6CGCARD.js → chunk-4ETVCRQD.js} +7 -7
- package/dist/{chunk-V6CGCARD.js.map → chunk-4ETVCRQD.js.map} +1 -1
- package/dist/{chunk-IMSIVIIC.mjs → chunk-HVNEWDRF.mjs} +2 -2
- package/dist/{chunk-XZJJ4SZW.mjs → chunk-JEGL7KHK.mjs} +1218 -1154
- package/dist/chunk-JEGL7KHK.mjs.map +1 -0
- package/dist/{chunk-6J2SQZ3G.js → chunk-L7DZSTFU.js} +2 -1
- package/dist/chunk-L7DZSTFU.js.map +1 -0
- package/dist/{chunk-46CQ3RRQ.js → chunk-OKGGYCO5.js} +935 -871
- package/dist/chunk-OKGGYCO5.js.map +1 -0
- package/dist/{chunk-M5QAHSCY.mjs → chunk-Q2BREXH3.mjs} +2 -1
- package/dist/chunk-Q2BREXH3.mjs.map +1 -0
- package/dist/client/index.js +4 -4
- package/dist/client/index.mjs +3 -3
- package/dist/components/index.d.mts +2 -1
- package/dist/components/index.d.ts +2 -1
- package/dist/components/index.js +4 -4
- package/dist/components/index.mjs +3 -3
- package/dist/contexts/index.d.mts +18 -1
- package/dist/contexts/index.d.ts +18 -1
- package/dist/contexts/index.js +8 -4
- package/dist/contexts/index.js.map +1 -1
- package/dist/contexts/index.mjs +7 -3
- package/dist/core/index.d.mts +2 -1
- package/dist/core/index.d.ts +2 -1
- package/dist/core/index.js +2 -2
- package/dist/core/index.mjs +1 -1
- package/dist/features/help/index.js +37 -37
- package/dist/features/help/index.mjs +3 -3
- package/dist/features/tokenusage/index.js +101 -101
- package/dist/features/tokenusage/index.mjs +3 -3
- package/dist/index.js +3 -3
- package/dist/index.mjs +2 -2
- package/dist/server/index.js +3 -3
- package/dist/server/index.mjs +1 -1
- package/package.json +1 -1
- package/src/components/navigations/Breadcrumb.tsx +21 -9
- package/src/components/navigations/Header.tsx +3 -1
- package/src/contexts/BreadcrumbRootContext.tsx +28 -0
- package/src/contexts/index.ts +1 -0
- package/src/features/billing/i18n-keys.ts +7 -0
- package/src/features/billing/stripe-price/components/forms/PriceEditor.tsx +108 -2
- package/src/features/billing/stripe-price/components/forms/__tests__/PriceEditor.spec.tsx +182 -0
- package/src/features/billing/stripe-price/components/lists/PricesList.tsx +152 -25
- package/src/features/billing/stripe-price/components/lists/__tests__/PricesList.spec.tsx +268 -0
- package/src/features/billing/stripe-price/data/__tests__/stripe-price.transfer.spec.ts +265 -0
- package/src/features/billing/stripe-price/data/stripe-price.fields.ts +1 -0
- package/src/features/billing/stripe-price/data/stripe-price.transfer.ts +194 -0
- package/src/features/billing/stripe-price/hooks/useStripePriceTableStructure.tsx +66 -2
- package/dist/chunk-46CQ3RRQ.js.map +0 -1
- package/dist/chunk-6J2SQZ3G.js.map +0 -1
- package/dist/chunk-M5QAHSCY.mjs.map +0 -1
- package/dist/chunk-XZJJ4SZW.mjs.map +0 -1
- /package/dist/{BlockNoteEditor-PFCHUFTD.mjs.map → BlockNoteEditor-CR5KH4AI.mjs.map} +0 -0
- /package/dist/{BlockNoteViewer-TYLVTIUD.mjs.map → BlockNoteViewer-NJKFUNXP.mjs.map} +0 -0
- /package/dist/{chunk-IMSIVIIC.mjs.map → chunk-HVNEWDRF.mjs.map} +0 -0
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
|
2
|
+
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { ModuleRegistry } from "../../../../../../core/registry/ModuleRegistry";
|
|
4
|
+
import { configureI18n } from "../../../../../../i18n";
|
|
5
|
+
import { StripePriceSeed } from "../../../data/stripe-price.transfer";
|
|
6
|
+
import PriceEditor from "../PriceEditor";
|
|
7
|
+
|
|
8
|
+
// Hoisted so the vi.mock factories below (which run before the module body)
|
|
9
|
+
// can close over them.
|
|
10
|
+
const mocks = vi.hoisted(() => ({
|
|
11
|
+
findMany: vi.fn(),
|
|
12
|
+
createPrice: vi.fn(),
|
|
13
|
+
updatePrice: vi.fn(),
|
|
14
|
+
showToast: vi.fn(),
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
vi.mock("../../../../../feature", () => ({
|
|
18
|
+
FeatureService: { findMany: mocks.findMany },
|
|
19
|
+
}));
|
|
20
|
+
|
|
21
|
+
vi.mock("../../../../contexts/PriceContext", () => ({
|
|
22
|
+
usePriceContext: () => ({
|
|
23
|
+
createPrice: mocks.createPrice,
|
|
24
|
+
updatePrice: mocks.updatePrice,
|
|
25
|
+
productId: "product-from-context",
|
|
26
|
+
}),
|
|
27
|
+
priceLabel: (price: { nickname?: string }) => price.nickname ?? "price",
|
|
28
|
+
}));
|
|
29
|
+
|
|
30
|
+
vi.mock("../../../../../../utils/toast", () => ({
|
|
31
|
+
showToast: mocks.showToast,
|
|
32
|
+
showError: vi.fn(),
|
|
33
|
+
dismissToast: vi.fn(),
|
|
34
|
+
showCustomToast: vi.fn(),
|
|
35
|
+
}));
|
|
36
|
+
|
|
37
|
+
const CORE = { id: "feature-core", name: "Core", isCore: true };
|
|
38
|
+
const AI = { id: "feature-ai", name: "AI Assistant", isCore: false };
|
|
39
|
+
const SSO = { id: "feature-sso", name: "SSO", isCore: false };
|
|
40
|
+
|
|
41
|
+
const seed: StripePriceSeed = {
|
|
42
|
+
unitAmount: 5900, // MINOR units — the form must render 59
|
|
43
|
+
currency: "usd",
|
|
44
|
+
interval: "year",
|
|
45
|
+
intervalCount: 1,
|
|
46
|
+
usageType: "licensed",
|
|
47
|
+
nickname: "Cloned Pro",
|
|
48
|
+
description: "A cloned plan",
|
|
49
|
+
features: ["Unlimited exports"],
|
|
50
|
+
token: 500,
|
|
51
|
+
isTrial: false,
|
|
52
|
+
platformFeatures: ["AI Assistant"],
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/** The sheet is portaled, so everything is queried from document. */
|
|
56
|
+
const input = (name: string) => document.querySelector<HTMLInputElement>(`input[name="${name}"]`);
|
|
57
|
+
const selectTriggers = () => Array.from(document.querySelectorAll('[data-slot="select-trigger"]'));
|
|
58
|
+
// Base UI puts the `id` on the hidden native input it renders for the form,
|
|
59
|
+
// not on the visible control, so read the checked state from there.
|
|
60
|
+
const checkbox = (featureId: string) => document.getElementById(`feature-${featureId}`) as HTMLInputElement | null;
|
|
61
|
+
const isChecked = (featureId: string) => checkbox(featureId)?.checked === true;
|
|
62
|
+
|
|
63
|
+
/** Rendered once the async FeatureService.findMany has landed. */
|
|
64
|
+
const waitForFeatures = async () => {
|
|
65
|
+
await waitFor(() => expect(checkbox(CORE.id)).not.toBeNull());
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
// `Modules.StripePrice` resolves through the registry at render time.
|
|
69
|
+
beforeAll(() => {
|
|
70
|
+
ModuleRegistry.register("StripePrice", {
|
|
71
|
+
name: "stripe-prices",
|
|
72
|
+
pageUrl: "/administration/prices",
|
|
73
|
+
} as any);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
beforeEach(() => {
|
|
77
|
+
vi.clearAllMocks();
|
|
78
|
+
mocks.findMany.mockResolvedValue([CORE, AI, SSO]);
|
|
79
|
+
mocks.createPrice.mockImplementation(async (payload: { id: string }) => ({ id: payload.id }));
|
|
80
|
+
configureI18n({
|
|
81
|
+
useRouter: () => ({
|
|
82
|
+
push: vi.fn(),
|
|
83
|
+
replace: vi.fn(),
|
|
84
|
+
back: vi.fn(),
|
|
85
|
+
forward: vi.fn(),
|
|
86
|
+
refresh: vi.fn(),
|
|
87
|
+
prefetch: vi.fn(),
|
|
88
|
+
}),
|
|
89
|
+
useTranslations: () => (key: string) => key,
|
|
90
|
+
Link: ({ children }: any) => children,
|
|
91
|
+
usePathname: () => "/",
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe("PriceEditor — seed", () => {
|
|
96
|
+
it("renders a seeded editor in CREATE mode", async () => {
|
|
97
|
+
render(<PriceEditor forceShow productId="product-1" seed={seed} />);
|
|
98
|
+
|
|
99
|
+
expect(await screen.findByText("common.edit.create.title")).not.toBeNull();
|
|
100
|
+
expect(screen.getByTestId("modal-button-create").textContent).toContain("ui.buttons.confirm_create");
|
|
101
|
+
expect(screen.queryByText("common.edit.update.title")).toBeNull();
|
|
102
|
+
expect(screen.queryByText("billing.admin.prices.immutability.title")).toBeNull();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("pre-fills the fields from the seed, converting the amount to MAJOR units", async () => {
|
|
106
|
+
render(<PriceEditor forceShow productId="product-1" seed={seed} />);
|
|
107
|
+
await screen.findByText("common.edit.create.title");
|
|
108
|
+
|
|
109
|
+
// 5900 minor units -> 59 major units.
|
|
110
|
+
expect(input("unitAmount")?.value).toBe("59");
|
|
111
|
+
expect(input("nickname")?.value).toBe("Cloned Pro");
|
|
112
|
+
// The token pipeline is string-based end to end.
|
|
113
|
+
expect(input("token")?.value).toBe("500");
|
|
114
|
+
expect(input("intervalCount")?.value).toBe("1");
|
|
115
|
+
expect(screen.getByDisplayValue("A cloned plan")).not.toBeNull();
|
|
116
|
+
expect(screen.getByDisplayValue("Unlimited exports")).not.toBeNull();
|
|
117
|
+
|
|
118
|
+
// Trigger order: currency, interval, usageType.
|
|
119
|
+
expect(selectTriggers()[0]?.textContent).toContain("USD ($)");
|
|
120
|
+
expect(selectTriggers()[1]?.textContent).toContain("billing.admin.prices.interval.year");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("checks the seeded platform features once the late feature list lands", async () => {
|
|
124
|
+
render(<PriceEditor forceShow productId="product-1" seed={seed} />);
|
|
125
|
+
await waitForFeatures();
|
|
126
|
+
|
|
127
|
+
// The guarded re-seed: `allFeatures` resolves AFTER useForm has seeded, and
|
|
128
|
+
// EditorSheet only re-seeds when isEdit — without the catch-up effect both
|
|
129
|
+
// of these stay unchecked.
|
|
130
|
+
await waitFor(() => expect(isChecked(AI.id)).toBe(true));
|
|
131
|
+
expect(isChecked(CORE.id)).toBe(true);
|
|
132
|
+
expect(isChecked(SSO.id)).toBe(false);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("drops a platform feature this environment does not know, warns once, and does not throw", async () => {
|
|
136
|
+
const foreignSeed: StripePriceSeed = { ...seed, platformFeatures: ["AI Assistant", "Telepathy"] };
|
|
137
|
+
|
|
138
|
+
render(<PriceEditor forceShow productId="product-1" seed={foreignSeed} />);
|
|
139
|
+
await waitForFeatures();
|
|
140
|
+
|
|
141
|
+
await waitFor(() => expect(mocks.showToast).toHaveBeenCalledTimes(1));
|
|
142
|
+
expect(mocks.showToast).toHaveBeenCalledWith("billing.admin.prices.import.warnings.unknownFeatures");
|
|
143
|
+
|
|
144
|
+
// The known name still resolves; nothing extra gets checked.
|
|
145
|
+
await waitFor(() => expect(isChecked(AI.id)).toBe(true));
|
|
146
|
+
expect(isChecked(CORE.id)).toBe(true);
|
|
147
|
+
expect(isChecked(SSO.id)).toBe(false);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("submits a seeded editor as a brand-new price: fresh uuid, no Stripe identity", async () => {
|
|
151
|
+
render(<PriceEditor forceShow productId="product-1" seed={seed} />);
|
|
152
|
+
await waitForFeatures();
|
|
153
|
+
await waitFor(() => expect(isChecked(AI.id)).toBe(true));
|
|
154
|
+
|
|
155
|
+
fireEvent.click(screen.getByTestId("modal-button-create"));
|
|
156
|
+
|
|
157
|
+
await waitFor(() => expect(mocks.createPrice).toHaveBeenCalledTimes(1));
|
|
158
|
+
expect(mocks.updatePrice).not.toHaveBeenCalled();
|
|
159
|
+
|
|
160
|
+
const payload = mocks.createPrice.mock.calls[0][0];
|
|
161
|
+
expect(payload.id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i);
|
|
162
|
+
expect(payload).not.toHaveProperty("stripePriceId");
|
|
163
|
+
expect(JSON.stringify(payload)).not.toContain("stripePriceId");
|
|
164
|
+
expect(JSON.stringify(payload)).not.toContain("price_");
|
|
165
|
+
|
|
166
|
+
// The rest of the seed survives the round trip, back in MINOR units.
|
|
167
|
+
expect(payload.productId).toBe("product-1");
|
|
168
|
+
expect(payload.unitAmount).toBe(5900);
|
|
169
|
+
expect(payload.currency).toBe("usd");
|
|
170
|
+
expect(payload.recurring).toEqual({ interval: "year", intervalCount: 1, usageType: "licensed" });
|
|
171
|
+
expect(payload.token).toBe(500);
|
|
172
|
+
expect(payload.featureIds).toEqual(expect.arrayContaining([AI.id, CORE.id]));
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("does not put the editor in edit mode", async () => {
|
|
176
|
+
render(<PriceEditor forceShow productId="product-1" seed={seed} />);
|
|
177
|
+
await screen.findByText("common.edit.create.title");
|
|
178
|
+
|
|
179
|
+
expect(screen.queryByText("billing.admin.prices.immutability.title")).toBeNull();
|
|
180
|
+
expect(input("unitAmount")?.disabled).toBe(false);
|
|
181
|
+
});
|
|
182
|
+
});
|
|
@@ -1,16 +1,23 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { DollarSignIcon } from "lucide-react";
|
|
3
|
+
import { DollarSignIcon, UploadIcon } from "lucide-react";
|
|
4
4
|
import { useTranslations } from "next-intl";
|
|
5
|
-
import { ReactNode, useEffect } from "react";
|
|
6
|
-
import { ContentListTable } from "../../../../../components";
|
|
5
|
+
import { ChangeEvent, Fragment, ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
6
|
+
import { ContentListTable, errorToast } from "../../../../../components";
|
|
7
7
|
import { Modules } from "../../../../../core";
|
|
8
8
|
import { DataListRetriever, useDataListRetriever } from "../../../../../hooks";
|
|
9
|
-
import { EmptyState } from "../../../../../shadcnui";
|
|
9
|
+
import { Button, EmptyState } from "../../../../../shadcnui";
|
|
10
10
|
import { usePriceContext } from "../../../contexts/PriceContext";
|
|
11
11
|
import { StripePriceFields } from "../../data/stripe-price.fields";
|
|
12
12
|
import { StripePriceInterface } from "../../data/stripe-price.interface";
|
|
13
13
|
import { StripePriceService } from "../../data/stripe-price.service";
|
|
14
|
+
import {
|
|
15
|
+
buildStripePriceTransferDocument,
|
|
16
|
+
parseStripePriceTransferDocument,
|
|
17
|
+
priceToSeed,
|
|
18
|
+
StripePriceSeed,
|
|
19
|
+
stripePriceTransferFilename,
|
|
20
|
+
} from "../../data/stripe-price.transfer";
|
|
14
21
|
import PriceEditor from "../forms/PriceEditor";
|
|
15
22
|
|
|
16
23
|
type PricesListProps = {
|
|
@@ -27,6 +34,17 @@ type PricesListProps = {
|
|
|
27
34
|
export function PricesList({ productId, fullWidth }: PricesListProps) {
|
|
28
35
|
const t = useTranslations();
|
|
29
36
|
const { priceVersion } = usePriceContext();
|
|
37
|
+
const importInputRef = useRef<HTMLInputElement>(null);
|
|
38
|
+
|
|
39
|
+
// ONE seeded editor serves both clone and import. It is mounted on demand
|
|
40
|
+
// (`seed &&`) rather than kept mounted and toggled, because PriceEditor builds
|
|
41
|
+
// its react-hook-form defaults at construction time — a permanently mounted
|
|
42
|
+
// editor would be constructed with no seed and ignore every later one.
|
|
43
|
+
const [seed, setSeed] = useState<StripePriceSeed | undefined>(undefined);
|
|
44
|
+
// Remount key. Cloning a second row while the first clone's editor has just
|
|
45
|
+
// closed would otherwise reuse the same component instance and keep the first
|
|
46
|
+
// seed's defaults.
|
|
47
|
+
const [seedNonce, setSeedNonce] = useState(0);
|
|
30
48
|
|
|
31
49
|
const data: DataListRetriever<StripePriceInterface> = useDataListRetriever({
|
|
32
50
|
retriever: (params) => StripePriceService.listPrices({ ...params, productId }),
|
|
@@ -38,29 +56,138 @@ export function PricesList({ productId, fullWidth }: PricesListProps) {
|
|
|
38
56
|
if (priceVersion > 0) void data.refresh();
|
|
39
57
|
}, [priceVersion]);
|
|
40
58
|
|
|
41
|
-
const
|
|
59
|
+
const openSeededEditor = useCallback((next: StripePriceSeed) => {
|
|
60
|
+
setSeed(next);
|
|
61
|
+
setSeedNonce((nonce) => nonce + 1);
|
|
62
|
+
}, []);
|
|
63
|
+
|
|
64
|
+
const onClone = useCallback(
|
|
65
|
+
(price: StripePriceInterface) => openSeededEditor(priceToSeed(price)),
|
|
66
|
+
[openSeededEditor],
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Purely client-side: the document is built from the price already in memory,
|
|
71
|
+
* so there is no API round trip. Download mechanics copied verbatim from
|
|
72
|
+
* BackupCodesDialog.handleDownload (Blob -> createObjectURL -> anchor ->
|
|
73
|
+
* click -> revokeObjectURL).
|
|
74
|
+
*/
|
|
75
|
+
const onExport = useCallback((price: StripePriceInterface) => {
|
|
76
|
+
const text = JSON.stringify(buildStripePriceTransferDocument(price), null, 2);
|
|
77
|
+
const blob = new Blob([text], { type: "application/json" });
|
|
78
|
+
const url = URL.createObjectURL(blob);
|
|
79
|
+
const a = document.createElement("a");
|
|
80
|
+
a.href = url;
|
|
81
|
+
a.download = stripePriceTransferFilename(price);
|
|
82
|
+
a.click();
|
|
83
|
+
URL.revokeObjectURL(url);
|
|
84
|
+
}, []);
|
|
85
|
+
|
|
86
|
+
// Memoised: ContentListTable is `memo`-wrapped and feeds this straight into
|
|
87
|
+
// the table generator, so a fresh object every render would rebuild every
|
|
88
|
+
// column on every render.
|
|
89
|
+
const tableContext = useMemo(() => ({ onClone, onExport }), [onClone, onExport]);
|
|
90
|
+
|
|
91
|
+
const onImportFileChange = useCallback(
|
|
92
|
+
async (event: ChangeEvent<HTMLInputElement>) => {
|
|
93
|
+
const input = event.target;
|
|
94
|
+
const file = input.files?.[0];
|
|
95
|
+
// Reset immediately (EditableAvatar.tsx:119-126 does the same) so picking
|
|
96
|
+
// the SAME file twice still fires `change`. Held in `input` rather than
|
|
97
|
+
// read off the event after the await, since the handler is async.
|
|
98
|
+
input.value = "";
|
|
99
|
+
if (!file) return;
|
|
100
|
+
|
|
101
|
+
try {
|
|
102
|
+
// NOT named `document`: that shadows the global the export path uses for
|
|
103
|
+
// `document.createElement`, and a later edit that moved code between the
|
|
104
|
+
// two handlers would break silently.
|
|
105
|
+
const transfer = parseStripePriceTransferDocument(await file.text());
|
|
106
|
+
openSeededEditor(transfer.price);
|
|
107
|
+
} catch (error) {
|
|
108
|
+
// The parser's own messages are hardcoded English (it is a pure,
|
|
109
|
+
// i18n-free module), so they are logged for debugging and the user sees
|
|
110
|
+
// the translated line instead.
|
|
111
|
+
console.error("[PricesList] price import failed", error);
|
|
112
|
+
errorToast({
|
|
113
|
+
title: t("billing.admin.prices.import.errors.title"),
|
|
114
|
+
error: t("billing.admin.prices.import.errors.invalid"),
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
},
|
|
118
|
+
[openSeededEditor, t],
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
const functions: ReactNode[] = [
|
|
122
|
+
<PriceEditor
|
|
123
|
+
key="create-price"
|
|
124
|
+
productId={productId}
|
|
125
|
+
// Present ONLY to suppress navigation: without an `onSuccess`,
|
|
126
|
+
// EditorSheet.wrappedOnSubmit falls through to `onNavigate` and pushes the
|
|
127
|
+
// brand-new price's page, yanking the admin off the product they were
|
|
128
|
+
// editing. Deliberately does NOT call `data.refresh()` — PriceContext's
|
|
129
|
+
// `createPrice` already bumps `priceVersion`, and the effect above
|
|
130
|
+
// refreshes on that, so refreshing here too would fire two identical list
|
|
131
|
+
// requests for one create.
|
|
132
|
+
onSuccess={() => {}}
|
|
133
|
+
/>,
|
|
134
|
+
<Fragment key="import-price">
|
|
135
|
+
<Button variant="outline" size="sm" type="button" onClick={() => importInputRef.current?.click()}>
|
|
136
|
+
<UploadIcon />
|
|
137
|
+
{t("billing.admin.prices.actions.import")}
|
|
138
|
+
</Button>
|
|
139
|
+
<input
|
|
140
|
+
ref={importInputRef}
|
|
141
|
+
type="file"
|
|
142
|
+
accept="application/json"
|
|
143
|
+
className="hidden"
|
|
144
|
+
onChange={onImportFileChange}
|
|
145
|
+
/>
|
|
146
|
+
</Fragment>,
|
|
147
|
+
];
|
|
42
148
|
|
|
43
149
|
return (
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
150
|
+
<>
|
|
151
|
+
<ContentListTable
|
|
152
|
+
data={data}
|
|
153
|
+
fields={[
|
|
154
|
+
StripePriceFields.nickname,
|
|
155
|
+
StripePriceFields.amount,
|
|
156
|
+
StripePriceFields.interval,
|
|
157
|
+
StripePriceFields.token,
|
|
158
|
+
StripePriceFields.status,
|
|
159
|
+
StripePriceFields.actions,
|
|
160
|
+
]}
|
|
161
|
+
tableGeneratorType={Modules.StripePrice}
|
|
162
|
+
functions={functions}
|
|
163
|
+
context={tableContext}
|
|
164
|
+
fullWidth={fullWidth}
|
|
165
|
+
title={t("billing.admin.prices.title")}
|
|
166
|
+
emptyState={
|
|
167
|
+
<EmptyState
|
|
168
|
+
icon={DollarSignIcon}
|
|
169
|
+
title={t("billing.admin.prices.empty.title")}
|
|
170
|
+
description={t("billing.admin.prices.empty.description")}
|
|
171
|
+
/>
|
|
172
|
+
}
|
|
173
|
+
/>
|
|
174
|
+
{/* Passing `dialogOpen` makes EditorSheet render no trigger at all
|
|
175
|
+
(EditorSheet.tsx:265-267), so this is a headless, caller-driven sheet. */}
|
|
176
|
+
{seed && (
|
|
177
|
+
<PriceEditor
|
|
178
|
+
key={seedNonce}
|
|
179
|
+
productId={productId}
|
|
180
|
+
seed={seed}
|
|
181
|
+
dialogOpen
|
|
182
|
+
onDialogOpenChange={(open) => {
|
|
183
|
+
if (!open) setSeed(undefined);
|
|
184
|
+
}}
|
|
185
|
+
onSuccess={async () => {
|
|
186
|
+
setSeed(undefined);
|
|
187
|
+
await data.refresh();
|
|
188
|
+
}}
|
|
62
189
|
/>
|
|
63
|
-
}
|
|
64
|
-
|
|
190
|
+
)}
|
|
191
|
+
</>
|
|
65
192
|
);
|
|
66
193
|
}
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
|
2
|
+
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { ModuleRegistry } from "../../../../../../core/registry/ModuleRegistry";
|
|
4
|
+
import { configureI18n } from "../../../../../../i18n";
|
|
5
|
+
// Side-effect import: registers the "stripe-prices" table generator, which is
|
|
6
|
+
// what turns `StripePriceFields.actions` into a real column. Without it the
|
|
7
|
+
// registry has nothing to resolve and the table renders no columns at all.
|
|
8
|
+
import "../../../hooks/useStripePriceTableStructure";
|
|
9
|
+
import { PricesList } from "../PricesList";
|
|
10
|
+
|
|
11
|
+
// Hoisted so the vi.mock factories below (which run before the module body) can
|
|
12
|
+
// close over them. `retriever` is a mutable holder because the factory is
|
|
13
|
+
// evaluated at import time, long before beforeEach builds the fake retriever.
|
|
14
|
+
const mocks = vi.hoisted(() => ({
|
|
15
|
+
errorToast: vi.fn(),
|
|
16
|
+
refresh: vi.fn(),
|
|
17
|
+
retriever: { current: null as any },
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
// Only the two members PricesList takes from the barrel. Mocking it (rather
|
|
21
|
+
// than spreading the real one) keeps every feature barrel out of this suite;
|
|
22
|
+
// ContentListTable itself is the REAL component, because the assertions are
|
|
23
|
+
// about the columns it renders.
|
|
24
|
+
vi.mock("../../../../../../components", async () => {
|
|
25
|
+
const actual = await vi.importActual<typeof import("../../../../../../components/tables/ContentListTable")>(
|
|
26
|
+
"../../../../../../components/tables/ContentListTable",
|
|
27
|
+
);
|
|
28
|
+
return { ContentListTable: actual.ContentListTable, errorToast: mocks.errorToast };
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
// `useTableGenerator` stays REAL (the actions column is what is under test);
|
|
32
|
+
// only the network-backed list retriever is replaced.
|
|
33
|
+
vi.mock("../../../../../../hooks", async () => {
|
|
34
|
+
const actual = await vi.importActual<any>("../../../../../../hooks");
|
|
35
|
+
return { ...actual, useDataListRetriever: () => mocks.retriever.current };
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
vi.mock("../../../../contexts/PriceContext", () => ({
|
|
39
|
+
usePriceContext: () => ({ priceVersion: 0 }),
|
|
40
|
+
}));
|
|
41
|
+
|
|
42
|
+
// The real editor mounts a whole EditorSheet + form; here it only has to report
|
|
43
|
+
// WHICH editor was rendered and with what seed.
|
|
44
|
+
vi.mock("../../forms/PriceEditor", () => ({
|
|
45
|
+
default: (props: any) =>
|
|
46
|
+
props.seed ? (
|
|
47
|
+
<div
|
|
48
|
+
data-testid="seeded-editor"
|
|
49
|
+
data-seed={JSON.stringify(props.seed)}
|
|
50
|
+
data-dialog-open={String(!!props.dialogOpen)}
|
|
51
|
+
/>
|
|
52
|
+
) : (
|
|
53
|
+
<div data-testid="create-editor" data-has-on-success={String(typeof props.onSuccess === "function")} />
|
|
54
|
+
),
|
|
55
|
+
}));
|
|
56
|
+
|
|
57
|
+
const makePrice = (overrides: Record<string, any> = {}) => ({
|
|
58
|
+
id: "price-1",
|
|
59
|
+
// Stripe identity: must NEVER reach an exported document.
|
|
60
|
+
stripePriceId: "price_1NabcXYZ",
|
|
61
|
+
nickname: "Standard",
|
|
62
|
+
unitAmount: 2900,
|
|
63
|
+
currency: "usd",
|
|
64
|
+
priceType: "recurring",
|
|
65
|
+
recurring: { interval: "month", intervalCount: 1, usageType: "licensed" },
|
|
66
|
+
token: 100,
|
|
67
|
+
isTrial: false,
|
|
68
|
+
active: true,
|
|
69
|
+
description: "The standard plan",
|
|
70
|
+
features: ["Everything in Basic"],
|
|
71
|
+
priceFeatures: [{ id: "feature-ai", name: "AI Assistant" }],
|
|
72
|
+
...overrides,
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const prices = [makePrice(), makePrice({ id: "price-2", stripePriceId: "price_2DefGHI", nickname: "Pro" })];
|
|
76
|
+
|
|
77
|
+
const makeRetriever = (data: any[]) =>
|
|
78
|
+
({
|
|
79
|
+
data,
|
|
80
|
+
isLoaded: true,
|
|
81
|
+
ready: true,
|
|
82
|
+
next: undefined,
|
|
83
|
+
previous: undefined,
|
|
84
|
+
pageInfo: { startItem: 1, endItem: data.length },
|
|
85
|
+
total: data.length,
|
|
86
|
+
refresh: mocks.refresh,
|
|
87
|
+
addAdditionalParameter: vi.fn(),
|
|
88
|
+
removeAdditionalParameter: vi.fn(),
|
|
89
|
+
setRefreshedElement: vi.fn(),
|
|
90
|
+
removeElement: vi.fn(),
|
|
91
|
+
search: vi.fn(),
|
|
92
|
+
setReady: vi.fn(),
|
|
93
|
+
isSearch: false,
|
|
94
|
+
}) as any;
|
|
95
|
+
|
|
96
|
+
/** The hidden import input is driven by a ref, so it is queried, not clicked. */
|
|
97
|
+
const importInput = () => document.querySelector<HTMLInputElement>('input[type="file"]')!;
|
|
98
|
+
|
|
99
|
+
const cloneButtons = () => screen.queryAllByLabelText("billing.admin.prices.actions.clone");
|
|
100
|
+
const exportButtons = () => screen.queryAllByLabelText("billing.admin.prices.actions.export");
|
|
101
|
+
|
|
102
|
+
const anchorClicks: { href: string; download: string }[] = [];
|
|
103
|
+
|
|
104
|
+
// `Modules.StripePrice` resolves through the registry at render time, and the
|
|
105
|
+
// nickname column builds a URL from it.
|
|
106
|
+
beforeAll(() => {
|
|
107
|
+
ModuleRegistry.register("StripePrice", {
|
|
108
|
+
name: "stripe-prices",
|
|
109
|
+
pageUrl: "/administration/prices",
|
|
110
|
+
} as any);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
beforeEach(() => {
|
|
114
|
+
vi.clearAllMocks();
|
|
115
|
+
anchorClicks.length = 0;
|
|
116
|
+
mocks.retriever.current = makeRetriever(prices);
|
|
117
|
+
|
|
118
|
+
(URL as any).createObjectURL = vi.fn(() => "blob:price-export");
|
|
119
|
+
(URL as any).revokeObjectURL = vi.fn();
|
|
120
|
+
// jsdom does not navigate, so a real anchor click only logs "Not
|
|
121
|
+
// implemented". Spying records the href/download the download path produced.
|
|
122
|
+
vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(function (this: HTMLAnchorElement) {
|
|
123
|
+
anchorClicks.push({ href: this.href, download: this.download });
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
configureI18n({
|
|
127
|
+
useRouter: () => ({
|
|
128
|
+
push: vi.fn(),
|
|
129
|
+
replace: vi.fn(),
|
|
130
|
+
back: vi.fn(),
|
|
131
|
+
forward: vi.fn(),
|
|
132
|
+
refresh: vi.fn(),
|
|
133
|
+
prefetch: vi.fn(),
|
|
134
|
+
}),
|
|
135
|
+
useTranslations: () => (key: string) => key,
|
|
136
|
+
Link: ({ children }: any) => children,
|
|
137
|
+
usePathname: () => "/",
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
describe("PricesList — actions column", () => {
|
|
142
|
+
it("renders a clone and an export button for every row", () => {
|
|
143
|
+
render(<PricesList productId="product-1" />);
|
|
144
|
+
|
|
145
|
+
expect(cloneButtons()).toHaveLength(prices.length);
|
|
146
|
+
expect(exportButtons()).toHaveLength(prices.length);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("opens a seeded editor when a row is cloned", async () => {
|
|
150
|
+
render(<PricesList productId="product-1" />);
|
|
151
|
+
expect(screen.queryByTestId("seeded-editor")).not.toBeInTheDocument();
|
|
152
|
+
|
|
153
|
+
fireEvent.click(cloneButtons()[1]);
|
|
154
|
+
|
|
155
|
+
const editor = await screen.findByTestId("seeded-editor");
|
|
156
|
+
const seed = JSON.parse(editor.getAttribute("data-seed")!);
|
|
157
|
+
expect(seed.nickname).toBe("Pro");
|
|
158
|
+
// MINOR units survive the clone, identity does not.
|
|
159
|
+
expect(seed.unitAmount).toBe(2900);
|
|
160
|
+
expect(seed).not.toHaveProperty("stripePriceId");
|
|
161
|
+
expect(editor.getAttribute("data-dialog-open")).toBe("true");
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
describe("PricesList — export", () => {
|
|
166
|
+
it("downloads a JSON document that carries no Stripe identity", async () => {
|
|
167
|
+
render(<PricesList productId="product-1" />);
|
|
168
|
+
|
|
169
|
+
fireEvent.click(exportButtons()[0]);
|
|
170
|
+
|
|
171
|
+
expect(URL.createObjectURL).toHaveBeenCalledTimes(1);
|
|
172
|
+
const blob = (URL.createObjectURL as any).mock.calls[0][0] as Blob;
|
|
173
|
+
expect(blob.type).toBe("application/json");
|
|
174
|
+
|
|
175
|
+
const text = await blob.text();
|
|
176
|
+
expect(text).not.toContain("stripePriceId");
|
|
177
|
+
expect(text).not.toContain("price_1NabcXYZ");
|
|
178
|
+
expect(JSON.parse(text)).toMatchObject({
|
|
179
|
+
kind: "stripe-price",
|
|
180
|
+
version: 1,
|
|
181
|
+
price: { unitAmount: 2900, currency: "usd", interval: "month", nickname: "Standard" },
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
expect(anchorClicks).toEqual([{ href: "blob:price-export", download: "price-standard.json" }]);
|
|
185
|
+
expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:price-export");
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("exports without touching the API", () => {
|
|
189
|
+
render(<PricesList productId="product-1" />);
|
|
190
|
+
|
|
191
|
+
fireEvent.click(exportButtons()[0]);
|
|
192
|
+
|
|
193
|
+
expect(mocks.refresh).not.toHaveBeenCalled();
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
describe("PricesList — import", () => {
|
|
198
|
+
const upload = (contents: string, name = "price.json") => {
|
|
199
|
+
const file = new File([contents], name, { type: "application/json" });
|
|
200
|
+
fireEvent.change(importInput(), { target: { files: [file] } });
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
it("opens the seeded editor from a valid transfer document", async () => {
|
|
204
|
+
render(<PricesList productId="product-1" />);
|
|
205
|
+
|
|
206
|
+
upload(
|
|
207
|
+
JSON.stringify({
|
|
208
|
+
kind: "stripe-price",
|
|
209
|
+
version: 1,
|
|
210
|
+
price: { unitAmount: 9900, currency: "eur", interval: "year", nickname: "Imported" },
|
|
211
|
+
}),
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
const editor = await screen.findByTestId("seeded-editor");
|
|
215
|
+
expect(JSON.parse(editor.getAttribute("data-seed")!)).toMatchObject({
|
|
216
|
+
unitAmount: 9900,
|
|
217
|
+
currency: "eur",
|
|
218
|
+
interval: "year",
|
|
219
|
+
nickname: "Imported",
|
|
220
|
+
});
|
|
221
|
+
expect(mocks.errorToast).not.toHaveBeenCalled();
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it("resets the input so the same file can be picked twice", async () => {
|
|
225
|
+
render(<PricesList productId="product-1" />);
|
|
226
|
+
|
|
227
|
+
upload(
|
|
228
|
+
JSON.stringify({
|
|
229
|
+
kind: "stripe-price",
|
|
230
|
+
version: 1,
|
|
231
|
+
price: { unitAmount: 100, currency: "usd", interval: "month" },
|
|
232
|
+
}),
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
await screen.findByTestId("seeded-editor");
|
|
236
|
+
expect(importInput().value).toBe("");
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it("toasts and opens nothing when the file is garbage", async () => {
|
|
240
|
+
render(<PricesList productId="product-1" />);
|
|
241
|
+
|
|
242
|
+
upload("this is not json at all");
|
|
243
|
+
|
|
244
|
+
await waitFor(() => expect(mocks.errorToast).toHaveBeenCalledTimes(1));
|
|
245
|
+
expect(mocks.errorToast.mock.calls[0][0].title).toBe("billing.admin.prices.import.errors.title");
|
|
246
|
+
// The parser's messages are hardcoded English, so the toast carries the
|
|
247
|
+
// translated line instead and the raw error only reaches the console.
|
|
248
|
+
expect(mocks.errorToast.mock.calls[0][0].error).toBe("billing.admin.prices.import.errors.invalid");
|
|
249
|
+
expect(screen.queryByTestId("seeded-editor")).not.toBeInTheDocument();
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it("toasts and opens nothing when the JSON is not a price transfer document", async () => {
|
|
253
|
+
render(<PricesList productId="product-1" />);
|
|
254
|
+
|
|
255
|
+
upload(JSON.stringify({ kind: "something-else", version: 1, price: {} }));
|
|
256
|
+
|
|
257
|
+
await waitFor(() => expect(mocks.errorToast).toHaveBeenCalledTimes(1));
|
|
258
|
+
expect(screen.queryByTestId("seeded-editor")).not.toBeInTheDocument();
|
|
259
|
+
});
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
describe("PricesList — create", () => {
|
|
263
|
+
it("gives the create editor an onSuccess, which is what suppresses the navigation", () => {
|
|
264
|
+
render(<PricesList productId="product-1" />);
|
|
265
|
+
|
|
266
|
+
expect(screen.getByTestId("create-editor")).toHaveAttribute("data-has-on-success", "true");
|
|
267
|
+
});
|
|
268
|
+
});
|