@carlonicora/nextjs-jsonapi 3.11.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-XOVNTXDY.js → BlockNoteEditor-6G27S6P3.js} +19 -19
- package/dist/{BlockNoteEditor-XOVNTXDY.js.map → BlockNoteEditor-6G27S6P3.js.map} +1 -1
- package/dist/{BlockNoteEditor-NBHDEZ7W.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-XNGI4HDZ.mjs → chunk-JEGL7KHK.mjs} +49 -6
- package/dist/{chunk-XNGI4HDZ.mjs.map → chunk-JEGL7KHK.mjs.map} +1 -1
- package/dist/{chunk-6J2SQZ3G.js → chunk-L7DZSTFU.js} +2 -1
- package/dist/chunk-L7DZSTFU.js.map +1 -0
- package/dist/{chunk-YN4LOVL4.js → chunk-OKGGYCO5.js} +904 -861
- 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.js +4 -4
- package/dist/components/index.mjs +3 -3
- package/dist/contexts/index.js +4 -4
- package/dist/contexts/index.mjs +3 -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/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-6J2SQZ3G.js.map +0 -1
- package/dist/chunk-M5QAHSCY.mjs.map +0 -1
- package/dist/chunk-YN4LOVL4.js.map +0 -1
- /package/dist/{BlockNoteEditor-NBHDEZ7W.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
|
@@ -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
|
+
});
|