@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.
- package/dist/app/action-form-body.js +10 -2
- package/dist/app/action-form-support.d.ts +1 -0
- package/dist/app/action-form-support.js +1 -1
- package/dist/app/config-edit-body.js +6 -2
- package/dist/app/entity-edit-prefill.d.ts +11 -0
- package/dist/app/entity-edit-prefill.js +9 -4
- package/dist/app/extension-sections.d.ts +5 -3
- package/dist/app/form-schema.js +1 -1
- package/dist/app/header-actions-bar.js +1 -1
- package/dist/components/edit-mounts.d.ts +2 -2
- package/dist/components/embedded-list-field.js +1 -1
- package/dist/components/related-list-section.js +14 -1
- package/dist/components/render-edit-action-button.d.ts +1 -3
- package/dist/components/render-edit-action-button.js +2 -2
- package/dist/components/render-edit.js +11 -8
- package/dist/components/render-field.js +5 -2
- package/dist/components/render-list.js +31 -18
- package/dist/flow-diagram/render.js +9 -1
- package/dist/headless/form/form-controller.js +8 -0
- package/dist/headless/form/types.d.ts +1 -0
- package/dist/headless/format/index.js +89 -64
- package/dist/headless/view-model/edit.js +2 -0
- package/dist/headless/view-model/list.js +184 -149
- package/dist/headless/view-model/types.d.ts +3 -0
- package/dist/hooks/live-refetch-scheduler.js +4 -0
- package/dist/hooks/use-reference-lookup.d.ts +9 -1
- package/dist/hooks/use-reference-lookup.js +17 -3
- package/dist/i18n-defaults.js +6 -2
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/primitives.d.ts +19 -0
- package/dist/web/app/create-app.js +8 -3
- package/dist/web/app/dashboard-body.js +17 -1
- package/dist/web/app/dashboard-panels.js +12 -2
- package/dist/web/index.d.ts +1 -1
- package/dist/web/index.js +1 -1
- package/dist/web/layout/shell-header.js +2 -1
- package/dist/web/layout/uniform-bar-controls.d.ts +1 -0
- package/dist/web/layout/uniform-bar-controls.js +4 -0
- package/dist/web/lib/file-ref-download.js +4 -1
- package/dist/web/primitives/action-menu.js +1 -1
- package/dist/web/primitives/code-input.d.ts +6 -0
- package/dist/web/primitives/code-input.js +31 -0
- package/dist/web/primitives/data-table.js +4 -3
- package/dist/web/primitives/facet-filter.js +1 -1
- package/dist/web/primitives/file-thumbnail.d.ts +3 -0
- package/dist/web/primitives/file-thumbnail.js +5 -0
- package/dist/web/primitives/input.js +6 -1
- package/dist/web/primitives/layout-elements.js +6 -4
- package/dist/web/primitives/page-header.js +3 -1
- package/dist/web/primitives/row-actions.js +3 -3
- package/dist/web/primitives/use-focus-on-mount.d.ts +2 -0
- package/dist/web/primitives/use-focus-on-mount.js +9 -0
- package/dist/web/widgets/bounded-load-cache.d.ts +1 -1
- package/dist/web/widgets/bounded-load-cache.js +10 -4
- package/dist/web/widgets/choice-cards.d.ts +17 -0
- package/dist/web/widgets/choice-cards.js +12 -0
- package/dist/web/widgets/collapsible-section.js +1 -1
- package/dist/web/widgets/detail-list.d.ts +5 -1
- package/dist/web/widgets/detail-list.js +5 -1
- package/dist/web/widgets/index.d.ts +1 -0
- package/dist/web/widgets/index.js +1 -0
- package/dist/web/widgets/pdf-preview.js +1 -1
- package/dist/web/widgets/plan-card.d.ts +2 -0
- package/dist/web/widgets/plan-card.js +1 -1
- package/dist/web/widgets/upload-zone.js +13 -5
- package/package.json +5 -5
- 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 (
|
|
193
|
+
if (isEmptyCellValue(value) && spec.format !== "priority")
|
|
192
194
|
return "";
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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,
|
|
13
|
-
const columns =
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 {
|
|
84
|
+
return {
|
|
85
|
+
map,
|
|
86
|
+
loading: result.loading,
|
|
87
|
+
failed: result.error !== null,
|
|
88
|
+
complete: isCompleteRowPage(result.data),
|
|
89
|
+
};
|
|
76
90
|
}
|
package/dist/i18n-defaults.js
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
|
-
// Framework default bundle. These strings are required by renderer components (Save/Cancel/Delete
|
|
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
|
|
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",
|