@cosmicdrift/kumiko-headless 0.306.0 → 0.308.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-headless",
3
- "version": "0.306.0",
3
+ "version": "0.308.0",
4
4
  "description": "Headless UI logic for Kumiko — Dispatcher contract, Form-Controller, View-Model, Nav-Resolver. Plattform- und React-frei; jeder Renderer (renderer, renderer-web, renderer-native, …) komponiert darauf.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -36,7 +36,7 @@
36
36
  }
37
37
  },
38
38
  "dependencies": {
39
- "@cosmicdrift/kumiko-framework": "0.306.0",
39
+ "@cosmicdrift/kumiko-framework": "0.308.0",
40
40
  "temporal-polyfill": "^0.3.2",
41
41
  "zod": "^4.4.3"
42
42
  },
package/src/changes.json CHANGED
@@ -1,4 +1,11 @@
1
1
  [
2
+ {
3
+ "version": "0.308.0",
4
+ "type": "improvement",
5
+ "title": "computeEditViewModel threads section.actions and EditRelatedListSection.emptyState into the view model (fw#3234)",
6
+ "detail": "EditFieldsSectionViewModel, EditExtensionSectionViewModel, EditRelatedListSectionViewModel and EditWriteFormSectionViewModel all carry the spec's optional `actions` unchanged; EditRelatedListSectionViewModel's `emptyState.title`/`description` are translated the same way section.title already is.",
7
+ "migration": "Additive — a view model without actions/emptyState in its spec is unaffected."
8
+ },
2
9
  {
3
10
  "version": "0.289.0",
4
11
  "type": "improvement",
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { z } from "zod";
2
+ import * as z from "zod";
3
3
  import { createFormController } from "../form-controller";
4
4
 
5
5
  describe("conditional fields — FieldState resolution", () => {
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, mock, test } from "bun:test";
2
- import { z } from "zod";
2
+ import * as z from "zod";
3
3
  import { createFormController } from "../form-controller";
4
4
 
5
5
  describe("createFormController — core state machine", () => {
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, mock, test } from "bun:test";
2
- import { z } from "zod";
2
+ import * as z from "zod";
3
3
  import type { Dispatcher, WriteResult } from "../../dispatcher";
4
4
  import { createStore } from "../../store";
5
5
  import { createFormController } from "../form-controller";
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, mock, test } from "bun:test";
2
- import { z } from "zod";
2
+ import * as z from "zod";
3
3
  import { createFormController } from "../form-controller";
4
4
  import { groupIssuesByPath, zodErrorToFieldIssues } from "../zod-bridge";
5
5
 
@@ -339,7 +339,7 @@ export function createFormController<TValues extends FormValues, TCtx = unknown>
339
339
  // the first network call returned) would otherwise fire two writes
340
340
  // AND rebase twice — compounding with the stale-submit race below.
341
341
  // Serialize: subsequent calls await the in-flight promise. Same
342
- // pattern the server-side event-dispatcher uses (passInFlight).
342
+ // pattern the server-side event-dispatcher uses (inFlightTurns).
343
343
  if (submitInFlight) return submitInFlight as Promise<SubmitResult<TData>>;
344
344
 
345
345
  if (!runValidate(submitCfg.validateScope, { includeRoot: true })) {
@@ -38,6 +38,27 @@ describe("applyFormatSpec — boolean/currency", () => {
38
38
  });
39
39
  });
40
40
 
41
+ describe("applyFormatSpec — number grouping (fw#3234)", () => {
42
+ test("groups thousands by default", () => {
43
+ expect(applyFormatSpec({ format: "number" }, 2021)).toBe("2,021");
44
+ expect(applyFormatSpec({ format: "number", locale: "de-DE" }, 2021)).toBe("2.021");
45
+ });
46
+
47
+ test("grouping: false renders no thousands separator", () => {
48
+ expect(applyFormatSpec({ format: "number", grouping: false }, 2021)).toBe("2021");
49
+ expect(applyFormatSpec({ format: "number", locale: "de-DE", grouping: false }, 2021)).toBe(
50
+ "2021",
51
+ );
52
+ });
53
+
54
+ test("decimal/bigInt share the same grouping path", () => {
55
+ expect(applyFormatSpec({ format: "decimal", locale: "de-DE" }, 2021)).toBe("2.021");
56
+ expect(applyFormatSpec({ format: "bigInt", locale: "de-DE", grouping: false }, 2021)).toBe(
57
+ "2021",
58
+ );
59
+ });
60
+ });
61
+
41
62
  describe("applyFormatSpec — timestamp/date (formatDateCell-Pfad)", () => {
42
63
  // Mittag UTC: das Datum kippt in keiner Zeitzone UTC-11..UTC+11 —
43
64
  // deterministisch auf CI (UTC) und lokal (CET).
@@ -70,10 +70,10 @@ function formatDateCell(
70
70
  // No `locale` fallback to a browser API here (headless has none) — passing
71
71
  // `undefined` to Intl.NumberFormat resolves the runtime's default locale,
72
72
  // the same value a browser's navigator.language-based guess would produce.
73
- function formatNumberCell(value: unknown, locale?: string): string {
73
+ function formatNumberCell(value: unknown, locale?: string, grouping = true): string {
74
74
  if (typeof value !== "number" || !Number.isFinite(value)) return String(value);
75
75
  try {
76
- return new Intl.NumberFormat(locale).format(value);
76
+ return new Intl.NumberFormat(locale, { useGrouping: grouping }).format(value);
77
77
  } catch {
78
78
  // Malformed BCP-47 locale tag — mirrors formatDateCell's fallback above.
79
79
  return String(value);
@@ -179,7 +179,11 @@ export function applyFormatSpec(
179
179
  case "number":
180
180
  case "decimal":
181
181
  case "bigInt":
182
- return formatNumberCell(value, spec["locale"] as string | undefined);
182
+ return formatNumberCell(
183
+ value,
184
+ spec["locale"] as string | undefined,
185
+ (spec["grouping"] as boolean | undefined) ?? true,
186
+ );
183
187
  case "unit":
184
188
  return formatUnitCell(
185
189
  value,
@@ -1,9 +1,11 @@
1
1
  import type {
2
+ EditExtensionSection,
2
3
  EditRelatedListSection,
3
4
  EditWriteFormSection,
4
5
  EntityDefinition,
5
6
  EntityEditScreenDefinition,
6
7
  FieldCondition,
8
+ ParsedRefTarget,
7
9
  } from "@cosmicdrift/kumiko-framework/ui-types";
8
10
  import {
9
11
  evalFieldCondition,
@@ -111,6 +113,7 @@ function computeWriteFormSectionViewModel<TValues extends Readonly<Record<string
111
113
  ...(sectionSpec.submitLabel !== undefined && {
112
114
  submitLabel: translate(sectionSpec.submitLabel),
113
115
  }),
116
+ ...(sectionSpec.actions !== undefined && { actions: sectionSpec.actions }),
114
117
  };
115
118
  }
116
119
 
@@ -134,6 +137,332 @@ function computeRelatedListSectionViewModel(
134
137
  ...(sectionSpec.rowClick !== undefined && { rowClick: sectionSpec.rowClick }),
135
138
  ...(sectionSpec.rowActions !== undefined && { rowActions: sectionSpec.rowActions }),
136
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 }),
137
466
  };
138
467
  }
139
468
 
@@ -147,14 +476,7 @@ export function computeEditViewModel<
147
476
 
148
477
  const sections: EditSectionViewModel[] = screen.layout.sections.map((sectionSpec) => {
149
478
  if (isExtensionEditSection(sectionSpec)) {
150
- return {
151
- kind: "extension" as const,
152
- ...(sectionSpec.id !== undefined && { id: sectionSpec.id }),
153
- title: translate(sectionSpec.title),
154
- component: sectionSpec.component,
155
- contributesToFormSubmit: sectionSpec.contributesToFormSubmit === true,
156
- ...(sectionSpec.entityName !== undefined && { entityName: sectionSpec.entityName }),
157
- };
479
+ return buildExtensionSectionViewModel(sectionSpec, translate);
158
480
  }
159
481
  if (isWriteFormEditSection(sectionSpec)) {
160
482
  return computeWriteFormSectionViewModel(
@@ -205,187 +527,36 @@ export function computeEditViewModel<
205
527
  // collects less up-front) respects the screen override.
206
528
  const entityRequired = (fieldDef as unknown as { required?: boolean }).required === true;
207
529
  const required = evalCondition(normalized.required, entityRequired, values);
208
- // Select-Optionen bei `type: "select"` mitnehmen — der Renderer
209
- // braucht sie für das Dropdown ohne nochmal die EntityDefinition
210
- // zu reichen. Plus translated Labels (gleiche Convention wie der
211
- // List-Builder), damit Form-Selects und List-Cells dieselbe
212
- // i18n-Quelle teilen.
213
- const options =
214
- fieldDef.type === "select" || fieldDef.type === "multiSelect"
215
- ? ((fieldDef as unknown as { options?: readonly string[] }).options ?? [])
216
- : undefined;
217
- const optionLabels =
218
- options !== undefined
219
- ? buildOptionLabels(
220
- translate,
221
- (value) => fieldOptionLabelKey(featureName, screen.entity, normalized.field, value),
222
- options,
223
- )
224
- : undefined;
225
- // Checkbox-grid rendering hint for `type: "multiSelect"` and the
226
- // radio-vs-dropdown hint for `type: "select"` — pass through unchanged
227
- // so the renderer can skip its own layout heuristic.
228
- const display =
229
- fieldDef.type === "multiSelect" || fieldDef.type === "select"
230
- ? fieldDef.display
231
- : undefined;
232
- const columns = fieldDef.type === "multiSelect" ? fieldDef.columns : undefined;
233
- const maxRows = fieldDef.type === "multiSelect" ? fieldDef.maxRows : undefined;
234
- // Multiline hint for `type: "text"` — the renderer then switches to a
235
- // textarea. `type: "longText"` always renders a textarea regardless
236
- // of this hint; it's only carried through as an optional `{ rows }`
237
- // override (#1925).
238
- const multiline =
239
- fieldDef.type === "text" || fieldDef.type === "longText"
240
- ? (fieldDef as unknown as { multiline?: boolean | { rows?: number } }).multiline
241
- : undefined;
242
- // format hint for `type: "text"` — "password" makes the renderer mask
243
- // the input (#2548).
244
- const format =
245
- fieldDef.type === "text"
246
- ? (fieldDef as unknown as { format?: "email" | "url" | "phone" | "password" }).format
247
- : undefined;
248
- // Wall-Clock-Hint bei `type: "timestamp"` mit locatedBy — der
249
- // Renderer emittiert dann lokale Zeit ohne `Z` statt UTC-Instant.
250
- const wallClock =
251
- fieldDef.type === "timestamp" &&
252
- (fieldDef as unknown as { locatedBy?: string }).locatedBy !== undefined
253
- ? true
254
- : undefined;
255
- // Datumsgrenzen + Format/Locale-Override bei date/timestamp — der
256
- // Renderer begrenzt damit den Picker. Quelle: Date/TimestampFieldDef.
257
- const dateBounds =
258
- fieldDef.type === "date" ||
259
- fieldDef.type === "timestamp" ||
260
- fieldDef.type === "locatedTimestamp"
261
- ? (fieldDef as unknown as { min?: string; max?: string; locale?: string })
262
- : undefined;
263
- const min = dateBounds?.min;
264
- const max = dateBounds?.max;
265
- const dateLocale = dateBounds?.locale;
266
- // Tier 2.7e-3: Reference-Field — refEntity + refLabelField travel into
267
- // the view model so the renderer can build the lookup query without
268
- // touching the EntityDefinition again. The entity-string can be
269
- // same-feature ("user") or cross-feature ("users:user"); parseRefTarget
270
- // splits that, the renderer builds the lookup QN from
271
- // (refFeature, refEntity). Declared metadata (declaredRefTarget) takes
272
- // priority, same as effectiveType above.
273
- const refRaw =
274
- declaredRefTarget === undefined && fieldDef.type === "reference"
275
- ? (fieldDef as unknown as { entity?: string }).entity
276
- : undefined;
277
- const refTarget =
278
- declaredRefTarget ??
279
- (refRaw !== undefined ? parseRefTarget(refRaw, featureName) : undefined);
280
- const refEntity = refTarget?.entityName;
281
- const refFeature = refTarget?.featureName;
282
- const refLabelField =
283
- declaredRefTarget !== undefined
284
- ? (normalized.refLabelField ?? "id")
285
- : fieldDef.type === "reference"
286
- ? ((fieldDef as unknown as { labelField?: string }).labelField ?? "id")
287
- : undefined;
288
- // Declared reference metadata has no `multiple` or `optionsQuery`
289
- // concept (it targets row-meta/derived fields, always single-valued and
290
- // resolved through the target entity) — only a real ReferenceFieldDef
291
- // carries them.
292
- const ownReferenceDef =
293
- declaredRefTarget === undefined && fieldDef.type === "reference"
294
- ? (fieldDef as unknown as { multiple?: boolean; optionsQuery?: string })
295
- : undefined;
296
- const refOptionsQuery = ownReferenceDef?.optionsQuery;
297
- const refMultiple =
298
- ownReferenceDef === undefined ? undefined : (ownReferenceDef.multiple ?? false);
299
- // file/image: accept/maxSize ins ViewModel + entityType/fieldName für
300
- // den Upload-POST (Endpoint validiert gegen die richtige Field-Def).
301
- const isFileType =
302
- fieldDef.type === "file" || fieldDef.type === "image" || fieldDef.type === "images";
303
530
  // ponytail: "EUR" mirrors DEFAULT_CURRENCIES[0] from
304
531
  // framework/src/engine/field-helpers.ts — headless has no dependency
305
532
  // on that module, so the literal is duplicated here instead of
306
533
  // importing it just for one fallback string.
307
534
  const resolvedCurrency = entity.defaultCurrency ?? "EUR";
308
- const fileDef = isFileType
309
- ? (fieldDef as unknown as {
310
- accept?: readonly string[];
311
- maxSize?: string;
312
- variants?: Readonly<Record<string, unknown>>;
313
- capture?: "environment" | "user";
314
- })
315
- : undefined;
316
- const imageVariant =
317
- fieldDef.type === "image" || fieldDef.type === "images"
318
- ? Object.keys(fileDef?.variants ?? {})[0]
319
- : undefined;
320
- const capture = fieldDef.type === "image" ? fileDef?.capture : undefined;
321
- // Embedded-LIST field (`multiple: true`) — per-cell metadata for a
322
- // renderer to draw one row per array item (invoice-positions-style
323
- // table). A plain (non-list) embedded field emits none of this; the
324
- // renderer tells the two apart by whether embeddedListCells is set,
325
- // not by `type` (which stays "embedded" either way).
326
- const isEmbeddedList =
327
- fieldDef.type === "embedded" &&
328
- (fieldDef as unknown as { multiple?: boolean }).multiple === true;
329
- const embeddedListDef = isEmbeddedList
330
- ? (fieldDef as unknown as {
331
- schema: Readonly<Record<string, EmbeddedSubFieldShape>>;
332
- minItems?: number;
333
- maxItems?: number;
334
- derived?: Readonly<
335
- Record<
336
- string,
337
- { readonly op: "multiply" | "sum" | "subtract"; readonly from: readonly string[] }
338
- >
339
- >;
340
- totals?: readonly string[];
341
- })
342
- : undefined;
343
- const embeddedListCells: readonly EmbeddedListCellViewModel[] | undefined =
344
- embeddedListDef !== undefined
345
- ? Object.entries(embeddedListDef.schema).map(([subFieldName, subField]) => {
346
- const cellLabel = translate(
347
- embeddedCellLabelKey(featureName, screen.entity, normalized.field, subFieldName),
348
- );
349
- const cellOptions = subField.type === "select" ? (subField.options ?? []) : undefined;
350
- const cellOptionLabels =
351
- cellOptions !== undefined
352
- ? buildOptionLabels(
353
- translate,
354
- (value) =>
355
- embeddedCellOptionLabelKey(
356
- featureName,
357
- screen.entity,
358
- normalized.field,
359
- subFieldName,
360
- value,
361
- ),
362
- cellOptions,
363
- )
364
- : undefined;
365
- const cellRef = subField.type === "reference" ? subField : undefined;
366
- const cellRefTarget =
367
- cellRef?.entity !== undefined
368
- ? parseRefTarget(cellRef.entity, featureName)
369
- : undefined;
370
- const cell: EmbeddedListCellViewModel = {
371
- field: subFieldName,
372
- label: cellLabel,
373
- type: subField.type,
374
- required: subField.required === true,
375
- ...(cellOptions !== undefined && { options: cellOptions }),
376
- ...(cellOptionLabels !== undefined && { optionLabels: cellOptionLabels }),
377
- ...(cellRefTarget !== undefined && { refEntity: cellRefTarget.entityName }),
378
- ...(cellRefTarget !== undefined && { refFeature: cellRefTarget.featureName }),
379
- ...(cellRef?.labelField !== undefined && { refLabelField: cellRef.labelField }),
380
- ...(cellRef?.optionsQuery !== undefined && {
381
- refOptionsQuery: cellRef.optionsQuery,
382
- }),
383
- ...(subField.type === "decimal" &&
384
- subField.scale !== undefined && { scale: subField.scale }),
385
- };
386
- return cell;
387
- })
388
- : undefined;
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
+ );
389
560
  const view: EditFieldViewModel = {
390
561
  field: normalized.field,
391
562
  label,
@@ -396,46 +567,14 @@ export function computeEditViewModel<
396
567
  required,
397
568
  ...(normalized.span !== undefined && { span: normalized.span }),
398
569
  ...(normalized.renderer !== undefined && { renderer: normalized.renderer }),
399
- ...(options !== undefined && { options }),
400
- ...(optionLabels !== undefined && { optionLabels }),
401
- ...(display !== undefined && { display }),
402
- ...(columns !== undefined && { columns }),
403
- ...(maxRows !== undefined && { maxRows }),
404
- ...(multiline !== undefined && { multiline }),
405
- ...(format !== undefined && { format }),
406
- ...(wallClock !== undefined && { wallClock }),
407
- ...(min !== undefined && { min }),
408
- ...(max !== undefined && { max }),
409
- ...(dateLocale !== undefined && { dateLocale }),
410
- ...(refEntity !== undefined && { refEntity }),
411
- ...(refFeature !== undefined && { refFeature }),
412
- ...(refLabelField !== undefined && { refLabelField }),
413
- ...(refOptionsQuery !== undefined && { refOptionsQuery }),
414
- ...(refMultiple !== undefined && { refMultiple }),
415
- ...(fileDef?.accept !== undefined && { accept: fileDef.accept }),
416
- ...(fileDef?.maxSize !== undefined && { maxSize: fileDef.maxSize }),
417
- ...(isFileType && { entityType: screen.entity, fieldName: normalized.field }),
418
- ...(imageVariant !== undefined && { imageVariant }),
419
- ...(capture !== undefined && { capture }),
570
+ ...selectHints,
571
+ ...textHints,
572
+ ...dateHints,
573
+ ...numericHints,
574
+ ...referenceHints,
575
+ ...fileHints,
420
576
  ...(normalized.icon !== undefined && { icon: normalized.icon }),
421
- ...(embeddedListCells !== undefined && { embeddedListCells }),
422
- ...(embeddedListDef?.minItems !== undefined && {
423
- embeddedListMinItems: embeddedListDef.minItems,
424
- }),
425
- ...(embeddedListDef?.maxItems !== undefined && {
426
- embeddedListMaxItems: embeddedListDef.maxItems,
427
- }),
428
- ...(embeddedListDef?.derived !== undefined && {
429
- embeddedListDerived: embeddedListDef.derived,
430
- }),
431
- ...(embeddedListDef?.totals !== undefined && {
432
- embeddedListTotals: embeddedListDef.totals,
433
- }),
434
- // Currency lives on the head aggregate (entity.defaultCurrency), not
435
- // per row — one value for the whole embedded list.
436
- ...(embeddedListDef !== undefined && { embeddedListCurrency: resolvedCurrency }),
437
- ...(fieldDef.type === "money" && { currency: resolvedCurrency }),
438
- ...(fieldDef.type === "number" && fieldDef.unit !== undefined && { unit: fieldDef.unit }),
577
+ ...embeddedListHints,
439
578
  };
440
579
  return view;
441
580
  });
@@ -474,6 +613,7 @@ export function computeEditViewModel<
474
613
  fields,
475
614
  ...(groups !== undefined && { groups }),
476
615
  ...(sectionSpec.icon !== undefined && { icon: sectionSpec.icon }),
616
+ ...(sectionSpec.actions !== undefined && { actions: sectionSpec.actions }),
477
617
  };
478
618
  });
479
619
 
@@ -164,6 +164,7 @@ export function computeListViewModel(input: ComputeListViewModelInput): ListView
164
164
  (fieldDef as unknown as { options?: readonly string[] }).options ?? [],
165
165
  )
166
166
  : undefined;
167
+ const grouping = fieldDef.type === "number" ? fieldDef.grouping : undefined;
167
168
  const column: ListColumnViewModel = {
168
169
  field: normalized.field,
169
170
  label,
@@ -174,6 +175,7 @@ export function computeListViewModel(input: ComputeListViewModelInput): ListView
174
175
  ...(refEntity !== undefined && { refEntity }),
175
176
  ...(refFeature !== undefined && { refFeature }),
176
177
  ...(refLabelField !== undefined && { refLabelField }),
178
+ ...(grouping !== undefined && { grouping }),
177
179
  };
178
180
  columns.push(column);
179
181
  }
@@ -68,6 +68,10 @@ export type ListColumnViewModel = {
68
68
  * multi-year statement grid. DataTable renders it with a distinct
69
69
  * header/cell background. */
70
70
  readonly highlighted?: boolean;
71
+ /** Only for `type: "number"` — mirrors `NumberFieldDef.grouping` (default
72
+ * `true`). `false` renders without thousands-separators (e.g. a model
73
+ * year). */
74
+ readonly grouping?: boolean;
71
75
  };
72
76
 
73
77
  export type ListRowViewModel = {
@@ -179,6 +183,10 @@ export type EditFieldViewModel = {
179
183
  readonly min?: string;
180
184
  readonly max?: string;
181
185
  readonly dateLocale?: string;
186
+ /** Only for `type: "number"` — mirrors `NumberFieldDef.grouping` (default
187
+ * `true`). `false` renders without thousands-separators (e.g. a model
188
+ * year). */
189
+ readonly grouping?: boolean;
182
190
  /** Nur bei `type: "reference"` gesetzt — Tier 2.7e-3.
183
191
  * Die referenced Entity (kurz, ohne feature-prefix). Der Renderer
184
192
  * baut die Query-QN als `<refFeature>:query:<refEntity>:list`. */
@@ -291,6 +299,9 @@ export type EditFieldsSectionViewModel = {
291
299
  /** From `EditFieldsSection.icon` — closed IconKey vocabulary, renders
292
300
  * left of the title. No effect without `title`. */
293
301
  readonly icon?: IconKey;
302
+ /** From `EditFieldsSection.actions` — rendered in the Section's
303
+ * title row, same slot as the projectionDetail head card's actions. */
304
+ readonly actions?: readonly RowAction[];
294
305
  };
295
306
 
296
307
  export type EditExtensionSectionViewModel = {
@@ -303,6 +314,8 @@ export type EditExtensionSectionViewModel = {
303
314
  readonly contributesToFormSubmit: boolean;
304
315
  /** Section-declared override — see `EditExtensionSection.entityName`. */
305
316
  readonly entityName?: string;
317
+ /** From `EditExtensionSection.actions`. */
318
+ readonly actions?: readonly RowAction[];
306
319
  };
307
320
 
308
321
  // Mirrors EditRelatedListSection verbatim — no per-row/query resolution
@@ -322,6 +335,17 @@ export type EditRelatedListSectionViewModel = {
322
335
  readonly rowClick?: { readonly entity: string; readonly idColumn?: string };
323
336
  readonly rowActions?: readonly RowAction[];
324
337
  readonly toolbarActions?: readonly RelatedListToolbarAction[];
338
+ /** From `EditRelatedListSection.actions` — rendered in the
339
+ * Section's title row, distinct from `toolbarActions` (which render
340
+ * above the table itself). */
341
+ readonly actions?: readonly RowAction[];
342
+ /** From `EditRelatedListSection.emptyState` — forwarded to the
343
+ * DataTable's `emptyState` prop when the section has zero rows. */
344
+ readonly emptyState?: {
345
+ readonly title: string;
346
+ readonly description?: string;
347
+ readonly action?: RowAction;
348
+ };
325
349
  };
326
350
 
327
351
  // Mirrors EditWriteFormSection, except `fields` is already resolved through
@@ -337,6 +361,8 @@ export type EditWriteFormSectionViewModel = {
337
361
  readonly icon?: IconKey;
338
362
  readonly handler: string;
339
363
  readonly submitLabel?: string;
364
+ /** From `EditWriteFormSection.actions`. */
365
+ readonly actions?: readonly RowAction[];
340
366
  };
341
367
 
342
368
  export type EditViewModel = {