@cosmicdrift/kumiko-renderer 0.386.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 (222) hide show
  1. package/LICENSE +21 -57
  2. package/dist/app/action-form-body.d.ts +2 -2
  3. package/dist/app/action-form-body.js +12 -4
  4. package/dist/app/action-form-shim.d.ts +8 -8
  5. package/dist/app/action-form-shim.js +20 -22
  6. package/dist/app/action-form-support.d.ts +1 -0
  7. package/dist/app/action-form-support.js +1 -1
  8. package/dist/app/app-features-context.js +4 -4
  9. package/dist/app/column-renderers.d.ts +7 -9
  10. package/dist/app/column-renderers.js +15 -18
  11. package/dist/app/config-edit-body.js +7 -3
  12. package/dist/app/config-edit-shim.d.ts +7 -8
  13. package/dist/app/config-edit-shim.js +18 -20
  14. package/dist/app/custom-screens.d.ts +3 -3
  15. package/dist/app/custom-screens.js +8 -8
  16. package/dist/app/dashboard-body.d.ts +2 -2
  17. package/dist/app/dashboard-body.js +2 -2
  18. package/dist/app/entity-edit-prefill.d.ts +11 -0
  19. package/dist/app/entity-edit-prefill.js +18 -13
  20. package/dist/app/entity-edit-screen.js +4 -4
  21. package/dist/app/entity-list-screen.js +3 -3
  22. package/dist/app/extension-form-submit.d.ts +1 -1
  23. package/dist/app/extension-form-submit.js +14 -15
  24. package/dist/app/extension-sections.d.ts +29 -28
  25. package/dist/app/extension-sections.js +10 -10
  26. package/dist/app/feature-schema.js +11 -11
  27. package/dist/app/form-schema.js +2 -2
  28. package/dist/app/header-actions-bar.js +2 -2
  29. package/dist/app/kumiko-screen.js +8 -10
  30. package/dist/app/layout-fields.js +1 -1
  31. package/dist/app/list-facets.js +3 -3
  32. package/dist/app/nav.d.ts +10 -10
  33. package/dist/app/nav.js +13 -13
  34. package/dist/app/projection-detail-body.js +4 -4
  35. package/dist/app/projection-detail-shim.d.ts +6 -5
  36. package/dist/app/projection-detail-shim.js +23 -22
  37. package/dist/app/projection-list-body.js +10 -10
  38. package/dist/app/projection-list-shim.d.ts +5 -5
  39. package/dist/app/projection-list-shim.js +17 -18
  40. package/dist/app/qn.js +10 -12
  41. package/dist/app/qualify-screen-id.js +1 -1
  42. package/dist/app/screen-access.js +2 -2
  43. package/dist/app/screen-shared.js +1 -1
  44. package/dist/app/secret-mint-body.js +4 -4
  45. package/dist/app/write-failed-error.js +8 -7
  46. package/dist/components/edit-mounts.d.ts +2 -2
  47. package/dist/components/embedded-list-field.js +5 -5
  48. package/dist/components/grid-cell-for-field.d.ts +1 -1
  49. package/dist/components/reference-create-dialog.js +3 -3
  50. package/dist/components/related-list-section.d.ts +1 -1
  51. package/dist/components/related-list-section.js +15 -2
  52. package/dist/components/render-edit-action-button.d.ts +1 -3
  53. package/dist/components/render-edit-action-button.js +2 -2
  54. package/dist/components/render-edit-logic.js +1 -1
  55. package/dist/components/render-edit-types.d.ts +6 -6
  56. package/dist/components/render-edit.js +74 -73
  57. package/dist/components/render-field.d.ts +7 -7
  58. package/dist/components/render-field.js +62 -61
  59. package/dist/components/render-list.d.ts +42 -43
  60. package/dist/components/render-list.js +93 -84
  61. package/dist/components/use-wizard-form-draft.js +10 -10
  62. package/dist/context/dispatcher-context.js +5 -5
  63. package/dist/context/user-roles-context.js +2 -2
  64. package/dist/flow-diagram/render.js +9 -1
  65. package/dist/headless/apex/index.d.ts +1 -2
  66. package/dist/headless/apex/index.js +5 -6
  67. package/dist/headless/form/form-controller.js +14 -6
  68. package/dist/headless/form/types.d.ts +1 -0
  69. package/dist/headless/form/zod-bridge.js +2 -2
  70. package/dist/headless/format/index.js +89 -64
  71. package/dist/headless/format/money.js +1 -1
  72. package/dist/headless/index.js +2 -2
  73. package/dist/headless/view-model/edit.js +4 -2
  74. package/dist/headless/view-model/list.js +184 -150
  75. package/dist/headless/view-model/types.d.ts +6 -3
  76. package/dist/hooks/live-refetch-scheduler.js +4 -0
  77. package/dist/hooks/reference-limits.js +11 -12
  78. package/dist/hooks/use-list-url-state.d.ts +13 -13
  79. package/dist/hooks/use-list-url-state.js +21 -22
  80. package/dist/hooks/use-query.js +3 -3
  81. package/dist/hooks/use-reference-lookup.d.ts +9 -1
  82. package/dist/hooks/use-reference-lookup.js +32 -19
  83. package/dist/hooks/use-store.js +18 -22
  84. package/dist/hooks/use-stream-handler.js +4 -2
  85. package/dist/i18n-defaults.js +6 -8
  86. package/dist/i18n.d.ts +10 -10
  87. package/dist/i18n.js +25 -25
  88. package/dist/index.d.ts +1 -0
  89. package/dist/index.js +6 -4
  90. package/dist/live/dispatcher-live.js +2 -2
  91. package/dist/primitives.d.ts +244 -221
  92. package/dist/primitives.js +1 -1
  93. package/dist/sse/live-events.d.ts +5 -5
  94. package/dist/sse/live-events.js +17 -17
  95. package/dist/tokens.d.ts +8 -8
  96. package/dist/tokens.js +16 -17
  97. package/dist/web/app/browser-locale.d.ts +9 -9
  98. package/dist/web/app/browser-locale.js +19 -19
  99. package/dist/web/app/client-plugin.d.ts +44 -52
  100. package/dist/web/app/client-plugin.js +4 -5
  101. package/dist/web/app/create-app.d.ts +28 -31
  102. package/dist/web/app/create-app.js +29 -25
  103. package/dist/web/app/dashboard-body.js +17 -1
  104. package/dist/web/app/dashboard-panels.js +12 -2
  105. package/dist/web/app/document-lang-sync.js +1 -1
  106. package/dist/web/app/draft-storage.js +1 -1
  107. package/dist/web/app/nav-providers-context.d.ts +3 -3
  108. package/dist/web/app/nav-providers-context.js +3 -3
  109. package/dist/web/app/nav.d.ts +18 -17
  110. package/dist/web/app/nav.js +66 -64
  111. package/dist/web/app/rich-content-editor.js +1 -1
  112. package/dist/web/components/config-cascade.js +17 -18
  113. package/dist/web/components/config-source-badge.js +4 -4
  114. package/dist/web/index.d.ts +1 -1
  115. package/dist/web/index.js +1 -1
  116. package/dist/web/layout/app-layout.d.ts +9 -9
  117. package/dist/web/layout/app-layout.js +2 -2
  118. package/dist/web/layout/avatar.d.ts +4 -3
  119. package/dist/web/layout/avatar.js +15 -17
  120. package/dist/web/layout/default-app-shell.d.ts +22 -23
  121. package/dist/web/layout/editor-panel.js +3 -3
  122. package/dist/web/layout/nav-tree.js +86 -84
  123. package/dist/web/layout/profile-menu.d.ts +1 -1
  124. package/dist/web/layout/shell-header.js +2 -1
  125. package/dist/web/layout/sidebar-brand.d.ts +7 -7
  126. package/dist/web/layout/sidebar-user.d.ts +1 -1
  127. package/dist/web/layout/sidebar-user.js +4 -4
  128. package/dist/web/layout/sidebar.d.ts +8 -8
  129. package/dist/web/layout/sidebar.js +7 -7
  130. package/dist/web/layout/target-resolver-stub.d.ts +5 -5
  131. package/dist/web/layout/target-resolver-stub.js +20 -20
  132. package/dist/web/layout/target-url.d.ts +7 -7
  133. package/dist/web/layout/target-url.js +22 -22
  134. package/dist/web/layout/theme-toggle.js +4 -5
  135. package/dist/web/layout/uniform-bar-controls.d.ts +1 -0
  136. package/dist/web/layout/uniform-bar-controls.js +4 -0
  137. package/dist/web/layout/workspace-shell.d.ts +21 -21
  138. package/dist/web/layout/workspace-shell.js +29 -30
  139. package/dist/web/lib/cn.d.ts +4 -4
  140. package/dist/web/lib/cn.js +4 -4
  141. package/dist/web/lib/file-ref-download.js +4 -1
  142. package/dist/web/primitives/action-menu.d.ts +8 -8
  143. package/dist/web/primitives/action-menu.js +14 -13
  144. package/dist/web/primitives/calendar-popover.d.ts +1 -1
  145. package/dist/web/primitives/calendar-popover.js +10 -10
  146. package/dist/web/primitives/card-styles.js +1 -1
  147. package/dist/web/primitives/card.js +2 -2
  148. package/dist/web/primitives/code-input.d.ts +6 -0
  149. package/dist/web/primitives/code-input.js +31 -0
  150. package/dist/web/primitives/combobox.d.ts +12 -12
  151. package/dist/web/primitives/combobox.js +34 -35
  152. package/dist/web/primitives/data-table-cell.js +2 -2
  153. package/dist/web/primitives/data-table.js +6 -6
  154. package/dist/web/primitives/date-field.js +2 -2
  155. package/dist/web/primitives/date-input.js +4 -4
  156. package/dist/web/primitives/default-form.js +2 -2
  157. package/dist/web/primitives/dropdown-menu.js +14 -15
  158. package/dist/web/primitives/embedded-list-input.js +3 -3
  159. package/dist/web/primitives/facet-filter.js +2 -2
  160. package/dist/web/primitives/field.js +9 -8
  161. package/dist/web/primitives/file-thumbnail.d.ts +3 -0
  162. package/dist/web/primitives/file-thumbnail.js +5 -0
  163. package/dist/web/primitives/form.d.ts +1 -1
  164. package/dist/web/primitives/form.js +6 -6
  165. package/dist/web/primitives/input.js +8 -3
  166. package/dist/web/primitives/layout-elements.js +7 -5
  167. package/dist/web/primitives/layout.js +2 -2
  168. package/dist/web/primitives/located-timestamp-input.js +4 -4
  169. package/dist/web/primitives/money-input.js +13 -17
  170. package/dist/web/primitives/page-header.js +3 -1
  171. package/dist/web/primitives/pager.js +1 -1
  172. package/dist/web/primitives/row-actions.js +6 -7
  173. package/dist/web/primitives/secret-reveal.js +1 -1
  174. package/dist/web/primitives/timestamp-input.d.ts +9 -9
  175. package/dist/web/primitives/timestamp-input.js +21 -21
  176. package/dist/web/primitives/toast.js +12 -13
  177. package/dist/web/primitives/use-focus-on-mount.d.ts +2 -0
  178. package/dist/web/primitives/use-focus-on-mount.js +9 -0
  179. package/dist/web/sse/live-events.d.ts +6 -6
  180. package/dist/web/sse/live-events.js +3 -3
  181. package/dist/web/tokens.d.ts +11 -12
  182. package/dist/web/tokens.js +35 -37
  183. package/dist/web/version/update-checker.js +14 -13
  184. package/dist/web/widgets/ai-text-field.js +1 -1
  185. package/dist/web/widgets/bounded-load-cache.d.ts +1 -1
  186. package/dist/web/widgets/bounded-load-cache.js +10 -4
  187. package/dist/web/widgets/charts.d.ts +10 -10
  188. package/dist/web/widgets/charts.js +8 -8
  189. package/dist/web/widgets/choice-cards.d.ts +17 -0
  190. package/dist/web/widgets/choice-cards.js +12 -0
  191. package/dist/web/widgets/collapsible-section.d.ts +3 -3
  192. package/dist/web/widgets/collapsible-section.js +7 -7
  193. package/dist/web/widgets/detail-list.d.ts +5 -3
  194. package/dist/web/widgets/detail-list.js +5 -3
  195. package/dist/web/widgets/feed-list.d.ts +2 -2
  196. package/dist/web/widgets/feed-list.js +2 -2
  197. package/dist/web/widgets/form-fields.d.ts +14 -14
  198. package/dist/web/widgets/form-fields.js +14 -13
  199. package/dist/web/widgets/index.d.ts +1 -0
  200. package/dist/web/widgets/index.js +5 -4
  201. package/dist/web/widgets/infinity-list.js +3 -3
  202. package/dist/web/widgets/mode-switch.d.ts +2 -2
  203. package/dist/web/widgets/mode-switch.js +2 -2
  204. package/dist/web/widgets/pdf-preview.js +1 -1
  205. package/dist/web/widgets/plan-card.d.ts +2 -0
  206. package/dist/web/widgets/plan-card.js +1 -1
  207. package/dist/web/widgets/progress-list.d.ts +1 -2
  208. package/dist/web/widgets/progress-list.js +1 -2
  209. package/dist/web/widgets/query-table.d.ts +6 -6
  210. package/dist/web/widgets/result-panel.d.ts +16 -16
  211. package/dist/web/widgets/result-panel.js +10 -10
  212. package/dist/web/widgets/section-card.d.ts +3 -3
  213. package/dist/web/widgets/section-card.js +3 -3
  214. package/dist/web/widgets/stat.d.ts +4 -4
  215. package/dist/web/widgets/stat.js +6 -6
  216. package/dist/web/widgets/states.d.ts +4 -4
  217. package/dist/web/widgets/states.js +4 -4
  218. package/dist/web/widgets/upload-zone.js +13 -5
  219. package/dist/web/widgets/use-draft.d.ts +7 -7
  220. package/dist/web/widgets/use-draft.js +7 -7
  221. package/package.json +6 -6
  222. 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
  }
@@ -1,7 +1,7 @@
1
1
  // Currency decimal places — covers the world's zero- and three-decimal
2
2
  // outliers, defaults to 2 for everything else. Shared between renderer/web's
3
3
  // MoneyInput (minor-unit display factor) and RenderField's minor/major-unit
4
- // conversion (kumiko-framework#1923) — a single source keeps both sides
4
+ // conversion — a single source keeps both sides
5
5
  // scaling the same currency the same way.
6
6
  export function currencyDecimals(code) {
7
7
  if (code === "JPY" || code === "KRW" || code === "VND" || code === "ISK")
@@ -1,5 +1,5 @@
1
- // Re-Export aus framework/ui-types damit Renderer-Code denselben
2
- // Parser nutzt wie der Server-Boot-Validator (Cross-Feature-Refs).
1
+ // Re-exports from framework/ui-types so renderer code uses the same
2
+ // parser as the server boot validator (cross-feature references).
3
3
  export { parseRefTarget } from "@cosmicdrift/kumiko-framework/ui-types";
4
4
  export { generateRequestId, StreamFrame } from "./dispatcher/index.js";
5
5
  export { createFormController, groupIssuesByPath, I18N_KEY_PARAM, relevantFieldIssues, zodErrorToFieldIssues, } from "./form/index.js";
@@ -52,7 +52,7 @@ function wasEmptiedByWriteExclusion(declaredFieldSpecs, remainingFields) {
52
52
  return declaredFieldSpecs.length > 0 && remainingFields.length === 0;
53
53
  }
54
54
  // relatedList runs its own query — nothing here to resolve against
55
- // entity/values, the spec passes through verbatim (fw#2166).
55
+ // entity/values, the spec passes through verbatim.
56
56
  export function computeRelatedListSectionViewModel(sectionSpec, translate) {
57
57
  return {
58
58
  kind: "relatedList",
@@ -461,7 +461,7 @@ export function computeEditViewModel(input) {
461
461
  const visible = hasVisibleContent(fields, sectionSpec.actions, values);
462
462
  // Tabs mode renders this section as its own panel, where two columns reads
463
463
  // better than the single-column default a stacked form keeps. Applies to
464
- // entityEdit tabs as well since fw#3134, not just projectionDetail.
464
+ // entityEdit tabs as well, not just projectionDetail.
465
465
  const defaultColumns = screen.layout.mode === "tabs" ? 2 : 1;
466
466
  const groups = ("groups" in sectionSpec ? sectionSpec.groups : undefined)
467
467
  ?.map((group) => ({ ...group, fields: group.fields.filter((f) => !isHiddenByExclusion(f)) }))
@@ -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,168 +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
- // Tier 2.7e-3 + Cross-Feature: Reference-Field — entity-String
112
- // kann same-feature ("user") oder cross-feature ("users:user")
113
- // sein. parseRefTarget gibt (featureName, entityName), der
114
- // Renderer baut die Lookup-QN als
115
- // `<refFeature>:query:<refEntity>:list`.
116
- const refRaw = fieldDef.type === "reference" ? fieldDef.entity : undefined;
117
- const refTarget = refRaw !== undefined ? parseRefTarget(refRaw, featureName) : undefined;
118
- const refEntity = refTarget?.entityName;
119
- const refFeature = refTarget?.featureName;
120
- const refLabelField = fieldDef.type === "reference" ? (fieldDef.labelField ?? "id") : undefined;
121
- // For select and multiSelect fields: bake in translated option labels.
122
- // Convention matches the form path → one translation source for list +
123
- // form. A missing key returns the key itself per convention; the
124
- // renderer then falls back to humanizeSlug.
125
- const optionLabels = fieldDef.type === "select" || fieldDef.type === "multiSelect"
126
- ? buildOptionLabels(translate, (value) => fieldOptionLabelKey(featureName, screen.entity, normalized.field, value), fieldDef.options)
127
- : undefined;
128
- const optionTones = fieldDef.type === "select" ? fieldDef.optionTones : undefined;
129
- const grouping = fieldDef.type === "number" ? fieldDef.grouping : undefined;
130
- const fileDisplay = deriveListFileDisplay(fieldDef, normalized.renderer);
131
- const column = {
132
- field: normalized.field,
133
- label,
134
- type: fieldDef.type,
135
- sortable: fieldIsSortable(fieldDef),
136
- ...(normalized.renderer !== undefined && { renderer: normalized.renderer }),
137
- ...(optionLabels !== undefined && { optionLabels }),
138
- ...(optionTones !== undefined && { optionTones }),
139
- ...(refEntity !== undefined && { refEntity }),
140
- ...(refFeature !== undefined && { refFeature }),
141
- ...(refLabelField !== undefined && { refLabelField }),
142
- ...(grouping !== undefined && { grouping }),
143
- ...(fileDisplay !== undefined && { fileDisplay }),
144
- ...(normalized.hideOnNarrow === true && { hideOnNarrow: true }),
145
- };
146
- 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;
147
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;
148
195
  const writeOnlyFieldNames = screen.columns
149
196
  .map((spec) => normalizeListColumn(spec).field)
150
197
  .filter((name) => {
151
198
  const fieldDef = entity.fields[name];
152
199
  return fieldDef?.type === "text" && fieldDef.writeOnly === true;
153
200
  });
154
- // The wire carries `true` (set) / `null` (empty) for writeOnly fields; the cell shows a word, not "true".
155
- const showWriteOnlyAsWord = (row) => {
201
+ return (row) => {
156
202
  const shown = { ...row };
157
203
  for (const name of writeOnlyFieldNames) {
158
204
  if (row[name] === true)
159
205
  shown[name] = translate(WRITE_ONLY_SET_LABEL_KEY);
160
206
  }
161
- return shown;
162
- };
163
- const listRows = rows.map((row) => ({
164
- id: String(row["id"] ?? ""),
165
- values: showWriteOnlyAsWord(row),
166
- }));
167
- return {
168
- screenId: screen.id,
169
- entityName: screen.entity,
170
- columns,
171
- rows: listRows,
172
- ...(screen.slots && { slots: screen.slots }),
173
- isEmpty: listRows.length === 0,
207
+ return { id: String(row["id"] ?? ""), values: shown };
174
208
  };
175
209
  }
176
210
  // Field-i18n-key convention matches what features register translations
@@ -191,7 +191,7 @@ export type EditFieldViewModel = {
191
191
  * entity appears as the display label in the dropdown. Default: "id". */
192
192
  readonly refLabelField?: string;
193
193
  /** Only for `type: "reference"` — query handler QN the picker sources its
194
- * options from (fw#2780), replacing the referenced entity's list handler.
194
+ * options from, replacing the referenced entity's list handler.
195
195
  * Rows carry `{ id, label }`; `refLabelField` is then unused for the
196
196
  * picker but still drives list cells. */
197
197
  readonly refOptionsQuery?: string;
@@ -268,7 +268,7 @@ export type EditFieldViewModel = {
268
268
  /** Only set for a plain (non-list, non-embedded) `type: "money"` field —
269
269
  * same source as embeddedListCurrency (EntityDefinition.defaultCurrency,
270
270
  * falls back to "EUR"). The renderer needs this to build the payload's
271
- * `{amount, currency}` shape (kumiko-framework#1923). */
271
+ * `{amount, currency}` shape. */
272
272
  readonly currency?: string;
273
273
  /** Only set for `type: "number"` when NumberFieldDef.unit is set — a static
274
274
  * suffix string, or a sibling-field reference the renderer resolves against
@@ -292,7 +292,7 @@ export type EditFieldsSectionViewModel = {
292
292
  * `excludeFields`. Unlike a declared-empty section (review step), the
293
293
  * renderer must not give it a tab or wizard step. */
294
294
  readonly emptiedByWriteExclusion?: boolean;
295
- /** Optional — eine titellose Section rendert nur ihre Felder (flache Form). */
295
+ /** Optional — a titleless section renders only its fields (flat form). */
296
296
  readonly title?: string;
297
297
  /** Translated help text under the block heading, from
298
298
  * `EditFieldsSection.description`. Renders even without `title`. */
@@ -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
  }