@cosmicdrift/kumiko-headless 0.327.0 → 0.328.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) 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/changes.json +7 -0
  65. package/src/apex/__tests__/lightbox.test.ts +0 -20
  66. package/src/apex/__tests__/page-head.test.ts +0 -63
  67. package/src/apex/__tests__/render.test.ts +0 -401
  68. package/src/apex/index.ts +0 -667
  69. package/src/contracts/__tests__/contracts.test.ts +0 -123
  70. package/src/contracts/__tests__/field-issue-compat.test.ts +0 -19
  71. package/src/contracts/asset.ts +0 -48
  72. package/src/contracts/index.ts +0 -23
  73. package/src/contracts/locale.ts +0 -33
  74. package/src/contracts/primitives.ts +0 -158
  75. package/src/dispatcher/__tests__/contract.test.ts +0 -163
  76. package/src/dispatcher/index.ts +0 -16
  77. package/src/dispatcher/types.ts +0 -217
  78. package/src/form/__tests__/conditional-fields.test.ts +0 -177
  79. package/src/form/__tests__/form-controller.test.ts +0 -231
  80. package/src/form/__tests__/submit.test.ts +0 -575
  81. package/src/form/__tests__/validation.test.ts +0 -274
  82. package/src/form/form-controller.ts +0 -458
  83. package/src/form/index.ts +0 -15
  84. package/src/form/types.ts +0 -297
  85. package/src/format/__tests__/escape.test.ts +0 -79
  86. package/src/format/__tests__/format.test.ts +0 -281
  87. package/src/format/__tests__/html-template.test.ts +0 -47
  88. package/src/format/html-template.ts +0 -49
  89. package/src/format/index.ts +0 -219
  90. package/src/index.ts +0 -118
  91. package/src/locale-routing/__tests__/locale-routing.test.ts +0 -276
  92. package/src/locale-routing/index.ts +0 -218
  93. package/src/nav/__tests__/resolve.test.ts +0 -279
  94. package/src/nav/index.ts +0 -8
  95. package/src/nav/resolve.ts +0 -60
  96. package/src/nav/types.ts +0 -70
  97. package/src/store/__tests__/create-store.test.ts +0 -139
  98. package/src/store/__tests__/equality.test.ts +0 -66
  99. package/src/store/index.ts +0 -3
  100. package/src/store/types.ts +0 -27
  101. package/src/view-model/__tests__/edit.test.ts +0 -1072
  102. package/src/view-model/__tests__/embedded-list.test.ts +0 -96
  103. package/src/view-model/__tests__/field-labels.test.ts +0 -62
  104. package/src/view-model/__tests__/list.test.ts +0 -428
  105. package/src/view-model/edit.ts +0 -641
  106. package/src/view-model/embedded-list.ts +0 -89
  107. package/src/view-model/index.ts +0 -43
  108. package/src/view-model/list.ts +0 -277
  109. package/src/view-model/types.ts +0 -392
@@ -1,641 +0,0 @@
1
- import type {
2
- EditExtensionSection,
3
- EditRelatedListSection,
4
- EditWriteFormSection,
5
- EntityDefinition,
6
- EntityEditScreenDefinition,
7
- FieldCondition,
8
- ParsedRefTarget,
9
- } from "@cosmicdrift/kumiko-framework/ui-types";
10
- import {
11
- evalFieldCondition,
12
- isExtensionEditSection,
13
- isFieldsEditSection,
14
- isWriteFormEditSection,
15
- normalizeEditField,
16
- parseRefTarget,
17
- sectionFieldSpecs,
18
- WRITE_FORM_SECTION_ENTITY,
19
- } from "@cosmicdrift/kumiko-framework/ui-types";
20
- import {
21
- buildOptionLabels,
22
- embeddedCellLabelKey,
23
- embeddedCellOptionLabelKey,
24
- fieldLabelKey,
25
- fieldOptionLabelKey,
26
- } from "./list";
27
- import type {
28
- EditFieldViewModel,
29
- EditRelatedListSectionViewModel,
30
- EditSectionViewModel,
31
- EditViewModel,
32
- EditWriteFormSectionViewModel,
33
- EmbeddedListCellViewModel,
34
- Translate,
35
- } from "./types";
36
-
37
- // Sub-field shape read off an EmbeddedFieldDef.schema entry. Mirrors
38
- // EmbeddedSubFieldDef from packages/types/src/fields.ts — headless only
39
- // depends on @cosmicdrift/kumiko-framework/ui-types (client-safe subset),
40
- // which doesn't re-export the embedded types, so this stays a local cast
41
- // shape like every other field-type narrowing in this file.
42
- type EmbeddedSubFieldShape = {
43
- readonly type:
44
- | "text"
45
- | "number"
46
- | "boolean"
47
- | "date"
48
- | "money"
49
- | "decimal"
50
- | "select"
51
- | "reference"
52
- | "timestamp";
53
- readonly required?: boolean;
54
- readonly options?: readonly string[];
55
- readonly entity?: string;
56
- readonly labelField?: string;
57
- readonly optionsQuery?: string;
58
- readonly scale?: number;
59
- };
60
-
61
- export type ComputeEditViewModelInput<
62
- TValues extends Readonly<Record<string, unknown>> = Readonly<Record<string, unknown>>,
63
- > = {
64
- readonly screen: EntityEditScreenDefinition;
65
- readonly entity: EntityDefinition;
66
- readonly values: TValues;
67
- readonly translate: Translate;
68
- readonly featureName: string;
69
- };
70
-
71
- // Resolves a writeForm section's own fieldDefs/fields through the same
72
- // per-field pipeline as a plain fields section, by reusing the pipeline
73
- // itself rather than duplicating it: wrap fieldDefs/fields as a throwaway
74
- // entityEdit screen (same "synthesize a pseudo-entity" idiom as the shims in
75
- // packages/renderer/src/app/*-shim.ts) and recurse into computeEditViewModel
76
- // once. Split out of computeEditViewModel's section-map to keep that
77
- // function's own complexity from absorbing this branch's decision points.
78
- function computeWriteFormSectionViewModel<TValues extends Readonly<Record<string, unknown>>>(
79
- sectionSpec: EditWriteFormSection,
80
- screenId: string,
81
- values: TValues,
82
- translate: Translate,
83
- featureName: string,
84
- ): EditWriteFormSectionViewModel {
85
- const innerEntity: EntityDefinition = { fields: sectionSpec.fieldDefs } as EntityDefinition;
86
- const innerScreen: EntityEditScreenDefinition = {
87
- id: `${screenId}:${sectionSpec.id ?? "write-form"}`,
88
- type: "entityEdit",
89
- entity: WRITE_FORM_SECTION_ENTITY,
90
- layout: {
91
- sections: [{ kind: "fields", fields: sectionSpec.fields, columns: sectionSpec.columns }],
92
- },
93
- };
94
- const inner = computeEditViewModel({
95
- screen: innerScreen,
96
- entity: innerEntity,
97
- values,
98
- translate,
99
- featureName,
100
- });
101
- const innerSection = inner.sections[0];
102
- const fields = innerSection?.kind === "fields" ? innerSection.fields : [];
103
- return {
104
- kind: "writeForm" as const,
105
- ...(sectionSpec.title !== undefined && { title: translate(sectionSpec.title) }),
106
- ...(sectionSpec.description !== undefined && {
107
- description: translate(sectionSpec.description),
108
- }),
109
- columns: sectionSpec.columns ?? 1,
110
- fields,
111
- ...(sectionSpec.icon !== undefined && { icon: sectionSpec.icon }),
112
- handler: sectionSpec.handler,
113
- ...(sectionSpec.submitLabel !== undefined && {
114
- submitLabel: translate(sectionSpec.submitLabel),
115
- }),
116
- ...(sectionSpec.actions !== undefined && { actions: sectionSpec.actions }),
117
- };
118
- }
119
-
120
- // relatedList runs its own query — nothing here to resolve against
121
- // entity/values, the spec passes through verbatim (fw#2166).
122
- function computeRelatedListSectionViewModel(
123
- sectionSpec: EditRelatedListSection,
124
- translate: Translate,
125
- ): EditRelatedListSectionViewModel {
126
- return {
127
- kind: "relatedList" as const,
128
- title: translate(sectionSpec.title),
129
- query: sectionSpec.query,
130
- ...(sectionSpec.parentParam !== undefined && { parentParam: sectionSpec.parentParam }),
131
- ...(sectionSpec.parentFilter !== undefined && { parentFilter: sectionSpec.parentFilter }),
132
- columns: sectionSpec.columns,
133
- ...(sectionSpec.pageSize !== undefined && { pageSize: sectionSpec.pageSize }),
134
- ...(sectionSpec.defaultSort !== undefined && { defaultSort: sectionSpec.defaultSort }),
135
- ...(sectionSpec.searchable !== undefined && { searchable: sectionSpec.searchable }),
136
- ...(sectionSpec.facets !== undefined && { facets: sectionSpec.facets }),
137
- ...(sectionSpec.rowClick !== undefined && { rowClick: sectionSpec.rowClick }),
138
- ...(sectionSpec.rowActions !== undefined && { rowActions: sectionSpec.rowActions }),
139
- ...(sectionSpec.toolbarActions !== undefined && { toolbarActions: sectionSpec.toolbarActions }),
140
- ...(sectionSpec.actions !== undefined && { actions: sectionSpec.actions }),
141
- ...(sectionSpec.emptyState !== undefined && {
142
- emptyState: {
143
- title: translate(sectionSpec.emptyState.title),
144
- ...(sectionSpec.emptyState.description !== undefined && {
145
- description: translate(sectionSpec.emptyState.description),
146
- }),
147
- // Action stays untranslated, same convention as rowActions/
148
- // toolbarActions above — the renderer's row-actions builder
149
- // translates `action.label` at button-build time.
150
- ...(sectionSpec.emptyState.action !== undefined && {
151
- action: sectionSpec.emptyState.action,
152
- }),
153
- },
154
- }),
155
- };
156
- }
157
-
158
- // --- Per-field-type view-model hints ------------------------------------
159
- // Split out of the field-map callback below (was one function covering
160
- // every field type's derivation, growing into a single complexity hotspot)
161
- // so each field-type family stays its own small, independently reviewable
162
- // unit. Pure data derivation, no side effects — each returns only the keys
163
- // its own field type ever sets.
164
-
165
- type EntityFieldDef = NonNullable<EntityDefinition["fields"][string]>;
166
- type NormalizedEditField = ReturnType<typeof normalizeEditField>;
167
-
168
- type SelectFieldHints = Pick<
169
- EditFieldViewModel,
170
- "options" | "optionLabels" | "display" | "columns" | "maxRows"
171
- >;
172
-
173
- function deriveSelectFieldHints(
174
- fieldDef: EntityFieldDef,
175
- translate: Translate,
176
- featureName: string,
177
- entityName: string,
178
- fieldName: string,
179
- ): SelectFieldHints {
180
- const options =
181
- fieldDef.type === "select" || fieldDef.type === "multiSelect"
182
- ? ((fieldDef as unknown as { options?: readonly string[] }).options ?? [])
183
- : undefined;
184
- const optionLabels =
185
- options !== undefined
186
- ? buildOptionLabels(
187
- translate,
188
- (value) => fieldOptionLabelKey(featureName, entityName, fieldName, value),
189
- options,
190
- )
191
- : undefined;
192
- const display =
193
- fieldDef.type === "multiSelect" || fieldDef.type === "select" ? fieldDef.display : undefined;
194
- const columns = fieldDef.type === "multiSelect" ? fieldDef.columns : undefined;
195
- const maxRows = fieldDef.type === "multiSelect" ? fieldDef.maxRows : undefined;
196
- return {
197
- ...(options !== undefined && { options }),
198
- ...(optionLabels !== undefined && { optionLabels }),
199
- ...(display !== undefined && { display }),
200
- ...(columns !== undefined && { columns }),
201
- ...(maxRows !== undefined && { maxRows }),
202
- };
203
- }
204
-
205
- type TextFieldHints = Pick<EditFieldViewModel, "multiline" | "format">;
206
-
207
- function deriveTextFieldHints(fieldDef: EntityFieldDef): TextFieldHints {
208
- const multiline =
209
- fieldDef.type === "text" || fieldDef.type === "longText"
210
- ? (fieldDef as unknown as { multiline?: boolean | { rows?: number } }).multiline
211
- : undefined;
212
- const format =
213
- fieldDef.type === "text"
214
- ? (fieldDef as unknown as { format?: "email" | "url" | "phone" | "password" }).format
215
- : undefined;
216
- return {
217
- ...(multiline !== undefined && { multiline }),
218
- ...(format !== undefined && { format }),
219
- };
220
- }
221
-
222
- type DateFieldHints = Pick<EditFieldViewModel, "wallClock" | "min" | "max" | "dateLocale">;
223
-
224
- function deriveDateFieldHints(fieldDef: EntityFieldDef): DateFieldHints {
225
- const wallClock =
226
- fieldDef.type === "timestamp" &&
227
- (fieldDef as unknown as { locatedBy?: string }).locatedBy !== undefined
228
- ? true
229
- : undefined;
230
- const dateBounds =
231
- fieldDef.type === "date" ||
232
- fieldDef.type === "timestamp" ||
233
- fieldDef.type === "locatedTimestamp"
234
- ? (fieldDef as unknown as { min?: string; max?: string; locale?: string })
235
- : undefined;
236
- return {
237
- ...(wallClock !== undefined && { wallClock }),
238
- ...(dateBounds?.min !== undefined && { min: dateBounds.min }),
239
- ...(dateBounds?.max !== undefined && { max: dateBounds.max }),
240
- ...(dateBounds?.locale !== undefined && { dateLocale: dateBounds.locale }),
241
- };
242
- }
243
-
244
- type NumericFieldHints = Pick<EditFieldViewModel, "grouping" | "currency" | "unit">;
245
-
246
- function deriveNumericFieldHints(
247
- fieldDef: EntityFieldDef,
248
- resolvedCurrency: string,
249
- ): NumericFieldHints {
250
- const grouping = fieldDef.type === "number" ? fieldDef.grouping : undefined;
251
- return {
252
- ...(grouping !== undefined && { grouping }),
253
- ...(fieldDef.type === "money" && { currency: resolvedCurrency }),
254
- ...(fieldDef.type === "number" && fieldDef.unit !== undefined && { unit: fieldDef.unit }),
255
- };
256
- }
257
-
258
- type ReferenceFieldHints = Pick<
259
- EditFieldViewModel,
260
- "refEntity" | "refFeature" | "refLabelField" | "refOptionsQuery" | "refMultiple"
261
- >;
262
-
263
- // Declared reference metadata (EditFieldSpec.refEntity) takes priority over
264
- // fieldDef.type — see computeEditViewModel's effectiveType, same reasoning.
265
- function deriveReferenceFieldHints(
266
- fieldDef: EntityFieldDef,
267
- normalized: NormalizedEditField,
268
- declaredRefTarget: ParsedRefTarget | undefined,
269
- featureName: string,
270
- ): ReferenceFieldHints {
271
- const refRaw =
272
- declaredRefTarget === undefined && fieldDef.type === "reference"
273
- ? (fieldDef as unknown as { entity?: string }).entity
274
- : undefined;
275
- const refTarget =
276
- declaredRefTarget ?? (refRaw !== undefined ? parseRefTarget(refRaw, featureName) : undefined);
277
- const refLabelField =
278
- declaredRefTarget !== undefined
279
- ? (normalized.refLabelField ?? "id")
280
- : fieldDef.type === "reference"
281
- ? ((fieldDef as unknown as { labelField?: string }).labelField ?? "id")
282
- : undefined;
283
- return {
284
- ...(refTarget?.entityName !== undefined && { refEntity: refTarget.entityName }),
285
- ...(refTarget?.featureName !== undefined && { refFeature: refTarget.featureName }),
286
- ...(refLabelField !== undefined && { refLabelField }),
287
- ...deriveReferenceMultiHints(fieldDef, declaredRefTarget),
288
- };
289
- }
290
-
291
- type ReferenceMultiHints = Pick<EditFieldViewModel, "refOptionsQuery" | "refMultiple">;
292
-
293
- // Declared reference metadata has no `multiple` or `optionsQuery` concept
294
- // (it targets row-meta/derived fields, always single-valued and resolved
295
- // through the target entity) — only a real ReferenceFieldDef carries them.
296
- function deriveReferenceMultiHints(
297
- fieldDef: EntityFieldDef,
298
- declaredRefTarget: ParsedRefTarget | undefined,
299
- ): ReferenceMultiHints {
300
- const ownReferenceDef =
301
- declaredRefTarget === undefined && fieldDef.type === "reference"
302
- ? (fieldDef as unknown as { multiple?: boolean; optionsQuery?: string })
303
- : undefined;
304
- const refMultiple =
305
- ownReferenceDef === undefined ? undefined : (ownReferenceDef.multiple ?? false);
306
- return {
307
- ...(ownReferenceDef?.optionsQuery !== undefined && {
308
- refOptionsQuery: ownReferenceDef.optionsQuery,
309
- }),
310
- ...(refMultiple !== undefined && { refMultiple }),
311
- };
312
- }
313
-
314
- type FileFieldHints = Pick<
315
- EditFieldViewModel,
316
- "accept" | "maxSize" | "entityType" | "fieldName" | "imageVariant" | "capture"
317
- >;
318
-
319
- // file/image: entityType/fieldName travel with the upload POST so the
320
- // endpoint validates against the right field definition.
321
- function deriveFileFieldHints(
322
- fieldDef: EntityFieldDef,
323
- entityName: string,
324
- fieldName: string,
325
- ): FileFieldHints {
326
- const isFileType =
327
- fieldDef.type === "file" || fieldDef.type === "image" || fieldDef.type === "images";
328
- const fileDef = isFileType
329
- ? (fieldDef as unknown as {
330
- accept?: readonly string[];
331
- maxSize?: string;
332
- variants?: Readonly<Record<string, unknown>>;
333
- capture?: "environment" | "user";
334
- })
335
- : undefined;
336
- const imageVariant =
337
- fieldDef.type === "image" || fieldDef.type === "images"
338
- ? Object.keys(fileDef?.variants ?? {})[0]
339
- : undefined;
340
- const capture = fieldDef.type === "image" ? fileDef?.capture : undefined;
341
- return {
342
- ...(fileDef?.accept !== undefined && { accept: fileDef.accept }),
343
- ...(fileDef?.maxSize !== undefined && { maxSize: fileDef.maxSize }),
344
- ...(isFileType && { entityType: entityName, fieldName }),
345
- ...(imageVariant !== undefined && { imageVariant }),
346
- ...(capture !== undefined && { capture }),
347
- };
348
- }
349
-
350
- type EmbeddedListHints = Pick<
351
- EditFieldViewModel,
352
- | "embeddedListCells"
353
- | "embeddedListMinItems"
354
- | "embeddedListMaxItems"
355
- | "embeddedListDerived"
356
- | "embeddedListTotals"
357
- | "embeddedListCurrency"
358
- >;
359
-
360
- // Embedded-LIST field (`multiple: true`) — per-cell metadata for a renderer
361
- // to draw one row per array item (invoice-positions-style table). A plain
362
- // (non-list) embedded field emits none of this; the renderer tells the two
363
- // apart by whether embeddedListCells is set, not by `type` (which stays
364
- // "embedded" either way).
365
- function deriveEmbeddedListHints(
366
- fieldDef: EntityFieldDef,
367
- translate: Translate,
368
- featureName: string,
369
- entityName: string,
370
- fieldName: string,
371
- resolvedCurrency: string,
372
- ): EmbeddedListHints {
373
- const isEmbeddedList =
374
- fieldDef.type === "embedded" &&
375
- (fieldDef as unknown as { multiple?: boolean }).multiple === true;
376
- const embeddedListDef = isEmbeddedList
377
- ? (fieldDef as unknown as {
378
- schema: Readonly<Record<string, EmbeddedSubFieldShape>>;
379
- minItems?: number;
380
- maxItems?: number;
381
- derived?: Readonly<
382
- Record<
383
- string,
384
- { readonly op: "multiply" | "sum" | "subtract"; readonly from: readonly string[] }
385
- >
386
- >;
387
- totals?: readonly string[];
388
- })
389
- : undefined;
390
- const embeddedListCells: readonly EmbeddedListCellViewModel[] | undefined =
391
- embeddedListDef !== undefined
392
- ? Object.entries(embeddedListDef.schema).map(([subFieldName, subField]) => {
393
- const cellLabel = translate(
394
- embeddedCellLabelKey(featureName, entityName, fieldName, subFieldName),
395
- );
396
- const cellOptions = subField.type === "select" ? (subField.options ?? []) : undefined;
397
- const cellOptionLabels =
398
- cellOptions !== undefined
399
- ? buildOptionLabels(
400
- translate,
401
- (value) =>
402
- embeddedCellOptionLabelKey(
403
- featureName,
404
- entityName,
405
- fieldName,
406
- subFieldName,
407
- value,
408
- ),
409
- cellOptions,
410
- )
411
- : undefined;
412
- const cellRef = subField.type === "reference" ? subField : undefined;
413
- const cellRefTarget =
414
- cellRef?.entity !== undefined ? parseRefTarget(cellRef.entity, featureName) : undefined;
415
- const cell: EmbeddedListCellViewModel = {
416
- field: subFieldName,
417
- label: cellLabel,
418
- type: subField.type,
419
- required: subField.required === true,
420
- ...(cellOptions !== undefined && { options: cellOptions }),
421
- ...(cellOptionLabels !== undefined && { optionLabels: cellOptionLabels }),
422
- ...(cellRefTarget !== undefined && { refEntity: cellRefTarget.entityName }),
423
- ...(cellRefTarget !== undefined && { refFeature: cellRefTarget.featureName }),
424
- ...(cellRef?.labelField !== undefined && { refLabelField: cellRef.labelField }),
425
- ...(cellRef?.optionsQuery !== undefined && {
426
- refOptionsQuery: cellRef.optionsQuery,
427
- }),
428
- ...(subField.type === "decimal" &&
429
- subField.scale !== undefined && { scale: subField.scale }),
430
- };
431
- return cell;
432
- })
433
- : undefined;
434
- return {
435
- ...(embeddedListCells !== undefined && { embeddedListCells }),
436
- ...(embeddedListDef?.minItems !== undefined && {
437
- embeddedListMinItems: embeddedListDef.minItems,
438
- }),
439
- ...(embeddedListDef?.maxItems !== undefined && {
440
- embeddedListMaxItems: embeddedListDef.maxItems,
441
- }),
442
- ...(embeddedListDef?.derived !== undefined && {
443
- embeddedListDerived: embeddedListDef.derived,
444
- }),
445
- ...(embeddedListDef?.totals !== undefined && {
446
- embeddedListTotals: embeddedListDef.totals,
447
- }),
448
- // Currency lives on the head aggregate (entity.defaultCurrency), not per
449
- // row — one value for the whole embedded list.
450
- ...(embeddedListDef !== undefined && { embeddedListCurrency: resolvedCurrency }),
451
- };
452
- }
453
-
454
- function buildExtensionSectionViewModel(
455
- sectionSpec: EditExtensionSection,
456
- translate: Translate,
457
- ): Extract<EditSectionViewModel, { kind: "extension" }> {
458
- return {
459
- kind: "extension" as const,
460
- ...(sectionSpec.id !== undefined && { id: sectionSpec.id }),
461
- title: translate(sectionSpec.title),
462
- component: sectionSpec.component,
463
- contributesToFormSubmit: sectionSpec.contributesToFormSubmit === true,
464
- ...(sectionSpec.entityName !== undefined && { entityName: sectionSpec.entityName }),
465
- ...(sectionSpec.actions !== undefined && { actions: sectionSpec.actions }),
466
- };
467
- }
468
-
469
- // Pure transform from screen-def + entity-def + row-values to the flat
470
- // section/field tree the renderer draws. FieldConditions are evaluated here
471
- // so the renderer never re-runs them during React render.
472
- export function computeEditViewModel<
473
- TValues extends Readonly<Record<string, unknown>> = Readonly<Record<string, unknown>>,
474
- >(input: ComputeEditViewModelInput<TValues>): EditViewModel {
475
- const { screen, entity, values, translate, featureName } = input;
476
-
477
- const sections: EditSectionViewModel[] = screen.layout.sections.map((sectionSpec) => {
478
- if (isExtensionEditSection(sectionSpec)) {
479
- return buildExtensionSectionViewModel(sectionSpec, translate);
480
- }
481
- if (isWriteFormEditSection(sectionSpec)) {
482
- return computeWriteFormSectionViewModel(
483
- sectionSpec,
484
- screen.id,
485
- values,
486
- translate,
487
- featureName,
488
- );
489
- }
490
- if (!isFieldsEditSection(sectionSpec)) {
491
- return computeRelatedListSectionViewModel(sectionSpec, translate);
492
- }
493
- // `groups` flattens into the same per-field pipeline as plain `fields`;
494
- // the group structure below just re-groups the computed views by name.
495
- const fields: EditFieldViewModel[] = sectionFieldSpecs(sectionSpec).map((fieldSpec) => {
496
- const normalized = normalizeEditField(fieldSpec);
497
- const fieldDef = entity.fields[normalized.field];
498
- if (!fieldDef) {
499
- throw new Error(
500
- `computeEditViewModel: screen "${screen.id}" references unknown field "${normalized.field}" on entity "${screen.entity}"`,
501
- );
502
- }
503
- // Declared reference metadata (EditFieldSpec.refEntity) — for
504
- // projectionDetail fields, which have no EntityDefinition field to
505
- // carry a real "reference" type. Takes priority over fieldDef.type
506
- // below so it also fires against the pseudo-entity that hardcodes
507
- // every field as "text" (projection-detail-shim) — real entityEdit
508
- // screens never set this, so their fieldDef.type === "reference"
509
- // branch is unaffected.
510
- const declaredRefTarget =
511
- normalized.refEntity !== undefined
512
- ? parseRefTarget(normalized.refEntity, featureName)
513
- : undefined;
514
- const effectiveType = declaredRefTarget !== undefined ? "reference" : fieldDef.type;
515
- const label = translate(
516
- screen.fieldLabels?.[normalized.field] ??
517
- fieldLabelKey(featureName, screen.entity, normalized.field),
518
- );
519
- const visible = evalCondition(normalized.visible, true, values);
520
- // `readOnly` (camelCase) is the name on both sides: EditFieldSpec
521
- // in the engine, and the view-model emitted here. One convention
522
- // through the stack beats translating at the boundary.
523
- const readOnly = evalCondition(normalized.readOnly, false, values);
524
- // `required` on the field-spec overrides the entity-default. A
525
- // field that's required at the entity-level but marked required:
526
- // false on the screen (e.g. a soft-onboarding wizard that
527
- // collects less up-front) respects the screen override.
528
- const entityRequired = (fieldDef as unknown as { required?: boolean }).required === true;
529
- const required = evalCondition(normalized.required, entityRequired, values);
530
- // ponytail: "EUR" mirrors DEFAULT_CURRENCIES[0] from
531
- // framework/src/engine/field-helpers.ts — headless has no dependency
532
- // on that module, so the literal is duplicated here instead of
533
- // importing it just for one fallback string.
534
- const resolvedCurrency = entity.defaultCurrency ?? "EUR";
535
- const selectHints = deriveSelectFieldHints(
536
- fieldDef,
537
- translate,
538
- featureName,
539
- screen.entity,
540
- normalized.field,
541
- );
542
- const textHints = deriveTextFieldHints(fieldDef);
543
- const dateHints = deriveDateFieldHints(fieldDef);
544
- const numericHints = deriveNumericFieldHints(fieldDef, resolvedCurrency);
545
- const referenceHints = deriveReferenceFieldHints(
546
- fieldDef,
547
- normalized,
548
- declaredRefTarget,
549
- featureName,
550
- );
551
- const fileHints = deriveFileFieldHints(fieldDef, screen.entity, normalized.field);
552
- const embeddedListHints = deriveEmbeddedListHints(
553
- fieldDef,
554
- translate,
555
- featureName,
556
- screen.entity,
557
- normalized.field,
558
- resolvedCurrency,
559
- );
560
- const view: EditFieldViewModel = {
561
- field: normalized.field,
562
- label,
563
- type: effectiveType,
564
- value: values[normalized.field],
565
- visible,
566
- readOnly,
567
- required,
568
- ...(normalized.span !== undefined && { span: normalized.span }),
569
- ...(normalized.renderer !== undefined && { renderer: normalized.renderer }),
570
- ...selectHints,
571
- ...textHints,
572
- ...dateHints,
573
- ...numericHints,
574
- ...referenceHints,
575
- ...fileHints,
576
- ...(normalized.icon !== undefined && { icon: normalized.icon }),
577
- ...embeddedListHints,
578
- };
579
- return view;
580
- });
581
- // Boot-validator rejects fields.length === 0 with no groups (screens.ts),
582
- // so an empty section never reaches this code.
583
- const visible = fields.some((field) => field.visible);
584
- // Tabs mode renders this section as its own panel, where two columns reads
585
- // better than the single-column default a stacked form keeps. Applies to
586
- // entityEdit tabs as well since fw#3134, not just projectionDetail.
587
- const defaultColumns = screen.layout.mode === "tabs" ? 2 : 1;
588
- const groups = sectionSpec.groups?.map((group) => ({
589
- title: translate(group.title),
590
- columns: group.columns ?? 2,
591
- fields: group.fields.map((fieldSpec) => {
592
- const fieldName = normalizeEditField(fieldSpec).field;
593
- const fieldView = fields.find((f) => f.field === fieldName);
594
- if (fieldView === undefined) {
595
- throw new Error(
596
- `computeEditViewModel: screen "${screen.id}" group "${group.title}" references field ` +
597
- `"${fieldName}" that failed to resolve.`,
598
- );
599
- }
600
- return fieldView;
601
- }),
602
- }));
603
- return {
604
- kind: "fields" as const,
605
- ...(sectionSpec.id !== undefined && { id: sectionSpec.id }),
606
- visible,
607
- // Titellose Section (flache Form) → kein h3; nur übersetzen wenn gesetzt.
608
- ...(sectionSpec.title !== undefined && { title: translate(sectionSpec.title) }),
609
- ...(sectionSpec.description !== undefined && {
610
- description: translate(sectionSpec.description),
611
- }),
612
- columns: sectionSpec.columns ?? defaultColumns,
613
- fields,
614
- ...(groups !== undefined && { groups }),
615
- ...(sectionSpec.icon !== undefined && { icon: sectionSpec.icon }),
616
- ...(sectionSpec.actions !== undefined && { actions: sectionSpec.actions }),
617
- };
618
- });
619
-
620
- const id = (values["id"] as string | undefined) ?? null;
621
-
622
- return {
623
- screenId: screen.id,
624
- entityName: screen.entity,
625
- id,
626
- sections,
627
- ...(screen.slots && { slots: screen.slots }),
628
- };
629
- }
630
-
631
- // Resolves a FieldCondition against the current row values.
632
- // `undefined` means "not declared" — caller substitutes the default.
633
- function evalCondition<TValues>(
634
- condition: FieldCondition | undefined,
635
- fallback: boolean,
636
- values: TValues,
637
- ): boolean {
638
- if (condition === undefined) return fallback;
639
- // @cast-boundary view-model: TValues ist strukturell ein Record.
640
- return evalFieldCondition(condition, values as Record<string, unknown>);
641
- }