@cosmicdrift/kumiko-headless 0.327.0 → 0.328.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/dist/apex/css.d.ts +3 -0
  2. package/{src/apex/css.ts → dist/apex/css.js} +11 -24
  3. package/dist/apex/index.d.ts +229 -0
  4. package/dist/apex/index.js +360 -0
  5. package/dist/apex/lightbox.d.ts +4 -0
  6. package/{src/apex/lightbox.ts → dist/apex/lightbox.js} +0 -4
  7. package/dist/contracts/asset.d.ts +15 -0
  8. package/dist/contracts/asset.js +17 -0
  9. package/dist/contracts/index.d.ts +3 -0
  10. package/dist/contracts/index.js +1 -0
  11. package/dist/contracts/locale.d.ts +7 -0
  12. package/dist/contracts/locale.js +11 -0
  13. package/dist/contracts/primitives.d.ts +93 -0
  14. package/dist/contracts/primitives.js +21 -0
  15. package/dist/dispatcher/index.d.ts +2 -0
  16. package/dist/dispatcher/index.js +1 -0
  17. package/dist/dispatcher/types.d.ts +86 -0
  18. package/dist/dispatcher/types.js +34 -0
  19. package/dist/form/form-controller.d.ts +2 -0
  20. package/dist/form/form-controller.js +404 -0
  21. package/dist/form/index.d.ts +3 -0
  22. package/dist/form/index.js +2 -0
  23. package/dist/form/types.d.ts +71 -0
  24. package/dist/form/types.js +1 -0
  25. package/dist/form/zod-bridge.d.ts +5 -0
  26. package/{src/form/zod-bridge.ts → dist/form/zod-bridge.js} +52 -58
  27. package/dist/format/escape.d.ts +5 -0
  28. package/{src/format/escape.ts → dist/format/escape.js} +43 -47
  29. package/dist/format/html-template.d.ts +9 -0
  30. package/dist/format/html-template.js +39 -0
  31. package/dist/format/index.d.ts +10 -0
  32. package/dist/format/index.js +192 -0
  33. package/dist/format/money.d.ts +1 -0
  34. package/{src/format/money.ts → dist/format/money.js} +6 -5
  35. package/dist/index.d.ts +14 -0
  36. package/dist/index.js +9 -0
  37. package/dist/locale-routing/index.d.ts +39 -0
  38. package/dist/locale-routing/index.js +143 -0
  39. package/dist/nav/index.d.ts +2 -0
  40. package/dist/nav/index.js +1 -0
  41. package/dist/nav/resolve.d.ts +2 -0
  42. package/dist/nav/resolve.js +58 -0
  43. package/dist/nav/types.d.ts +26 -0
  44. package/dist/nav/types.js +1 -0
  45. package/dist/store/create-store.d.ts +2 -0
  46. package/{src/store/create-store.ts → dist/store/create-store.js} +21 -23
  47. package/dist/store/equality.d.ts +1 -0
  48. package/{src/store/equality.ts → dist/store/equality.js} +19 -17
  49. package/dist/store/index.d.ts +3 -0
  50. package/dist/store/index.js +2 -0
  51. package/dist/store/types.d.ts +7 -0
  52. package/dist/store/types.js +10 -0
  53. package/dist/view-model/edit.d.ts +10 -0
  54. package/dist/view-model/edit.js +392 -0
  55. package/dist/view-model/embedded-list.d.ts +27 -0
  56. package/dist/view-model/embedded-list.js +63 -0
  57. package/dist/view-model/index.d.ts +7 -0
  58. package/dist/view-model/index.js +3 -0
  59. package/dist/view-model/list.d.ts +16 -0
  60. package/dist/view-model/list.js +201 -0
  61. package/dist/view-model/types.d.ts +300 -0
  62. package/dist/view-model/types.js +1 -0
  63. package/package.json +31 -12
  64. package/src/changes.json +7 -0
  65. package/src/apex/__tests__/lightbox.test.ts +0 -20
  66. package/src/apex/__tests__/page-head.test.ts +0 -63
  67. package/src/apex/__tests__/render.test.ts +0 -401
  68. package/src/apex/index.ts +0 -667
  69. package/src/contracts/__tests__/contracts.test.ts +0 -123
  70. package/src/contracts/__tests__/field-issue-compat.test.ts +0 -19
  71. package/src/contracts/asset.ts +0 -48
  72. package/src/contracts/index.ts +0 -23
  73. package/src/contracts/locale.ts +0 -33
  74. package/src/contracts/primitives.ts +0 -158
  75. package/src/dispatcher/__tests__/contract.test.ts +0 -163
  76. package/src/dispatcher/index.ts +0 -16
  77. package/src/dispatcher/types.ts +0 -217
  78. package/src/form/__tests__/conditional-fields.test.ts +0 -177
  79. package/src/form/__tests__/form-controller.test.ts +0 -231
  80. package/src/form/__tests__/submit.test.ts +0 -575
  81. package/src/form/__tests__/validation.test.ts +0 -274
  82. package/src/form/form-controller.ts +0 -458
  83. package/src/form/index.ts +0 -15
  84. package/src/form/types.ts +0 -297
  85. package/src/format/__tests__/escape.test.ts +0 -79
  86. package/src/format/__tests__/format.test.ts +0 -281
  87. package/src/format/__tests__/html-template.test.ts +0 -47
  88. package/src/format/html-template.ts +0 -49
  89. package/src/format/index.ts +0 -219
  90. package/src/index.ts +0 -118
  91. package/src/locale-routing/__tests__/locale-routing.test.ts +0 -276
  92. package/src/locale-routing/index.ts +0 -218
  93. package/src/nav/__tests__/resolve.test.ts +0 -279
  94. package/src/nav/index.ts +0 -8
  95. package/src/nav/resolve.ts +0 -60
  96. package/src/nav/types.ts +0 -70
  97. package/src/store/__tests__/create-store.test.ts +0 -139
  98. package/src/store/__tests__/equality.test.ts +0 -66
  99. package/src/store/index.ts +0 -3
  100. package/src/store/types.ts +0 -27
  101. package/src/view-model/__tests__/edit.test.ts +0 -1072
  102. package/src/view-model/__tests__/embedded-list.test.ts +0 -96
  103. package/src/view-model/__tests__/field-labels.test.ts +0 -62
  104. package/src/view-model/__tests__/list.test.ts +0 -428
  105. package/src/view-model/edit.ts +0 -641
  106. package/src/view-model/embedded-list.ts +0 -89
  107. package/src/view-model/index.ts +0 -43
  108. package/src/view-model/list.ts +0 -277
  109. package/src/view-model/types.ts +0 -392
@@ -1,89 +0,0 @@
1
- import type { EmbeddedDerivedCellDef } from "@cosmicdrift/kumiko-framework/ui-types";
2
- import type { FieldIssue } from "../dispatcher";
3
-
4
- export type EmbeddedDerivedOp = EmbeddedDerivedCellDef["op"];
5
-
6
- export type { DerivedCellRoundingTarget } from "@cosmicdrift/kumiko-framework/ui-types";
7
- // Canonical implementation moved to packages/framework/src/engine/embedded-derived.ts
8
- // (the write-schema preprocess needs it server-side too).
9
- export {
10
- computeDerivedCellValue,
11
- roundDerivedCellValue,
12
- } from "@cosmicdrift/kumiko-framework/ui-types";
13
-
14
- /** Sums a numeric/money/decimal column across all rows. Non-numeric or
15
- * missing values count as 0. Money columns are minor-unit integers —
16
- * the sum stays in minor units, never rounds through a major-unit float. */
17
- export function sumEmbeddedListColumn(
18
- rows: readonly Readonly<Record<string, unknown>>[],
19
- field: string,
20
- ): number {
21
- let sum = 0;
22
- for (const row of rows) {
23
- const value = row[field];
24
- if (typeof value === "number" && Number.isFinite(value)) sum += value;
25
- }
26
- return sum;
27
- }
28
-
29
- export type EmbeddedListIssueGroups = {
30
- /** Issues at exactly `listField` — e.g. min/max row-count violations. */
31
- readonly listIssues: readonly FieldIssue[];
32
- /** Issues at exactly `${listField}.${rowIndex}` — a row-level check
33
- * (e.g. "row incomplete") that isn't attributable to one cell. */
34
- readonly rowIssues: Readonly<Record<number, readonly FieldIssue[]>>;
35
- /** Issues at exactly `${listField}.${rowIndex}.${cellField}`, keyed
36
- * `${rowIndex}.${cellField}` (not the full path — the caller already
37
- * knows listField). */
38
- readonly cellIssues: Readonly<Record<string, readonly FieldIssue[]>>;
39
- };
40
-
41
- /** Buckets a flat issues-by-path map (FormSnapshot.errors shape — see
42
- * packages/headless/src/form/zod-bridge.ts groupIssuesByPath) into the
43
- * three levels an embedded-list widget renders. Only keys that are
44
- * exactly `listField`, `listField.N`, or `listField.N.subfield` are
45
- * claimed; unrelated keys (other top-level fields, or deeper nesting
46
- * than this field ever produces) are ignored. */
47
- export function groupEmbeddedListIssues(
48
- allIssues: Readonly<Record<string, readonly FieldIssue[]>>,
49
- listField: string,
50
- ): EmbeddedListIssueGroups {
51
- const listIssues: FieldIssue[] = [];
52
- const rowIssues: Record<number, readonly FieldIssue[]> = {};
53
- const cellIssues: Record<string, readonly FieldIssue[]> = {};
54
-
55
- const prefix = `${listField}.`;
56
- for (const [path, issues] of Object.entries(allIssues)) {
57
- if (path === listField) {
58
- listIssues.push(...issues);
59
- continue;
60
- }
61
- if (!path.startsWith(prefix)) continue;
62
- const rest = path.slice(prefix.length);
63
- const segments = rest.split(".");
64
- if (segments.length === 1) {
65
- const rowIndex = parsePureRowIndex(segments[0]);
66
- if (rowIndex === undefined) continue;
67
- rowIssues[rowIndex] = issues;
68
- continue;
69
- }
70
- if (segments.length === 2) {
71
- const rowIndex = parsePureRowIndex(segments[0]);
72
- if (rowIndex === undefined) continue;
73
- const cellField = segments[1];
74
- cellIssues[`${rowIndex}.${cellField}`] = issues;
75
- }
76
- // Deeper than `listField.N.subfield` — not a shape this field ever
77
- // produces; ignore rather than misattribute to a cell.
78
- }
79
-
80
- return { listIssues, rowIssues, cellIssues };
81
- }
82
-
83
- function parsePureRowIndex(segment: string | undefined): number | undefined {
84
- if (segment === undefined || segment.length === 0) return undefined;
85
- for (const char of segment) {
86
- if (char < "0" || char > "9") return undefined;
87
- }
88
- return Number(segment);
89
- }
@@ -1,43 +0,0 @@
1
- export type { ComputeEditViewModelInput } from "./edit";
2
- export { computeEditViewModel } from "./edit";
3
- export type {
4
- DerivedCellRoundingTarget,
5
- EmbeddedDerivedOp,
6
- EmbeddedListIssueGroups,
7
- } from "./embedded-list";
8
- export {
9
- computeDerivedCellValue,
10
- groupEmbeddedListIssues,
11
- roundDerivedCellValue,
12
- sumEmbeddedListColumn,
13
- } from "./embedded-list";
14
- export type { ComputeListViewModelInput } from "./list";
15
- export {
16
- computeListViewModel,
17
- embeddedCellLabelKey,
18
- embeddedCellOptionLabelKey,
19
- fieldLabelKey,
20
- fieldOptionLabelKey,
21
- fieldOptionLabelKeyPrefix,
22
- } from "./list";
23
- export type {
24
- EditExtensionSectionViewModel,
25
- EditFieldSpec,
26
- EditFieldsSectionViewModel,
27
- EditFieldViewModel,
28
- EditRelatedListSectionViewModel,
29
- EditSectionSpec,
30
- EditSectionViewModel,
31
- EditViewModel,
32
- EditWriteFormSectionViewModel,
33
- EmbeddedListCellViewModel,
34
- FieldConditionCtx,
35
- FieldRenderer,
36
- ListColumnSpec,
37
- ListColumnViewModel,
38
- ListRowViewModel,
39
- ListViewModel,
40
- RuntimeRenderer,
41
- ScreenSlots,
42
- Translate,
43
- } from "./types";
@@ -1,277 +0,0 @@
1
- import type {
2
- EntityDefinition,
3
- EntityListScreenDefinition,
4
- FieldDefinition,
5
- } from "@cosmicdrift/kumiko-framework/ui-types";
6
- import {
7
- LIST_ROW_META_COLUMNS,
8
- LIST_ROW_META_REFERENCES,
9
- normalizeListColumn,
10
- parseRefTarget,
11
- } from "@cosmicdrift/kumiko-framework/ui-types";
12
- import type { ListColumnViewModel, ListRowViewModel, ListViewModel, Translate } from "./types";
13
-
14
- export type ComputeListViewModelInput = {
15
- readonly screen: EntityListScreenDefinition;
16
- readonly entity: EntityDefinition;
17
- readonly rows: readonly Readonly<Record<string, unknown>>[];
18
- // Translate callback — normally LocaleResolver.translate. Labels use the
19
- // i18n convention "{feature}:entity:{entityName}:field:{fieldName}"; if
20
- // the key is absent from the active bundle, i18next falls back to the
21
- // key itself, which is fine for dev.
22
- readonly translate: Translate;
23
- // Feature + entity name are required for i18n key composition —
24
- // ScreenDefinition carries `entity: string` but not the feature scope,
25
- // and i18n keys are prefixed by feature. Passed in by the caller (the
26
- // renderer knows its host-feature context).
27
- readonly featureName: string;
28
- };
29
-
30
- // Pure transform: takes the declared screen + entity + incoming rows, spits
31
- // out the flat shape the renderer draws. No conditions, no access-checks —
32
- // those are list-level decisions that the caller makes BEFORE calling
33
- // here (e.g. filtering rows by ownership on the server / before query).
34
- // Field-level read-access (Level 3 of UI-architecture.md §Permission) is a
35
- // follow-up; this v1 assumes the caller passed a row-filter that drops any
36
- // field the user may not see.
37
- export function computeListViewModel(input: ComputeListViewModelInput): ListViewModel {
38
- const { screen, entity, rows, translate, featureName } = input;
39
-
40
- const columns: ListColumnViewModel[] = [];
41
- for (const spec of screen.columns) {
42
- const normalized = normalizeListColumn(spec);
43
- if (normalized.refEntity !== undefined) {
44
- // Declared reference metadata (ListColumnSpec.refEntity) — for
45
- // projectionList/relatedList columns, which have no EntityDefinition
46
- // field to carry a real "reference" type. Checked before the
47
- // entity.fields lookup below so it also fires against the pseudo-
48
- // entities that hardcode every field as "text" (projection-list-shim,
49
- // related-list-section) — those never reach the reference branch
50
- // further down since fieldDef.type is always "text" there.
51
- const refTarget = parseRefTarget(normalized.refEntity, featureName);
52
- columns.push({
53
- field: normalized.field,
54
- label: translate(
55
- normalized.label ?? fieldLabelKey(featureName, screen.entity, normalized.field),
56
- ),
57
- type: "reference",
58
- // The declared value is the referenced id, not the resolved label —
59
- // there is no column to server-sort by, same as row-meta references.
60
- sortable: false,
61
- refEntity: refTarget.entityName,
62
- refFeature: refTarget.featureName,
63
- refLabelField: normalized.refLabelField ?? "id",
64
- ...(normalized.renderer !== undefined && { renderer: normalized.renderer }),
65
- });
66
- continue;
67
- }
68
- const fieldDef = entity.fields[normalized.field];
69
- if (!fieldDef) {
70
- // Not a stored field — may be a read-time derived field (value appended
71
- // by the list-query handler). Render it as its display valueType; derived
72
- // columns carry no reference/select metadata and never server-sort.
73
- const derivedDef = entity.derivedFields?.[normalized.field];
74
- if (!derivedDef) {
75
- // Row-meta column (id/tenantId/version/insertedAt/modifiedAt/...) —
76
- // a real base-table column that's never a declared entity field, e.g.
77
- // a SystemAdmin cross-tenant list exposing tenantId. Unlike the
78
- // derivedFields branch above, these are actual DB columns, so
79
- // server-side ORDER BY works — sortable stays true, except for the
80
- // ID columns in LIST_ROW_META_REFERENCES (see their reference branch).
81
- const rowMetaType = LIST_ROW_META_COLUMNS[normalized.field];
82
- if (rowMetaType !== undefined) {
83
- const rowMetaRef = LIST_ROW_META_REFERENCES[normalized.field];
84
- columns.push({
85
- field: normalized.field,
86
- label: translate(
87
- normalized.label ?? fieldLabelKey(featureName, screen.entity, normalized.field),
88
- ),
89
- type: rowMetaRef !== undefined ? "reference" : rowMetaType,
90
- // The DB column holds the GUID, not the resolved label — server-side
91
- // ORDER BY would sort by GUID while the cell shows the name.
92
- sortable: rowMetaRef === undefined,
93
- ...(normalized.renderer !== undefined && { renderer: normalized.renderer }),
94
- ...(rowMetaRef !== undefined && {
95
- refEntity: rowMetaRef.refEntity,
96
- refFeature: rowMetaRef.refFeature,
97
- refLabelField: rowMetaRef.refLabelField,
98
- }),
99
- });
100
- continue;
101
- }
102
- // A virtual presentational column — drawn entirely by a columnRenderer
103
- // component from the row (e.g. tag chips); value is undefined and it
104
- // never server-sorts. `field` is just the column key. It needs BOTH a
105
- // label AND a renderer — renderer is what actually draws it; a label
106
- // alone with no renderer would push an empty, unrendered column into
107
- // the view model. Missing either is an author typo (or a stale
108
- // field-rename) → fail loud instead of silently drawing nothing.
109
- if (normalized.label !== undefined && normalized.renderer !== undefined) {
110
- columns.push({
111
- field: normalized.field,
112
- label: translate(normalized.label),
113
- type: "text",
114
- sortable: false,
115
- renderer: normalized.renderer,
116
- });
117
- continue;
118
- }
119
- throw new Error(
120
- `computeListViewModel: screen "${screen.id}" references unknown field "${normalized.field}" on entity "${screen.entity}"`,
121
- );
122
- }
123
- columns.push({
124
- field: normalized.field,
125
- label: translate(
126
- normalized.label ?? fieldLabelKey(featureName, screen.entity, normalized.field),
127
- ),
128
- type: derivedDef.valueType,
129
- // Display-only: a header sort would round-trip to the server, which has
130
- // no column to sort by (see DerivedFieldDef). Never offer the affordance.
131
- sortable: false,
132
- ...(normalized.renderer !== undefined && { renderer: normalized.renderer }),
133
- });
134
- continue;
135
- }
136
- const label = translate(
137
- normalized.label ?? fieldLabelKey(featureName, screen.entity, normalized.field),
138
- );
139
- // Tier 2.7e-3 + Cross-Feature: Reference-Field — entity-String
140
- // kann same-feature ("user") oder cross-feature ("users:user")
141
- // sein. parseRefTarget gibt (featureName, entityName), der
142
- // Renderer baut die Lookup-QN als
143
- // `<refFeature>:query:<refEntity>:list`.
144
- const refRaw =
145
- fieldDef.type === "reference"
146
- ? (fieldDef as unknown as { entity?: string }).entity
147
- : undefined;
148
- const refTarget = refRaw !== undefined ? parseRefTarget(refRaw, featureName) : undefined;
149
- const refEntity = refTarget?.entityName;
150
- const refFeature = refTarget?.featureName;
151
- const refLabelField =
152
- fieldDef.type === "reference"
153
- ? ((fieldDef as unknown as { labelField?: string }).labelField ?? "id")
154
- : undefined;
155
- // For select and multiSelect fields: bake in translated option labels.
156
- // Convention matches the form path → one translation source for list +
157
- // form. A missing key returns the key itself per convention; the
158
- // renderer then falls back to humanizeSlug.
159
- const optionLabels =
160
- fieldDef.type === "select" || fieldDef.type === "multiSelect"
161
- ? buildOptionLabels(
162
- translate,
163
- (value) => fieldOptionLabelKey(featureName, screen.entity, normalized.field, value),
164
- (fieldDef as unknown as { options?: readonly string[] }).options ?? [],
165
- )
166
- : undefined;
167
- const grouping = fieldDef.type === "number" ? fieldDef.grouping : undefined;
168
- const column: ListColumnViewModel = {
169
- field: normalized.field,
170
- label,
171
- type: fieldDef.type,
172
- sortable: fieldIsSortable(fieldDef),
173
- ...(normalized.renderer !== undefined && { renderer: normalized.renderer }),
174
- ...(optionLabels !== undefined && { optionLabels }),
175
- ...(refEntity !== undefined && { refEntity }),
176
- ...(refFeature !== undefined && { refFeature }),
177
- ...(refLabelField !== undefined && { refLabelField }),
178
- ...(grouping !== undefined && { grouping }),
179
- };
180
- columns.push(column);
181
- }
182
-
183
- const listRows: ListRowViewModel[] = rows.map((row) => ({
184
- id: String(row["id"] ?? ""),
185
- values: row,
186
- }));
187
-
188
- return {
189
- screenId: screen.id,
190
- entityName: screen.entity,
191
- columns,
192
- rows: listRows,
193
- ...(screen.slots && { slots: screen.slots }),
194
- isEmpty: listRows.length === 0,
195
- };
196
- }
197
-
198
- // Field-i18n-key convention matches what features register translations
199
- // under (see packages/framework/src/i18n/ for the pattern). Duplicated
200
- // here as a plain function — the ui-core boundary forbids depending on
201
- // the i18n module directly.
202
- export function fieldLabelKey(featureName: string, entityName: string, fieldName: string): string {
203
- return `${featureName}:entity:${entityName}:field:${fieldName}`;
204
- }
205
-
206
- export function fieldOptionLabelKeyPrefix(
207
- featureName: string,
208
- entityName: string,
209
- fieldName: string,
210
- ): string {
211
- return `${fieldLabelKey(featureName, entityName, fieldName)}:option:`;
212
- }
213
-
214
- export function fieldOptionLabelKey(
215
- featureName: string,
216
- entityName: string,
217
- fieldName: string,
218
- value: string,
219
- ): string {
220
- return `${fieldOptionLabelKeyPrefix(featureName, entityName, fieldName)}${value}`;
221
- }
222
-
223
- // Embedded-list cell label key — one level deeper than fieldLabelKey,
224
- // keyed by the sub-field name inside an embedded-LIST field's `schema`
225
- // (invoice-positions-style tables built via createEmbeddedListField()).
226
- export function embeddedCellLabelKey(
227
- featureName: string,
228
- entityName: string,
229
- fieldName: string,
230
- subFieldName: string,
231
- ): string {
232
- return `${featureName}:entity:${entityName}:field:${fieldName}:cell:${subFieldName}`;
233
- }
234
-
235
- export function embeddedCellOptionLabelKey(
236
- featureName: string,
237
- entityName: string,
238
- fieldName: string,
239
- subFieldName: string,
240
- value: string,
241
- ): string {
242
- return `${featureName}:entity:${entityName}:field:${fieldName}:cell:${subFieldName}:option:${value}`;
243
- }
244
-
245
- // Build a value→label map for a select-field's options. Convention:
246
- // translate() returns the input key when the lookup misses (i18next
247
- // default + LocaleResolver convention) — we surface the *raw value* in
248
- // that case so the renderer's humanizeSlug fallback can take over.
249
- // Without that fallback, an unlabeled option would render as the full
250
- // `feature:entity:field:option:value`-key.
251
- //
252
- // `keyFor` carries whichever key convention the caller needs (top-level
253
- // field via fieldOptionLabelKey, embedded-list cell via
254
- // embeddedCellOptionLabelKey) — shared between list-VM, edit-VM, and
255
- // embedded-list cells so all three produce identical option-translations.
256
- export function buildOptionLabels(
257
- translate: (key: string, params?: Readonly<Record<string, unknown>>) => string,
258
- keyFor: (value: string) => string,
259
- options: readonly string[],
260
- ): Readonly<Record<string, string>> {
261
- const out: Record<string, string> = {};
262
- for (const value of options) {
263
- const key = keyFor(value);
264
- const translated = translate(key);
265
- out[value] = translated === key ? value : translated;
266
- }
267
- return out;
268
- }
269
-
270
- // A field can declare `sortable: true` on the FieldDefinition. This is
271
- // framework-level metadata used both by the server's list-query builder
272
- // (ORDER BY safety) and by the UI (column header click indicator). All
273
- // field types can bear the flag; it's off by default.
274
- function fieldIsSortable(field: FieldDefinition): boolean {
275
- const flag = (field as unknown as { sortable?: boolean }).sortable;
276
- return flag === true;
277
- }