@cosmicdrift/kumiko-headless 0.328.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 (108) 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/apex/__tests__/lightbox.test.ts +0 -20
  65. package/src/apex/__tests__/page-head.test.ts +0 -63
  66. package/src/apex/__tests__/render.test.ts +0 -401
  67. package/src/apex/index.ts +0 -667
  68. package/src/contracts/__tests__/contracts.test.ts +0 -123
  69. package/src/contracts/__tests__/field-issue-compat.test.ts +0 -19
  70. package/src/contracts/asset.ts +0 -48
  71. package/src/contracts/index.ts +0 -23
  72. package/src/contracts/locale.ts +0 -33
  73. package/src/contracts/primitives.ts +0 -158
  74. package/src/dispatcher/__tests__/contract.test.ts +0 -163
  75. package/src/dispatcher/index.ts +0 -16
  76. package/src/dispatcher/types.ts +0 -217
  77. package/src/form/__tests__/conditional-fields.test.ts +0 -177
  78. package/src/form/__tests__/form-controller.test.ts +0 -231
  79. package/src/form/__tests__/submit.test.ts +0 -575
  80. package/src/form/__tests__/validation.test.ts +0 -274
  81. package/src/form/form-controller.ts +0 -458
  82. package/src/form/index.ts +0 -15
  83. package/src/form/types.ts +0 -297
  84. package/src/format/__tests__/escape.test.ts +0 -79
  85. package/src/format/__tests__/format.test.ts +0 -285
  86. package/src/format/__tests__/html-template.test.ts +0 -47
  87. package/src/format/html-template.ts +0 -49
  88. package/src/format/index.ts +0 -223
  89. package/src/index.ts +0 -118
  90. package/src/locale-routing/__tests__/locale-routing.test.ts +0 -276
  91. package/src/locale-routing/index.ts +0 -218
  92. package/src/nav/__tests__/resolve.test.ts +0 -279
  93. package/src/nav/index.ts +0 -8
  94. package/src/nav/resolve.ts +0 -60
  95. package/src/nav/types.ts +0 -70
  96. package/src/store/__tests__/create-store.test.ts +0 -139
  97. package/src/store/__tests__/equality.test.ts +0 -66
  98. package/src/store/index.ts +0 -3
  99. package/src/store/types.ts +0 -27
  100. package/src/view-model/__tests__/edit.test.ts +0 -1072
  101. package/src/view-model/__tests__/embedded-list.test.ts +0 -96
  102. package/src/view-model/__tests__/field-labels.test.ts +0 -62
  103. package/src/view-model/__tests__/list.test.ts +0 -439
  104. package/src/view-model/edit.ts +0 -645
  105. package/src/view-model/embedded-list.ts +0 -89
  106. package/src/view-model/index.ts +0 -43
  107. package/src/view-model/list.ts +0 -280
  108. package/src/view-model/types.ts +0 -400
@@ -1,96 +0,0 @@
1
- import { describe, expect, test } from "bun:test";
2
- import type { FieldIssue } from "../../dispatcher";
3
- import {
4
- computeDerivedCellValue,
5
- groupEmbeddedListIssues,
6
- roundDerivedCellValue,
7
- sumEmbeddedListColumn,
8
- } from "../embedded-list";
9
-
10
- function issue(path: string): FieldIssue {
11
- return { path, code: "custom", i18nKey: "errors.validation.custom" };
12
- }
13
-
14
- describe("computeDerivedCellValue", () => {
15
- test("multiply with two values", () => {
16
- expect(computeDerivedCellValue("multiply", [3, 4])).toBe(12);
17
- });
18
-
19
- test("multiply with three values", () => {
20
- expect(computeDerivedCellValue("multiply", [2, 3, 5])).toBe(30);
21
- });
22
-
23
- test("sum adds all values", () => {
24
- expect(computeDerivedCellValue("sum", [1, 2, 3])).toBe(6);
25
- });
26
-
27
- test("subtract subtracts every value after the first", () => {
28
- expect(computeDerivedCellValue("subtract", [10, 3, 2])).toBe(5);
29
- });
30
-
31
- test("multiply returns undefined when a source is missing", () => {
32
- expect(computeDerivedCellValue("multiply", [3, undefined])).toBeUndefined();
33
- });
34
-
35
- test("sum treats a missing source as 0", () => {
36
- expect(computeDerivedCellValue("sum", [1, undefined, 2])).toBe(3);
37
- });
38
-
39
- test("subtract treats a missing source as 0", () => {
40
- expect(computeDerivedCellValue("subtract", [10, undefined])).toBe(10);
41
- });
42
- });
43
-
44
- describe("roundDerivedCellValue", () => {
45
- test("rounds a money target to whole minor units, kaufmännisch", () => {
46
- expect(roundDerivedCellValue(2307.58, { type: "money" })).toBe(2308);
47
- expect(roundDerivedCellValue(-2307.5, { type: "money" })).toBe(-2308);
48
- });
49
-
50
- test("leaves a number target unrounded", () => {
51
- expect(roundDerivedCellValue(1.5, { type: "number" })).toBe(1.5);
52
- });
53
- });
54
-
55
- describe("sumEmbeddedListColumn", () => {
56
- test("sums a numeric column across rows", () => {
57
- const rows = [{ amount: 100 }, { amount: 250 }, { amount: 50 }];
58
- expect(sumEmbeddedListColumn(rows, "amount")).toBe(400);
59
- });
60
-
61
- test("treats a row missing the field as 0", () => {
62
- const rows = [{ amount: 100 }, { other: 1 }, { amount: 50 }];
63
- expect(sumEmbeddedListColumn(rows, "amount")).toBe(150);
64
- });
65
-
66
- test("returns 0 for an empty rows array", () => {
67
- expect(sumEmbeddedListColumn([], "amount")).toBe(0);
68
- });
69
- });
70
-
71
- describe("groupEmbeddedListIssues", () => {
72
- test("buckets list/row/cell issues, ignoring unrelated and too-deep keys", () => {
73
- const allIssues: Record<string, readonly FieldIssue[]> = {
74
- lines: [issue("lines")],
75
- "lines.0": [issue("lines.0")],
76
- "lines.0.amount": [issue("lines.0.amount")],
77
- "lines.1.qty": [issue("lines.1.qty")],
78
- title: [issue("title")],
79
- "lines.0.amount.nested": [issue("lines.0.amount.nested")],
80
- };
81
-
82
- const result = groupEmbeddedListIssues(allIssues, "lines");
83
-
84
- expect(result.listIssues).toEqual([issue("lines")]);
85
- expect(result.rowIssues).toEqual({ 0: [issue("lines.0")] });
86
- expect(result.cellIssues).toEqual({
87
- "0.amount": [issue("lines.0.amount")],
88
- "1.qty": [issue("lines.1.qty")],
89
- });
90
- // Unrelated top-level key and the too-deep key must not leak anywhere.
91
- expect(Object.values(result.cellIssues).flat()).not.toContainEqual(issue("title"));
92
- expect(Object.values(result.cellIssues).flat()).not.toContainEqual(
93
- issue("lines.0.amount.nested"),
94
- );
95
- });
96
- });
@@ -1,62 +0,0 @@
1
- import { describe, expect, test } from "bun:test";
2
- import {
3
- buildOptionLabels,
4
- embeddedCellLabelKey,
5
- embeddedCellOptionLabelKey,
6
- fieldLabelKey,
7
- fieldOptionLabelKey,
8
- } from "../list";
9
-
10
- describe("fieldLabelKey", () => {
11
- test("follows feature:entity:field convention", () => {
12
- expect(fieldLabelKey("billing", "invoice", "amount")).toBe(
13
- "billing:entity:invoice:field:amount",
14
- );
15
- });
16
- });
17
-
18
- describe("fieldOptionLabelKey", () => {
19
- test("appends option value segment", () => {
20
- expect(fieldOptionLabelKey("billing", "invoice", "status", "paid")).toBe(
21
- "billing:entity:invoice:field:status:option:paid",
22
- );
23
- });
24
- });
25
-
26
- describe("embeddedCellLabelKey", () => {
27
- test("adds a cell segment for the sub-field name", () => {
28
- expect(embeddedCellLabelKey("billing", "invoice", "lines", "quantity")).toBe(
29
- "billing:entity:invoice:field:lines:cell:quantity",
30
- );
31
- });
32
- });
33
-
34
- describe("embeddedCellOptionLabelKey", () => {
35
- test("appends option value segment after the cell segment", () => {
36
- expect(embeddedCellOptionLabelKey("billing", "invoice", "lines", "unit", "hour")).toBe(
37
- "billing:entity:invoice:field:lines:cell:unit:option:hour",
38
- );
39
- });
40
- });
41
-
42
- describe("buildOptionLabels", () => {
43
- test("maps option values to translated labels with fallback to raw value", () => {
44
- const labels = buildOptionLabels(
45
- (key) => (key.endsWith(":option:draft") ? "Draft" : key),
46
- (value) => fieldOptionLabelKey("tasks", "task", "status", value),
47
- ["draft", "done"],
48
- );
49
- expect(labels["draft"]).toBe("Draft");
50
- expect(labels["done"]).toBe("done");
51
- });
52
-
53
- test("keyFor lets the caller supply any key convention (e.g. embedded cell)", () => {
54
- const labels = buildOptionLabels(
55
- (key) => (key.endsWith(":option:hour") ? "Hour" : key),
56
- (value) => embeddedCellOptionLabelKey("billing", "invoice", "lines", "unit", value),
57
- ["hour", "day"],
58
- );
59
- expect(labels["hour"]).toBe("Hour");
60
- expect(labels["day"]).toBe("day");
61
- });
62
- });
@@ -1,439 +0,0 @@
1
- import { describe, expect, mock, test } from "bun:test";
2
- import type {
3
- EntityDefinition,
4
- EntityListScreenDefinition,
5
- } from "@cosmicdrift/kumiko-framework/ui-types";
6
- import {
7
- LIST_ROW_META_COLUMNS,
8
- LIST_ROW_META_REFERENCES,
9
- } from "@cosmicdrift/kumiko-framework/ui-types";
10
- import { computeListViewModel } from "../list";
11
-
12
- // Minimal EntityDefinition-shape. ui-core's view-model only reads
13
- // entity.fields and per-field metadata; tests stay untyped-casted via
14
- // `as unknown as EntityDefinition` so we don't pull the entire framework
15
- // FieldDefinition union into fixtures.
16
- const taskEntity = {
17
- fields: {
18
- title: { type: "text", required: true, sortable: true },
19
- done: { type: "boolean" },
20
- priority: { type: "number", sortable: true },
21
- role: { type: "select", options: ["admin", "member"] },
22
- labels: { type: "multiSelect", options: ["urgent", "low-priority"] },
23
- },
24
- derivedFields: {
25
- // Read-time computed (value appended by the list-query handler). The
26
- // view-model only reads valueType; the derive body never runs here, so a
27
- // no-op stand-in is enough.
28
- statusLabel: { valueType: "text", derive: () => "" },
29
- ageDays: { valueType: "number", derive: () => 0 },
30
- },
31
- } as unknown as EntityDefinition;
32
-
33
- function listScreen(columns: EntityListScreenDefinition["columns"]): EntityListScreenDefinition {
34
- return {
35
- id: "tasks:screen:task-list",
36
- type: "entityList",
37
- entity: "task",
38
- columns,
39
- };
40
- }
41
-
42
- // Fake translate passes the key through so tests can assert on the
43
- // key-composition convention without wiring i18next.
44
- const translate = (key: string) => key;
45
-
46
- describe("computeListViewModel", () => {
47
- test("string columns expand to field-name + resolved label + type", () => {
48
- const vm = computeListViewModel({
49
- screen: listScreen(["title", "done"]),
50
- entity: taskEntity,
51
- rows: [],
52
- translate,
53
- featureName: "tasks",
54
- });
55
-
56
- expect(vm.columns).toEqual([
57
- {
58
- field: "title",
59
- label: "tasks:entity:task:field:title",
60
- type: "text",
61
- sortable: true,
62
- },
63
- {
64
- field: "done",
65
- label: "tasks:entity:task:field:done",
66
- type: "boolean",
67
- sortable: false,
68
- },
69
- ]);
70
- });
71
-
72
- test("object-form column carries renderer through to the view model", () => {
73
- const fmt = { format: "currency" as const, symbol: "€" };
74
- const vm = computeListViewModel({
75
- screen: listScreen([{ field: "title", renderer: fmt }]),
76
- entity: taskEntity,
77
- rows: [],
78
- translate,
79
- featureName: "tasks",
80
- });
81
-
82
- expect(vm.columns[0]?.renderer).toEqual(fmt);
83
- });
84
-
85
- test("rows map to { id, values } with id pulled from the row", () => {
86
- const vm = computeListViewModel({
87
- screen: listScreen(["title"]),
88
- entity: taskEntity,
89
- rows: [
90
- { id: "t-1", title: "first" },
91
- { id: "t-2", title: "second" },
92
- ],
93
- translate,
94
- featureName: "tasks",
95
- });
96
-
97
- expect(vm.rows).toEqual([
98
- { id: "t-1", values: { id: "t-1", title: "first" } },
99
- { id: "t-2", values: { id: "t-2", title: "second" } },
100
- ]);
101
- expect(vm.isEmpty).toBe(false);
102
- });
103
-
104
- test("empty rows list flags isEmpty for the renderer's 'no results' state", () => {
105
- const vm = computeListViewModel({
106
- screen: listScreen(["title"]),
107
- entity: taskEntity,
108
- rows: [],
109
- translate,
110
- featureName: "tasks",
111
- });
112
- expect(vm.isEmpty).toBe(true);
113
- });
114
-
115
- test("unknown field reference throws — stale rename caught at render-time, not silently", () => {
116
- expect(() =>
117
- computeListViewModel({
118
- screen: listScreen(["doesNotExist"]),
119
- entity: taskEntity,
120
- rows: [],
121
- translate,
122
- featureName: "tasks",
123
- }),
124
- ).toThrow(/unknown field "doesNotExist"/);
125
- });
126
-
127
- test("labeled column with no matching field → virtual presentational column (no throw)", () => {
128
- const vm = computeListViewModel({
129
- screen: listScreen([
130
- "title",
131
- { field: "tags", label: "Tags", renderer: { react: { __component: "TagsCell" } } },
132
- ]),
133
- entity: taskEntity,
134
- rows: [],
135
- translate,
136
- featureName: "tasks",
137
- });
138
- // `field` becomes the column key; label is taken verbatim (translate is
139
- // identity here), type defaults to text, never server-sortable.
140
- expect(vm.columns[1]).toEqual({
141
- field: "tags",
142
- label: "Tags",
143
- type: "text",
144
- sortable: false,
145
- renderer: { react: { __component: "TagsCell" } },
146
- });
147
- });
148
-
149
- test("labeled column with no matching field AND no renderer → throws (label alone isn't enough)", () => {
150
- // Regression (697/1): renderer is what actually draws a virtual column —
151
- // a label with no renderer would otherwise push an empty, unrendered
152
- // column into the view model instead of catching the author typo.
153
- expect(() =>
154
- computeListViewModel({
155
- screen: listScreen(["title", { field: "tags", label: "Tags" }]),
156
- entity: taskEntity,
157
- rows: [],
158
- translate,
159
- featureName: "tasks",
160
- }),
161
- ).toThrow(/unknown field "tags"/);
162
- });
163
-
164
- test("label overrides the field-convention header on a real field", () => {
165
- const vm = computeListViewModel({
166
- screen: listScreen([{ field: "title", label: "custom.header" }]),
167
- entity: taskEntity,
168
- rows: [],
169
- translate,
170
- featureName: "tasks",
171
- });
172
- // label goes through translate (identity here) instead of the
173
- // tasks:entity:task:field:title convention key.
174
- expect(vm.columns[0]?.label).toBe("custom.header");
175
- });
176
-
177
- test("translate is called with the expected i18n-key per field", () => {
178
- const spy = mock((key: string) => `T:${key}`);
179
- computeListViewModel({
180
- screen: listScreen(["title", "priority"]),
181
- entity: taskEntity,
182
- rows: [],
183
- translate: spy,
184
- featureName: "tasks",
185
- });
186
-
187
- expect(spy).toHaveBeenCalledWith("tasks:entity:task:field:title");
188
- expect(spy).toHaveBeenCalledWith("tasks:entity:task:field:priority");
189
- });
190
-
191
- test("derived-field column carries its valueType, is display-only (never sortable), no stored-field metadata", () => {
192
- const vm = computeListViewModel({
193
- screen: listScreen(["statusLabel", "ageDays"]),
194
- entity: taskEntity,
195
- rows: [],
196
- translate,
197
- featureName: "tasks",
198
- });
199
-
200
- expect(vm.columns).toEqual([
201
- {
202
- field: "statusLabel",
203
- label: "tasks:entity:task:field:statusLabel",
204
- type: "text",
205
- sortable: false,
206
- },
207
- {
208
- field: "ageDays",
209
- label: "tasks:entity:task:field:ageDays",
210
- type: "number",
211
- sortable: false,
212
- },
213
- ]);
214
- });
215
-
216
- test("derived column value passes through from the row (handler already appended it)", () => {
217
- const vm = computeListViewModel({
218
- screen: listScreen(["title", "statusLabel"]),
219
- entity: taskEntity,
220
- rows: [{ id: "t-1", title: "first", statusLabel: "overdue" }],
221
- translate,
222
- featureName: "tasks",
223
- });
224
-
225
- expect(vm.rows[0]?.values).toEqual({ id: "t-1", title: "first", statusLabel: "overdue" });
226
- });
227
-
228
- test("derived columns accept an object-form renderer like stored columns", () => {
229
- const fmt = { format: "currency" as const, symbol: "€" };
230
- const vm = computeListViewModel({
231
- screen: listScreen([{ field: "ageDays", renderer: fmt }]),
232
- entity: taskEntity,
233
- rows: [],
234
- translate,
235
- featureName: "tasks",
236
- });
237
-
238
- expect(vm.columns[0]?.renderer).toEqual(fmt);
239
- });
240
-
241
- test("select field column includes translated optionLabels", () => {
242
- const vm = computeListViewModel({
243
- screen: listScreen(["role"]),
244
- entity: taskEntity,
245
- rows: [],
246
- translate: (key) => (key === "tasks:entity:task:field:role:option:admin" ? "Admin" : key),
247
- featureName: "tasks",
248
- });
249
-
250
- expect(vm.columns[0]?.optionLabels).toEqual({
251
- admin: "Admin",
252
- member: "member",
253
- });
254
- });
255
-
256
- test("multiSelect field column includes translated optionLabels (fw#2491)", () => {
257
- const vm = computeListViewModel({
258
- screen: listScreen(["labels"]),
259
- entity: taskEntity,
260
- rows: [],
261
- translate: (key) => (key === "tasks:entity:task:field:labels:option:urgent" ? "Urgent" : key),
262
- featureName: "tasks",
263
- });
264
-
265
- expect(vm.columns[0]?.optionLabels).toEqual({
266
- urgent: "Urgent",
267
- "low-priority": "low-priority",
268
- });
269
- });
270
-
271
- // Table-driven over LIST_ROW_META_COLUMNS itself (not one-off cases) — a
272
- // row-meta column (id/tenantId/version/insertedAt/...) is a base-table
273
- // column, never a declared entity field (a SystemAdmin cross-tenant list
274
- // picks tenantId as a column this way). Together with boot-validator.test.ts
275
- // (both boot validators accept the same set) and list-row-meta-drift.test.ts
276
- // (LIST_ROW_META_COLUMNS key-set drift guard), this pins "whatever boot
277
- // accepts as a row-meta column, the renderer can actually draw" — a #2601
278
- // review found the two sides had drifted apart.
279
- test("every row-meta column resolves to its declared type, sortable, no throw", () => {
280
- for (const [field, expectedType] of Object.entries(LIST_ROW_META_COLUMNS)) {
281
- const vm = computeListViewModel({
282
- screen: listScreen(["title", field]),
283
- entity: taskEntity,
284
- rows: [],
285
- translate,
286
- featureName: "tasks",
287
- });
288
-
289
- const ref = LIST_ROW_META_REFERENCES[field];
290
- expect(vm.columns[1]).toEqual({
291
- field,
292
- label: `tasks:entity:task:field:${field}`,
293
- ...(ref !== undefined
294
- ? {
295
- type: "reference",
296
- sortable: false,
297
- refFeature: ref.refFeature,
298
- refEntity: ref.refEntity,
299
- refLabelField: ref.refLabelField,
300
- }
301
- : { type: expectedType, sortable: true }),
302
- });
303
- }
304
- });
305
-
306
- // tenantId carries a GUID in the DB but should render the referenced
307
- // tenant's display name, like a declared reference field does — not the
308
- // raw GUID. insertedById stays unchanged: this also pins that the map
309
- // switches exactly one column, not every row-meta ID column.
310
- test("tenantId row-meta column resolves as a reference column, insertedById unchanged", () => {
311
- const vm = computeListViewModel({
312
- screen: listScreen(["tenantId", "insertedById", "id"]),
313
- entity: taskEntity,
314
- rows: [],
315
- translate,
316
- featureName: "tasks",
317
- });
318
-
319
- expect(vm.columns[0]).toEqual({
320
- field: "tenantId",
321
- label: "tasks:entity:task:field:tenantId",
322
- type: "reference",
323
- sortable: false,
324
- refFeature: "tenant",
325
- refEntity: "tenant",
326
- refLabelField: "name",
327
- });
328
- expect(vm.columns[1]).toEqual({
329
- field: "insertedById",
330
- label: "tasks:entity:task:field:insertedById",
331
- type: "text",
332
- sortable: true,
333
- });
334
- expect(vm.columns[2]).toEqual({
335
- field: "id",
336
- label: "tasks:entity:task:field:id",
337
- type: "text",
338
- sortable: true,
339
- });
340
- });
341
-
342
- // projectionList/relatedList columns have no real EntityDefinition — the
343
- // shim that adapts them synthesizes a pseudo-entity where every field is
344
- // hardcoded to "text" (see projection-list-shim.ts /
345
- // related-list-section.tsx). ListColumnSpec.refEntity must be evaluated
346
- // BEFORE the entity.fields lookup, or it never fires against that shape.
347
- const projectionPseudoEntity = {
348
- fields: {
349
- tenantId: { type: "text" },
350
- type: { type: "text" },
351
- },
352
- } as unknown as EntityDefinition;
353
-
354
- test("ListColumnSpec.refEntity marks the column as a reference, even against a pseudo-entity whose fields are all 'text' (fw#2662)", () => {
355
- const vm = computeListViewModel({
356
- screen: listScreen([
357
- {
358
- field: "tenantId",
359
- label: "delivery.log.col.tenantId",
360
- refEntity: "tenant:tenant",
361
- refLabelField: "name",
362
- },
363
- ]),
364
- entity: projectionPseudoEntity,
365
- rows: [],
366
- translate,
367
- featureName: "delivery",
368
- });
369
-
370
- expect(vm.columns[0]).toEqual({
371
- field: "tenantId",
372
- label: "delivery.log.col.tenantId",
373
- type: "reference",
374
- sortable: false,
375
- refFeature: "tenant",
376
- refEntity: "tenant",
377
- refLabelField: "name",
378
- });
379
- });
380
-
381
- test("non-regression: a projectionList column without refEntity metadata stays 'text', unchanged (fw#2662)", () => {
382
- const vm = computeListViewModel({
383
- screen: listScreen([{ field: "type", label: "delivery.log.col.type" }]),
384
- entity: projectionPseudoEntity,
385
- rows: [],
386
- translate,
387
- featureName: "delivery",
388
- });
389
-
390
- expect(vm.columns[0]).toEqual({
391
- field: "type",
392
- label: "delivery.log.col.type",
393
- type: "text",
394
- sortable: false,
395
- });
396
- });
397
-
398
- test("row-meta column: explicit label + renderer are passed through unchanged", () => {
399
- const fmt = { format: "currency" as const, symbol: "€" };
400
- const vm = computeListViewModel({
401
- screen: listScreen([{ field: "insertedAt", label: "custom.created", renderer: fmt }]),
402
- entity: taskEntity,
403
- rows: [],
404
- translate,
405
- featureName: "tasks",
406
- });
407
-
408
- expect(vm.columns[0]).toEqual({
409
- field: "insertedAt",
410
- label: "custom.created",
411
- type: "timestamp",
412
- sortable: true,
413
- renderer: fmt,
414
- });
415
- });
416
-
417
- test("slots pass through unchanged for the renderer to mount", () => {
418
- const slots = { header: { react: { component: "HeaderRef" } } };
419
- const vm = computeListViewModel({
420
- screen: { ...listScreen(["title"]), slots },
421
- entity: taskEntity,
422
- rows: [],
423
- translate,
424
- featureName: "tasks",
425
- });
426
- expect(vm.slots).toBe(slots);
427
- });
428
- test("hideOnNarrow is carried onto the column view model only when set", () => {
429
- const vm = computeListViewModel({
430
- screen: listScreen(["title", { field: "priority", hideOnNarrow: true }]),
431
- entity: taskEntity,
432
- rows: [],
433
- translate,
434
- featureName: "tasks",
435
- });
436
- expect(vm.columns[0]?.hideOnNarrow).toBeUndefined();
437
- expect(vm.columns[1]?.hideOnNarrow).toBe(true);
438
- });
439
- });