@cosmicdrift/kumiko-headless 0.183.2 → 0.185.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-headless",
3
- "version": "0.183.2",
3
+ "version": "0.185.0",
4
4
  "description": "Headless UI logic for Kumiko — Dispatcher contract, Form-Controller, View-Model, Nav-Resolver. Plattform- und React-frei; jeder Renderer (renderer, renderer-web, renderer-native, …) komponiert darauf.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -36,7 +36,7 @@
36
36
  }
37
37
  },
38
38
  "dependencies": {
39
- "@cosmicdrift/kumiko-framework": "0.183.2",
39
+ "@cosmicdrift/kumiko-framework": "0.185.0",
40
40
  "temporal-polyfill": "^0.3.2",
41
41
  "zod": "^4.4.3"
42
42
  },
package/src/index.ts CHANGED
@@ -86,6 +86,9 @@ export type {
86
86
  EditSectionSpec,
87
87
  EditSectionViewModel,
88
88
  EditViewModel,
89
+ EmbeddedDerivedOp,
90
+ EmbeddedListCellViewModel,
91
+ EmbeddedListIssueGroups,
89
92
  FieldConditionCtx,
90
93
  FieldRenderer,
91
94
  ListColumnSpec,
@@ -96,4 +99,13 @@ export type {
96
99
  ScreenSlots,
97
100
  Translate,
98
101
  } from "./view-model";
99
- export { computeEditViewModel, computeListViewModel, fieldLabelKey } from "./view-model";
102
+ export {
103
+ computeDerivedCellValue,
104
+ computeEditViewModel,
105
+ computeListViewModel,
106
+ embeddedCellLabelKey,
107
+ embeddedCellOptionLabelKey,
108
+ fieldLabelKey,
109
+ groupEmbeddedListIssues,
110
+ sumEmbeddedListColumn,
111
+ } from "./view-model";
@@ -362,3 +362,191 @@ describe("computeEditViewModel — date/timestamp min/max/locale (#369)", () =>
362
362
  expect(field?.dateLocale).toBeUndefined();
363
363
  });
364
364
  });
365
+
366
+ describe("computeEditViewModel — embedded-list cells (#1835)", () => {
367
+ const lineFieldSchema = {
368
+ description: { type: "text", required: true },
369
+ quantity: { type: "number", required: true },
370
+ unit: { type: "select", options: ["hour", "day"] },
371
+ product: { type: "reference", entity: "product", labelField: "name" },
372
+ };
373
+
374
+ function embeddedListEntity(overrides?: Record<string, unknown>): EntityDefinition {
375
+ return {
376
+ fields: {
377
+ lines: {
378
+ type: "embedded",
379
+ multiple: true,
380
+ schema: lineFieldSchema,
381
+ ...overrides,
382
+ },
383
+ },
384
+ } as unknown as EntityDefinition;
385
+ }
386
+
387
+ test("multiple: true embedded field → embeddedListCells has one entry per schema key with correct type/required/options/optionLabels/refEntity", () => {
388
+ const vm = computeEditViewModel({
389
+ screen: editScreen({ sections: [{ title: "x", fields: ["lines"] }] }),
390
+ entity: embeddedListEntity(),
391
+ values: { lines: [] },
392
+ translate,
393
+ featureName: "orders",
394
+ });
395
+
396
+ const field = asFields(vm.sections[0]).fields[0];
397
+ expect(field?.type).toBe("embedded");
398
+ const cells = field?.embeddedListCells;
399
+ expect(cells).toHaveLength(4);
400
+
401
+ const byField = Object.fromEntries((cells ?? []).map((cell) => [cell.field, cell]));
402
+ expect(byField["description"]).toMatchObject({
403
+ type: "text",
404
+ required: true,
405
+ label: "orders:entity:order:field:lines:cell:description",
406
+ });
407
+ expect(byField["quantity"]).toMatchObject({ type: "number", required: true });
408
+ expect(byField["unit"]).toMatchObject({
409
+ type: "select",
410
+ required: false,
411
+ options: ["hour", "day"],
412
+ optionLabels: { hour: "hour", day: "day" },
413
+ });
414
+ expect(byField["product"]).toMatchObject({
415
+ type: "reference",
416
+ required: false,
417
+ refEntity: "product",
418
+ refFeature: "orders",
419
+ refLabelField: "name",
420
+ });
421
+ });
422
+
423
+ test("minItems/maxItems/derived/totals pass through onto the field view-model when set", () => {
424
+ const vm = computeEditViewModel({
425
+ screen: editScreen({ sections: [{ title: "x", fields: ["lines"] }] }),
426
+ entity: embeddedListEntity({
427
+ minItems: 1,
428
+ maxItems: 20,
429
+ derived: { total: { op: "multiply", from: ["quantity", "unitPrice"] } },
430
+ totals: ["quantity", "total"],
431
+ }),
432
+ values: { lines: [] },
433
+ translate,
434
+ featureName: "orders",
435
+ });
436
+
437
+ const field = asFields(vm.sections[0]).fields[0];
438
+ expect(field?.embeddedListMinItems).toBe(1);
439
+ expect(field?.embeddedListMaxItems).toBe(20);
440
+ expect(field?.embeddedListDerived).toEqual({
441
+ total: { op: "multiply", from: ["quantity", "unitPrice"] },
442
+ });
443
+ expect(field?.embeddedListTotals).toEqual(["quantity", "total"]);
444
+ });
445
+
446
+ test("minItems/maxItems/derived/totals stay undefined when not declared", () => {
447
+ const vm = computeEditViewModel({
448
+ screen: editScreen({ sections: [{ title: "x", fields: ["lines"] }] }),
449
+ entity: embeddedListEntity(),
450
+ values: { lines: [] },
451
+ translate,
452
+ featureName: "orders",
453
+ });
454
+
455
+ const field = asFields(vm.sections[0]).fields[0];
456
+ expect(field?.embeddedListMinItems).toBeUndefined();
457
+ expect(field?.embeddedListMaxItems).toBeUndefined();
458
+ expect(field?.embeddedListDerived).toBeUndefined();
459
+ expect(field?.embeddedListTotals).toBeUndefined();
460
+ });
461
+
462
+ test("a plain (non-list) embedded field never gets embeddedListCells — that's how the renderer tells it apart from an embedded list", () => {
463
+ const entity = {
464
+ fields: {
465
+ meta: { type: "embedded", schema: { note: { type: "text" } } },
466
+ },
467
+ } as unknown as EntityDefinition;
468
+
469
+ const vm = computeEditViewModel({
470
+ screen: editScreen({ sections: [{ title: "x", fields: ["meta"] }] }),
471
+ entity,
472
+ values: { meta: {} },
473
+ translate,
474
+ featureName: "orders",
475
+ });
476
+
477
+ const field = asFields(vm.sections[0]).fields[0];
478
+ expect(field?.type).toBe("embedded");
479
+ expect(field?.embeddedListCells).toBeUndefined();
480
+ expect(field?.embeddedListMinItems).toBeUndefined();
481
+ });
482
+
483
+ test("select-cell options are translated when registered, fall back to the raw value otherwise", () => {
484
+ const translateUnit = (key: string) =>
485
+ key === "orders:entity:order:field:lines:cell:unit:option:hour" ? "Stunde" : key;
486
+
487
+ const vm = computeEditViewModel({
488
+ screen: editScreen({ sections: [{ title: "x", fields: ["lines"] }] }),
489
+ entity: embeddedListEntity(),
490
+ values: { lines: [] },
491
+ translate: translateUnit,
492
+ featureName: "orders",
493
+ });
494
+
495
+ const field = asFields(vm.sections[0]).fields[0];
496
+ const unitCell = field?.embeddedListCells?.find((cell) => cell.field === "unit");
497
+ expect(unitCell?.optionLabels).toEqual({ hour: "Stunde", day: "day" });
498
+ });
499
+
500
+ test("embeddedListCurrency mirrors entity.defaultCurrency (#1839)", () => {
501
+ const entity = {
502
+ defaultCurrency: "USD",
503
+ fields: {
504
+ lines: { type: "embedded", multiple: true, schema: lineFieldSchema },
505
+ },
506
+ } as unknown as EntityDefinition;
507
+
508
+ const vm = computeEditViewModel({
509
+ screen: editScreen({ sections: [{ title: "x", fields: ["lines"] }] }),
510
+ entity,
511
+ values: { lines: [] },
512
+ translate,
513
+ featureName: "orders",
514
+ });
515
+
516
+ const field = asFields(vm.sections[0]).fields[0];
517
+ expect(field?.embeddedListCurrency).toBe("USD");
518
+ });
519
+
520
+ test("embeddedListCurrency falls back to EUR when the entity has no defaultCurrency (#1839)", () => {
521
+ const vm = computeEditViewModel({
522
+ screen: editScreen({ sections: [{ title: "x", fields: ["lines"] }] }),
523
+ entity: embeddedListEntity(),
524
+ values: { lines: [] },
525
+ translate,
526
+ featureName: "orders",
527
+ });
528
+
529
+ const field = asFields(vm.sections[0]).fields[0];
530
+ expect(field?.embeddedListCurrency).toBe("EUR");
531
+ });
532
+
533
+ test("embeddedListCurrency is undefined for a plain (non-list) embedded field", () => {
534
+ const entity = {
535
+ defaultCurrency: "USD",
536
+ fields: {
537
+ meta: { type: "embedded", schema: { note: { type: "text" } } },
538
+ },
539
+ } as unknown as EntityDefinition;
540
+
541
+ const vm = computeEditViewModel({
542
+ screen: editScreen({ sections: [{ title: "x", fields: ["meta"] }] }),
543
+ entity,
544
+ values: { meta: {} },
545
+ translate,
546
+ featureName: "orders",
547
+ });
548
+
549
+ const field = asFields(vm.sections[0]).fields[0];
550
+ expect(field?.embeddedListCurrency).toBeUndefined();
551
+ });
552
+ });
@@ -0,0 +1,84 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { FieldIssue } from "../../dispatcher";
3
+ import {
4
+ computeDerivedCellValue,
5
+ groupEmbeddedListIssues,
6
+ sumEmbeddedListColumn,
7
+ } from "../embedded-list";
8
+
9
+ function issue(path: string): FieldIssue {
10
+ return { path, code: "custom", i18nKey: "errors.validation.custom" };
11
+ }
12
+
13
+ describe("computeDerivedCellValue", () => {
14
+ test("multiply with two values", () => {
15
+ expect(computeDerivedCellValue("multiply", [3, 4])).toBe(12);
16
+ });
17
+
18
+ test("multiply with three values", () => {
19
+ expect(computeDerivedCellValue("multiply", [2, 3, 5])).toBe(30);
20
+ });
21
+
22
+ test("sum adds all values", () => {
23
+ expect(computeDerivedCellValue("sum", [1, 2, 3])).toBe(6);
24
+ });
25
+
26
+ test("subtract subtracts every value after the first", () => {
27
+ expect(computeDerivedCellValue("subtract", [10, 3, 2])).toBe(5);
28
+ });
29
+
30
+ test("multiply returns undefined when a source is missing", () => {
31
+ expect(computeDerivedCellValue("multiply", [3, undefined])).toBeUndefined();
32
+ });
33
+
34
+ test("sum treats a missing source as 0", () => {
35
+ expect(computeDerivedCellValue("sum", [1, undefined, 2])).toBe(3);
36
+ });
37
+
38
+ test("subtract treats a missing source as 0", () => {
39
+ expect(computeDerivedCellValue("subtract", [10, undefined])).toBe(10);
40
+ });
41
+ });
42
+
43
+ describe("sumEmbeddedListColumn", () => {
44
+ test("sums a numeric column across rows", () => {
45
+ const rows = [{ amount: 100 }, { amount: 250 }, { amount: 50 }];
46
+ expect(sumEmbeddedListColumn(rows, "amount")).toBe(400);
47
+ });
48
+
49
+ test("treats a row missing the field as 0", () => {
50
+ const rows = [{ amount: 100 }, { other: 1 }, { amount: 50 }];
51
+ expect(sumEmbeddedListColumn(rows, "amount")).toBe(150);
52
+ });
53
+
54
+ test("returns 0 for an empty rows array", () => {
55
+ expect(sumEmbeddedListColumn([], "amount")).toBe(0);
56
+ });
57
+ });
58
+
59
+ describe("groupEmbeddedListIssues", () => {
60
+ test("buckets list/row/cell issues, ignoring unrelated and too-deep keys", () => {
61
+ const allIssues: Record<string, readonly FieldIssue[]> = {
62
+ lines: [issue("lines")],
63
+ "lines.0": [issue("lines.0")],
64
+ "lines.0.amount": [issue("lines.0.amount")],
65
+ "lines.1.qty": [issue("lines.1.qty")],
66
+ title: [issue("title")],
67
+ "lines.0.amount.nested": [issue("lines.0.amount.nested")],
68
+ };
69
+
70
+ const result = groupEmbeddedListIssues(allIssues, "lines");
71
+
72
+ expect(result.listIssues).toEqual([issue("lines")]);
73
+ expect(result.rowIssues).toEqual({ 0: [issue("lines.0")] });
74
+ expect(result.cellIssues).toEqual({
75
+ "0.amount": [issue("lines.0.amount")],
76
+ "1.qty": [issue("lines.1.qty")],
77
+ });
78
+ // Unrelated top-level key and the too-deep key must not leak anywhere.
79
+ expect(Object.values(result.cellIssues).flat()).not.toContainEqual(issue("title"));
80
+ expect(Object.values(result.cellIssues).flat()).not.toContainEqual(
81
+ issue("lines.0.amount.nested"),
82
+ );
83
+ });
84
+ });
@@ -1,5 +1,11 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { buildOptionLabels, fieldLabelKey, fieldOptionLabelKey } from "../list";
2
+ import {
3
+ buildOptionLabels,
4
+ embeddedCellLabelKey,
5
+ embeddedCellOptionLabelKey,
6
+ fieldLabelKey,
7
+ fieldOptionLabelKey,
8
+ } from "../list";
3
9
 
4
10
  describe("fieldLabelKey", () => {
5
11
  test("follows feature:entity:field convention", () => {
@@ -17,16 +23,40 @@ describe("fieldOptionLabelKey", () => {
17
23
  });
18
24
  });
19
25
 
26
+ describe("embeddedCellLabelKey", () => {
27
+ test("adds a cell segment for the sub-field name", () => {
28
+ expect(embeddedCellLabelKey("billing", "invoice", "lines", "quantity")).toBe(
29
+ "billing:entity:invoice:field:lines:cell:quantity",
30
+ );
31
+ });
32
+ });
33
+
34
+ describe("embeddedCellOptionLabelKey", () => {
35
+ test("appends option value segment after the cell segment", () => {
36
+ expect(embeddedCellOptionLabelKey("billing", "invoice", "lines", "unit", "hour")).toBe(
37
+ "billing:entity:invoice:field:lines:cell:unit:option:hour",
38
+ );
39
+ });
40
+ });
41
+
20
42
  describe("buildOptionLabels", () => {
21
43
  test("maps option values to translated labels with fallback to raw value", () => {
22
44
  const labels = buildOptionLabels(
23
45
  (key) => (key.endsWith(":option:draft") ? "Draft" : key),
24
- "tasks",
25
- "task",
26
- "status",
46
+ (value) => fieldOptionLabelKey("tasks", "task", "status", value),
27
47
  ["draft", "done"],
28
48
  );
29
49
  expect(labels["draft"]).toBe("Draft");
30
50
  expect(labels["done"]).toBe("done");
31
51
  });
52
+
53
+ test("keyFor lets the caller supply any key convention (e.g. embedded cell)", () => {
54
+ const labels = buildOptionLabels(
55
+ (key) => (key.endsWith(":option:hour") ? "Hour" : key),
56
+ (value) => embeddedCellOptionLabelKey("billing", "invoice", "lines", "unit", value),
57
+ ["hour", "day"],
58
+ );
59
+ expect(labels["hour"]).toBe("Hour");
60
+ expect(labels["day"]).toBe("day");
61
+ });
32
62
  });
@@ -9,8 +9,42 @@ import {
9
9
  normalizeEditField,
10
10
  parseRefTarget,
11
11
  } from "@cosmicdrift/kumiko-framework/ui-types";
12
- import { buildOptionLabels, fieldLabelKey } from "./list";
13
- import type { EditFieldViewModel, EditSectionViewModel, EditViewModel, Translate } from "./types";
12
+ import {
13
+ buildOptionLabels,
14
+ embeddedCellLabelKey,
15
+ embeddedCellOptionLabelKey,
16
+ fieldLabelKey,
17
+ fieldOptionLabelKey,
18
+ } from "./list";
19
+ import type {
20
+ EditFieldViewModel,
21
+ EditSectionViewModel,
22
+ EditViewModel,
23
+ EmbeddedListCellViewModel,
24
+ Translate,
25
+ } from "./types";
26
+
27
+ // Sub-field shape read off an EmbeddedFieldDef.schema entry. Mirrors
28
+ // EmbeddedSubFieldDef from packages/types/src/fields.ts — headless only
29
+ // depends on @cosmicdrift/kumiko-framework/ui-types (client-safe subset),
30
+ // which doesn't re-export the embedded types, so this stays a local cast
31
+ // shape like every other field-type narrowing in this file.
32
+ type EmbeddedSubFieldShape = {
33
+ readonly type:
34
+ | "text"
35
+ | "number"
36
+ | "boolean"
37
+ | "date"
38
+ | "money"
39
+ | "decimal"
40
+ | "select"
41
+ | "reference"
42
+ | "timestamp";
43
+ readonly required?: boolean;
44
+ readonly options?: readonly string[];
45
+ readonly entity?: string;
46
+ readonly labelField?: string;
47
+ };
14
48
 
15
49
  export type ComputeEditViewModelInput<
16
50
  TValues extends Readonly<Record<string, unknown>> = Readonly<Record<string, unknown>>,
@@ -72,7 +106,11 @@ export function computeEditViewModel<
72
106
  : undefined;
73
107
  const optionLabels =
74
108
  options !== undefined
75
- ? buildOptionLabels(translate, featureName, screen.entity, normalized.field, options)
109
+ ? buildOptionLabels(
110
+ translate,
111
+ (value) => fieldOptionLabelKey(featureName, screen.entity, normalized.field, value),
112
+ options,
113
+ )
76
114
  : undefined;
77
115
  // Multiline-Hint bei `type: "text"` — der Renderer wechselt
78
116
  // dann auf textarea. ViewModel hält die Form-Render-Decision
@@ -127,6 +165,69 @@ export function computeEditViewModel<
127
165
  const fileDef = isFileType
128
166
  ? (fieldDef as unknown as { accept?: readonly string[]; maxSize?: string })
129
167
  : undefined;
168
+ // Embedded-LIST field (`multiple: true`) — per-cell metadata for a
169
+ // renderer to draw one row per array item (invoice-positions-style
170
+ // table). A plain (non-list) embedded field emits none of this; the
171
+ // renderer tells the two apart by whether embeddedListCells is set,
172
+ // not by `type` (which stays "embedded" either way).
173
+ const isEmbeddedList =
174
+ fieldDef.type === "embedded" &&
175
+ (fieldDef as unknown as { multiple?: boolean }).multiple === true;
176
+ const embeddedListDef = isEmbeddedList
177
+ ? (fieldDef as unknown as {
178
+ schema: Readonly<Record<string, EmbeddedSubFieldShape>>;
179
+ minItems?: number;
180
+ maxItems?: number;
181
+ derived?: Readonly<
182
+ Record<
183
+ string,
184
+ { readonly op: "multiply" | "sum" | "subtract"; readonly from: readonly string[] }
185
+ >
186
+ >;
187
+ totals?: readonly string[];
188
+ })
189
+ : undefined;
190
+ const embeddedListCells: readonly EmbeddedListCellViewModel[] | undefined =
191
+ embeddedListDef !== undefined
192
+ ? Object.entries(embeddedListDef.schema).map(([subFieldName, subField]) => {
193
+ const cellLabel = translate(
194
+ embeddedCellLabelKey(featureName, screen.entity, normalized.field, subFieldName),
195
+ );
196
+ const cellOptions = subField.type === "select" ? (subField.options ?? []) : undefined;
197
+ const cellOptionLabels =
198
+ cellOptions !== undefined
199
+ ? buildOptionLabels(
200
+ translate,
201
+ (value) =>
202
+ embeddedCellOptionLabelKey(
203
+ featureName,
204
+ screen.entity,
205
+ normalized.field,
206
+ subFieldName,
207
+ value,
208
+ ),
209
+ cellOptions,
210
+ )
211
+ : undefined;
212
+ const cellRefTarget =
213
+ subField.type === "reference" && subField.entity !== undefined
214
+ ? parseRefTarget(subField.entity, featureName)
215
+ : undefined;
216
+ const cell: EmbeddedListCellViewModel = {
217
+ field: subFieldName,
218
+ label: cellLabel,
219
+ type: subField.type,
220
+ required: subField.required === true,
221
+ ...(cellOptions !== undefined && { options: cellOptions }),
222
+ ...(cellOptionLabels !== undefined && { optionLabels: cellOptionLabels }),
223
+ ...(cellRefTarget !== undefined && { refEntity: cellRefTarget.entityName }),
224
+ ...(cellRefTarget !== undefined && { refFeature: cellRefTarget.featureName }),
225
+ ...(subField.type === "reference" &&
226
+ subField.labelField !== undefined && { refLabelField: subField.labelField }),
227
+ };
228
+ return cell;
229
+ })
230
+ : undefined;
130
231
  const view: EditFieldViewModel = {
131
232
  field: normalized.field,
132
233
  label,
@@ -152,6 +253,28 @@ export function computeEditViewModel<
152
253
  ...(fileDef?.maxSize !== undefined && { maxSize: fileDef.maxSize }),
153
254
  ...(isFileType && { entityType: screen.entity, fieldName: normalized.field }),
154
255
  ...(normalized.icon !== undefined && { icon: normalized.icon }),
256
+ ...(embeddedListCells !== undefined && { embeddedListCells }),
257
+ ...(embeddedListDef?.minItems !== undefined && {
258
+ embeddedListMinItems: embeddedListDef.minItems,
259
+ }),
260
+ ...(embeddedListDef?.maxItems !== undefined && {
261
+ embeddedListMaxItems: embeddedListDef.maxItems,
262
+ }),
263
+ ...(embeddedListDef?.derived !== undefined && {
264
+ embeddedListDerived: embeddedListDef.derived,
265
+ }),
266
+ ...(embeddedListDef?.totals !== undefined && {
267
+ embeddedListTotals: embeddedListDef.totals,
268
+ }),
269
+ // ponytail: "EUR" mirrors DEFAULT_CURRENCIES[0] from
270
+ // framework/src/engine/field-helpers.ts — headless has no dependency
271
+ // on that module, so the literal is duplicated here instead of
272
+ // importing it just for one fallback string. Currency lives on the
273
+ // head aggregate (entity.defaultCurrency), not per row — one value
274
+ // for the whole embedded list.
275
+ ...(embeddedListDef !== undefined && {
276
+ embeddedListCurrency: entity.defaultCurrency ?? "EUR",
277
+ }),
155
278
  };
156
279
  return view;
157
280
  });
@@ -0,0 +1,84 @@
1
+ import type { FieldIssue } from "../dispatcher";
2
+
3
+ export type EmbeddedDerivedOp = "multiply" | "sum" | "subtract";
4
+
5
+ // Canonical implementation moved to packages/framework/src/engine/embedded-derived.ts
6
+ // (the write-schema preprocess needs it server-side too).
7
+ export { computeDerivedCellValue } from "@cosmicdrift/kumiko-framework/ui-types";
8
+
9
+ /** Sums a numeric/money/decimal column across all rows. Non-numeric or
10
+ * missing values count as 0. Money columns are minor-unit integers —
11
+ * the sum stays in minor units, never rounds through a major-unit float. */
12
+ export function sumEmbeddedListColumn(
13
+ rows: readonly Readonly<Record<string, unknown>>[],
14
+ field: string,
15
+ ): number {
16
+ let sum = 0;
17
+ for (const row of rows) {
18
+ const value = row[field];
19
+ if (typeof value === "number" && Number.isFinite(value)) sum += value;
20
+ }
21
+ return sum;
22
+ }
23
+
24
+ export type EmbeddedListIssueGroups = {
25
+ /** Issues at exactly `listField` — e.g. min/max row-count violations. */
26
+ readonly listIssues: readonly FieldIssue[];
27
+ /** Issues at exactly `${listField}.${rowIndex}` — a row-level check
28
+ * (e.g. "row incomplete") that isn't attributable to one cell. */
29
+ readonly rowIssues: Readonly<Record<number, readonly FieldIssue[]>>;
30
+ /** Issues at exactly `${listField}.${rowIndex}.${cellField}`, keyed
31
+ * `${rowIndex}.${cellField}` (not the full path — the caller already
32
+ * knows listField). */
33
+ readonly cellIssues: Readonly<Record<string, readonly FieldIssue[]>>;
34
+ };
35
+
36
+ /** Buckets a flat issues-by-path map (FormSnapshot.errors shape — see
37
+ * packages/headless/src/form/zod-bridge.ts groupIssuesByPath) into the
38
+ * three levels an embedded-list widget renders. Only keys that are
39
+ * exactly `listField`, `listField.N`, or `listField.N.subfield` are
40
+ * claimed; unrelated keys (other top-level fields, or deeper nesting
41
+ * than this field ever produces) are ignored. */
42
+ export function groupEmbeddedListIssues(
43
+ allIssues: Readonly<Record<string, readonly FieldIssue[]>>,
44
+ listField: string,
45
+ ): EmbeddedListIssueGroups {
46
+ const listIssues: FieldIssue[] = [];
47
+ const rowIssues: Record<number, readonly FieldIssue[]> = {};
48
+ const cellIssues: Record<string, readonly FieldIssue[]> = {};
49
+
50
+ const prefix = `${listField}.`;
51
+ for (const [path, issues] of Object.entries(allIssues)) {
52
+ if (path === listField) {
53
+ listIssues.push(...issues);
54
+ continue;
55
+ }
56
+ if (!path.startsWith(prefix)) continue;
57
+ const rest = path.slice(prefix.length);
58
+ const segments = rest.split(".");
59
+ if (segments.length === 1) {
60
+ const rowIndex = parsePureRowIndex(segments[0]);
61
+ if (rowIndex === undefined) continue;
62
+ rowIssues[rowIndex] = issues;
63
+ continue;
64
+ }
65
+ if (segments.length === 2) {
66
+ const rowIndex = parsePureRowIndex(segments[0]);
67
+ if (rowIndex === undefined) continue;
68
+ const cellField = segments[1];
69
+ cellIssues[`${rowIndex}.${cellField}`] = issues;
70
+ }
71
+ // Deeper than `listField.N.subfield` — not a shape this field ever
72
+ // produces; ignore rather than misattribute to a cell.
73
+ }
74
+
75
+ return { listIssues, rowIssues, cellIssues };
76
+ }
77
+
78
+ function parsePureRowIndex(segment: string | undefined): number | undefined {
79
+ if (segment === undefined || segment.length === 0) return undefined;
80
+ for (const char of segment) {
81
+ if (char < "0" || char > "9") return undefined;
82
+ }
83
+ return Number(segment);
84
+ }
@@ -1,7 +1,18 @@
1
1
  export type { ComputeEditViewModelInput } from "./edit";
2
2
  export { computeEditViewModel } from "./edit";
3
+ export type { EmbeddedDerivedOp, EmbeddedListIssueGroups } from "./embedded-list";
4
+ export {
5
+ computeDerivedCellValue,
6
+ groupEmbeddedListIssues,
7
+ sumEmbeddedListColumn,
8
+ } from "./embedded-list";
3
9
  export type { ComputeListViewModelInput } from "./list";
4
- export { computeListViewModel, fieldLabelKey } from "./list";
10
+ export {
11
+ computeListViewModel,
12
+ embeddedCellLabelKey,
13
+ embeddedCellOptionLabelKey,
14
+ fieldLabelKey,
15
+ } from "./list";
5
16
  export type {
6
17
  EditExtensionSectionViewModel,
7
18
  EditFieldSpec,
@@ -10,6 +21,7 @@ export type {
10
21
  EditSectionSpec,
11
22
  EditSectionViewModel,
12
23
  EditViewModel,
24
+ EmbeddedListCellViewModel,
13
25
  FieldConditionCtx,
14
26
  FieldRenderer,
15
27
  ListColumnSpec,
@@ -103,9 +103,7 @@ export function computeListViewModel(input: ComputeListViewModelInput): ListView
103
103
  fieldDef.type === "select"
104
104
  ? buildOptionLabels(
105
105
  translate,
106
- featureName,
107
- screen.entity,
108
- normalized.field,
106
+ (value) => fieldOptionLabelKey(featureName, screen.entity, normalized.field, value),
109
107
  (fieldDef as unknown as { options?: readonly string[] }).options ?? [],
110
108
  )
111
109
  : undefined;
@@ -155,6 +153,28 @@ export function fieldOptionLabelKey(
155
153
  return `${featureName}:entity:${entityName}:field:${fieldName}:option:${value}`;
156
154
  }
157
155
 
156
+ // Embedded-list cell label key — one level deeper than fieldLabelKey,
157
+ // keyed by the sub-field name inside an embedded-LIST field's `schema`
158
+ // (invoice-positions-style tables built via createEmbeddedListField()).
159
+ export function embeddedCellLabelKey(
160
+ featureName: string,
161
+ entityName: string,
162
+ fieldName: string,
163
+ subFieldName: string,
164
+ ): string {
165
+ return `${featureName}:entity:${entityName}:field:${fieldName}:cell:${subFieldName}`;
166
+ }
167
+
168
+ export function embeddedCellOptionLabelKey(
169
+ featureName: string,
170
+ entityName: string,
171
+ fieldName: string,
172
+ subFieldName: string,
173
+ value: string,
174
+ ): string {
175
+ return `${featureName}:entity:${entityName}:field:${fieldName}:cell:${subFieldName}:option:${value}`;
176
+ }
177
+
158
178
  // Build a value→label map for a select-field's options. Convention:
159
179
  // translate() returns the input key when the lookup misses (i18next
160
180
  // default + LocaleResolver convention) — we surface the *raw value* in
@@ -162,18 +182,18 @@ export function fieldOptionLabelKey(
162
182
  // Without that fallback, an unlabeled option would render as the full
163
183
  // `feature:entity:field:option:value`-key.
164
184
  //
165
- // Shared between list-VM and edit-VM so both builders produce
166
- // identical option-translations.
185
+ // `keyFor` carries whichever key convention the caller needs (top-level
186
+ // field via fieldOptionLabelKey, embedded-list cell via
187
+ // embeddedCellOptionLabelKey) — shared between list-VM, edit-VM, and
188
+ // embedded-list cells so all three produce identical option-translations.
167
189
  export function buildOptionLabels(
168
190
  translate: (key: string, params?: Readonly<Record<string, unknown>>) => string,
169
- featureName: string,
170
- entityName: string,
171
- fieldName: string,
191
+ keyFor: (value: string) => string,
172
192
  options: readonly string[],
173
193
  ): Readonly<Record<string, string>> {
174
194
  const out: Record<string, string> = {};
175
195
  for (const value of options) {
176
- const key = fieldOptionLabelKey(featureName, entityName, fieldName, value);
196
+ const key = keyFor(value);
177
197
  const translated = translate(key);
178
198
  out[value] = translated === key ? value : translated;
179
199
  }
@@ -75,6 +75,33 @@ export type ListViewModel = {
75
75
 
76
76
  // --- edit view model ---
77
77
 
78
+ // Per-cell metadata for one sub-field of an embedded-LIST field
79
+ // (`multiple: true`) — the column/cell shape a renderer needs to draw an
80
+ // invoice-positions-style table. One entry per key of the source
81
+ // EmbeddedFieldDef's `schema`.
82
+ export type EmbeddedListCellViewModel = {
83
+ readonly field: string;
84
+ readonly label: string;
85
+ readonly type:
86
+ | "text"
87
+ | "number"
88
+ | "boolean"
89
+ | "date"
90
+ | "money"
91
+ | "decimal"
92
+ | "select"
93
+ | "reference"
94
+ | "timestamp";
95
+ readonly required: boolean;
96
+ /** Only for `type: "select"`. */
97
+ readonly options?: readonly string[];
98
+ readonly optionLabels?: Readonly<Record<string, string>>;
99
+ /** Only for `type: "reference"`. */
100
+ readonly refEntity?: string;
101
+ readonly refFeature?: string;
102
+ readonly refLabelField?: string;
103
+ };
104
+
78
105
  // Resolved field — all predicates evaluated, labels translated. The
79
106
  // renderer reads `{ visible, readonly, required }` directly without
80
107
  // re-running any predicate.
@@ -148,6 +175,28 @@ export type EditFieldViewModel = {
148
175
  * resolves it against the FIELD_ICONS registry; unknown keys silently
149
176
  * fall back to "no icon". */
150
177
  readonly icon?: string;
178
+ /** Only set for an embedded-LIST field (`multiple: true`) — the per-cell
179
+ * metadata the renderer needs to draw each column/cell. Absent for a
180
+ * plain (non-list) embedded field. */
181
+ readonly embeddedListCells?: readonly EmbeddedListCellViewModel[];
182
+ /** Minimum row count for the embedded list. From EmbeddedFieldDef.minItems. */
183
+ readonly embeddedListMinItems?: number;
184
+ /** Maximum row count for the embedded list. From EmbeddedFieldDef.maxItems. */
185
+ readonly embeddedListMaxItems?: number;
186
+ /** Sub-field name → how to compute its cell from other sub-fields of the
187
+ * same row. From EmbeddedFieldDef.derived. */
188
+ readonly embeddedListDerived?: Readonly<
189
+ Record<
190
+ string,
191
+ { readonly op: "multiply" | "sum" | "subtract"; readonly from: readonly string[] }
192
+ >
193
+ >;
194
+ /** Numeric sub-field names to sum in a totals row. From EmbeddedFieldDef.totals. */
195
+ readonly embeddedListTotals?: readonly string[];
196
+ /** Currency for the whole embedded list's money cells and totals row —
197
+ * currency lives on the head aggregate (EntityDefinition.defaultCurrency),
198
+ * not per row. Falls back to "EUR" when the entity has no defaultCurrency. */
199
+ readonly embeddedListCurrency?: string;
151
200
  };
152
201
 
153
202
  // Discriminated by `kind` — mirrors EditSectionSpec on the engine side.