@cosmicdrift/kumiko-renderer-web 0.194.0 → 0.196.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/package.json +4 -4
- package/src/__tests__/create-app.test.tsx +9 -1
- package/src/__tests__/entity-edit-redirect.test.tsx +32 -0
- package/src/__tests__/render-edit.test.tsx +394 -1
- package/src/app/__tests__/plain-content-editor.test.tsx +24 -3
- package/src/app/__tests__/rich-content-editor.test.tsx +1 -0
- package/src/app/__tests__/tiptap-editor.test.tsx +53 -8
- package/src/app/plain-content-editor.tsx +9 -8
- package/src/app/tiptap-editor.tsx +34 -16
- package/src/index.ts +1 -0
- package/src/layout/__tests__/workspace-switcher.test.tsx +22 -2
- package/src/layout/nav-tree.tsx +8 -0
- package/src/layout/workspace-switcher.tsx +6 -0
- package/src/lib/__tests__/resize-image.test.ts +50 -6
- package/src/lib/accept-attr.ts +5 -0
- package/src/lib/resize-image.ts +6 -1
- package/src/primitives/__tests__/embedded-list-input.test.tsx +43 -2
- package/src/primitives/embedded-list-input.tsx +21 -8
- package/src/primitives/file-upload.tsx +1 -6
- package/src/primitives/index.tsx +7 -0
- package/src/primitives/use-narrow-viewport.ts +28 -0
- package/src/widgets/__tests__/drawer.test.tsx +22 -0
- package/src/widgets/__tests__/upload-zone.test.tsx +27 -1
- package/src/widgets/drawer.tsx +16 -6
- package/src/widgets/infinity-list.tsx +33 -5
- package/src/widgets/upload-zone.tsx +7 -8
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer-web",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.196.0",
|
|
4
4
|
"description": "Web-platform bindings for @cosmicdrift/kumiko-renderer. HTML default-primitives, browser history-based navigation, EventSource-backed live events, and a one-call createKumikoApp that mounts the whole stack via react-dom.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -16,9 +16,9 @@
|
|
|
16
16
|
"./styles.css": "./src/styles.css"
|
|
17
17
|
},
|
|
18
18
|
"dependencies": {
|
|
19
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
20
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
21
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
19
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.196.0",
|
|
20
|
+
"@cosmicdrift/kumiko-headless": "0.196.0",
|
|
21
|
+
"@cosmicdrift/kumiko-renderer": "0.196.0",
|
|
22
22
|
"@radix-ui/react-dialog": "^1.1.15",
|
|
23
23
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
|
24
24
|
"@radix-ui/react-label": "^2.1.8",
|
|
@@ -327,7 +327,15 @@ describe("createKumikoApp", () => {
|
|
|
327
327
|
}
|
|
328
328
|
function EditorProbe(): ReactNode {
|
|
329
329
|
const Editor = useContentEditor("rich");
|
|
330
|
-
return
|
|
330
|
+
return (
|
|
331
|
+
<Editor
|
|
332
|
+
id="ca-rich-editor-probe"
|
|
333
|
+
value="hello"
|
|
334
|
+
onChange={() => {}}
|
|
335
|
+
variables={[]}
|
|
336
|
+
readOnly={false}
|
|
337
|
+
/>
|
|
338
|
+
);
|
|
331
339
|
}
|
|
332
340
|
const probeSchema: FeatureSchema = {
|
|
333
341
|
featureName: "tasks",
|
|
@@ -74,6 +74,38 @@ describe("entityEdit redirect (#1942)", () => {
|
|
|
74
74
|
await waitFor(() => expect(navigated).toEqual([{ screenId: "product-detail" }]));
|
|
75
75
|
});
|
|
76
76
|
|
|
77
|
+
test("create: successful save with redirect set carries the newly created entityId along (#1945)", async () => {
|
|
78
|
+
const navigated: NavTarget[] = [];
|
|
79
|
+
const dispatcher = createMockDispatcher({
|
|
80
|
+
write: (async () => ({
|
|
81
|
+
isSuccess: true,
|
|
82
|
+
data: { id: "new-99" },
|
|
83
|
+
})) as unknown as Dispatcher["write"],
|
|
84
|
+
});
|
|
85
|
+
render(
|
|
86
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
87
|
+
<NavProvider
|
|
88
|
+
value={{
|
|
89
|
+
route: { screenId: "shop:screen:product-edit" },
|
|
90
|
+
navigate: (target) => navigated.push(target),
|
|
91
|
+
replace: () => {},
|
|
92
|
+
hrefFor: () => "",
|
|
93
|
+
searchParams: {},
|
|
94
|
+
setSearchParams: () => {},
|
|
95
|
+
}}
|
|
96
|
+
>
|
|
97
|
+
<KumikoScreen schema={buildSchema("product-detail")} qn="shop:screen:product-edit" />
|
|
98
|
+
</NavProvider>
|
|
99
|
+
</DispatcherProvider>,
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
fillNameAndSubmit();
|
|
103
|
+
|
|
104
|
+
await waitFor(() =>
|
|
105
|
+
expect(navigated).toEqual([{ screenId: "product-detail", entityId: "new-99" }]),
|
|
106
|
+
);
|
|
107
|
+
});
|
|
108
|
+
|
|
77
109
|
test("create: no redirect set falls back to the entity's list screen", async () => {
|
|
78
110
|
const navigated: NavTarget[] = [];
|
|
79
111
|
render(
|
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
type RenderEditControls,
|
|
15
15
|
useExtensionFormSubmit,
|
|
16
16
|
} from "@cosmicdrift/kumiko-renderer";
|
|
17
|
-
import { useState } from "react";
|
|
17
|
+
import { type ReactNode, useCallback, useState } from "react";
|
|
18
18
|
import { z } from "zod";
|
|
19
19
|
import {
|
|
20
20
|
act,
|
|
@@ -757,6 +757,55 @@ describe("RenderEdit — controlled mode (#1887)", () => {
|
|
|
757
757
|
expect(controls?.getValues().count).toBe("Acme".length);
|
|
758
758
|
});
|
|
759
759
|
|
|
760
|
+
// fw#1899: a caller that supplies onControlsReady only after this mount
|
|
761
|
+
// (e.g. `onControlsReady={ready ? handler : undefined}`) must still get
|
|
762
|
+
// delivered to for THIS mount — the effect's deps previously excluded
|
|
763
|
+
// onControlsReady itself, so it never re-ran once mounted without it.
|
|
764
|
+
test("onControlsReady delivers to a handler supplied after mount, not just at mount time", () => {
|
|
765
|
+
function Host(): ReactNode {
|
|
766
|
+
const [ready, setReady] = useState(false);
|
|
767
|
+
const [received, setReceived] = useState<RenderEditControls<TestValues> | undefined>(
|
|
768
|
+
undefined,
|
|
769
|
+
);
|
|
770
|
+
// Stable identity across renders, like a real caller's useCallback:
|
|
771
|
+
// an inline arrow here would change on every render and defeat the
|
|
772
|
+
// re-delivery guard's identity check, looping the effect.
|
|
773
|
+
const onControlsReady = useCallback((c: RenderEditControls<TestValues>) => {
|
|
774
|
+
setReceived(c);
|
|
775
|
+
}, []);
|
|
776
|
+
return (
|
|
777
|
+
<>
|
|
778
|
+
<button type="button" data-testid="make-ready" onClick={() => setReady(true)}>
|
|
779
|
+
ready
|
|
780
|
+
</button>
|
|
781
|
+
<div data-testid="received">{received !== undefined ? "yes" : "no"}</div>
|
|
782
|
+
<RenderEdit<TestValues>
|
|
783
|
+
screen={makeScreen()}
|
|
784
|
+
entity={orderEntity}
|
|
785
|
+
featureName="orders"
|
|
786
|
+
initial={{ title: "", count: 0, isUrgent: false }}
|
|
787
|
+
writeCommand="order:create"
|
|
788
|
+
{...(ready && { onControlsReady })}
|
|
789
|
+
/>
|
|
790
|
+
</>
|
|
791
|
+
);
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
render(
|
|
795
|
+
<DispatcherProvider dispatcher={makeDispatcher()}>
|
|
796
|
+
<Host />
|
|
797
|
+
</DispatcherProvider>,
|
|
798
|
+
);
|
|
799
|
+
|
|
800
|
+
expect(screen.getByTestId("received").textContent).toBe("no");
|
|
801
|
+
|
|
802
|
+
act(() => {
|
|
803
|
+
fireEvent.click(screen.getByTestId("make-ready"));
|
|
804
|
+
});
|
|
805
|
+
|
|
806
|
+
expect(screen.getByTestId("received").textContent).toBe("yes");
|
|
807
|
+
});
|
|
808
|
+
|
|
760
809
|
test("controls.patch sets values from outside without losing edits already made in other fields", () => {
|
|
761
810
|
let controls: RenderEditControls<TestValues> | undefined;
|
|
762
811
|
render(
|
|
@@ -1230,6 +1279,122 @@ describe("RenderEdit wizard mode", () => {
|
|
|
1230
1279
|
expect(screen.queryByTestId("field-title-errors")).toBeNull();
|
|
1231
1280
|
});
|
|
1232
1281
|
|
|
1282
|
+
// fw#1901: handleWizardNext used to validate every field in the section,
|
|
1283
|
+
// including ones currently hidden by their own condition. A required
|
|
1284
|
+
// field that only applies conditionally (e.g. a VAT id shown only for
|
|
1285
|
+
// companies) must not permanently block "Next" while it's hidden. Note:
|
|
1286
|
+
// form-controller.ts's runValidate() already excludes hidden-field
|
|
1287
|
+
// issues from every validate() call via its own `hiddenFields` set
|
|
1288
|
+
// (form-controller.ts:200), independent of scope; this test protects
|
|
1289
|
+
// that existing guard, not the fieldNames narrowing added here.
|
|
1290
|
+
test("Weiter is not blocked by a required field that's currently hidden by its own condition", async () => {
|
|
1291
|
+
const companyEntity = {
|
|
1292
|
+
fields: {
|
|
1293
|
+
isCompany: { type: "boolean" },
|
|
1294
|
+
vatId: { type: "text", required: true },
|
|
1295
|
+
count: { type: "number" },
|
|
1296
|
+
},
|
|
1297
|
+
} as unknown as EntityDefinition;
|
|
1298
|
+
const wizardScreen: EntityEditScreenDefinition = {
|
|
1299
|
+
id: "orders:screen:order-wizard-conditional",
|
|
1300
|
+
type: "entityEdit",
|
|
1301
|
+
entity: "order",
|
|
1302
|
+
layout: {
|
|
1303
|
+
mode: "wizard",
|
|
1304
|
+
sections: [
|
|
1305
|
+
{
|
|
1306
|
+
title: "Basics",
|
|
1307
|
+
columns: 1,
|
|
1308
|
+
fields: [
|
|
1309
|
+
"isCompany",
|
|
1310
|
+
{
|
|
1311
|
+
field: "vatId",
|
|
1312
|
+
visible: { field: "isCompany", eq: true },
|
|
1313
|
+
required: { field: "isCompany", eq: true },
|
|
1314
|
+
},
|
|
1315
|
+
],
|
|
1316
|
+
},
|
|
1317
|
+
{ title: "Details", columns: 1, fields: [{ field: "count" }] },
|
|
1318
|
+
],
|
|
1319
|
+
},
|
|
1320
|
+
};
|
|
1321
|
+
// A schema that (realistically) doesn't special-case the conditional
|
|
1322
|
+
// requirement itself — it's the FieldCondition's hidden-field exclusion
|
|
1323
|
+
// that's supposed to keep this from blocking, not the schema.
|
|
1324
|
+
const schema = z.object({ isCompany: z.boolean(), vatId: z.string().min(1) });
|
|
1325
|
+
|
|
1326
|
+
render(
|
|
1327
|
+
<DispatcherProvider dispatcher={makeDispatcher()}>
|
|
1328
|
+
<RenderEdit
|
|
1329
|
+
screen={wizardScreen}
|
|
1330
|
+
entity={companyEntity}
|
|
1331
|
+
featureName="orders"
|
|
1332
|
+
initial={{ isCompany: false, vatId: "", count: 0 } as never}
|
|
1333
|
+
writeCommand="order:create"
|
|
1334
|
+
schema={schema}
|
|
1335
|
+
/>
|
|
1336
|
+
</DispatcherProvider>,
|
|
1337
|
+
);
|
|
1338
|
+
|
|
1339
|
+
expect(screen.queryByTestId("field-vatId")).toBeNull();
|
|
1340
|
+
|
|
1341
|
+
await act(async () => {
|
|
1342
|
+
fireEvent.click(screen.getByTestId("render-edit-wizard-next"));
|
|
1343
|
+
await Promise.resolve();
|
|
1344
|
+
});
|
|
1345
|
+
|
|
1346
|
+
expect(screen.getByTestId("field-count")).toBeTruthy();
|
|
1347
|
+
expect(screen.queryByTestId("render-edit-wizard-next")).toBeNull();
|
|
1348
|
+
});
|
|
1349
|
+
|
|
1350
|
+
// fw#1901: a section that's entirely hidden (every field in it currently
|
|
1351
|
+
// condition-hidden) must not occupy its own wizard step — it would render
|
|
1352
|
+
// empty and the step count/progress would include a step nobody can see.
|
|
1353
|
+
test("a fully-hidden section is skipped in the wizard step count instead of rendering an empty step", async () => {
|
|
1354
|
+
const wizardScreen: EntityEditScreenDefinition = {
|
|
1355
|
+
id: "orders:screen:order-wizard-hidden-section",
|
|
1356
|
+
type: "entityEdit",
|
|
1357
|
+
entity: "order",
|
|
1358
|
+
layout: {
|
|
1359
|
+
mode: "wizard",
|
|
1360
|
+
sections: [
|
|
1361
|
+
{ title: "Basics", columns: 1, fields: [{ field: "title" }] },
|
|
1362
|
+
{
|
|
1363
|
+
title: "Company-only",
|
|
1364
|
+
columns: 1,
|
|
1365
|
+
fields: [{ field: "notes", visible: { field: "isUrgent", eq: true } }],
|
|
1366
|
+
},
|
|
1367
|
+
{ title: "Details", columns: 1, fields: [{ field: "count" }] },
|
|
1368
|
+
],
|
|
1369
|
+
},
|
|
1370
|
+
};
|
|
1371
|
+
|
|
1372
|
+
render(
|
|
1373
|
+
<DispatcherProvider dispatcher={makeDispatcher()}>
|
|
1374
|
+
<RenderEdit<TestValues>
|
|
1375
|
+
screen={wizardScreen}
|
|
1376
|
+
entity={orderEntity}
|
|
1377
|
+
featureName="orders"
|
|
1378
|
+
initial={{ title: "Acme", count: 0, isUrgent: false }}
|
|
1379
|
+
writeCommand="order:create"
|
|
1380
|
+
/>
|
|
1381
|
+
</DispatcherProvider>,
|
|
1382
|
+
);
|
|
1383
|
+
|
|
1384
|
+
// Only 2 real steps (Basics, Details) — "Company-only" is entirely
|
|
1385
|
+
// hidden (isUrgent is false) and must not count as a step.
|
|
1386
|
+
expect(screen.getByTestId("render-edit-wizard-step-label").textContent).toContain("of 2");
|
|
1387
|
+
|
|
1388
|
+
await act(async () => {
|
|
1389
|
+
fireEvent.click(screen.getByTestId("render-edit-wizard-next"));
|
|
1390
|
+
await Promise.resolve();
|
|
1391
|
+
});
|
|
1392
|
+
|
|
1393
|
+
// Landed directly on Details, skipping the hidden "Company-only" step.
|
|
1394
|
+
expect(screen.getByTestId("field-count")).toBeTruthy();
|
|
1395
|
+
expect(screen.queryByTestId("render-edit-wizard-next")).toBeNull();
|
|
1396
|
+
});
|
|
1397
|
+
|
|
1233
1398
|
// A server-side field error can land on a field belonging to a step the
|
|
1234
1399
|
// user isn't currently viewing — the old `setFormError(fieldIssues.length
|
|
1235
1400
|
// === 0 ? result.error : null)` rule suppressed the banner in that case
|
|
@@ -1517,6 +1682,59 @@ describe("RenderEdit wizard draft", () => {
|
|
|
1517
1682
|
expect(titleInputAgain.value).toBe("Acme");
|
|
1518
1683
|
});
|
|
1519
1684
|
|
|
1685
|
+
// fw#1929: bare crypto.randomUUID() breaks in non-secure contexts and
|
|
1686
|
+
// React Native/Hermes without a polyfill. With it deleted, minting a
|
|
1687
|
+
// draftId must still work through the mintDraftId() fallback instead of
|
|
1688
|
+
// throwing.
|
|
1689
|
+
test("draft id still mints when crypto.randomUUID is unavailable (fw#1929)", async () => {
|
|
1690
|
+
const cryptoRef = (globalThis as { crypto?: { randomUUID?: () => string } }).crypto;
|
|
1691
|
+
const original = cryptoRef?.randomUUID;
|
|
1692
|
+
// `randomUUID` lives on crypto's prototype (non-configurable) — `delete`
|
|
1693
|
+
// is a silent no-op there, so shadow it with an own `undefined` instead.
|
|
1694
|
+
if (cryptoRef) cryptoRef.randomUUID = undefined;
|
|
1695
|
+
|
|
1696
|
+
try {
|
|
1697
|
+
const draftKeys: string[] = [];
|
|
1698
|
+
const dispatcher = createMockDispatcher({
|
|
1699
|
+
query: (async () => ({ isSuccess: true, data: {} })) as Dispatcher["query"],
|
|
1700
|
+
write: (async (type: string, payload: unknown) => {
|
|
1701
|
+
if (type === "form-draft:write:save") {
|
|
1702
|
+
draftKeys.push((payload as { draftKey: string }).draftKey);
|
|
1703
|
+
}
|
|
1704
|
+
return { isSuccess: true, data: { id: "1" } };
|
|
1705
|
+
}) as Dispatcher["write"],
|
|
1706
|
+
});
|
|
1707
|
+
|
|
1708
|
+
render(
|
|
1709
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
1710
|
+
<DraftStorageProvider value={createFakeDraftStorage()}>
|
|
1711
|
+
<RenderEdit<TestValues>
|
|
1712
|
+
screen={makeDraftWizardScreen(true)}
|
|
1713
|
+
entity={orderEntity}
|
|
1714
|
+
featureName="orders"
|
|
1715
|
+
initial={{ title: "", count: 0 }}
|
|
1716
|
+
writeCommand="order:create"
|
|
1717
|
+
/>
|
|
1718
|
+
</DraftStorageProvider>
|
|
1719
|
+
</DispatcherProvider>,
|
|
1720
|
+
);
|
|
1721
|
+
|
|
1722
|
+
fireEvent.change(
|
|
1723
|
+
screen.getByTestId("field-title").querySelector("input") as HTMLInputElement,
|
|
1724
|
+
{ target: { value: "Acme" } },
|
|
1725
|
+
);
|
|
1726
|
+
await act(async () => {
|
|
1727
|
+
fireEvent.submit(screen.getByTestId("render-edit-form"));
|
|
1728
|
+
await Promise.resolve();
|
|
1729
|
+
});
|
|
1730
|
+
|
|
1731
|
+
expect(draftKeys.length).toBeGreaterThan(0);
|
|
1732
|
+
expect(draftKeys[0]).toContain(":new:draft-");
|
|
1733
|
+
} finally {
|
|
1734
|
+
if (cryptoRef && original) cryptoRef.randomUUID = original;
|
|
1735
|
+
}
|
|
1736
|
+
});
|
|
1737
|
+
|
|
1520
1738
|
test("a successful submit discards the draft", async () => {
|
|
1521
1739
|
const { dispatcher, store, calls } = makeDraftDispatcher();
|
|
1522
1740
|
|
|
@@ -1552,6 +1770,62 @@ describe("RenderEdit wizard draft", () => {
|
|
|
1552
1770
|
expect(calls).toContain("form-draft:write:discard");
|
|
1553
1771
|
});
|
|
1554
1772
|
|
|
1773
|
+
// fw#1978: payloadMode "changes" + an untouched form means
|
|
1774
|
+
// controller.submit() never calls dispatcher.write (nothing changed to
|
|
1775
|
+
// send) — handleSubmit must not treat that no-write success like a normal
|
|
1776
|
+
// submit and discard the draft underneath it, or a host driving
|
|
1777
|
+
// controls.submit() on a pre-filled, untouched form silently loses it.
|
|
1778
|
+
test("payloadMode 'changes' + an unchanged form: submit() is a no-op that keeps the draft alive", async () => {
|
|
1779
|
+
const { dispatcher, store, calls } = makeDraftDispatcher();
|
|
1780
|
+
let controls: RenderEditControls<TestValues> | undefined;
|
|
1781
|
+
let submitResult: SubmitResult<unknown> | undefined;
|
|
1782
|
+
|
|
1783
|
+
render(
|
|
1784
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
1785
|
+
<RenderEdit<TestValues>
|
|
1786
|
+
screen={makeDraftWizardScreen(true)}
|
|
1787
|
+
entity={orderEntity}
|
|
1788
|
+
featureName="orders"
|
|
1789
|
+
initial={{ title: "Acme", count: 0 }}
|
|
1790
|
+
writeCommand="order:create"
|
|
1791
|
+
payloadMode="changes"
|
|
1792
|
+
onControlsReady={(c) => {
|
|
1793
|
+
controls = c;
|
|
1794
|
+
}}
|
|
1795
|
+
onSubmit={(result) => {
|
|
1796
|
+
submitResult = result;
|
|
1797
|
+
}}
|
|
1798
|
+
/>
|
|
1799
|
+
</DispatcherProvider>,
|
|
1800
|
+
);
|
|
1801
|
+
|
|
1802
|
+
// Step to the last wizard step without touching any field — saveDraft()
|
|
1803
|
+
// persists the current (unmodified) values, so a draft exists, but the
|
|
1804
|
+
// form itself is still not dirty.
|
|
1805
|
+
await act(async () => {
|
|
1806
|
+
fireEvent.click(screen.getByTestId("render-edit-wizard-next"));
|
|
1807
|
+
await Promise.resolve();
|
|
1808
|
+
});
|
|
1809
|
+
await waitFor(() => expect(store.current).not.toBeNull());
|
|
1810
|
+
calls.length = 0;
|
|
1811
|
+
|
|
1812
|
+
// The built-in Save button stays disabled on an unchanged form — drive
|
|
1813
|
+
// the write the way a host would (RenderEditControls.submit(), the API
|
|
1814
|
+
// this changeset's "pre-filled, untouched form" use case documents).
|
|
1815
|
+
await act(async () => {
|
|
1816
|
+
await controls?.submit();
|
|
1817
|
+
});
|
|
1818
|
+
|
|
1819
|
+
expect(submitResult?.validationBlocked).toBe(false);
|
|
1820
|
+
if (submitResult?.validationBlocked === false) {
|
|
1821
|
+
expect(submitResult.isSuccess).toBe(true);
|
|
1822
|
+
expect(submitResult.isNoOp).toBe(true);
|
|
1823
|
+
}
|
|
1824
|
+
expect(calls).not.toContain("order:create");
|
|
1825
|
+
expect(calls).not.toContain("form-draft:write:discard");
|
|
1826
|
+
expect(store.current).not.toBeNull();
|
|
1827
|
+
});
|
|
1828
|
+
|
|
1555
1829
|
test("without layout.draft nothing hits the form-draft feature", async () => {
|
|
1556
1830
|
const { dispatcher, calls } = makeDraftDispatcher();
|
|
1557
1831
|
|
|
@@ -1810,6 +2084,41 @@ describe("RenderEdit wizard draft", () => {
|
|
|
1810
2084
|
expect(savesSoFar()).toBe(before + 1);
|
|
1811
2085
|
});
|
|
1812
2086
|
|
|
2087
|
+
// fw#1932: a patch() inside the 500ms debounce window followed by an
|
|
2088
|
+
// unmount (navigation away, dialog close) must not drop the save — the
|
|
2089
|
+
// old cleanup only cleared the timer, silently discarding the last edit.
|
|
2090
|
+
test("a pending debounced patch-save flushes on unmount instead of being dropped", async () => {
|
|
2091
|
+
const { dispatcher, store } = makeDraftDispatcher();
|
|
2092
|
+
let controls: RenderEditControls<TestValues> | undefined;
|
|
2093
|
+
|
|
2094
|
+
const rendered = render(
|
|
2095
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
2096
|
+
<RenderEdit<TestValues>
|
|
2097
|
+
screen={makeDraftWizardScreen(true)}
|
|
2098
|
+
entity={orderEntity}
|
|
2099
|
+
featureName="orders"
|
|
2100
|
+
initial={{ title: "Acme", count: 0 }}
|
|
2101
|
+
writeCommand="order:create"
|
|
2102
|
+
onControlsReady={(c) => {
|
|
2103
|
+
controls = c;
|
|
2104
|
+
}}
|
|
2105
|
+
/>
|
|
2106
|
+
</DispatcherProvider>,
|
|
2107
|
+
);
|
|
2108
|
+
|
|
2109
|
+
fireEvent.click(screen.getByTestId("render-edit-wizard-next"));
|
|
2110
|
+
expect(screen.getByTestId("field-count")).toBeTruthy();
|
|
2111
|
+
|
|
2112
|
+
// Arms the debounce, then unmounts well inside the 500ms window — before
|
|
2113
|
+
// the timer could ever fire on its own.
|
|
2114
|
+
act(() => {
|
|
2115
|
+
controls?.patch({ count: 7 });
|
|
2116
|
+
});
|
|
2117
|
+
rendered.unmount();
|
|
2118
|
+
|
|
2119
|
+
await waitFor(() => expect(store.current?.values["count"]).toBe(7));
|
|
2120
|
+
});
|
|
2121
|
+
|
|
1813
2122
|
// #1914's debounce must not resurrect a draft after it was intentionally
|
|
1814
2123
|
// ended: discardDraft() clears the pending timer (render-edit.tsx:735-738)
|
|
1815
2124
|
// specifically so a patch() immediately followed by submit can't have its
|
|
@@ -2560,6 +2869,90 @@ describe("RenderEdit locked state (#1896)", () => {
|
|
|
2560
2869
|
const titleInput = screen.getByTestId("field-title").querySelector("input");
|
|
2561
2870
|
expect((titleInput as HTMLInputElement).disabled).toBe(false);
|
|
2562
2871
|
});
|
|
2872
|
+
|
|
2873
|
+
test("disabled prevents the Delete button from invoking onDelete (fw#1909)", async () => {
|
|
2874
|
+
const onDelete = mock(async () => {});
|
|
2875
|
+
render(
|
|
2876
|
+
<DispatcherProvider dispatcher={makeDispatcher()}>
|
|
2877
|
+
<RenderEdit<TestValues>
|
|
2878
|
+
screen={makeScreen()}
|
|
2879
|
+
entity={orderEntity}
|
|
2880
|
+
featureName="orders"
|
|
2881
|
+
initial={{ title: "Acme", count: 1, isUrgent: false }}
|
|
2882
|
+
writeCommand="order:create"
|
|
2883
|
+
onDelete={onDelete}
|
|
2884
|
+
disabled
|
|
2885
|
+
/>
|
|
2886
|
+
</DispatcherProvider>,
|
|
2887
|
+
);
|
|
2888
|
+
|
|
2889
|
+
const deleteButton = screen.getByTestId("render-edit-delete") as HTMLButtonElement;
|
|
2890
|
+
expect(deleteButton.disabled).toBe(true);
|
|
2891
|
+
fireEvent.click(deleteButton);
|
|
2892
|
+
expect(screen.queryByTestId("render-edit-delete-dialog")).toBeNull();
|
|
2893
|
+
expect(onDelete).not.toHaveBeenCalled();
|
|
2894
|
+
});
|
|
2895
|
+
|
|
2896
|
+
test("disabled prevents picking a draft candidate from adopting it (fw#1909)", async () => {
|
|
2897
|
+
const screenDef: EntityEditScreenDefinition = {
|
|
2898
|
+
id: "orders:screen:order-wizard-locked-draftpicker",
|
|
2899
|
+
type: "entityEdit",
|
|
2900
|
+
entity: "order",
|
|
2901
|
+
layout: {
|
|
2902
|
+
mode: "wizard",
|
|
2903
|
+
draft: true,
|
|
2904
|
+
sections: [
|
|
2905
|
+
{ title: "Basics", columns: 1, fields: [{ field: "title" }] },
|
|
2906
|
+
{ title: "Details", columns: 1, fields: [{ field: "count" }] },
|
|
2907
|
+
],
|
|
2908
|
+
},
|
|
2909
|
+
};
|
|
2910
|
+
const candidates = [
|
|
2911
|
+
{
|
|
2912
|
+
id: `${screenDef.id}:new:draft-1`,
|
|
2913
|
+
draftKey: `${screenDef.id}:new:draft-1`,
|
|
2914
|
+
stepIndex: 0,
|
|
2915
|
+
savedAt: "2026-01-01T00:00:00Z",
|
|
2916
|
+
},
|
|
2917
|
+
{
|
|
2918
|
+
id: `${screenDef.id}:new:draft-2`,
|
|
2919
|
+
draftKey: `${screenDef.id}:new:draft-2`,
|
|
2920
|
+
stepIndex: 0,
|
|
2921
|
+
savedAt: "2026-01-02T00:00:00Z",
|
|
2922
|
+
},
|
|
2923
|
+
];
|
|
2924
|
+
const dispatcher = createMockDispatcher({
|
|
2925
|
+
query: (async (type: string) => {
|
|
2926
|
+
if (type === "form-draft:query:list")
|
|
2927
|
+
return { isSuccess: true, data: { drafts: candidates } };
|
|
2928
|
+
return { isSuccess: true, data: {} };
|
|
2929
|
+
}) as Dispatcher["query"],
|
|
2930
|
+
write: (async () => ({ isSuccess: true, data: { id: "1" } })) as Dispatcher["write"],
|
|
2931
|
+
});
|
|
2932
|
+
|
|
2933
|
+
render(
|
|
2934
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
2935
|
+
<DraftStorageProvider value={createFakeDraftStorage()}>
|
|
2936
|
+
<RenderEdit<TestValues>
|
|
2937
|
+
screen={screenDef}
|
|
2938
|
+
entity={orderEntity}
|
|
2939
|
+
featureName="orders"
|
|
2940
|
+
initial={{ title: "", count: 0 }}
|
|
2941
|
+
writeCommand="order:create"
|
|
2942
|
+
disabled
|
|
2943
|
+
/>
|
|
2944
|
+
</DraftStorageProvider>
|
|
2945
|
+
</DispatcherProvider>,
|
|
2946
|
+
);
|
|
2947
|
+
|
|
2948
|
+
await waitFor(() => expect(screen.getByTestId("render-edit-draft-picker")).toBeTruthy());
|
|
2949
|
+
const pickSecond = screen.getByTestId(`render-edit-draft-pick-${screenDef.id}:new:draft-2`);
|
|
2950
|
+
fireEvent.click(pickSecond);
|
|
2951
|
+
|
|
2952
|
+
expect(screen.getByTestId("render-edit-draft-picker")).toBeTruthy();
|
|
2953
|
+
const titleInput = screen.getByTestId("field-title").querySelector("input") as HTMLInputElement;
|
|
2954
|
+
expect(titleInput.value).toBe("");
|
|
2955
|
+
});
|
|
2563
2956
|
});
|
|
2564
2957
|
|
|
2565
2958
|
describe("RenderEdit hideActions (host-driven action bar)", () => {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { CONTENT_EDITOR_ELEMENT_ID } from "@cosmicdrift/kumiko-renderer";
|
|
2
3
|
import { type ReactNode, useState } from "react";
|
|
3
4
|
import { fireEvent, render, screen } from "../../__tests__/test-utils";
|
|
4
5
|
import { PlainContentEditor } from "../plain-content-editor";
|
|
@@ -6,21 +7,41 @@ import { PlainContentEditor } from "../plain-content-editor";
|
|
|
6
7
|
function Controlled({ initial }: { readonly initial: string }): ReactNode {
|
|
7
8
|
const [value, setValue] = useState(initial);
|
|
8
9
|
return (
|
|
9
|
-
<PlainContentEditor
|
|
10
|
+
<PlainContentEditor
|
|
11
|
+
id={CONTENT_EDITOR_ELEMENT_ID}
|
|
12
|
+
value={value}
|
|
13
|
+
onChange={setValue}
|
|
14
|
+
variables={["name"]}
|
|
15
|
+
readOnly={false}
|
|
16
|
+
/>
|
|
10
17
|
);
|
|
11
18
|
}
|
|
12
19
|
|
|
13
20
|
describe("PlainContentEditor", () => {
|
|
14
21
|
test("renders the textarea plus one chip per variable", () => {
|
|
15
22
|
render(
|
|
16
|
-
<PlainContentEditor
|
|
23
|
+
<PlainContentEditor
|
|
24
|
+
id={CONTENT_EDITOR_ELEMENT_ID}
|
|
25
|
+
value=""
|
|
26
|
+
onChange={() => {}}
|
|
27
|
+
variables={["name"]}
|
|
28
|
+
readOnly={false}
|
|
29
|
+
/>,
|
|
17
30
|
);
|
|
18
31
|
expect(screen.getByRole("textbox")).toBeTruthy();
|
|
19
32
|
expect(screen.getByText("{{name}}")).toBeTruthy();
|
|
20
33
|
});
|
|
21
34
|
|
|
22
35
|
test("no variables → no chips", () => {
|
|
23
|
-
render(
|
|
36
|
+
render(
|
|
37
|
+
<PlainContentEditor
|
|
38
|
+
id={CONTENT_EDITOR_ELEMENT_ID}
|
|
39
|
+
value=""
|
|
40
|
+
onChange={() => {}}
|
|
41
|
+
variables={[]}
|
|
42
|
+
readOnly={false}
|
|
43
|
+
/>,
|
|
44
|
+
);
|
|
24
45
|
expect(screen.queryAllByRole("button")).toHaveLength(0);
|
|
25
46
|
});
|
|
26
47
|
|
|
@@ -7,6 +7,7 @@ describe("RichContentEditor", () => {
|
|
|
7
7
|
test("falls back to the plain textarea while the tiptap chunk loads, then swaps in the editor", async () => {
|
|
8
8
|
render(
|
|
9
9
|
<RichContentEditor
|
|
10
|
+
id={CONTENT_EDITOR_ELEMENT_ID}
|
|
10
11
|
value="<p>hello</p>"
|
|
11
12
|
onChange={() => {}}
|
|
12
13
|
variables={[]}
|