@cosmicdrift/kumiko-headless 0.328.0 → 0.328.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/dist/apex/css.d.ts +3 -0
  2. package/{src/apex/css.ts → dist/apex/css.js} +11 -24
  3. package/dist/apex/index.d.ts +229 -0
  4. package/dist/apex/index.js +360 -0
  5. package/dist/apex/lightbox.d.ts +4 -0
  6. package/{src/apex/lightbox.ts → dist/apex/lightbox.js} +0 -4
  7. package/dist/contracts/asset.d.ts +15 -0
  8. package/dist/contracts/asset.js +17 -0
  9. package/dist/contracts/index.d.ts +3 -0
  10. package/dist/contracts/index.js +1 -0
  11. package/dist/contracts/locale.d.ts +7 -0
  12. package/dist/contracts/locale.js +11 -0
  13. package/dist/contracts/primitives.d.ts +93 -0
  14. package/dist/contracts/primitives.js +21 -0
  15. package/dist/dispatcher/index.d.ts +2 -0
  16. package/dist/dispatcher/index.js +1 -0
  17. package/dist/dispatcher/types.d.ts +86 -0
  18. package/dist/dispatcher/types.js +34 -0
  19. package/dist/form/form-controller.d.ts +2 -0
  20. package/dist/form/form-controller.js +404 -0
  21. package/dist/form/index.d.ts +3 -0
  22. package/dist/form/index.js +2 -0
  23. package/dist/form/types.d.ts +71 -0
  24. package/dist/form/types.js +1 -0
  25. package/dist/form/zod-bridge.d.ts +5 -0
  26. package/{src/form/zod-bridge.ts → dist/form/zod-bridge.js} +52 -58
  27. package/dist/format/escape.d.ts +5 -0
  28. package/{src/format/escape.ts → dist/format/escape.js} +43 -47
  29. package/dist/format/html-template.d.ts +9 -0
  30. package/dist/format/html-template.js +39 -0
  31. package/dist/format/index.d.ts +10 -0
  32. package/dist/format/index.js +192 -0
  33. package/dist/format/money.d.ts +1 -0
  34. package/{src/format/money.ts → dist/format/money.js} +6 -5
  35. package/dist/index.d.ts +14 -0
  36. package/dist/index.js +9 -0
  37. package/dist/locale-routing/index.d.ts +39 -0
  38. package/dist/locale-routing/index.js +143 -0
  39. package/dist/nav/index.d.ts +2 -0
  40. package/dist/nav/index.js +1 -0
  41. package/dist/nav/resolve.d.ts +2 -0
  42. package/dist/nav/resolve.js +58 -0
  43. package/dist/nav/types.d.ts +26 -0
  44. package/dist/nav/types.js +1 -0
  45. package/dist/store/create-store.d.ts +2 -0
  46. package/{src/store/create-store.ts → dist/store/create-store.js} +21 -23
  47. package/dist/store/equality.d.ts +1 -0
  48. package/{src/store/equality.ts → dist/store/equality.js} +19 -17
  49. package/dist/store/index.d.ts +3 -0
  50. package/dist/store/index.js +2 -0
  51. package/dist/store/types.d.ts +7 -0
  52. package/dist/store/types.js +10 -0
  53. package/dist/view-model/edit.d.ts +10 -0
  54. package/dist/view-model/edit.js +392 -0
  55. package/dist/view-model/embedded-list.d.ts +27 -0
  56. package/dist/view-model/embedded-list.js +63 -0
  57. package/dist/view-model/index.d.ts +7 -0
  58. package/dist/view-model/index.js +3 -0
  59. package/dist/view-model/list.d.ts +16 -0
  60. package/dist/view-model/list.js +201 -0
  61. package/dist/view-model/types.d.ts +300 -0
  62. package/dist/view-model/types.js +1 -0
  63. package/package.json +31 -12
  64. package/src/apex/__tests__/lightbox.test.ts +0 -20
  65. package/src/apex/__tests__/page-head.test.ts +0 -63
  66. package/src/apex/__tests__/render.test.ts +0 -401
  67. package/src/apex/index.ts +0 -667
  68. package/src/contracts/__tests__/contracts.test.ts +0 -123
  69. package/src/contracts/__tests__/field-issue-compat.test.ts +0 -19
  70. package/src/contracts/asset.ts +0 -48
  71. package/src/contracts/index.ts +0 -23
  72. package/src/contracts/locale.ts +0 -33
  73. package/src/contracts/primitives.ts +0 -158
  74. package/src/dispatcher/__tests__/contract.test.ts +0 -163
  75. package/src/dispatcher/index.ts +0 -16
  76. package/src/dispatcher/types.ts +0 -217
  77. package/src/form/__tests__/conditional-fields.test.ts +0 -177
  78. package/src/form/__tests__/form-controller.test.ts +0 -231
  79. package/src/form/__tests__/submit.test.ts +0 -575
  80. package/src/form/__tests__/validation.test.ts +0 -274
  81. package/src/form/form-controller.ts +0 -458
  82. package/src/form/index.ts +0 -15
  83. package/src/form/types.ts +0 -297
  84. package/src/format/__tests__/escape.test.ts +0 -79
  85. package/src/format/__tests__/format.test.ts +0 -285
  86. package/src/format/__tests__/html-template.test.ts +0 -47
  87. package/src/format/html-template.ts +0 -49
  88. package/src/format/index.ts +0 -223
  89. package/src/index.ts +0 -118
  90. package/src/locale-routing/__tests__/locale-routing.test.ts +0 -276
  91. package/src/locale-routing/index.ts +0 -218
  92. package/src/nav/__tests__/resolve.test.ts +0 -279
  93. package/src/nav/index.ts +0 -8
  94. package/src/nav/resolve.ts +0 -60
  95. package/src/nav/types.ts +0 -70
  96. package/src/store/__tests__/create-store.test.ts +0 -139
  97. package/src/store/__tests__/equality.test.ts +0 -66
  98. package/src/store/index.ts +0 -3
  99. package/src/store/types.ts +0 -27
  100. package/src/view-model/__tests__/edit.test.ts +0 -1072
  101. package/src/view-model/__tests__/embedded-list.test.ts +0 -96
  102. package/src/view-model/__tests__/field-labels.test.ts +0 -62
  103. package/src/view-model/__tests__/list.test.ts +0 -439
  104. package/src/view-model/edit.ts +0 -645
  105. package/src/view-model/embedded-list.ts +0 -89
  106. package/src/view-model/index.ts +0 -43
  107. package/src/view-model/list.ts +0 -280
  108. package/src/view-model/types.ts +0 -400
@@ -0,0 +1,201 @@
1
+ import { LIST_ROW_META_COLUMNS, LIST_ROW_META_REFERENCES, normalizeListColumn, parseRefTarget, } from "@cosmicdrift/kumiko-framework/ui-types";
2
+ // Pure transform: takes the declared screen + entity + incoming rows, spits
3
+ // out the flat shape the renderer draws. No conditions, no access-checks —
4
+ // those are list-level decisions that the caller makes BEFORE calling
5
+ // here (e.g. filtering rows by ownership on the server / before query).
6
+ // Field-level read-access (Level 3 of UI-architecture.md §Permission) is a
7
+ // follow-up; this v1 assumes the caller passed a row-filter that drops any
8
+ // field the user may not see.
9
+ export function computeListViewModel(input) {
10
+ const { screen, entity, rows, translate, featureName } = input;
11
+ const columns = [];
12
+ for (const spec of screen.columns) {
13
+ const normalized = normalizeListColumn(spec);
14
+ if (normalized.refEntity !== undefined) {
15
+ // Declared reference metadata (ListColumnSpec.refEntity) — for
16
+ // projectionList/relatedList columns, which have no EntityDefinition
17
+ // field to carry a real "reference" type. Checked before the
18
+ // entity.fields lookup below so it also fires against the pseudo-
19
+ // entities that hardcode every field as "text" (projection-list-shim,
20
+ // related-list-section) — those never reach the reference branch
21
+ // further down since fieldDef.type is always "text" there.
22
+ const refTarget = parseRefTarget(normalized.refEntity, featureName);
23
+ columns.push({
24
+ field: normalized.field,
25
+ label: translate(normalized.label ?? fieldLabelKey(featureName, screen.entity, normalized.field)),
26
+ type: "reference",
27
+ // The declared value is the referenced id, not the resolved label —
28
+ // there is no column to server-sort by, same as row-meta references.
29
+ sortable: false,
30
+ refEntity: refTarget.entityName,
31
+ refFeature: refTarget.featureName,
32
+ refLabelField: normalized.refLabelField ?? "id",
33
+ ...(normalized.renderer !== undefined && { renderer: normalized.renderer }),
34
+ });
35
+ continue;
36
+ }
37
+ const fieldDef = entity.fields[normalized.field];
38
+ if (!fieldDef) {
39
+ // Not a stored field — may be a read-time derived field (value appended
40
+ // by the list-query handler). Render it as its display valueType; derived
41
+ // columns carry no reference/select metadata and never server-sort.
42
+ const derivedDef = entity.derivedFields?.[normalized.field];
43
+ if (!derivedDef) {
44
+ // Row-meta column (id/tenantId/version/insertedAt/modifiedAt/...) —
45
+ // a real base-table column that's never a declared entity field, e.g.
46
+ // a SystemAdmin cross-tenant list exposing tenantId. Unlike the
47
+ // derivedFields branch above, these are actual DB columns, so
48
+ // server-side ORDER BY works — sortable stays true, except for the
49
+ // ID columns in LIST_ROW_META_REFERENCES (see their reference branch).
50
+ const rowMetaType = LIST_ROW_META_COLUMNS[normalized.field];
51
+ if (rowMetaType !== undefined) {
52
+ const rowMetaRef = LIST_ROW_META_REFERENCES[normalized.field];
53
+ columns.push({
54
+ field: normalized.field,
55
+ label: translate(normalized.label ?? fieldLabelKey(featureName, screen.entity, normalized.field)),
56
+ type: rowMetaRef !== undefined ? "reference" : rowMetaType,
57
+ // The DB column holds the GUID, not the resolved label — server-side
58
+ // ORDER BY would sort by GUID while the cell shows the name.
59
+ sortable: rowMetaRef === undefined,
60
+ ...(normalized.renderer !== undefined && { renderer: normalized.renderer }),
61
+ ...(rowMetaRef !== undefined && {
62
+ refEntity: rowMetaRef.refEntity,
63
+ refFeature: rowMetaRef.refFeature,
64
+ refLabelField: rowMetaRef.refLabelField,
65
+ }),
66
+ });
67
+ continue;
68
+ }
69
+ // A virtual presentational column — drawn entirely by a columnRenderer
70
+ // component from the row (e.g. tag chips); value is undefined and it
71
+ // never server-sorts. `field` is just the column key. It needs BOTH a
72
+ // label AND a renderer — renderer is what actually draws it; a label
73
+ // alone with no renderer would push an empty, unrendered column into
74
+ // the view model. Missing either is an author typo (or a stale
75
+ // field-rename) → fail loud instead of silently drawing nothing.
76
+ if (normalized.label !== undefined && normalized.renderer !== undefined) {
77
+ columns.push({
78
+ field: normalized.field,
79
+ label: translate(normalized.label),
80
+ type: "text",
81
+ sortable: false,
82
+ renderer: normalized.renderer,
83
+ });
84
+ continue;
85
+ }
86
+ throw new Error(`computeListViewModel: screen "${screen.id}" references unknown field "${normalized.field}" on entity "${screen.entity}"`);
87
+ }
88
+ columns.push({
89
+ field: normalized.field,
90
+ label: translate(normalized.label ?? fieldLabelKey(featureName, screen.entity, normalized.field)),
91
+ type: derivedDef.valueType,
92
+ // Display-only: a header sort would round-trip to the server, which has
93
+ // no column to sort by (see DerivedFieldDef). Never offer the affordance.
94
+ sortable: false,
95
+ ...(normalized.renderer !== undefined && { renderer: normalized.renderer }),
96
+ });
97
+ continue;
98
+ }
99
+ const label = translate(normalized.label ?? fieldLabelKey(featureName, screen.entity, normalized.field));
100
+ // Tier 2.7e-3 + Cross-Feature: Reference-Field — entity-String
101
+ // kann same-feature ("user") oder cross-feature ("users:user")
102
+ // sein. parseRefTarget gibt (featureName, entityName), der
103
+ // Renderer baut die Lookup-QN als
104
+ // `<refFeature>:query:<refEntity>:list`.
105
+ const refRaw = fieldDef.type === "reference"
106
+ ? fieldDef.entity
107
+ : undefined;
108
+ const refTarget = refRaw !== undefined ? parseRefTarget(refRaw, featureName) : undefined;
109
+ const refEntity = refTarget?.entityName;
110
+ const refFeature = refTarget?.featureName;
111
+ const refLabelField = fieldDef.type === "reference"
112
+ ? (fieldDef.labelField ?? "id")
113
+ : undefined;
114
+ // For select and multiSelect fields: bake in translated option labels.
115
+ // Convention matches the form path → one translation source for list +
116
+ // form. A missing key returns the key itself per convention; the
117
+ // renderer then falls back to humanizeSlug.
118
+ const optionLabels = fieldDef.type === "select" || fieldDef.type === "multiSelect"
119
+ ? buildOptionLabels(translate, (value) => fieldOptionLabelKey(featureName, screen.entity, normalized.field, value), fieldDef.options ?? [])
120
+ : undefined;
121
+ const optionTones = fieldDef.type === "select" ? fieldDef.optionTones : undefined;
122
+ const grouping = fieldDef.type === "number" ? fieldDef.grouping : undefined;
123
+ const column = {
124
+ field: normalized.field,
125
+ label,
126
+ type: fieldDef.type,
127
+ sortable: fieldIsSortable(fieldDef),
128
+ ...(normalized.renderer !== undefined && { renderer: normalized.renderer }),
129
+ ...(optionLabels !== undefined && { optionLabels }),
130
+ ...(optionTones !== undefined && { optionTones }),
131
+ ...(refEntity !== undefined && { refEntity }),
132
+ ...(refFeature !== undefined && { refFeature }),
133
+ ...(refLabelField !== undefined && { refLabelField }),
134
+ ...(grouping !== undefined && { grouping }),
135
+ ...(normalized.hideOnNarrow === true && { hideOnNarrow: true }),
136
+ };
137
+ columns.push(column);
138
+ }
139
+ const listRows = rows.map((row) => ({
140
+ id: String(row["id"] ?? ""),
141
+ values: row,
142
+ }));
143
+ return {
144
+ screenId: screen.id,
145
+ entityName: screen.entity,
146
+ columns,
147
+ rows: listRows,
148
+ ...(screen.slots && { slots: screen.slots }),
149
+ isEmpty: listRows.length === 0,
150
+ };
151
+ }
152
+ // Field-i18n-key convention matches what features register translations
153
+ // under (see packages/framework/src/i18n/ for the pattern). Duplicated
154
+ // here as a plain function — the ui-core boundary forbids depending on
155
+ // the i18n module directly.
156
+ export function fieldLabelKey(featureName, entityName, fieldName) {
157
+ return `${featureName}:entity:${entityName}:field:${fieldName}`;
158
+ }
159
+ export function fieldOptionLabelKeyPrefix(featureName, entityName, fieldName) {
160
+ return `${fieldLabelKey(featureName, entityName, fieldName)}:option:`;
161
+ }
162
+ export function fieldOptionLabelKey(featureName, entityName, fieldName, value) {
163
+ return `${fieldOptionLabelKeyPrefix(featureName, entityName, fieldName)}${value}`;
164
+ }
165
+ // Embedded-list cell label key — one level deeper than fieldLabelKey,
166
+ // keyed by the sub-field name inside an embedded-LIST field's `schema`
167
+ // (invoice-positions-style tables built via createEmbeddedListField()).
168
+ export function embeddedCellLabelKey(featureName, entityName, fieldName, subFieldName) {
169
+ return `${featureName}:entity:${entityName}:field:${fieldName}:cell:${subFieldName}`;
170
+ }
171
+ export function embeddedCellOptionLabelKey(featureName, entityName, fieldName, subFieldName, value) {
172
+ return `${featureName}:entity:${entityName}:field:${fieldName}:cell:${subFieldName}:option:${value}`;
173
+ }
174
+ // Build a value→label map for a select-field's options. Convention:
175
+ // translate() returns the input key when the lookup misses (i18next
176
+ // default + LocaleResolver convention) — we surface the *raw value* in
177
+ // that case so the renderer's humanizeSlug fallback can take over.
178
+ // Without that fallback, an unlabeled option would render as the full
179
+ // `feature:entity:field:option:value`-key.
180
+ //
181
+ // `keyFor` carries whichever key convention the caller needs (top-level
182
+ // field via fieldOptionLabelKey, embedded-list cell via
183
+ // embeddedCellOptionLabelKey) — shared between list-VM, edit-VM, and
184
+ // embedded-list cells so all three produce identical option-translations.
185
+ export function buildOptionLabels(translate, keyFor, options) {
186
+ const out = {};
187
+ for (const value of options) {
188
+ const key = keyFor(value);
189
+ const translated = translate(key);
190
+ out[value] = translated === key ? value : translated;
191
+ }
192
+ return out;
193
+ }
194
+ // A field can declare `sortable: true` on the FieldDefinition. This is
195
+ // framework-level metadata used both by the server's list-query builder
196
+ // (ORDER BY safety) and by the UI (column header click indicator). All
197
+ // field types can bear the flag; it's off by default.
198
+ function fieldIsSortable(field) {
199
+ const flag = field.sortable;
200
+ return flag === true;
201
+ }
@@ -0,0 +1,300 @@
1
+ import type { EditFieldSpec, EditSectionSpec, FieldIconKey, FieldRenderer, IconKey, ListColumnSpec, ListFacetSpec, ListSortSpec, PlatformComponent, RelatedListToolbarAction, RowAction, ScreenSlots, SelectOptionTone } from "@cosmicdrift/kumiko-framework/ui-types";
2
+ export type RuntimeRenderer = (value: unknown, row?: Readonly<Record<string, unknown>>) => string;
3
+ export type ListColumnViewModel = {
4
+ readonly field: string;
5
+ readonly label: string;
6
+ readonly type: string;
7
+ readonly renderer?: FieldRenderer | RuntimeRenderer;
8
+ readonly sortable: boolean;
9
+ /** Only for `type: "select"` or `type: "multiSelect"` — translated option
10
+ * labels keyed by raw value. The renderer renders `optionLabels[value]`
11
+ * instead of humanizeSlug when present. Convention key:
12
+ * `<feature>:entity:<entity>:field:<field>:option:<value>`. */
13
+ readonly optionLabels?: Readonly<Record<string, string>>;
14
+ /** Only for `type: "select"` — declared tone per raw option value. */
15
+ readonly optionTones?: Readonly<Partial<Record<string, SelectOptionTone>>>;
16
+ /** Nur bei `type: "reference"` — referenced Entity-Name für Bulk-
17
+ * Lookup im Renderer (`<refFeature>:query:<refEntity>:list`). */
18
+ readonly refEntity?: string;
19
+ /** Nur bei `type: "reference"` — Feature-Name in dem die referenced
20
+ * Entity wohnt. Default = current feature. Cross-Feature über
21
+ * qualifizierte Form ("feature:entity") am ReferenceFieldDef. */
22
+ readonly refFeature?: string;
23
+ /** Nur bei `type: "reference"` — Welches Feld der referenced Entity
24
+ * als Display-Wert in der Cell erscheint (Default "id"). */
25
+ readonly refLabelField?: string;
26
+ /** Set by the caller at runtime (not schema-derived) to emphasize one
27
+ * column — e.g. the currently selected base-year column in a
28
+ * multi-year statement grid. DataTable renders it with a distinct
29
+ * header/cell background. */
30
+ readonly highlighted?: boolean;
31
+ /** Mirrors `ListColumnSpec.hideOnNarrow`: card layout skips the column. */
32
+ readonly hideOnNarrow?: boolean;
33
+ /** Only for `type: "number"` — mirrors `NumberFieldDef.grouping` (default
34
+ * `true`). `false` renders without thousands-separators (e.g. a model
35
+ * year). */
36
+ readonly grouping?: boolean;
37
+ };
38
+ export type ListRowViewModel = {
39
+ readonly id: string;
40
+ readonly values: Readonly<Record<string, unknown>>;
41
+ };
42
+ export type ListViewModel = {
43
+ readonly screenId: string;
44
+ readonly entityName: string;
45
+ readonly columns: readonly ListColumnViewModel[];
46
+ readonly rows: readonly ListRowViewModel[];
47
+ readonly slots?: ScreenSlots;
48
+ readonly isEmpty: boolean;
49
+ };
50
+ export type EmbeddedListCellViewModel = {
51
+ readonly field: string;
52
+ readonly label: string;
53
+ readonly type: "text" | "number" | "boolean" | "date" | "money" | "decimal" | "select" | "reference" | "timestamp";
54
+ readonly required: boolean;
55
+ /** Only for `type: "select"`. */
56
+ readonly options?: readonly string[];
57
+ readonly optionLabels?: Readonly<Record<string, string>>;
58
+ /** Only for `type: "reference"`. */
59
+ readonly refEntity?: string;
60
+ readonly refFeature?: string;
61
+ readonly refLabelField?: string;
62
+ readonly refOptionsQuery?: string;
63
+ /** Only for `type: "decimal"`. */
64
+ readonly scale?: number;
65
+ };
66
+ export type EditFieldViewModel = {
67
+ readonly field: string;
68
+ readonly label: string;
69
+ readonly type: string;
70
+ readonly value: unknown;
71
+ readonly visible: boolean;
72
+ readonly readOnly: boolean;
73
+ readonly required: boolean;
74
+ readonly span?: number;
75
+ readonly renderer?: FieldRenderer;
76
+ /** Set for `type: "select"` and `type: "multiSelect"` — the allowed
77
+ * values. The renderer uses these as dropdown/combobox options.
78
+ * Source is SelectFieldDef.options / MultiSelectFieldDef.options on
79
+ * the EntityDefinition. */
80
+ readonly options?: readonly string[];
81
+ /** Set for `type: "select"` and `type: "multiSelect"` — translated
82
+ * labels per option, keyed by raw value. The renderer shows
83
+ * `optionLabels[value]` as the dropdown/combobox label instead of the
84
+ * raw value. Convention key:
85
+ * `<feature>:entity:<entity>:field:<field>:option:<value>`. */
86
+ readonly optionLabels?: Readonly<Record<string, string>>;
87
+ /** Set for `type: "multiSelect"` when MultiSelectFieldDef.display is
88
+ * "checkboxes" — the renderer shows a checkbox grid with a select-all
89
+ * toggle instead of the default combobox dropdown. Set for
90
+ * `type: "select"` when SelectFieldDef.display is "radio" or "dropdown" —
91
+ * the renderer then skips its own radio-vs-dropdown heuristic. */
92
+ readonly display?: "checkboxes" | "dropdown" | "radio";
93
+ /** Set for `type: "multiSelect"` when MultiSelectFieldDef.columns is
94
+ * defined — column count for the checkbox grid at the widest breakpoint.
95
+ * Ignored unless `display` is "checkboxes". */
96
+ readonly columns?: 1 | 2 | 3 | 4;
97
+ /** Set for `type: "multiSelect"` when MultiSelectFieldDef.maxRows is
98
+ * defined — grid rows visible before the checkbox grid becomes
99
+ * scrollable. Ignored unless `display` is "checkboxes". */
100
+ readonly maxRows?: number;
101
+ /** For `type: "text"`, set when TextFieldDef.multiline is true — the
102
+ * renderer then renders a textarea instead of a single-line input.
103
+ * `type: "longText"` always renders a textarea regardless of this
104
+ * value; here it only carries an optional `{ rows }` override.
105
+ * `true` = default rows, `{ rows }` = explicit height. */
106
+ readonly multiline?: boolean | {
107
+ readonly rows?: number;
108
+ };
109
+ /** From TextFieldDef.format — `password` makes the renderer mask the input. */
110
+ readonly format?: "email" | "url" | "phone" | "password";
111
+ /** Nur bei `type: "timestamp"` gesetzt wenn TimestampFieldDef.locatedBy
112
+ * existiert — Wall-Clock-Zeit ohne Offset. Der Renderer emittiert
113
+ * dann lokale Zeit ohne `Z` statt eines UTC-Instants. */
114
+ readonly wallClock?: boolean;
115
+ /** Nur bei `type: "date" | "timestamp"` — Datumsgrenzen (ISO) und
116
+ * Format/Locale-Override aus der FieldDefinition. Der Renderer begrenzt
117
+ * damit den Picker; die Write-Validierung läuft separat über Zod. */
118
+ readonly min?: string;
119
+ readonly max?: string;
120
+ readonly dateLocale?: string;
121
+ /** Only for `type: "number"` — mirrors `NumberFieldDef.grouping` (default
122
+ * `true`). `false` renders without thousands-separators (e.g. a model
123
+ * year). */
124
+ readonly grouping?: boolean;
125
+ /** Nur bei `type: "reference"` gesetzt — Tier 2.7e-3.
126
+ * Die referenced Entity (kurz, ohne feature-prefix). Der Renderer
127
+ * baut die Query-QN als `<refFeature>:query:<refEntity>:list`. */
128
+ readonly refEntity?: string;
129
+ /** Nur bei `type: "reference"` gesetzt — Feature-Name in dem die
130
+ * referenced Entity wohnt. Same-feature default = aktuelles
131
+ * Feature. Cross-Feature wird über "feature:entity" am
132
+ * ReferenceFieldDef.entity erkannt. */
133
+ readonly refFeature?: string;
134
+ /** Nur bei `type: "reference"` gesetzt — Welches Feld der referenced
135
+ * Entity als Display-Label im Dropdown erscheint. Default: "id". */
136
+ readonly refLabelField?: string;
137
+ /** Only for `type: "reference"` — query handler QN the picker sources its
138
+ * options from (fw#2780), replacing the referenced entity's list handler.
139
+ * Rows carry `{ id, label }`; `refLabelField` is then unused for the
140
+ * picker but still drives list cells. */
141
+ readonly refOptionsQuery?: string;
142
+ /** Nur bei `type: "reference"` — Multi-Mode (Tier 2.7e-Multi):
143
+ * Wert ist UUID-Array, Renderer mountet Multi-Combobox mit Tags. */
144
+ readonly refMultiple?: boolean;
145
+ /** Nur bei `type: "file" | "image"` — erlaubte Extensions/MIME (z.B.
146
+ * ["jpg","png"]) + Max-Größe ("5mb"). Der Renderer setzt das `accept`-
147
+ * Attribut + zeigt die Grenze; die Validierung läuft serverseitig im
148
+ * Upload-Endpoint gegen die Field-Def. */
149
+ readonly accept?: readonly string[];
150
+ readonly maxSize?: string;
151
+ /** Nur bei `type: "file" | "image"` — Entity-Name + Field-Name für den
152
+ * Upload-POST (`/api/files`), damit der Endpoint maxSize/accept gegen
153
+ * die richtige Field-Def prüfen kann. */
154
+ readonly entityType?: string;
155
+ readonly fieldName?: string;
156
+ /** Only for `type: "image"` — the variant name the preview requests
157
+ * (`/api/files/:id/variant/:name`) instead of the original. The FIRST
158
+ * variant declared on the field def wins; a spec may legitimately carry
159
+ * neither `size` nor `maxEdge` (format-/blur-only), so "the smallest one"
160
+ * is not a rule that always has an answer. Absent when the field declares
161
+ * no variants — the preview then loads the original. */
162
+ readonly imageVariant?: string;
163
+ /** Only for `type: "image"` — forwarded to the file input's `capture`
164
+ * attribute so a phone opens the camera instead of the file picker. */
165
+ readonly capture?: "environment" | "user";
166
+ /** Closed FieldIconKey from EditFieldSpec.icon (FIELD_ICONS registry). */
167
+ readonly icon?: FieldIconKey;
168
+ /** Only set for an embedded-LIST field (`multiple: true`) — the per-cell
169
+ * metadata the renderer needs to draw each column/cell. Absent for a
170
+ * plain (non-list) embedded field. */
171
+ readonly embeddedListCells?: readonly EmbeddedListCellViewModel[];
172
+ /** Minimum row count for the embedded list. From EmbeddedFieldDef.minItems. */
173
+ readonly embeddedListMinItems?: number;
174
+ /** Maximum row count for the embedded list. From EmbeddedFieldDef.maxItems. */
175
+ readonly embeddedListMaxItems?: number;
176
+ /** Sub-field name → how to compute its cell from other sub-fields of the
177
+ * same row. From EmbeddedFieldDef.derived. */
178
+ readonly embeddedListDerived?: Readonly<Record<string, {
179
+ readonly op: "multiply" | "sum" | "subtract";
180
+ readonly from: readonly string[];
181
+ }>>;
182
+ /** Numeric sub-field names to sum in a totals row. From EmbeddedFieldDef.totals. */
183
+ readonly embeddedListTotals?: readonly string[];
184
+ /** Currency for the whole embedded list's money cells and totals row —
185
+ * currency lives on the head aggregate (EntityDefinition.defaultCurrency),
186
+ * not per row. Falls back to "EUR" when the entity has no defaultCurrency. */
187
+ readonly embeddedListCurrency?: string;
188
+ /** Only set for a plain (non-list, non-embedded) `type: "money"` field —
189
+ * same source as embeddedListCurrency (EntityDefinition.defaultCurrency,
190
+ * falls back to "EUR"). The renderer needs this to build the payload's
191
+ * `{amount, currency}` shape (kumiko-framework#1923). */
192
+ readonly currency?: string;
193
+ /** Only set for `type: "number"` when NumberFieldDef.unit is set — a static
194
+ * suffix string, or a sibling-field reference the renderer resolves against
195
+ * the live row values. Display-only, never converts the stored number. */
196
+ readonly unit?: string | {
197
+ readonly field: string;
198
+ };
199
+ };
200
+ export type EditSectionViewModel = EditFieldsSectionViewModel | EditExtensionSectionViewModel | EditRelatedListSectionViewModel | EditWriteFormSectionViewModel;
201
+ export type EditFieldsSectionViewModel = {
202
+ readonly kind: "fields";
203
+ /** From `EditFieldsSection.id`. The boot-validator makes it mandatory under
204
+ * `layout.mode: "tabs"` so the tab strip has a stable key per tab; other
205
+ * layouts leave it unset. */
206
+ readonly id?: string;
207
+ /** False when the section has fields but none of them is visible.
208
+ * The renderer then draws nothing, but the section stays in the array
209
+ * (key stability). */
210
+ readonly visible: boolean;
211
+ /** Optional — eine titellose Section rendert nur ihre Felder (flache Form). */
212
+ readonly title?: string;
213
+ /** Translated help text under the block heading, from
214
+ * `EditFieldsSection.description`. Renders even without `title`. */
215
+ readonly description?: string;
216
+ readonly columns: number;
217
+ readonly fields: readonly EditFieldViewModel[];
218
+ /** From `EditFieldsSection.groups` — each entry's `fields` are the same
219
+ * view-model objects as `fields` above, re-grouped (not recomputed). */
220
+ readonly groups?: readonly {
221
+ readonly title: string;
222
+ readonly columns: number;
223
+ readonly fields: readonly EditFieldViewModel[];
224
+ }[];
225
+ /** From `EditFieldsSection.icon` — closed IconKey vocabulary, renders
226
+ * left of the title. No effect without `title`. */
227
+ readonly icon?: IconKey;
228
+ /** From `EditFieldsSection.actions` — rendered in the Section's
229
+ * title row, same slot as the projectionDetail head card's actions. */
230
+ readonly actions?: readonly RowAction[];
231
+ };
232
+ export type EditExtensionSectionViewModel = {
233
+ readonly kind: "extension";
234
+ /** From `EditExtensionSection.id` — same tab-strip role as on a "fields"
235
+ * section, see EditFieldsSectionViewModel.id. */
236
+ readonly id?: string;
237
+ readonly title: string;
238
+ readonly component: PlatformComponent;
239
+ readonly contributesToFormSubmit: boolean;
240
+ /** Section-declared override — see `EditExtensionSection.entityName`. */
241
+ readonly entityName?: string;
242
+ /** From `EditExtensionSection.actions`. */
243
+ readonly actions?: readonly RowAction[];
244
+ };
245
+ export type EditRelatedListSectionViewModel = {
246
+ readonly kind: "relatedList";
247
+ readonly title: string;
248
+ readonly query: string;
249
+ readonly parentParam?: string;
250
+ readonly parentFilter?: {
251
+ readonly field: string;
252
+ };
253
+ readonly columns: readonly ListColumnSpec[];
254
+ readonly description?: string;
255
+ /** Untranslated plural-forms key, resolved with the row count by the list footer. */
256
+ readonly itemNoun?: string;
257
+ readonly pageSize?: number;
258
+ readonly defaultSort?: ListSortSpec;
259
+ readonly searchable?: boolean;
260
+ readonly facets?: readonly ListFacetSpec[];
261
+ readonly rowClick?: {
262
+ readonly entity: string;
263
+ readonly idColumn?: string;
264
+ };
265
+ readonly rowActions?: readonly RowAction[];
266
+ readonly toolbarActions?: readonly RelatedListToolbarAction[];
267
+ /** From `EditRelatedListSection.actions` — rendered in the
268
+ * Section's title row, distinct from `toolbarActions` (which render
269
+ * above the table itself). */
270
+ readonly actions?: readonly RowAction[];
271
+ /** From `EditRelatedListSection.emptyState` — forwarded to the
272
+ * DataTable's `emptyState` prop when the section has zero rows. */
273
+ readonly emptyState?: {
274
+ readonly title: string;
275
+ readonly description?: string;
276
+ readonly action?: RowAction;
277
+ };
278
+ };
279
+ export type EditWriteFormSectionViewModel = {
280
+ readonly kind: "writeForm";
281
+ readonly title?: string;
282
+ readonly description?: string;
283
+ readonly columns: number;
284
+ readonly fields: readonly EditFieldViewModel[];
285
+ readonly icon?: IconKey;
286
+ readonly handler: string;
287
+ readonly submitLabel?: string;
288
+ /** From `EditWriteFormSection.actions`. */
289
+ readonly actions?: readonly RowAction[];
290
+ };
291
+ export type EditViewModel = {
292
+ readonly screenId: string;
293
+ readonly entityName: string;
294
+ readonly id: string | null;
295
+ readonly sections: readonly EditSectionViewModel[];
296
+ readonly slots?: ScreenSlots;
297
+ };
298
+ export type Translate = (key: string, params?: Readonly<Record<string, unknown>>) => string;
299
+ export type FieldConditionCtx = unknown;
300
+ export type { EditFieldSpec, EditSectionSpec, FieldRenderer, ListColumnSpec, ScreenSlots };
@@ -0,0 +1 @@
1
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-headless",
3
- "version": "0.328.0",
3
+ "version": "0.328.1",
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>",
@@ -19,33 +19,52 @@
19
19
  },
20
20
  "exports": {
21
21
  ".": {
22
- "types": "./src/index.ts",
23
- "default": "./src/index.ts"
22
+ "types": "./dist/index.d.ts",
23
+ "default": "./dist/index.js"
24
24
  },
25
25
  "./dispatcher": {
26
- "types": "./src/dispatcher/index.ts",
27
- "default": "./src/dispatcher/index.ts"
26
+ "types": "./dist/dispatcher/index.d.ts",
27
+ "default": "./dist/dispatcher/index.js"
28
28
  },
29
29
  "./apex": {
30
- "types": "./src/apex/index.ts",
31
- "default": "./src/apex/index.ts"
30
+ "types": "./dist/apex/index.d.ts",
31
+ "default": "./dist/apex/index.js"
32
32
  },
33
33
  "./locale-routing": {
34
- "types": "./src/locale-routing/index.ts",
35
- "default": "./src/locale-routing/index.ts"
34
+ "types": "./dist/locale-routing/index.d.ts",
35
+ "default": "./dist/locale-routing/index.js"
36
36
  }
37
37
  },
38
38
  "dependencies": {
39
- "@cosmicdrift/kumiko-framework": "0.328.0",
39
+ "@cosmicdrift/kumiko-framework": "0.328.1",
40
40
  "temporal-polyfill": "^0.3.2",
41
41
  "zod": "^4.4.3"
42
42
  },
43
43
  "publishConfig": {
44
44
  "registry": "https://registry.npmjs.org",
45
- "access": "public"
45
+ "access": "public",
46
+ "exports": {
47
+ ".": {
48
+ "types": "./dist/index.d.ts",
49
+ "default": "./dist/index.js"
50
+ },
51
+ "./dispatcher": {
52
+ "types": "./dist/dispatcher/index.d.ts",
53
+ "default": "./dist/dispatcher/index.js"
54
+ },
55
+ "./apex": {
56
+ "types": "./dist/apex/index.d.ts",
57
+ "default": "./dist/apex/index.js"
58
+ },
59
+ "./locale-routing": {
60
+ "types": "./dist/locale-routing/index.d.ts",
61
+ "default": "./dist/locale-routing/index.js"
62
+ }
63
+ }
46
64
  },
47
65
  "files": [
48
- "src",
66
+ "dist",
67
+ "src/changes.json",
49
68
  "README.md",
50
69
  "LICENSE"
51
70
  ]
@@ -1,20 +0,0 @@
1
- import { describe, expect, test } from "bun:test";
2
- import { createHash } from "node:crypto";
3
- import { APEX_LIGHTBOX_SCRIPT, APEX_LIGHTBOX_SCRIPT_CSP_HASH } from "../index";
4
-
5
- function scriptBody(html: string): string {
6
- const match = html.match(/^<script>(?<body>[\s\S]*)<\/script>$/);
7
- const body = match?.groups?.["body"];
8
- if (body === undefined) {
9
- throw new Error("APEX_LIGHTBOX_SCRIPT isn't a single <script>...</script> string");
10
- }
11
- return body;
12
- }
13
-
14
- describe("APEX_LIGHTBOX_SCRIPT_CSP_HASH", () => {
15
- test("matches the actual script content byte-for-byte", () => {
16
- const body = scriptBody(APEX_LIGHTBOX_SCRIPT);
17
- const hash = `sha256-${createHash("sha256").update(body).digest("base64")}`;
18
- expect(hash).toBe(APEX_LIGHTBOX_SCRIPT_CSP_HASH);
19
- });
20
- });
@@ -1,63 +0,0 @@
1
- import { describe, expect, test } from "bun:test";
2
- import { injectPageHead, resolveAndInjectPageHead } from "../index";
3
-
4
- describe("injectPageHead", () => {
5
- const TAGS = '<title>New Title</title>\n<meta name="description" content="d">';
6
-
7
- test("replaces an existing <title> instead of duplicating it", () => {
8
- const html = "<html><head><title>Old</title></head><body></body></html>";
9
- const out = injectPageHead(html, TAGS);
10
- expect(out).not.toContain("<title>Old</title>");
11
- expect((out.match(/<title>/g) ?? []).length).toBe(1);
12
- expect(out).toContain("<title>New Title</title>");
13
- });
14
-
15
- test("applying twice does not duplicate the head-tags block", () => {
16
- const html = "<html><head><title>Old</title></head><body></body></html>";
17
- const once = injectPageHead(html, TAGS);
18
- const twice = injectPageHead(once, TAGS);
19
- expect(twice).toBe(once);
20
- expect((twice.match(/kumiko-page-head/g) ?? []).length).toBe(1);
21
- });
22
-
23
- test("HTML without </head> is returned unchanged", () => {
24
- const html = "<html><body><title>Old</title></body></html>";
25
- expect(injectPageHead(html, TAGS)).toBe(html);
26
- });
27
- });
28
-
29
- describe("resolveAndInjectPageHead", () => {
30
- const HTML = "<!doctype html><html><head><title>Shell</title></head><body>shell</body></html>";
31
- const input = { path: "/", host: "t", systemQuery: async () => ({}) };
32
-
33
- test("resolved meta is injected as head tags", async () => {
34
- const out = await resolveAndInjectPageHead(
35
- HTML,
36
- async () => ({ title: "Vehicle X", description: "A car", ogImage: "https://x/i.png" }),
37
- input,
38
- );
39
- expect(out).toContain("<title>Vehicle X</title>");
40
- expect(out).toContain('<meta property="og:image" content="https://x/i.png" />');
41
- });
42
-
43
- test("resolver throws → unchanged html, not a rejection", async () => {
44
- const out = await resolveAndInjectPageHead(
45
- HTML,
46
- async () => {
47
- throw new Error("boom");
48
- },
49
- input,
50
- );
51
- expect(out).toBe(HTML);
52
- });
53
-
54
- test("resolver returns null → unchanged html", async () => {
55
- const out = await resolveAndInjectPageHead(HTML, async () => null, input);
56
- expect(out).toBe(HTML);
57
- });
58
-
59
- test("resolver never resolves → unchanged html after the shared timeout", async () => {
60
- const out = await resolveAndInjectPageHead(HTML, () => new Promise(() => {}), input);
61
- expect(out).toBe(HTML);
62
- }, 2000);
63
- });