@cosmicdrift/kumiko-renderer-web 0.192.0 → 0.193.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-renderer-web",
3
- "version": "0.192.0",
3
+ "version": "0.193.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.192.0",
20
- "@cosmicdrift/kumiko-headless": "0.192.0",
21
- "@cosmicdrift/kumiko-renderer": "0.192.0",
19
+ "@cosmicdrift/kumiko-dispatcher-live": "0.193.0",
20
+ "@cosmicdrift/kumiko-headless": "0.193.0",
21
+ "@cosmicdrift/kumiko-renderer": "0.193.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",
@@ -3,7 +3,7 @@ import type {
3
3
  EntityDefinition,
4
4
  EntityEditScreenDefinition,
5
5
  } from "@cosmicdrift/kumiko-framework/ui-types";
6
- import type { Dispatcher, SubmitResult } from "@cosmicdrift/kumiko-headless";
6
+ import type { Dispatcher, FormSnapshot, SubmitResult } from "@cosmicdrift/kumiko-headless";
7
7
  import {
8
8
  DispatcherProvider,
9
9
  DraftStorageProvider,
@@ -815,6 +815,44 @@ describe("RenderEdit — controlled mode (#1887)", () => {
815
815
  expect(titleInput.value).toBe("Acme");
816
816
  expect(screen.queryByTestId("render-edit-form-error")).toBeNull();
817
817
  });
818
+
819
+ // A caller like `schema={z.object({...})}` builds a fresh schema object on
820
+ // every render — unstable identity. If the effect depended on `schema`
821
+ // directly (not a ref), a parent that re-renders on every onChange call
822
+ // would refire the effect every time even though nothing in the form
823
+ // actually changed, risking an infinite update loop.
824
+ test("a parent re-rendering with a fresh schema object on every onChange does not loop", () => {
825
+ let calls = 0;
826
+ function Wrapper() {
827
+ const [, setTick] = useState(0);
828
+ const schema = z.object({ title: z.string().min(1) });
829
+ return (
830
+ <RenderEdit<TestValues>
831
+ screen={makeScreen()}
832
+ entity={orderEntity}
833
+ featureName="orders"
834
+ initial={{ title: "", count: 0, isUrgent: false }}
835
+ writeCommand="order:create"
836
+ schema={schema}
837
+ onChange={() => {
838
+ calls += 1;
839
+ // Cap so a still-broken implementation fails fast on a bounded
840
+ // count instead of hanging the test runner in an update loop.
841
+ if (calls < 15) setTick((t) => t + 1);
842
+ }}
843
+ />
844
+ );
845
+ }
846
+
847
+ render(
848
+ <DispatcherProvider dispatcher={makeDispatcher()}>
849
+ <Wrapper />
850
+ </DispatcherProvider>,
851
+ );
852
+
853
+ expect(calls).toBeGreaterThanOrEqual(1);
854
+ expect(calls).toBeLessThan(5);
855
+ });
818
856
  });
819
857
 
820
858
  // Issue #1916: proves FieldConditions (visible/readOnly/required) react to
@@ -1015,7 +1053,7 @@ describe("RenderEdit wizard mode", () => {
1015
1053
  );
1016
1054
 
1017
1055
  expect(screen.getByTestId("field-title")).toBeTruthy();
1018
- expect(screen.queryByTestId("field-count")).toBeNull();
1056
+ expect(screen.getByTestId("field-count").closest("[hidden]")).not.toBeNull();
1019
1057
  expect(screen.getByTestId("render-edit-wizard-step-label").textContent).toContain("1");
1020
1058
  });
1021
1059
 
@@ -1045,7 +1083,7 @@ describe("RenderEdit wizard mode", () => {
1045
1083
 
1046
1084
  expect(screen.getByTestId("field-title-errors")).toBeTruthy();
1047
1085
  expect(screen.getByTestId("field-title")).toBeTruthy();
1048
- expect(screen.queryByTestId("field-count")).toBeNull();
1086
+ expect(screen.getByTestId("field-count").closest("[hidden]")).not.toBeNull();
1049
1087
  });
1050
1088
 
1051
1089
  test("Weiter advances to the next step once the current step is valid; last step shows the submit button", async () => {
@@ -1077,7 +1115,7 @@ describe("RenderEdit wizard mode", () => {
1077
1115
  await Promise.resolve();
1078
1116
  });
1079
1117
 
1080
- expect(screen.queryByTestId("field-title")).toBeNull();
1118
+ expect(screen.getByTestId("field-title").closest("[hidden]")).not.toBeNull();
1081
1119
  expect(screen.getByTestId("field-count")).toBeTruthy();
1082
1120
  expect(screen.getByTestId("render-edit-submit")).toBeTruthy();
1083
1121
  expect(screen.queryByTestId("render-edit-wizard-next")).toBeNull();
@@ -1121,6 +1159,170 @@ describe("RenderEdit wizard mode", () => {
1121
1159
  expect(titleInputAgain.value).toBe("Acme");
1122
1160
  expect(screen.queryByTestId("field-title-errors")).toBeNull();
1123
1161
  });
1162
+
1163
+ // A server-side field error can land on a field belonging to a step the
1164
+ // user isn't currently viewing — the old `setFormError(fieldIssues.length
1165
+ // === 0 ? result.error : null)` rule suppressed the banner in that case
1166
+ // (field issues exist) while the field itself was invisible on the
1167
+ // current step, silently hiding the error entirely.
1168
+ test("a server field error for a step that's not currently shown jumps the wizard there and shows the field error", async () => {
1169
+ const write = mock(
1170
+ async () =>
1171
+ ({
1172
+ isSuccess: false,
1173
+ error: {
1174
+ code: "validation_failed",
1175
+ httpStatus: 422,
1176
+ i18nKey: "kumiko.errors.validation",
1177
+ message: "Validation failed",
1178
+ details: {
1179
+ fields: [{ path: "title", code: "too_small", i18nKey: "kumiko.errors.required" }],
1180
+ },
1181
+ },
1182
+ }) as never,
1183
+ );
1184
+ render(
1185
+ <DispatcherProvider dispatcher={makeDispatcher(write)}>
1186
+ <RenderEdit<TestValues>
1187
+ screen={makeWizardScreen()}
1188
+ entity={orderEntity}
1189
+ featureName="orders"
1190
+ initial={{ title: "Acme", count: 0 }}
1191
+ writeCommand="order:create"
1192
+ />
1193
+ </DispatcherProvider>,
1194
+ );
1195
+
1196
+ await act(async () => {
1197
+ fireEvent.click(screen.getByTestId("render-edit-wizard-next"));
1198
+ await Promise.resolve();
1199
+ });
1200
+ const countInput = screen.getByTestId("field-count").querySelector("input") as HTMLInputElement;
1201
+ fireEvent.change(countInput, { target: { value: "5" } });
1202
+
1203
+ await act(async () => {
1204
+ fireEvent.click(screen.getByTestId("render-edit-submit"));
1205
+ await Promise.resolve();
1206
+ });
1207
+
1208
+ // Jumped back to the step that owns `title`, where the server error lives.
1209
+ expect(screen.getByTestId("field-title")).toBeTruthy();
1210
+ expect(screen.getByTestId("field-title-errors")).toBeTruthy();
1211
+ expect(screen.getByTestId("field-count").closest("[hidden]")).not.toBeNull();
1212
+ // The field itself shows the error — no redundant top-level banner.
1213
+ expect(screen.queryByTestId("render-edit-form-error")).toBeNull();
1214
+ });
1215
+
1216
+ // A root-level issue (e.g. a cross-field `.refine()`) has no field to jump
1217
+ // to — it must NOT be silently suppressed just because `details.fields`
1218
+ // is non-empty.
1219
+ test("a root-level server error with no matching field is not suppressed — the banner shows", async () => {
1220
+ const write = mock(
1221
+ async () =>
1222
+ ({
1223
+ isSuccess: false,
1224
+ error: {
1225
+ code: "validation_failed",
1226
+ httpStatus: 422,
1227
+ i18nKey: "kumiko.errors.validation",
1228
+ message: "Validation failed",
1229
+ details: {
1230
+ fields: [{ path: "(root)", code: "custom", i18nKey: "kumiko.errors.cross-field" }],
1231
+ },
1232
+ },
1233
+ }) as never,
1234
+ );
1235
+ render(
1236
+ <DispatcherProvider dispatcher={makeDispatcher(write)}>
1237
+ <RenderEdit<TestValues>
1238
+ screen={makeWizardScreen()}
1239
+ entity={orderEntity}
1240
+ featureName="orders"
1241
+ initial={{ title: "Acme", count: 0 }}
1242
+ writeCommand="order:create"
1243
+ />
1244
+ </DispatcherProvider>,
1245
+ );
1246
+
1247
+ await act(async () => {
1248
+ fireEvent.click(screen.getByTestId("render-edit-wizard-next"));
1249
+ await Promise.resolve();
1250
+ });
1251
+ const countInput = screen.getByTestId("field-count").querySelector("input") as HTMLInputElement;
1252
+ fireEvent.change(countInput, { target: { value: "5" } });
1253
+ await act(async () => {
1254
+ fireEvent.click(screen.getByTestId("render-edit-submit"));
1255
+ await Promise.resolve();
1256
+ });
1257
+
1258
+ expect(screen.getByTestId("render-edit-form-error")).toBeTruthy();
1259
+ });
1260
+
1261
+ // An extension section on an earlier wizard step used to unmount when the
1262
+ // wizard advanced (only the current step's section was rendered), which
1263
+ // tore down its useExtensionFormSubmit registration (registry.remove on
1264
+ // unmount). Finish then only ran the last-mounted step's handler and
1265
+ // silently dropped the earlier step's write. Steps must stay mounted
1266
+ // (hidden, not unmounted) so every step's handler survives to Finish.
1267
+ test("an extension section on an earlier wizard step still submits on Finish after navigating past it", async () => {
1268
+ const submitSpy = mock();
1269
+ const ComposedCF = (_: { entityName: string; entityId: string | null }) => {
1270
+ useExtensionFormSubmit({
1271
+ dirty: true,
1272
+ onSubmit: async (ctx) => {
1273
+ submitSpy(ctx);
1274
+ return { isSuccess: true as const };
1275
+ },
1276
+ });
1277
+ return <div data-testid="composed-cf" />;
1278
+ };
1279
+
1280
+ const screenDef: EntityEditScreenDefinition = {
1281
+ id: "orders:screen:order-wizard-extension",
1282
+ type: "entityEdit",
1283
+ entity: "order",
1284
+ layout: {
1285
+ mode: "wizard",
1286
+ sections: [
1287
+ {
1288
+ kind: "extension",
1289
+ title: "Custom Fields",
1290
+ component: { react: { __component: "ComposedCF" } },
1291
+ },
1292
+ { title: "Basics", columns: 1, fields: [{ field: "title" }] },
1293
+ ],
1294
+ },
1295
+ };
1296
+
1297
+ render(
1298
+ <DispatcherProvider dispatcher={makeDispatcher()}>
1299
+ <ExtensionSectionsProvider value={{ ComposedCF }}>
1300
+ <RenderEdit<TestValues>
1301
+ screen={screenDef}
1302
+ entity={orderEntity}
1303
+ featureName="orders"
1304
+ initial={{ title: "Acme", count: 0 } as TestValues}
1305
+ entityId="order-1"
1306
+ writeCommand="order:update"
1307
+ />
1308
+ </ExtensionSectionsProvider>
1309
+ </DispatcherProvider>,
1310
+ );
1311
+
1312
+ expect(screen.getByTestId("composed-cf")).toBeTruthy();
1313
+
1314
+ fireEvent.click(screen.getByTestId("render-edit-wizard-next"));
1315
+ expect(screen.getByTestId("field-title")).toBeTruthy();
1316
+ // Step 1's extension section stayed mounted (hidden), not unmounted.
1317
+ expect(screen.getByTestId("composed-cf").closest("[hidden]")).not.toBeNull();
1318
+
1319
+ await act(async () => {
1320
+ fireEvent.click(screen.getByTestId("render-edit-submit"));
1321
+ await Promise.resolve();
1322
+ });
1323
+
1324
+ expect(submitSpy).toHaveBeenCalledWith({ entityId: "order-1" });
1325
+ });
1124
1326
  });
1125
1327
 
1126
1328
  describe("RenderEdit wizard draft", () => {
@@ -1231,7 +1433,7 @@ describe("RenderEdit wizard draft", () => {
1231
1433
  );
1232
1434
 
1233
1435
  await waitFor(() => expect(screen.getByTestId("field-count")).toBeTruthy());
1234
- expect(screen.queryByTestId("field-title")).toBeNull();
1436
+ expect(screen.getByTestId("field-title").closest("[hidden]")).not.toBeNull();
1235
1437
  expect(screen.getByTestId("render-edit-wizard-step-label").textContent).toContain("2");
1236
1438
 
1237
1439
  fireEvent.click(screen.getByTestId("render-edit-wizard-back"));
@@ -1440,6 +1642,94 @@ describe("RenderEdit wizard draft", () => {
1440
1642
  await waitFor(() => expect(savesSoFar()).toBe(before + 1), { timeout: 3000 });
1441
1643
  expect(savesSoFar()).toBe(before + 1);
1442
1644
  });
1645
+
1646
+ // A rejected discard write (network error) must be best-effort: the entity
1647
+ // write already succeeded by the time discardDraft() runs, so a failure
1648
+ // here must not propagate out of handleSubmit and break onSubmit/
1649
+ // navigation — that would risk a duplicate submit on retry. Orphaned draft
1650
+ // rows are swept later by cleanup.job.ts.
1651
+ test("a rejected discard write does not break an otherwise successful submit", async () => {
1652
+ const seenResults: SubmitResult<unknown>[] = [];
1653
+ const write = mock(async (type: string) => {
1654
+ if (type === "form-draft:write:discard") throw new Error("network error");
1655
+ return { isSuccess: true, data: { id: "1" } };
1656
+ });
1657
+ const dispatcher = makeDispatcher(write as unknown as Dispatcher["write"]);
1658
+
1659
+ render(
1660
+ <DispatcherProvider dispatcher={dispatcher}>
1661
+ <DraftStorageProvider value={createFakeDraftStorage()}>
1662
+ <RenderEdit<TestValues>
1663
+ screen={makeDraftWizardScreen(true)}
1664
+ entity={orderEntity}
1665
+ featureName="orders"
1666
+ initial={{ title: "", count: 0 }}
1667
+ writeCommand="order:create"
1668
+ onSubmit={(r) => seenResults.push(r)}
1669
+ />
1670
+ </DraftStorageProvider>
1671
+ </DispatcherProvider>,
1672
+ );
1673
+
1674
+ const titleInput = screen.getByTestId("field-title").querySelector("input") as HTMLInputElement;
1675
+ fireEvent.change(titleInput, { target: { value: "Acme" } });
1676
+ await act(async () => {
1677
+ fireEvent.click(screen.getByTestId("render-edit-wizard-next"));
1678
+ await Promise.resolve();
1679
+ });
1680
+
1681
+ const countInput = screen.getByTestId("field-count").querySelector("input") as HTMLInputElement;
1682
+ fireEvent.change(countInput, { target: { value: "5" } });
1683
+ await act(async () => {
1684
+ fireEvent.click(screen.getByTestId("render-edit-submit"));
1685
+ await Promise.resolve();
1686
+ await Promise.resolve();
1687
+ });
1688
+
1689
+ // handleSubmit completed despite the rejected discard: onSubmit still
1690
+ // fired with the successful entity-write result, not an unhandled
1691
+ // rejection or an aborted handler.
1692
+ expect(seenResults).toHaveLength(1);
1693
+ expect(seenResults[0]?.isSuccess).toBe(true);
1694
+ });
1695
+
1696
+ // `disabled` means "no input/no write", not "no navigation" (#1896). The
1697
+ // old `if (disabled) return` guard sat BEFORE the wizard-next branch, and
1698
+ // Next itself carried `disabled={disabled}` — together they made a
1699
+ // disabled wizard unnavigable past step 0. Next must still step forward,
1700
+ // but without running validate() (would block on an empty required field
1701
+ // nobody can fill in) or saveDraft() (would mint a draftId / dispatch a
1702
+ // write — exactly what `disabled` forbids).
1703
+ test("disabled: clicking Next on step 0 navigates without validating or writing a draft", async () => {
1704
+ const schema = z.object({ title: z.string().min(1), count: z.number().optional() });
1705
+ const write = mock(async () => ({ isSuccess: true, data: { id: "1" } }) as never);
1706
+
1707
+ render(
1708
+ <DispatcherProvider dispatcher={makeDispatcher(write)}>
1709
+ <DraftStorageProvider value={createFakeDraftStorage()}>
1710
+ <RenderEdit<TestValues>
1711
+ screen={makeDraftWizardScreen(true)}
1712
+ entity={orderEntity}
1713
+ featureName="orders"
1714
+ initial={{ title: "", count: 0 }}
1715
+ writeCommand="order:create"
1716
+ schema={schema}
1717
+ disabled
1718
+ />
1719
+ </DraftStorageProvider>
1720
+ </DispatcherProvider>,
1721
+ );
1722
+
1723
+ expect(screen.getByTestId("field-title")).toBeTruthy();
1724
+ fireEvent.click(screen.getByTestId("render-edit-wizard-next"));
1725
+
1726
+ // Navigated despite the empty required `title` field.
1727
+ expect(screen.getByTestId("field-count")).toBeTruthy();
1728
+ expect(screen.getByTestId("field-title").closest("[hidden]")).not.toBeNull();
1729
+ // No validate() ran (no field error painted) and no write fired
1730
+ // (neither a form-draft:write:save nor an order:create).
1731
+ expect(write).not.toHaveBeenCalled();
1732
+ });
1443
1733
  });
1444
1734
 
1445
1735
  describe("RenderEdit create-mode draftId (issue #1913)", () => {
@@ -1574,11 +1864,13 @@ describe("RenderEdit create-mode draftId (issue #1913)", () => {
1574
1864
  expect(titles).toEqual(["Session A", "Session B"]);
1575
1865
  });
1576
1866
 
1577
- test("cleared storage with exactly one open draft resumes it automatically via list", async () => {
1867
+ test("cleared storage with exactly one open draft still shows the picker — no silent cross-tab adopt", async () => {
1578
1868
  const { dispatcher, drafts } = makeMultiDraftDispatcher();
1579
1869
  const screenDef = makeDraftWizardScreen();
1580
1870
  // Pre-seed one existing create-mode draft, as if minted by an earlier,
1581
- // now-storage-less session (new tab / cleared sessionStorage).
1871
+ // now-storage-less session (new tab / cleared sessionStorage) — or a
1872
+ // genuinely different parallel session on the same screen. Auto-adopting
1873
+ // it would silently hand this tab someone else's in-progress draft.
1582
1874
  drafts.set(`${screenDef.id}:new:existing-id`, {
1583
1875
  values: { title: "Resumed", count: 0 },
1584
1876
  stepIndex: 0,
@@ -1599,13 +1891,54 @@ describe("RenderEdit create-mode draftId (issue #1913)", () => {
1599
1891
  </DispatcherProvider>,
1600
1892
  );
1601
1893
 
1602
- await waitFor(() => {
1603
- const titleInput = screen
1604
- .getByTestId("field-title")
1605
- .querySelector("input") as HTMLInputElement;
1606
- expect(titleInput.value).toBe("Resumed");
1894
+ await waitFor(() => expect(screen.getByTestId("render-edit-draft-picker")).toBeTruthy());
1895
+ // No silent adopt: the form stays pristine until the user picks.
1896
+ const titleInput = screen.getByTestId("field-title").querySelector("input") as HTMLInputElement;
1897
+ expect(titleInput.value).toBe("");
1898
+ });
1899
+
1900
+ test("start-new on a one-candidate picker clears it without adopting, leaving the candidate untouched", async () => {
1901
+ const { dispatcher, drafts } = makeMultiDraftDispatcher();
1902
+ const screenDef = makeDraftWizardScreen();
1903
+ drafts.set(`${screenDef.id}:new:existing-id`, {
1904
+ values: { title: "Resumed", count: 0 },
1905
+ stepIndex: 0,
1906
+ savedAt: "2026-01-01T00:00:00Z",
1607
1907
  });
1908
+
1909
+ render(
1910
+ <DispatcherProvider dispatcher={dispatcher}>
1911
+ <DraftStorageProvider value={createFakeDraftStorage()}>
1912
+ <RenderEdit<TestValues>
1913
+ screen={screenDef}
1914
+ entity={orderEntity}
1915
+ featureName="orders"
1916
+ initial={{ title: "", count: 0 }}
1917
+ writeCommand="order:create"
1918
+ />
1919
+ </DraftStorageProvider>
1920
+ </DispatcherProvider>,
1921
+ );
1922
+
1923
+ await waitFor(() => expect(screen.getByTestId("render-edit-draft-picker")).toBeTruthy());
1924
+ fireEvent.click(screen.getByTestId("render-edit-draft-start-new"));
1608
1925
  expect(screen.queryByTestId("render-edit-draft-picker")).toBeNull();
1926
+
1927
+ const titleInput = screen.getByTestId("field-title").querySelector("input") as HTMLInputElement;
1928
+ fireEvent.change(titleInput, { target: { value: "Brand new" } });
1929
+ await act(async () => {
1930
+ fireEvent.click(screen.getByTestId("render-edit-wizard-next"));
1931
+ await Promise.resolve();
1932
+ });
1933
+
1934
+ // Minted a genuinely different draftId — the ignored candidate is untouched.
1935
+ expect(drafts.get(`${screenDef.id}:new:existing-id`)?.values["title"]).toBe("Resumed");
1936
+ const prefix = `${screenDef.id}:new:`;
1937
+ const mintedKeys = [...drafts.keys()].filter(
1938
+ (k) => k.startsWith(prefix) && k !== `${prefix}existing-id`,
1939
+ );
1940
+ expect(mintedKeys).toHaveLength(1);
1941
+ expect(drafts.get(mintedKeys[0] as string)?.values["title"]).toBe("Brand new");
1609
1942
  });
1610
1943
 
1611
1944
  test("cleared storage with multiple open drafts shows a picker; picking one resumes it", async () => {
@@ -1739,8 +2072,10 @@ describe("RenderEdit create-mode draftId (issue #1913)", () => {
1739
2072
  </DispatcherProvider>,
1740
2073
  );
1741
2074
 
1742
- // The lone open draft (`other-session`) auto-adopts on mount — the same
1743
- // path a cleared-storage tab takes. Wait for that to settle first.
2075
+ // The lone open draft (`other-session`) shows in the picker on mount —
2076
+ // the user explicitly picks it (no silent cross-tab auto-adopt).
2077
+ await waitFor(() => expect(screen.getByTestId("render-edit-draft-picker")).toBeTruthy());
2078
+ fireEvent.click(screen.getByTestId(`render-edit-draft-pick-${screenDef.id}:new:other-session`));
1744
2079
  await waitFor(() => {
1745
2080
  const titleInput = screen
1746
2081
  .getByTestId("field-title")
@@ -1788,7 +2123,7 @@ describe("RenderEdit create-mode draftId (issue #1913)", () => {
1788
2123
  });
1789
2124
 
1790
2125
  expect(listCallCount).toBe(1);
1791
- expect(screen.queryByTestId("field-title")).toBeNull();
2126
+ expect(screen.getByTestId("field-title").closest("[hidden]")).not.toBeNull();
1792
2127
  expect(screen.queryByTestId("render-edit-draft-picker")).toBeNull();
1793
2128
  const countAfterSubmit = screen
1794
2129
  .getByTestId("field-count")
@@ -1892,6 +2227,91 @@ describe("RenderEdit locked state (#1896)", () => {
1892
2227
  });
1893
2228
  });
1894
2229
 
2230
+ describe("RenderEdit hideActions (host-driven action bar)", () => {
2231
+ test("hideActions renders the fields but drops RenderEdit's own action bar", () => {
2232
+ render(
2233
+ <DispatcherProvider dispatcher={makeDispatcher()}>
2234
+ <RenderEdit<TestValues>
2235
+ screen={makeScreen()}
2236
+ entity={orderEntity}
2237
+ featureName="orders"
2238
+ initial={{ title: "Acme", count: 1, isUrgent: false }}
2239
+ writeCommand="order:create"
2240
+ hideActions
2241
+ />
2242
+ </DispatcherProvider>,
2243
+ );
2244
+
2245
+ expect(screen.getByTestId("field-title").querySelector("input")).toBeTruthy();
2246
+ expect(screen.queryByTestId("render-edit-submit")).toBeNull();
2247
+ });
2248
+
2249
+ test("without hideActions, the submit button stays (existing behaviour unchanged)", () => {
2250
+ render(
2251
+ <DispatcherProvider dispatcher={makeDispatcher()}>
2252
+ <RenderEdit<TestValues>
2253
+ screen={makeScreen()}
2254
+ entity={orderEntity}
2255
+ featureName="orders"
2256
+ initial={{ title: "Acme", count: 1, isUrgent: false }}
2257
+ writeCommand="order:create"
2258
+ />
2259
+ </DispatcherProvider>,
2260
+ );
2261
+
2262
+ expect(screen.getByTestId("render-edit-submit")).toBeTruthy();
2263
+ });
2264
+
2265
+ test("controls.submit() writes an unchanged, pre-filled form through customSubmit — the built-in button (disabled while unchanged) cannot do this", async () => {
2266
+ const customSubmit = mock(
2267
+ async (_snapshot: FormSnapshot<TestValues>): Promise<SubmitResult<unknown>> => ({
2268
+ validationBlocked: false,
2269
+ isSuccess: true,
2270
+ data: { id: "42" },
2271
+ }),
2272
+ );
2273
+ const onSubmit = mock((_result: SubmitResult<unknown>) => {});
2274
+ let controls: RenderEditControls<TestValues> | undefined;
2275
+ render(
2276
+ <DispatcherProvider dispatcher={makeDispatcher()}>
2277
+ <RenderEdit<TestValues>
2278
+ screen={makeScreen()}
2279
+ entity={orderEntity}
2280
+ featureName="orders"
2281
+ initial={{ title: "Acme", count: 1, isUrgent: false }}
2282
+ customSubmit={customSubmit}
2283
+ onSubmit={onSubmit}
2284
+ onControlsReady={(c) => {
2285
+ controls = c;
2286
+ }}
2287
+ />
2288
+ </DispatcherProvider>,
2289
+ );
2290
+
2291
+ // Nothing was ever edited — the built-in save button proves it by
2292
+ // staying disabled. controls.submit() has no such guard and must
2293
+ // still write, which is exactly the point of this test.
2294
+ expect((screen.getByTestId("render-edit-submit") as HTMLButtonElement).disabled).toBe(true);
2295
+
2296
+ await act(async () => {
2297
+ await controls?.submit();
2298
+ });
2299
+
2300
+ expect(customSubmit).toHaveBeenCalledTimes(1);
2301
+ expect(customSubmit.mock.calls[0]?.[0]?.values).toEqual({
2302
+ title: "Acme",
2303
+ count: 1,
2304
+ isUrgent: false,
2305
+ });
2306
+ expect(onSubmit).toHaveBeenCalledTimes(1);
2307
+ expect(onSubmit.mock.calls[0]?.[0]).toEqual({
2308
+ validationBlocked: false,
2309
+ isSuccess: true,
2310
+ data: { id: "42" },
2311
+ });
2312
+ });
2313
+ });
2314
+
1895
2315
  describe("RenderEdit fields filter", () => {
1896
2316
  const filterEntity = {
1897
2317
  fields: {
@@ -55,8 +55,8 @@ describe("entityEdit wizard — presence validation on Next (fw#1910)", () => {
55
55
 
56
56
  expect(screen.getByTestId("render-edit-wizard-step-label").textContent).toContain("1");
57
57
  expect(screen.getByTestId("field-fullName-errors")).toBeTruthy();
58
- // Step 2's field never mounts — the transition was blocked.
59
- expect(document.querySelector("#kumiko-edit-email")).toBeNull();
58
+ // Step 2's field stays mounted but hidden — the transition was blocked.
59
+ expect(screen.getByTestId("field-email").closest("[hidden]")).not.toBeNull();
60
60
  });
61
61
 
62
62
  test("filling the required field allows Next to advance to step 2", async () => {
@@ -166,7 +166,7 @@ describe("defaultCellRender", () => {
166
166
  });
167
167
 
168
168
  test("money → { amount, currency } formatiert, kein [object Object]", () => {
169
- const result = defaultCellRender({ amount: 45000, currency: "EUR" }, "money");
169
+ const result = defaultCellRender({ amount: 450, currency: "EUR" }, "money");
170
170
  expect(result).not.toBe("[object Object]");
171
171
  expect(result.replace(/[^0-9]/g, "")).toBe("45000");
172
172
  });
@@ -326,14 +326,17 @@ describe("EmbeddedListInput — totals", () => {
326
326
  });
327
327
 
328
328
  describe("EmbeddedListInput — tab-to-add-row", () => {
329
- test("Tab on the last cell of the last row (no maxItems limit) fires onAddRow and prevents default", () => {
329
+ // COLUMNS' last column ("amount") is derived, so its control is rendered
330
+ // disabled and never focusable/keydown-reachable in a real browser — the
331
+ // append-row handler must live on "quantity", the last EDITABLE column.
332
+ test("Tab on the last editable cell of the last row (no maxItems limit) fires onAddRow and prevents default", () => {
330
333
  const rows = [{ description: "A", quantity: 1, amount: 100 }];
331
334
  const onAddRow = mock(() => {});
332
335
  renderWithLocale(<EmbeddedListInput {...baseProps({ rows, onAddRow })} />);
333
336
  const desktop = within(screen.getByTestId("lines-desktop"));
334
- const lastCell = desktop.getByTestId("lines-cell-0-amount");
335
- const input = lastCell.querySelector("input");
336
- if (input === null) throw new Error("expected an <input> inside the last cell");
337
+ const lastEditableCell = desktop.getByTestId("lines-cell-0-quantity");
338
+ const input = lastEditableCell.querySelector("input");
339
+ if (input === null) throw new Error("expected an <input> inside the last editable cell");
337
340
 
338
341
  // fireEvent returns false when the event's default was prevented —
339
342
  // same return-value convention as native dispatchEvent.
@@ -347,9 +350,26 @@ describe("EmbeddedListInput — tab-to-add-row", () => {
347
350
  const onAddRow = mock(() => {});
348
351
  renderWithLocale(<EmbeddedListInput {...baseProps({ rows, onAddRow, maxItems: 1 })} />);
349
352
  const desktop = within(screen.getByTestId("lines-desktop"));
350
- const lastCell = desktop.getByTestId("lines-cell-0-amount");
351
- const input = lastCell.querySelector("input");
352
- if (input === null) throw new Error("expected an <input> inside the last cell");
353
+ const lastEditableCell = desktop.getByTestId("lines-cell-0-quantity");
354
+ const input = lastEditableCell.querySelector("input");
355
+ if (input === null) throw new Error("expected an <input> inside the last editable cell");
356
+
357
+ fireEvent.keyDown(input, { key: "Tab", code: "Tab" });
358
+ expect(onAddRow).not.toHaveBeenCalled();
359
+ });
360
+
361
+ // Regression: a derived trailing column (e.g. "amount") used to be treated
362
+ // as the last cell (columns.length - 1), but its control is disabled and
363
+ // unreachable via Tab/Enter in a real browser — the handler must attach
364
+ // to "quantity" (last editable), not "amount" (last, but derived).
365
+ test("Tab on the derived trailing cell ('amount') does not fire onAddRow — it isn't the last editable column", () => {
366
+ const rows = [{ description: "A", quantity: 1, amount: 100 }];
367
+ const onAddRow = mock(() => {});
368
+ renderWithLocale(<EmbeddedListInput {...baseProps({ rows, onAddRow })} />);
369
+ const desktop = within(screen.getByTestId("lines-desktop"));
370
+ const derivedCell = desktop.getByTestId("lines-cell-0-amount");
371
+ const input = derivedCell.querySelector("input");
372
+ if (input === null) throw new Error("expected an <input> inside the derived cell");
353
373
 
354
374
  fireEvent.keyDown(input, { key: "Tab", code: "Tab" });
355
375
  expect(onAddRow).not.toHaveBeenCalled();
@@ -405,14 +425,14 @@ describe("EmbeddedListInput — paste", () => {
405
425
  });
406
426
 
407
427
  describe("EmbeddedListInput — Enter-to-add-row (#1839)", () => {
408
- test("Enter on the last cell of the last row fires onAddRow and prevents default, same as Tab", () => {
428
+ test("Enter on the last editable cell of the last row fires onAddRow and prevents default, same as Tab", () => {
409
429
  const rows = [{ description: "A", quantity: 1, amount: 100 }];
410
430
  const onAddRow = mock(() => {});
411
431
  renderWithLocale(<EmbeddedListInput {...baseProps({ rows, onAddRow })} />);
412
432
  const desktop = within(screen.getByTestId("lines-desktop"));
413
- const lastCell = desktop.getByTestId("lines-cell-0-amount");
414
- const input = lastCell.querySelector("input");
415
- if (input === null) throw new Error("expected an <input> inside the last cell");
433
+ const lastEditableCell = desktop.getByTestId("lines-cell-0-quantity");
434
+ const input = lastEditableCell.querySelector("input");
435
+ if (input === null) throw new Error("expected an <input> inside the last editable cell");
416
436
 
417
437
  const notPrevented = fireEvent.keyDown(input, { key: "Enter", code: "Enter" });
418
438
  expect(onAddRow).toHaveBeenCalledTimes(1);
@@ -427,9 +447,9 @@ describe("EmbeddedListInput — Enter-to-add-row (#1839)", () => {
427
447
  const onAddRow = mock(() => {});
428
448
  renderWithLocale(<EmbeddedListInput {...baseProps({ rows, onAddRow, maxItems: 1 })} />);
429
449
  const desktop = within(screen.getByTestId("lines-desktop"));
430
- const lastCell = desktop.getByTestId("lines-cell-0-amount");
431
- const input = lastCell.querySelector("input");
432
- if (input === null) throw new Error("expected an <input> inside the last cell");
450
+ const lastEditableCell = desktop.getByTestId("lines-cell-0-quantity");
451
+ const input = lastEditableCell.querySelector("input");
452
+ if (input === null) throw new Error("expected an <input> inside the last editable cell");
433
453
 
434
454
  fireEvent.keyDown(input, { key: "Enter", code: "Enter" });
435
455
  expect(onAddRow).not.toHaveBeenCalled();
@@ -406,6 +406,13 @@ export function EmbeddedListInput({
406
406
 
407
407
  const cellId = (rowIndex: number, field: string): string => `${id}-${rowIndex}-${field}`;
408
408
 
409
+ // handleLastCellKeyDown must attach to the last EDITABLE column, not
410
+ // columns.length - 1 — a derived column there is rendered disabled
411
+ // (renderCellControl) and never receives focus/keydown, which would
412
+ // silently kill Tab/Enter-appends-a-row whenever the table ends in a
413
+ // derived column (e.g. a trailing "amount" total).
414
+ const lastEditableIndex = columns.findLastIndex((column) => !column.derived);
415
+
409
416
  const showControls = disabled !== true;
410
417
  const addDisabled = maxItems !== undefined && rows.length >= maxItems;
411
418
 
@@ -500,7 +507,7 @@ export function EmbeddedListInput({
500
507
  >
501
508
  <TableRow data-testid={testIdFor(`row-${rowIndex}`)}>
502
509
  {columns.map((column, columnIndex) => {
503
- const isLastCell = isLastRow && columnIndex === columns.length - 1;
510
+ const isLastCell = isLastRow && columnIndex === lastEditableIndex;
504
511
  const issues = cellIssues?.[`${rowIndex}.${column.field}`];
505
512
  return (
506
513
  <TableCell
@@ -614,7 +621,7 @@ export function EmbeddedListInput({
614
621
  className="flex flex-col gap-3 rounded-lg border bg-card p-4"
615
622
  >
616
623
  {columns.map((column, columnIndex) => {
617
- const isLastCell = isLastRow && columnIndex === columns.length - 1;
624
+ const isLastCell = isLastRow && columnIndex === lastEditableIndex;
618
625
  const issues = cellIssues?.[`${rowIndex}.${column.field}`];
619
626
  return (
620
627
  // biome-ignore lint/a11y/noStaticElementInteractions: paste/keydown are delegated from the focusable cell control rendered inside, not direct interaction on this wrapper div.
@@ -99,7 +99,7 @@ import { FileUploadInput } from "./file-upload";
99
99
  import { DefaultLightbox } from "./lightbox";
100
100
  import { LocatedTimestampInput } from "./located-timestamp-input";
101
101
  import { DefaultModal } from "./modal";
102
- import { formatMoney, MoneyInput } from "./money-input";
102
+ import { currencyDecimals, formatMoney, MoneyInput } from "./money-input";
103
103
  import { TimestampInput } from "./timestamp-input";
104
104
  import { useToast } from "./toast";
105
105
  import { TzInput } from "./tz-input";
@@ -386,6 +386,7 @@ function DefaultInput(props: InputProps): ReactNode {
386
386
  {...common}
387
387
  data-testid={props.testId}
388
388
  value={props.value}
389
+ step={props.step}
389
390
  onChange={(e: ChangeEvent<HTMLInputElement>) => {
390
391
  const v = e.target.value;
391
392
  props.onChange(v === "" ? undefined : Number(v));
@@ -1444,10 +1445,13 @@ export function defaultCellRender(
1444
1445
  // can render differently in a table vs. a form on the same screen.
1445
1446
  // Mid-term: pass the app locale down here too, analogous to render-field.
1446
1447
  //
1447
- // formatMoney expects minor units. rehydrateMoney returns amount in MAJOR
1448
- // units plus amountMinor (cents). Prefer amountMinor when present; legacy
1449
- // shapes without it still carry cents in `amount`.
1450
- const minor = typeof value.amountMinor === "number" ? value.amountMinor : value.amount;
1448
+ // formatMoney expects minor units scaled by currencyDecimals(currency).
1449
+ // rehydrateMoney's `amountMinor` is scaled by a flat MINOR_UNIT_SCALE=100
1450
+ // instead, which disagrees with currencyDecimals for non-2-decimal
1451
+ // currencies (JPY: 0 decimals 100x too high; BHD: 3 decimals → 10x too
1452
+ // low). Deriving minor units from `amount` keeps this consistent with
1453
+ // render-field.tsx's moneyMinorValue.
1454
+ const minor = Math.round(value.amount * 10 ** currencyDecimals(value.currency));
1451
1455
  return formatMoney(minor, value.currency);
1452
1456
  }
1453
1457
  if (type === "select") {
@@ -1718,6 +1722,7 @@ function DefaultSection({
1718
1722
  actions,
1719
1723
  variant = "default",
1720
1724
  testId,
1725
+ hidden,
1721
1726
  }: SectionProps): ReactNode {
1722
1727
  const insideForm = useContext(InsideFormContext);
1723
1728
 
@@ -1753,9 +1758,11 @@ function DefaultSection({
1753
1758
  return (
1754
1759
  <section
1755
1760
  data-testid={testId}
1761
+ hidden={hidden}
1756
1762
  className={cn(
1757
1763
  "flex flex-col gap-4 px-6 py-6",
1758
1764
  variant === "destructive" && "border-l-2 border-destructive/40",
1765
+ hidden && "hidden",
1759
1766
  )}
1760
1767
  >
1761
1768
  {header}
@@ -1782,10 +1789,12 @@ function DefaultSection({
1782
1789
  return (
1783
1790
  <div
1784
1791
  data-testid={testId}
1792
+ hidden={hidden}
1785
1793
  className={cn(
1786
1794
  cardSurface(),
1787
1795
  "overflow-hidden",
1788
1796
  variant === "destructive" && "border-destructive/40",
1797
+ hidden && "hidden",
1789
1798
  )}
1790
1799
  >
1791
1800
  <div className="flex flex-col gap-4 px-6 py-6">
@@ -295,6 +295,119 @@ describe("InfinityList", () => {
295
295
  expect(calls[2]).toEqual({ limit: 1 });
296
296
  });
297
297
 
298
+ // Regression for the "logic inversion" bug: staleRows used to keep
299
+ // exactly the rows MISSING from the fresh page-1 response, so a row
300
+ // deleted elsewhere never left the list.
301
+ test("Row aus Seite 1 gelöscht → verschwindet nach Refresh", async () => {
302
+ const calls: Array<Readonly<Record<string, unknown>>> = [];
303
+ const dispatcher = createMockDispatcher({
304
+ query: ((_type: string, payload: Readonly<Record<string, unknown>>) => {
305
+ calls.push(payload);
306
+ if (calls.length === 1) {
307
+ return Promise.resolve({
308
+ isSuccess: true,
309
+ data: {
310
+ rows: [
311
+ { id: "m1", subject: "Erste" },
312
+ { id: "m2", subject: "Zweite" },
313
+ ],
314
+ nextCursor: null,
315
+ },
316
+ });
317
+ }
318
+ // Live refresh: m1 was deleted elsewhere, only m2 remains on page 1.
319
+ return Promise.resolve({
320
+ isSuccess: true,
321
+ data: { rows: [{ id: "m2", subject: "Zweite" }], nextCursor: null },
322
+ });
323
+ }) as unknown as Dispatcher["query"],
324
+ });
325
+ const fake = makeFakeLiveEvents();
326
+
327
+ renderWithLive(list("inbox:query:message:list"), dispatcher, fake.subscriber);
328
+
329
+ await waitFor(() => expect(screen.getByText("Erste")).toBeTruthy());
330
+ expect(screen.getByText("Zweite")).toBeTruthy();
331
+
332
+ act(() => {
333
+ fake.inject("message.deleted", {
334
+ id: "m1",
335
+ aggregateType: "message",
336
+ version: 1,
337
+ payload: {},
338
+ createdAt: "",
339
+ });
340
+ });
341
+
342
+ await waitFor(() => expect(screen.queryByText("Erste")).toBeNull());
343
+ expect(screen.getByText("Zweite")).toBeTruthy();
344
+ });
345
+
346
+ // A row that was never part of page 1 (it lives on an already-loaded
347
+ // page 2) must survive a page-1-only live refresh even when it isn't in
348
+ // the fresh response — the fix must not treat "not in freshIds" alone
349
+ // as reason to drop a row.
350
+ test("Row aus Seite 2 bleibt nach Seite-1-Refresh erhalten", async () => {
351
+ const calls: Array<Readonly<Record<string, unknown>>> = [];
352
+ const dispatcher = createMockDispatcher({
353
+ query: ((_type: string, payload: Readonly<Record<string, unknown>>) => {
354
+ calls.push(payload);
355
+ if (calls.length === 1) {
356
+ return Promise.resolve({
357
+ isSuccess: true,
358
+ data: { rows: [{ id: "m1", subject: "Erste" }], nextCursor: "c1" },
359
+ });
360
+ }
361
+ if (calls.length === 2) {
362
+ return Promise.resolve({
363
+ isSuccess: true,
364
+ data: { rows: [{ id: "m2", subject: "Zweite" }], nextCursor: null },
365
+ });
366
+ }
367
+ // Live refresh of page 1: m1 was deleted elsewhere, m3 arrives new.
368
+ // m2 lives on page 2 and is outside this fetch's scope.
369
+ return Promise.resolve({
370
+ isSuccess: true,
371
+ data: { rows: [{ id: "m3", subject: "Neu" }], nextCursor: null },
372
+ });
373
+ }) as unknown as Dispatcher["query"],
374
+ });
375
+ const fake = makeFakeLiveEvents();
376
+
377
+ renderWithLive(
378
+ <InfinityList<Page, Row>
379
+ query="inbox:query:message:list"
380
+ pageSize={1}
381
+ rows={(data) => data.rows}
382
+ nextCursor={(data) => data.nextCursor}
383
+ rowId={(row) => row.id}
384
+ renderRow={(row) => <span>{row.subject}</span>}
385
+ testId="inbox"
386
+ />,
387
+ dispatcher,
388
+ fake.subscriber,
389
+ );
390
+
391
+ await waitFor(() => expect(screen.getByText("Erste")).toBeTruthy());
392
+ fireIntersect();
393
+ await waitFor(() => expect(screen.getByText("Zweite")).toBeTruthy());
394
+ expect(calls.length).toBe(2);
395
+
396
+ act(() => {
397
+ fake.inject("message.created", {
398
+ id: "m3",
399
+ aggregateType: "message",
400
+ version: 1,
401
+ payload: {},
402
+ createdAt: "",
403
+ });
404
+ });
405
+
406
+ await waitFor(() => expect(screen.getByText("Neu")).toBeTruthy());
407
+ expect(screen.queryByText("Erste")).toBeNull();
408
+ expect(screen.getByText("Zweite")).toBeTruthy();
409
+ });
410
+
298
411
  test("live=false: SSE-Event wird ignoriert, kein Refetch", async () => {
299
412
  let calls = 0;
300
413
  const dispatcher = createMockDispatcher({
@@ -80,6 +80,12 @@ export function InfinityList<TData = unknown, TRow = Readonly<Record<string, unk
80
80
  // overwrite it, leaving stale rows on screen (fw#1705).
81
81
  const requestSeq = useRef(0);
82
82
 
83
+ // IDs of the most recently loaded first page. refreshFirstPage() needs
84
+ // this to tell "row dropped from page 1 elsewhere" (must be pruned) apart
85
+ // from "row belongs to an already-accumulated later page" (must survive)
86
+ // — both are simply absent from the fresh page's id set.
87
+ const firstPageIdsRef = useRef<ReadonlySet<string>>(new Set());
88
+
83
89
  // biome-ignore lint/correctness/useExhaustiveDependencies: payload goes through payloadKey
84
90
  const load = useCallback(
85
91
  async (cursor: string | null): Promise<void> => {
@@ -95,6 +101,11 @@ export function InfinityList<TData = unknown, TRow = Readonly<Record<string, unk
95
101
  return;
96
102
  }
97
103
  const nextRows = rowsRef.current(res.data);
104
+ if (cursor === null) {
105
+ firstPageIdsRef.current = new Set(
106
+ nextRows.map((row, index) => rowIdRef.current(row, index)),
107
+ );
108
+ }
98
109
  setState((prev) => ({
99
110
  kind: "ready",
100
111
  rows: cursor === null || prev.kind !== "ready" ? nextRows : [...prev.rows, ...nextRows],
@@ -131,12 +142,20 @@ export function InfinityList<TData = unknown, TRow = Readonly<Record<string, unk
131
142
  if (!res.isSuccess) return;
132
143
  const freshRows = rowsRef.current(res.data);
133
144
  const freshIds = new Set(freshRows.map((row, index) => rowIdRef.current(row, index)));
145
+ // A row absent from freshIds is stale for one of two reasons: it was
146
+ // dropped from page 1 (deleted, or filtered out elsewhere) and must be
147
+ // pruned, or it belongs to an already-accumulated later page and must
148
+ // survive. previousFirstPageIds — captured before it's overwritten below
149
+ // — is what tells those two cases apart.
150
+ const previousFirstPageIds = firstPageIdsRef.current;
151
+ firstPageIdsRef.current = freshIds;
134
152
  setState((prev) => {
135
153
  // skip: not showing an accumulated list yet, nothing to merge into
136
154
  if (prev.kind !== "ready") return prev;
137
- const staleRows = prev.rows.filter(
138
- (row, index) => !freshIds.has(rowIdRef.current(row, index)),
139
- );
155
+ const staleRows = prev.rows.filter((row, index) => {
156
+ const id = rowIdRef.current(row, index);
157
+ return !freshIds.has(id) && !previousFirstPageIds.has(id);
158
+ });
140
159
  return { kind: "ready", rows: [...freshRows, ...staleRows], cursor: prev.cursor };
141
160
  });
142
161
  }, [dispatcher, query, pageSize, payloadKey]);