@cosmicdrift/kumiko-renderer 0.399.0 → 0.400.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/dist/app/entity-edit-prefill.js +7 -1
  2. package/dist/app/form-schema.js +42 -17
  3. package/dist/app/projection-detail-body.js +7 -0
  4. package/dist/app/projection-detail-header-model.d.ts +1 -1
  5. package/dist/app/projection-detail-header-model.js +2 -2
  6. package/dist/app/projection-detail-header-region.d.ts +1 -0
  7. package/dist/app/projection-detail-header-region.js +1 -1
  8. package/dist/app/projection-detail-header.js +4 -2
  9. package/dist/app/projection-detail-navigation.d.ts +1 -0
  10. package/dist/app/projection-detail-navigation.js +9 -1
  11. package/dist/app/use-related-list-tab-counts.d.ts +5 -0
  12. package/dist/app/use-related-list-tab-counts.js +68 -0
  13. package/dist/components/embedded-list-field.js +69 -91
  14. package/dist/components/embedded-list-paste.d.ts +2 -0
  15. package/dist/components/embedded-list-paste.js +73 -0
  16. package/dist/components/related-list-section.js +4 -8
  17. package/dist/components/render-edit-helpers.js +2 -2
  18. package/dist/components/render-field-choice-inputs.js +1 -1
  19. package/dist/embedded-list-types.d.ts +117 -0
  20. package/dist/embedded-list-types.js +1 -0
  21. package/dist/headless/form/form-controller.js +9 -1
  22. package/dist/headless/index.d.ts +1 -1
  23. package/dist/headless/index.js +1 -1
  24. package/dist/headless/view-model/edit.js +40 -50
  25. package/dist/headless/view-model/embedded-list-cells.d.ts +12 -0
  26. package/dist/headless/view-model/embedded-list-cells.js +49 -0
  27. package/dist/headless/view-model/embedded-list.d.ts +1 -1
  28. package/dist/headless/view-model/embedded-list.js +1 -1
  29. package/dist/headless/view-model/index.d.ts +1 -1
  30. package/dist/headless/view-model/index.js +1 -1
  31. package/dist/headless/view-model/list.js +2 -3
  32. package/dist/headless/view-model/types.d.ts +23 -6
  33. package/dist/hooks/related-list-parent-payload.d.ts +2 -0
  34. package/dist/hooks/related-list-parent-payload.js +7 -0
  35. package/dist/i18n-defaults.js +3 -0
  36. package/dist/primitives.d.ts +4 -88
  37. package/dist/web/lib/file-upload-form-data.d.ts +2 -0
  38. package/dist/web/lib/file-upload-form-data.js +2 -0
  39. package/dist/web/lib/upload-file-ref.d.ts +1 -1
  40. package/dist/web/primitives/data-table-cell.d.ts +2 -0
  41. package/dist/web/primitives/data-table-cell.js +1 -1
  42. package/dist/web/primitives/embedded-file-cell.d.ts +11 -0
  43. package/dist/web/primitives/embedded-file-cell.js +50 -0
  44. package/dist/web/primitives/embedded-list-cards.d.ts +38 -0
  45. package/dist/web/primitives/embedded-list-cards.js +71 -0
  46. package/dist/web/primitives/embedded-list-cell.d.ts +41 -0
  47. package/dist/web/primitives/embedded-list-cell.js +132 -0
  48. package/dist/web/primitives/embedded-list-input.d.ts +1 -1
  49. package/dist/web/primitives/embedded-list-input.js +29 -142
  50. package/dist/web/primitives/file-upload.d.ts +2 -1
  51. package/dist/web/primitives/file-upload.js +2 -1
  52. package/dist/web/primitives/input-choice-kinds.js +1 -1
  53. package/dist/web/primitives/input-optional-props.d.ts +2 -1
  54. package/dist/web/primitives/input-optional-props.js +2 -1
  55. package/dist/web/primitives/locale-number.d.ts +3 -0
  56. package/dist/web/primitives/locale-number.js +8 -0
  57. package/dist/web/primitives/number-input.d.ts +2 -1
  58. package/dist/web/primitives/number-input.js +4 -11
  59. package/dist/web/primitives/segmented-select.d.ts +6 -1
  60. package/dist/web/primitives/segmented-select.js +8 -4
  61. package/dist/web/widgets/status-badge.d.ts +1 -0
  62. package/dist/web/widgets/status-badge.js +1 -1
  63. package/package.json +5 -5
@@ -73,7 +73,7 @@ export function wizardStepKey(section, index) {
73
73
  return declaredId ?? section.title ?? `step-${index}`;
74
74
  }
75
75
  function isMoneyValue(value) {
76
- return typeof value === "object" && value !== null && "amount" in value;
76
+ return typeof value === "object" && value !== null && ("amount" in value || "currency" in value);
77
77
  }
78
78
  // The seeds buildInitialValues writes for NULL columns of an existing record.
79
79
  function isEmptyFormValue(value) {
@@ -82,7 +82,7 @@ function isEmptyFormValue(value) {
82
82
  }
83
83
  if (Array.isArray(value))
84
84
  return value.length === 0;
85
- return isMoneyValue(value) && value.amount === 0;
85
+ return isMoneyValue(value) && (value.amount === 0 || value.amount === undefined);
86
86
  }
87
87
  // select/boolean are excluded from the "holds user data" check: they are
88
88
  // often pre-filled (defaults, server-set values), so they say nothing about
@@ -25,7 +25,7 @@ export function renderSelectInput({ field, Input, onChange }, { common }) {
25
25
  // `display` is shared with multiSelect's "checkboxes", which is not a
26
26
  // select presentation — narrow to the two a select field can request.
27
27
  const display = field.display === "radio" || field.display === "dropdown" ? field.display : undefined;
28
- return (_jsx(Input, { kind: "select", ...common, value: stringValue(field.value), onChange: (v) => onChange(v), options: selectOptions, ...(display !== undefined && { display }) }));
28
+ return (_jsx(Input, { kind: "select", ...common, value: stringValue(field.value), onChange: (v) => onChange(v), options: selectOptions, ...(display !== undefined && { display }), ...(field.optionTones !== undefined && { optionTones: field.optionTones }) }));
29
29
  }
30
30
  export function renderFileInput({ field, Input, onChange }, { common }) {
31
31
  const kind = field.type === "image" ? "image" : "file";
@@ -0,0 +1,117 @@
1
+ import type { SelectOptionTone } from "@cosmicdrift/kumiko-framework/ui-types";
2
+ import type { FieldIssue } from "./headless/index.ts";
3
+ /** Cell type for one column of an embedded-list field. Mirrors
4
+ * EmbeddedListCellViewModel["type"] from `@cosmicdrift/kumiko-renderer/headless`. */
5
+ export type EmbeddedListCellType = "text" | "number" | "boolean" | "date" | "money" | "decimal" | "select" | "reference" | "timestamp" | "images" | "files";
6
+ /** One column of an embedded-list table (one entry per key of the source
7
+ * EmbeddedFieldDef's `schema`). */
8
+ export type EmbeddedListColumn = {
9
+ readonly field: string;
10
+ readonly label: string;
11
+ readonly type: EmbeddedListCellType;
12
+ readonly required: boolean;
13
+ /** Read-only, value supplied by the caller (already computed from
14
+ * sibling cells) — not directly user-editable. */
15
+ readonly derived: boolean;
16
+ /** Shown but not editable by the user; unlike `derived` it is not computed. */
17
+ readonly readOnly?: boolean;
18
+ /** Phone cards skip this column; the table still shows it. */
19
+ readonly hiddenInCard?: boolean;
20
+ /** Phone cards show the row's min/max (sub-field names) as a hint under this column's control. */
21
+ readonly cardLimits?: {
22
+ readonly min: string;
23
+ readonly max: string;
24
+ };
25
+ /** Phone cards render this column unlabeled beside the named column's control. */
26
+ readonly cardAttachedTo?: string;
27
+ /** Only for `type: "select"`. */
28
+ readonly options?: readonly string[];
29
+ readonly optionLabels?: Readonly<Record<string, string>>;
30
+ /** Only for `type: "select"`; `"radio"` renders segmented buttons, the
31
+ * default is a dropdown. */
32
+ readonly display?: "radio" | "dropdown";
33
+ /** Only for `type: "reference"` — pre-fetched by the caller, ONE query
34
+ * per column shared across every row (not one query per cell). */
35
+ readonly referenceOptions?: readonly {
36
+ readonly value: string;
37
+ readonly label: string;
38
+ }[];
39
+ readonly referenceLoading?: boolean;
40
+ /** Only for `type: "select"`; tone per option (selected segment, derived badge). */
41
+ readonly optionTones?: Readonly<Partial<Record<string, SelectOptionTone>>>;
42
+ /** Only for `type: "images" | "files"`. */
43
+ readonly capture?: "user" | "environment";
44
+ readonly maxCount?: number;
45
+ readonly accept?: readonly string[];
46
+ /** Per-row state, evaluated by the caller (conditions are engine logic the primitive must not import).
47
+ * A hidden cell keeps its value but shows no control. */
48
+ readonly isHiddenInRow?: (row: Readonly<Record<string, unknown>>) => boolean;
49
+ readonly isRequiredInRow?: (row: Readonly<Record<string, unknown>>) => boolean;
50
+ /** Overrides `derived` per row: a conditional derived cell is editable on rows where it does not apply. */
51
+ readonly isDerivedInRow?: (row: Readonly<Record<string, unknown>>) => boolean;
52
+ };
53
+ /** One entry of an embedded-list totals row (e.g. an invoice's total
54
+ * amount, summed across all rows by the caller). */
55
+ export type EmbeddedListTotal = {
56
+ readonly field: string;
57
+ readonly label: string;
58
+ readonly value: number;
59
+ /** Set for labelled multi-column totals, which have no single column to
60
+ * read the money type from. */
61
+ readonly money?: boolean;
62
+ };
63
+ /** Row-array + totals table for a `createEmbeddedListField` (invoice-
64
+ * positions-style) field — structurally too different from `Input`'s
65
+ * value/onChange union to fit there, so it's its own primitive, same as
66
+ * `DataTable`. Controlled: the caller (the eventual form-field wrapper)
67
+ * computes rows/derived values/issue groups and wires every callback;
68
+ * this primitive only renders and reports interaction. */
69
+ export type EmbeddedListInputProps = {
70
+ readonly id: string;
71
+ readonly columns: readonly EmbeddedListColumn[];
72
+ readonly rows: readonly Readonly<Record<string, unknown>>[];
73
+ readonly totals?: readonly EmbeddedListTotal[];
74
+ /** Currency for money cells and the totals row — one value for the whole
75
+ * list (currency lives on the head aggregate, not the row). Web impl
76
+ * falls back to "EUR" when absent. */
77
+ readonly currency?: string;
78
+ /** Whole-list read-only (e.g. a released invoice) — hides row-mutation
79
+ * affordances and disables every cell. */
80
+ readonly disabled?: boolean;
81
+ /** Rows are fixed: no add, remove, duplicate or move; cells stay editable. */
82
+ readonly fixedRows?: boolean;
83
+ readonly minItems?: number;
84
+ readonly maxItems?: number;
85
+ /** Upload target of file cells: `entityType`/`entityId`/`fieldName` of the list, the column name is the `subField`. */
86
+ readonly uploadTarget?: {
87
+ readonly entityType: string;
88
+ readonly fieldName: string;
89
+ readonly entityId?: string;
90
+ };
91
+ /** Shown under the whole list (e.g. under the totals row). */
92
+ readonly listIssues?: readonly FieldIssue[];
93
+ /** Keyed by row index. */
94
+ readonly rowIssues?: Readonly<Record<number, readonly FieldIssue[]>>;
95
+ /** Keyed `${rowIndex}.${field}`. */
96
+ readonly cellIssues?: Readonly<Record<string, readonly FieldIssue[]>>;
97
+ readonly onCellChange: (rowIndex: number, field: string, value: unknown) => void;
98
+ readonly onAddRow: () => void;
99
+ readonly onRemoveRow: (rowIndex: number) => void;
100
+ readonly onDuplicateRow: (rowIndex: number) => void;
101
+ readonly onMoveRow: (fromIndex: number, toIndex: number) => void;
102
+ /** Tab/newline-delimited clipboard paste starting at (rowIndex,
103
+ * columnIndex) — parsed to a 2D string grid by the Web impl (paste
104
+ * events are a browser-only concept); undefined caller = no paste
105
+ * handling wired up. */
106
+ readonly onPasteCells?: (rowIndex: number, columnIndex: number, grid: readonly (readonly string[])[]) => void;
107
+ readonly addLabel: string;
108
+ /** Accessible name of the (otherwise headerless) row-actions column. */
109
+ readonly actionsLabel: string;
110
+ readonly removeLabel: string;
111
+ readonly duplicateLabel: string;
112
+ readonly moveUpLabel: string;
113
+ readonly moveDownLabel: string;
114
+ readonly emptyLabel: string;
115
+ readonly emptyCtaLabel: string;
116
+ readonly testId?: string;
117
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -62,6 +62,13 @@ function valuesDiff(current, initial) {
62
62
  // of being omitted — no validation error surfaces it, unlike the "" case.
63
63
  // Fixing that needs real per-field seed-tracking (which value was actually
64
64
  // user-set vs. defaulted), not just a wider strip predicate here.
65
+ // The seed of an untouched optional money field (buildInitialValues); never a valid payload value.
66
+ function isCurrencyOnlyMoney(value) {
67
+ return (typeof value === "object" &&
68
+ value !== null &&
69
+ "currency" in value &&
70
+ !("amount" in value && value.amount !== undefined));
71
+ }
65
72
  function stripUntouchedEmptyStrings(values, initial, forceIncludeEmpty) {
66
73
  const v = values; // @cast-boundary form-values
67
74
  const ini = initial; // @cast-boundary form-values
@@ -69,7 +76,8 @@ function stripUntouchedEmptyStrings(values, initial, forceIncludeEmpty) {
69
76
  for (const key of Object.keys(v)) {
70
77
  // Value-based seed strip, except keys previously submitted as "" after a
71
78
  // real clear — those must survive post-rebase submits.
72
- if (!forceIncludeEmpty.has(key) && v[key] === "" && ini[key] === "") {
79
+ const untouchedEmptyString = !forceIncludeEmpty.has(key) && v[key] === "" && ini[key] === "";
80
+ if (untouchedEmptyString || isCurrencyOnlyMoney(v[key])) {
73
81
  out ??= { ...v };
74
82
  delete out[key];
75
83
  }
@@ -12,4 +12,4 @@ export { pluralKey } from "./plural-key.ts";
12
12
  export type { Store, WritableStore } from "./store/index.ts";
13
13
  export { createStore, shallowEqual } from "./store/index.ts";
14
14
  export type { CellLink, CellLinkItem, ComputeEditViewModelInput, ComputeListViewModelInput, DerivedCellRoundingTarget, EditExtensionSectionViewModel, EditFieldSpec, EditFieldsSectionViewModel, EditFieldViewModel, EditFormMode, EditRelatedListSectionViewModel, EditSectionSpec, EditSectionViewModel, EditViewModel, EditWriteFormSectionViewModel, EmbeddedDerivedOp, EmbeddedListCellViewModel, EmbeddedListIssueGroups, FieldConditionCtx, FieldRenderer, ListColumnFileDisplay, ListColumnSpec, ListColumnViewModel, ListRowViewModel, ListViewModel, RuntimeCellLink, RuntimeCellLinkItems, RuntimeRenderer, ScreenSlots, Translate, } from "./view-model/index.ts";
15
- export { buildOptionLabels, computeDerivedCellValue, computeEditViewModel, computeListViewModel, computeRelatedListSectionViewModel, embeddedCellLabelKey, embeddedCellOptionLabelKey, fieldLabelKey, fieldOptionLabelKey, fieldOptionLabelKeyPrefix, groupEmbeddedListIssues, roundDerivedCellValue, sumEmbeddedListColumn, } from "./view-model/index.ts";
15
+ export { buildOptionLabels, computeDerivedCellValue, computeEditViewModel, computeListViewModel, computeRelatedListSectionViewModel, embeddedCellLabelKey, embeddedCellOptionLabelKey, fieldLabelKey, fieldOptionLabelKey, fieldOptionLabelKeyPrefix, groupEmbeddedListIssues, roundDerivedCellValue, sumEmbeddedListColumn, withDerivedCells, } from "./view-model/index.ts";
@@ -7,4 +7,4 @@ export { applyFormatSpec, currencyDecimals, DOCUMENT_FALLBACK_CURRENCY, EMPTY_VA
7
7
  export { resolveNavigation } from "./nav/index.js";
8
8
  export { pluralKey } from "./plural-key.js";
9
9
  export { createStore, shallowEqual } from "./store/index.js";
10
- export { buildOptionLabels, computeDerivedCellValue, computeEditViewModel, computeListViewModel, computeRelatedListSectionViewModel, embeddedCellLabelKey, embeddedCellOptionLabelKey, fieldLabelKey, fieldOptionLabelKey, fieldOptionLabelKeyPrefix, groupEmbeddedListIssues, roundDerivedCellValue, sumEmbeddedListColumn, } from "./view-model/index.js";
10
+ export { buildOptionLabels, computeDerivedCellValue, computeEditViewModel, computeListViewModel, computeRelatedListSectionViewModel, embeddedCellLabelKey, embeddedCellOptionLabelKey, fieldLabelKey, fieldOptionLabelKey, fieldOptionLabelKeyPrefix, groupEmbeddedListIssues, roundDerivedCellValue, sumEmbeddedListColumn, withDerivedCells, } from "./view-model/index.js";
@@ -1,5 +1,6 @@
1
- import { availableSelectOptions, evalFieldCondition, isExtensionEditSection, isFieldsEditSection, isFormDerivationInputMode, isUnusedFallbackOperand, isWriteFormEditSection, normalizeEditField, parseRefTarget, sectionFieldSpecs, WRITE_FORM_SECTION_ENTITY, } from "@cosmicdrift/kumiko-framework/ui-types";
2
- import { buildOptionLabels, embeddedCellLabelKey, embeddedCellOptionLabelKey, fieldLabelKey, fieldOptionLabelKey, } from "./list.js";
1
+ import { availableSelectOptions, evalFieldCondition, isExtensionEditSection, isFieldRequired, isFieldsEditSection, isFieldVisible, isFormDerivationInputMode, isUnusedFallbackOperand, isWriteFormEditSection, normalizeEditField, parseRefTarget, sectionFieldSpecs, WRITE_FORM_SECTION_ENTITY, } from "@cosmicdrift/kumiko-framework/ui-types";
2
+ import { buildEmbeddedListCell } from "./embedded-list-cells.js";
3
+ import { buildOptionLabels, fieldLabelKey, fieldOptionLabelKey } from "./list.js";
3
4
  // Resolves a writeForm section's own fieldDefs/fields through the same
4
5
  // per-field pipeline as a plain fields section, by reusing the pipeline
5
6
  // itself rather than duplicating it: wrap fieldDefs/fields as a throwaway
@@ -161,15 +162,22 @@ function selectableOptions(fieldDef, fieldName, values) {
161
162
  const current = values[fieldName];
162
163
  return fieldDef.options.filter((option) => available.includes(option) || option === current);
163
164
  }
165
+ function deriveSelectDisplay(fieldDef) {
166
+ const declaredDisplay = fieldDef.type === "multiSelect" || fieldDef.type === "select" ? fieldDef.display : undefined;
167
+ // The renderer's heuristic counts options, so a filtered list would switch widgets with the discriminator.
168
+ const display = declaredDisplay ??
169
+ (fieldDef.type === "select" && fieldDef.conditionalOptions ? "dropdown" : undefined);
170
+ return {
171
+ ...(display !== undefined && { display }),
172
+ ...(fieldDef.type === "select" &&
173
+ fieldDef.optionTones !== undefined && { optionTones: fieldDef.optionTones }),
174
+ };
175
+ }
164
176
  function deriveSelectFieldHints(fieldDef, translate, featureName, entityName, fieldName, values) {
165
177
  const options = selectableOptions(fieldDef, fieldName, values);
166
178
  const optionLabels = options !== undefined
167
179
  ? buildOptionLabels(translate, (value) => fieldOptionLabelKey(featureName, entityName, fieldName, value), options)
168
180
  : undefined;
169
- const declaredDisplay = fieldDef.type === "multiSelect" || fieldDef.type === "select" ? fieldDef.display : undefined;
170
- // The renderer's heuristic counts options, so a filtered list would switch widgets with the discriminator.
171
- const display = declaredDisplay ??
172
- (fieldDef.type === "select" && fieldDef.conditionalOptions ? "dropdown" : undefined);
173
181
  const columns = fieldDef.type === "multiSelect" ? fieldDef.columns : undefined;
174
182
  const maxRows = fieldDef.type === "multiSelect" ? fieldDef.maxRows : undefined;
175
183
  return {
@@ -177,7 +185,7 @@ function deriveSelectFieldHints(fieldDef, translate, featureName, entityName, fi
177
185
  ...deriveSelectOptionsAvailabilityQuery(fieldDef),
178
186
  ...(options !== undefined && { options }),
179
187
  ...(optionLabels !== undefined && { optionLabels }),
180
- ...(display !== undefined && { display }),
188
+ ...deriveSelectDisplay(fieldDef),
181
189
  ...(columns !== undefined && { columns }),
182
190
  ...(maxRows !== undefined && { maxRows }),
183
191
  };
@@ -195,10 +203,6 @@ function deriveTextFieldHints(fieldDef) {
195
203
  ...("sensitive" in fieldDef && fieldDef.sensitive === true && { sensitive: true }),
196
204
  };
197
205
  }
198
- // files/images field defs carry no `required` property.
199
- function isRequiredByEntity(fieldDef) {
200
- return "required" in fieldDef && fieldDef.required === true;
201
- }
202
206
  function deriveDateFieldHints(fieldDef) {
203
207
  const wallClock = fieldDef.type === "timestamp" && fieldDef.locatedBy !== undefined ? true : undefined;
204
208
  const dateBounds = fieldDef.type === "date" ||
@@ -287,48 +291,16 @@ function deriveFileFieldHints(fieldDef, entityName, fieldName, entityId, actionF
287
291
  ...deriveImageHints(fieldDef),
288
292
  };
289
293
  }
290
- function deriveEmbeddedCellSelectHints(subField, translate, featureName, entityName, fieldName, subFieldName) {
291
- if (subField.type !== "select")
292
- return {};
293
- const options = subField.options ?? [];
294
- return {
295
- options,
296
- optionLabels: buildOptionLabels(translate, (value) => embeddedCellOptionLabelKey(featureName, entityName, fieldName, subFieldName, value), options),
297
- };
298
- }
299
- function deriveEmbeddedCellReferenceHints(subField, featureName) {
300
- if (subField.type !== "reference")
301
- return {};
302
- const target = subField.entity !== undefined ? parseRefTarget(subField.entity, featureName) : undefined;
303
- return {
304
- ...(target !== undefined && { refEntity: target.entityName, refFeature: target.featureName }),
305
- ...(subField.labelField !== undefined && { refLabelField: subField.labelField }),
306
- ...(subField.optionsQuery !== undefined && { refOptionsQuery: subField.optionsQuery }),
307
- };
308
- }
309
- function buildEmbeddedListCell(subFieldName, subField, translate, featureName, entityName, fieldName, listSpec) {
310
- return {
311
- field: subFieldName,
312
- label: translate(embeddedCellLabelKey(featureName, entityName, fieldName, subFieldName)),
313
- type: subField.type,
314
- required: subField.required === true,
315
- ...deriveEmbeddedCellSelectHints(subField, translate, featureName, entityName, fieldName, subFieldName),
316
- ...deriveEmbeddedCellReferenceHints(subField, featureName),
317
- ...(subField.type === "decimal" && subField.scale !== undefined && { scale: subField.scale }),
318
- ...(subField.type === "select" &&
319
- subField.display !== undefined && { display: subField.display }),
320
- ...(listSpec?.readOnlyCells?.includes(subFieldName) === true && { readOnly: true }),
321
- };
322
- }
323
294
  // Embedded-LIST field (`multiple: true`) — per-cell metadata for a renderer
324
295
  // to draw one row per array item (invoice-positions-style table). A plain
325
296
  // (non-list) embedded field emits none of this; the renderer tells the two
326
297
  // apart by whether embeddedListCells is set, not by `type` (which stays
327
298
  // "embedded" either way).
328
- function deriveEmbeddedListHints(fieldDef, translate, featureName, entityName, fieldName, resolvedCurrency, listSpec) {
299
+ function deriveEmbeddedListHints(fieldDef, translate, featureName, entityName, fieldName, resolvedCurrency, listSpec, entityId) {
329
300
  const embeddedListDef = fieldDef.type === "embedded" && fieldDef.multiple === true ? fieldDef : undefined;
301
+ const cellContext = { translate, featureName, entityName, fieldName, listSpec };
330
302
  const embeddedListCells = embeddedListDef !== undefined
331
- ? Object.entries(embeddedListDef.schema).map(([subFieldName, subField]) => buildEmbeddedListCell(subFieldName, subField, translate, featureName, entityName, fieldName, listSpec))
303
+ ? Object.entries(embeddedListDef.schema).map(([subFieldName, subField]) => buildEmbeddedListCell(subFieldName, subField, cellContext))
332
304
  : undefined;
333
305
  return {
334
306
  ...(embeddedListCells !== undefined && { embeddedListCells }),
@@ -349,6 +321,14 @@ function deriveEmbeddedListHints(fieldDef, translate, featureName, entityName, f
349
321
  ...(embeddedListDef !== undefined && { embeddedListCurrency: resolvedCurrency }),
350
322
  ...(embeddedListDef !== undefined &&
351
323
  listSpec?.fixedRows === true && { embeddedListFixedRows: true }),
324
+ ...(embeddedListCells?.some((cell) => cell.type === "images" || cell.type === "files") ===
325
+ true && {
326
+ embeddedListUploadTarget: {
327
+ entityType: entityName,
328
+ fieldName,
329
+ ...(entityId !== undefined && { entityId }),
330
+ },
331
+ }),
352
332
  };
353
333
  }
354
334
  function buildExtensionSectionViewModel(sectionSpec, translate) {
@@ -378,6 +358,17 @@ function isLockedByDerivation(derivation, values, field) {
378
358
  !isFormDerivationInputMode(derivation, values, field);
379
359
  return isDerivedAndLocked || isUnusedFallbackOperand(derivation, values, field);
380
360
  }
361
+ // A screen-spec condition overrides the entity's own visibleWhen / required / requiredWhen.
362
+ function isEditFieldVisible(normalized, fieldDef, values) {
363
+ return normalized.visible !== undefined
364
+ ? evalFieldCondition(normalized.visible, values)
365
+ : isFieldVisible(fieldDef, values);
366
+ }
367
+ function isEditFieldRequired(normalized, fieldDef, values) {
368
+ return normalized.required !== undefined
369
+ ? evalFieldCondition(normalized.required, values)
370
+ : isFieldRequired(fieldDef, values);
371
+ }
381
372
  export function computeEditViewModel(input) {
382
373
  const { screen, entity, values, translate, featureName, mode, entityId, derivation, actionFormScreenQn, } = input;
383
374
  const excludedFields = new Set(mode === undefined ? [] : screen.writeExcludedFields?.[mode]);
@@ -419,7 +410,7 @@ export function computeEditViewModel(input) {
419
410
  const effectiveType = declaredRefTarget !== undefined ? "reference" : fieldDef.type;
420
411
  const label = translate(screen.fieldLabels?.[normalized.field] ??
421
412
  fieldLabelKey(featureName, screen.entity, normalized.field));
422
- const visible = evalCondition(normalized.visible, true, values);
413
+ const visible = isEditFieldVisible(normalized, fieldDef, values);
423
414
  // `readOnly` (camelCase) is the name on both sides: EditFieldSpec
424
415
  // in the engine, and the view-model emitted here. One convention
425
416
  // through the stack beats translating at the boundary.
@@ -431,8 +422,7 @@ export function computeEditViewModel(input) {
431
422
  // field that's required at the entity-level but marked required:
432
423
  // false on the screen (e.g. a soft-onboarding wizard that
433
424
  // collects less up-front) respects the screen override.
434
- const entityRequired = isRequiredByEntity(fieldDef);
435
- const required = !lockedByDerivation && evalCondition(normalized.required, entityRequired, values);
425
+ const required = !lockedByDerivation && isEditFieldRequired(normalized, fieldDef, values);
436
426
  // ponytail: "EUR" mirrors DEFAULT_CURRENCIES[0] from
437
427
  // framework/src/engine/field-helpers.ts — headless has no dependency
438
428
  // on that module, so the literal is duplicated here instead of
@@ -444,7 +434,7 @@ export function computeEditViewModel(input) {
444
434
  const numericHints = deriveNumericFieldHints(fieldDef, resolvedCurrency);
445
435
  const referenceHints = deriveReferenceFieldHints(fieldDef, normalized, declaredRefTarget, featureName);
446
436
  const fileHints = deriveFileFieldHints(fieldDef, screen.entity, normalized.field, entityId, actionFormScreenQn);
447
- const embeddedListHints = deriveEmbeddedListHints(fieldDef, translate, featureName, screen.entity, normalized.field, resolvedCurrency, normalized.embeddedList);
437
+ const embeddedListHints = deriveEmbeddedListHints(fieldDef, translate, featureName, screen.entity, normalized.field, resolvedCurrency, normalized.embeddedList, entityId);
448
438
  const view = {
449
439
  field: normalized.field,
450
440
  label,
@@ -0,0 +1,12 @@
1
+ import type { EmbeddedListEditSpec } from "@cosmicdrift/kumiko-framework/ui-types";
2
+ import type { EmbeddedSubFieldDef } from "@cosmicdrift/kumiko-types/fields";
3
+ import type { EmbeddedListCellViewModel, Translate } from "./types.ts";
4
+ type CellContext = {
5
+ readonly translate: Translate;
6
+ readonly featureName: string;
7
+ readonly entityName: string;
8
+ readonly fieldName: string;
9
+ readonly listSpec: EmbeddedListEditSpec | undefined;
10
+ };
11
+ export declare function buildEmbeddedListCell(subFieldName: string, subField: EmbeddedSubFieldDef, ctx: CellContext): EmbeddedListCellViewModel;
12
+ export {};
@@ -0,0 +1,49 @@
1
+ import { parseRefTarget } from "@cosmicdrift/kumiko-framework/ui-types";
2
+ import { buildOptionLabels, embeddedCellLabelKey, embeddedCellOptionLabelKey } from "./list.js";
3
+ function selectHints(subField, subFieldName, ctx) {
4
+ if (subField.type !== "select")
5
+ return {};
6
+ return {
7
+ options: subField.options,
8
+ optionLabels: buildOptionLabels(ctx.translate, (value) => embeddedCellOptionLabelKey(ctx.featureName, ctx.entityName, ctx.fieldName, subFieldName, value), subField.options),
9
+ ...(subField.display !== undefined && { display: subField.display }),
10
+ ...(subField.optionTones !== undefined && { optionTones: subField.optionTones }),
11
+ };
12
+ }
13
+ function referenceHints(subField, featureName) {
14
+ if (subField.type !== "reference")
15
+ return {};
16
+ const target = parseRefTarget(subField.entity, featureName);
17
+ return {
18
+ refEntity: target.entityName,
19
+ refFeature: target.featureName,
20
+ ...(subField.labelField !== undefined && { refLabelField: subField.labelField }),
21
+ ...(subField.optionsQuery !== undefined && { refOptionsQuery: subField.optionsQuery }),
22
+ };
23
+ }
24
+ function fileHints(subField) {
25
+ if (subField.type !== "images" && subField.type !== "files")
26
+ return {};
27
+ return {
28
+ ...(subField.maxCount !== undefined && { maxCount: subField.maxCount }),
29
+ ...(subField.accept !== undefined && { accept: subField.accept }),
30
+ ...(subField.type === "images" &&
31
+ subField.capture !== undefined && { capture: subField.capture }),
32
+ };
33
+ }
34
+ export function buildEmbeddedListCell(subFieldName, subField, ctx) {
35
+ return {
36
+ field: subFieldName,
37
+ label: ctx.translate(embeddedCellLabelKey(ctx.featureName, ctx.entityName, ctx.fieldName, subFieldName)),
38
+ type: subField.type,
39
+ required: subField.required === true,
40
+ ...(subField.visibleWhen !== undefined && { visibleWhen: subField.visibleWhen }),
41
+ ...(subField.requiredWhen !== undefined && { requiredWhen: subField.requiredWhen }),
42
+ ...selectHints(subField, subFieldName, ctx),
43
+ ...referenceHints(subField, ctx.featureName),
44
+ ...(subField.type === "decimal" && subField.scale !== undefined && { scale: subField.scale }),
45
+ ...fileHints(subField),
46
+ ...(ctx.listSpec?.readOnlyCells?.includes(subFieldName) === true && { readOnly: true }),
47
+ ...(ctx.listSpec?.cardHiddenCells?.includes(subFieldName) === true && { hiddenInCard: true }),
48
+ };
49
+ }
@@ -2,7 +2,7 @@ import type { EmbeddedDerivedCellDef } from "@cosmicdrift/kumiko-framework/ui-ty
2
2
  import type { FieldIssue } from "../dispatcher/index.ts";
3
3
  export type EmbeddedDerivedOp = EmbeddedDerivedCellDef["op"];
4
4
  export type { DerivedCellRoundingTarget } from "@cosmicdrift/kumiko-framework/ui-types";
5
- export { computeDerivedCellValue, roundDerivedCellValue, } from "@cosmicdrift/kumiko-framework/ui-types";
5
+ export { computeDerivedCellValue, roundDerivedCellValue, withDerivedCells, } from "@cosmicdrift/kumiko-framework/ui-types";
6
6
  /** Sums a numeric/money/decimal column across all rows. Non-numeric or
7
7
  * missing values count as 0. Money columns are minor-unit integers —
8
8
  * the sum stays in minor units, never rounds through a major-unit float. */
@@ -1,6 +1,6 @@
1
1
  // Canonical implementation moved to packages/framework/src/engine/embedded-derived.ts
2
2
  // (the write-schema preprocess needs it server-side too).
3
- export { computeDerivedCellValue, roundDerivedCellValue, } from "@cosmicdrift/kumiko-framework/ui-types";
3
+ export { computeDerivedCellValue, roundDerivedCellValue, withDerivedCells, } from "@cosmicdrift/kumiko-framework/ui-types";
4
4
  /** Sums a numeric/money/decimal column across all rows. Non-numeric or
5
5
  * missing values count as 0. Money columns are minor-unit integers —
6
6
  * the sum stays in minor units, never rounds through a major-unit float. */
@@ -1,7 +1,7 @@
1
1
  export type { ComputeEditViewModelInput, EditFormMode } from "./edit.ts";
2
2
  export { computeEditViewModel, computeRelatedListSectionViewModel } from "./edit.ts";
3
3
  export type { DerivedCellRoundingTarget, EmbeddedDerivedOp, EmbeddedListIssueGroups, } from "./embedded-list.ts";
4
- export { computeDerivedCellValue, groupEmbeddedListIssues, roundDerivedCellValue, sumEmbeddedListColumn, } from "./embedded-list.ts";
4
+ export { computeDerivedCellValue, groupEmbeddedListIssues, roundDerivedCellValue, sumEmbeddedListColumn, withDerivedCells, } from "./embedded-list.ts";
5
5
  export type { ComputeListViewModelInput } from "./list.ts";
6
6
  export { buildOptionLabels, computeListViewModel, embeddedCellLabelKey, embeddedCellOptionLabelKey, fieldLabelKey, fieldOptionLabelKey, fieldOptionLabelKeyPrefix, } from "./list.ts";
7
7
  export type { CellLink, CellLinkItem, EditExtensionSectionViewModel, EditFieldSpec, EditFieldsSectionViewModel, EditFieldViewModel, EditRelatedListSectionViewModel, EditSectionSpec, EditSectionViewModel, EditViewModel, EditWriteFormSectionViewModel, EmbeddedListCellViewModel, FieldConditionCtx, FieldRenderer, ListColumnFileDisplay, ListColumnSpec, ListColumnViewModel, ListRowViewModel, ListViewModel, RuntimeCellLink, RuntimeCellLinkItems, RuntimeRenderer, ScreenSlots, Translate, } from "./types.ts";
@@ -1,3 +1,3 @@
1
1
  export { computeEditViewModel, computeRelatedListSectionViewModel } from "./edit.js";
2
- export { computeDerivedCellValue, groupEmbeddedListIssues, roundDerivedCellValue, sumEmbeddedListColumn, } from "./embedded-list.js";
2
+ export { computeDerivedCellValue, groupEmbeddedListIssues, roundDerivedCellValue, sumEmbeddedListColumn, withDerivedCells, } from "./embedded-list.js";
3
3
  export { buildOptionLabels, computeListViewModel, embeddedCellLabelKey, embeddedCellOptionLabelKey, fieldLabelKey, fieldOptionLabelKey, fieldOptionLabelKeyPrefix, } from "./list.js";
@@ -136,9 +136,8 @@ function derivedFieldColumn(input, column) {
136
136
  field: column.field,
137
137
  label: translatedColumnLabel(input, column),
138
138
  type: derivedDef.valueType,
139
- // Display-only: a header sort would round-trip to the server, which has
140
- // no column to sort by (see DerivedFieldDef). Never offer the affordance.
141
- sortable: false,
139
+ // The client schema carries only `sortable` (never `sql`), so test the flag, not the SQL shape.
140
+ sortable: "sortable" in derivedDef && derivedDef.sortable === true,
142
141
  ...(column.renderer !== undefined && { renderer: column.renderer }),
143
142
  ...(column.hideOnNarrow === true && { hideOnNarrow: true }),
144
143
  };
@@ -1,4 +1,4 @@
1
- import type { EditFieldSpec, EditSectionSpec, FieldAffix, FieldFillAction, FieldIconKey, FieldRenderer, IconKey, ListBulkAction, ListColumnSpec, ListFacetSpec, ListSortSpec, ListTableOptions, OptionsQueryPayload, PlatformComponent, RelatedListEmptyStateVariant, RelatedListRowTone, RelatedListToolbarAction, RowAction, RowActionMode, ScreenSlots, SelectOptionTone } from "@cosmicdrift/kumiko-framework/ui-types";
1
+ import type { EditFieldSpec, EditSectionSpec, EmbeddedDerivedCellDef, FieldAffix, FieldCondition, FieldFillAction, FieldIconKey, FieldRenderer, IconKey, ListBulkAction, ListColumnSpec, ListFacetSpec, ListSortSpec, ListTableOptions, OptionsQueryPayload, PlatformComponent, RelatedListEmptyStateVariant, RelatedListRowTone, RelatedListToolbarAction, RowAction, RowActionMode, ScreenSlots, SelectOptionTone } from "@cosmicdrift/kumiko-framework/ui-types";
2
2
  export type RuntimeRenderer = (value: unknown, row?: Readonly<Record<string, unknown>>) => string;
3
3
  /** Where a cell's text links to. `href` comes from NavApi.hrefFor (new-tab and
4
4
  * copy-link work); `onPress` is the in-app navigation for a plain click. */
@@ -79,15 +79,26 @@ export type ListViewModel = {
79
79
  export type EmbeddedListCellViewModel = {
80
80
  readonly field: string;
81
81
  readonly label: string;
82
- readonly type: "text" | "number" | "boolean" | "date" | "money" | "decimal" | "select" | "reference" | "timestamp";
82
+ readonly type: "text" | "number" | "boolean" | "date" | "money" | "decimal" | "select" | "reference" | "timestamp" | "images" | "files";
83
83
  readonly required: boolean;
84
+ /** Per-row state: hidden / required for a row where the condition holds. */
85
+ readonly visibleWhen?: Exclude<FieldCondition, boolean>;
86
+ readonly requiredWhen?: Exclude<FieldCondition, boolean>;
84
87
  /** Only for `type: "select"`. */
85
88
  readonly options?: readonly string[];
89
+ /** Only for `type: "select"`; tone of each option's badge or selected segment. */
90
+ readonly optionTones?: Readonly<Partial<Record<string, SelectOptionTone>>>;
91
+ /** Only for `type: "images" | "files"`. */
92
+ readonly capture?: "user" | "environment";
93
+ readonly maxCount?: number;
94
+ readonly accept?: readonly string[];
86
95
  readonly optionLabels?: Readonly<Record<string, string>>;
87
96
  /** Only for `type: "select"`; `"radio"` renders segmented buttons instead of a dropdown. */
88
97
  readonly display?: "radio" | "dropdown";
89
98
  /** Cell is shown but not editable (`EditFieldSpec.embeddedList.readOnlyCells`). */
90
99
  readonly readOnly?: boolean;
100
+ /** Not shown on phone cards (`EditFieldSpec.embeddedList.cardHiddenCells`). */
101
+ readonly hiddenInCard?: boolean;
91
102
  /** Only for `type: "reference"`. */
92
103
  readonly refEntity?: string;
93
104
  readonly refFeature?: string;
@@ -140,6 +151,8 @@ export type EditFieldViewModel = {
140
151
  * `type: "select"` when SelectFieldDef.display is "radio" or "dropdown" —
141
152
  * the renderer then skips its own radio-vs-dropdown heuristic. */
142
153
  readonly display?: "checkboxes" | "dropdown" | "radio";
154
+ /** `type: "select"` only: tone per option, shown on the selected radio segment. */
155
+ readonly optionTones?: Readonly<Partial<Record<string, SelectOptionTone>>>;
143
156
  /** Set for `type: "multiSelect"` when MultiSelectFieldDef.columns is
144
157
  * defined — column count for the checkbox grid at the widest breakpoint.
145
158
  * Ignored unless `display` is "checkboxes". */
@@ -249,10 +262,7 @@ export type EditFieldViewModel = {
249
262
  readonly embeddedListMaxItems?: number;
250
263
  /** Sub-field name → how to compute its cell from other sub-fields of the
251
264
  * same row. From EmbeddedFieldDef.derived. */
252
- readonly embeddedListDerived?: Readonly<Record<string, {
253
- readonly op: "multiply" | "sum" | "subtract" | "percent";
254
- readonly from: readonly string[];
255
- }>>;
265
+ readonly embeddedListDerived?: Readonly<Record<string, EmbeddedDerivedCellDef>>;
256
266
  /** Footer totals: a sub-field name, or a labelled sum of several. From EmbeddedFieldDef.totals. */
257
267
  readonly embeddedListTotals?: readonly (string | {
258
268
  readonly label: string;
@@ -261,6 +271,13 @@ export type EditFieldViewModel = {
261
271
  /** No add, remove, duplicate or move; cells stay editable. From
262
272
  * EditFieldSpec.embeddedList.fixedRows. */
263
273
  readonly embeddedListFixedRows?: boolean;
274
+ /** Where photos of a row's file cells are uploaded: the entity and the top-level list field;
275
+ * the cell name travels as `subField`. Set when the list has a file cell. */
276
+ readonly embeddedListUploadTarget?: {
277
+ readonly entityType: string;
278
+ readonly fieldName: string;
279
+ readonly entityId?: string;
280
+ };
264
281
  /** Currency for the whole embedded list's money cells and totals row —
265
282
  * currency lives on the head aggregate (EntityDefinition.defaultCurrency),
266
283
  * not per row. Falls back to "EUR" when the entity has no defaultCurrency. */
@@ -0,0 +1,2 @@
1
+ import type { EditRelatedListSection } from "@cosmicdrift/kumiko-framework/ui-types";
2
+ export declare function buildRelatedListParentPayload(section: Pick<EditRelatedListSection, "parentFilter" | "parentParam">, parentId: string): Readonly<Record<string, unknown>>;
@@ -0,0 +1,7 @@
1
+ // `parentFilter` sends the parent id as a server-side `filter` clause: the generic
2
+ // `<entity>:list` query understands `filter`, not an arbitrary `parentParam` key.
3
+ export function buildRelatedListParentPayload(section, parentId) {
4
+ return section.parentFilter !== undefined
5
+ ? { filter: { field: section.parentFilter.field, op: "eq", value: parentId } }
6
+ : { [section.parentParam ?? "id"]: parentId };
7
+ }
@@ -50,6 +50,9 @@ export const kumikoDefaultTranslations = {
50
50
  "kumiko.field.writeOnly.willRemove": "Removed on save",
51
51
  "kumiko.field.embedded-list.actions": "Actions",
52
52
  "kumiko.field.embedded-list.cell-label": "{column}, row {row}",
53
+ "kumiko.field.embedded-list.limit-range": "Limit {min} to {max}",
54
+ "kumiko.field.embedded-list.limit-min": "At least {min}",
55
+ "kumiko.field.embedded-list.limit-max": "At most {max}",
53
56
  "kumiko.field.file-list.add": "Add file",
54
57
  "kumiko.field.file-list.move-up": "Move file up",
55
58
  "kumiko.field.file-list.move-down": "Move file down",