@cosmicdrift/kumiko-renderer-web 0.219.0 → 0.220.1

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.219.0",
3
+ "version": "0.220.1",
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.219.0",
20
- "@cosmicdrift/kumiko-headless": "0.219.0",
21
- "@cosmicdrift/kumiko-renderer": "0.219.0",
19
+ "@cosmicdrift/kumiko-dispatcher-live": "0.220.1",
20
+ "@cosmicdrift/kumiko-headless": "0.220.1",
21
+ "@cosmicdrift/kumiko-renderer": "0.220.1",
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",
@@ -64,7 +64,7 @@
64
64
  "@types/react-dom": "^19.2.3",
65
65
  "jsdom": "^29.1.1",
66
66
  "tailwindcss": "^4.3.0",
67
- "@cosmicdrift/kumiko-locale-de": "0.219.0"
67
+ "@cosmicdrift/kumiko-locale-de": "0.220.1"
68
68
  },
69
69
  "repository": {
70
70
  "type": "git",
@@ -24,8 +24,8 @@ describe("DefaultForm Action-Footer", () => {
24
24
  const actions = screen.getByTestId("f-actions");
25
25
  expect(actions.className).toContain("justify-end");
26
26
  expect(actions.className).toContain("border-t");
27
- // Footer liegt im max-w-3xl-Form-Body (Buttons enden an der Feld-Linie).
28
- expect(actions.closest(".max-w-3xl")).toBeTruthy();
27
+ // Footer sits in the form shell (buttons align with the field edge).
28
+ expect(actions.closest(".max-w-full")).toBeTruthy();
29
29
  // Titel ist ein eigenes Heading oben, NICHT mehr in der Action-Bar.
30
30
  expect(actions.textContent).not.toContain("Titel");
31
31
  expect(screen.getByTestId("f-title").textContent).toBe("Titel");
@@ -1570,8 +1570,8 @@ describe("KumikoScreen", () => {
1570
1570
  expect(writeCalls[0]?.payload).toEqual({ title: "New Task", priority: 1 });
1571
1571
  });
1572
1572
 
1573
- test("actionForm mit redirect: nach success → nav.navigate({screenId: redirect})", async () => {
1574
- const navigateCalls: { screenId: string }[] = [];
1573
+ test("actionForm mit redirect: nach success → nav.navigate({screenId: redirect, entityId})", async () => {
1574
+ const navigateCalls: NavTarget[] = [];
1575
1575
  const dispatcher = makeDispatcher({
1576
1576
  write: (async () => ({
1577
1577
  isSuccess: true,
@@ -1613,11 +1613,13 @@ describe("KumikoScreen", () => {
1613
1613
  fireEvent.change(titleInput, { target: { value: "go" } });
1614
1614
  fireEvent.click(screen.getByTestId("render-edit-submit"));
1615
1615
  await waitFor(() => expect(navigateCalls.length).toBe(1));
1616
- expect(navigateCalls[0]).toEqual({ screenId: "task-list" });
1616
+ // #2416: the handler returns a created id (`{ id: "x" }`) — it must land
1617
+ // on the navigate as entityId, same as entityEdit-create.
1618
+ expect(navigateCalls[0]).toEqual({ screenId: "task-list", entityId: "x" });
1617
1619
  });
1618
1620
 
1619
1621
  test("actionForm mit Cross-Feature-QN als redirect: navigiert per Short-Id (#1946)", async () => {
1620
- const navigateCalls: { screenId: string }[] = [];
1622
+ const navigateCalls: NavTarget[] = [];
1621
1623
  const dispatcher = makeDispatcher({
1622
1624
  write: (async () => ({
1623
1625
  isSuccess: true,
@@ -1659,7 +1661,59 @@ describe("KumikoScreen", () => {
1659
1661
  fireEvent.change(titleInput, { target: { value: "go" } });
1660
1662
  fireEvent.click(screen.getByTestId("render-edit-submit"));
1661
1663
  await waitFor(() => expect(navigateCalls.length).toBe(1));
1662
- expect(navigateCalls[0]).toEqual({ screenId: "statement-upload-list" });
1664
+ expect(navigateCalls[0]).toEqual({ screenId: "statement-upload-list", entityId: "x" });
1665
+ });
1666
+
1667
+ // #2416: entityEdit-create passes the newly created record's id through to
1668
+ // the post-submit navigate as `entityId` (extractCreatedId); actionForm's
1669
+ // redirect did the same navigate but never extracted the id. The with-id
1670
+ // case is covered by the actionForm redirect success test above (now
1671
+ // asserting entityId); this pins the back-compat guarantee.
1672
+ test("actionForm mit redirect: Handler-Result ohne id → navigate ohne entityId-Key (Rückwärtskompatibilität, #2416)", async () => {
1673
+ const navigateCalls: NavTarget[] = [];
1674
+ const dispatcher = makeDispatcher({
1675
+ write: (async () => ({
1676
+ isSuccess: true,
1677
+ data: {},
1678
+ })) as unknown as Dispatcher["write"],
1679
+ });
1680
+ const memoryNav = {
1681
+ route: { screenId: "quick-add" },
1682
+ navigate: (target: NavTarget) => {
1683
+ if ("screenId" in target) navigateCalls.push(target);
1684
+ },
1685
+ replace: () => undefined,
1686
+ hrefFor: (t: NavTarget) => ("screenId" in t ? `/${t.screenId}` : ""),
1687
+ searchParams: {},
1688
+ setSearchParams: () => undefined,
1689
+ };
1690
+ const actionScreen: ActionFormScreenDefinition = {
1691
+ id: "quick-add",
1692
+ type: "actionForm",
1693
+ handler: "tasks:write:task:quick-add",
1694
+ fields: { title: { type: "text", required: true } },
1695
+ layout: { sections: [{ title: "x", fields: ["title"] }] },
1696
+ redirect: "task-list",
1697
+ };
1698
+
1699
+ const { NavProvider } = await import("@cosmicdrift/kumiko-renderer");
1700
+ render(
1701
+ <NavProvider value={memoryNav}>
1702
+ <DispatcherProvider dispatcher={dispatcher}>
1703
+ <KumikoScreen
1704
+ schema={{ ...schema, screens: [actionScreen, listScreen] }}
1705
+ qn="tasks:screen:quick-add"
1706
+ />
1707
+ </DispatcherProvider>
1708
+ </NavProvider>,
1709
+ );
1710
+
1711
+ const titleInput = screen.getByTestId("field-title").querySelector("input") as HTMLInputElement;
1712
+ fireEvent.change(titleInput, { target: { value: "go" } });
1713
+ fireEvent.click(screen.getByTestId("render-edit-submit"));
1714
+ await waitFor(() => expect(navigateCalls.length).toBe(1));
1715
+ expect(navigateCalls[0]).toEqual({ screenId: "task-list" });
1716
+ expect(Object.hasOwn(navigateCalls[0] ?? {}, "entityId")).toBe(false);
1663
1717
  });
1664
1718
 
1665
1719
  // cancelTarget (Bug-Bash 2026-06-07, Bug 9): redirect erzeugte
@@ -283,7 +283,7 @@ describe("RenderEdit", () => {
283
283
  expect(seenResults[0]?.isSuccess).toBe(true);
284
284
  });
285
285
 
286
- test("layout.width defaults the form shell to max-w-3xl when unset", () => {
286
+ test("layout.width defaults the form shell to max-w-full when unset", () => {
287
287
  render(
288
288
  <DispatcherProvider dispatcher={makeDispatcher()}>
289
289
  <RenderEdit<TestValues>
@@ -297,16 +297,16 @@ describe("RenderEdit", () => {
297
297
  );
298
298
 
299
299
  const shell = screen.getByTestId("render-edit-form").firstElementChild;
300
- expect(shell?.className).toContain("max-w-3xl");
301
- expect(shell?.className).not.toContain("max-w-full");
300
+ expect(shell?.className).toContain("max-w-full");
301
+ expect(shell?.className).not.toContain("max-w-3xl");
302
302
  });
303
303
 
304
- test("layout.width: 'full' widens the form shell to max-w-full (#1676)", () => {
304
+ test("layout.width: '3xl' narrows the form shell when set", () => {
305
305
  const screenDef = makeScreen();
306
306
  render(
307
307
  <DispatcherProvider dispatcher={makeDispatcher()}>
308
308
  <RenderEdit<TestValues>
309
- screen={{ ...screenDef, layout: { ...screenDef.layout, width: "full" } }}
309
+ screen={{ ...screenDef, layout: { ...screenDef.layout, width: "3xl" } }}
310
310
  entity={orderEntity}
311
311
  featureName="orders"
312
312
  initial={{ title: "", count: 0, isUrgent: false }}
@@ -316,8 +316,8 @@ describe("RenderEdit", () => {
316
316
  );
317
317
 
318
318
  const shell = screen.getByTestId("render-edit-form").firstElementChild;
319
- expect(shell?.className).toContain("max-w-full");
320
- expect(shell?.className).not.toContain("max-w-3xl");
319
+ expect(shell?.className).toContain("max-w-3xl");
320
+ expect(shell?.className).not.toContain("max-w-full");
321
321
  });
322
322
 
323
323
  test("title resolved aus i18n-Key `screen:<id>.title` mit screenId als Fallback", () => {
@@ -673,8 +673,8 @@ function DefaultDataTable({
673
673
  // scrollen typischerweise mit dem Page-Container, nicht intern.
674
674
  // Sticky würde mit der Topbar konkurrieren.
675
675
  const hasTableActions = rowActions !== undefined && rowActions.length > 0;
676
- const tableContent =
677
- rows.length === 0 ? (
676
+ function tableInner(): ReactNode {
677
+ return rows.length === 0 ? (
678
678
  <div
679
679
  data-testid={testId !== undefined ? `${testId}-empty` : "render-list-empty"}
680
680
  className="flex flex-col items-center justify-center rounded-md border border-dashed p-12 text-sm text-muted-foreground gap-3"
@@ -777,6 +777,8 @@ function DefaultDataTable({
777
777
  </Table>
778
778
  </div>
779
779
  );
780
+ }
781
+ const tableContent = tableInner();
780
782
 
781
783
  // Pager wird IMMER unter der Tabelle gerendert (auch bei rows=[]),
782
784
  // damit der User bei einem Filter-Hit-of-Zero zurückblättern kann
@@ -1620,13 +1622,9 @@ function DefaultForm({
1620
1622
  );
1621
1623
  }
1622
1624
 
1623
- // shadcn-Form-Muster: das Formular ist EINE Card Titel als Card-Header
1624
- // OHNE Trennlinie darunter (Titel fließt in die erste Section). Sections
1625
- // sind divide-y-getrennt (Linien nur ZWISCHEN ihnen). Action-Footer mit
1626
- // bg-muted/30 als Farb-Trenner statt harter Linie. main hat kein Padding;
1627
- // der max-w-3xl-Body rahmt die Card, mx-auto zentriert ihn im Main statt
1628
- // linksbündig (sonst „eingesperrt" mit leerer rechter Hälfte auf breiten
1629
- // Screens).
1625
+ // One card form: title as header (no divider under it), sections divided
1626
+ // between each other, muted action footer. Shell width defaults to full
1627
+ // (same chrome as lists); pass width to narrow (auth-adjacent / dense).
1630
1628
  return (
1631
1629
  <form
1632
1630
  onSubmit={(e) => {
@@ -1698,17 +1696,13 @@ function DefaultForm({
1698
1696
  );
1699
1697
  }
1700
1698
 
1701
- // Kanonische Form/Settings-Shell: zentrierte Spalte mit Standard-Screen-
1702
- // Padding. DefaultForm (configEdit/entityEdit) UND custom Settings-Screens
1703
- // (url-settings, privacy-center) teilen sie einheitliche Breite +
1704
- // Zentrierung statt per-Screen-Wildwuchs. Breite über `maxWidth`-Intent statt
1705
- // beliebiger max-w-*-Overrides: sm=schmale Auth-Forms, 3xl=Standard-Detail,
1706
- // 4xl=tabellen-nahe Forms, full=volle Breite. Inhalt nutzt Card-Primitives;
1707
- // `className` (z.B. "flex flex-col gap-6") für Multi-Card-Stacks.
1699
+ // Canonical form/settings shell shared by DefaultForm (configEdit/entityEdit)
1700
+ // and custom settings screens (profile, privacy-center). Default width matches
1701
+ // list chrome (full); override via maxWidth / layout.width when a screen
1702
+ // truly needs a narrow column (sm=auth-adjacent, 3xl/4xl=centered detail).
1708
1703
  export type FormScreenShellWidth = FormWidth;
1709
1704
 
1710
1705
  const formScreenShellWidth: Record<FormScreenShellWidth, string> = {
1711
- sm: "max-w-sm mx-auto",
1712
1706
  "3xl": "max-w-3xl mx-auto",
1713
1707
  "4xl": "max-w-4xl mx-auto",
1714
1708
  full: "max-w-full",
@@ -1718,7 +1712,7 @@ export function FormScreenShell({
1718
1712
  children,
1719
1713
  className,
1720
1714
  testId,
1721
- maxWidth = "3xl",
1715
+ maxWidth = "full",
1722
1716
  }: {
1723
1717
  readonly children: ReactNode;
1724
1718
  readonly className?: string;
@@ -20,12 +20,6 @@ export type InfinityListProps<TData = unknown, TRow = Readonly<Record<string, un
20
20
  /** Must derive from row content (e.g. `row.id`), not from `index` — a
21
21
  * live refresh reorders rows (new/changed rows move to the front). */
22
22
  readonly rowId: (row: TRow, index: number) => string;
23
- /** Pass this when `rowId` depends on list position — the live-merge path
24
- * needs a position-independent identity. Without it, an index-based
25
- * `rowId` (legitimate when live-merge isn't used) can collide between
26
- * the freshly-fetched first page's local indices and the accumulated
27
- * list's indices, silently dropping an unrelated mid-list row on merge. */
28
- readonly rowKey?: (row: TRow) => string;
29
23
  readonly renderRow: (row: TRow) => ReactNode;
30
24
  readonly emptyState?: ReactNode;
31
25
  readonly className?: string;
@@ -55,7 +49,6 @@ export function InfinityList<TData = unknown, TRow = Readonly<Record<string, unk
55
49
  rows,
56
50
  nextCursor,
57
51
  rowId,
58
- rowKey,
59
52
  renderRow,
60
53
  emptyState,
61
54
  className,
@@ -80,16 +73,6 @@ export function InfinityList<TData = unknown, TRow = Readonly<Record<string, unk
80
73
  nextCursorRef.current = nextCursor;
81
74
  const rowIdRef = useRef(rowId);
82
75
  rowIdRef.current = rowId;
83
- const rowKeyRef = useRef(rowKey);
84
- rowKeyRef.current = rowKey;
85
- // Position-independent when rowKey is supplied, otherwise falls back to
86
- // the (possibly position-dependent) rowId — same identity function used
87
- // for both sides of the live-merge diff below so a position-based rowId
88
- // can no longer collide across the fresh page's local indices and the
89
- // accumulated list's indices (fw#1829).
90
- const identifyRef = useRef((row: TRow, index: number): string =>
91
- rowKeyRef.current !== undefined ? rowKeyRef.current(row) : rowIdRef.current(row, index),
92
- );
93
76
 
94
77
  // Discards a response whose request was superseded by a newer one before
95
78
  // it resolved (e.g. two searches fired in quick succession) — without
@@ -121,9 +104,8 @@ export function InfinityList<TData = unknown, TRow = Readonly<Record<string, unk
121
104
  if (cursor === null) {
122
105
  // Same identity as refreshFirstPage's freshIds/staleRows diff below
123
106
  // — this ref is what that diff's previousFirstPageIds reads, so it
124
- // must agree on rowKey vs rowId or the two id spaces never overlap.
125
107
  firstPageIdsRef.current = new Set(
126
- nextRows.map((row, index) => identifyRef.current(row, index)),
108
+ nextRows.map((row, index) => rowIdRef.current(row, index)),
127
109
  );
128
110
  }
129
111
  setState((prev) => {
@@ -174,7 +156,7 @@ export function InfinityList<TData = unknown, TRow = Readonly<Record<string, unk
174
156
  // skip: background live refresh failed, keep showing the current rows
175
157
  if (!res.isSuccess) return;
176
158
  const freshRows = rowsRef.current(res.data);
177
- const freshIds = new Set(freshRows.map((row, index) => identifyRef.current(row, index)));
159
+ const freshIds = new Set(freshRows.map((row, index) => rowIdRef.current(row, index)));
178
160
  // A row absent from freshIds is stale for one of two reasons: it was
179
161
  // dropped from page 1 (deleted, or filtered out elsewhere) and must be
180
162
  // pruned, or it belongs to an already-accumulated later page and must
@@ -194,7 +176,7 @@ export function InfinityList<TData = unknown, TRow = Readonly<Record<string, unk
194
176
  return { kind: "ready", rows: freshRows, cursor: nextCursorRef.current(res.data) };
195
177
  }
196
178
  const staleRows = prev.rows.filter((row, index) => {
197
- const id = identifyRef.current(row, index);
179
+ const id = rowIdRef.current(row, index);
198
180
  return !freshIds.has(id) && !previousFirstPageIds.has(id);
199
181
  });
200
182
  return { kind: "ready", rows: [...freshRows, ...staleRows], cursor: prev.cursor };