@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 +2 -2
- package/src/changes.json +7 -0
- package/src/form/__tests__/conditional-fields.test.ts +1 -1
- package/src/form/__tests__/form-controller.test.ts +1 -1
- package/src/form/__tests__/submit.test.ts +1 -1
- package/src/form/__tests__/validation.test.ts +1 -1
- package/src/form/form-controller.ts +1 -1
- package/src/format/__tests__/format.test.ts +21 -0
- package/src/format/index.ts +7 -3
- package/src/view-model/edit.ts +363 -223
- package/src/view-model/list.ts +2 -0
- package/src/view-model/types.ts +26 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-headless",
|
|
3
|
-
"version": "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.
|
|
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, mock, test } from "bun:test";
|
|
2
|
-
import
|
|
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";
|
|
@@ -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 (
|
|
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).
|
package/src/format/index.ts
CHANGED
|
@@ -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(
|
|
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,
|
package/src/view-model/edit.ts
CHANGED
|
@@ -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
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
const
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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
|
-
...
|
|
400
|
-
...
|
|
401
|
-
...
|
|
402
|
-
...
|
|
403
|
-
...
|
|
404
|
-
...
|
|
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
|
-
...
|
|
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
|
|
package/src/view-model/list.ts
CHANGED
|
@@ -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
|
}
|
package/src/view-model/types.ts
CHANGED
|
@@ -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 = {
|