@cosmicdrift/kumiko-renderer 0.288.0 → 0.290.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",
3
- "version": "0.288.0",
3
+ "version": "0.290.0",
4
4
  "description": "Platform-agnostic React renderer for Kumiko screens. Contains the shared logic — primitives-contract, hooks, KumikoScreen, navigation & SSE abstractions — that any platform-specific renderer (web, native) composes. No DOM, no EventSource, no react-dom.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -15,9 +15,9 @@
15
15
  }
16
16
  },
17
17
  "dependencies": {
18
- "@cosmicdrift/kumiko-framework": "0.288.0",
19
- "@cosmicdrift/kumiko-headless": "0.288.0",
20
- "@cosmicdrift/kumiko-types": "0.288.0",
18
+ "@cosmicdrift/kumiko-framework": "0.290.0",
19
+ "@cosmicdrift/kumiko-headless": "0.290.0",
20
+ "@cosmicdrift/kumiko-types": "0.290.0",
21
21
  "react": "^19.2.6",
22
22
  "temporal-polyfill": "^0.3.2",
23
23
  "zod": "^4.4.3"
@@ -28,7 +28,7 @@
28
28
  "@types/react-dom": "^19.2.3",
29
29
  "jsdom": "^29.1.1",
30
30
  "react-dom": "^19.2.6",
31
- "@cosmicdrift/kumiko-locale-de": "0.288.0"
31
+ "@cosmicdrift/kumiko-locale-de": "0.290.0"
32
32
  },
33
33
  "repository": {
34
34
  "type": "git",
@@ -1,5 +1,7 @@
1
1
  import { describe, expect, spyOn, test } from "bun:test";
2
+ import type { EntityEditScreenDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
2
3
  import { mergeSearchParamsIntoInitial } from "../app/kumiko-screen";
4
+ import { layoutFieldNames } from "../app/layout-fields";
3
5
 
4
6
  type FieldDef = {
5
7
  type?: string;
@@ -188,6 +190,22 @@ describe("mergeSearchParamsIntoInitial — coercion (every field URL-prefillable
188
190
  expect(result["ownerId"]).toBe("");
189
191
  });
190
192
 
193
+ test("renderableFields from a groups-only layout: searchParam on a group field is kept", () => {
194
+ const screen: EntityEditScreenDefinition = {
195
+ id: "s",
196
+ type: "entityEdit",
197
+ entity: "e",
198
+ layout: { sections: [{ fields: [], groups: [{ title: "g", fields: ["status"] }] }] },
199
+ };
200
+ const fields: Record<string, FieldDef> = { status: { type: "text", default: "draft" } };
201
+ const result = mergeWithAllFieldsUrlPrefillable(
202
+ fields,
203
+ { status: "approved" },
204
+ layoutFieldNames(screen),
205
+ );
206
+ expect(result["status"]).toBe("approved");
207
+ });
208
+
191
209
  test("no renderableFields set given (undefined): behaves as before, all fields eligible", () => {
192
210
  const fields: Record<string, FieldDef> = { ownerId: { type: "text" } };
193
211
  const result = mergeWithAllFieldsUrlPrefillable(fields, { ownerId: "user-123" });
@@ -41,6 +41,21 @@ describe("buildFormSchema", () => {
41
41
  }
42
42
  });
43
43
 
44
+ test("required field inside a groups-only section → presence issue", () => {
45
+ const entity = entityWith({ name: { type: "text", required: true } });
46
+ const screen: EntityEditScreenDefinition = {
47
+ id: "s",
48
+ type: "entityEdit",
49
+ entity: "e",
50
+ layout: { sections: [{ fields: [], groups: [{ title: "g", fields: ["name"] }] }] },
51
+ };
52
+
53
+ const result = buildFormSchema(entity, screen).safeParse({ name: "" });
54
+ expect(result.success).toBe(false);
55
+ if (result.success) return;
56
+ expect(result.error.issues[0]?.path).toEqual(["name"]);
57
+ });
58
+
44
59
  test("required field missing → issue carries the required-field i18nKey override", () => {
45
60
  const entity = entityWith({ name: { type: "text", required: true } });
46
61
  const screen = screenWith(["name"]);
@@ -2,7 +2,11 @@ import type {
2
2
  EditFieldSpec,
3
3
  EntityEditScreenDefinition,
4
4
  } from "@cosmicdrift/kumiko-framework/ui-types";
5
- import { isFieldsEditSection, normalizeEditField } from "@cosmicdrift/kumiko-framework/ui-types";
5
+ import {
6
+ isFieldsEditSection,
7
+ normalizeEditField,
8
+ sectionFieldSpecs,
9
+ } from "@cosmicdrift/kumiko-framework/ui-types";
6
10
 
7
11
  // Normalized field specs actually rendered by the screen's layout, extension
8
12
  // (and relatedList) sections skipped. Both this and `layoutFieldNames` key
@@ -15,7 +19,7 @@ export function layoutEditFields(
15
19
  const specs: Exclude<EditFieldSpec, string>[] = [];
16
20
  for (const section of screen.layout.sections) {
17
21
  if (!isFieldsEditSection(section)) continue;
18
- for (const spec of section.fields) {
22
+ for (const spec of sectionFieldSpecs(section)) {
19
23
  specs.push(normalizeEditField(spec));
20
24
  }
21
25
  }
@@ -22,7 +22,6 @@
22
22
 
23
23
  import type {
24
24
  EditFieldSpec,
25
- EditFieldsSection,
26
25
  EditLayout,
27
26
  EntityDefinition,
28
27
  EntityEditScreenDefinition,
@@ -32,18 +31,9 @@ import {
32
31
  isFieldsEditSection,
33
32
  normalizeEditField,
34
33
  PROJECTION_DETAIL_ENTITY as PROJECTION_DETAIL_PSEUDO_ENTITY,
34
+ sectionFieldSpecs,
35
35
  } from "@cosmicdrift/kumiko-framework/ui-types";
36
36
 
37
- // The boot-validator treats `fields`/`groups` as mutually exclusive per
38
- // section, but this reads both anyway: an extra name the current section
39
- // shape can never carry is harmless, while a name computeEditViewModel does
40
- // reference and this shim omitted throws downstream ("references unknown
41
- // field ... on entity"). Union is strictly safer than picking one branch.
42
- function allFieldSpecs(section: EditFieldsSection): readonly EditFieldSpec[] {
43
- const grouped = section.groups?.flatMap((group) => group.fields) ?? [];
44
- return [...section.fields, ...grouped];
45
- }
46
-
47
37
  /** Minimale EntityDefinition aus den Layout-Feldern: jedes Feld ein Text-
48
38
  * Feld — computeEditViewModel liest nur `fields[<f>].type`, Text reicht für
49
39
  * eine reine Anzeige (kein Select/Number-spezifisches Rendering nötig). */
@@ -52,7 +42,7 @@ export function synthesizeProjectionDetailEntity(layout: EditLayout): EntityDefi
52
42
  for (const section of layout.sections) {
53
43
  // relatedList and extension sections carry no `fields` to synthesize.
54
44
  if (!isFieldsEditSection(section)) continue;
55
- for (const spec of allFieldSpecs(section)) {
45
+ for (const spec of sectionFieldSpecs(section)) {
56
46
  fields[normalizeEditField(spec).field] = { type: "text" };
57
47
  }
58
48
  }
package/src/changes.json CHANGED
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.290.0",
4
+ "type": "fix",
5
+ "title": "Layout field readers see groups[].fields",
6
+ "detail": "layoutEditFields flattens section.groups, so required fields inside a groups-only section are presence-validated by buildFormSchema and survive the renderableFields gate for search-param prefills."
7
+ },
2
8
  {
3
9
  "version": "0.283.0",
4
10
  "type": "fix",
@@ -22,7 +22,18 @@ import { RenderField, type RenderFieldProps } from "../render-field";
22
22
 
23
23
  let captured: EmbeddedListInputProps | undefined;
24
24
  const captureEmbeddedListInput: ComponentType<EmbeddedListInputProps> = (props) => {
25
- captured = props;
25
+ captured = {
26
+ ...props,
27
+ onCellChange: (...args) => act(() => props.onCellChange(...args)),
28
+ onAddRow: () => act(() => props.onAddRow()),
29
+ onRemoveRow: (rowIndex) => act(() => props.onRemoveRow(rowIndex)),
30
+ onDuplicateRow: (rowIndex) => act(() => props.onDuplicateRow(rowIndex)),
31
+ onMoveRow: (fromIndex, toIndex) => act(() => props.onMoveRow(fromIndex, toIndex)),
32
+ ...(props.onPasteCells !== undefined && {
33
+ onPasteCells: (...args: Parameters<NonNullable<EmbeddedListInputProps["onPasteCells"]>>) =>
34
+ act(() => props.onPasteCells?.(...args)),
35
+ }),
36
+ };
26
37
  return null;
27
38
  };
28
39
  const noop = (): ReactNode => null;
@@ -112,12 +123,12 @@ function invoiceLinesField(overrides: Partial<EditFieldViewModel> = {}): EditFie
112
123
  };
113
124
  }
114
125
 
115
- function renderEmbeddedListField(
126
+ async function renderEmbeddedListField(
116
127
  field: EditFieldViewModel,
117
128
  onChange: (v: unknown) => void,
118
129
  allIssues: Readonly<Record<string, readonly FieldIssue[]>> = {},
119
130
  productRows: readonly ProductRow[] = [],
120
- ): void {
131
+ ): Promise<void> {
121
132
  captured = undefined;
122
133
  render(
123
134
  <LocaleProvider
@@ -137,13 +148,14 @@ function renderEmbeddedListField(
137
148
  </DispatcherProvider>
138
149
  </LocaleProvider>,
139
150
  );
151
+ await act(async () => {});
140
152
  }
141
153
 
142
154
  describe("EmbeddedListField — cell change recomputes derived", () => {
143
- test("changing quantity updates the cell and recomputes amount in the same row", () => {
155
+ test("changing quantity updates the cell and recomputes amount in the same row", async () => {
144
156
  const rows = [{ product: "p1", unit: "pcs", quantity: 2, unitPrice: 500, amount: 1000 }];
145
157
  let lastValue: unknown;
146
- renderEmbeddedListField(invoiceLinesField({ value: rows }), (v) => {
158
+ await renderEmbeddedListField(invoiceLinesField({ value: rows }), (v) => {
147
159
  lastValue = v;
148
160
  });
149
161
  captured?.onCellChange(0, "quantity", 4);
@@ -152,10 +164,10 @@ describe("EmbeddedListField — cell change recomputes derived", () => {
152
164
  ]);
153
165
  });
154
166
 
155
- test("a fractional product on the money-typed amount cell shows the rounded live preview, not the raw fraction", () => {
167
+ test("a fractional product on the money-typed amount cell shows the rounded live preview, not the raw fraction", async () => {
156
168
  const rows = [{ product: "p1", unit: "pcs", quantity: 2, unitPrice: 923, amount: 1846 }];
157
169
  let lastValue: unknown;
158
- renderEmbeddedListField(invoiceLinesField({ value: rows }), (v) => {
170
+ await renderEmbeddedListField(invoiceLinesField({ value: rows }), (v) => {
159
171
  lastValue = v;
160
172
  });
161
173
  // 2.5 * 923 = 2307.5 — a fractional minor-unit amount the money target
@@ -177,7 +189,7 @@ describe("EmbeddedListField — cell change recomputes derived", () => {
177
189
  // frozen `source` snapshot, same as the server. Two field configs with
178
190
  // "net"/"gross" declared in opposite key order must therefore produce the
179
191
  // identical, order-independent result.
180
- test("chained derived cells (gross depends on net) recompute the same value regardless of the derived-map's key order", () => {
192
+ test("chained derived cells (gross depends on net) recompute the same value regardless of the derived-map's key order", async () => {
181
193
  const cells: EditFieldViewModel["embeddedListCells"] = [
182
194
  { field: "qty", label: "Qty", type: "number", required: true },
183
195
  { field: "price", label: "Price", type: "number", required: true },
@@ -194,7 +206,7 @@ describe("EmbeddedListField — cell change recomputes derived", () => {
194
206
  const expected = { qty: 4, price: 3, tax: 1, net: 12, gross: 7 };
195
207
 
196
208
  let netFirstResult: unknown;
197
- renderEmbeddedListField(
209
+ await renderEmbeddedListField(
198
210
  {
199
211
  ...invoiceLinesField({ value: rows }),
200
212
  embeddedListCells: cells,
@@ -210,7 +222,7 @@ describe("EmbeddedListField — cell change recomputes derived", () => {
210
222
  captured?.onCellChange(0, "qty", 4);
211
223
 
212
224
  let grossFirstResult: unknown;
213
- renderEmbeddedListField(
225
+ await renderEmbeddedListField(
214
226
  {
215
227
  ...invoiceLinesField({ value: rows }),
216
228
  embeddedListCells: cells,
@@ -236,45 +248,45 @@ describe("EmbeddedListField — row operations", () => {
236
248
  { product: "p2", unit: "hours", quantity: 2, unitPrice: 200, amount: 400 },
237
249
  ];
238
250
 
239
- test("onAddRow appends an empty row with derived recomputed", () => {
251
+ test("onAddRow appends an empty row with derived recomputed", async () => {
240
252
  let lastValue: unknown;
241
- renderEmbeddedListField(invoiceLinesField({ value: rows }), (v) => {
253
+ await renderEmbeddedListField(invoiceLinesField({ value: rows }), (v) => {
242
254
  lastValue = v;
243
255
  });
244
256
  captured?.onAddRow();
245
257
  expect(lastValue).toEqual([...rows, { amount: undefined }]);
246
258
  });
247
259
 
248
- test("onRemoveRow removes exactly the targeted row", () => {
260
+ test("onRemoveRow removes exactly the targeted row", async () => {
249
261
  let lastValue: unknown;
250
- renderEmbeddedListField(invoiceLinesField({ value: rows }), (v) => {
262
+ await renderEmbeddedListField(invoiceLinesField({ value: rows }), (v) => {
251
263
  lastValue = v;
252
264
  });
253
265
  captured?.onRemoveRow(0);
254
266
  expect(lastValue).toEqual([rows[1]]);
255
267
  });
256
268
 
257
- test("onDuplicateRow inserts a copy right after the source row", () => {
269
+ test("onDuplicateRow inserts a copy right after the source row", async () => {
258
270
  let lastValue: unknown;
259
- renderEmbeddedListField(invoiceLinesField({ value: rows }), (v) => {
271
+ await renderEmbeddedListField(invoiceLinesField({ value: rows }), (v) => {
260
272
  lastValue = v;
261
273
  });
262
274
  captured?.onDuplicateRow(0);
263
275
  expect(lastValue).toEqual([rows[0], rows[0], rows[1]]);
264
276
  });
265
277
 
266
- test("onDuplicateRow is a no-op for an out-of-range index", () => {
278
+ test("onDuplicateRow is a no-op for an out-of-range index", async () => {
267
279
  let called = false;
268
- renderEmbeddedListField(invoiceLinesField({ value: rows }), () => {
280
+ await renderEmbeddedListField(invoiceLinesField({ value: rows }), () => {
269
281
  called = true;
270
282
  });
271
283
  captured?.onDuplicateRow(99);
272
284
  expect(called).toBe(false);
273
285
  });
274
286
 
275
- test("onMoveRow moves an element from one index to another, immutably", () => {
287
+ test("onMoveRow moves an element from one index to another, immutably", async () => {
276
288
  let lastValue: unknown;
277
- renderEmbeddedListField(invoiceLinesField({ value: rows }), (v) => {
289
+ await renderEmbeddedListField(invoiceLinesField({ value: rows }), (v) => {
278
290
  lastValue = v;
279
291
  });
280
292
  captured?.onMoveRow(1, 0);
@@ -286,9 +298,9 @@ describe("EmbeddedListField — row operations", () => {
286
298
  ]);
287
299
  });
288
300
 
289
- test("onMoveRow is a no-op for an out-of-range target index", () => {
301
+ test("onMoveRow is a no-op for an out-of-range target index", async () => {
290
302
  let called = false;
291
- renderEmbeddedListField(invoiceLinesField({ value: rows }), () => {
303
+ await renderEmbeddedListField(invoiceLinesField({ value: rows }), () => {
292
304
  called = true;
293
305
  });
294
306
  captured?.onMoveRow(0, 5);
@@ -297,10 +309,10 @@ describe("EmbeddedListField — row operations", () => {
297
309
  });
298
310
 
299
311
  describe("EmbeddedListField — paste coercion", () => {
300
- test("pastes number/money/select cells with correct coercion per column type", () => {
312
+ test("pastes number/money/select cells with correct coercion per column type", async () => {
301
313
  const rows = [{ product: "p1", unit: "pcs", quantity: 1, unitPrice: 100, amount: 100 }];
302
314
  let lastValue: unknown;
303
- renderEmbeddedListField(invoiceLinesField({ value: rows }), (v) => {
315
+ await renderEmbeddedListField(invoiceLinesField({ value: rows }), (v) => {
304
316
  lastValue = v;
305
317
  });
306
318
  // Columns in order: product, unit, quantity, unitPrice, amount.
@@ -315,10 +327,10 @@ describe("EmbeddedListField — paste coercion", () => {
315
327
  // the typed-in path's currencyDecimals-based scaling for any non-2-decimal
316
328
  // currency — JPY (0 decimals) pasted "1234" would have landed on 123400
317
329
  // (100x too large) instead of 1234.
318
- test("pastes a money cell for a zero-decimal currency (JPY) without the paste-path ×100 bug", () => {
330
+ test("pastes a money cell for a zero-decimal currency (JPY) without the paste-path ×100 bug", async () => {
319
331
  const rows = [{ product: "p1", unit: "pcs", quantity: 1, unitPrice: 0, amount: 0 }];
320
332
  let lastValue: unknown;
321
- renderEmbeddedListField(
333
+ await renderEmbeddedListField(
322
334
  invoiceLinesField({ value: rows, embeddedListCurrency: "JPY" }),
323
335
  (v) => {
324
336
  lastValue = v;
@@ -331,12 +343,15 @@ describe("EmbeddedListField — paste coercion", () => {
331
343
  expect(result[0]?.["unitPrice"]).toBe(1234);
332
344
  });
333
345
 
334
- test("paste beyond the current rows appends new rows but never past maxItems", () => {
346
+ test("paste beyond the current rows appends new rows but never past maxItems", async () => {
335
347
  const rows = [{ product: "p1", unit: "pcs", quantity: 1, unitPrice: 100, amount: 100 }];
336
348
  let lastValue: unknown;
337
- renderEmbeddedListField(invoiceLinesField({ value: rows, embeddedListMaxItems: 2 }), (v) => {
338
- lastValue = v;
339
- });
349
+ await renderEmbeddedListField(
350
+ invoiceLinesField({ value: rows, embeddedListMaxItems: 2 }),
351
+ (v) => {
352
+ lastValue = v;
353
+ },
354
+ );
340
355
  captured?.onPasteCells?.(0, 2, [
341
356
  ["1", "100"],
342
357
  ["2", "200"],
@@ -348,7 +363,10 @@ describe("EmbeddedListField — paste coercion", () => {
348
363
 
349
364
  test("paste beyond maxItems surfaces a listIssue with the dropped-row count", async () => {
350
365
  const rows = [{ product: "p1", unit: "pcs", quantity: 1, unitPrice: 100, amount: 100 }];
351
- renderEmbeddedListField(invoiceLinesField({ value: rows, embeddedListMaxItems: 2 }), () => {});
366
+ await renderEmbeddedListField(
367
+ invoiceLinesField({ value: rows, embeddedListMaxItems: 2 }),
368
+ () => {},
369
+ );
352
370
  act(() => {
353
371
  captured?.onPasteCells?.(0, 2, [
354
372
  ["1", "100"],
@@ -371,7 +389,7 @@ describe("EmbeddedListField — paste coercion", () => {
371
389
  test("a select paste value with no matching option leaves the cell unchanged and surfaces a listIssue", async () => {
372
390
  const rows = [{ product: "p1", unit: "pcs", quantity: 1, unitPrice: 100, amount: 100 }];
373
391
  let lastValue: unknown;
374
- renderEmbeddedListField(invoiceLinesField({ value: rows }), (v) => {
392
+ await renderEmbeddedListField(invoiceLinesField({ value: rows }), (v) => {
375
393
  lastValue = v;
376
394
  });
377
395
  // Columns in order: product, unit, quantity, unitPrice, amount. Column
@@ -396,10 +414,10 @@ describe("EmbeddedListField — paste coercion", () => {
396
414
  // kumiko-framework#1838: a naive `replace(",", ".")` on a DE-locale paste
397
415
  // with a thousands separator turned "1.234,56" into "1.234.56" → NaN →
398
416
  // the cell silently cleared instead of keeping the pasted amount.
399
- test("pastes a DE-locale money value with a thousands separator (1.234,56)", () => {
417
+ test("pastes a DE-locale money value with a thousands separator (1.234,56)", async () => {
400
418
  const rows = [{ product: "p1", unit: "pcs", quantity: 1, unitPrice: 100, amount: 100 }];
401
419
  let lastValue: unknown;
402
- renderEmbeddedListField(invoiceLinesField({ value: rows }), (v) => {
420
+ await renderEmbeddedListField(invoiceLinesField({ value: rows }), (v) => {
403
421
  lastValue = v;
404
422
  });
405
423
  // Column index 3 = "unitPrice".
@@ -408,10 +426,10 @@ describe("EmbeddedListField — paste coercion", () => {
408
426
  expect(result[0]?.["unitPrice"]).toBe(123456);
409
427
  });
410
428
 
411
- test("pastes an en-US-locale money value with a thousands separator (1,234.56)", () => {
429
+ test("pastes an en-US-locale money value with a thousands separator (1,234.56)", async () => {
412
430
  const rows = [{ product: "p1", unit: "pcs", quantity: 1, unitPrice: 100, amount: 100 }];
413
431
  let lastValue: unknown;
414
- renderEmbeddedListField(invoiceLinesField({ value: rows }), (v) => {
432
+ await renderEmbeddedListField(invoiceLinesField({ value: rows }), (v) => {
415
433
  lastValue = v;
416
434
  });
417
435
  captured?.onPasteCells?.(0, 3, [["1,234.56"]]);
@@ -422,7 +440,7 @@ describe("EmbeddedListField — paste coercion", () => {
422
440
  test("an unparseable money paste leaves the cell unchanged and surfaces a listIssue instead of clearing it", async () => {
423
441
  const rows = [{ product: "p1", unit: "pcs", quantity: 1, unitPrice: 100, amount: 100 }];
424
442
  let lastValue: unknown;
425
- renderEmbeddedListField(invoiceLinesField({ value: rows }), (v) => {
443
+ await renderEmbeddedListField(invoiceLinesField({ value: rows }), (v) => {
426
444
  lastValue = v;
427
445
  });
428
446
  act(() => {
@@ -445,7 +463,7 @@ describe("EmbeddedListField — paste coercion", () => {
445
463
 
446
464
  describe("EmbeddedListField — reference column populated via useQuery", () => {
447
465
  test("referenceOptions come from the product list query", async () => {
448
- renderEmbeddedListField(invoiceLinesField({ value: [] }), () => {}, {}, [
466
+ await renderEmbeddedListField(invoiceLinesField({ value: [] }), () => {}, {}, [
449
467
  { id: "p1", name: "Widget A" },
450
468
  ]);
451
469
  await waitFor(() => {
@@ -456,10 +474,10 @@ describe("EmbeddedListField — reference column populated via useQuery", () =>
456
474
  });
457
475
 
458
476
  describe("EmbeddedListField — issue grouping wiring", () => {
459
- test("a lines.0.amount issue is routed as a cellIssue at 0.amount", () => {
477
+ test("a lines.0.amount issue is routed as a cellIssue at 0.amount", async () => {
460
478
  const rows = [{ product: "p1", unit: "pcs", quantity: 1, unitPrice: 100, amount: 100 }];
461
479
  const issue: FieldIssue = { path: "lines.0.amount", code: "custom", i18nKey: "Bad amount" };
462
- renderEmbeddedListField(invoiceLinesField({ value: rows }), () => {}, {
480
+ await renderEmbeddedListField(invoiceLinesField({ value: rows }), () => {}, {
463
481
  "lines.0.amount": [issue],
464
482
  });
465
483
  expect(captured?.cellIssues?.["0.amount"]).toEqual([issue]);
@@ -467,19 +485,19 @@ describe("EmbeddedListField — issue grouping wiring", () => {
467
485
  expect(captured?.listIssues ?? []).toEqual([]);
468
486
  });
469
487
 
470
- test("a lines-level issue is routed as a listIssue", () => {
488
+ test("a lines-level issue is routed as a listIssue", async () => {
471
489
  const issue: FieldIssue = { path: "lines", code: "custom", i18nKey: "Too few lines" };
472
- renderEmbeddedListField(invoiceLinesField({ value: [] }), () => {}, {
490
+ await renderEmbeddedListField(invoiceLinesField({ value: [] }), () => {}, {
473
491
  lines: [issue],
474
492
  });
475
493
  expect(captured?.listIssues).toEqual([issue]);
476
494
  });
477
495
  });
478
496
 
479
- function renderFieldWithEmbeddedList(
497
+ async function renderFieldWithEmbeddedList(
480
498
  field: EditFieldViewModel,
481
499
  allIssues: RenderFieldProps["allIssues"],
482
- ): void {
500
+ ): Promise<void> {
483
501
  captured = undefined;
484
502
  render(
485
503
  <LocaleProvider
@@ -498,13 +516,14 @@ function renderFieldWithEmbeddedList(
498
516
  </DispatcherProvider>
499
517
  </LocaleProvider>,
500
518
  );
519
+ await act(async () => {});
501
520
  }
502
521
 
503
522
  describe("RenderField — routes embedded-list fields to EmbeddedListField (plumbing)", () => {
504
- test("field.embeddedListCells set → RenderField mounts EmbeddedListField, allIssues propagate", () => {
523
+ test("field.embeddedListCells set → RenderField mounts EmbeddedListField, allIssues propagate", async () => {
505
524
  const rows = [{ product: "p1", unit: "pcs", quantity: 1, unitPrice: 100, amount: 100 }];
506
525
  const issue: FieldIssue = { path: "lines.0.amount", code: "custom", i18nKey: "Bad amount" };
507
- renderFieldWithEmbeddedList(invoiceLinesField({ value: rows }), {
526
+ await renderFieldWithEmbeddedList(invoiceLinesField({ value: rows }), {
508
527
  "lines.0.amount": [issue],
509
528
  });
510
529
  expect(captured?.cellIssues?.["0.amount"]).toEqual([issue]);
@@ -188,6 +188,7 @@ export const kumikoDefaultTranslations: TranslationsByLocale = {
188
188
  "errors.notFound": "Not found.",
189
189
  "errors.conflict": "Conflict — the operation could not be completed.",
190
190
  "errors.versionConflict": "The record was modified in the meantime. Reload and try again.",
191
+ "errors.preconditionFailed": "The record changed in the meantime. Reload and try again.",
191
192
  "errors.uniqueViolation": "This entry already exists.",
192
193
  "errors.unprocessable": "The request could not be processed.",
193
194
  "errors.unconfigured": "This feature isn't configured yet.",