@cosmicdrift/kumiko-renderer 0.399.0 → 0.401.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 (75) 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/components/use-reference-column-lookups.d.ts +6 -0
  20. package/dist/components/use-reference-column-lookups.js +36 -0
  21. package/dist/components/use-render-list-columns.d.ts +2 -3
  22. package/dist/components/use-render-list-columns.js +4 -31
  23. package/dist/embedded-list-types.d.ts +117 -0
  24. package/dist/embedded-list-types.js +1 -0
  25. package/dist/headless/form/form-controller.js +9 -1
  26. package/dist/headless/index.d.ts +1 -1
  27. package/dist/headless/index.js +1 -1
  28. package/dist/headless/view-model/edit.js +40 -50
  29. package/dist/headless/view-model/embedded-list-cells.d.ts +12 -0
  30. package/dist/headless/view-model/embedded-list-cells.js +49 -0
  31. package/dist/headless/view-model/embedded-list.d.ts +1 -1
  32. package/dist/headless/view-model/embedded-list.js +1 -1
  33. package/dist/headless/view-model/index.d.ts +1 -1
  34. package/dist/headless/view-model/index.js +1 -1
  35. package/dist/headless/view-model/list.js +2 -3
  36. package/dist/headless/view-model/types.d.ts +23 -6
  37. package/dist/hooks/related-list-parent-payload.d.ts +2 -0
  38. package/dist/hooks/related-list-parent-payload.js +7 -0
  39. package/dist/i18n-defaults.js +3 -0
  40. package/dist/primitives.d.ts +4 -88
  41. package/dist/web/app/dashboard-body.js +3 -3
  42. package/dist/web/app/dashboard-panels.d.ts +2 -1
  43. package/dist/web/app/dashboard-panels.js +11 -3
  44. package/dist/web/app/dashboard-reference-list-table.d.ts +17 -0
  45. package/dist/web/app/dashboard-reference-list-table.js +47 -0
  46. package/dist/web/lib/file-upload-form-data.d.ts +2 -0
  47. package/dist/web/lib/file-upload-form-data.js +2 -0
  48. package/dist/web/lib/upload-file-ref.d.ts +1 -1
  49. package/dist/web/primitives/data-table-cell.d.ts +2 -0
  50. package/dist/web/primitives/data-table-cell.js +1 -1
  51. package/dist/web/primitives/embedded-file-cell.d.ts +11 -0
  52. package/dist/web/primitives/embedded-file-cell.js +50 -0
  53. package/dist/web/primitives/embedded-list-cards.d.ts +38 -0
  54. package/dist/web/primitives/embedded-list-cards.js +71 -0
  55. package/dist/web/primitives/embedded-list-cell.d.ts +41 -0
  56. package/dist/web/primitives/embedded-list-cell.js +132 -0
  57. package/dist/web/primitives/embedded-list-input.d.ts +1 -1
  58. package/dist/web/primitives/embedded-list-input.js +29 -142
  59. package/dist/web/primitives/file-upload.d.ts +2 -1
  60. package/dist/web/primitives/file-upload.js +2 -1
  61. package/dist/web/primitives/input-choice-kinds.js +1 -1
  62. package/dist/web/primitives/input-optional-props.d.ts +2 -1
  63. package/dist/web/primitives/input-optional-props.js +2 -1
  64. package/dist/web/primitives/locale-number.d.ts +3 -0
  65. package/dist/web/primitives/locale-number.js +8 -0
  66. package/dist/web/primitives/number-input.d.ts +2 -1
  67. package/dist/web/primitives/number-input.js +4 -11
  68. package/dist/web/primitives/segmented-select.d.ts +6 -1
  69. package/dist/web/primitives/segmented-select.js +8 -4
  70. package/dist/web/widgets/dashboard-list.d.ts +2 -0
  71. package/dist/web/widgets/dashboard-list.js +2 -0
  72. package/dist/web/widgets/status-badge.d.ts +1 -0
  73. package/dist/web/widgets/status-badge.js +1 -1
  74. package/package.json +5 -5
  75. package/src/changes.json +6 -0
@@ -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,6 @@
1
+ import { type PublishedReferenceChunk, type PublishedReferenceLookup, type ReferenceColumnSpec, type ReferenceLookupRequest } from "./render-list-reference-columns.ts";
2
+ export declare function useReferenceColumnLookups(rows: readonly Readonly<Record<string, unknown>>[], referenceColumns: readonly ReferenceColumnSpec[]): {
3
+ referenceRequests: readonly ReferenceLookupRequest[];
4
+ handleLookupMap: (field: string, chunkIndex: number, lookup: PublishedReferenceChunk) => void;
5
+ referenceLookups: Record<string, PublishedReferenceLookup>;
6
+ };
@@ -0,0 +1,36 @@
1
+ import { useCallback, useMemo, useState } from "react";
2
+ import { REFERENCE_LIST_LOOKUP_LIMIT } from "../hooks/reference-limits.js";
3
+ import { mergeChunkLookups, referenceIdChunks, } from "./render-list-reference-columns.js";
4
+ // Bridge components (renderReferenceLookupBridges) publish each id chunk's
5
+ // lookup through handleLookupMap; the merged result feeds enrichReferenceColumns.
6
+ export function useReferenceColumnLookups(rows, referenceColumns) {
7
+ const referenceRequests = useMemo(() => referenceColumns.map((column) => ({
8
+ ...column,
9
+ idChunks: referenceIdChunks(rows, column.field, REFERENCE_LIST_LOOKUP_LIMIT),
10
+ })), [referenceColumns, rows]);
11
+ const [chunkLookups, setChunkLookups] = useState({});
12
+ const handleLookupMap = useCallback((field, chunkIndex, lookup) => {
13
+ setChunkLookups((prev) => {
14
+ // Skip the update when the lookup is identical. Otherwise the bridge
15
+ // would re-render and send the same map back until React entered
16
+ // an infinite loop.
17
+ const current = prev[field]?.[chunkIndex];
18
+ const unchanged = current?.map === lookup.map &&
19
+ current.complete === lookup.complete &&
20
+ current.idsKey === lookup.idsKey;
21
+ if (unchanged)
22
+ return prev;
23
+ return { ...prev, [field]: { ...prev[field], [chunkIndex]: lookup } };
24
+ });
25
+ }, []);
26
+ const referenceLookups = useMemo(() => {
27
+ const merged = {};
28
+ for (const request of referenceRequests) {
29
+ const lookup = mergeChunkLookups(request.idChunks, chunkLookups[request.field]);
30
+ if (lookup !== undefined)
31
+ merged[request.field] = lookup;
32
+ }
33
+ return merged;
34
+ }, [referenceRequests, chunkLookups]);
35
+ return { referenceRequests, handleLookupMap, referenceLookups };
36
+ }
@@ -1,7 +1,6 @@
1
1
  import type { EntityDefinition, EntityListScreenDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
2
2
  import { type ScreenLinkResolver } from "../app/reference-link.ts";
3
3
  import type { ListColumnViewModel, ListViewModel, Translate } from "../headless/index.ts";
4
- import { type PublishedReferenceChunk, type ReferenceLookupRequest } from "./render-list-reference-columns.ts";
5
4
  export declare function withScreenLinks(columns: readonly ListColumnViewModel[], screen: EntityListScreenDefinition, resolveScreenLink: ScreenLinkResolver): readonly ListColumnViewModel[];
6
5
  type RenderListColumnsInput = {
7
6
  readonly screen: EntityListScreenDefinition;
@@ -11,8 +10,8 @@ type RenderListColumnsInput = {
11
10
  readonly translate: Translate;
12
11
  };
13
12
  export declare function useRenderListColumns(input: RenderListColumnsInput): {
14
- referenceRequests: readonly ReferenceLookupRequest[];
15
- handleLookupMap: (field: string, chunkIndex: number, lookup: PublishedReferenceChunk) => void;
13
+ referenceRequests: readonly import("./render-list-reference-columns.ts").ReferenceLookupRequest[];
14
+ handleLookupMap: (field: string, chunkIndex: number, lookup: import("./render-list-reference-columns.ts").PublishedReferenceChunk) => void;
16
15
  enrichedVm: Omit<ListViewModel, "columns"> & {
17
16
  columns: readonly ListColumnViewModel[];
18
17
  };
@@ -1,9 +1,9 @@
1
1
  import { normalizeListColumn } from "@cosmicdrift/kumiko-framework/ui-types";
2
- import { useCallback, useMemo, useState } from "react";
2
+ import { useMemo } from "react";
3
3
  import { useReferenceLinkResolver, useScreenLinkResolver, } from "../app/reference-link.js";
4
4
  import { computeListViewModel } from "../headless/index.js";
5
- import { REFERENCE_LIST_LOOKUP_LIMIT } from "../hooks/reference-limits.js";
6
- import { enrichReferenceColumns, mergeChunkLookups, referenceIdChunks, selectReferenceColumns, } from "./render-list-reference-columns.js";
5
+ import { enrichReferenceColumns, selectReferenceColumns } from "./render-list-reference-columns.js";
6
+ import { useReferenceColumnLookups } from "./use-reference-column-lookups.js";
7
7
  // A column whose rows name their own target screen links each cell there.
8
8
  export function withScreenLinks(columns, screen, resolveScreenLink) {
9
9
  const linkFields = new Map();
@@ -38,34 +38,7 @@ export function useRenderListColumns(input) {
38
38
  // through onMap. Each bridge makes a constant number of hook calls (one),
39
39
  // and React manages its lifecycle when the schema changes via key.
40
40
  const referenceColumns = useMemo(() => selectReferenceColumns(vm.columns, featureName), [vm.columns, featureName]);
41
- const referenceRequests = useMemo(() => referenceColumns.map((column) => ({
42
- ...column,
43
- idChunks: referenceIdChunks(rows, column.field, REFERENCE_LIST_LOOKUP_LIMIT),
44
- })), [referenceColumns, rows]);
45
- const [chunkLookups, setChunkLookups] = useState({});
46
- const handleLookupMap = useCallback((field, chunkIndex, lookup) => {
47
- setChunkLookups((prev) => {
48
- // Skip the update when the lookup is identical. Otherwise the bridge
49
- // would re-render and send the same map back until React entered
50
- // an infinite loop.
51
- const current = prev[field]?.[chunkIndex];
52
- const unchanged = current?.map === lookup.map &&
53
- current.complete === lookup.complete &&
54
- current.idsKey === lookup.idsKey;
55
- if (unchanged)
56
- return prev;
57
- return { ...prev, [field]: { ...prev[field], [chunkIndex]: lookup } };
58
- });
59
- }, []);
60
- const referenceLookups = useMemo(() => {
61
- const merged = {};
62
- for (const request of referenceRequests) {
63
- const lookup = mergeChunkLookups(request.idChunks, chunkLookups[request.field]);
64
- if (lookup !== undefined)
65
- merged[request.field] = lookup;
66
- }
67
- return merged;
68
- }, [referenceRequests, chunkLookups]);
41
+ const { referenceRequests, handleLookupMap, referenceLookups } = useReferenceColumnLookups(rows, referenceColumns);
69
42
  const resolveReferenceLink = useReferenceLinkResolver();
70
43
  const resolveScreenLink = useScreenLinkResolver();
71
44
  const enrichedColumns = useMemo(() => withScreenLinks(enrichReferenceColumns({
@@ -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
  };