@cosmicdrift/kumiko-headless 0.360.0 → 0.362.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.
@@ -198,6 +198,11 @@ export function applyFormatSpec(spec, value, translate) {
198
198
  // multiSelect-style columns hold an array of option values
199
199
  return Array.isArray(value) ? value.map(labelFor).join(", ") : labelFor(value);
200
200
  }
201
+ // Thumbnails and file links are drawn by the web list cell; this is the
202
+ // text fallback for every other surface.
203
+ case "image":
204
+ case "file":
205
+ return Array.isArray(value) ? value.join(", ") : String(value);
201
206
  case "json":
202
207
  return stringifyJsonOrString(value, typeof spec["indent"] === "number" ? spec["indent"] : 2);
203
208
  default:
package/dist/index.d.ts CHANGED
@@ -10,5 +10,5 @@ export type { NavDefinition, NavNode, NavRegistrySlice, NavTree, ResolveNavigati
10
10
  export { resolveNavigation } from "./nav/index.js";
11
11
  export type { Store, WritableStore } from "./store/index.js";
12
12
  export { createStore, shallowEqual } from "./store/index.js";
13
- export type { ComputeEditViewModelInput, ComputeListViewModelInput, DerivedCellRoundingTarget, EditExtensionSectionViewModel, EditFieldSpec, EditFieldsSectionViewModel, EditFieldViewModel, EditFormMode, EditRelatedListSectionViewModel, EditSectionSpec, EditSectionViewModel, EditViewModel, EditWriteFormSectionViewModel, EmbeddedDerivedOp, EmbeddedListCellViewModel, EmbeddedListIssueGroups, FieldConditionCtx, FieldRenderer, ListColumnSpec, ListColumnViewModel, ListRowViewModel, ListViewModel, RuntimeRenderer, ScreenSlots, Translate, } from "./view-model/index.js";
13
+ export type { ComputeEditViewModelInput, ComputeListViewModelInput, DerivedCellRoundingTarget, EditExtensionSectionViewModel, EditFieldSpec, EditFieldsSectionViewModel, EditFieldViewModel, EditFormMode, EditRelatedListSectionViewModel, EditSectionSpec, EditSectionViewModel, EditViewModel, EditWriteFormSectionViewModel, EmbeddedDerivedOp, EmbeddedListCellViewModel, EmbeddedListIssueGroups, FieldConditionCtx, FieldRenderer, ListColumnFileDisplay, ListColumnSpec, ListColumnViewModel, ListRowViewModel, ListViewModel, RuntimeRenderer, ScreenSlots, Translate, } from "./view-model/index.js";
14
14
  export { buildOptionLabels, computeDerivedCellValue, computeEditViewModel, computeListViewModel, computeRelatedListSectionViewModel, embeddedCellLabelKey, embeddedCellOptionLabelKey, fieldLabelKey, fieldOptionLabelKey, fieldOptionLabelKeyPrefix, groupEmbeddedListIssues, roundDerivedCellValue, sumEmbeddedListColumn, } from "./view-model/index.js";
@@ -4,7 +4,9 @@ import { isUiAccessGranted } from "@cosmicdrift/kumiko-framework/ui-types";
4
4
  // access-checked; a hidden parent drops its entire subtree implicitly
5
5
  // because we never recurse into it. Siblings sort by `order` (ascending,
6
6
  // default 0), tie-broken by qualified name so renders stay deterministic
7
- // across registry iteration orders.
7
+ // across registry iteration orders. A pure group (no screen, target,
8
+ // provider or createAction) whose children are all hidden or absent is
9
+ // dropped too.
8
10
  //
9
11
  // Pure — same inputs produce the same tree. The renderer memoizes the
10
12
  // result and only recomputes when the registry changes (rare — boot
@@ -24,6 +26,10 @@ export function resolveNavigation(options) {
24
26
  if (node !== null)
25
27
  children.push(node);
26
28
  }
29
+ // A pure group (nothing to open itself) with nothing visible below it
30
+ // would render as an empty, unclickable heading.
31
+ if (children.length === 0 && isPureGroup(entry))
32
+ return null;
27
33
  children.sort(bySortKey);
28
34
  return {
29
35
  qualifiedName: entry.id,
@@ -47,6 +53,13 @@ export function resolveNavigation(options) {
47
53
  roots.sort(bySortKey);
48
54
  return roots;
49
55
  }
56
+ function isPureGroup(entry) {
57
+ return (entry.screen === undefined &&
58
+ entry.target === undefined &&
59
+ entry.provider === undefined &&
60
+ entry.actions === undefined &&
61
+ entry.createAction === undefined);
62
+ }
50
63
  function bySortKey(a, b) {
51
64
  // Primary key: `order` ascending. Secondary: qualifiedName alphabetic,
52
65
  // which is a stable fallback — the registry-iteration order isn't
@@ -249,44 +249,52 @@ function deriveFileFieldHints(fieldDef, entityName, fieldName, entityId) {
249
249
  ...deriveImageHints(fieldDef, fileDef),
250
250
  };
251
251
  }
252
+ function deriveEmbeddedCellSelectHints(subField, translate, featureName, entityName, fieldName, subFieldName) {
253
+ if (subField.type !== "select")
254
+ return {};
255
+ const options = subField.options ?? [];
256
+ return {
257
+ options,
258
+ optionLabels: buildOptionLabels(translate, (value) => embeddedCellOptionLabelKey(featureName, entityName, fieldName, subFieldName, value), options),
259
+ };
260
+ }
261
+ function deriveEmbeddedCellReferenceHints(subField, featureName) {
262
+ if (subField.type !== "reference")
263
+ return {};
264
+ const target = subField.entity !== undefined ? parseRefTarget(subField.entity, featureName) : undefined;
265
+ return {
266
+ ...(target !== undefined && { refEntity: target.entityName, refFeature: target.featureName }),
267
+ ...(subField.labelField !== undefined && { refLabelField: subField.labelField }),
268
+ ...(subField.optionsQuery !== undefined && { refOptionsQuery: subField.optionsQuery }),
269
+ };
270
+ }
271
+ function buildEmbeddedListCell(subFieldName, subField, translate, featureName, entityName, fieldName, listSpec) {
272
+ return {
273
+ field: subFieldName,
274
+ label: translate(embeddedCellLabelKey(featureName, entityName, fieldName, subFieldName)),
275
+ type: subField.type,
276
+ required: subField.required === true,
277
+ ...deriveEmbeddedCellSelectHints(subField, translate, featureName, entityName, fieldName, subFieldName),
278
+ ...deriveEmbeddedCellReferenceHints(subField, featureName),
279
+ ...(subField.type === "decimal" && subField.scale !== undefined && { scale: subField.scale }),
280
+ ...(subField.type === "select" &&
281
+ subField.display !== undefined && { display: subField.display }),
282
+ ...(listSpec?.readOnlyCells?.includes(subFieldName) === true && { readOnly: true }),
283
+ };
284
+ }
252
285
  // Embedded-LIST field (`multiple: true`) — per-cell metadata for a renderer
253
286
  // to draw one row per array item (invoice-positions-style table). A plain
254
287
  // (non-list) embedded field emits none of this; the renderer tells the two
255
288
  // apart by whether embeddedListCells is set, not by `type` (which stays
256
289
  // "embedded" either way).
257
- function deriveEmbeddedListHints(fieldDef, translate, featureName, entityName, fieldName, resolvedCurrency) {
290
+ function deriveEmbeddedListHints(fieldDef, translate, featureName, entityName, fieldName, resolvedCurrency, listSpec) {
258
291
  const isEmbeddedList = fieldDef.type === "embedded" &&
259
292
  fieldDef.multiple === true;
260
293
  const embeddedListDef = isEmbeddedList
261
294
  ? fieldDef
262
295
  : undefined;
263
296
  const embeddedListCells = embeddedListDef !== undefined
264
- ? Object.entries(embeddedListDef.schema).map(([subFieldName, subField]) => {
265
- const cellLabel = translate(embeddedCellLabelKey(featureName, entityName, fieldName, subFieldName));
266
- const cellOptions = subField.type === "select" ? (subField.options ?? []) : undefined;
267
- const cellOptionLabels = cellOptions !== undefined
268
- ? buildOptionLabels(translate, (value) => embeddedCellOptionLabelKey(featureName, entityName, fieldName, subFieldName, value), cellOptions)
269
- : undefined;
270
- const cellRef = subField.type === "reference" ? subField : undefined;
271
- const cellRefTarget = cellRef?.entity !== undefined ? parseRefTarget(cellRef.entity, featureName) : undefined;
272
- const cell = {
273
- field: subFieldName,
274
- label: cellLabel,
275
- type: subField.type,
276
- required: subField.required === true,
277
- ...(cellOptions !== undefined && { options: cellOptions }),
278
- ...(cellOptionLabels !== undefined && { optionLabels: cellOptionLabels }),
279
- ...(cellRefTarget !== undefined && { refEntity: cellRefTarget.entityName }),
280
- ...(cellRefTarget !== undefined && { refFeature: cellRefTarget.featureName }),
281
- ...(cellRef?.labelField !== undefined && { refLabelField: cellRef.labelField }),
282
- ...(cellRef?.optionsQuery !== undefined && {
283
- refOptionsQuery: cellRef.optionsQuery,
284
- }),
285
- ...(subField.type === "decimal" &&
286
- subField.scale !== undefined && { scale: subField.scale }),
287
- };
288
- return cell;
289
- })
297
+ ? Object.entries(embeddedListDef.schema).map(([subFieldName, subField]) => buildEmbeddedListCell(subFieldName, subField, translate, featureName, entityName, fieldName, listSpec))
290
298
  : undefined;
291
299
  return {
292
300
  ...(embeddedListCells !== undefined && { embeddedListCells }),
@@ -305,6 +313,8 @@ function deriveEmbeddedListHints(fieldDef, translate, featureName, entityName, f
305
313
  // Currency lives on the head aggregate (entity.defaultCurrency), not per
306
314
  // row — one value for the whole embedded list.
307
315
  ...(embeddedListDef !== undefined && { embeddedListCurrency: resolvedCurrency }),
316
+ ...(embeddedListDef !== undefined &&
317
+ listSpec?.fixedRows === true && { embeddedListFixedRows: true }),
308
318
  };
309
319
  }
310
320
  function buildExtensionSectionViewModel(sectionSpec, translate) {
@@ -389,7 +399,7 @@ export function computeEditViewModel(input) {
389
399
  const numericHints = deriveNumericFieldHints(fieldDef, resolvedCurrency);
390
400
  const referenceHints = deriveReferenceFieldHints(fieldDef, normalized, declaredRefTarget, featureName);
391
401
  const fileHints = deriveFileFieldHints(fieldDef, screen.entity, normalized.field, entityId);
392
- const embeddedListHints = deriveEmbeddedListHints(fieldDef, translate, featureName, screen.entity, normalized.field, resolvedCurrency);
402
+ const embeddedListHints = deriveEmbeddedListHints(fieldDef, translate, featureName, screen.entity, normalized.field, resolvedCurrency, normalized.embeddedList);
393
403
  const view = {
394
404
  field: normalized.field,
395
405
  label,
@@ -4,4 +4,4 @@ export type { DerivedCellRoundingTarget, EmbeddedDerivedOp, EmbeddedListIssueGro
4
4
  export { computeDerivedCellValue, groupEmbeddedListIssues, roundDerivedCellValue, sumEmbeddedListColumn, } from "./embedded-list.js";
5
5
  export type { ComputeListViewModelInput } from "./list.js";
6
6
  export { buildOptionLabels, computeListViewModel, embeddedCellLabelKey, embeddedCellOptionLabelKey, fieldLabelKey, fieldOptionLabelKey, fieldOptionLabelKeyPrefix, } from "./list.js";
7
- export type { EditExtensionSectionViewModel, EditFieldSpec, EditFieldsSectionViewModel, EditFieldViewModel, EditRelatedListSectionViewModel, EditSectionSpec, EditSectionViewModel, EditViewModel, EditWriteFormSectionViewModel, EmbeddedListCellViewModel, FieldConditionCtx, FieldRenderer, ListColumnSpec, ListColumnViewModel, ListRowViewModel, ListViewModel, RuntimeRenderer, ScreenSlots, Translate, } from "./types.js";
7
+ export type { EditExtensionSectionViewModel, EditFieldSpec, EditFieldsSectionViewModel, EditFieldViewModel, EditRelatedListSectionViewModel, EditSectionSpec, EditSectionViewModel, EditViewModel, EditWriteFormSectionViewModel, EmbeddedListCellViewModel, FieldConditionCtx, FieldRenderer, ListColumnFileDisplay, ListColumnSpec, ListColumnViewModel, ListRowViewModel, ListViewModel, RuntimeRenderer, ScreenSlots, Translate, } from "./types.js";
@@ -0,0 +1,3 @@
1
+ import type { FieldDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
2
+ import type { ListColumnFileDisplay, ListColumnViewModel } from "./types.js";
3
+ export declare function deriveListFileDisplay(fieldDef: FieldDefinition, renderer: ListColumnViewModel["renderer"]): ListColumnFileDisplay | undefined;
@@ -0,0 +1,28 @@
1
+ function firstVariantKey(fieldDef) {
2
+ const variants = fieldDef.variants;
3
+ return variants === undefined ? undefined : Object.keys(variants)[0];
4
+ }
5
+ function fromFormatSpec(renderer) {
6
+ if (typeof renderer !== "object" || renderer === null || !("format" in renderer)) {
7
+ return undefined;
8
+ }
9
+ if (renderer.format === "file")
10
+ return { kind: "file" };
11
+ if (renderer.format !== "image")
12
+ return undefined;
13
+ return renderer.variant === undefined
14
+ ? { kind: "image" }
15
+ : { kind: "image", variant: renderer.variant };
16
+ }
17
+ function fromFieldType(fieldDef) {
18
+ if (fieldDef.type === "file" || fieldDef.type === "files")
19
+ return { kind: "file" };
20
+ if (fieldDef.type !== "image" && fieldDef.type !== "images")
21
+ return undefined;
22
+ const variant = firstVariantKey(fieldDef);
23
+ return variant === undefined ? { kind: "image" } : { kind: "image", variant };
24
+ }
25
+ // An explicit `{ format: "image" | "file" }` renderer wins over the field type.
26
+ export function deriveListFileDisplay(fieldDef, renderer) {
27
+ return fromFormatSpec(renderer) ?? fromFieldType(fieldDef);
28
+ }
@@ -1,4 +1,5 @@
1
1
  import { LIST_ROW_META_COLUMNS, LIST_ROW_META_REFERENCES, normalizeListColumn, parseRefTarget, } from "@cosmicdrift/kumiko-framework/ui-types";
2
+ import { deriveListFileDisplay } from "./list-file-display.js";
2
3
  const WRITE_ONLY_SET_LABEL_KEY = "kumiko.field.writeOnly.set";
3
4
  // Pure transform: takes the declared screen + entity + incoming rows, spits
4
5
  // out the flat shape the renderer draws. No conditions, no access-checks —
@@ -130,6 +131,7 @@ export function computeListViewModel(input) {
130
131
  : undefined;
131
132
  const optionTones = fieldDef.type === "select" ? fieldDef.optionTones : undefined;
132
133
  const grouping = fieldDef.type === "number" ? fieldDef.grouping : undefined;
134
+ const fileDisplay = deriveListFileDisplay(fieldDef, normalized.renderer);
133
135
  const column = {
134
136
  field: normalized.field,
135
137
  label,
@@ -142,6 +144,7 @@ export function computeListViewModel(input) {
142
144
  ...(refFeature !== undefined && { refFeature }),
143
145
  ...(refLabelField !== undefined && { refLabelField }),
144
146
  ...(grouping !== undefined && { grouping }),
147
+ ...(fileDisplay !== undefined && { fileDisplay }),
145
148
  ...(normalized.hideOnNarrow === true && { hideOnNarrow: true }),
146
149
  };
147
150
  columns.push(column);
@@ -1,5 +1,12 @@
1
1
  import type { EditFieldSpec, EditSectionSpec, FieldIconKey, FieldRenderer, IconKey, ListColumnSpec, ListFacetSpec, ListSortSpec, OptionsQueryPayload, PlatformComponent, RelatedListGroupBy, 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
+ /** How a list cell shows a FileRef value (id or id array). */
4
+ export type ListColumnFileDisplay = {
5
+ readonly kind: "image";
6
+ readonly variant?: string;
7
+ } | {
8
+ readonly kind: "file";
9
+ };
3
10
  export type ListColumnViewModel = {
4
11
  readonly field: string;
5
12
  readonly label: string;
@@ -34,6 +41,10 @@ export type ListColumnViewModel = {
34
41
  * `true`). `false` renders without thousands-separators (e.g. a model
35
42
  * year). */
36
43
  readonly grouping?: boolean;
44
+ /** Set for file/image columns: from the entity field type, or from a
45
+ * `{ format: "image" | "file" }` renderer. The cell shows a thumbnail or the
46
+ * file name instead of the FileRef UUID. */
47
+ readonly fileDisplay?: ListColumnFileDisplay;
37
48
  };
38
49
  export type ListRowViewModel = {
39
50
  readonly id: string;
@@ -55,6 +66,10 @@ export type EmbeddedListCellViewModel = {
55
66
  /** Only for `type: "select"`. */
56
67
  readonly options?: readonly string[];
57
68
  readonly optionLabels?: Readonly<Record<string, string>>;
69
+ /** Only for `type: "select"`; `"radio"` renders segmented buttons instead of a dropdown. */
70
+ readonly display?: "radio" | "dropdown";
71
+ /** Cell is shown but not editable (`EditFieldSpec.embeddedList.readOnlyCells`). */
72
+ readonly readOnly?: boolean;
58
73
  /** Only for `type: "reference"`. */
59
74
  readonly refEntity?: string;
60
75
  readonly refFeature?: string;
@@ -209,6 +224,9 @@ export type EditFieldViewModel = {
209
224
  }>>;
210
225
  /** Numeric sub-field names to sum in a totals row. From EmbeddedFieldDef.totals. */
211
226
  readonly embeddedListTotals?: readonly string[];
227
+ /** No add, remove, duplicate or move; cells stay editable. From
228
+ * EditFieldSpec.embeddedList.fixedRows. */
229
+ readonly embeddedListFixedRows?: boolean;
212
230
  /** Currency for the whole embedded list's money cells and totals row —
213
231
  * currency lives on the head aggregate (EntityDefinition.defaultCurrency),
214
232
  * not per row. Falls back to "EUR" when the entity has no defaultCurrency. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-headless",
3
- "version": "0.360.0",
3
+ "version": "0.362.0",
4
4
  "description": "Headless UI logic for Kumiko — Dispatcher contract, Form-Controller, View-Model, Nav-Resolver. Plattform- und React-frei; jeder Renderer (renderer, renderer-web, renderer-native, …) komponiert darauf.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -36,8 +36,8 @@
36
36
  }
37
37
  },
38
38
  "dependencies": {
39
- "@cosmicdrift/kumiko-framework": "0.360.0",
40
- "@cosmicdrift/kumiko-types": "0.360.0",
39
+ "@cosmicdrift/kumiko-framework": "0.362.0",
40
+ "@cosmicdrift/kumiko-types": "0.362.0",
41
41
  "temporal-polyfill": "^0.3.2",
42
42
  "zod": "^4.4.3"
43
43
  },
package/src/changes.json CHANGED
@@ -1,4 +1,22 @@
1
1
  [
2
+ {
3
+ "version": "0.361.0",
4
+ "type": "improvement",
5
+ "title": "Edit view model carries read-only cells and fixed rows",
6
+ "detail": "EmbeddedListCellViewModel.readOnly and EditFieldViewModel.embeddedListFixedRows are derived from EditFieldSpec.embeddedList."
7
+ },
8
+ {
9
+ "version": "0.361.0",
10
+ "type": "improvement",
11
+ "title": "Embedded list cells carry the select display",
12
+ "detail": "EmbeddedListCellViewModel.display is taken from the sub-field."
13
+ },
14
+ {
15
+ "version": "0.361.0",
16
+ "type": "improvement",
17
+ "title": "resolveNavigation no longer renders empty groups or entries that open nothing",
18
+ "detail": "A nav node with no screen, target, provider, actions and createAction is dropped when none of its children is visible, also when it never had children. Allowlisted sidebars lose groups whose children are all filtered out. Give the entry a screen or keep a child visible to retain it."
19
+ },
2
20
  {
3
21
  "version": "0.360.0",
4
22
  "type": "improvement",