@cosmicdrift/kumiko-renderer 0.387.0 → 0.388.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 (68) hide show
  1. package/dist/app/action-form-body.js +10 -2
  2. package/dist/app/action-form-support.d.ts +1 -0
  3. package/dist/app/action-form-support.js +1 -1
  4. package/dist/app/config-edit-body.js +6 -2
  5. package/dist/app/entity-edit-prefill.d.ts +11 -0
  6. package/dist/app/entity-edit-prefill.js +9 -4
  7. package/dist/app/extension-sections.d.ts +5 -3
  8. package/dist/app/form-schema.js +1 -1
  9. package/dist/app/header-actions-bar.js +1 -1
  10. package/dist/components/edit-mounts.d.ts +2 -2
  11. package/dist/components/embedded-list-field.js +1 -1
  12. package/dist/components/related-list-section.js +14 -1
  13. package/dist/components/render-edit-action-button.d.ts +1 -3
  14. package/dist/components/render-edit-action-button.js +2 -2
  15. package/dist/components/render-edit.js +11 -8
  16. package/dist/components/render-field.js +5 -2
  17. package/dist/components/render-list.js +31 -18
  18. package/dist/flow-diagram/render.js +9 -1
  19. package/dist/headless/form/form-controller.js +8 -0
  20. package/dist/headless/form/types.d.ts +1 -0
  21. package/dist/headless/format/index.js +89 -64
  22. package/dist/headless/view-model/edit.js +2 -0
  23. package/dist/headless/view-model/list.js +184 -149
  24. package/dist/headless/view-model/types.d.ts +3 -0
  25. package/dist/hooks/live-refetch-scheduler.js +4 -0
  26. package/dist/hooks/use-reference-lookup.d.ts +9 -1
  27. package/dist/hooks/use-reference-lookup.js +17 -3
  28. package/dist/i18n-defaults.js +6 -2
  29. package/dist/index.d.ts +1 -0
  30. package/dist/index.js +1 -0
  31. package/dist/primitives.d.ts +19 -0
  32. package/dist/web/app/create-app.js +8 -3
  33. package/dist/web/app/dashboard-body.js +17 -1
  34. package/dist/web/app/dashboard-panels.js +12 -2
  35. package/dist/web/index.d.ts +1 -1
  36. package/dist/web/index.js +1 -1
  37. package/dist/web/layout/shell-header.js +2 -1
  38. package/dist/web/layout/uniform-bar-controls.d.ts +1 -0
  39. package/dist/web/layout/uniform-bar-controls.js +4 -0
  40. package/dist/web/lib/file-ref-download.js +4 -1
  41. package/dist/web/primitives/action-menu.js +1 -1
  42. package/dist/web/primitives/code-input.d.ts +6 -0
  43. package/dist/web/primitives/code-input.js +31 -0
  44. package/dist/web/primitives/data-table.js +4 -3
  45. package/dist/web/primitives/facet-filter.js +1 -1
  46. package/dist/web/primitives/file-thumbnail.d.ts +3 -0
  47. package/dist/web/primitives/file-thumbnail.js +5 -0
  48. package/dist/web/primitives/input.js +6 -1
  49. package/dist/web/primitives/layout-elements.js +6 -4
  50. package/dist/web/primitives/page-header.js +3 -1
  51. package/dist/web/primitives/row-actions.js +3 -3
  52. package/dist/web/primitives/use-focus-on-mount.d.ts +2 -0
  53. package/dist/web/primitives/use-focus-on-mount.js +9 -0
  54. package/dist/web/widgets/bounded-load-cache.d.ts +1 -1
  55. package/dist/web/widgets/bounded-load-cache.js +10 -4
  56. package/dist/web/widgets/choice-cards.d.ts +17 -0
  57. package/dist/web/widgets/choice-cards.js +12 -0
  58. package/dist/web/widgets/collapsible-section.js +1 -1
  59. package/dist/web/widgets/detail-list.d.ts +5 -1
  60. package/dist/web/widgets/detail-list.js +5 -1
  61. package/dist/web/widgets/index.d.ts +1 -0
  62. package/dist/web/widgets/index.js +1 -0
  63. package/dist/web/widgets/pdf-preview.js +1 -1
  64. package/dist/web/widgets/plan-card.d.ts +2 -0
  65. package/dist/web/widgets/plan-card.js +1 -1
  66. package/dist/web/widgets/upload-zone.js +13 -5
  67. package/package.json +5 -5
  68. package/src/changes.json +49 -0
@@ -184,73 +184,98 @@ function unitDisplaySpec(spec) {
184
184
  const v = spec["unitDisplay"];
185
185
  return isOneOf(INTL_UNIT_DISPLAYS, v) ? v : "short";
186
186
  }
187
+ function isEmptyCellValue(value) {
188
+ return value === null || value === undefined || value === "";
189
+ }
187
190
  export function applyFormatSpec(spec, value, translate) {
188
- const isEmpty = value === null || value === undefined || value === "";
189
191
  // priority renders its emptyLabel for empty values — every other format
190
192
  // collapses empty to "".
191
- if (isEmpty && spec.format !== "priority")
193
+ if (isEmptyCellValue(value) && spec.format !== "priority")
192
194
  return "";
193
- switch (spec.format) {
194
- case "timestamp":
195
- case "date":
196
- return formatDateCell(value, spec.format, {
197
- locale: stringSpec(spec, "locale"),
198
- dateStyle: dateStyleSpec(spec, "dateStyle"),
199
- timeStyle: spec.format === "timestamp" ? dateStyleSpec(spec, "timeStyle") : undefined,
200
- timeZone: spec.format === "timestamp" ? stringSpec(spec, "timeZone") : undefined,
201
- });
202
- case "boolean": {
203
- if (value === true) {
204
- const trueLabel = stringSpec(spec, "trueLabel");
205
- return trueLabel === undefined ? "✓" : (translate?.(trueLabel) ?? trueLabel);
206
- }
207
- if (value === false) {
208
- const falseLabel = stringSpec(spec, "falseLabel");
209
- return falseLabel === undefined ? "" : (translate?.(falseLabel) ?? falseLabel);
210
- }
211
- return "";
212
- }
213
- case "currency": {
214
- const sym = stringSpec(spec, "symbol") ?? "";
215
- return sym.length > 0 ? `${value} ${sym}` : String(value);
216
- }
217
- case "number":
218
- case "decimal":
219
- case "bigInt":
220
- return formatNumberCell(value, stringSpec(spec, "locale"), booleanSpec(spec, "grouping") ?? true, spec.format === "bigInt" ? {} : fractionDigitsSpec(spec));
221
- case "unit":
222
- return formatUnitCell(value, stringSpec(spec, "unit"), stringSpec(spec, "locale"), unitDisplaySpec(spec));
223
- case "priority": {
224
- const emptyLabel = stringSpec(spec, "emptyLabel") ?? EMPTY_VALUE_PLACEHOLDER;
225
- const prefix = stringSpec(spec, "prefix") ?? "";
226
- if (isEmpty || value === 0)
227
- return emptyLabel;
228
- return `${prefix}${value}`;
229
- }
230
- case "enumOption": {
231
- const keyPrefix = stringSpec(spec, "keyPrefix");
232
- const labelFor = (item) => {
233
- const raw = typeof item === "string" ? item : String(item);
234
- if (keyPrefix === undefined || translate === undefined)
235
- return raw;
236
- const key = `${keyPrefix}${raw}`;
237
- const translated = translate(key);
238
- return translated === key ? raw : translated;
239
- };
240
- // multiSelect-style columns hold an array of option values
241
- return Array.isArray(value) ? value.map(labelFor).join(", ") : labelFor(value);
242
- }
243
- // Thumbnails and file links are drawn by the web list cell; this is the
244
- // text fallback for every other surface.
245
- case "image":
246
- case "file":
247
- return Array.isArray(value) ? value.join(", ") : String(value);
248
- case "json":
249
- return stringifyJsonOrString(value, typeof spec["indent"] === "number" ? spec["indent"] : 2);
250
- default:
251
- if (typeof process !== "undefined" && process.env.NODE_ENV !== "production") {
252
- rendererWarn(`[kumiko] applyFormatSpec: unknown format key "${spec.format}" — registered via FieldFormatRegistry module augmentation?`);
253
- }
254
- return typeof value === "string" ? value : String(value);
195
+ if (!isBuiltInCellFormat(spec.format))
196
+ return formatUnknownCellFormat(spec, value);
197
+ return CELL_FORMATTERS[spec.format](spec, value, translate);
198
+ }
199
+ function formatTemporalCell(spec, value) {
200
+ const isTimestamp = spec.format === "timestamp";
201
+ return formatDateCell(value, spec.format, {
202
+ locale: stringSpec(spec, "locale"),
203
+ dateStyle: dateStyleSpec(spec, "dateStyle"),
204
+ timeStyle: isTimestamp ? dateStyleSpec(spec, "timeStyle") : undefined,
205
+ timeZone: isTimestamp ? stringSpec(spec, "timeZone") : undefined,
206
+ });
207
+ }
208
+ function formatBooleanCell(spec, value, translate) {
209
+ if (value === true) {
210
+ const trueLabel = stringSpec(spec, "trueLabel");
211
+ return trueLabel === undefined ? "✓" : (translate?.(trueLabel) ?? trueLabel);
212
+ }
213
+ if (value === false) {
214
+ const falseLabel = stringSpec(spec, "falseLabel");
215
+ return falseLabel === undefined ? "" : (translate?.(falseLabel) ?? falseLabel);
216
+ }
217
+ return "";
218
+ }
219
+ function formatCurrencyCell(spec, value) {
220
+ const sym = stringSpec(spec, "symbol") ?? "";
221
+ return sym.length > 0 ? `${value} ${sym}` : String(value);
222
+ }
223
+ function formatNumericCell(spec, value) {
224
+ return formatNumberCell(value, stringSpec(spec, "locale"), booleanSpec(spec, "grouping") ?? true, spec.format === "bigInt" ? {} : fractionDigitsSpec(spec));
225
+ }
226
+ function formatUnitSpecCell(spec, value) {
227
+ return formatUnitCell(value, stringSpec(spec, "unit"), stringSpec(spec, "locale"), unitDisplaySpec(spec));
228
+ }
229
+ function formatPriorityCell(spec, value) {
230
+ const emptyLabel = stringSpec(spec, "emptyLabel") ?? EMPTY_VALUE_PLACEHOLDER;
231
+ const prefix = stringSpec(spec, "prefix") ?? "";
232
+ if (isEmptyCellValue(value) || value === 0)
233
+ return emptyLabel;
234
+ return `${prefix}${value}`;
235
+ }
236
+ function formatEnumOptionCell(spec, value, translate) {
237
+ const keyPrefix = stringSpec(spec, "keyPrefix");
238
+ const labelFor = (item) => {
239
+ const raw = typeof item === "string" ? item : String(item);
240
+ if (keyPrefix === undefined || translate === undefined)
241
+ return raw;
242
+ const key = `${keyPrefix}${raw}`;
243
+ const translated = translate(key);
244
+ return translated === key ? raw : translated;
245
+ };
246
+ // multiSelect-style columns hold an array of option values
247
+ return Array.isArray(value) ? value.map(labelFor).join(", ") : labelFor(value);
248
+ }
249
+ // Thumbnails and file links are drawn by the web list cell; this is the
250
+ // text fallback for every other surface.
251
+ function formatFileCell(_spec, value) {
252
+ return Array.isArray(value) ? value.join(", ") : String(value);
253
+ }
254
+ function formatJsonCell(spec, value) {
255
+ return stringifyJsonOrString(value, typeof spec["indent"] === "number" ? spec["indent"] : 2);
256
+ }
257
+ const CELL_FORMATTERS = {
258
+ timestamp: formatTemporalCell,
259
+ date: formatTemporalCell,
260
+ boolean: formatBooleanCell,
261
+ currency: formatCurrencyCell,
262
+ number: formatNumericCell,
263
+ decimal: formatNumericCell,
264
+ bigInt: formatNumericCell,
265
+ unit: formatUnitSpecCell,
266
+ priority: formatPriorityCell,
267
+ enumOption: formatEnumOptionCell,
268
+ image: formatFileCell,
269
+ file: formatFileCell,
270
+ json: formatJsonCell,
271
+ };
272
+ // Own-key check: a format such as "toString" must not resolve to an inherited member.
273
+ function isBuiltInCellFormat(format) {
274
+ return Object.hasOwn(CELL_FORMATTERS, format);
275
+ }
276
+ function formatUnknownCellFormat(spec, value) {
277
+ if (typeof process !== "undefined" && process.env.NODE_ENV !== "production") {
278
+ rendererWarn(`[kumiko] applyFormatSpec: unknown format key "${spec.format}" — registered via FieldFormatRegistry module augmentation?`);
255
279
  }
280
+ return typeof value === "string" ? value : String(value);
256
281
  }
@@ -469,6 +469,7 @@ export function computeEditViewModel(input) {
469
469
  .map((group) => ({
470
470
  title: translate(group.title),
471
471
  columns: group.columns ?? 2,
472
+ columnsDeclared: group.columns !== undefined,
472
473
  fields: group.fields.map((fieldSpec) => {
473
474
  const fieldName = normalizeEditField(fieldSpec).field;
474
475
  const fieldView = fields.find((f) => f.field === fieldName);
@@ -491,6 +492,7 @@ export function computeEditViewModel(input) {
491
492
  }),
492
493
  ...(sectionSpec.subtitle !== undefined && { subtitle: translate(sectionSpec.subtitle) }),
493
494
  columns: sectionSpec.columns ?? defaultColumns,
495
+ columnsDeclared: sectionSpec.columns !== undefined,
494
496
  fields,
495
497
  ...(groups !== undefined && { groups }),
496
498
  ...(sectionSpec.icon !== undefined && { icon: sectionSpec.icon }),
@@ -9,167 +9,202 @@ const WRITE_ONLY_SET_LABEL_KEY = "kumiko.field.writeOnly.set";
9
9
  // follow-up; this v1 assumes the caller passed a row-filter that drops any
10
10
  // field the user may not see.
11
11
  export function computeListViewModel(input) {
12
- const { screen, entity, rows, translate, featureName } = input;
13
- const columns = [];
14
- for (const spec of screen.columns) {
15
- const normalized = normalizeListColumn(spec);
16
- const storedFieldType = entity.fields[normalized.field]?.type;
17
- // A real non-text entity field keeps its own type (documented on
18
- // ListColumnSpec.refEntity); only the all-"text" pseudo-entities of the
19
- // projection shims and fields without a stored def take the declared path.
20
- if (normalized.refEntity !== undefined &&
21
- (storedFieldType === undefined || storedFieldType === "text")) {
22
- // Declared reference metadata (ListColumnSpec.refEntity) — for
23
- // projectionList/relatedList columns, which have no EntityDefinition
24
- // field to carry a real "reference" type. Checked before the
25
- // entity.fields lookup below so it also fires against the pseudo-
26
- // entities that hardcode every field as "text" (projection-list-shim,
27
- // related-list-section) — those never reach the reference branch
28
- // further down since fieldDef.type is always "text" there.
29
- const refTarget = parseRefTarget(normalized.refEntity, featureName);
30
- columns.push({
31
- field: normalized.field,
32
- label: translate(normalized.label ?? fieldLabelKey(featureName, screen.entity, normalized.field)),
33
- type: "reference",
34
- // The declared value is the referenced id, not the resolved label —
35
- // there is no column to server-sort by, same as row-meta references.
36
- sortable: false,
37
- refEntity: refTarget.entityName,
38
- refFeature: refTarget.featureName,
39
- refLabelField: normalized.refLabelField ?? "id",
40
- ...(normalized.renderer !== undefined && { renderer: normalized.renderer }),
41
- ...(normalized.hideOnNarrow === true && { hideOnNarrow: true }),
42
- });
43
- continue;
44
- }
45
- const fieldDef = entity.fields[normalized.field];
46
- if (!fieldDef) {
47
- // Not a stored field — may be a read-time derived field (value appended
48
- // by the list-query handler). Render it as its display valueType; derived
49
- // columns carry no reference/select metadata and never server-sort.
50
- const derivedDef = entity.derivedFields?.[normalized.field];
51
- if (!derivedDef) {
52
- // Row-meta column (id/tenantId/version/insertedAt/modifiedAt/...) —
53
- // a real base-table column that's never a declared entity field, e.g.
54
- // a SystemAdmin cross-tenant list exposing tenantId. Unlike the
55
- // derivedFields branch above, these are actual DB columns, so
56
- // server-side ORDER BY works — sortable stays true, except for the
57
- // ID columns in LIST_ROW_META_REFERENCES (see their reference branch).
58
- const rowMetaType = LIST_ROW_META_COLUMNS[normalized.field];
59
- if (rowMetaType !== undefined) {
60
- const rowMetaRef = LIST_ROW_META_REFERENCES[normalized.field];
61
- columns.push({
62
- field: normalized.field,
63
- label: translate(normalized.label ?? fieldLabelKey(featureName, screen.entity, normalized.field)),
64
- type: rowMetaRef !== undefined ? "reference" : rowMetaType,
65
- // The DB column holds the GUID, not the resolved label — server-side
66
- // ORDER BY would sort by GUID while the cell shows the name.
67
- sortable: rowMetaRef === undefined,
68
- ...(normalized.renderer !== undefined && { renderer: normalized.renderer }),
69
- ...(normalized.hideOnNarrow === true && { hideOnNarrow: true }),
70
- ...(rowMetaRef !== undefined && {
71
- refEntity: rowMetaRef.refEntity,
72
- refFeature: rowMetaRef.refFeature,
73
- refLabelField: rowMetaRef.refLabelField,
74
- }),
75
- });
76
- continue;
77
- }
78
- // A virtual presentational column — drawn entirely by a columnRenderer
79
- // component from the row (e.g. tag chips); value is undefined and it
80
- // never server-sorts. `field` is just the column key. It needs BOTH a
81
- // label AND a renderer — renderer is what actually draws it; a label
82
- // alone with no renderer would push an empty, unrendered column into
83
- // the view model. Missing either is an author typo (or a stale
84
- // field-rename) → fail loud instead of silently drawing nothing.
85
- if (normalized.label !== undefined && normalized.renderer !== undefined) {
86
- columns.push({
87
- field: normalized.field,
88
- label: translate(normalized.label),
89
- type: "text",
90
- sortable: false,
91
- renderer: normalized.renderer,
92
- ...(normalized.hideOnNarrow === true && { hideOnNarrow: true }),
93
- });
94
- continue;
95
- }
96
- throw new Error(`computeListViewModel: screen "${screen.id}" references unknown field "${normalized.field}" on entity "${screen.entity}"`);
97
- }
98
- columns.push({
99
- field: normalized.field,
100
- label: translate(normalized.label ?? fieldLabelKey(featureName, screen.entity, normalized.field)),
101
- type: derivedDef.valueType,
102
- // Display-only: a header sort would round-trip to the server, which has
103
- // no column to sort by (see DerivedFieldDef). Never offer the affordance.
104
- sortable: false,
105
- ...(normalized.renderer !== undefined && { renderer: normalized.renderer }),
106
- ...(normalized.hideOnNarrow === true && { hideOnNarrow: true }),
107
- });
108
- continue;
109
- }
110
- const label = translate(normalized.label ?? fieldLabelKey(featureName, screen.entity, normalized.field));
111
- // Reference fields may target the same feature ("user") or another feature
112
- // ("users:user"). parseRefTarget returns the feature and entity names, which
113
- // the renderer uses to build the lookup query as
114
- // `<refFeature>:query:<refEntity>:list`.
115
- const refRaw = fieldDef.type === "reference" ? fieldDef.entity : undefined;
116
- const refTarget = refRaw !== undefined ? parseRefTarget(refRaw, featureName) : undefined;
117
- const refEntity = refTarget?.entityName;
118
- const refFeature = refTarget?.featureName;
119
- const refLabelField = fieldDef.type === "reference" ? (fieldDef.labelField ?? "id") : undefined;
120
- // For select and multiSelect fields: bake in translated option labels.
121
- // Convention matches the form path → one translation source for list +
122
- // form. A missing key returns the key itself per convention; the
123
- // renderer then falls back to humanizeSlug.
124
- const optionLabels = fieldDef.type === "select" || fieldDef.type === "multiSelect"
125
- ? buildOptionLabels(translate, (value) => fieldOptionLabelKey(featureName, screen.entity, normalized.field, value), fieldDef.options)
126
- : undefined;
127
- const optionTones = fieldDef.type === "select" ? fieldDef.optionTones : undefined;
128
- const grouping = fieldDef.type === "number" ? fieldDef.grouping : undefined;
129
- const fileDisplay = deriveListFileDisplay(fieldDef, normalized.renderer);
130
- const column = {
131
- field: normalized.field,
132
- label,
133
- type: fieldDef.type,
134
- sortable: fieldIsSortable(fieldDef),
135
- ...(normalized.renderer !== undefined && { renderer: normalized.renderer }),
136
- ...(optionLabels !== undefined && { optionLabels }),
137
- ...(optionTones !== undefined && { optionTones }),
138
- ...(refEntity !== undefined && { refEntity }),
139
- ...(refFeature !== undefined && { refFeature }),
140
- ...(refLabelField !== undefined && { refLabelField }),
141
- ...(grouping !== undefined && { grouping }),
142
- ...(fileDisplay !== undefined && { fileDisplay }),
143
- ...(normalized.hideOnNarrow === true && { hideOnNarrow: true }),
144
- };
145
- columns.push(column);
12
+ const { screen, rows } = input;
13
+ const columns = screen.columns.map((spec) => resolveListColumn(input, normalizeListColumn(spec)));
14
+ const listRows = rows.map(writeOnlyRowFormatter(input));
15
+ return {
16
+ screenId: screen.id,
17
+ entityName: screen.entity,
18
+ columns,
19
+ rows: listRows,
20
+ ...(screen.slots && { slots: screen.slots }),
21
+ isEmpty: listRows.length === 0,
22
+ };
23
+ }
24
+ // First match wins; the order encodes which metadata source a column name
25
+ // resolves against when several could claim it.
26
+ const LIST_COLUMN_RESOLVERS = [
27
+ declaredReferenceColumn,
28
+ storedFieldColumn,
29
+ derivedFieldColumn,
30
+ rowMetaColumn,
31
+ virtualRendererColumn,
32
+ ];
33
+ function resolveListColumn(input, column) {
34
+ for (const resolve of LIST_COLUMN_RESOLVERS) {
35
+ const resolved = resolve(input, column);
36
+ if (resolved)
37
+ return resolved;
146
38
  }
39
+ // No metadata source claims the name: an author typo or a stale field
40
+ // rename, so fail loud instead of silently drawing nothing.
41
+ throw new Error(`computeListViewModel: screen "${input.screen.id}" references unknown field "${column.field}" on entity "${input.screen.entity}"`);
42
+ }
43
+ function translatedColumnLabel(input, column) {
44
+ return input.translate(column.label ?? fieldLabelKey(input.featureName, input.screen.entity, column.field));
45
+ }
46
+ // A real non-text entity field keeps its own type (documented on
47
+ // ListColumnSpec.refEntity); only the all-"text" pseudo-entities of the
48
+ // projection shims and fields without a stored def take the declared path.
49
+ function declaredReferenceColumn(input, column) {
50
+ const storedFieldType = input.entity.fields[column.field]?.type;
51
+ // skip: no declared reference, or a real non-text field that keeps its own type
52
+ if (column.refEntity === undefined ||
53
+ (storedFieldType !== undefined && storedFieldType !== "text")) {
54
+ return undefined;
55
+ }
56
+ // Declared reference metadata (ListColumnSpec.refEntity) — for
57
+ // projectionList/relatedList columns, which have no EntityDefinition
58
+ // field to carry a real "reference" type. Checked before the
59
+ // entity.fields lookup so it also fires against the pseudo-entities
60
+ // that hardcode every field as "text" (projection-list-shim,
61
+ // related-list-section) — those never reach the stored reference branch
62
+ // since fieldDef.type is always "text" there.
63
+ const refTarget = parseRefTarget(column.refEntity, input.featureName);
64
+ return {
65
+ field: column.field,
66
+ label: translatedColumnLabel(input, column),
67
+ type: "reference",
68
+ // The declared value is the referenced id, not the resolved label —
69
+ // there is no column to server-sort by, same as row-meta references.
70
+ sortable: false,
71
+ refEntity: refTarget.entityName,
72
+ refFeature: refTarget.featureName,
73
+ refLabelField: column.refLabelField ?? "id",
74
+ ...(column.renderer !== undefined && { renderer: column.renderer }),
75
+ ...(column.hideOnNarrow === true && { hideOnNarrow: true }),
76
+ };
77
+ }
78
+ function storedFieldColumn(input, column) {
79
+ const fieldDef = input.entity.fields[column.field];
80
+ // skip: not a stored field, a later resolver decides
81
+ if (!fieldDef)
82
+ return undefined;
83
+ return {
84
+ field: column.field,
85
+ label: translatedColumnLabel(input, column),
86
+ type: fieldDef.type,
87
+ sortable: fieldIsSortable(fieldDef),
88
+ ...(column.renderer !== undefined && { renderer: column.renderer }),
89
+ ...storedFieldMetadata(input, column, fieldDef),
90
+ ...(column.hideOnNarrow === true && { hideOnNarrow: true }),
91
+ };
92
+ }
93
+ // Key order is part of the serialized view model, so it stays fixed here.
94
+ function storedFieldMetadata(input, column, fieldDef) {
95
+ // For select and multiSelect fields: bake in translated option labels.
96
+ // Convention matches the form path → one translation source for list +
97
+ // form. A missing key returns the key itself per convention; the
98
+ // renderer then falls back to humanizeSlug.
99
+ const optionLabels = fieldDef.type === "select" || fieldDef.type === "multiSelect"
100
+ ? buildOptionLabels(input.translate, (value) => fieldOptionLabelKey(input.featureName, input.screen.entity, column.field, value), fieldDef.options)
101
+ : undefined;
102
+ const optionTones = fieldDef.type === "select" ? fieldDef.optionTones : undefined;
103
+ const grouping = fieldDef.type === "number" ? fieldDef.grouping : undefined;
104
+ const fileDisplay = deriveListFileDisplay(fieldDef, column.renderer);
105
+ return {
106
+ ...(optionLabels !== undefined && { optionLabels }),
107
+ ...(optionTones !== undefined && { optionTones }),
108
+ ...storedReferenceMetadata(fieldDef, input.featureName),
109
+ ...(grouping !== undefined && { grouping }),
110
+ ...(fileDisplay !== undefined && { fileDisplay }),
111
+ };
112
+ }
113
+ // Reference fields may target the same feature ("user") or another feature
114
+ // ("users:user"). parseRefTarget returns the feature and entity names, which
115
+ // the renderer uses to build the lookup query as
116
+ // `<refFeature>:query:<refEntity>:list`.
117
+ function storedReferenceMetadata(fieldDef, featureName) {
118
+ if (fieldDef.type !== "reference")
119
+ return {};
120
+ const refTarget = parseRefTarget(fieldDef.entity, featureName);
121
+ return {
122
+ refEntity: refTarget.entityName,
123
+ refFeature: refTarget.featureName,
124
+ refLabelField: fieldDef.labelField ?? "id",
125
+ };
126
+ }
127
+ // Not a stored field — may be a read-time derived field (value appended
128
+ // by the list-query handler). Render it as its display valueType; derived
129
+ // columns carry no reference/select metadata and never server-sort.
130
+ function derivedFieldColumn(input, column) {
131
+ const derivedDef = input.entity.derivedFields?.[column.field];
132
+ // skip: not a derived field, a later resolver decides
133
+ if (!derivedDef)
134
+ return undefined;
135
+ return {
136
+ field: column.field,
137
+ label: translatedColumnLabel(input, column),
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,
142
+ ...(column.renderer !== undefined && { renderer: column.renderer }),
143
+ ...(column.hideOnNarrow === true && { hideOnNarrow: true }),
144
+ };
145
+ }
146
+ // Row-meta column (id/tenantId/version/insertedAt/modifiedAt/...) —
147
+ // a real base-table column that's never a declared entity field, e.g.
148
+ // a SystemAdmin cross-tenant list exposing tenantId. Unlike derived
149
+ // fields, these are actual DB columns, so server-side ORDER BY works —
150
+ // sortable stays true, except for the ID columns in LIST_ROW_META_REFERENCES.
151
+ function rowMetaColumn(input, column) {
152
+ const rowMetaType = LIST_ROW_META_COLUMNS[column.field];
153
+ // skip: not a base-table column, a later resolver decides
154
+ if (rowMetaType === undefined)
155
+ return undefined;
156
+ const rowMetaRef = LIST_ROW_META_REFERENCES[column.field];
157
+ return {
158
+ field: column.field,
159
+ label: translatedColumnLabel(input, column),
160
+ type: rowMetaRef !== undefined ? "reference" : rowMetaType,
161
+ // The DB column holds the GUID, not the resolved label — server-side
162
+ // ORDER BY would sort by GUID while the cell shows the name.
163
+ sortable: rowMetaRef === undefined,
164
+ ...(column.renderer !== undefined && { renderer: column.renderer }),
165
+ ...(column.hideOnNarrow === true && { hideOnNarrow: true }),
166
+ ...(rowMetaRef !== undefined && {
167
+ refEntity: rowMetaRef.refEntity,
168
+ refFeature: rowMetaRef.refFeature,
169
+ refLabelField: rowMetaRef.refLabelField,
170
+ }),
171
+ };
172
+ }
173
+ // A virtual presentational column — drawn entirely by a columnRenderer
174
+ // component from the row (e.g. tag chips); value is undefined and it
175
+ // never server-sorts. `field` is just the column key. It needs BOTH a
176
+ // label AND a renderer — renderer is what actually draws it; a label
177
+ // alone with no renderer would push an empty, unrendered column into
178
+ // the view model.
179
+ function virtualRendererColumn(input, column) {
180
+ // skip: without both a label and a renderer the column is unknown
181
+ if (column.label === undefined || column.renderer === undefined)
182
+ return undefined;
183
+ return {
184
+ field: column.field,
185
+ label: input.translate(column.label),
186
+ type: "text",
187
+ sortable: false,
188
+ renderer: column.renderer,
189
+ ...(column.hideOnNarrow === true && { hideOnNarrow: true }),
190
+ };
191
+ }
192
+ // The wire carries `true` (set) / `null` (empty) for writeOnly fields; the cell shows a word, not "true".
193
+ function writeOnlyRowFormatter(input) {
194
+ const { screen, entity, translate } = input;
147
195
  const writeOnlyFieldNames = screen.columns
148
196
  .map((spec) => normalizeListColumn(spec).field)
149
197
  .filter((name) => {
150
198
  const fieldDef = entity.fields[name];
151
199
  return fieldDef?.type === "text" && fieldDef.writeOnly === true;
152
200
  });
153
- // The wire carries `true` (set) / `null` (empty) for writeOnly fields; the cell shows a word, not "true".
154
- const showWriteOnlyAsWord = (row) => {
201
+ return (row) => {
155
202
  const shown = { ...row };
156
203
  for (const name of writeOnlyFieldNames) {
157
204
  if (row[name] === true)
158
205
  shown[name] = translate(WRITE_ONLY_SET_LABEL_KEY);
159
206
  }
160
- return shown;
161
- };
162
- const listRows = rows.map((row) => ({
163
- id: String(row["id"] ?? ""),
164
- values: showWriteOnlyAsWord(row),
165
- }));
166
- return {
167
- screenId: screen.id,
168
- entityName: screen.entity,
169
- columns,
170
- rows: listRows,
171
- ...(screen.slots && { slots: screen.slots }),
172
- isEmpty: listRows.length === 0,
207
+ return { id: String(row["id"] ?? ""), values: shown };
173
208
  };
174
209
  }
175
210
  // Field-i18n-key convention matches what features register translations
@@ -300,12 +300,15 @@ export type EditFieldsSectionViewModel = {
300
300
  /** Translated `EditFieldsSection.subtitle` (wizard step rail / up-next box). */
301
301
  readonly subtitle?: string;
302
302
  readonly columns: number;
303
+ /** True when the author declared `columns`; false means the tabs/stacked default. */
304
+ readonly columnsDeclared?: boolean;
303
305
  readonly fields: readonly EditFieldViewModel[];
304
306
  /** From `EditFieldsSection.groups` — each entry's `fields` are the same
305
307
  * view-model objects as `fields` above, re-grouped (not recomputed). */
306
308
  readonly groups?: readonly {
307
309
  readonly title: string;
308
310
  readonly columns: number;
311
+ readonly columnsDeclared?: boolean;
309
312
  readonly fields: readonly EditFieldViewModel[];
310
313
  }[];
311
314
  /** From `EditFieldsSection.icon` — closed IconKey vocabulary, renders
@@ -12,6 +12,10 @@ export function createLiveRefetchScheduler(run, windowMs = LIVE_REFETCH_COALESCE
12
12
  try {
13
13
  await run();
14
14
  }
15
+ catch {
16
+ // The caller surfaces its own load errors in state; a failed run must
17
+ // neither become an unhandled rejection nor drop the queued follow-up.
18
+ }
15
19
  finally {
16
20
  inFlight = false;
17
21
  }
@@ -13,10 +13,18 @@ export declare function referenceOptionSource(field: {
13
13
  readonly queryQn: string;
14
14
  readonly labelKey: string;
15
15
  };
16
- /** Empty while the query loads (or fails), so callers fall back to the raw id.
16
+ export declare function isCompleteRowPage(data: {
17
+ readonly nextCursor?: string | null;
18
+ } | null): boolean;
19
+ /** `map` holds the resolved labels; while the query loads or has failed an id is
20
+ * simply not in it. Callers decide the fallback: the raw id, or the "Unknown"
21
+ * label for entities listed in `UNRESOLVED_REFERENCE_LABEL_KEYS` once the
22
+ * lookup is complete.
17
23
  * `featureName` is the reference's target feature, not the screen's. */
18
24
  export declare function useReferenceLookup(featureName: string, refEntity: string, labelField: string): {
19
25
  readonly map: ReferenceLookupMap;
20
26
  readonly loading: boolean;
21
27
  readonly failed: boolean;
28
+ /** False when the lookup page was capped: absent ids may still exist. */
29
+ readonly complete: boolean;
22
30
  };
@@ -9,7 +9,8 @@
9
9
  // of N+1) but would have to extend the executor API with lookupTables.
10
10
  // The renderer-side path wins for the MVP.
11
11
  // - limit:200 is a hard UX limit: with more entries in the referenced
12
- // entity, the last rows show only UUIDs. A searchable combobox plus a
12
+ // entity, the last rows fall back to the raw id (or "Unknown" for
13
+ // opt-in entities once the lookup is complete). A searchable combobox plus a
13
14
  // server-side eager load with an ID whitelist will lift that later.
14
15
  //
15
16
  // The hook is call-stable: useQuery's internal cache shares the lookup map
@@ -44,7 +45,15 @@ export function referenceOptionSource(field, refFeature, refEntity) {
44
45
  }
45
46
  return referenceLookupSource(refFeature, refEntity, field.refLabelField ?? "id");
46
47
  }
47
- /** Empty while the query loads (or fails), so callers fall back to the raw id.
48
+ // A page with a continuation cursor is only a prefix of the referenced rows,
49
+ // so a missing id in it proves nothing about the row's existence.
50
+ export function isCompleteRowPage(data) {
51
+ return data !== null && (data.nextCursor ?? null) === null;
52
+ }
53
+ /** `map` holds the resolved labels; while the query loads or has failed an id is
54
+ * simply not in it. Callers decide the fallback: the raw id, or the "Unknown"
55
+ * label for entities listed in `UNRESOLVED_REFERENCE_LABEL_KEYS` once the
56
+ * lookup is complete.
48
57
  * `featureName` is the reference's target feature, not the screen's. */
49
58
  export function useReferenceLookup(featureName, refEntity, labelField) {
50
59
  const { queryQn, labelKey } = referenceLookupSource(featureName, refEntity, labelField);
@@ -72,5 +81,10 @@ export function useReferenceLookup(featureName, refEntity, labelField) {
72
81
  }
73
82
  return out;
74
83
  }, [result.data, labelKey, systemLabel, translate]);
75
- return { map, loading: result.loading, failed: result.error !== null };
84
+ return {
85
+ map,
86
+ loading: result.loading,
87
+ failed: result.error !== null,
88
+ complete: isCompleteRowPage(result.data),
89
+ };
76
90
  }
@@ -1,6 +1,10 @@
1
- // Framework default bundle. These strings are required by renderer components (Save/Cancel/Delete buttons, empty states, search placeholder, navigation-toggle ARIA labels, and validation reasons). createKumikoApp adds this as the last fallback in LocaleProvider, while apps can override individual keys via clientFeatures.translations.
1
+ // Framework default bundle. These strings are required by renderer components (Save/Cancel/Delete
2
+ // buttons, empty states, search placeholder, navigation-toggle ARIA labels, and validation
3
+ // reasons). createKumikoApp adds this as the last fallback in LocaleProvider, while apps can
4
+ // override individual keys via clientFeatures.translations.
2
5
  //
3
- // All keys use the `kumiko.` prefix to avoid collisions with app keys. Subpaths group keys by area (actions / list / nav / form / validation).
6
+ // All keys use the `kumiko.` prefix to avoid collisions with app keys. Subpaths group keys by area
7
+ // (actions / list / nav / form / validation).
4
8
  export const kumikoDefaultTranslations = {
5
9
  en: {
6
10
  "kumiko.actions.save": "Save",