@cosmicdrift/kumiko-headless 0.328.0 → 0.329.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.
Files changed (108) hide show
  1. package/dist/apex/css.d.ts +3 -0
  2. package/{src/apex/css.ts → dist/apex/css.js} +11 -24
  3. package/dist/apex/index.d.ts +229 -0
  4. package/dist/apex/index.js +360 -0
  5. package/dist/apex/lightbox.d.ts +4 -0
  6. package/{src/apex/lightbox.ts → dist/apex/lightbox.js} +0 -4
  7. package/dist/contracts/asset.d.ts +15 -0
  8. package/dist/contracts/asset.js +17 -0
  9. package/dist/contracts/index.d.ts +3 -0
  10. package/dist/contracts/index.js +1 -0
  11. package/dist/contracts/locale.d.ts +7 -0
  12. package/dist/contracts/locale.js +11 -0
  13. package/dist/contracts/primitives.d.ts +93 -0
  14. package/dist/contracts/primitives.js +21 -0
  15. package/dist/dispatcher/index.d.ts +2 -0
  16. package/dist/dispatcher/index.js +1 -0
  17. package/dist/dispatcher/types.d.ts +86 -0
  18. package/dist/dispatcher/types.js +34 -0
  19. package/dist/form/form-controller.d.ts +2 -0
  20. package/dist/form/form-controller.js +404 -0
  21. package/dist/form/index.d.ts +3 -0
  22. package/dist/form/index.js +2 -0
  23. package/dist/form/types.d.ts +71 -0
  24. package/dist/form/types.js +1 -0
  25. package/dist/form/zod-bridge.d.ts +5 -0
  26. package/{src/form/zod-bridge.ts → dist/form/zod-bridge.js} +52 -58
  27. package/dist/format/escape.d.ts +5 -0
  28. package/{src/format/escape.ts → dist/format/escape.js} +43 -47
  29. package/dist/format/html-template.d.ts +9 -0
  30. package/dist/format/html-template.js +39 -0
  31. package/dist/format/index.d.ts +10 -0
  32. package/dist/format/index.js +192 -0
  33. package/dist/format/money.d.ts +1 -0
  34. package/{src/format/money.ts → dist/format/money.js} +6 -5
  35. package/dist/index.d.ts +14 -0
  36. package/dist/index.js +9 -0
  37. package/dist/locale-routing/index.d.ts +39 -0
  38. package/dist/locale-routing/index.js +143 -0
  39. package/dist/nav/index.d.ts +2 -0
  40. package/dist/nav/index.js +1 -0
  41. package/dist/nav/resolve.d.ts +2 -0
  42. package/dist/nav/resolve.js +58 -0
  43. package/dist/nav/types.d.ts +26 -0
  44. package/dist/nav/types.js +1 -0
  45. package/dist/store/create-store.d.ts +2 -0
  46. package/{src/store/create-store.ts → dist/store/create-store.js} +21 -23
  47. package/dist/store/equality.d.ts +1 -0
  48. package/{src/store/equality.ts → dist/store/equality.js} +19 -17
  49. package/dist/store/index.d.ts +3 -0
  50. package/dist/store/index.js +2 -0
  51. package/dist/store/types.d.ts +7 -0
  52. package/dist/store/types.js +10 -0
  53. package/dist/view-model/edit.d.ts +10 -0
  54. package/dist/view-model/edit.js +392 -0
  55. package/dist/view-model/embedded-list.d.ts +27 -0
  56. package/dist/view-model/embedded-list.js +63 -0
  57. package/dist/view-model/index.d.ts +7 -0
  58. package/dist/view-model/index.js +3 -0
  59. package/dist/view-model/list.d.ts +16 -0
  60. package/dist/view-model/list.js +201 -0
  61. package/dist/view-model/types.d.ts +300 -0
  62. package/dist/view-model/types.js +1 -0
  63. package/package.json +31 -12
  64. package/src/apex/__tests__/lightbox.test.ts +0 -20
  65. package/src/apex/__tests__/page-head.test.ts +0 -63
  66. package/src/apex/__tests__/render.test.ts +0 -401
  67. package/src/apex/index.ts +0 -667
  68. package/src/contracts/__tests__/contracts.test.ts +0 -123
  69. package/src/contracts/__tests__/field-issue-compat.test.ts +0 -19
  70. package/src/contracts/asset.ts +0 -48
  71. package/src/contracts/index.ts +0 -23
  72. package/src/contracts/locale.ts +0 -33
  73. package/src/contracts/primitives.ts +0 -158
  74. package/src/dispatcher/__tests__/contract.test.ts +0 -163
  75. package/src/dispatcher/index.ts +0 -16
  76. package/src/dispatcher/types.ts +0 -217
  77. package/src/form/__tests__/conditional-fields.test.ts +0 -177
  78. package/src/form/__tests__/form-controller.test.ts +0 -231
  79. package/src/form/__tests__/submit.test.ts +0 -575
  80. package/src/form/__tests__/validation.test.ts +0 -274
  81. package/src/form/form-controller.ts +0 -458
  82. package/src/form/index.ts +0 -15
  83. package/src/form/types.ts +0 -297
  84. package/src/format/__tests__/escape.test.ts +0 -79
  85. package/src/format/__tests__/format.test.ts +0 -285
  86. package/src/format/__tests__/html-template.test.ts +0 -47
  87. package/src/format/html-template.ts +0 -49
  88. package/src/format/index.ts +0 -223
  89. package/src/index.ts +0 -118
  90. package/src/locale-routing/__tests__/locale-routing.test.ts +0 -276
  91. package/src/locale-routing/index.ts +0 -218
  92. package/src/nav/__tests__/resolve.test.ts +0 -279
  93. package/src/nav/index.ts +0 -8
  94. package/src/nav/resolve.ts +0 -60
  95. package/src/nav/types.ts +0 -70
  96. package/src/store/__tests__/create-store.test.ts +0 -139
  97. package/src/store/__tests__/equality.test.ts +0 -66
  98. package/src/store/index.ts +0 -3
  99. package/src/store/types.ts +0 -27
  100. package/src/view-model/__tests__/edit.test.ts +0 -1072
  101. package/src/view-model/__tests__/embedded-list.test.ts +0 -96
  102. package/src/view-model/__tests__/field-labels.test.ts +0 -62
  103. package/src/view-model/__tests__/list.test.ts +0 -439
  104. package/src/view-model/edit.ts +0 -645
  105. package/src/view-model/embedded-list.ts +0 -89
  106. package/src/view-model/index.ts +0 -43
  107. package/src/view-model/list.ts +0 -280
  108. package/src/view-model/types.ts +0 -400
@@ -1,1072 +0,0 @@
1
- import { describe, expect, test } from "bun:test";
2
- import type {
3
- EntityDefinition,
4
- EntityEditScreenDefinition,
5
- RowAction,
6
- } from "@cosmicdrift/kumiko-framework/ui-types";
7
- import { computeEditViewModel } from "../edit";
8
- import type {
9
- EditFieldsSectionViewModel,
10
- EditRelatedListSectionViewModel,
11
- EditSectionViewModel,
12
- EditWriteFormSectionViewModel,
13
- } from "../types";
14
-
15
- function asFields(s: EditSectionViewModel | undefined): EditFieldsSectionViewModel {
16
- if (s === undefined || s.kind !== "fields") {
17
- throw new Error(`expected fields-section, got ${s?.kind ?? "undefined"}`);
18
- }
19
- return s;
20
- }
21
-
22
- const orderEntity = {
23
- fields: {
24
- customerName: { type: "text", required: true },
25
- notes: { type: "text" },
26
- vatExempt: { type: "boolean" },
27
- vatReason: { type: "text" },
28
- kind: { type: "text" },
29
- },
30
- } as unknown as EntityDefinition;
31
-
32
- function editScreen(
33
- layout: EntityEditScreenDefinition["layout"],
34
- overrides?: Partial<EntityEditScreenDefinition>,
35
- ): EntityEditScreenDefinition {
36
- return {
37
- id: "orders:screen:order-edit",
38
- type: "entityEdit",
39
- entity: "order",
40
- layout,
41
- ...overrides,
42
- };
43
- }
44
-
45
- const translate = (key: string) => key;
46
-
47
- describe("computeEditViewModel", () => {
48
- test("flat screen: single section, string-form fields → resolved field view models", () => {
49
- const vm = computeEditViewModel({
50
- screen: editScreen({
51
- sections: [{ title: "Hauptdaten", fields: ["customerName", "notes"] }],
52
- }),
53
- entity: orderEntity,
54
- values: { customerName: "Acme", notes: "VIP" },
55
- translate,
56
- featureName: "orders",
57
- });
58
-
59
- expect(vm.sections).toHaveLength(1);
60
- const section = vm.sections[0];
61
- expect(section?.title).toBe("Hauptdaten");
62
- expect(asFields(section).columns).toBe(1); // default
63
- expect(asFields(section).fields).toEqual([
64
- {
65
- field: "customerName",
66
- label: "orders:entity:order:field:customerName",
67
- type: "text",
68
- value: "Acme",
69
- visible: true,
70
- readOnly: false,
71
- required: true, // from entity
72
- },
73
- {
74
- field: "notes",
75
- label: "orders:entity:order:field:notes",
76
- type: "text",
77
- value: "VIP",
78
- visible: true,
79
- readOnly: false,
80
- required: false,
81
- },
82
- ]);
83
- });
84
-
85
- test("fieldLabels override the convention key per field; absent fields fall back", () => {
86
- const vm = computeEditViewModel({
87
- screen: editScreen(
88
- { sections: [{ title: "Main", fields: ["customerName", "notes"] }] },
89
- { fieldLabels: { customerName: "config:mask:customer-name" } },
90
- ),
91
- entity: orderEntity,
92
- values: {},
93
- translate,
94
- featureName: "orders",
95
- });
96
-
97
- const fields = asFields(vm.sections[0]).fields;
98
- // overridden field uses the injected i18n key (the Settings-Hub puts mask.title here)
99
- expect(fields[0]?.label).toBe("config:mask:customer-name");
100
- // un-overridden field stays on the `<feature>:entity:<entity>:field:<name>` convention
101
- expect(fields[1]?.label).toBe("orders:entity:order:field:notes");
102
- });
103
-
104
- test("section.columns override is respected; defaults to 1 when absent", () => {
105
- const vm = computeEditViewModel({
106
- screen: editScreen({
107
- sections: [
108
- { title: "Main", columns: 2, fields: ["customerName"] },
109
- { title: "Notes", fields: ["notes"] },
110
- ],
111
- }),
112
- entity: orderEntity,
113
- values: {},
114
- translate,
115
- featureName: "orders",
116
- });
117
-
118
- expect(asFields(vm.sections[0]).columns).toBe(2);
119
- expect(asFields(vm.sections[1]).columns).toBe(1);
120
- });
121
-
122
- // The two-column default came in with projectionDetail tabs and now also
123
- // covers entityEdit tabs (fw#3134) — a tab is its own panel, where a stacked
124
- // form's single column wastes the width.
125
- test("a tabs layout defaults sections to 2 columns, not 1", () => {
126
- const vm = computeEditViewModel({
127
- screen: editScreen({
128
- mode: "tabs",
129
- sections: [
130
- { id: "main", title: "Main", fields: ["customerName"] },
131
- { id: "notes", title: "Notes", columns: 1, fields: ["notes"] },
132
- ],
133
- }),
134
- entity: orderEntity,
135
- values: {},
136
- translate,
137
- featureName: "orders",
138
- });
139
-
140
- expect(asFields(vm.sections[0]).columns).toBe(2);
141
- expect(asFields(vm.sections[1]).columns).toBe(1);
142
- });
143
-
144
- test("tabs sections carry their declared id into the view model", () => {
145
- const vm = computeEditViewModel({
146
- screen: editScreen({
147
- mode: "tabs",
148
- sections: [
149
- { id: "main", title: "Main", fields: ["customerName"] },
150
- { id: "notes", title: "Notes", fields: ["notes"] },
151
- ],
152
- }),
153
- entity: orderEntity,
154
- values: {},
155
- translate,
156
- featureName: "orders",
157
- });
158
-
159
- expect(asFields(vm.sections[0]).id).toBe("main");
160
- expect(asFields(vm.sections[1]).id).toBe("notes");
161
- });
162
-
163
- test("visible predicate evaluated against current values (live-reactive)", () => {
164
- const screen = editScreen({
165
- sections: [
166
- {
167
- title: "VAT",
168
- fields: [
169
- "vatExempt",
170
- {
171
- field: "vatReason",
172
- visible: { field: "vatExempt", eq: true },
173
- required: { field: "vatExempt", eq: true },
174
- },
175
- ],
176
- },
177
- ],
178
- });
179
-
180
- const hidden = computeEditViewModel({
181
- screen,
182
- entity: orderEntity,
183
- values: { vatExempt: false },
184
- translate,
185
- featureName: "orders",
186
- });
187
- const reasonHidden = asFields(hidden.sections[0]).fields[1];
188
- expect(reasonHidden?.visible).toBe(false);
189
- expect(reasonHidden?.required).toBe(false);
190
-
191
- const shown = computeEditViewModel({
192
- screen,
193
- entity: orderEntity,
194
- values: { vatExempt: true },
195
- translate,
196
- featureName: "orders",
197
- });
198
- const reasonShown = asFields(shown.sections[0]).fields[1];
199
- expect(reasonShown?.visible).toBe(true);
200
- expect(reasonShown?.required).toBe(true);
201
- });
202
-
203
- test("section with all fields hidden by visible condition → section.visible is false but section stays in the array (key stability)", () => {
204
- const screen = editScreen({
205
- sections: [
206
- {
207
- title: "Person",
208
- fields: [{ field: "customerName", visible: { field: "kind", eq: "person" } }],
209
- },
210
- {
211
- title: "Firma",
212
- fields: [{ field: "notes", visible: { field: "kind", eq: "company" } }],
213
- },
214
- ],
215
- });
216
-
217
- const vm = computeEditViewModel({
218
- screen,
219
- entity: orderEntity,
220
- values: { kind: "person", customerName: "Acme" },
221
- translate,
222
- featureName: "orders",
223
- });
224
-
225
- expect(vm.sections).toHaveLength(2);
226
- expect(asFields(vm.sections[0]).visible).toBe(true);
227
- expect(asFields(vm.sections[1]).visible).toBe(false);
228
- expect(vm.sections[1]?.title).toBe("Firma");
229
- });
230
-
231
- test("readonly condition { field, ne } evaluates against form values", () => {
232
- const screen = editScreen({
233
- sections: [
234
- {
235
- title: "x",
236
- fields: [{ field: "customerName", readOnly: { field: "isEditable", ne: true } }],
237
- },
238
- ],
239
- });
240
-
241
- const locked = computeEditViewModel({
242
- screen,
243
- entity: orderEntity,
244
- values: { customerName: "A", isEditable: false },
245
- translate,
246
- featureName: "orders",
247
- });
248
- expect(asFields(locked.sections[0]).fields[0]?.readOnly).toBe(true);
249
-
250
- const editable = computeEditViewModel({
251
- screen,
252
- entity: orderEntity,
253
- values: { customerName: "A", isEditable: true },
254
- translate,
255
- featureName: "orders",
256
- });
257
- expect(asFields(editable.sections[0]).fields[0]?.readOnly).toBe(false);
258
- });
259
-
260
- test("screen-level required override wins over entity-level required", () => {
261
- // customerName is required:true on the entity. Screen marks it
262
- // required:false — a short-form wizard might collect less up-front.
263
- const vm = computeEditViewModel({
264
- screen: editScreen({
265
- sections: [
266
- {
267
- title: "x",
268
- fields: [{ field: "customerName", required: false }],
269
- },
270
- ],
271
- }),
272
- entity: orderEntity,
273
- values: {},
274
- translate,
275
- featureName: "orders",
276
- });
277
-
278
- expect(asFields(vm.sections[0]).fields[0]?.required).toBe(false);
279
- });
280
-
281
- test("id is extracted from values or null on create (no existing row)", () => {
282
- const create = computeEditViewModel({
283
- screen: editScreen({ sections: [{ title: "x", fields: ["customerName"] }] }),
284
- entity: orderEntity,
285
- values: { customerName: "new" },
286
- translate,
287
- featureName: "orders",
288
- });
289
- expect(create.id).toBeNull();
290
-
291
- const edit = computeEditViewModel({
292
- screen: editScreen({ sections: [{ title: "x", fields: ["customerName"] }] }),
293
- entity: orderEntity,
294
- values: { id: "o-1", customerName: "existing" },
295
- translate,
296
- featureName: "orders",
297
- });
298
- expect(edit.id).toBe("o-1");
299
- });
300
-
301
- test("unknown field in a section throws — no silent-ignore of a typo", () => {
302
- expect(() =>
303
- computeEditViewModel({
304
- screen: editScreen({ sections: [{ title: "x", fields: ["ghost"] }] }),
305
- entity: orderEntity,
306
- values: {},
307
- translate,
308
- featureName: "orders",
309
- }),
310
- ).toThrow(/unknown field "ghost"/);
311
- });
312
-
313
- test("slots pass through for the renderer to mount", () => {
314
- const slots = { header: { react: "H" } };
315
- const vm = computeEditViewModel({
316
- screen: editScreen({ sections: [{ title: "x", fields: ["customerName"] }] }, { slots }),
317
- entity: orderEntity,
318
- values: {},
319
- translate,
320
- featureName: "orders",
321
- });
322
- expect(vm.slots).toBe(slots);
323
- });
324
-
325
- test("span on field-spec propagates to the view model", () => {
326
- const vm = computeEditViewModel({
327
- screen: editScreen({
328
- sections: [{ title: "x", columns: 3, fields: [{ field: "customerName", span: 2 }] }],
329
- }),
330
- entity: orderEntity,
331
- values: {},
332
- translate,
333
- featureName: "orders",
334
- });
335
- expect(asFields(vm.sections[0]).fields[0]?.span).toBe(2);
336
- });
337
-
338
- test("section.description is translated and passed through; absent stays undefined", () => {
339
- const vm = computeEditViewModel({
340
- screen: editScreen({
341
- sections: [
342
- {
343
- title: "Basics",
344
- description: "orders:screen:order-edit.basics.help",
345
- fields: ["notes"],
346
- },
347
- { title: "Extra", fields: ["kind"] },
348
- ],
349
- }),
350
- entity: orderEntity,
351
- values: {},
352
- translate,
353
- featureName: "orders",
354
- });
355
- expect(asFields(vm.sections[0]).description).toBe("orders:screen:order-edit.basics.help");
356
- expect(asFields(vm.sections[1]).description).toBeUndefined();
357
- });
358
-
359
- test("field.icon propagates to the view model; absent stays undefined", () => {
360
- const vm = computeEditViewModel({
361
- screen: editScreen({
362
- sections: [{ title: "x", fields: [{ field: "customerName", icon: "user" }, "notes"] }],
363
- }),
364
- entity: orderEntity,
365
- values: {},
366
- translate,
367
- featureName: "orders",
368
- });
369
- const fields = asFields(vm.sections[0]).fields;
370
- expect(fields[0]?.icon).toBe("user");
371
- expect(fields[1]?.icon).toBeUndefined();
372
- });
373
- });
374
-
375
- describe("computeEditViewModel — date/timestamp min/max/locale (#369)", () => {
376
- test("date-Field reicht min/max/locale ins ViewModel", () => {
377
- const entity = {
378
- fields: {
379
- born: { type: "date", min: "1900-01-01", max: "2026-06-15", locale: "en-US" },
380
- },
381
- } as unknown as EntityDefinition;
382
- const vm = computeEditViewModel({
383
- screen: editScreen({ sections: [{ title: "X", fields: ["born"] }] }),
384
- entity,
385
- values: {},
386
- translate,
387
- featureName: "orders",
388
- });
389
- const field = asFields(vm.sections[0]).fields[0];
390
- expect(field?.min).toBe("1900-01-01");
391
- expect(field?.max).toBe("2026-06-15");
392
- expect(field?.dateLocale).toBe("en-US");
393
- });
394
-
395
- test("date-Field ohne bounds: min/max/dateLocale bleiben weg", () => {
396
- const entity = {
397
- fields: { born: { type: "date" } },
398
- } as unknown as EntityDefinition;
399
- const vm = computeEditViewModel({
400
- screen: editScreen({ sections: [{ title: "X", fields: ["born"] }] }),
401
- entity,
402
- values: {},
403
- translate,
404
- featureName: "orders",
405
- });
406
- const field = asFields(vm.sections[0]).fields[0];
407
- expect(field?.min).toBeUndefined();
408
- expect(field?.max).toBeUndefined();
409
- expect(field?.dateLocale).toBeUndefined();
410
- });
411
- });
412
-
413
- describe("computeEditViewModel — embedded-list cells (#1835)", () => {
414
- const lineFieldSchema = {
415
- description: { type: "text", required: true },
416
- quantity: { type: "number", required: true },
417
- unit: { type: "select", options: ["hour", "day"] },
418
- product: { type: "reference", entity: "product", labelField: "name" },
419
- };
420
-
421
- function embeddedListEntity(overrides?: Record<string, unknown>): EntityDefinition {
422
- return {
423
- fields: {
424
- lines: {
425
- type: "embedded",
426
- multiple: true,
427
- schema: lineFieldSchema,
428
- ...overrides,
429
- },
430
- },
431
- } as unknown as EntityDefinition;
432
- }
433
-
434
- test("multiple: true embedded field → embeddedListCells has one entry per schema key with correct type/required/options/optionLabels/refEntity", () => {
435
- const vm = computeEditViewModel({
436
- screen: editScreen({ sections: [{ title: "x", fields: ["lines"] }] }),
437
- entity: embeddedListEntity(),
438
- values: { lines: [] },
439
- translate,
440
- featureName: "orders",
441
- });
442
-
443
- const field = asFields(vm.sections[0]).fields[0];
444
- expect(field?.type).toBe("embedded");
445
- const cells = field?.embeddedListCells;
446
- expect(cells).toHaveLength(4);
447
-
448
- const byField = Object.fromEntries((cells ?? []).map((cell) => [cell.field, cell]));
449
- expect(byField["description"]).toMatchObject({
450
- type: "text",
451
- required: true,
452
- label: "orders:entity:order:field:lines:cell:description",
453
- });
454
- expect(byField["quantity"]).toMatchObject({ type: "number", required: true });
455
- expect(byField["unit"]).toMatchObject({
456
- type: "select",
457
- required: false,
458
- options: ["hour", "day"],
459
- optionLabels: { hour: "hour", day: "day" },
460
- });
461
- expect(byField["product"]).toMatchObject({
462
- type: "reference",
463
- required: false,
464
- refEntity: "product",
465
- refFeature: "orders",
466
- refLabelField: "name",
467
- });
468
- expect(byField["product"]?.refOptionsQuery).toBeUndefined();
469
- });
470
-
471
- test("a reference sub-field's optionsQuery reaches the cell view-model (fw#2780)", () => {
472
- const vm = computeEditViewModel({
473
- screen: editScreen({ sections: [{ title: "x", fields: ["lines"] }] }),
474
- entity: embeddedListEntity({
475
- schema: {
476
- ...lineFieldSchema,
477
- product: {
478
- type: "reference",
479
- entity: "product",
480
- labelField: "name",
481
- optionsQuery: "orders:query:product:options",
482
- },
483
- },
484
- }),
485
- values: { lines: [] },
486
- translate,
487
- featureName: "orders",
488
- });
489
-
490
- const cells = asFields(vm.sections[0]).fields[0]?.embeddedListCells;
491
- const product = (cells ?? []).find((cell) => cell.field === "product");
492
- expect(product?.refOptionsQuery).toBe("orders:query:product:options");
493
- });
494
-
495
- test("minItems/maxItems/derived/totals pass through onto the field view-model when set", () => {
496
- const vm = computeEditViewModel({
497
- screen: editScreen({ sections: [{ title: "x", fields: ["lines"] }] }),
498
- entity: embeddedListEntity({
499
- minItems: 1,
500
- maxItems: 20,
501
- derived: { total: { op: "multiply", from: ["quantity", "unitPrice"] } },
502
- totals: ["quantity", "total"],
503
- }),
504
- values: { lines: [] },
505
- translate,
506
- featureName: "orders",
507
- });
508
-
509
- const field = asFields(vm.sections[0]).fields[0];
510
- expect(field?.embeddedListMinItems).toBe(1);
511
- expect(field?.embeddedListMaxItems).toBe(20);
512
- expect(field?.embeddedListDerived).toEqual({
513
- total: { op: "multiply", from: ["quantity", "unitPrice"] },
514
- });
515
- expect(field?.embeddedListTotals).toEqual(["quantity", "total"]);
516
- });
517
-
518
- test("minItems/maxItems/derived/totals stay undefined when not declared", () => {
519
- const vm = computeEditViewModel({
520
- screen: editScreen({ sections: [{ title: "x", fields: ["lines"] }] }),
521
- entity: embeddedListEntity(),
522
- values: { lines: [] },
523
- translate,
524
- featureName: "orders",
525
- });
526
-
527
- const field = asFields(vm.sections[0]).fields[0];
528
- expect(field?.embeddedListMinItems).toBeUndefined();
529
- expect(field?.embeddedListMaxItems).toBeUndefined();
530
- expect(field?.embeddedListDerived).toBeUndefined();
531
- expect(field?.embeddedListTotals).toBeUndefined();
532
- });
533
-
534
- test("a plain (non-list) embedded field never gets embeddedListCells — that's how the renderer tells it apart from an embedded list", () => {
535
- const entity = {
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?.type).toBe("embedded");
551
- expect(field?.embeddedListCells).toBeUndefined();
552
- expect(field?.embeddedListMinItems).toBeUndefined();
553
- });
554
-
555
- test("select-cell options are translated when registered, fall back to the raw value otherwise", () => {
556
- const translateUnit = (key: string) =>
557
- key === "orders:entity:order:field:lines:cell:unit:option:hour" ? "Stunde" : key;
558
-
559
- const vm = computeEditViewModel({
560
- screen: editScreen({ sections: [{ title: "x", fields: ["lines"] }] }),
561
- entity: embeddedListEntity(),
562
- values: { lines: [] },
563
- translate: translateUnit,
564
- featureName: "orders",
565
- });
566
-
567
- const field = asFields(vm.sections[0]).fields[0];
568
- const unitCell = field?.embeddedListCells?.find((cell) => cell.field === "unit");
569
- expect(unitCell?.optionLabels).toEqual({ hour: "Stunde", day: "day" });
570
- });
571
-
572
- test("embeddedListCurrency mirrors entity.defaultCurrency (#1839)", () => {
573
- const entity = {
574
- defaultCurrency: "USD",
575
- fields: {
576
- lines: { type: "embedded", multiple: true, schema: lineFieldSchema },
577
- },
578
- } as unknown as EntityDefinition;
579
-
580
- const vm = computeEditViewModel({
581
- screen: editScreen({ sections: [{ title: "x", fields: ["lines"] }] }),
582
- entity,
583
- values: { lines: [] },
584
- translate,
585
- featureName: "orders",
586
- });
587
-
588
- const field = asFields(vm.sections[0]).fields[0];
589
- expect(field?.embeddedListCurrency).toBe("USD");
590
- });
591
-
592
- test("embeddedListCurrency falls back to EUR when the entity has no defaultCurrency (#1839)", () => {
593
- const vm = computeEditViewModel({
594
- screen: editScreen({ sections: [{ title: "x", fields: ["lines"] }] }),
595
- entity: embeddedListEntity(),
596
- values: { lines: [] },
597
- translate,
598
- featureName: "orders",
599
- });
600
-
601
- const field = asFields(vm.sections[0]).fields[0];
602
- expect(field?.embeddedListCurrency).toBe("EUR");
603
- });
604
-
605
- test("embeddedListCurrency is undefined for a plain (non-list) embedded field", () => {
606
- const entity = {
607
- defaultCurrency: "USD",
608
- fields: {
609
- meta: { type: "embedded", schema: { note: { type: "text" } } },
610
- },
611
- } as unknown as EntityDefinition;
612
-
613
- const vm = computeEditViewModel({
614
- screen: editScreen({ sections: [{ title: "x", fields: ["meta"] }] }),
615
- entity,
616
- values: { meta: {} },
617
- translate,
618
- featureName: "orders",
619
- });
620
-
621
- const field = asFields(vm.sections[0]).fields[0];
622
- expect(field?.embeddedListCurrency).toBeUndefined();
623
- });
624
-
625
- test("imageVariant is the FIRST variant declared on the field def", () => {
626
- const entity = {
627
- fields: {
628
- avatar: {
629
- type: "image",
630
- variants: {
631
- profile: { fit: "cover", size: { width: 512, height: 512 }, format: "webp" },
632
- big: { fit: "cover", size: { width: 1024, height: 1024 }, format: "webp" },
633
- },
634
- },
635
- },
636
- } as unknown as EntityDefinition;
637
-
638
- const vm = computeEditViewModel({
639
- screen: editScreen({ sections: [{ title: "x", fields: ["avatar"] }] }),
640
- entity,
641
- values: {},
642
- translate,
643
- featureName: "orders",
644
- });
645
-
646
- const field = asFields(vm.sections[0]).fields[0];
647
- expect(field?.imageVariant).toBe("profile");
648
- });
649
-
650
- test("imageVariant is undefined for an image field without variants", () => {
651
- const entity = {
652
- fields: {
653
- avatar: { type: "image" },
654
- },
655
- } as unknown as EntityDefinition;
656
-
657
- const vm = computeEditViewModel({
658
- screen: editScreen({ sections: [{ title: "x", fields: ["avatar"] }] }),
659
- entity,
660
- values: {},
661
- translate,
662
- featureName: "orders",
663
- });
664
-
665
- const field = asFields(vm.sections[0]).fields[0];
666
- expect(field?.imageVariant).toBeUndefined();
667
- });
668
-
669
- test("capture is forwarded for an image field that declares it", () => {
670
- const entity = {
671
- fields: {
672
- avatar: { type: "image", capture: "environment" },
673
- },
674
- } as unknown as EntityDefinition;
675
-
676
- const vm = computeEditViewModel({
677
- screen: editScreen({ sections: [{ title: "x", fields: ["avatar"] }] }),
678
- entity,
679
- values: {},
680
- translate,
681
- featureName: "orders",
682
- });
683
-
684
- const field = asFields(vm.sections[0]).fields[0];
685
- expect(field?.capture).toBe("environment");
686
- });
687
-
688
- test("capture is undefined for an image field without it", () => {
689
- const entity = {
690
- fields: {
691
- avatar: { type: "image" },
692
- },
693
- } as unknown as EntityDefinition;
694
-
695
- const vm = computeEditViewModel({
696
- screen: editScreen({ sections: [{ title: "x", fields: ["avatar"] }] }),
697
- entity,
698
- values: {},
699
- translate,
700
- featureName: "orders",
701
- });
702
-
703
- const field = asFields(vm.sections[0]).fields[0];
704
- expect(field?.capture).toBeUndefined();
705
- });
706
- });
707
-
708
- describe("computeEditViewModel — multiSelect display/columns/maxRows passthrough", () => {
709
- function multiSelectEntity(overrides?: Record<string, unknown>): EntityDefinition {
710
- return {
711
- fields: {
712
- languages: {
713
- type: "multiSelect",
714
- options: ["en", "de", "es"],
715
- ...overrides,
716
- },
717
- },
718
- } as unknown as EntityDefinition;
719
- }
720
-
721
- test("display/columns/maxRows carry through when set on the field def", () => {
722
- const vm = computeEditViewModel({
723
- screen: editScreen({ sections: [{ title: "x", fields: ["languages"] }] }),
724
- entity: multiSelectEntity({ display: "checkboxes", columns: 2, maxRows: 3 }),
725
- values: {},
726
- translate,
727
- featureName: "orders",
728
- });
729
-
730
- const field = asFields(vm.sections[0]).fields[0];
731
- expect(field?.display).toBe("checkboxes");
732
- expect(field?.columns).toBe(2);
733
- expect(field?.maxRows).toBe(3);
734
- });
735
-
736
- test("display/columns/maxRows are absent when not set on the field def (back-compat)", () => {
737
- const vm = computeEditViewModel({
738
- screen: editScreen({ sections: [{ title: "x", fields: ["languages"] }] }),
739
- entity: multiSelectEntity(),
740
- values: {},
741
- translate,
742
- featureName: "orders",
743
- });
744
-
745
- const field = asFields(vm.sections[0]).fields[0];
746
- expect(field?.display).toBeUndefined();
747
- expect(field?.columns).toBeUndefined();
748
- expect(field?.maxRows).toBeUndefined();
749
- });
750
- });
751
-
752
- describe("computeEditViewModel — declared reference metadata (fw#2662)", () => {
753
- // projectionDetail fields have no real EntityDefinition — the shim that
754
- // adapts them synthesizes a pseudo-entity where every field is hardcoded
755
- // to "text" (see projection-detail-shim.ts). EditFieldSpec.refEntity must
756
- // be evaluated BEFORE the entity.fields lookup, or it never fires against
757
- // that shape.
758
- const projectionPseudoEntity = {
759
- fields: {
760
- userId: { type: "text" },
761
- ip: { type: "text" },
762
- },
763
- } as unknown as EntityDefinition;
764
-
765
- test("EditFieldSpec.refEntity marks the field as a reference, even against a pseudo-entity whose fields are all 'text' (fw#2662)", () => {
766
- const vm = computeEditViewModel({
767
- screen: editScreen({
768
- sections: [
769
- { fields: [{ field: "userId", refEntity: "user:user", refLabelField: "displayName" }] },
770
- ],
771
- }),
772
- entity: projectionPseudoEntity,
773
- values: { userId: "u-1" },
774
- translate,
775
- featureName: "sessions",
776
- });
777
-
778
- const field = asFields(vm.sections[0]).fields[0];
779
- expect(field).toMatchObject({
780
- field: "userId",
781
- type: "reference",
782
- refEntity: "user",
783
- refFeature: "user",
784
- refLabelField: "displayName",
785
- });
786
- expect(field?.refMultiple).toBeUndefined();
787
- });
788
-
789
- test("a reference field's optionsQuery reaches the field view-model, labelField untouched (fw#2780)", () => {
790
- const entity = {
791
- fields: {
792
- leaseId: {
793
- type: "reference",
794
- entity: "lease",
795
- labelField: "startDate",
796
- optionsQuery: "deposits:query:lease:options",
797
- },
798
- },
799
- } as unknown as EntityDefinition;
800
- const vm = computeEditViewModel({
801
- screen: editScreen({ sections: [{ fields: ["leaseId"] }] }),
802
- entity,
803
- values: {},
804
- translate,
805
- featureName: "deposits",
806
- });
807
-
808
- const field = asFields(vm.sections[0]).fields[0];
809
- expect(field).toMatchObject({
810
- type: "reference",
811
- refEntity: "lease",
812
- refLabelField: "startDate",
813
- refOptionsQuery: "deposits:query:lease:options",
814
- });
815
- });
816
-
817
- test("non-regression: a projectionDetail field without refEntity metadata stays 'text', unchanged (fw#2662)", () => {
818
- const vm = computeEditViewModel({
819
- screen: editScreen({ sections: [{ fields: ["ip"] }] }),
820
- entity: projectionPseudoEntity,
821
- values: { ip: "10.0.0.1" },
822
- translate,
823
- featureName: "sessions",
824
- });
825
-
826
- const field = asFields(vm.sections[0]).fields[0];
827
- expect(field?.type).toBe("text");
828
- expect(field?.refEntity).toBeUndefined();
829
- expect(field?.refFeature).toBeUndefined();
830
- expect(field?.refLabelField).toBeUndefined();
831
- });
832
- });
833
-
834
- function asRelatedList(s: EditSectionViewModel | undefined): EditRelatedListSectionViewModel {
835
- if (s === undefined || s.kind !== "relatedList") {
836
- throw new Error(`expected relatedList-section, got ${s?.kind ?? "undefined"}`);
837
- }
838
- return s;
839
- }
840
-
841
- function asWriteForm(s: EditSectionViewModel | undefined): EditWriteFormSectionViewModel {
842
- if (s === undefined || s.kind !== "writeForm") {
843
- throw new Error(`expected writeForm-section, got ${s?.kind ?? "undefined"}`);
844
- }
845
- return s;
846
- }
847
-
848
- describe("computeEditViewModel — relatedList rowActions passthrough (fw editable-detail-screens)", () => {
849
- test("rowActions pass through onto the relatedList section view-model verbatim", () => {
850
- const rowActions: readonly RowAction[] = [
851
- { id: "sync", label: "actions.sync", handler: "app:write:sync", payload: { pick: ["id"] } },
852
- ];
853
- const vm = computeEditViewModel({
854
- screen: editScreen({
855
- sections: [
856
- {
857
- kind: "relatedList",
858
- title: "History",
859
- query: "app:query:history",
860
- columns: ["name"],
861
- rowActions,
862
- },
863
- ],
864
- }),
865
- entity: orderEntity,
866
- values: {},
867
- translate,
868
- featureName: "orders",
869
- });
870
-
871
- expect(asRelatedList(vm.sections[0]).rowActions).toEqual(rowActions);
872
- });
873
-
874
- test("rowActions is absent from the view-model when the section spec has none", () => {
875
- const vm = computeEditViewModel({
876
- screen: editScreen({
877
- sections: [
878
- { kind: "relatedList", title: "History", query: "app:query:history", columns: ["name"] },
879
- ],
880
- }),
881
- entity: orderEntity,
882
- values: {},
883
- translate,
884
- featureName: "orders",
885
- });
886
-
887
- expect(asRelatedList(vm.sections[0]).rowActions).toBeUndefined();
888
- });
889
- });
890
-
891
- describe("computeEditViewModel — relatedList searchable/facets passthrough (fw#2740)", () => {
892
- test("searchable and facets pass through onto the relatedList section view-model untranslated", () => {
893
- const facets = [
894
- {
895
- field: "status",
896
- type: "select" as const,
897
- label: "app:facet.status.label",
898
- options: [{ value: "active", label: "app:facet.status.active" }],
899
- },
900
- ];
901
- const vm = computeEditViewModel({
902
- screen: editScreen({
903
- sections: [
904
- {
905
- kind: "relatedList",
906
- title: "History",
907
- query: "app:query:history",
908
- columns: ["name", "status"],
909
- searchable: true,
910
- facets,
911
- },
912
- ],
913
- }),
914
- entity: orderEntity,
915
- values: {},
916
- translate,
917
- featureName: "orders",
918
- });
919
-
920
- const section = asRelatedList(vm.sections[0]);
921
- expect(section.searchable).toBe(true);
922
- expect(section.facets).toEqual(facets);
923
- });
924
-
925
- test("searchable and facets are absent from the view-model when the section spec has none", () => {
926
- const vm = computeEditViewModel({
927
- screen: editScreen({
928
- sections: [
929
- { kind: "relatedList", title: "History", query: "app:query:history", columns: ["name"] },
930
- ],
931
- }),
932
- entity: orderEntity,
933
- values: {},
934
- translate,
935
- featureName: "orders",
936
- });
937
-
938
- const section = asRelatedList(vm.sections[0]);
939
- expect(section.searchable).toBeUndefined();
940
- expect(section.facets).toBeUndefined();
941
- });
942
- });
943
-
944
- describe("computeEditViewModel — relatedList parentFilter passthrough", () => {
945
- test("parentFilter passes through onto the relatedList section view-model verbatim", () => {
946
- const vm = computeEditViewModel({
947
- screen: editScreen({
948
- sections: [
949
- {
950
- kind: "relatedList",
951
- title: "History",
952
- query: "app:query:history",
953
- columns: ["name"],
954
- parentFilter: { field: "orderId" },
955
- },
956
- ],
957
- }),
958
- entity: orderEntity,
959
- values: {},
960
- translate,
961
- featureName: "orders",
962
- });
963
-
964
- expect(asRelatedList(vm.sections[0]).parentFilter).toEqual({ field: "orderId" });
965
- });
966
-
967
- test("parentFilter is absent from the view-model when the section spec has none", () => {
968
- const vm = computeEditViewModel({
969
- screen: editScreen({
970
- sections: [
971
- { kind: "relatedList", title: "History", query: "app:query:history", columns: ["name"] },
972
- ],
973
- }),
974
- entity: orderEntity,
975
- values: {},
976
- translate,
977
- featureName: "orders",
978
- });
979
-
980
- expect(asRelatedList(vm.sections[0]).parentFilter).toBeUndefined();
981
- });
982
- });
983
-
984
- describe("computeEditViewModel — writeForm sections (fw editable-detail-screens)", () => {
985
- test("resolves the section's own fieldDefs/fields through the same per-field pipeline as a fields section", () => {
986
- const vm = computeEditViewModel({
987
- screen: editScreen({
988
- sections: [
989
- {
990
- kind: "writeForm",
991
- title: "Add note",
992
- fieldDefs: { note: { type: "text", required: true } },
993
- fields: ["note"],
994
- handler: "orders:write:add-note",
995
- },
996
- ],
997
- }),
998
- entity: orderEntity,
999
- values: {},
1000
- translate,
1001
- featureName: "orders",
1002
- });
1003
-
1004
- const section = asWriteForm(vm.sections[0]);
1005
- expect(section.handler).toBe("orders:write:add-note");
1006
- expect(section.fields).toEqual([
1007
- {
1008
- field: "note",
1009
- label: "orders:entity:__write-form-section__:field:note",
1010
- type: "text",
1011
- value: undefined,
1012
- visible: true,
1013
- readOnly: false,
1014
- required: true,
1015
- },
1016
- ]);
1017
- });
1018
-
1019
- test("readOnly/required on a writeForm field come from the section's own fieldDefs, independent of the host entity", () => {
1020
- const vm = computeEditViewModel({
1021
- screen: editScreen({
1022
- sections: [
1023
- {
1024
- kind: "writeForm",
1025
- title: "Add note",
1026
- // "notes" is optional on orderEntity — the writeForm section's own
1027
- // fieldDefs declares its own "notes" field as required, and that
1028
- // (not the host entity's) must win.
1029
- fieldDefs: { notes: { type: "text", required: true } },
1030
- fields: ["notes"],
1031
- handler: "orders:write:add-note",
1032
- },
1033
- ],
1034
- }),
1035
- entity: orderEntity,
1036
- values: {},
1037
- translate,
1038
- featureName: "orders",
1039
- });
1040
-
1041
- expect(asWriteForm(vm.sections[0]).fields[0]?.required).toBe(true);
1042
- });
1043
-
1044
- test("columns defaults to 1; icon/description/submitLabel are translated and passed through when set", () => {
1045
- const vm = computeEditViewModel({
1046
- screen: editScreen({
1047
- sections: [
1048
- {
1049
- kind: "writeForm",
1050
- title: "Add note",
1051
- description: "orders:write-form.description",
1052
- icon: "plus",
1053
- fieldDefs: { note: { type: "text" } },
1054
- fields: ["note"],
1055
- handler: "orders:write:add-note",
1056
- submitLabel: "orders:write-form.submit",
1057
- },
1058
- ],
1059
- }),
1060
- entity: orderEntity,
1061
- values: {},
1062
- translate,
1063
- featureName: "orders",
1064
- });
1065
-
1066
- const section = asWriteForm(vm.sections[0]);
1067
- expect(section.columns).toBe(1);
1068
- expect(section.icon).toBe("plus");
1069
- expect(section.description).toBe("orders:write-form.description");
1070
- expect(section.submitLabel).toBe("orders:write-form.submit");
1071
- });
1072
- });