@cosmicdrift/kumiko-renderer-web 0.193.0 → 0.194.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 +1 -1
- package/src/__tests__/entity-edit-redirect.test.tsx +111 -0
- package/src/__tests__/field-hide-label.test.tsx +166 -1
- package/src/__tests__/kumiko-screen.test.tsx +59 -0
- package/src/__tests__/nav-tree.test.tsx +43 -11
- package/src/__tests__/primitives.test.tsx +33 -2
- package/src/__tests__/render-edit.test.tsx +344 -9
- package/src/__tests__/test-utils.tsx +20 -0
- package/src/__tests__/wizard-form-validation.test.tsx +129 -1
- package/src/app/__tests__/merge-content-editors.test.ts +2 -2
- package/src/app/__tests__/plain-content-editor.test.tsx +4 -0
- package/src/app/__tests__/rich-content-editor.test.tsx +5 -2
- package/src/app/__tests__/tiptap-editor.test.tsx +50 -0
- package/src/app/client-plugin.tsx +3 -3
- package/src/app/create-app.tsx +40 -49
- package/src/app/tiptap-editor.tsx +10 -0
- package/src/layout/__tests__/workspace-switcher.test.tsx +40 -0
- package/src/layout/sidebar-panel.tsx +1 -4
- package/src/layout/workspace-switcher.tsx +20 -13
- package/src/lib/__tests__/resize-image.test.ts +152 -0
- package/src/lib/clamp.ts +3 -0
- package/src/lib/resize-image.ts +61 -0
- package/src/primitives/__tests__/data-table-logic.test.ts +15 -3
- package/src/primitives/__tests__/date-field.test.tsx +37 -0
- package/src/primitives/__tests__/date-parse.test.ts +14 -2
- package/src/primitives/__tests__/embedded-list-input.test.tsx +36 -2
- package/src/primitives/__tests__/file-upload.test.tsx +24 -0
- package/src/primitives/date-field.tsx +8 -0
- package/src/primitives/date-parse.ts +6 -1
- package/src/primitives/dropdown-menu.tsx +23 -0
- package/src/primitives/embedded-list-input.tsx +14 -5
- package/src/primitives/file-upload.tsx +6 -1
- package/src/primitives/index.tsx +114 -120
- package/src/primitives/money-input.tsx +4 -7
- package/src/ui/sheet.tsx +3 -1
- package/src/widgets/__tests__/drawer.test.tsx +293 -0
- package/src/widgets/__tests__/infinity-list.test.tsx +38 -0
- package/src/widgets/__tests__/upload-zone.test.tsx +125 -1
- package/src/widgets/__tests__/widgets.test.tsx +25 -0
- package/src/widgets/ai-text-field.tsx +3 -1
- package/src/widgets/drawer.tsx +54 -29
- package/src/widgets/infinity-list.tsx +18 -5
- package/src/widgets/progress-bar.tsx +3 -1
- package/src/widgets/step-bar.tsx +1 -1
- package/src/widgets/upload-zone.tsx +42 -3
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, expect, mock, test } from "bun:test";
|
|
1
|
+
import { describe, expect, mock, spyOn, test } from "bun:test";
|
|
2
2
|
import type {
|
|
3
3
|
EntityDefinition,
|
|
4
4
|
EntityEditScreenDefinition,
|
|
@@ -724,6 +724,39 @@ describe("RenderEdit — controlled mode (#1887)", () => {
|
|
|
724
724
|
expect(controls?.getValues().count).toBe("Acme".length);
|
|
725
725
|
});
|
|
726
726
|
|
|
727
|
+
test("onControlsReady fires before the mount-time onChange call (fw#1899)", () => {
|
|
728
|
+
// A prefilled `initial` derives a dependent field on mount via
|
|
729
|
+
// controls.patch() from inside onChange — this only works if controls
|
|
730
|
+
// are already handed out by the time onChange fires for the first time.
|
|
731
|
+
let mountTimeControlsWereDefined = false;
|
|
732
|
+
let onChangeCalls = 0;
|
|
733
|
+
let controls: RenderEditControls<TestValues> | undefined;
|
|
734
|
+
render(
|
|
735
|
+
<DispatcherProvider dispatcher={makeDispatcher()}>
|
|
736
|
+
<RenderEdit<TestValues>
|
|
737
|
+
screen={makeScreen()}
|
|
738
|
+
entity={orderEntity}
|
|
739
|
+
featureName="orders"
|
|
740
|
+
initial={{ title: "Acme", count: 0, isUrgent: false }}
|
|
741
|
+
writeCommand="order:create"
|
|
742
|
+
onChange={(state) => {
|
|
743
|
+
onChangeCalls += 1;
|
|
744
|
+
if (onChangeCalls === 1) {
|
|
745
|
+
mountTimeControlsWereDefined = controls !== undefined;
|
|
746
|
+
controls?.patch({ count: state.values.title.length });
|
|
747
|
+
}
|
|
748
|
+
}}
|
|
749
|
+
onControlsReady={(c) => {
|
|
750
|
+
controls = c;
|
|
751
|
+
}}
|
|
752
|
+
/>
|
|
753
|
+
</DispatcherProvider>,
|
|
754
|
+
);
|
|
755
|
+
|
|
756
|
+
expect(mountTimeControlsWereDefined).toBe(true);
|
|
757
|
+
expect(controls?.getValues().count).toBe("Acme".length);
|
|
758
|
+
});
|
|
759
|
+
|
|
727
760
|
test("controls.patch sets values from outside without losing edits already made in other fields", () => {
|
|
728
761
|
let controls: RenderEditControls<TestValues> | undefined;
|
|
729
762
|
render(
|
|
@@ -946,9 +979,9 @@ describe("RenderEdit — FieldConditions react to extension patch() (#1916)", ()
|
|
|
946
979
|
);
|
|
947
980
|
}
|
|
948
981
|
|
|
949
|
-
function renderVehicleForm(): void {
|
|
982
|
+
function renderVehicleForm(writeFn?: Dispatcher["write"], schema?: z.ZodType): void {
|
|
950
983
|
render(
|
|
951
|
-
<DispatcherProvider dispatcher={makeDispatcher()}>
|
|
984
|
+
<DispatcherProvider dispatcher={makeDispatcher(writeFn)}>
|
|
952
985
|
<ExtensionSectionsProvider value={{ VinDecodeSection }}>
|
|
953
986
|
<RenderEdit<VehicleValues>
|
|
954
987
|
screen={makeVehicleScreen()}
|
|
@@ -956,6 +989,7 @@ describe("RenderEdit — FieldConditions react to extension patch() (#1916)", ()
|
|
|
956
989
|
featureName="vehicles"
|
|
957
990
|
initial={{ title: "Listing", vin: "" }}
|
|
958
991
|
writeCommand="vehicle:create"
|
|
992
|
+
{...(schema !== undefined && { schema })}
|
|
959
993
|
/>
|
|
960
994
|
</ExtensionSectionsProvider>
|
|
961
995
|
</DispatcherProvider>,
|
|
@@ -963,15 +997,13 @@ describe("RenderEdit — FieldConditions react to extension patch() (#1916)", ()
|
|
|
963
997
|
}
|
|
964
998
|
|
|
965
999
|
function isRequired(testId: string): boolean {
|
|
966
|
-
|
|
967
|
-
return (label?.textContent ?? "").includes("*");
|
|
1000
|
+
return screen.getByTestId(testId).querySelector("[data-required]") !== null;
|
|
968
1001
|
}
|
|
969
1002
|
|
|
970
1003
|
function isDisabled(testId: string): boolean {
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
);
|
|
1004
|
+
const input = screen.getByTestId(testId).querySelector("input");
|
|
1005
|
+
if (!input) throw new Error(`${testId} has no input`);
|
|
1006
|
+
return (input as HTMLInputElement).disabled;
|
|
975
1007
|
}
|
|
976
1008
|
|
|
977
1009
|
test("provider delivers a value: the gated field becomes visible and required, the source field locks", () => {
|
|
@@ -1021,6 +1053,44 @@ describe("RenderEdit — FieldConditions react to extension patch() (#1916)", ()
|
|
|
1021
1053
|
expect(screen.queryByTestId("field-trim")).toBeNull();
|
|
1022
1054
|
expect(isDisabled("field-vin")).toBe(false);
|
|
1023
1055
|
});
|
|
1056
|
+
|
|
1057
|
+
// #1916's required condition must reach the validation record, not just
|
|
1058
|
+
// the rendered asterisk — a regression where the extension patch() lands
|
|
1059
|
+
// in the render model but not the values the schema validates against
|
|
1060
|
+
// would stay green without this: the field would look required but submit
|
|
1061
|
+
// would go through anyway. Mirrors buildFormSchema's conditional-required
|
|
1062
|
+
// shape (form-schema.ts) with an inline schema, same as the other
|
|
1063
|
+
// RenderEdit tests in this file — RenderEdit itself takes `schema` as a
|
|
1064
|
+
// prop and does not derive one from the layout on its own.
|
|
1065
|
+
test("clearing trim after a decode-hit blocks submit — the required condition is enforced, not just displayed", async () => {
|
|
1066
|
+
const write = mock(async () => ({ isSuccess: true, data: { id: "1" } }) as never);
|
|
1067
|
+
const schema = z
|
|
1068
|
+
.object({ title: z.string().min(1) })
|
|
1069
|
+
.passthrough()
|
|
1070
|
+
.superRefine((values, ctx) => {
|
|
1071
|
+
const v = values as { decodeStatus?: string; trim?: string };
|
|
1072
|
+
if (v.decodeStatus === "hit" && (v.trim === undefined || v.trim === "")) {
|
|
1073
|
+
ctx.addIssue({ code: "custom", path: ["trim"], message: '"trim" is required.' });
|
|
1074
|
+
}
|
|
1075
|
+
});
|
|
1076
|
+
renderVehicleForm(write as unknown as Dispatcher["write"], schema);
|
|
1077
|
+
|
|
1078
|
+
act(() => {
|
|
1079
|
+
fireEvent.click(screen.getByTestId("decode-hit"));
|
|
1080
|
+
});
|
|
1081
|
+
const trimInput = screen.getByTestId("field-trim").querySelector("input") as HTMLInputElement;
|
|
1082
|
+
expect(isRequired("field-trim")).toBe(true);
|
|
1083
|
+
|
|
1084
|
+
fireEvent.change(trimInput, { target: { value: "" } });
|
|
1085
|
+
|
|
1086
|
+
await act(async () => {
|
|
1087
|
+
fireEvent.submit(screen.getByTestId("render-edit-form"));
|
|
1088
|
+
await Promise.resolve();
|
|
1089
|
+
});
|
|
1090
|
+
|
|
1091
|
+
expect(write).not.toHaveBeenCalled();
|
|
1092
|
+
expect(screen.getByTestId("field-trim-errors")).toBeTruthy();
|
|
1093
|
+
});
|
|
1024
1094
|
});
|
|
1025
1095
|
|
|
1026
1096
|
describe("RenderEdit wizard mode", () => {
|
|
@@ -1326,6 +1396,10 @@ describe("RenderEdit wizard mode", () => {
|
|
|
1326
1396
|
});
|
|
1327
1397
|
|
|
1328
1398
|
describe("RenderEdit wizard draft", () => {
|
|
1399
|
+
// Mirrors render-edit.tsx's own PATCH_DRAFT_SAVE_DEBOUNCE_MS (not
|
|
1400
|
+
// exported) — tests below wait out a full window to catch stragglers.
|
|
1401
|
+
const PATCH_DRAFT_SAVE_DEBOUNCE_MS = 500;
|
|
1402
|
+
|
|
1329
1403
|
function makeDraftWizardScreen(draft: boolean): EntityEditScreenDefinition {
|
|
1330
1404
|
return {
|
|
1331
1405
|
id: "orders:screen:order-wizard-draft",
|
|
@@ -1506,6 +1580,59 @@ describe("RenderEdit wizard draft", () => {
|
|
|
1506
1580
|
expect(calls.some((c) => c.startsWith("form-draft:"))).toBe(false);
|
|
1507
1581
|
});
|
|
1508
1582
|
|
|
1583
|
+
// The restore effect's `if (controller.getSnapshot().isDirty) return;`
|
|
1584
|
+
// guard (render-edit.tsx) — a user who starts typing before the
|
|
1585
|
+
// form-draft:query:get round-trip resolves must not have their input
|
|
1586
|
+
// clobbered by the stored draft landing afterwards.
|
|
1587
|
+
test("typing while the restore query is in flight — the typed value wins over the stored draft", async () => {
|
|
1588
|
+
let resolveRestore: (result: {
|
|
1589
|
+
readonly isSuccess: true;
|
|
1590
|
+
readonly data: { readonly draft: DraftBlob | null };
|
|
1591
|
+
}) => void = () => {};
|
|
1592
|
+
const restorePromise = new Promise<{
|
|
1593
|
+
readonly isSuccess: true;
|
|
1594
|
+
readonly data: { readonly draft: DraftBlob | null };
|
|
1595
|
+
}>((resolve) => {
|
|
1596
|
+
resolveRestore = resolve;
|
|
1597
|
+
});
|
|
1598
|
+
const dispatcher = createMockDispatcher({
|
|
1599
|
+
query: (async (type: string) => {
|
|
1600
|
+
if (type === "form-draft:query:get") return restorePromise;
|
|
1601
|
+
return { isSuccess: true, data: {} };
|
|
1602
|
+
}) as Dispatcher["query"],
|
|
1603
|
+
write: (async () => ({ isSuccess: true, data: { id: "1" } })) as Dispatcher["write"],
|
|
1604
|
+
});
|
|
1605
|
+
|
|
1606
|
+
render(
|
|
1607
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
1608
|
+
<RenderEdit<TestValues>
|
|
1609
|
+
screen={makeDraftWizardScreen(true)}
|
|
1610
|
+
entity={orderEntity}
|
|
1611
|
+
featureName="orders"
|
|
1612
|
+
initial={{ title: "", count: 0 }}
|
|
1613
|
+
entityId="order-1"
|
|
1614
|
+
writeCommand="order:update"
|
|
1615
|
+
/>
|
|
1616
|
+
</DispatcherProvider>,
|
|
1617
|
+
);
|
|
1618
|
+
|
|
1619
|
+
const titleInput = screen.getByTestId("field-title").querySelector("input") as HTMLInputElement;
|
|
1620
|
+
fireEvent.change(titleInput, { target: { value: "Typed while loading" } });
|
|
1621
|
+
|
|
1622
|
+
resolveRestore({
|
|
1623
|
+
isSuccess: true,
|
|
1624
|
+
data: { draft: { values: { title: "From storage", count: 9 }, stepIndex: 1 } },
|
|
1625
|
+
});
|
|
1626
|
+
// The step advancing to 2 only happens once setValues/setRawStep from
|
|
1627
|
+
// the restore effect actually ran — waiting for it proves the restore
|
|
1628
|
+
// fully landed before asserting the title was left untouched.
|
|
1629
|
+
await waitFor(() =>
|
|
1630
|
+
expect(screen.getByTestId("render-edit-wizard-step-label").textContent).toContain("2"),
|
|
1631
|
+
);
|
|
1632
|
+
|
|
1633
|
+
expect(titleInput.value).toBe("Typed while loading");
|
|
1634
|
+
});
|
|
1635
|
+
|
|
1509
1636
|
// Issue #1914: patch() (controlled mode / extension sections) previously
|
|
1510
1637
|
// never triggered a draft save — only handleWizardNext/Back did. A patch()
|
|
1511
1638
|
// on the last step (no further Next click coming) or on a step abandoned
|
|
@@ -1609,6 +1736,38 @@ describe("RenderEdit wizard draft", () => {
|
|
|
1609
1736
|
);
|
|
1610
1737
|
});
|
|
1611
1738
|
|
|
1739
|
+
test("a draftKey over the server's 256-char limit is not sent — warns and skips instead of failing silently server-side (fw#1903)", async () => {
|
|
1740
|
+
const { dispatcher, calls } = makeDraftDispatcher();
|
|
1741
|
+
let controls: RenderEditControls<TestValues> | undefined;
|
|
1742
|
+
const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
|
|
1743
|
+
|
|
1744
|
+
try {
|
|
1745
|
+
render(
|
|
1746
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
1747
|
+
<RenderEdit<TestValues>
|
|
1748
|
+
screen={{ ...makeDraftWizardScreen(true), id: `orders:screen:${"x".repeat(300)}` }}
|
|
1749
|
+
entity={orderEntity}
|
|
1750
|
+
featureName="orders"
|
|
1751
|
+
initial={{ title: "", count: 0 }}
|
|
1752
|
+
writeCommand="order:create"
|
|
1753
|
+
onControlsReady={(c) => {
|
|
1754
|
+
controls = c;
|
|
1755
|
+
}}
|
|
1756
|
+
/>
|
|
1757
|
+
</DispatcherProvider>,
|
|
1758
|
+
);
|
|
1759
|
+
|
|
1760
|
+
act(() => {
|
|
1761
|
+
controls?.patch({ count: 1 });
|
|
1762
|
+
});
|
|
1763
|
+
|
|
1764
|
+
await waitFor(() => expect(warnSpy).toHaveBeenCalled(), { timeout: 3000 });
|
|
1765
|
+
expect(calls.filter((c) => c === "form-draft:write:save").length).toBe(0);
|
|
1766
|
+
} finally {
|
|
1767
|
+
warnSpy.mockRestore();
|
|
1768
|
+
}
|
|
1769
|
+
});
|
|
1770
|
+
|
|
1612
1771
|
test("a burst of patch() calls collapses into a single debounced draft save", async () => {
|
|
1613
1772
|
const { dispatcher, calls } = makeDraftDispatcher();
|
|
1614
1773
|
let controls: RenderEditControls<TestValues> | undefined;
|
|
@@ -1641,6 +1800,63 @@ describe("RenderEdit wizard draft", () => {
|
|
|
1641
1800
|
|
|
1642
1801
|
await waitFor(() => expect(savesSoFar()).toBe(before + 1), { timeout: 3000 });
|
|
1643
1802
|
expect(savesSoFar()).toBe(before + 1);
|
|
1803
|
+
|
|
1804
|
+
// The debounce collapses bursts into one save, but a save landing in a
|
|
1805
|
+
// LATER window (the burst's tail re-arming the timer, or an unrelated
|
|
1806
|
+
// second burst) would slip past an assertion taken right after the
|
|
1807
|
+
// first save. Wait out a full extra debounce window to prove there is
|
|
1808
|
+
// no straggler.
|
|
1809
|
+
await new Promise((resolve) => setTimeout(resolve, PATCH_DRAFT_SAVE_DEBOUNCE_MS + 200));
|
|
1810
|
+
expect(savesSoFar()).toBe(before + 1);
|
|
1811
|
+
});
|
|
1812
|
+
|
|
1813
|
+
// #1914's debounce must not resurrect a draft after it was intentionally
|
|
1814
|
+
// ended: discardDraft() clears the pending timer (render-edit.tsx:735-738)
|
|
1815
|
+
// specifically so a patch() immediately followed by submit can't have its
|
|
1816
|
+
// debounced save land AFTER the discard and leave an orphaned row behind.
|
|
1817
|
+
test("a pending debounced patch-save is killed by submit's discard, not resurrected later", async () => {
|
|
1818
|
+
const { dispatcher, store, calls } = makeDraftDispatcher();
|
|
1819
|
+
let controls: RenderEditControls<TestValues> | undefined;
|
|
1820
|
+
|
|
1821
|
+
render(
|
|
1822
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
1823
|
+
<RenderEdit<TestValues>
|
|
1824
|
+
screen={makeDraftWizardScreen(true)}
|
|
1825
|
+
entity={orderEntity}
|
|
1826
|
+
featureName="orders"
|
|
1827
|
+
initial={{ title: "Acme", count: 0 }}
|
|
1828
|
+
writeCommand="order:create"
|
|
1829
|
+
onControlsReady={(c) => {
|
|
1830
|
+
controls = c;
|
|
1831
|
+
}}
|
|
1832
|
+
/>
|
|
1833
|
+
</DispatcherProvider>,
|
|
1834
|
+
);
|
|
1835
|
+
|
|
1836
|
+
fireEvent.click(screen.getByTestId("render-edit-wizard-next"));
|
|
1837
|
+
expect(screen.getByTestId("field-count")).toBeTruthy();
|
|
1838
|
+
|
|
1839
|
+
// patch() arms a 500ms debounced draft save, then submit fires well
|
|
1840
|
+
// inside that window — before the timer could ever run on its own.
|
|
1841
|
+
act(() => {
|
|
1842
|
+
controls?.patch({ count: 9 });
|
|
1843
|
+
});
|
|
1844
|
+
await act(async () => {
|
|
1845
|
+
fireEvent.click(screen.getByTestId("render-edit-submit"));
|
|
1846
|
+
await Promise.resolve();
|
|
1847
|
+
});
|
|
1848
|
+
|
|
1849
|
+
expect(calls).toContain("form-draft:write:discard");
|
|
1850
|
+
const savesAtSubmit = calls.filter((c) => c === "form-draft:write:save").length;
|
|
1851
|
+
expect(store.current).toBeNull();
|
|
1852
|
+
|
|
1853
|
+
// Wait past the debounce window the patch() call armed. If discard
|
|
1854
|
+
// hadn't cleared the timer, a straggling save would land here and
|
|
1855
|
+
// resurrect the just-discarded draft.
|
|
1856
|
+
await new Promise((resolve) => setTimeout(resolve, PATCH_DRAFT_SAVE_DEBOUNCE_MS + 200));
|
|
1857
|
+
|
|
1858
|
+
expect(calls.filter((c) => c === "form-draft:write:save").length).toBe(savesAtSubmit);
|
|
1859
|
+
expect(store.current).toBeNull();
|
|
1644
1860
|
});
|
|
1645
1861
|
|
|
1646
1862
|
// A rejected discard write (network error) must be best-effort: the entity
|
|
@@ -1864,6 +2080,71 @@ describe("RenderEdit create-mode draftId (issue #1913)", () => {
|
|
|
1864
2080
|
expect(titles).toEqual(["Session A", "Session B"]);
|
|
1865
2081
|
});
|
|
1866
2082
|
|
|
2083
|
+
// Same-tab variant of the test above: two RenderEdit instances sharing
|
|
2084
|
+
// ONE DraftStorage AND one dispatcher — the layout a single browser tab
|
|
2085
|
+
// with two open create forms actually has (a fresh DraftStorage per
|
|
2086
|
+
// session only happens across tabs). The mint keys off each component's
|
|
2087
|
+
// own `draftId` React state, not a re-read of the shared storage slot, so
|
|
2088
|
+
// this must isolate too.
|
|
2089
|
+
test("two parallel create sessions sharing one DraftStorage still get different draftKeys (#1908)", async () => {
|
|
2090
|
+
const { dispatcher, drafts } = makeMultiDraftDispatcher();
|
|
2091
|
+
const screenDef = makeDraftWizardScreen();
|
|
2092
|
+
const sharedDraftStorage = createFakeDraftStorage();
|
|
2093
|
+
|
|
2094
|
+
const sessionA = render(
|
|
2095
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
2096
|
+
<DraftStorageProvider value={sharedDraftStorage}>
|
|
2097
|
+
<RenderEdit<TestValues>
|
|
2098
|
+
screen={screenDef}
|
|
2099
|
+
entity={orderEntity}
|
|
2100
|
+
featureName="orders"
|
|
2101
|
+
initial={{ title: "", count: 0 }}
|
|
2102
|
+
writeCommand="order:create"
|
|
2103
|
+
/>
|
|
2104
|
+
</DraftStorageProvider>
|
|
2105
|
+
</DispatcherProvider>,
|
|
2106
|
+
);
|
|
2107
|
+
const sessionB = render(
|
|
2108
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
2109
|
+
<DraftStorageProvider value={sharedDraftStorage}>
|
|
2110
|
+
<RenderEdit<TestValues>
|
|
2111
|
+
screen={screenDef}
|
|
2112
|
+
entity={orderEntity}
|
|
2113
|
+
featureName="orders"
|
|
2114
|
+
initial={{ title: "", count: 0 }}
|
|
2115
|
+
writeCommand="order:create"
|
|
2116
|
+
/>
|
|
2117
|
+
</DraftStorageProvider>
|
|
2118
|
+
</DispatcherProvider>,
|
|
2119
|
+
);
|
|
2120
|
+
|
|
2121
|
+
const titleA = within(sessionA.container)
|
|
2122
|
+
.getByTestId("field-title")
|
|
2123
|
+
.querySelector("input") as HTMLInputElement;
|
|
2124
|
+
fireEvent.change(titleA, { target: { value: "Session A" } });
|
|
2125
|
+
await act(async () => {
|
|
2126
|
+
fireEvent.submit(within(sessionA.container).getByTestId("render-edit-form"));
|
|
2127
|
+
await Promise.resolve();
|
|
2128
|
+
});
|
|
2129
|
+
|
|
2130
|
+
const titleB = within(sessionB.container)
|
|
2131
|
+
.getByTestId("field-title")
|
|
2132
|
+
.querySelector("input") as HTMLInputElement;
|
|
2133
|
+
fireEvent.change(titleB, { target: { value: "Session B" } });
|
|
2134
|
+
await act(async () => {
|
|
2135
|
+
fireEvent.submit(within(sessionB.container).getByTestId("render-edit-form"));
|
|
2136
|
+
await Promise.resolve();
|
|
2137
|
+
});
|
|
2138
|
+
|
|
2139
|
+
const prefix = `${screenDef.id}:new:`;
|
|
2140
|
+
const createDraftKeys = [...drafts.keys()].filter((k) => k.startsWith(prefix));
|
|
2141
|
+
expect(createDraftKeys).toHaveLength(2);
|
|
2142
|
+
const [keyA, keyB] = createDraftKeys as [string, string];
|
|
2143
|
+
expect(keyA).not.toBe(keyB);
|
|
2144
|
+
const titles = createDraftKeys.map((k) => drafts.get(k)?.values["title"]).sort();
|
|
2145
|
+
expect(titles).toEqual(["Session A", "Session B"]);
|
|
2146
|
+
});
|
|
2147
|
+
|
|
1867
2148
|
test("cleared storage with exactly one open draft still shows the picker — no silent cross-tab adopt", async () => {
|
|
1868
2149
|
const { dispatcher, drafts } = makeMultiDraftDispatcher();
|
|
1869
2150
|
const screenDef = makeDraftWizardScreen();
|
|
@@ -2159,6 +2440,60 @@ describe("RenderEdit create-mode draftId (issue #1913)", () => {
|
|
|
2159
2440
|
|
|
2160
2441
|
expect([...drafts.keys()]).toEqual([`${screenDef.id}:order-77`]);
|
|
2161
2442
|
});
|
|
2443
|
+
|
|
2444
|
+
// draftKey is `${screen.id}:${entityId}` — opening a different entity on
|
|
2445
|
+
// the same screen must not resume the previous entity's saved draft.
|
|
2446
|
+
test("switching entityId does not load the previous entity's draft", async () => {
|
|
2447
|
+
const { dispatcher, drafts } = makeMultiDraftDispatcher();
|
|
2448
|
+
const screenDef = makeDraftWizardScreen();
|
|
2449
|
+
drafts.set(`${screenDef.id}:order-A`, {
|
|
2450
|
+
values: { title: "Draft for A", count: 1 },
|
|
2451
|
+
stepIndex: 0,
|
|
2452
|
+
savedAt: "2026-01-01T00:00:00Z",
|
|
2453
|
+
});
|
|
2454
|
+
|
|
2455
|
+
const first = render(
|
|
2456
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
2457
|
+
<DraftStorageProvider value={createFakeDraftStorage()}>
|
|
2458
|
+
<RenderEdit<TestValues>
|
|
2459
|
+
screen={screenDef}
|
|
2460
|
+
entity={orderEntity}
|
|
2461
|
+
featureName="orders"
|
|
2462
|
+
initial={{ title: "", count: 0 }}
|
|
2463
|
+
entityId="order-A"
|
|
2464
|
+
writeCommand="order:update"
|
|
2465
|
+
/>
|
|
2466
|
+
</DraftStorageProvider>
|
|
2467
|
+
</DispatcherProvider>,
|
|
2468
|
+
);
|
|
2469
|
+
|
|
2470
|
+
await waitFor(() =>
|
|
2471
|
+
expect(
|
|
2472
|
+
(screen.getByTestId("field-title").querySelector("input") as HTMLInputElement).value,
|
|
2473
|
+
).toBe("Draft for A"),
|
|
2474
|
+
);
|
|
2475
|
+
first.unmount();
|
|
2476
|
+
|
|
2477
|
+
render(
|
|
2478
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
2479
|
+
<DraftStorageProvider value={createFakeDraftStorage()}>
|
|
2480
|
+
<RenderEdit<TestValues>
|
|
2481
|
+
screen={screenDef}
|
|
2482
|
+
entity={orderEntity}
|
|
2483
|
+
featureName="orders"
|
|
2484
|
+
initial={{ title: "", count: 0 }}
|
|
2485
|
+
entityId="order-B"
|
|
2486
|
+
writeCommand="order:update"
|
|
2487
|
+
/>
|
|
2488
|
+
</DraftStorageProvider>
|
|
2489
|
+
</DispatcherProvider>,
|
|
2490
|
+
);
|
|
2491
|
+
|
|
2492
|
+
const titleInputB = screen
|
|
2493
|
+
.getByTestId("field-title")
|
|
2494
|
+
.querySelector("input") as HTMLInputElement;
|
|
2495
|
+
expect(titleInputB.value).toBe("");
|
|
2496
|
+
});
|
|
2162
2497
|
});
|
|
2163
2498
|
|
|
2164
2499
|
describe("RenderEdit locked state (#1896)", () => {
|
|
@@ -112,6 +112,26 @@ export function renderWithSidebar(
|
|
|
112
112
|
return _render(ui, { wrapper: SidebarProviders, ...options });
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
+
// Nested PrimitivesProvider overrides the outer DefaultProviders one for
|
|
116
|
+
// this subtree — for tests exercising a fallback when a primitive isn't
|
|
117
|
+
// registered (e.g. StepBar undefined on an older PrimitivesRegistry).
|
|
118
|
+
export function renderWithPrimitivesOverride(
|
|
119
|
+
ui: ReactElement,
|
|
120
|
+
primitivesOverride: Partial<typeof defaultPrimitives>,
|
|
121
|
+
options?: Omit<RenderOptions, "wrapper">,
|
|
122
|
+
): RenderResult {
|
|
123
|
+
function Wrapper({ children }: { readonly children: ReactNode }): ReactNode {
|
|
124
|
+
return (
|
|
125
|
+
<DefaultProviders>
|
|
126
|
+
<PrimitivesProvider value={{ ...defaultPrimitives, ...primitivesOverride }}>
|
|
127
|
+
{children}
|
|
128
|
+
</PrimitivesProvider>
|
|
129
|
+
</DefaultProviders>
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
return _render(ui, { wrapper: Wrapper, ...options });
|
|
133
|
+
}
|
|
134
|
+
|
|
115
135
|
// ---------------------------------------------------------------------------
|
|
116
136
|
// Mock-Dispatcher-Helper
|
|
117
137
|
// ---------------------------------------------------------------------------
|
|
@@ -8,10 +8,11 @@ import type {
|
|
|
8
8
|
EntityDefinition,
|
|
9
9
|
EntityEditScreenDefinition,
|
|
10
10
|
} from "@cosmicdrift/kumiko-framework/ui-types";
|
|
11
|
+
import type { Dispatcher } from "@cosmicdrift/kumiko-headless";
|
|
11
12
|
import type { FeatureSchema } from "@cosmicdrift/kumiko-renderer";
|
|
12
13
|
import { DispatcherProvider, KumikoScreen } from "@cosmicdrift/kumiko-renderer";
|
|
13
14
|
import userEvent from "@testing-library/user-event";
|
|
14
|
-
import { createMockDispatcher, render, screen } from "./test-utils";
|
|
15
|
+
import { createMockDispatcher, render, renderWithPrimitivesOverride, screen } from "./test-utils";
|
|
15
16
|
|
|
16
17
|
const profileEntity = {
|
|
17
18
|
fields: {
|
|
@@ -70,6 +71,84 @@ describe("entityEdit wizard — presence validation on Next (fw#1910)", () => {
|
|
|
70
71
|
expect(screen.getByTestId("render-edit-wizard-step-label").textContent).toContain("2");
|
|
71
72
|
expect(screen.queryByTestId("field-fullName-errors")).toBeNull();
|
|
72
73
|
});
|
|
74
|
+
|
|
75
|
+
// handleWizardNext validates only the CURRENT step's fields
|
|
76
|
+
// (render-edit.tsx:679) — an unscoped controller.validate() regression
|
|
77
|
+
// would block Next on step 1 for a required field that only exists on
|
|
78
|
+
// step 2, permanently stuck on step 1. Submit must still enforce it.
|
|
79
|
+
test("a required field on a later step never blocks Next on an earlier step, but blocks submit", async () => {
|
|
80
|
+
const requiredEmailEntity = {
|
|
81
|
+
fields: {
|
|
82
|
+
fullName: { type: "text", required: true },
|
|
83
|
+
email: { type: "text", required: true },
|
|
84
|
+
},
|
|
85
|
+
} as unknown as EntityDefinition;
|
|
86
|
+
const requiredEmailSchema: FeatureSchema = {
|
|
87
|
+
featureName: "demo",
|
|
88
|
+
entities: { profile: requiredEmailEntity },
|
|
89
|
+
screens: [wizardScreen],
|
|
90
|
+
};
|
|
91
|
+
const { container } = render(
|
|
92
|
+
<DispatcherProvider dispatcher={createMockDispatcher()}>
|
|
93
|
+
<KumikoScreen schema={requiredEmailSchema} qn="demo:screen:profile-edit" />
|
|
94
|
+
</DispatcherProvider>,
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
const fullNameInput = container.querySelector("#kumiko-edit-fullName");
|
|
98
|
+
await userEvent.type(fullNameInput as Element, "Ada Lovelace");
|
|
99
|
+
await userEvent.click(screen.getByTestId("render-edit-wizard-next"));
|
|
100
|
+
|
|
101
|
+
expect(screen.getByTestId("render-edit-wizard-step-label").textContent).toContain("2");
|
|
102
|
+
|
|
103
|
+
await userEvent.click(screen.getByTestId("render-edit-submit"));
|
|
104
|
+
|
|
105
|
+
expect(screen.getByTestId("render-edit-wizard-step-label").textContent).toContain("2");
|
|
106
|
+
expect(screen.getByTestId("field-email-errors")).toBeTruthy();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// `fieldset[disabled]` also excludes its descendants' controls from a real
|
|
110
|
+
// `new FormData(form)` / native form submission — if render-edit's submit
|
|
111
|
+
// path ever read values that way instead of from controller/React state,
|
|
112
|
+
// disabling step 1's fieldset after Next would silently drop fullName on
|
|
113
|
+
// submit. Assert on the value the dispatcher actually receives, not just
|
|
114
|
+
// that step 2 shows no error.
|
|
115
|
+
test("submitting from a later step still sends an earlier step's value to the dispatcher", async () => {
|
|
116
|
+
const calls: Array<[string, unknown]> = [];
|
|
117
|
+
const write = (async (type: string, payload: unknown) => {
|
|
118
|
+
calls.push([type, payload]);
|
|
119
|
+
return { isSuccess: true, data: {} };
|
|
120
|
+
}) as unknown as Dispatcher["write"];
|
|
121
|
+
const { container } = render(
|
|
122
|
+
<DispatcherProvider dispatcher={createMockDispatcher({ write })}>
|
|
123
|
+
<KumikoScreen schema={schema} qn="demo:screen:profile-edit" />
|
|
124
|
+
</DispatcherProvider>,
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
const fullNameInput = container.querySelector("#kumiko-edit-fullName");
|
|
128
|
+
await userEvent.type(fullNameInput as Element, "Ada Lovelace");
|
|
129
|
+
await userEvent.click(screen.getByTestId("render-edit-wizard-next"));
|
|
130
|
+
await userEvent.click(screen.getByTestId("render-edit-submit"));
|
|
131
|
+
|
|
132
|
+
expect(calls).toHaveLength(1);
|
|
133
|
+
const [, payload] = calls[0] as [string, Record<string, unknown>];
|
|
134
|
+
expect(payload["fullName"]).toBe("Ada Lovelace");
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
// fw high-severity finding: `hidden` alone does not bar a field from the
|
|
138
|
+
// browser's native constraint validation, so a required field on an
|
|
139
|
+
// off-screen step blocked the whole form's `type="submit"` Next button —
|
|
140
|
+
// jsdom doesn't implement constraint validation, so this only reproduces
|
|
141
|
+
// as a DOM-shape check (fieldset[disabled] ancestor), not the actual
|
|
142
|
+
// browser-blocks-navigation symptom.
|
|
143
|
+
test("an off-screen step's fields sit inside a disabled fieldset, the current step's don't", async () => {
|
|
144
|
+
renderWizard();
|
|
145
|
+
|
|
146
|
+
const hiddenStepField = screen.getByTestId("field-email").closest("fieldset");
|
|
147
|
+
expect(hiddenStepField?.disabled).toBe(true);
|
|
148
|
+
|
|
149
|
+
const currentStepField = screen.getByTestId("field-fullName").closest("fieldset");
|
|
150
|
+
expect(currentStepField?.disabled).toBe(false);
|
|
151
|
+
});
|
|
73
152
|
});
|
|
74
153
|
|
|
75
154
|
// fw#1966: the wizard chrome used to only show a "Step X of Y" counter —
|
|
@@ -133,4 +212,53 @@ describe("entityEdit wizard — step bar (fw#1966)", () => {
|
|
|
133
212
|
const steps = container.querySelectorAll('[data-testid^="render-edit-wizard-steps-step-"]');
|
|
134
213
|
expect(steps.length).toBe(3);
|
|
135
214
|
});
|
|
215
|
+
|
|
216
|
+
// fw#1967: a PrimitivesRegistry without StepBar (older/custom registry)
|
|
217
|
+
// must stay on the plain "Step X of Y" label RenderEdit always had —
|
|
218
|
+
// this is the changeset's stated backward-compat guarantee.
|
|
219
|
+
test("falls back to the plain step label when the registry doesn't supply StepBar", () => {
|
|
220
|
+
renderWithPrimitivesOverride(
|
|
221
|
+
<DispatcherProvider dispatcher={createMockDispatcher()}>
|
|
222
|
+
<KumikoScreen schema={schema} qn="demo:screen:profile-edit" />
|
|
223
|
+
</DispatcherProvider>,
|
|
224
|
+
{ StepBar: undefined },
|
|
225
|
+
);
|
|
226
|
+
|
|
227
|
+
expect(screen.getByTestId("render-edit-wizard-step-label").textContent).toBe(
|
|
228
|
+
"Step 1 of 2 · Step 1",
|
|
229
|
+
);
|
|
230
|
+
expect(screen.queryByTestId("render-edit-wizard-steps-step-0")).toBeNull();
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
// fw#1967: a titleless section used to fall back to the stringified step
|
|
234
|
+
// number, duplicating the StepBar's own numbered badge ("3 3"). The
|
|
235
|
+
// fallback is now an empty label — only the badge shows.
|
|
236
|
+
test("a titleless section shows only the step number, not a duplicated digit", () => {
|
|
237
|
+
const untitledStepScreen: EntityEditScreenDefinition = {
|
|
238
|
+
id: "profile-edit-untitled",
|
|
239
|
+
type: "entityEdit",
|
|
240
|
+
entity: "profile",
|
|
241
|
+
layout: {
|
|
242
|
+
mode: "wizard",
|
|
243
|
+
sections: [
|
|
244
|
+
{ title: "Basics", fields: ["fullName"] },
|
|
245
|
+
{ title: "Contact", fields: ["email"] },
|
|
246
|
+
{ fields: [] },
|
|
247
|
+
],
|
|
248
|
+
},
|
|
249
|
+
};
|
|
250
|
+
const untitledStepSchema: FeatureSchema = {
|
|
251
|
+
featureName: "demo",
|
|
252
|
+
entities: { profile: profileEntity },
|
|
253
|
+
screens: [untitledStepScreen],
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
render(
|
|
257
|
+
<DispatcherProvider dispatcher={createMockDispatcher()}>
|
|
258
|
+
<KumikoScreen schema={untitledStepSchema} qn="demo:screen:profile-edit-untitled" />
|
|
259
|
+
</DispatcherProvider>,
|
|
260
|
+
);
|
|
261
|
+
|
|
262
|
+
expect(screen.getByTestId("render-edit-wizard-steps-step-2").textContent).toBe("3");
|
|
263
|
+
});
|
|
136
264
|
});
|
|
@@ -19,7 +19,7 @@ describe("mergeContentEditors", () => {
|
|
|
19
19
|
expect(merged["rich"]).toBe(richEditor);
|
|
20
20
|
});
|
|
21
21
|
|
|
22
|
-
test("
|
|
22
|
+
test("key collision → warns + last-wins applies", () => {
|
|
23
23
|
const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
|
|
24
24
|
const first = editor();
|
|
25
25
|
const second = editor();
|
|
@@ -37,7 +37,7 @@ describe("mergeContentEditors", () => {
|
|
|
37
37
|
warnSpy.mockRestore();
|
|
38
38
|
});
|
|
39
39
|
|
|
40
|
-
test("clientFeatures
|
|
40
|
+
test("clientFeatures without contentEditors are skipped", () => {
|
|
41
41
|
const features: ClientFeatureDefinition[] = [{ name: "a" }];
|
|
42
42
|
expect(mergeContentEditors(features)).toEqual({});
|
|
43
43
|
});
|
|
@@ -32,6 +32,10 @@ describe("PlainContentEditor", () => {
|
|
|
32
32
|
textarea.setSelectionRange(6, 6);
|
|
33
33
|
fireEvent.click(screen.getByText("{{name}}"));
|
|
34
34
|
expect(textarea.value).toBe("Hello {{name}} world");
|
|
35
|
+
// Caret-restore effect: focus + selection must land right after the
|
|
36
|
+
// inserted placeholder, not wherever the DOM happened to leave it.
|
|
37
|
+
expect(textarea.selectionStart).toBe(14);
|
|
38
|
+
expect(textarea.selectionEnd).toBe(14);
|
|
35
39
|
});
|
|
36
40
|
|
|
37
41
|
test("chip click replaces a selection instead of inserting alongside it", () => {
|
|
@@ -15,7 +15,10 @@ describe("RichContentEditor", () => {
|
|
|
15
15
|
);
|
|
16
16
|
// The textarea id proves #1794's "never an empty panel" fallback is the
|
|
17
17
|
// Suspense boundary — a null fallback would leave nothing to find here.
|
|
18
|
-
|
|
19
|
-
|
|
18
|
+
// TiptapEditor puts the same id on its contenteditable, so asserting
|
|
19
|
+
// presence alone wouldn't distinguish the two; assert the tag instead.
|
|
20
|
+
expect(document.getElementById(CONTENT_EDITOR_ELEMENT_ID)?.tagName).toBe("TEXTAREA");
|
|
21
|
+
const editable = await screen.findByText("hello");
|
|
22
|
+
expect(editable.closest('[contenteditable="true"]')).not.toBeNull();
|
|
20
23
|
});
|
|
21
24
|
});
|