@cosmicdrift/kumiko-framework 0.202.0 → 0.204.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/package.json +3 -3
- package/src/bun-db/__tests__/sql-expr-brand.test.ts +33 -1
- package/src/db/__tests__/list-pagination.test.ts +28 -0
- package/src/db/dialect.ts +7 -8
- package/src/db/entity-table-meta.ts +4 -3
- package/src/db/event-store-executor-read.ts +26 -2
- package/src/db/sql-inventory.ts +16 -1
- package/src/engine/__tests__/boot-validator-action-wiring.test.ts +46 -0
- package/src/engine/__tests__/boot-validator-projection-list.test.ts +191 -0
- package/src/engine/__tests__/build-app-schema.test.ts +94 -0
- package/src/engine/__tests__/projection-detail-actions.test.ts +137 -0
- package/src/engine/__tests__/screen.test.ts +192 -0
- package/src/engine/boot-validator/action-wiring.ts +15 -3
- package/src/engine/boot-validator/index.ts +2 -0
- package/src/engine/boot-validator/projection-list-screens.ts +82 -0
- package/src/engine/boot-validator/screens.ts +92 -1
- package/src/engine/build-app-schema.ts +55 -2
- package/src/engine/index.ts +6 -1
- package/src/engine/screen-helpers.ts +5 -0
- package/src/i18n/required-surface-keys.ts +11 -0
- package/src/testing/e2e-generator.ts +4 -4
- package/src/ui-types/index.ts +2 -0
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { withBootValidatorFixture } from "../../testing/boot-validator-fixture";
|
|
4
|
+
import { validateBoot as validateBootRaw } from "../boot-validator";
|
|
5
|
+
import { defineFeature } from "../define-feature";
|
|
6
|
+
import { createEntity, createTextField } from "../factories";
|
|
7
|
+
|
|
8
|
+
function validateBoot(features: Parameters<typeof validateBootRaw>[0]): void {
|
|
9
|
+
validateBootRaw(withBootValidatorFixture(features));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// fw#2166: projectionDetail screens can declare header `actions`, reusing
|
|
13
|
+
// RowAction (the displayed record stands in for the row). `rowClick` has no
|
|
14
|
+
// row to target on a detail screen and is rejected outright; navigate/
|
|
15
|
+
// writeHandler actions get the same existence checks as entityList/
|
|
16
|
+
// projectionList rowActions/toolbarActions.
|
|
17
|
+
describe("validateBoot — projectionDetail actions (fw#2166)", () => {
|
|
18
|
+
test("navigate action with rowClick: true throws, naming the screen and action", () => {
|
|
19
|
+
const feature = defineFeature("app", (r) => {
|
|
20
|
+
r.screen({
|
|
21
|
+
id: "rent-detail",
|
|
22
|
+
type: "projectionDetail",
|
|
23
|
+
query: "app:query:rent:detail",
|
|
24
|
+
layout: { sections: [{ title: "s", fields: ["description"] }] },
|
|
25
|
+
actions: [
|
|
26
|
+
{
|
|
27
|
+
kind: "navigate",
|
|
28
|
+
id: "edit",
|
|
29
|
+
label: "actions.edit",
|
|
30
|
+
screen: "rent-edit",
|
|
31
|
+
rowClick: true,
|
|
32
|
+
},
|
|
33
|
+
],
|
|
34
|
+
});
|
|
35
|
+
r.screen({ id: "rent-edit", type: "custom", renderer: { react: "stub" } });
|
|
36
|
+
});
|
|
37
|
+
expect(() => validateBoot([feature])).toThrow(
|
|
38
|
+
/Screen "app:screen:rent-detail" \(projectionDetail\) action "edit" sets rowClick: true/,
|
|
39
|
+
);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("valid navigate + writeHandler actions boot cleanly", () => {
|
|
43
|
+
const feature = defineFeature("app", (r) => {
|
|
44
|
+
r.writeHandler(
|
|
45
|
+
"archive",
|
|
46
|
+
z.object({ id: z.string() }),
|
|
47
|
+
async () => ({ isSuccess: true as const, data: {} }),
|
|
48
|
+
{ access: { openToAll: true } },
|
|
49
|
+
);
|
|
50
|
+
r.screen({
|
|
51
|
+
id: "rent-detail",
|
|
52
|
+
type: "projectionDetail",
|
|
53
|
+
query: "app:query:rent:detail",
|
|
54
|
+
layout: { sections: [{ title: "s", fields: ["description"] }] },
|
|
55
|
+
actions: [
|
|
56
|
+
{ kind: "navigate", id: "edit", label: "actions.edit", screen: "rent-edit" },
|
|
57
|
+
{
|
|
58
|
+
kind: "writeHandler",
|
|
59
|
+
id: "archive",
|
|
60
|
+
label: "actions.archive",
|
|
61
|
+
handler: "app:write:archive",
|
|
62
|
+
},
|
|
63
|
+
],
|
|
64
|
+
});
|
|
65
|
+
r.screen({ id: "rent-edit", type: "custom", renderer: { react: "stub" } });
|
|
66
|
+
});
|
|
67
|
+
expect(() => validateBoot([feature])).not.toThrow();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("navigate action to an unregistered screen throws, via the same existence check as entityList/projectionList", () => {
|
|
71
|
+
const feature = defineFeature("app", (r) => {
|
|
72
|
+
r.screen({
|
|
73
|
+
id: "rent-detail",
|
|
74
|
+
type: "projectionDetail",
|
|
75
|
+
query: "app:query:rent:detail",
|
|
76
|
+
layout: { sections: [{ title: "s", fields: ["description"] }] },
|
|
77
|
+
actions: [{ kind: "navigate", id: "edit", label: "actions.edit", screen: "ghost-screen" }],
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
expect(() => validateBoot([feature])).toThrow(
|
|
81
|
+
/action "edit" navigate-target "ghost-screen" does not resolve to a registered screen/,
|
|
82
|
+
);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("navigate action with params targeting an entityEdit of the SAME entity (via detailFor) throws — params are a no-op on an update target (review finding 3b)", () => {
|
|
86
|
+
const feature = defineFeature("app", (r) => {
|
|
87
|
+
r.entity("rent", createEntity({ fields: { name: createTextField() } }));
|
|
88
|
+
r.screen({
|
|
89
|
+
id: "rent-detail",
|
|
90
|
+
type: "projectionDetail",
|
|
91
|
+
query: "app:query:rent:detail",
|
|
92
|
+
layout: { sections: [{ title: "s", fields: ["description"] }] },
|
|
93
|
+
detailFor: "rent",
|
|
94
|
+
actions: [
|
|
95
|
+
{
|
|
96
|
+
kind: "navigate",
|
|
97
|
+
id: "edit",
|
|
98
|
+
label: "actions.edit",
|
|
99
|
+
screen: "rent-edit",
|
|
100
|
+
params: { pick: ["name"] },
|
|
101
|
+
},
|
|
102
|
+
],
|
|
103
|
+
});
|
|
104
|
+
r.screen({
|
|
105
|
+
id: "rent-edit",
|
|
106
|
+
type: "entityEdit",
|
|
107
|
+
entity: "rent",
|
|
108
|
+
layout: { sections: [{ columns: 1, fields: ["name"] }] },
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
expect(() => validateBoot([feature])).toThrow(
|
|
112
|
+
/rowAction "edit" sets params on navigate-target "rent-edit" which resolves to UPDATE mode \(same entity "rent" auto-fills row\["id"\]\)/,
|
|
113
|
+
);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("writeHandler action referencing an unregistered handler QN throws", () => {
|
|
117
|
+
const feature = defineFeature("app", (r) => {
|
|
118
|
+
r.screen({
|
|
119
|
+
id: "rent-detail",
|
|
120
|
+
type: "projectionDetail",
|
|
121
|
+
query: "app:query:rent:detail",
|
|
122
|
+
layout: { sections: [{ title: "s", fields: ["description"] }] },
|
|
123
|
+
actions: [
|
|
124
|
+
{
|
|
125
|
+
kind: "writeHandler",
|
|
126
|
+
id: "archive",
|
|
127
|
+
label: "actions.archive",
|
|
128
|
+
handler: "app:write:ghost",
|
|
129
|
+
},
|
|
130
|
+
],
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
expect(() => validateBoot([feature])).toThrow(
|
|
134
|
+
/action "archive" handler "app:write:ghost" is not a registered write-handler/,
|
|
135
|
+
);
|
|
136
|
+
});
|
|
137
|
+
});
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { z } from "zod";
|
|
2
3
|
import { withBootValidatorFixture } from "../../testing/boot-validator-fixture";
|
|
3
4
|
import { validateBoot as validateBootRaw } from "../boot-validator";
|
|
5
|
+
import { createTenantConfig } from "../config-helpers";
|
|
4
6
|
import { defineFeature } from "../define-feature";
|
|
5
7
|
import { createDerivedField, createEntity, createTextField } from "../factories";
|
|
6
8
|
import { createRegistry } from "../registry";
|
|
@@ -165,6 +167,196 @@ describe("r.screen() — registration", () => {
|
|
|
165
167
|
expect(() => validateBoot(features)).toThrow(/extension section/i);
|
|
166
168
|
});
|
|
167
169
|
|
|
170
|
+
test("validateBoot rejects a projectionDetail relatedList section with an empty query (fw#2166)", () => {
|
|
171
|
+
const features = [
|
|
172
|
+
defineFeature("app", (r) => {
|
|
173
|
+
r.screen({
|
|
174
|
+
id: "x",
|
|
175
|
+
type: "projectionDetail",
|
|
176
|
+
query: "app:query:foo:detail",
|
|
177
|
+
layout: {
|
|
178
|
+
sections: [{ kind: "relatedList", title: "s", query: "", columns: ["name"] }],
|
|
179
|
+
},
|
|
180
|
+
});
|
|
181
|
+
}),
|
|
182
|
+
];
|
|
183
|
+
expect(() => validateBoot(features)).toThrow(
|
|
184
|
+
/Screen "x".*relatedList.*empty or non-string query/,
|
|
185
|
+
);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test("validateBoot rejects a relatedList rowClick with no matching detailFor screen (fw#2166)", () => {
|
|
189
|
+
const features = [
|
|
190
|
+
defineFeature("app", (r) => {
|
|
191
|
+
r.screen({
|
|
192
|
+
id: "x",
|
|
193
|
+
type: "projectionDetail",
|
|
194
|
+
query: "app:query:foo:detail",
|
|
195
|
+
layout: {
|
|
196
|
+
sections: [
|
|
197
|
+
{
|
|
198
|
+
kind: "relatedList",
|
|
199
|
+
title: "s",
|
|
200
|
+
query: "app:query:foo:list",
|
|
201
|
+
columns: ["name"],
|
|
202
|
+
rowClick: { entity: "invoice" },
|
|
203
|
+
},
|
|
204
|
+
],
|
|
205
|
+
},
|
|
206
|
+
});
|
|
207
|
+
}),
|
|
208
|
+
];
|
|
209
|
+
expect(() => validateBoot(features)).toThrow(
|
|
210
|
+
/Add detailFor: "invoice" to the screen that shows it\./,
|
|
211
|
+
);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test("validateBoot accepts a relatedList rowClick with a detailFor screen in another feature (fw#2166)", () => {
|
|
215
|
+
const features = [
|
|
216
|
+
defineFeature("app", (r) => {
|
|
217
|
+
r.screen({
|
|
218
|
+
id: "x",
|
|
219
|
+
type: "projectionDetail",
|
|
220
|
+
query: "app:query:foo:detail",
|
|
221
|
+
layout: {
|
|
222
|
+
sections: [
|
|
223
|
+
{
|
|
224
|
+
kind: "relatedList",
|
|
225
|
+
title: "s",
|
|
226
|
+
query: "app:query:foo:list",
|
|
227
|
+
columns: ["name"],
|
|
228
|
+
rowClick: { entity: "invoice" },
|
|
229
|
+
},
|
|
230
|
+
],
|
|
231
|
+
},
|
|
232
|
+
});
|
|
233
|
+
}),
|
|
234
|
+
defineFeature("billing", (r) => {
|
|
235
|
+
r.entity(
|
|
236
|
+
"invoice",
|
|
237
|
+
createEntity({ table: "invoices", fields: { name: createTextField() } }),
|
|
238
|
+
);
|
|
239
|
+
r.screen({
|
|
240
|
+
id: "invoice-detail",
|
|
241
|
+
type: "custom",
|
|
242
|
+
renderer: { react: { __component: "stub" } },
|
|
243
|
+
detailFor: "invoice",
|
|
244
|
+
});
|
|
245
|
+
}),
|
|
246
|
+
];
|
|
247
|
+
expect(() => validateBoot(features)).not.toThrow();
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
test("validateBoot rejects a relatedList section in a wizard layout (fw#2166)", () => {
|
|
251
|
+
const features = [
|
|
252
|
+
defineFeature("app", (r) => {
|
|
253
|
+
r.screen({
|
|
254
|
+
id: "x",
|
|
255
|
+
type: "projectionDetail",
|
|
256
|
+
query: "app:query:foo:detail",
|
|
257
|
+
layout: {
|
|
258
|
+
mode: "wizard",
|
|
259
|
+
sections: [
|
|
260
|
+
{ kind: "relatedList", title: "s", query: "app:query:foo:list", columns: ["name"] },
|
|
261
|
+
],
|
|
262
|
+
},
|
|
263
|
+
});
|
|
264
|
+
}),
|
|
265
|
+
];
|
|
266
|
+
expect(() => validateBoot(features)).toThrow(/relatedList.*wizard layout/);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test("validateBoot accepts a valid relatedList section without rowClick (fw#2166)", () => {
|
|
270
|
+
const features = [
|
|
271
|
+
defineFeature("app", (r) => {
|
|
272
|
+
r.screen({
|
|
273
|
+
id: "x",
|
|
274
|
+
type: "projectionDetail",
|
|
275
|
+
query: "app:query:foo:detail",
|
|
276
|
+
layout: {
|
|
277
|
+
sections: [
|
|
278
|
+
{ kind: "relatedList", title: "s", query: "app:query:foo:list", columns: ["name"] },
|
|
279
|
+
],
|
|
280
|
+
},
|
|
281
|
+
});
|
|
282
|
+
}),
|
|
283
|
+
];
|
|
284
|
+
expect(() => validateBoot(features)).not.toThrow();
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
test("validateBoot rejects a relatedList section on entityEdit (fw#2166)", () => {
|
|
288
|
+
const features = [
|
|
289
|
+
defineFeature("shop", (r) => {
|
|
290
|
+
r.entity("product", productEntity());
|
|
291
|
+
r.screen({
|
|
292
|
+
id: "product-edit",
|
|
293
|
+
type: "entityEdit",
|
|
294
|
+
entity: "product",
|
|
295
|
+
layout: {
|
|
296
|
+
sections: [
|
|
297
|
+
{ kind: "relatedList", title: "s", query: "shop:query:foo:list", columns: ["name"] },
|
|
298
|
+
],
|
|
299
|
+
},
|
|
300
|
+
});
|
|
301
|
+
}),
|
|
302
|
+
];
|
|
303
|
+
expect(() => validateBoot(features)).toThrow(
|
|
304
|
+
/\(entityEdit\) relatedList section "s".*projectionDetail-only/,
|
|
305
|
+
);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
test("validateBoot rejects a relatedList section on configEdit (fw#2166)", () => {
|
|
309
|
+
const features = [
|
|
310
|
+
defineFeature("shop", (r) => {
|
|
311
|
+
r.config({
|
|
312
|
+
keys: { "site-name": createTenantConfig("text", { default: "" }) },
|
|
313
|
+
});
|
|
314
|
+
r.screen({
|
|
315
|
+
id: "settings",
|
|
316
|
+
type: "configEdit",
|
|
317
|
+
scope: "tenant",
|
|
318
|
+
configKeys: { siteName: "shop:config:site-name" },
|
|
319
|
+
fields: { siteName: { type: "text" } } as never,
|
|
320
|
+
layout: {
|
|
321
|
+
sections: [
|
|
322
|
+
{ kind: "relatedList", title: "s", query: "shop:query:foo:list", columns: ["name"] },
|
|
323
|
+
],
|
|
324
|
+
},
|
|
325
|
+
});
|
|
326
|
+
}),
|
|
327
|
+
];
|
|
328
|
+
expect(() => validateBoot(features)).toThrow(
|
|
329
|
+
/\(configEdit\) relatedList section "s".*projectionDetail-only/,
|
|
330
|
+
);
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
test("validateBoot rejects a relatedList section on actionForm (fw#2166)", () => {
|
|
334
|
+
const features = [
|
|
335
|
+
defineFeature("shop", (r) => {
|
|
336
|
+
r.writeHandler(
|
|
337
|
+
"restock",
|
|
338
|
+
z.object({}),
|
|
339
|
+
async () => ({ isSuccess: true as const, data: null }),
|
|
340
|
+
{ access: { roles: ["Admin"] } },
|
|
341
|
+
);
|
|
342
|
+
r.screen({
|
|
343
|
+
id: "restock",
|
|
344
|
+
type: "actionForm",
|
|
345
|
+
handler: "shop:write:restock",
|
|
346
|
+
fields: { qty: { type: "number" } } as never,
|
|
347
|
+
layout: {
|
|
348
|
+
sections: [
|
|
349
|
+
{ kind: "relatedList", title: "s", query: "shop:query:foo:list", columns: ["name"] },
|
|
350
|
+
],
|
|
351
|
+
},
|
|
352
|
+
});
|
|
353
|
+
}),
|
|
354
|
+
];
|
|
355
|
+
expect(() => validateBoot(features)).toThrow(
|
|
356
|
+
/\(actionForm\) relatedList section "s".*projectionDetail-only/,
|
|
357
|
+
);
|
|
358
|
+
});
|
|
359
|
+
|
|
168
360
|
test("stores an entityEdit screen with sections + conditional fields", () => {
|
|
169
361
|
const feature = defineFeature("shop", (r) => {
|
|
170
362
|
r.entity("product", productEntity());
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { isFieldsEditSection, normalizeEditField, normalizeListColumn } from "../screen-helpers";
|
|
2
2
|
import type {
|
|
3
3
|
EditFieldSpec,
|
|
4
4
|
EditLayout,
|
|
@@ -29,7 +29,7 @@ const ACTION_FUNCTION_FIELDS = ["payload", "params", "entityId", "visible"] as c
|
|
|
29
29
|
function validateActionNoFunctions(
|
|
30
30
|
featureName: string,
|
|
31
31
|
screenId: string,
|
|
32
|
-
actionKind: "rowAction" | "toolbarAction",
|
|
32
|
+
actionKind: "rowAction" | "toolbarAction" | "action",
|
|
33
33
|
action: RowAction | ToolbarAction,
|
|
34
34
|
): void {
|
|
35
35
|
const record = action as unknown as Record<string, unknown>;
|
|
@@ -45,6 +45,12 @@ function validateActionNoFunctions(
|
|
|
45
45
|
|
|
46
46
|
export function validateActionWiring(feature: FeatureDefinition): void {
|
|
47
47
|
for (const screen of Object.values(feature.screens)) {
|
|
48
|
+
if (screen.type === "projectionDetail") {
|
|
49
|
+
for (const action of screen.actions ?? []) {
|
|
50
|
+
validateActionNoFunctions(feature.name, screen.id, "action", action);
|
|
51
|
+
}
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
48
54
|
if (screen.type !== "entityList" && screen.type !== "projectionList") continue;
|
|
49
55
|
for (const action of screen.rowActions ?? []) {
|
|
50
56
|
validateActionNoFunctions(feature.name, screen.id, "rowAction", action);
|
|
@@ -106,7 +112,13 @@ function validateEditLayoutNoFunctions(
|
|
|
106
112
|
layout: EditLayout,
|
|
107
113
|
): void {
|
|
108
114
|
for (const section of layout.sections) {
|
|
109
|
-
|
|
115
|
+
// relatedList carries `columns` instead of `fields` — route it through
|
|
116
|
+
// the same column check entityList/projectionList top-level columns use.
|
|
117
|
+
if (section.kind === "relatedList") {
|
|
118
|
+
validateColumnsNoFunctions(featureName, screenId, screenType, section.columns);
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
if (!isFieldsEditSection(section)) continue;
|
|
110
122
|
for (const fieldSpec of section.fields) {
|
|
111
123
|
validateEditFieldNoFunctions(featureName, screenId, screenType, fieldSpec);
|
|
112
124
|
}
|
|
@@ -45,6 +45,7 @@ import {
|
|
|
45
45
|
} from "./nav";
|
|
46
46
|
import { validateOwnershipRules } from "./ownership";
|
|
47
47
|
import { validatePiiAndRetention } from "./pii-retention";
|
|
48
|
+
import { validateProjectionListScreens } from "./projection-list-screens";
|
|
48
49
|
import {
|
|
49
50
|
collectScreenQns,
|
|
50
51
|
collectScreensByShortId,
|
|
@@ -210,6 +211,7 @@ export function validateBoot(
|
|
|
210
211
|
validateI18nSurfaceKeys(features);
|
|
211
212
|
validateEntityListScreens(features);
|
|
212
213
|
validateDetailForScreens(features, featureMap);
|
|
214
|
+
validateProjectionListScreens(features);
|
|
213
215
|
validateExtensionPreSaveWiring(features);
|
|
214
216
|
validateGdprStoragePersistence(features);
|
|
215
217
|
validateFeatureBootChecks(features);
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { ZodObject } from "zod";
|
|
2
|
+
import { QnTypes, qualifyEntityName } from "../qualified-name";
|
|
3
|
+
import type { FeatureDefinition, ProjectionListScreenDefinition, QueryHandlerDef } from "../types";
|
|
4
|
+
import { SEARCHABLE_FALSE_WHITELIST } from "./entity-list-screens";
|
|
5
|
+
|
|
6
|
+
// Sibling to entity-list-screens.ts rather than an extension of it:
|
|
7
|
+
// validateOneEntityListScreen is typed to EntityListScreenDefinition and
|
|
8
|
+
// reaches into feature.entities[screen.entity] — a projectionList has no
|
|
9
|
+
// entity, so sharing the function would mean threading a discriminated
|
|
10
|
+
// union through every entity-bound helper it calls.
|
|
11
|
+
|
|
12
|
+
function buildQueryHandlerMap(
|
|
13
|
+
features: readonly FeatureDefinition[],
|
|
14
|
+
): ReadonlyMap<string, QueryHandlerDef> {
|
|
15
|
+
const out = new Map<string, QueryHandlerDef>();
|
|
16
|
+
for (const feature of features) {
|
|
17
|
+
for (const [name, handler] of Object.entries(feature.queryHandlers ?? {})) {
|
|
18
|
+
out.set(qualifyEntityName(feature.name, QnTypes.query, name), handler);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return out;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Non-ZodObject schemas (e.g. a z.union across payload shapes) and
|
|
25
|
+
// unresolved query handlers both fall through to "capability absent" —
|
|
26
|
+
// consistent with buildAppSchema's derivation, no throw either way.
|
|
27
|
+
function schemaAccepts(schema: QueryHandlerDef["schema"] | undefined, key: string): boolean {
|
|
28
|
+
const shape = schema instanceof ZodObject ? schema.shape : undefined;
|
|
29
|
+
return shape !== undefined && key in shape;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function validateOneProjectionListScreen(
|
|
33
|
+
feature: FeatureDefinition,
|
|
34
|
+
screen: ProjectionListScreenDefinition,
|
|
35
|
+
queryHandlers: ReadonlyMap<string, QueryHandlerDef>,
|
|
36
|
+
): void {
|
|
37
|
+
const prefix = `[projectionList] Feature "${feature.name}" screen "${screen.id}"`;
|
|
38
|
+
const schema = queryHandlers.get(screen.query)?.schema;
|
|
39
|
+
|
|
40
|
+
if (screen.searchable === true && !schemaAccepts(schema, "search")) {
|
|
41
|
+
throw new Error(
|
|
42
|
+
`${prefix}: searchable: true but query "${screen.query}" has no "search" parameter in its Zod schema`,
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (
|
|
47
|
+
screen.searchable === false &&
|
|
48
|
+
schemaAccepts(schema, "search") &&
|
|
49
|
+
!SEARCHABLE_FALSE_WHITELIST.has(screen.id)
|
|
50
|
+
) {
|
|
51
|
+
throw new Error(
|
|
52
|
+
`${prefix}: query "${screen.query}" accepts "search" but searchable: false disables it — remove searchable: false or add "${screen.id}" to SEARCHABLE_FALSE_WHITELIST`,
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// sortable/paginated are derived by buildAppSchema from the query's Zod
|
|
57
|
+
// schema (fw#2165) — there is no separate wire type from the author-facing
|
|
58
|
+
// ProjectionListScreenDefinition, so hand-authoring them would otherwise be
|
|
59
|
+
// silently overwritten with no signal to the author. Reject outright.
|
|
60
|
+
if (screen.sortable !== undefined) {
|
|
61
|
+
throw new Error(`${prefix}: sortable is derived from the query's Zod schema, don't set it`);
|
|
62
|
+
}
|
|
63
|
+
if (screen.paginated !== undefined) {
|
|
64
|
+
throw new Error(`${prefix}: paginated is derived from the query's Zod schema, don't set it`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const searchActive = screen.searchable !== false && schemaAccepts(schema, "search");
|
|
68
|
+
const sortActive = schemaAccepts(schema, "sort");
|
|
69
|
+
if ((searchActive || sortActive) && screen.defaultSort === undefined) {
|
|
70
|
+
throw new Error(`${prefix}: defaultSort required when search or sort is active`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function validateProjectionListScreens(features: readonly FeatureDefinition[]): void {
|
|
75
|
+
const queryHandlers = buildQueryHandlerMap(features);
|
|
76
|
+
for (const feature of features) {
|
|
77
|
+
for (const screen of Object.values(feature.screens)) {
|
|
78
|
+
if (screen.type !== "projectionList") continue;
|
|
79
|
+
validateOneProjectionListScreen(feature, screen, queryHandlers);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -67,7 +67,7 @@ function validateNoWidgetRequiredField(
|
|
|
67
67
|
function validateRowActionNavigateParams(
|
|
68
68
|
featureName: string,
|
|
69
69
|
screenId: string,
|
|
70
|
-
screenType: "entityList" | "projectionList",
|
|
70
|
+
screenType: "entityList" | "projectionList" | "projectionDetail",
|
|
71
71
|
screenEntity: string | undefined,
|
|
72
72
|
action: RowAction,
|
|
73
73
|
target: { readonly featureName: string; readonly screen: ScreenDefinition } | undefined,
|
|
@@ -381,6 +381,35 @@ export function validateScreens(
|
|
|
381
381
|
`section to persist against.`,
|
|
382
382
|
);
|
|
383
383
|
}
|
|
384
|
+
if (section.kind === "relatedList") {
|
|
385
|
+
if (!section.query || typeof section.query !== "string") {
|
|
386
|
+
throw new Error(
|
|
387
|
+
`[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) section "${section.title}" ` +
|
|
388
|
+
`(relatedList) has empty or non-string query.`,
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
if (screen.layout.mode === "wizard") {
|
|
392
|
+
throw new Error(
|
|
393
|
+
`[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) section "${section.title}" ` +
|
|
394
|
+
`is kind "relatedList" in a wizard layout — a read-only list is not supported in a ` +
|
|
395
|
+
`stepped form. Remove mode: "wizard" or drop the relatedList section.`,
|
|
396
|
+
);
|
|
397
|
+
}
|
|
398
|
+
if (section.rowClick !== undefined) {
|
|
399
|
+
const targetEntity = section.rowClick.entity;
|
|
400
|
+
const hasDetailScreen = [...featureMap.values()].some((f) =>
|
|
401
|
+
Object.values(f.screens).some((s) => s.detailFor === targetEntity),
|
|
402
|
+
);
|
|
403
|
+
if (!hasDetailScreen) {
|
|
404
|
+
throw new Error(
|
|
405
|
+
`[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) section "${section.title}" ` +
|
|
406
|
+
`(relatedList) rowClick targets entity "${targetEntity}", but no screen declares ` +
|
|
407
|
+
`detailFor: "${targetEntity}". Add detailFor: "${targetEntity}" to the screen that shows it.`,
|
|
408
|
+
);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
continue;
|
|
412
|
+
}
|
|
384
413
|
if (section.fields.length === 0) {
|
|
385
414
|
throw new Error(
|
|
386
415
|
`[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) has a section "${section.title}" ` +
|
|
@@ -388,6 +417,47 @@ export function validateScreens(
|
|
|
388
417
|
);
|
|
389
418
|
}
|
|
390
419
|
}
|
|
420
|
+
// Header actions reuse RowAction (the displayed record stands in for
|
|
421
|
+
// the row), so the same navigate/writeHandler existence checks as
|
|
422
|
+
// entityList/projectionList apply. `rowClick` is rejected outright —
|
|
423
|
+
// a detail screen has no row to click.
|
|
424
|
+
if (screen.actions !== undefined) {
|
|
425
|
+
for (const action of screen.actions) {
|
|
426
|
+
if (action.kind === "navigate" && action.rowClick === true) {
|
|
427
|
+
throw new Error(
|
|
428
|
+
`[Feature ${feature.name}] Screen "${qualifyEntityName(feature.name, "screen", screenId)}" ` +
|
|
429
|
+
`(projectionDetail) action "${action.id}" sets rowClick: true — there is no row to ` +
|
|
430
|
+
`click on a detail screen. Remove rowClick.`,
|
|
431
|
+
);
|
|
432
|
+
}
|
|
433
|
+
if (action.kind === "navigate") {
|
|
434
|
+
const candidateQn = qualifyEntityName(feature.name, "screen", action.screen);
|
|
435
|
+
if (!allScreenQns.has(candidateQn) && !navTargetShortIds.has(action.screen)) {
|
|
436
|
+
throw new Error(
|
|
437
|
+
`[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) action "${action.id}" ` +
|
|
438
|
+
`navigate-target "${action.screen}" does not resolve to a registered screen in any feature.`,
|
|
439
|
+
);
|
|
440
|
+
}
|
|
441
|
+
const target = screensByShortId.get(action.screen)?.[0];
|
|
442
|
+
validateRowActionNavigateParams(
|
|
443
|
+
feature.name,
|
|
444
|
+
screenId,
|
|
445
|
+
"projectionDetail",
|
|
446
|
+
screen.detailFor,
|
|
447
|
+
action,
|
|
448
|
+
target,
|
|
449
|
+
);
|
|
450
|
+
} else {
|
|
451
|
+
if (!allWriteHandlerQns.has(action.handler)) {
|
|
452
|
+
throw new Error(
|
|
453
|
+
`[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) action "${action.id}" ` +
|
|
454
|
+
`handler "${action.handler}" is not a registered write-handler. Check the QN spelling ` +
|
|
455
|
+
`(expected "<feature>:write:<short>") and that the handler is declared via r.writeHandler(...).`,
|
|
456
|
+
);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
}
|
|
391
461
|
continue;
|
|
392
462
|
}
|
|
393
463
|
|
|
@@ -433,6 +503,13 @@ export function validateScreens(
|
|
|
433
503
|
}
|
|
434
504
|
continue;
|
|
435
505
|
}
|
|
506
|
+
if (section.kind === "relatedList") {
|
|
507
|
+
throw new Error(
|
|
508
|
+
`[Feature ${feature.name}] Screen "${screenId}" (configEdit) relatedList section ` +
|
|
509
|
+
`"${section.title}" is not supported — relatedList is a projectionDetail-only ` +
|
|
510
|
+
`primitive (fw#2166).`,
|
|
511
|
+
);
|
|
512
|
+
}
|
|
436
513
|
if (section.fields.length === 0) {
|
|
437
514
|
throw new Error(
|
|
438
515
|
`[Feature ${feature.name}] Screen "${screenId}" (configEdit) has a section "${section.title}" ` +
|
|
@@ -541,6 +618,13 @@ export function validateScreens(
|
|
|
541
618
|
}
|
|
542
619
|
continue;
|
|
543
620
|
}
|
|
621
|
+
if (section.kind === "relatedList") {
|
|
622
|
+
throw new Error(
|
|
623
|
+
`[Feature ${feature.name}] Screen "${screenId}" (actionForm) relatedList section ` +
|
|
624
|
+
`"${section.title}" is not supported — relatedList is a projectionDetail-only ` +
|
|
625
|
+
`primitive (fw#2166).`,
|
|
626
|
+
);
|
|
627
|
+
}
|
|
544
628
|
if (section.fields.length === 0) {
|
|
545
629
|
throw new Error(
|
|
546
630
|
`[Feature ${feature.name}] Screen "${screenId}" (actionForm) has a section "${section.title}" ` +
|
|
@@ -868,6 +952,13 @@ export function validateScreens(
|
|
|
868
952
|
}
|
|
869
953
|
continue;
|
|
870
954
|
}
|
|
955
|
+
if (section.kind === "relatedList") {
|
|
956
|
+
throw new Error(
|
|
957
|
+
`[Feature ${feature.name}] Screen "${screenId}" (entityEdit) relatedList section ` +
|
|
958
|
+
`"${section.title}" is not supported — relatedList is a projectionDetail-only ` +
|
|
959
|
+
`primitive (fw#2166).`,
|
|
960
|
+
);
|
|
961
|
+
}
|
|
871
962
|
if (section.fields.length === 0) {
|
|
872
963
|
throw new Error(
|
|
873
964
|
`[Feature ${feature.name}] Screen "${screenId}" (entityEdit) has a section "${section.title}" ` +
|