@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 +2 -2
- package/src/index.ts +13 -1
- package/src/view-model/__tests__/edit.test.ts +188 -0
- package/src/view-model/__tests__/embedded-list.test.ts +84 -0
- package/src/view-model/__tests__/field-labels.test.ts +34 -4
- package/src/view-model/edit.ts +126 -3
- package/src/view-model/embedded-list.ts +84 -0
- package/src/view-model/index.ts +13 -1
- package/src/view-model/list.ts +29 -9
- package/src/view-model/types.ts +49 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-headless",
|
|
3
|
-
"version": "0.
|
|
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.
|
|
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 {
|
|
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 {
|
|
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
|
});
|
package/src/view-model/edit.ts
CHANGED
|
@@ -9,8 +9,42 @@ import {
|
|
|
9
9
|
normalizeEditField,
|
|
10
10
|
parseRefTarget,
|
|
11
11
|
} from "@cosmicdrift/kumiko-framework/ui-types";
|
|
12
|
-
import {
|
|
13
|
-
|
|
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(
|
|
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
|
+
}
|
package/src/view-model/index.ts
CHANGED
|
@@ -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 {
|
|
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,
|
package/src/view-model/list.ts
CHANGED
|
@@ -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
|
-
//
|
|
166
|
-
//
|
|
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
|
-
|
|
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 =
|
|
196
|
+
const key = keyFor(value);
|
|
177
197
|
const translated = translate(key);
|
|
178
198
|
out[value] = translated === key ? value : translated;
|
|
179
199
|
}
|
package/src/view-model/types.ts
CHANGED
|
@@ -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.
|