@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.
Files changed (57) hide show
  1. package/dist/{BlockNoteEditor-XOVNTXDY.js → BlockNoteEditor-6G27S6P3.js} +19 -19
  2. package/dist/{BlockNoteEditor-XOVNTXDY.js.map → BlockNoteEditor-6G27S6P3.js.map} +1 -1
  3. package/dist/{BlockNoteEditor-NBHDEZ7W.mjs → BlockNoteEditor-CR5KH4AI.mjs} +4 -4
  4. package/dist/{BlockNoteViewer-C2UUOBAF.js → BlockNoteViewer-5MITJ7AD.js} +3 -3
  5. package/dist/{BlockNoteViewer-C2UUOBAF.js.map → BlockNoteViewer-5MITJ7AD.js.map} +1 -1
  6. package/dist/{BlockNoteViewer-TYLVTIUD.mjs → BlockNoteViewer-NJKFUNXP.mjs} +2 -2
  7. package/dist/billing/index.d.mts +27 -0
  8. package/dist/billing/index.d.ts +27 -0
  9. package/dist/billing/index.js +697 -450
  10. package/dist/billing/index.js.map +1 -1
  11. package/dist/billing/index.mjs +282 -35
  12. package/dist/billing/index.mjs.map +1 -1
  13. package/dist/{chunk-V6CGCARD.js → chunk-4ETVCRQD.js} +7 -7
  14. package/dist/{chunk-V6CGCARD.js.map → chunk-4ETVCRQD.js.map} +1 -1
  15. package/dist/{chunk-IMSIVIIC.mjs → chunk-HVNEWDRF.mjs} +2 -2
  16. package/dist/{chunk-XNGI4HDZ.mjs → chunk-JEGL7KHK.mjs} +49 -6
  17. package/dist/{chunk-XNGI4HDZ.mjs.map → chunk-JEGL7KHK.mjs.map} +1 -1
  18. package/dist/{chunk-6J2SQZ3G.js → chunk-L7DZSTFU.js} +2 -1
  19. package/dist/chunk-L7DZSTFU.js.map +1 -0
  20. package/dist/{chunk-YN4LOVL4.js → chunk-OKGGYCO5.js} +904 -861
  21. package/dist/chunk-OKGGYCO5.js.map +1 -0
  22. package/dist/{chunk-M5QAHSCY.mjs → chunk-Q2BREXH3.mjs} +2 -1
  23. package/dist/chunk-Q2BREXH3.mjs.map +1 -0
  24. package/dist/client/index.js +4 -4
  25. package/dist/client/index.mjs +3 -3
  26. package/dist/components/index.js +4 -4
  27. package/dist/components/index.mjs +3 -3
  28. package/dist/contexts/index.js +4 -4
  29. package/dist/contexts/index.mjs +3 -3
  30. package/dist/core/index.d.mts +2 -1
  31. package/dist/core/index.d.ts +2 -1
  32. package/dist/core/index.js +2 -2
  33. package/dist/core/index.mjs +1 -1
  34. package/dist/features/help/index.js +37 -37
  35. package/dist/features/help/index.mjs +3 -3
  36. package/dist/features/tokenusage/index.js +101 -101
  37. package/dist/features/tokenusage/index.mjs +3 -3
  38. package/dist/index.js +3 -3
  39. package/dist/index.mjs +2 -2
  40. package/dist/server/index.js +3 -3
  41. package/dist/server/index.mjs +1 -1
  42. package/package.json +1 -1
  43. package/src/features/billing/i18n-keys.ts +7 -0
  44. package/src/features/billing/stripe-price/components/forms/PriceEditor.tsx +108 -2
  45. package/src/features/billing/stripe-price/components/forms/__tests__/PriceEditor.spec.tsx +182 -0
  46. package/src/features/billing/stripe-price/components/lists/PricesList.tsx +152 -25
  47. package/src/features/billing/stripe-price/components/lists/__tests__/PricesList.spec.tsx +268 -0
  48. package/src/features/billing/stripe-price/data/__tests__/stripe-price.transfer.spec.ts +265 -0
  49. package/src/features/billing/stripe-price/data/stripe-price.fields.ts +1 -0
  50. package/src/features/billing/stripe-price/data/stripe-price.transfer.ts +194 -0
  51. package/src/features/billing/stripe-price/hooks/useStripePriceTableStructure.tsx +66 -2
  52. package/dist/chunk-6J2SQZ3G.js.map +0 -1
  53. package/dist/chunk-M5QAHSCY.mjs.map +0 -1
  54. package/dist/chunk-YN4LOVL4.js.map +0 -1
  55. /package/dist/{BlockNoteEditor-NBHDEZ7W.mjs.map → BlockNoteEditor-CR5KH4AI.mjs.map} +0 -0
  56. /package/dist/{BlockNoteViewer-TYLVTIUD.mjs.map → BlockNoteViewer-NJKFUNXP.mjs.map} +0 -0
  57. /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 functions: ReactNode[] = [<PriceEditor key="create-price" productId={productId} />];
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
- <ContentListTable
45
- data={data}
46
- fields={[
47
- StripePriceFields.nickname,
48
- StripePriceFields.amount,
49
- StripePriceFields.interval,
50
- StripePriceFields.token,
51
- StripePriceFields.status,
52
- ]}
53
- tableGeneratorType={Modules.StripePrice}
54
- functions={functions}
55
- fullWidth={fullWidth}
56
- title={t("billing.admin.prices.title")}
57
- emptyState={
58
- <EmptyState
59
- icon={DollarSignIcon}
60
- title={t("billing.admin.prices.empty.title")}
61
- description={t("billing.admin.prices.empty.description")}
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
+ });