@cosmicdrift/kumiko-renderer-web 0.33.0 → 0.34.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.33.0",
3
+ "version": "0.34.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>",
@@ -7,7 +7,11 @@ import type {
7
7
  } from "@cosmicdrift/kumiko-framework/ui-types";
8
8
  import type { Dispatcher } from "@cosmicdrift/kumiko-headless";
9
9
  import type { FeatureSchema } from "@cosmicdrift/kumiko-renderer";
10
- import { DispatcherProvider, KumikoScreen } from "@cosmicdrift/kumiko-renderer";
10
+ import {
11
+ DispatcherProvider,
12
+ ExtensionSectionsProvider,
13
+ KumikoScreen,
14
+ } from "@cosmicdrift/kumiko-renderer";
11
15
  import userEvent from "@testing-library/user-event";
12
16
  import { createMockDispatcher, fireEvent, render, screen, waitFor } from "./test-utils";
13
17
 
@@ -160,6 +164,70 @@ describe("KumikoScreen", () => {
160
164
  });
161
165
  });
162
166
 
167
+ // Regression-Anker für den Set-Value-UI-Bug: die extension-section muss im
168
+ // Update-Mode die ECHTE entity-id bekommen — durch den vollen Flow
169
+ // (KumikoScreen → detail-load → EntityEditUpdateForm → RenderEdit → Mount).
170
+ // EntityEditUpdateForm lässt `id` bewusst aus den Form-values (id ist keine
171
+ // deklarierte Field), also reicht NUR der route-entityId-Durchgriff. Ohne
172
+ // ihn fiele die Section auf vm.id (=values["id"]=undefined) zurück und zeigte
173
+ // create-mode trotz Edit. Der alte render-edit-Test mockte `initial.id`
174
+ // manuell und war für genau diesen Flow blind — dieser Test rendert den
175
+ // realen detail-Pfad, der das in CI gefangen hätte.
176
+ test("entityEdit mit entityId → extension-section bekommt die echte entity-id (nicht create-mode)", async () => {
177
+ const editScreenWithExtension: EntityEditScreenDefinition = {
178
+ id: "task-edit-ext",
179
+ type: "entityEdit",
180
+ entity: "task",
181
+ layout: {
182
+ sections: [
183
+ { title: "Basics", fields: ["title"] },
184
+ {
185
+ kind: "extension",
186
+ title: "Custom Fields",
187
+ component: { react: { __component: "TaskCustomFields" } },
188
+ },
189
+ ],
190
+ },
191
+ };
192
+ const extSchema: FeatureSchema = {
193
+ featureName: "tasks",
194
+ entities: { task: taskEntity },
195
+ screens: [editScreenWithExtension],
196
+ };
197
+ const TaskCustomFields = ({
198
+ entityName,
199
+ entityId,
200
+ }: {
201
+ entityName: string;
202
+ entityId: string | null;
203
+ }) => (
204
+ <div data-testid="task-custom-fields">
205
+ {entityName}:{entityId ?? "(create)"}
206
+ </div>
207
+ );
208
+ const dispatcher = makeDispatcher({
209
+ // detail liefert die row MIT id — aber der Update-Form filtert id aus
210
+ // den Form-values; die Section darf trotzdem nicht in create-mode fallen.
211
+ query: (async () => ({
212
+ isSuccess: true,
213
+ data: { id: "task-1", version: 7, title: "loaded", count: 0, done: false },
214
+ })) as unknown as Dispatcher["query"],
215
+ });
216
+
217
+ render(
218
+ <DispatcherProvider dispatcher={dispatcher}>
219
+ <ExtensionSectionsProvider value={{ TaskCustomFields }}>
220
+ <KumikoScreen schema={extSchema} qn="tasks:screen:task-edit-ext" entityId="task-1" />
221
+ </ExtensionSectionsProvider>
222
+ </DispatcherProvider>,
223
+ );
224
+
225
+ await waitFor(() => expect(screen.queryByTestId("kumiko-screen-loading")).toBeNull());
226
+ const section = screen.getByTestId("task-custom-fields");
227
+ // Der Anker: echte route-id, NICHT "(create)". Vor dem Fix: "task:(create)".
228
+ expect(section.textContent).toBe("task:task-1");
229
+ });
230
+
163
231
  test("entityList onRowClick → Callback feuert mit Row-Viewmodel", async () => {
164
232
  const clicks: { id: string }[] = [];
165
233
  const dispatcher = makeDispatcher({
@@ -316,7 +384,7 @@ describe("KumikoScreen", () => {
316
384
  id: "archive",
317
385
  label: "actions.archive",
318
386
  handler: "tasks:write:task:archive",
319
- payload: (row) => ({ id: row["id"], reason: "manual" }),
387
+ payload: { pick: ["id"] },
320
388
  },
321
389
  ],
322
390
  };
@@ -344,7 +412,7 @@ describe("KumikoScreen", () => {
344
412
  await waitFor(() => expect(writeCalls.length).toBe(1));
345
413
  expect(writeCalls[0]).toEqual({
346
414
  type: "tasks:write:task:archive",
347
- payload: { id: "r1", reason: "manual" },
415
+ payload: { id: "r1" },
348
416
  });
349
417
  });
350
418
 
@@ -459,7 +527,7 @@ describe("KumikoScreen", () => {
459
527
  id: "edit",
460
528
  label: "actions.edit",
461
529
  screen: "task-edit",
462
- params: (row) => ({ taskId: row["id"], priority: 5 }),
530
+ params: { map: { taskId: "id" } },
463
531
  },
464
532
  ],
465
533
  };
@@ -482,7 +550,7 @@ describe("KumikoScreen", () => {
482
550
  await waitFor(() => expect(navigateCalls.length).toBe(1));
483
551
  expect(navigateCalls[0]).toEqual({ screenId: "task-edit" });
484
552
  // params werden zu Strings serialisiert (URL-Layer kennt nur Strings).
485
- expect(searchParamUpdates).toEqual([{ taskId: "r1", priority: "5" }]);
553
+ expect(searchParamUpdates).toEqual([{ taskId: "r1" }]);
486
554
  // Reihenfolge-Pin: erst navigate, dann setSearchParams.
487
555
  expect(calls.map((c) => c.kind)).toEqual(["navigate", "setSearchParams"]);
488
556
  });
@@ -522,7 +590,7 @@ describe("KumikoScreen", () => {
522
590
  id: "edit",
523
591
  label: "actions.edit",
524
592
  screen: "task-edit",
525
- entityId: (row) => String(row["id"] ?? ""),
593
+ entityId: "id",
526
594
  },
527
595
  ],
528
596
  };
@@ -547,12 +615,10 @@ describe("KumikoScreen", () => {
547
615
  expect(searchParamUpdates).toEqual([]);
548
616
  });
549
617
 
550
- // JSON-Schema-Fall (window.__KUMIKO_SCHEMA__): Function-Props wie
551
- // action.entityId werden beim JSON-Roundtrip silent gedroppt. Zielt
552
- // die navigate-Action auf einen entityEdit-Screen, MUSS row.id als
553
- // deklarativer Default greifen sonst öffnet der Edit im Create-Mode
554
- // (Prod-e2e-Befund 2026-06-07 nach F1).
555
- test("entityList rowActions kind=navigate auf entityEdit-Ziel: row.id ist der entityId-Default (JSON-Schema-sicher)", async () => {
618
+ // JSON-Schema-Fall (window.__KUMIKO_SCHEMA__): Declarative entityId: "id"
619
+ // überlebt JSON.stringify (String, kein Function-Drop). Das Schema
620
+ // funktioniert identisch ob direkt oder nach JSON-Roundtrip geladen.
621
+ test("entityList rowActions kind=navigate auf entityEdit-Ziel: entityId-String überlebt JSON-Roundtrip (JSON-Schema-sicher)", async () => {
556
622
  const navigateCalls: { screenId: string; entityId?: string }[] = [];
557
623
  const memoryNav = {
558
624
  route: { screenId: "task-list" },
@@ -583,10 +649,7 @@ describe("KumikoScreen", () => {
583
649
  id: "edit",
584
650
  label: "actions.edit",
585
651
  screen: "task-edit",
586
- // entityId-Function ABSICHTLICH gesetzt und dann per
587
- // JSON-Roundtrip gedroppt — exakt was buildAppSchema +
588
- // JSON.stringify mit dem Schema im Browser machen.
589
- entityId: (row) => String(row["id"] ?? ""),
652
+ entityId: "id",
590
653
  },
591
654
  ],
592
655
  };
@@ -786,7 +849,7 @@ describe("KumikoScreen", () => {
786
849
  id: "sync",
787
850
  label: "actions.sync",
788
851
  handler: "tasks:write:task:sync",
789
- payload: () => ({ all: true }),
852
+ payload: { all: true },
790
853
  },
791
854
  ],
792
855
  };
@@ -898,8 +961,7 @@ describe("KumikoScreen", () => {
898
961
  id: "start",
899
962
  label: "actions.start",
900
963
  handler: "tasks:write:task:start",
901
- // Nur sichtbar bei status===scheduled
902
- visible: (row: unknown) => (row as { status?: string }).status === "scheduled",
964
+ visible: { field: "status", eq: "scheduled" },
903
965
  },
904
966
  ],
905
967
  };
@@ -36,8 +36,8 @@ function makeScreen(): EntityEditScreenDefinition {
36
36
  "isUrgent",
37
37
  {
38
38
  field: "notes",
39
- visible: (d) => (d as { isUrgent?: boolean }).isUrgent === true,
40
- required: (d) => (d as { isUrgent?: boolean }).isUrgent === true,
39
+ visible: { field: "isUrgent", eq: true },
40
+ required: { field: "isUrgent", eq: true },
41
41
  },
42
42
  ],
43
43
  },
@@ -229,6 +229,59 @@ describe("RenderEdit", () => {
229
229
  expect(mounted.textContent).toBe("order:row-42");
230
230
  });
231
231
 
232
+ // Realer Update-Flow: EntityEditUpdateForm lässt `id` BEWUSST aus den
233
+ // Form-values (id ist keine deklarierte Field) und reicht die route-id
234
+ // stattdessen über die entityId-prop durch. Ohne den entityId-prop-Pfad
235
+ // fiele die Section auf vm.id (=values["id"]=undefined) zurück → create-
236
+ // mode trotz Edit (der Set-Value-UI-Bug, #187..fix).
237
+ test("extension section uses the entityId prop when id is absent from form values", () => {
238
+ const screenDef: EntityEditScreenDefinition = {
239
+ id: "orders:screen:order-edit",
240
+ type: "entityEdit",
241
+ entity: "order",
242
+ layout: {
243
+ sections: [
244
+ { title: "Basics", columns: 2, fields: [{ field: "title", span: 2 }] },
245
+ {
246
+ kind: "extension",
247
+ title: "Custom Fields",
248
+ component: { react: { __component: "MyCustomFieldsForm" } },
249
+ },
250
+ ],
251
+ },
252
+ };
253
+ const MyCustomFieldsForm = ({
254
+ entityName,
255
+ entityId,
256
+ }: {
257
+ entityName: string;
258
+ entityId: string | null;
259
+ }) => (
260
+ <div data-testid="my-custom-fields-form">
261
+ {entityName}:{entityId ?? "(create)"}
262
+ </div>
263
+ );
264
+ render(
265
+ <DispatcherProvider dispatcher={makeDispatcher()}>
266
+ <ExtensionSectionsProvider value={{ MyCustomFieldsForm }}>
267
+ <RenderEdit<TestValues>
268
+ screen={screenDef}
269
+ entity={orderEntity}
270
+ featureName="orders"
271
+ // KEIN id in den values (wie der echte Update-Form), aber
272
+ // entityId-prop trägt die route-id.
273
+ initial={{ title: "Existing", count: 0, isUrgent: false } as TestValues}
274
+ entityId="order-99"
275
+ writeCommand="order:update"
276
+ />
277
+ </ExtensionSectionsProvider>
278
+ </DispatcherProvider>,
279
+ );
280
+
281
+ const mounted = screen.getByTestId("my-custom-fields-form");
282
+ expect(mounted.textContent).toBe("order:order-99");
283
+ });
284
+
232
285
  test("extension section without registered component shows the placeholder banner", () => {
233
286
  const screenDef: EntityEditScreenDefinition = {
234
287
  id: "orders:screen:order-edit",