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