@cosmicdrift/kumiko-framework 0.224.1 → 0.225.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/engine/__tests__/boot-validator.test.ts +92 -2
- package/src/engine/__tests__/build-app-schema.test.ts +136 -1
- package/src/engine/__tests__/projection-detail-tabs.test.ts +158 -0
- package/src/engine/boot-validator/screens.ts +98 -9
- package/src/engine/build-app-schema.ts +6 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.225.0",
|
|
4
4
|
"description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -198,7 +198,7 @@
|
|
|
198
198
|
"./package.json": "./package.json"
|
|
199
199
|
},
|
|
200
200
|
"dependencies": {
|
|
201
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
201
|
+
"@cosmicdrift/kumiko-types": "0.225.0",
|
|
202
202
|
"bullmq": "^5.76.7",
|
|
203
203
|
"bun-types": "^1.3.13",
|
|
204
204
|
"hono": "^4.13.1",
|
|
@@ -214,7 +214,7 @@
|
|
|
214
214
|
"zod": "^4.4.3"
|
|
215
215
|
},
|
|
216
216
|
"devDependencies": {
|
|
217
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
217
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.225.0",
|
|
218
218
|
"bun-types": "^1.3.13",
|
|
219
219
|
"pino-pretty": "^13.1.3"
|
|
220
220
|
},
|
|
@@ -4099,7 +4099,7 @@ describe("boot-validator — config key backing × scope", () => {
|
|
|
4099
4099
|
expect(() => validateBoot([feature])).not.toThrow();
|
|
4100
4100
|
});
|
|
4101
4101
|
|
|
4102
|
-
test("navigate with params targeting a dashboard screen → Throw", () => {
|
|
4102
|
+
test("navigate with params targeting a dashboard screen without a filter → Throw (params would be a no-op)", () => {
|
|
4103
4103
|
const feature = defineFeature("shop", (r) => {
|
|
4104
4104
|
r.entity("product", createEntity({ fields: { name: createTextField() } }));
|
|
4105
4105
|
r.screen({
|
|
@@ -4135,7 +4135,97 @@ describe("boot-validator — config key backing × scope", () => {
|
|
|
4135
4135
|
});
|
|
4136
4136
|
});
|
|
4137
4137
|
expect(() => validateBoot([feature])).toThrow(
|
|
4138
|
-
/sets params on navigate-target "product-dashboard"
|
|
4138
|
+
/sets params on navigate-target "product-dashboard" \(dashboard\).*declares no filter.*no-op/,
|
|
4139
|
+
);
|
|
4140
|
+
});
|
|
4141
|
+
|
|
4142
|
+
test("navigate with params targeting a dashboard screen with a matching filter → no throw (useFilterParams reads it, fw#1708 follow-up)", () => {
|
|
4143
|
+
const feature = defineFeature("shop", (r) => {
|
|
4144
|
+
r.entity("product", createEntity({ fields: { name: createTextField() } }));
|
|
4145
|
+
r.screen({
|
|
4146
|
+
id: "product-list",
|
|
4147
|
+
type: "entityList",
|
|
4148
|
+
entity: "product",
|
|
4149
|
+
columns: ["name"],
|
|
4150
|
+
rowActions: [
|
|
4151
|
+
{
|
|
4152
|
+
kind: "navigate",
|
|
4153
|
+
id: "view",
|
|
4154
|
+
label: "actions.view",
|
|
4155
|
+
screen: "product-dashboard",
|
|
4156
|
+
params: { map: { category: "name" } },
|
|
4157
|
+
},
|
|
4158
|
+
],
|
|
4159
|
+
});
|
|
4160
|
+
r.queryHandler("count", z.object({}), async () => ({ total: 0 }), {
|
|
4161
|
+
access: { openToAll: true },
|
|
4162
|
+
});
|
|
4163
|
+
r.screen({
|
|
4164
|
+
id: "product-dashboard",
|
|
4165
|
+
type: "dashboard",
|
|
4166
|
+
filter: {
|
|
4167
|
+
id: "category",
|
|
4168
|
+
label: "Category",
|
|
4169
|
+
kind: "select",
|
|
4170
|
+
options: [{ value: "a", label: "A" }],
|
|
4171
|
+
},
|
|
4172
|
+
panels: [
|
|
4173
|
+
{
|
|
4174
|
+
kind: "stat",
|
|
4175
|
+
id: "count",
|
|
4176
|
+
label: "Products",
|
|
4177
|
+
query: "shop:query:count",
|
|
4178
|
+
valueField: "total",
|
|
4179
|
+
},
|
|
4180
|
+
],
|
|
4181
|
+
});
|
|
4182
|
+
});
|
|
4183
|
+
expect(() => validateBoot([feature])).not.toThrow();
|
|
4184
|
+
});
|
|
4185
|
+
|
|
4186
|
+
test("navigate with params targeting a dashboard screen whose filter id doesn't match any extracted key → Throw", () => {
|
|
4187
|
+
const feature = defineFeature("shop", (r) => {
|
|
4188
|
+
r.entity("product", createEntity({ fields: { name: createTextField() } }));
|
|
4189
|
+
r.screen({
|
|
4190
|
+
id: "product-list",
|
|
4191
|
+
type: "entityList",
|
|
4192
|
+
entity: "product",
|
|
4193
|
+
columns: ["name"],
|
|
4194
|
+
rowActions: [
|
|
4195
|
+
{
|
|
4196
|
+
kind: "navigate",
|
|
4197
|
+
id: "view",
|
|
4198
|
+
label: "actions.view",
|
|
4199
|
+
screen: "product-dashboard",
|
|
4200
|
+
params: { map: { productName: "name" } },
|
|
4201
|
+
},
|
|
4202
|
+
],
|
|
4203
|
+
});
|
|
4204
|
+
r.queryHandler("count", z.object({}), async () => ({ total: 0 }), {
|
|
4205
|
+
access: { openToAll: true },
|
|
4206
|
+
});
|
|
4207
|
+
r.screen({
|
|
4208
|
+
id: "product-dashboard",
|
|
4209
|
+
type: "dashboard",
|
|
4210
|
+
filter: {
|
|
4211
|
+
id: "category",
|
|
4212
|
+
label: "Category",
|
|
4213
|
+
kind: "select",
|
|
4214
|
+
options: [{ value: "a", label: "A" }],
|
|
4215
|
+
},
|
|
4216
|
+
panels: [
|
|
4217
|
+
{
|
|
4218
|
+
kind: "stat",
|
|
4219
|
+
id: "count",
|
|
4220
|
+
label: "Products",
|
|
4221
|
+
query: "shop:query:count",
|
|
4222
|
+
valueField: "total",
|
|
4223
|
+
},
|
|
4224
|
+
],
|
|
4225
|
+
});
|
|
4226
|
+
});
|
|
4227
|
+
expect(() => validateBoot([feature])).toThrow(
|
|
4228
|
+
/sets params \[productName\] on navigate-target "product-dashboard" \(dashboard\) whose filter id is "category".*none of the extracted keys match/,
|
|
4139
4229
|
);
|
|
4140
4230
|
});
|
|
4141
4231
|
|
|
@@ -12,7 +12,7 @@ import { z } from "zod";
|
|
|
12
12
|
import { buildAppSchema, findNonJsonSafePath } from "../build-app-schema";
|
|
13
13
|
import { defineFeature } from "../define-feature";
|
|
14
14
|
import { createRegistry } from "../registry";
|
|
15
|
-
import type { EntityDefinition } from "../types/fields";
|
|
15
|
+
import type { EntityDefinition, MultiSelectFieldDef } from "../types/fields";
|
|
16
16
|
import type { ProjectionListScreenDefinition } from "../types/screen";
|
|
17
17
|
|
|
18
18
|
describe("buildAppSchema", () => {
|
|
@@ -314,6 +314,141 @@ describe("buildAppSchema", () => {
|
|
|
314
314
|
expect(fields["name"]?.["searchable"]).toBeUndefined();
|
|
315
315
|
});
|
|
316
316
|
|
|
317
|
+
test("MultiSelect: display/columns/maxRows überleben die Projection (fw#2494)", () => {
|
|
318
|
+
// Regression: without display in the client schema, render-field.tsx's
|
|
319
|
+
// `field.display === "checkboxes"` check always falls through → the
|
|
320
|
+
// renderer always shows the combobox, MultiSelectCheckboxes is unreachable.
|
|
321
|
+
const entity = {
|
|
322
|
+
fields: {
|
|
323
|
+
tags: {
|
|
324
|
+
type: "multiSelect",
|
|
325
|
+
options: ["a", "b", "c"],
|
|
326
|
+
display: "checkboxes",
|
|
327
|
+
columns: 3,
|
|
328
|
+
maxRows: 5,
|
|
329
|
+
},
|
|
330
|
+
categories: { type: "multiSelect", options: ["x", "y"] },
|
|
331
|
+
},
|
|
332
|
+
} as unknown as EntityDefinition;
|
|
333
|
+
|
|
334
|
+
const f = defineFeature("ent", (r) => {
|
|
335
|
+
r.entity("thing", entity);
|
|
336
|
+
});
|
|
337
|
+
const app = buildAppSchema(createRegistry([f]));
|
|
338
|
+
const fields = (
|
|
339
|
+
app.features[0]!.entities["thing"] as unknown as {
|
|
340
|
+
fields: Record<string, Record<string, unknown>>;
|
|
341
|
+
}
|
|
342
|
+
).fields;
|
|
343
|
+
|
|
344
|
+
expect(fields["tags"]?.["display"]).toBe("checkboxes");
|
|
345
|
+
expect(fields["tags"]?.["columns"]).toBe(3);
|
|
346
|
+
expect(fields["tags"]?.["maxRows"]).toBe(5);
|
|
347
|
+
// Fields without display/columns/maxRows don't carry the keys (no false-litter).
|
|
348
|
+
expect(fields["categories"]?.["display"]).toBeUndefined();
|
|
349
|
+
expect(fields["categories"]?.["columns"]).toBeUndefined();
|
|
350
|
+
expect(fields["categories"]?.["maxRows"]).toBeUndefined();
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
test("MultiSelectFieldDef: alle Keys sind in projectField bewusst eingeordnet", () => {
|
|
354
|
+
// Bounded completeness check, scoped to MultiSelectFieldDef's own key set
|
|
355
|
+
// (packages/types/src/fields.ts) — NOT a general FieldDefinition lockstep.
|
|
356
|
+
// A full-union version would have to classify every property of all 20
|
|
357
|
+
// FieldDefinition variants as forwarded or server-only, and several are
|
|
358
|
+
// known-forwarded-but-missing today (e.g. image's `capture`, embedded's
|
|
359
|
+
// `schema`/`derived`/`totals`/`minItems`/`maxItems`, date's
|
|
360
|
+
// `min`/`max`/`locale`, longText's `multiline`) — declaring those
|
|
361
|
+
// "server-only" here would be a false claim, and fixing them is out of
|
|
362
|
+
// scope for fw#2494 (see report for the full gap list). This check only
|
|
363
|
+
// covers the type this fix actually touches.
|
|
364
|
+
//
|
|
365
|
+
// The `_allKeysClassified` assignment below is the actual guard: if
|
|
366
|
+
// MultiSelectFieldDef ever gains a key that isn't in one of the three
|
|
367
|
+
// lists, `Exclude<keyof MultiSelectFieldDef, Classified>` stops being
|
|
368
|
+
// `never` and the assignment fails to typecheck — `bun run typecheck`
|
|
369
|
+
// (part of `bun run kumiko check`) catches it even without touching this
|
|
370
|
+
// test's runtime assertions.
|
|
371
|
+
const FORWARDED_KEYS = [
|
|
372
|
+
"type",
|
|
373
|
+
"required",
|
|
374
|
+
"filterable",
|
|
375
|
+
"options",
|
|
376
|
+
"display",
|
|
377
|
+
"columns",
|
|
378
|
+
"maxRows",
|
|
379
|
+
] as const;
|
|
380
|
+
|
|
381
|
+
const SERVER_ONLY_KEYS = [
|
|
382
|
+
"sensitive", // controls write-response redaction, never rendered
|
|
383
|
+
"access", // server-side authz check, not a renderer concern
|
|
384
|
+
"pii", // PII classification, drives crypto/storage — not client-relevant
|
|
385
|
+
"userOwned", // same: subject-key annotation, server/crypto-only
|
|
386
|
+
"tenantOwned", // same: subject-key annotation, server/crypto-only
|
|
387
|
+
"anonymize", // retention-cleanup callback, never serializable to JSON
|
|
388
|
+
"allowPlaintext", // PII-audit reason string, not a renderer concern
|
|
389
|
+
"lookupable", // blind-index equality lookup, server-side query concern
|
|
390
|
+
"subjectRef", // GDPR-hook-coverage marker, not a renderer concern
|
|
391
|
+
] as const;
|
|
392
|
+
|
|
393
|
+
// Keys that ARE client-relevant in principle but aren't forwarded today —
|
|
394
|
+
// pre-existing gaps, each with its own reason, none introduced by fw#2494.
|
|
395
|
+
const KNOWN_GAP_KEYS = [
|
|
396
|
+
"default", // array-valued for multiSelect, but projectField's isLiteral()
|
|
397
|
+
// only accepts string/number/boolean/null — so array defaults are
|
|
398
|
+
// silently dropped, never a real server-only property. Verified
|
|
399
|
+
// empirically: a multiSelect field with `default: ["a"]` projects to
|
|
400
|
+
// no `default` key at all. Separate pre-existing bug, out of scope here.
|
|
401
|
+
] as const;
|
|
402
|
+
|
|
403
|
+
type Classified =
|
|
404
|
+
| (typeof FORWARDED_KEYS)[number]
|
|
405
|
+
| (typeof SERVER_ONLY_KEYS)[number]
|
|
406
|
+
| (typeof KNOWN_GAP_KEYS)[number];
|
|
407
|
+
const _allKeysClassified: Exclude<keyof MultiSelectFieldDef, Classified> extends never
|
|
408
|
+
? true
|
|
409
|
+
: never = true;
|
|
410
|
+
|
|
411
|
+
const entity = {
|
|
412
|
+
fields: {
|
|
413
|
+
tags: {
|
|
414
|
+
type: "multiSelect",
|
|
415
|
+
required: true,
|
|
416
|
+
filterable: true,
|
|
417
|
+
options: ["a", "b"],
|
|
418
|
+
display: "checkboxes",
|
|
419
|
+
columns: 2,
|
|
420
|
+
maxRows: 4,
|
|
421
|
+
sensitive: true,
|
|
422
|
+
access: { read: ["admin"] },
|
|
423
|
+
pii: true,
|
|
424
|
+
userOwned: { ownerField: "authorId" },
|
|
425
|
+
tenantOwned: true,
|
|
426
|
+
anonymize: () => "[ANONYMIZED]",
|
|
427
|
+
allowPlaintext: "is_business_data",
|
|
428
|
+
lookupable: true,
|
|
429
|
+
subjectRef: true,
|
|
430
|
+
},
|
|
431
|
+
},
|
|
432
|
+
} as unknown as EntityDefinition;
|
|
433
|
+
|
|
434
|
+
const f = defineFeature("ent", (r) => {
|
|
435
|
+
r.entity("thing", entity);
|
|
436
|
+
});
|
|
437
|
+
const app = buildAppSchema(createRegistry([f]));
|
|
438
|
+
const projected = (
|
|
439
|
+
app.features[0]!.entities["thing"] as unknown as {
|
|
440
|
+
fields: Record<string, Record<string, unknown>>;
|
|
441
|
+
}
|
|
442
|
+
).fields["tags"] as Record<string, unknown>;
|
|
443
|
+
|
|
444
|
+
for (const key of FORWARDED_KEYS) {
|
|
445
|
+
expect(projected[key]).toBeDefined();
|
|
446
|
+
}
|
|
447
|
+
for (const key of SERVER_ONLY_KEYS) {
|
|
448
|
+
expect(projected[key]).toBeUndefined();
|
|
449
|
+
}
|
|
450
|
+
});
|
|
451
|
+
|
|
317
452
|
test("AppSchema ist via JSON.stringify roundtrip-sicher", () => {
|
|
318
453
|
// Echter Smoke-Test des Vertrags — wenn jemand in den project-
|
|
319
454
|
// Helper eine Function reinschmuggelt, würde das hier brennen.
|
|
@@ -0,0 +1,158 @@
|
|
|
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
|
+
// Akten-Layout: projectionDetail `layout.mode: "tabs"` + `header`/`metrics`.
|
|
13
|
+
describe("validateBoot — projectionDetail tabs (fw record-layout)", () => {
|
|
14
|
+
test("mode: tabs with only one section throws", () => {
|
|
15
|
+
const feature = defineFeature("app", (r) => {
|
|
16
|
+
r.screen({
|
|
17
|
+
id: "rent-detail",
|
|
18
|
+
type: "projectionDetail",
|
|
19
|
+
query: "app:query:rent:detail",
|
|
20
|
+
layout: {
|
|
21
|
+
mode: "tabs",
|
|
22
|
+
sections: [{ id: "overview", title: "Overview", fields: ["description"] }],
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
expect(() => validateBoot([feature])).toThrow(/tabs need at least 2 sections/);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("tabs section without a title throws", () => {
|
|
30
|
+
const feature = defineFeature("app", (r) => {
|
|
31
|
+
r.screen({
|
|
32
|
+
id: "rent-detail",
|
|
33
|
+
type: "projectionDetail",
|
|
34
|
+
query: "app:query:rent:detail",
|
|
35
|
+
layout: {
|
|
36
|
+
mode: "tabs",
|
|
37
|
+
sections: [
|
|
38
|
+
{ id: "overview", fields: ["description"] },
|
|
39
|
+
{ id: "history", title: "History", fields: ["notes"] },
|
|
40
|
+
],
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
expect(() => validateBoot([feature])).toThrow(/sections\[0\] has no title/);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("tabs section without an id throws", () => {
|
|
48
|
+
const feature = defineFeature("app", (r) => {
|
|
49
|
+
r.screen({
|
|
50
|
+
id: "rent-detail",
|
|
51
|
+
type: "projectionDetail",
|
|
52
|
+
query: "app:query:rent:detail",
|
|
53
|
+
layout: {
|
|
54
|
+
mode: "tabs",
|
|
55
|
+
sections: [
|
|
56
|
+
{ title: "Overview", fields: ["description"] },
|
|
57
|
+
{ id: "history", title: "History", fields: ["notes"] },
|
|
58
|
+
],
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
expect(() => validateBoot([feature])).toThrow(/sections\[0\] \("Overview"\) has no id/);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("tabs section id that is not kebab-case throws", () => {
|
|
66
|
+
const feature = defineFeature("app", (r) => {
|
|
67
|
+
r.screen({
|
|
68
|
+
id: "rent-detail",
|
|
69
|
+
type: "projectionDetail",
|
|
70
|
+
query: "app:query:rent:detail",
|
|
71
|
+
layout: {
|
|
72
|
+
mode: "tabs",
|
|
73
|
+
sections: [
|
|
74
|
+
{ id: "Overview", title: "Overview", fields: ["description"] },
|
|
75
|
+
{ id: "history", title: "History", fields: ["notes"] },
|
|
76
|
+
],
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
expect(() => validateBoot([feature])).toThrow(/must be kebab-case/);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("duplicate tab ids throw", () => {
|
|
84
|
+
const feature = defineFeature("app", (r) => {
|
|
85
|
+
r.screen({
|
|
86
|
+
id: "rent-detail",
|
|
87
|
+
type: "projectionDetail",
|
|
88
|
+
query: "app:query:rent:detail",
|
|
89
|
+
layout: {
|
|
90
|
+
mode: "tabs",
|
|
91
|
+
sections: [
|
|
92
|
+
{ id: "overview", title: "Overview", fields: ["description"] },
|
|
93
|
+
{ id: "overview", title: "History", fields: ["notes"] },
|
|
94
|
+
],
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
expect(() => validateBoot([feature])).toThrow(/duplicate tab id "overview"/);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("mode: tabs on entityEdit throws — tabs are projectionDetail-only", () => {
|
|
102
|
+
const feature = defineFeature("app", (r) => {
|
|
103
|
+
r.entity("rent", createEntity({ fields: { name: createTextField() } }));
|
|
104
|
+
r.screen({
|
|
105
|
+
id: "rent-edit",
|
|
106
|
+
type: "entityEdit",
|
|
107
|
+
entity: "rent",
|
|
108
|
+
layout: {
|
|
109
|
+
mode: "tabs",
|
|
110
|
+
sections: [
|
|
111
|
+
{ id: "overview", title: "Overview", columns: 1, fields: ["name"] },
|
|
112
|
+
{ id: "history", title: "History", columns: 1, fields: ["name"] },
|
|
113
|
+
],
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
expect(() => validateBoot([feature])).toThrow(
|
|
118
|
+
/Screen "rent-edit" \(entityEdit\) sets mode: "tabs" — tabs are only supported on projectionDetail/,
|
|
119
|
+
);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("metric without a fieldLabels entry throws — no fallback to the raw column name", () => {
|
|
123
|
+
const feature = defineFeature("app", (r) => {
|
|
124
|
+
r.screen({
|
|
125
|
+
id: "rent-detail",
|
|
126
|
+
type: "projectionDetail",
|
|
127
|
+
query: "app:query:rent:detail",
|
|
128
|
+
layout: { sections: [{ title: "s", fields: ["description"] }] },
|
|
129
|
+
metrics: ["balance"],
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
expect(() => validateBoot([feature])).toThrow(/metric "balance" has no entry in fieldLabels/);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("valid tabs + header + metrics declaration boots cleanly", () => {
|
|
136
|
+
const feature = defineFeature("app", (r) => {
|
|
137
|
+
r.queryHandler("rent:detail", z.object({}), async () => ({ description: "x" }), {
|
|
138
|
+
access: { openToAll: true },
|
|
139
|
+
});
|
|
140
|
+
r.screen({
|
|
141
|
+
id: "rent-detail",
|
|
142
|
+
type: "projectionDetail",
|
|
143
|
+
query: "app:query:rent:detail",
|
|
144
|
+
header: { title: "name", subtitle: "address", status: "state" },
|
|
145
|
+
metrics: ["balance", "overdueDays"],
|
|
146
|
+
fieldLabels: { balance: "rent.balance", overdueDays: "rent.overdueDays" },
|
|
147
|
+
layout: {
|
|
148
|
+
mode: "tabs",
|
|
149
|
+
sections: [
|
|
150
|
+
{ id: "overview", title: "Overview", fields: ["description"] },
|
|
151
|
+
{ id: "history", title: "History", fields: ["notes"] },
|
|
152
|
+
],
|
|
153
|
+
},
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
expect(() => validateBoot([feature])).not.toThrow();
|
|
157
|
+
});
|
|
158
|
+
});
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import { NO_WIDGET_FIELD_TYPES } from "@cosmicdrift/kumiko-types/fields";
|
|
8
8
|
import { rowMetaFieldNames } from "../../db/table-builder";
|
|
9
|
-
import { isValidQn, qualifyEntityName } from "../qualified-name";
|
|
9
|
+
import { isKebabSegment, isValidQn, qualifyEntityName } from "../qualified-name";
|
|
10
10
|
import { getAllowedFilterOps, isFieldFilterable } from "../screen-filter-ops";
|
|
11
11
|
import { isExtensionEditSection, normalizeEditField, normalizeListColumn } from "../screen-helpers";
|
|
12
12
|
import type { EntityDefinition, FeatureDefinition } from "../types";
|
|
@@ -83,6 +83,14 @@ function validateNoWidgetRequiredField(
|
|
|
83
83
|
// entityList and projectionList (framework#1708) — projectionList has no
|
|
84
84
|
// `screen.entity`, so there's no same-entity row["id"] auto-fill case: any
|
|
85
85
|
// entityEdit target without an explicit entityId reaches create there.
|
|
86
|
+
//
|
|
87
|
+
// dashboard targets (framework#1708 follow-up, commit 4e0d6cb26) also read
|
|
88
|
+
// URL search params — but only for the single `filter.id` a dashboard
|
|
89
|
+
// declares (useFilterParams in dashboard-body.tsx seeds its value from
|
|
90
|
+
// `nav.searchParams[filter.id]`). A dashboard with no `filter` has nowhere
|
|
91
|
+
// for the value to land, and a params extractor whose keys don't include
|
|
92
|
+
// the filter's id would silently miss it — both are boot errors instead of
|
|
93
|
+
// a silently-empty dashboard.
|
|
86
94
|
function validateRowActionNavigateParams(
|
|
87
95
|
featureName: string,
|
|
88
96
|
screenId: string,
|
|
@@ -93,16 +101,38 @@ function validateRowActionNavigateParams(
|
|
|
93
101
|
): void {
|
|
94
102
|
// skip: not a navigate-with-params action — nothing to validate here.
|
|
95
103
|
if (action.kind !== "navigate" || action.params === undefined) return;
|
|
96
|
-
//
|
|
104
|
+
// skip: unresolvable/custom target already reported (or exempt) elsewhere.
|
|
105
|
+
if (target === undefined || target.screen.type === "custom") return;
|
|
106
|
+
// skip: entityList/projectionList targets also read URL search params (Tier
|
|
97
107
|
// 2.7c filter-prefill, see use-list-url-state.ts: `<screenId>.q/.sort/
|
|
98
108
|
// .dir/.page/.f.<field>`), not just actionForm/entityEdit-create.
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
109
|
+
if (target.screen.type === "entityList" || target.screen.type === "projectionList") return;
|
|
110
|
+
|
|
111
|
+
if (target.screen.type === "dashboard") {
|
|
112
|
+
const targetDescriptor =
|
|
113
|
+
action.screen !== undefined ? `"${action.screen}"` : `entity "${action.entity}"`;
|
|
114
|
+
const filter = target.screen.filter;
|
|
115
|
+
if (filter === undefined) {
|
|
116
|
+
throw new Error(
|
|
117
|
+
`[Feature ${featureName}] Screen "${screenId}" (${screenType}) rowAction "${action.id}" sets ` +
|
|
118
|
+
`params on navigate-target ${targetDescriptor} (dashboard) — target dashboard declares no ` +
|
|
119
|
+
`filter — params would be a no-op. Remove the params extractor, or add a \`filter\` to the ` +
|
|
120
|
+
`target dashboard so it has somewhere to read the value from.`,
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
const extractedKeys =
|
|
124
|
+
"pick" in action.params ? action.params.pick : Object.keys(action.params.map);
|
|
125
|
+
if (!extractedKeys.includes(filter.id)) {
|
|
126
|
+
throw new Error(
|
|
127
|
+
`[Feature ${featureName}] Screen "${screenId}" (${screenType}) rowAction "${action.id}" sets ` +
|
|
128
|
+
`params [${extractedKeys.join(", ")}] on navigate-target ${targetDescriptor} (dashboard) whose ` +
|
|
129
|
+
`filter id is "${filter.id}" — none of the extracted keys match, so the filter would stay ` +
|
|
130
|
+
`unset. Fix the params extractor to produce a "${filter.id}" key, or remove it.`,
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
// skip: filter present and the extractor's keys cover it — valid dashboard deep-link.
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
106
136
|
|
|
107
137
|
const isEntityEditUpdate =
|
|
108
138
|
target.screen.type === "entityEdit" &&
|
|
@@ -140,6 +170,17 @@ function validateWizardLayout(
|
|
|
140
170
|
layout: EditLayout,
|
|
141
171
|
featureMap: ReadonlyMap<string, FeatureDefinition>,
|
|
142
172
|
): void {
|
|
173
|
+
// Tabs truncate the layout to one section, but scopeFieldNames derives
|
|
174
|
+
// required-field validation from the `fields` prop, not from layout — a
|
|
175
|
+
// hidden tab could hide a required field and silently block submit.
|
|
176
|
+
// projectionDetail has no submit, so tabs are safe there (see its own
|
|
177
|
+
// branch in validateScreens) but not here.
|
|
178
|
+
if (layout.mode === "tabs") {
|
|
179
|
+
throw new Error(
|
|
180
|
+
`[Feature ${featureName}] Screen "${screenId}" (${screenType}) sets mode: "tabs" — tabs are only ` +
|
|
181
|
+
`supported on projectionDetail. Use mode: "wizard" or "single" instead.`,
|
|
182
|
+
);
|
|
183
|
+
}
|
|
143
184
|
// "form-draft" is hardcoded because the framework layer must not depend on
|
|
144
185
|
// @cosmicdrift/kumiko-bundled-features — same precedence as the
|
|
145
186
|
// "user-data-rights" check in gdpr-storage.ts.
|
|
@@ -572,6 +613,54 @@ export function validateScreens(
|
|
|
572
613
|
`declare at least one section.`,
|
|
573
614
|
);
|
|
574
615
|
}
|
|
616
|
+
if (screen.layout.mode === "tabs") {
|
|
617
|
+
if (screen.layout.sections.length < 2) {
|
|
618
|
+
throw new Error(
|
|
619
|
+
`[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) has mode: "tabs" but only ` +
|
|
620
|
+
`${screen.layout.sections.length} section(s) — tabs need at least 2 sections.`,
|
|
621
|
+
);
|
|
622
|
+
}
|
|
623
|
+
const tabIds = new Set<string>();
|
|
624
|
+
screen.layout.sections.forEach((section, index) => {
|
|
625
|
+
if (section.title === undefined || section.title.trim().length === 0) {
|
|
626
|
+
throw new Error(
|
|
627
|
+
`[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) has mode: "tabs" but ` +
|
|
628
|
+
`sections[${index}] has no title — every tab needs a title.`,
|
|
629
|
+
);
|
|
630
|
+
}
|
|
631
|
+
if (section.id === undefined || section.id.trim().length === 0) {
|
|
632
|
+
throw new Error(
|
|
633
|
+
`[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) has mode: "tabs" but ` +
|
|
634
|
+
`sections[${index}] ("${section.title}") has no id — every tab needs a stable id for ` +
|
|
635
|
+
`the ?tab= param.`,
|
|
636
|
+
);
|
|
637
|
+
}
|
|
638
|
+
if (!isKebabSegment(section.id)) {
|
|
639
|
+
throw new Error(
|
|
640
|
+
`[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) sections[${index}] ` +
|
|
641
|
+
`("${section.title}") has id "${section.id}" — must be kebab-case.`,
|
|
642
|
+
);
|
|
643
|
+
}
|
|
644
|
+
if (tabIds.has(section.id)) {
|
|
645
|
+
throw new Error(
|
|
646
|
+
`[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) has duplicate tab id ` +
|
|
647
|
+
`"${section.id}" (sections[${index}]).`,
|
|
648
|
+
);
|
|
649
|
+
}
|
|
650
|
+
tabIds.add(section.id);
|
|
651
|
+
});
|
|
652
|
+
}
|
|
653
|
+
if (screen.metrics !== undefined) {
|
|
654
|
+
for (const metric of screen.metrics) {
|
|
655
|
+
if (screen.fieldLabels?.[metric] === undefined) {
|
|
656
|
+
throw new Error(
|
|
657
|
+
`[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) metric "${metric}" has ` +
|
|
658
|
+
`no entry in fieldLabels — every metrics field needs a label, there is no fallback to ` +
|
|
659
|
+
`the raw column name.`,
|
|
660
|
+
);
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
}
|
|
575
664
|
for (const section of screen.layout.sections) {
|
|
576
665
|
if (isExtensionEditSection(section)) {
|
|
577
666
|
throw new Error(
|
|
@@ -428,6 +428,12 @@ function projectField(fieldDef: FieldDefinition): FieldDefinition {
|
|
|
428
428
|
if (typeof def["entity"] === "string") out["entity"] = def["entity"];
|
|
429
429
|
if (typeof def["labelField"] === "string") out["labelField"] = def["labelField"];
|
|
430
430
|
if (typeof def["multiple"] === "boolean") out["multiple"] = def["multiple"];
|
|
431
|
+
// MultiSelect: display picks checkboxes vs. combobox in the renderer,
|
|
432
|
+
// columns/maxRows size the checkbox grid — without these the renderer
|
|
433
|
+
// always falls back to the combobox (fw#2494).
|
|
434
|
+
if (typeof def["display"] === "string") out["display"] = def["display"];
|
|
435
|
+
if (typeof def["columns"] === "number") out["columns"] = def["columns"];
|
|
436
|
+
if (typeof def["maxRows"] === "number") out["maxRows"] = def["maxRows"];
|
|
431
437
|
return out as FieldDefinition; // @cast-boundary schema-walk
|
|
432
438
|
}
|
|
433
439
|
|