@cosmicdrift/kumiko-framework 0.208.2 → 0.209.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-projection-list.test.ts +204 -0
- package/src/engine/__tests__/boot-validator.test.ts +97 -0
- package/src/engine/__tests__/build-config-feature-schema.test.ts +28 -0
- package/src/engine/__tests__/projection-list-filter.integration.test.ts +110 -0
- package/src/engine/boot-validator/projection-list-screens.ts +39 -0
- package/src/engine/boot-validator/screens.ts +103 -0
- package/src/engine/build-config-feature-schema.ts +13 -2
- package/src/engine/field-helpers.ts +4 -0
- package/src/engine/index.ts +1 -1
- package/src/engine/types/index.ts +2 -1
- package/src/i18n/required-surface-keys.ts +3 -0
- package/src/ui-types/index.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.209.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>",
|
|
@@ -190,7 +190,7 @@
|
|
|
190
190
|
"./package.json": "./package.json"
|
|
191
191
|
},
|
|
192
192
|
"dependencies": {
|
|
193
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
193
|
+
"@cosmicdrift/kumiko-types": "0.209.0",
|
|
194
194
|
"bullmq": "^5.76.7",
|
|
195
195
|
"bun-types": "^1.3.13",
|
|
196
196
|
"hono": "^4.13.1",
|
|
@@ -206,7 +206,7 @@
|
|
|
206
206
|
"zod": "^4.4.3"
|
|
207
207
|
},
|
|
208
208
|
"devDependencies": {
|
|
209
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
209
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.209.0",
|
|
210
210
|
"bun-types": "^1.3.13",
|
|
211
211
|
"pino-pretty": "^13.1.3"
|
|
212
212
|
},
|
|
@@ -238,4 +238,208 @@ describe("validateBoot — projectionList screens", () => {
|
|
|
238
238
|
);
|
|
239
239
|
});
|
|
240
240
|
});
|
|
241
|
+
|
|
242
|
+
// fw#2224: filter/facets are new on ProjectionListScreenDefinition — no
|
|
243
|
+
// entity to check field-existence against, so validation is either
|
|
244
|
+
// structural (filter) or checked against the declared columns (facets),
|
|
245
|
+
// plus the same "does the bound query's schema actually accept this
|
|
246
|
+
// param" check fw#2165 already does for search/sort.
|
|
247
|
+
describe("projectionList filter + facets (fw#2224)", () => {
|
|
248
|
+
test('filter.op "in" requires an array value', () => {
|
|
249
|
+
const feature = defineFeature("ledger", (r) => {
|
|
250
|
+
r.queryHandler(
|
|
251
|
+
"schedule:list",
|
|
252
|
+
z.object({ filter: z.unknown().optional() }),
|
|
253
|
+
async () => ({ rows: [], nextCursor: null }),
|
|
254
|
+
{ access: { openToAll: true } },
|
|
255
|
+
);
|
|
256
|
+
r.screen({
|
|
257
|
+
id: "schedule-list",
|
|
258
|
+
type: "projectionList",
|
|
259
|
+
query: "ledger:query:schedule:list",
|
|
260
|
+
columns: ["description"],
|
|
261
|
+
filter: { field: "status", op: "in", value: "not-an-array" },
|
|
262
|
+
});
|
|
263
|
+
r.translations({
|
|
264
|
+
keys: { "screen:schedule-list.title": { de: "Liste", en: "List" } },
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
expect(() => validateBoot([feature])).toThrow(/filter\.op "in" requires/);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
test('filter declared but the query schema has no "filter" parameter', () => {
|
|
271
|
+
const feature = defineFeature("ledger", (r) => {
|
|
272
|
+
r.queryHandler(
|
|
273
|
+
"schedule:list",
|
|
274
|
+
z.object({}),
|
|
275
|
+
async () => ({ rows: [], nextCursor: null }),
|
|
276
|
+
{
|
|
277
|
+
access: { openToAll: true },
|
|
278
|
+
},
|
|
279
|
+
);
|
|
280
|
+
r.screen({
|
|
281
|
+
id: "schedule-list",
|
|
282
|
+
type: "projectionList",
|
|
283
|
+
query: "ledger:query:schedule:list",
|
|
284
|
+
columns: ["description"],
|
|
285
|
+
filter: { field: "status", op: "eq", value: "active" },
|
|
286
|
+
});
|
|
287
|
+
r.translations({
|
|
288
|
+
keys: { "screen:schedule-list.title": { de: "Liste", en: "List" } },
|
|
289
|
+
});
|
|
290
|
+
});
|
|
291
|
+
expect(() => validateBoot([feature])).toThrow(/no "filter" parameter/);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
test("a facet referencing a field that isn't a declared column is rejected", () => {
|
|
295
|
+
const feature = defineFeature("ledger", (r) => {
|
|
296
|
+
r.queryHandler(
|
|
297
|
+
"schedule:list",
|
|
298
|
+
z.object({ filters: z.unknown().optional() }),
|
|
299
|
+
async () => ({ rows: [], nextCursor: null }),
|
|
300
|
+
{ access: { openToAll: true } },
|
|
301
|
+
);
|
|
302
|
+
r.screen({
|
|
303
|
+
id: "schedule-list",
|
|
304
|
+
type: "projectionList",
|
|
305
|
+
query: "ledger:query:schedule:list",
|
|
306
|
+
columns: ["description"],
|
|
307
|
+
facets: [
|
|
308
|
+
{
|
|
309
|
+
field: "status",
|
|
310
|
+
type: "select",
|
|
311
|
+
label: "Status",
|
|
312
|
+
options: [{ value: "active", label: "Active" }],
|
|
313
|
+
},
|
|
314
|
+
],
|
|
315
|
+
});
|
|
316
|
+
r.translations({
|
|
317
|
+
keys: { "screen:schedule-list.title": { de: "Liste", en: "List" } },
|
|
318
|
+
});
|
|
319
|
+
});
|
|
320
|
+
expect(() => validateBoot([feature])).toThrow(/not a declared column/);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
test("duplicate facet fields are rejected", () => {
|
|
324
|
+
const feature = defineFeature("ledger", (r) => {
|
|
325
|
+
r.queryHandler(
|
|
326
|
+
"schedule:list",
|
|
327
|
+
z.object({ filters: z.unknown().optional() }),
|
|
328
|
+
async () => ({ rows: [], nextCursor: null }),
|
|
329
|
+
{ access: { openToAll: true } },
|
|
330
|
+
);
|
|
331
|
+
r.screen({
|
|
332
|
+
id: "schedule-list",
|
|
333
|
+
type: "projectionList",
|
|
334
|
+
query: "ledger:query:schedule:list",
|
|
335
|
+
columns: ["status"],
|
|
336
|
+
facets: [
|
|
337
|
+
{
|
|
338
|
+
field: "status",
|
|
339
|
+
type: "boolean",
|
|
340
|
+
label: "Status",
|
|
341
|
+
trueLabel: "On",
|
|
342
|
+
falseLabel: "Off",
|
|
343
|
+
},
|
|
344
|
+
{
|
|
345
|
+
field: "status",
|
|
346
|
+
type: "boolean",
|
|
347
|
+
label: "Status",
|
|
348
|
+
trueLabel: "On",
|
|
349
|
+
falseLabel: "Off",
|
|
350
|
+
},
|
|
351
|
+
],
|
|
352
|
+
});
|
|
353
|
+
r.translations({
|
|
354
|
+
keys: { "screen:schedule-list.title": { de: "Liste", en: "List" } },
|
|
355
|
+
});
|
|
356
|
+
});
|
|
357
|
+
expect(() => validateBoot([feature])).toThrow(/more than once/);
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
test("a select facet with an empty options list is rejected", () => {
|
|
361
|
+
const feature = defineFeature("ledger", (r) => {
|
|
362
|
+
r.queryHandler(
|
|
363
|
+
"schedule:list",
|
|
364
|
+
z.object({ filters: z.unknown().optional() }),
|
|
365
|
+
async () => ({ rows: [], nextCursor: null }),
|
|
366
|
+
{ access: { openToAll: true } },
|
|
367
|
+
);
|
|
368
|
+
r.screen({
|
|
369
|
+
id: "schedule-list",
|
|
370
|
+
type: "projectionList",
|
|
371
|
+
query: "ledger:query:schedule:list",
|
|
372
|
+
columns: ["status"],
|
|
373
|
+
facets: [{ field: "status", type: "select", label: "Status", options: [] }],
|
|
374
|
+
});
|
|
375
|
+
r.translations({
|
|
376
|
+
keys: { "screen:schedule-list.title": { de: "Liste", en: "List" } },
|
|
377
|
+
});
|
|
378
|
+
});
|
|
379
|
+
expect(() => validateBoot([feature])).toThrow(/empty options list/);
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
test('facets declared but the query schema has no "filters" parameter', () => {
|
|
383
|
+
const feature = defineFeature("ledger", (r) => {
|
|
384
|
+
r.queryHandler(
|
|
385
|
+
"schedule:list",
|
|
386
|
+
z.object({}),
|
|
387
|
+
async () => ({ rows: [], nextCursor: null }),
|
|
388
|
+
{
|
|
389
|
+
access: { openToAll: true },
|
|
390
|
+
},
|
|
391
|
+
);
|
|
392
|
+
r.screen({
|
|
393
|
+
id: "schedule-list",
|
|
394
|
+
type: "projectionList",
|
|
395
|
+
query: "ledger:query:schedule:list",
|
|
396
|
+
columns: ["status"],
|
|
397
|
+
facets: [
|
|
398
|
+
{
|
|
399
|
+
field: "status",
|
|
400
|
+
type: "boolean",
|
|
401
|
+
label: "Status",
|
|
402
|
+
trueLabel: "On",
|
|
403
|
+
falseLabel: "Off",
|
|
404
|
+
},
|
|
405
|
+
],
|
|
406
|
+
});
|
|
407
|
+
r.translations({
|
|
408
|
+
keys: { "screen:schedule-list.title": { de: "Liste", en: "List" } },
|
|
409
|
+
});
|
|
410
|
+
});
|
|
411
|
+
expect(() => validateBoot([feature])).toThrow(/no "filters" parameter/);
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
test("a valid filter + facets declaration on a schema that accepts both passes boot", () => {
|
|
415
|
+
const feature = defineFeature("ledger", (r) => {
|
|
416
|
+
r.queryHandler(
|
|
417
|
+
"schedule:list",
|
|
418
|
+
z.object({ filter: z.unknown().optional(), filters: z.unknown().optional() }),
|
|
419
|
+
async () => ({ rows: [], nextCursor: null }),
|
|
420
|
+
{ access: { openToAll: true } },
|
|
421
|
+
);
|
|
422
|
+
r.screen({
|
|
423
|
+
id: "schedule-list",
|
|
424
|
+
type: "projectionList",
|
|
425
|
+
query: "ledger:query:schedule:list",
|
|
426
|
+
columns: ["status"],
|
|
427
|
+
filter: { field: "tier", op: "eq", value: "gold" },
|
|
428
|
+
facets: [
|
|
429
|
+
{
|
|
430
|
+
field: "status",
|
|
431
|
+
type: "boolean",
|
|
432
|
+
label: "Status",
|
|
433
|
+
trueLabel: "On",
|
|
434
|
+
falseLabel: "Off",
|
|
435
|
+
},
|
|
436
|
+
],
|
|
437
|
+
});
|
|
438
|
+
r.translations({
|
|
439
|
+
keys: { "screen:schedule-list.title": { de: "Liste", en: "List" } },
|
|
440
|
+
});
|
|
441
|
+
});
|
|
442
|
+
expect(() => validateBoot([feature])).not.toThrow();
|
|
443
|
+
});
|
|
444
|
+
});
|
|
241
445
|
});
|
|
@@ -3511,6 +3511,103 @@ describe("boot-validator", () => {
|
|
|
3511
3511
|
});
|
|
3512
3512
|
});
|
|
3513
3513
|
|
|
3514
|
+
// --- toolbarAction drawer (fw#2225) ---
|
|
3515
|
+
describe("entityList toolbarAction drawer (fw#2225)", () => {
|
|
3516
|
+
function makeFeature(opts: {
|
|
3517
|
+
readonly targetId?: string;
|
|
3518
|
+
readonly targetType?: "actionForm" | "entityList";
|
|
3519
|
+
}) {
|
|
3520
|
+
const targetId = opts.targetId ?? "restock-form";
|
|
3521
|
+
return defineFeature("shop", (r) => {
|
|
3522
|
+
r.entity("product", createEntity({ fields: { name: createTextField() } }));
|
|
3523
|
+
r.screen({
|
|
3524
|
+
id: "product-list",
|
|
3525
|
+
type: "entityList",
|
|
3526
|
+
entity: "product",
|
|
3527
|
+
columns: ["name"],
|
|
3528
|
+
toolbarActions: [
|
|
3529
|
+
{ kind: "drawer", id: "open-drawer", label: "actions.restock", screen: targetId },
|
|
3530
|
+
],
|
|
3531
|
+
});
|
|
3532
|
+
if (opts.targetType === "entityList") {
|
|
3533
|
+
r.screen({
|
|
3534
|
+
id: targetId,
|
|
3535
|
+
type: "entityList",
|
|
3536
|
+
entity: "product",
|
|
3537
|
+
columns: ["name"],
|
|
3538
|
+
});
|
|
3539
|
+
return;
|
|
3540
|
+
}
|
|
3541
|
+
if (opts.targetType === "actionForm") {
|
|
3542
|
+
r.writeHandler(
|
|
3543
|
+
"restock",
|
|
3544
|
+
z.object({ qty: z.number() }),
|
|
3545
|
+
async () => ({ isSuccess: true as const, data: null }),
|
|
3546
|
+
{ access: { roles: ["Admin"] } },
|
|
3547
|
+
);
|
|
3548
|
+
r.screen({
|
|
3549
|
+
id: targetId,
|
|
3550
|
+
type: "actionForm",
|
|
3551
|
+
handler: "shop:write:restock",
|
|
3552
|
+
fields: { qty: { type: "number" } } as never,
|
|
3553
|
+
layout: { sections: [{ fields: ["qty"] }] },
|
|
3554
|
+
});
|
|
3555
|
+
}
|
|
3556
|
+
});
|
|
3557
|
+
}
|
|
3558
|
+
|
|
3559
|
+
test("drawer-target → registered actionForm → no throw", () => {
|
|
3560
|
+
expect(() => validateBoot([makeFeature({ targetType: "actionForm" })])).not.toThrow();
|
|
3561
|
+
});
|
|
3562
|
+
|
|
3563
|
+
test("drawer-target → unknown → throw with a clear message", () => {
|
|
3564
|
+
expect(() => validateBoot([makeFeature({ targetId: "ghost-form" })])).toThrow(
|
|
3565
|
+
/toolbarAction "open-drawer" drawer-target "ghost-form" does not resolve to a registered screen/,
|
|
3566
|
+
);
|
|
3567
|
+
});
|
|
3568
|
+
|
|
3569
|
+
test("drawer-target → screen exists but is not an actionForm → throw with a clear message", () => {
|
|
3570
|
+
expect(() =>
|
|
3571
|
+
validateBoot([makeFeature({ targetId: "product-list-2", targetType: "entityList" })]),
|
|
3572
|
+
).toThrow(
|
|
3573
|
+
/toolbarAction "open-drawer" drawer-target "product-list-2" is a "entityList" screen, not an actionForm/,
|
|
3574
|
+
);
|
|
3575
|
+
});
|
|
3576
|
+
|
|
3577
|
+
test("drawer-target → actionForm in ANOTHER feature → throw (drawer resolves same-feature only)", () => {
|
|
3578
|
+
const list = defineFeature("shop", (r) => {
|
|
3579
|
+
r.entity("product", createEntity({ fields: { name: createTextField() } }));
|
|
3580
|
+
r.screen({
|
|
3581
|
+
id: "product-list",
|
|
3582
|
+
type: "entityList",
|
|
3583
|
+
entity: "product",
|
|
3584
|
+
columns: ["name"],
|
|
3585
|
+
toolbarActions: [
|
|
3586
|
+
{ kind: "drawer", id: "open-drawer", label: "actions.restock", screen: "restock-form" },
|
|
3587
|
+
],
|
|
3588
|
+
});
|
|
3589
|
+
});
|
|
3590
|
+
const consumer = defineFeature("app", (r) => {
|
|
3591
|
+
r.writeHandler(
|
|
3592
|
+
"restock",
|
|
3593
|
+
z.object({ qty: z.number() }),
|
|
3594
|
+
async () => ({ isSuccess: true as const, data: null }),
|
|
3595
|
+
{ access: { roles: ["Admin"] } },
|
|
3596
|
+
);
|
|
3597
|
+
r.screen({
|
|
3598
|
+
id: "restock-form",
|
|
3599
|
+
type: "actionForm",
|
|
3600
|
+
handler: "app:write:restock",
|
|
3601
|
+
fields: { qty: { type: "number" } } as never,
|
|
3602
|
+
layout: { sections: [{ fields: ["qty"] }] },
|
|
3603
|
+
});
|
|
3604
|
+
});
|
|
3605
|
+
expect(() => validateBoot([list, consumer])).toThrow(
|
|
3606
|
+
/toolbarAction "open-drawer" drawer-target "restock-form" does not resolve to a registered screen in this feature/,
|
|
3607
|
+
);
|
|
3608
|
+
});
|
|
3609
|
+
});
|
|
3610
|
+
|
|
3514
3611
|
// --- defaultSort funktioniert für ALLE Field-Types die sortable
|
|
3515
3612
|
// unterstützen (Tier 2.6b Field-Erweiterung) ---
|
|
3516
3613
|
// Vor Tier 2.6b war `sortable` nur auf TextFieldDef. Erweitert auf
|
|
@@ -137,6 +137,34 @@ describe("buildConfigFeatureSchema — structure", () => {
|
|
|
137
137
|
]);
|
|
138
138
|
});
|
|
139
139
|
|
|
140
|
+
test("screen layout renders full-width", () => {
|
|
141
|
+
expect(configScreen("billing-tenant").layout.width).toBe("full");
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("section has no description when the feature never declares the '<feature>.settings.description' key", () => {
|
|
145
|
+
// billing declares no translations at all — the section must NOT carry a
|
|
146
|
+
// description prop (not just undefined), else the renderer would try to
|
|
147
|
+
// subtitle-render translate()'s undeclared-key fallback (the raw key).
|
|
148
|
+
const section = configScreen("billing-tenant").layout.sections[0];
|
|
149
|
+
expect(section && "description" in section).toBe(false);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("section carries description when the feature declares '<feature>.settings.description'", () => {
|
|
153
|
+
const described = defineFeature("described", (r) => {
|
|
154
|
+
r.translations({ keys: { "described.settings.description": { en: "Manage settings." } } });
|
|
155
|
+
r.config({
|
|
156
|
+
keys: { flag: createTenantConfig("boolean", { mask: { title: "described.flag" } }) },
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
const out = buildConfigFeatureSchema(createRegistry([described]));
|
|
160
|
+
const screen = out.screens.find((s) => s.id === "described-tenant");
|
|
161
|
+
if (screen?.type !== "configEdit") throw new Error("expected configEdit screen");
|
|
162
|
+
const section = screen.layout.sections[0];
|
|
163
|
+
expect(section && "description" in section ? section.description : undefined).toBe(
|
|
164
|
+
"described.settings.description",
|
|
165
|
+
);
|
|
166
|
+
});
|
|
167
|
+
|
|
140
168
|
test("excludes unmasked keys and computed keys", () => {
|
|
141
169
|
const s = configScreen("billing-tenant");
|
|
142
170
|
// internal-flag has no mask, derived is computed → neither appears anywhere
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// fw#2224: a projectionList screen can now declare `filter`/`facets`, but
|
|
2
|
+
// the wire contract that actually matters is the payload the query handler
|
|
3
|
+
// receives — `screen.filter`/facet-toggles reach the server as
|
|
4
|
+
// payload.filter/payload.filters (see kumiko-screen.tsx's ProjectionListBody
|
|
5
|
+
// queryPayload). This proves the server side of that contract over real
|
|
6
|
+
// HTTP: a query handler whose Zod schema accepts filter/filters (here the
|
|
7
|
+
// entity-list auto-CRUD handler, entityListSchema) genuinely narrows the
|
|
8
|
+
// returned rows — not just "the field is present in the payload".
|
|
9
|
+
//
|
|
10
|
+
// The bound query is a real entity-list handler (r.crud), the same one a
|
|
11
|
+
// projectionList screen would point `query` at to reuse another feature's
|
|
12
|
+
// list — see the registered "member-list" projectionList screen below,
|
|
13
|
+
// which pins that this is a realistic, boot-valid setup, not just a
|
|
14
|
+
// hand-rolled test handler.
|
|
15
|
+
|
|
16
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
17
|
+
import { asRawClient } from "../../db/query";
|
|
18
|
+
import { setupTestStack, type TestStack, TestUsers, unsafeCreateEntityTable } from "../../stack";
|
|
19
|
+
import { defineFeature } from "../define-feature";
|
|
20
|
+
import { createEntity, createTextField } from "../factories";
|
|
21
|
+
|
|
22
|
+
const memberEntity = createEntity({
|
|
23
|
+
table: "pl_filter_members",
|
|
24
|
+
fields: {
|
|
25
|
+
name: createTextField({ required: true }),
|
|
26
|
+
status: createTextField({ required: true, filterable: true }),
|
|
27
|
+
},
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const LIST_QN = "roster:query:member:list";
|
|
31
|
+
|
|
32
|
+
const rosterFeature = defineFeature("roster", (r) => {
|
|
33
|
+
r.crud("member", memberEntity, {
|
|
34
|
+
write: { access: { roles: ["Admin"] } },
|
|
35
|
+
read: { access: { openToAll: true } },
|
|
36
|
+
});
|
|
37
|
+
r.screen({
|
|
38
|
+
id: "member-list",
|
|
39
|
+
type: "projectionList",
|
|
40
|
+
query: LIST_QN,
|
|
41
|
+
columns: ["name", "status"],
|
|
42
|
+
filter: { field: "status", op: "eq", value: "active" },
|
|
43
|
+
});
|
|
44
|
+
r.translations({
|
|
45
|
+
keys: { "screen:member-list.title": { de: "Mitglieder", en: "Members" } },
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe("projectionList filter — real query narrows real rows (fw#2224)", () => {
|
|
50
|
+
let stack: TestStack;
|
|
51
|
+
|
|
52
|
+
beforeAll(async () => {
|
|
53
|
+
stack = await setupTestStack({ features: [rosterFeature] });
|
|
54
|
+
await unsafeCreateEntityTable(stack.db, memberEntity);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
afterAll(async () => {
|
|
58
|
+
await stack.cleanup();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
beforeEach(async () => {
|
|
62
|
+
await asRawClient(stack.db).unsafe("DELETE FROM kumiko_events");
|
|
63
|
+
await asRawClient(stack.db).unsafe('DELETE FROM "pl_filter_members"');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
async function seed(): Promise<void> {
|
|
67
|
+
const CREATE = "roster:write:member:create";
|
|
68
|
+
await stack.http.write(CREATE, { name: "Ada", status: "active" }, TestUsers.admin);
|
|
69
|
+
await stack.http.write(CREATE, { name: "Grace", status: "active" }, TestUsers.admin);
|
|
70
|
+
await stack.http.write(CREATE, { name: "Bob", status: "inactive" }, TestUsers.admin);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
test("screen.filter's shape (payload.filter, op:eq) returns only matching rows", async () => {
|
|
74
|
+
await seed();
|
|
75
|
+
const result = await stack.http.queryOk<{
|
|
76
|
+
readonly rows: readonly { readonly name: string; readonly status: string }[];
|
|
77
|
+
}>(
|
|
78
|
+
LIST_QN,
|
|
79
|
+
{ limit: 50, filter: { field: "status", op: "eq", value: "active" } },
|
|
80
|
+
TestUsers.admin,
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
expect(result.rows).toHaveLength(2);
|
|
84
|
+
expect(result.rows.map((r) => r.name).sort()).toEqual(["Ada", "Grace"]);
|
|
85
|
+
expect(result.rows.every((r) => r.status === "active")).toBe(true);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("a facet's shape (payload.filters, op:in) returns only matching rows", async () => {
|
|
89
|
+
await seed();
|
|
90
|
+
const result = await stack.http.queryOk<{
|
|
91
|
+
readonly rows: readonly { readonly name: string; readonly status: string }[];
|
|
92
|
+
}>(
|
|
93
|
+
LIST_QN,
|
|
94
|
+
{ limit: 50, filters: [{ field: "status", op: "in", value: ["inactive"] }] },
|
|
95
|
+
TestUsers.admin,
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
expect(result.rows).toHaveLength(1);
|
|
99
|
+
expect(result.rows[0]?.name).toBe("Bob");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("no filter — all rows return (control: proves the filter tests above actually narrow something)", async () => {
|
|
103
|
+
await seed();
|
|
104
|
+
const result = await stack.http.queryOk<{
|
|
105
|
+
readonly rows: readonly { readonly name: string; readonly status: string }[];
|
|
106
|
+
}>(LIST_QN, { limit: 50 }, TestUsers.admin);
|
|
107
|
+
|
|
108
|
+
expect(result.rows).toHaveLength(3);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
@@ -71,6 +71,45 @@ function validateOneProjectionListScreen(
|
|
|
71
71
|
if ((searchActive || sortActive) && screen.defaultSort === undefined) {
|
|
72
72
|
throw new Error(`${prefix}: defaultSort required when search or sort is active`);
|
|
73
73
|
}
|
|
74
|
+
|
|
75
|
+
validateProjectionListFilterSchemaAcceptance(prefix, screen, schema);
|
|
76
|
+
validateProjectionListFacetsSchemaAcceptance(prefix, screen, schema);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// filter (fw#2224) sends `payload.filter` — same 422 footgun as facets below
|
|
80
|
+
// if the handler's schema doesn't accept it.
|
|
81
|
+
function validateProjectionListFilterSchemaAcceptance(
|
|
82
|
+
prefix: string,
|
|
83
|
+
screen: ProjectionListScreenDefinition,
|
|
84
|
+
schema: QueryHandlerDef["schema"] | undefined,
|
|
85
|
+
): void {
|
|
86
|
+
// skip: no filter declared, or the schema already accepts it — nothing to reject.
|
|
87
|
+
if (screen.filter === undefined || schemaAccepts(schema, "filter")) return;
|
|
88
|
+
throw new Error(
|
|
89
|
+
`${prefix}: declares filter but query "${screen.query}" has no "filter" parameter in its Zod ` +
|
|
90
|
+
`schema — add filter: z.object({ field: z.string(), op: z.enum(["eq","ne","lt","gt","in"]), ` +
|
|
91
|
+
`value: z.unknown() }).optional() (or reuse entityListSchema's shape) to the handler's schema.`,
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// facets (fw#2224) send `payload.filters` — same failure mode as search/sort
|
|
96
|
+
// (fw#2165): definePagedQueryHandler doesn't auto-merge params into the
|
|
97
|
+
// handler's own Zod schema, so a declared facet would 422 on every query
|
|
98
|
+
// unless the author added `filters` themselves.
|
|
99
|
+
function validateProjectionListFacetsSchemaAcceptance(
|
|
100
|
+
prefix: string,
|
|
101
|
+
screen: ProjectionListScreenDefinition,
|
|
102
|
+
schema: QueryHandlerDef["schema"] | undefined,
|
|
103
|
+
): void {
|
|
104
|
+
// skip: no facets declared — nothing to reject.
|
|
105
|
+
if (screen.facets === undefined || screen.facets.length === 0) return;
|
|
106
|
+
// skip: the schema already accepts filters — nothing to reject.
|
|
107
|
+
if (schemaAccepts(schema, "filters")) return;
|
|
108
|
+
throw new Error(
|
|
109
|
+
`${prefix}: declares facets but query "${screen.query}" has no "filters" parameter in its ` +
|
|
110
|
+
`Zod schema — add filters: z.array(z.object({ field: z.string(), op: z.literal("in"), ` +
|
|
111
|
+
`value: z.unknown() })).optional() (or reuse entityListSchema's shape) to the handler's schema.`,
|
|
112
|
+
);
|
|
74
113
|
}
|
|
75
114
|
|
|
76
115
|
export function validateProjectionListScreens(features: readonly FeatureDefinition[]): void {
|
|
@@ -303,6 +303,39 @@ function validateScreenNavTarget(
|
|
|
303
303
|
}
|
|
304
304
|
}
|
|
305
305
|
|
|
306
|
+
// kind:"drawer" resolves same-feature only — unlike navigate/redirect,
|
|
307
|
+
// which the runtime router resolves app-wide (see the comment on
|
|
308
|
+
// validateScreens below), the drawer mounts the target inline using this
|
|
309
|
+
// feature's schema, so a cross-feature reference could never actually
|
|
310
|
+
// render. Two distinct failure messages: dangling reference vs. wrong
|
|
311
|
+
// screen type (fw#2225).
|
|
312
|
+
function validateToolbarDrawerAction(
|
|
313
|
+
featureName: string,
|
|
314
|
+
screenId: string,
|
|
315
|
+
screenKind: string,
|
|
316
|
+
action: Extract<ToolbarAction, { kind: "drawer" }>,
|
|
317
|
+
screens: FeatureDefinition["screens"],
|
|
318
|
+
): void {
|
|
319
|
+
const target = screens[action.screen];
|
|
320
|
+
if (target === undefined) {
|
|
321
|
+
throw new Error(
|
|
322
|
+
`[Feature ${featureName}] Screen "${screenId}" (${screenKind}) toolbarAction "${action.id}" ` +
|
|
323
|
+
`drawer-target "${action.screen}" does not resolve to a registered screen in this feature. ` +
|
|
324
|
+
`kind:"drawer" only resolves same-feature screens (unlike kind:"navigate", which can target ` +
|
|
325
|
+
`screens in any feature) — the drawer mounts the target inline using this feature's schema. ` +
|
|
326
|
+
`Known screens in this feature: ${[...Object.keys(screens)].sort().join(", ") || "(none)"}.`,
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
if (target.type !== "actionForm") {
|
|
330
|
+
throw new Error(
|
|
331
|
+
`[Feature ${featureName}] Screen "${screenId}" (${screenKind}) toolbarAction "${action.id}" ` +
|
|
332
|
+
`drawer-target "${action.screen}" is a "${target.type}" screen, not an actionForm. ` +
|
|
333
|
+
`kind:"drawer" mounts an actionForm inside a Drawer widget — point "screen" at an ` +
|
|
334
|
+
`actionForm screen, or use kind:"navigate" for a full-page target.`,
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
306
339
|
export function validateScreens(
|
|
307
340
|
feature: FeatureDefinition,
|
|
308
341
|
featureMap: ReadonlyMap<string, FeatureDefinition>,
|
|
@@ -351,6 +384,52 @@ export function validateScreens(
|
|
|
351
384
|
for (const col of screen.columns) {
|
|
352
385
|
validateColumnRendererForm(feature.name, screenId, normalizeListColumn(col));
|
|
353
386
|
}
|
|
387
|
+
// Screen filter (fw#2224) — field existence can't be checked without
|
|
388
|
+
// an entity (columns aren't a complete field inventory of the
|
|
389
|
+
// underlying query), so only pin the structure: "in" requires an
|
|
390
|
+
// array. Field validity is documented in the PR body.
|
|
391
|
+
if (
|
|
392
|
+
screen.filter !== undefined &&
|
|
393
|
+
screen.filter.op === "in" &&
|
|
394
|
+
!Array.isArray(screen.filter.value)
|
|
395
|
+
) {
|
|
396
|
+
throw new Error(
|
|
397
|
+
`[Feature ${feature.name}] Screen "${screenId}" (projectionList) filter.op "in" requires ` +
|
|
398
|
+
`filter.value to be a readonly array.`,
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
// Facets (fw#2224) — unlike filter, a field inventory IS available
|
|
402
|
+
// here: the declared columns. A facet on a field with no column is
|
|
403
|
+
// almost always a typo (the user never sees the field anywhere), so
|
|
404
|
+
// this is hard-checked rather than just documented.
|
|
405
|
+
if (screen.facets !== undefined) {
|
|
406
|
+
const columnFieldNames = new Set(
|
|
407
|
+
screen.columns.map((col) => normalizeListColumn(col).field),
|
|
408
|
+
);
|
|
409
|
+
const seenFacetFields = new Set<string>();
|
|
410
|
+
for (const facet of screen.facets) {
|
|
411
|
+
if (seenFacetFields.has(facet.field)) {
|
|
412
|
+
throw new Error(
|
|
413
|
+
`[Feature ${feature.name}] Screen "${screenId}" (projectionList) declares facet ` +
|
|
414
|
+
`"${facet.field}" more than once.`,
|
|
415
|
+
);
|
|
416
|
+
}
|
|
417
|
+
seenFacetFields.add(facet.field);
|
|
418
|
+
if (!columnFieldNames.has(facet.field)) {
|
|
419
|
+
throw new Error(
|
|
420
|
+
`[Feature ${feature.name}] Screen "${screenId}" (projectionList) facet references field ` +
|
|
421
|
+
`"${facet.field}" which is not a declared column. Known columns: ` +
|
|
422
|
+
`${[...columnFieldNames].sort().join(", ")}`,
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
if (facet.type === "select" && facet.options.length === 0) {
|
|
426
|
+
throw new Error(
|
|
427
|
+
`[Feature ${feature.name}] Screen "${screenId}" (projectionList) facet "${facet.field}" ` +
|
|
428
|
+
`(type "select") has an empty options list — declare at least one option.`,
|
|
429
|
+
);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
}
|
|
354
433
|
if (screen.rowActions !== undefined) {
|
|
355
434
|
for (const action of screen.rowActions) {
|
|
356
435
|
if (action.kind === "navigate") {
|
|
@@ -374,6 +453,22 @@ export function validateScreens(
|
|
|
374
453
|
}
|
|
375
454
|
validateAtMostOneRowClick(feature.name, screenId, "projectionList", screen.rowActions);
|
|
376
455
|
}
|
|
456
|
+
// Only drawer-kind is validated here — navigate/writeHandler toolbarActions
|
|
457
|
+
// on projectionList have no boot check yet (pre-existing gap, out of
|
|
458
|
+
// scope for fw#2225).
|
|
459
|
+
if (screen.toolbarActions !== undefined) {
|
|
460
|
+
for (const action of screen.toolbarActions) {
|
|
461
|
+
if (action.kind === "drawer") {
|
|
462
|
+
validateToolbarDrawerAction(
|
|
463
|
+
feature.name,
|
|
464
|
+
screenId,
|
|
465
|
+
"projectionList",
|
|
466
|
+
action,
|
|
467
|
+
feature.screens,
|
|
468
|
+
);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
}
|
|
377
472
|
continue;
|
|
378
473
|
}
|
|
379
474
|
|
|
@@ -923,6 +1018,14 @@ export function validateScreens(
|
|
|
923
1018
|
`navigate-target "${action.screen}" does not resolve to a registered screen in any feature.`,
|
|
924
1019
|
);
|
|
925
1020
|
}
|
|
1021
|
+
} else if (action.kind === "drawer") {
|
|
1022
|
+
validateToolbarDrawerAction(
|
|
1023
|
+
feature.name,
|
|
1024
|
+
screenId,
|
|
1025
|
+
"entityList",
|
|
1026
|
+
action,
|
|
1027
|
+
feature.screens,
|
|
1028
|
+
);
|
|
926
1029
|
} else {
|
|
927
1030
|
if (!allWriteHandlerQns.has(action.handler)) {
|
|
928
1031
|
throw new Error(
|
|
@@ -109,6 +109,13 @@ export function buildConfigFeatureSchema(registry: Registry): ConfigFeatureSchem
|
|
|
109
109
|
const masked = collectMaskedKeys(registry);
|
|
110
110
|
if (masked.length === 0) return { screens: [], navs: [] };
|
|
111
111
|
|
|
112
|
+
// Verbatim (unprefixed) keys as the client sees them — NOT getAllTranslations()
|
|
113
|
+
// (server-merged, "feature:"-prefixed, see build-app-schema.ts:77-81).
|
|
114
|
+
const declaredTranslationKeys = new Set<string>();
|
|
115
|
+
for (const f of registry.features.values()) {
|
|
116
|
+
for (const key of Object.keys(f.translations ?? {})) declaredTranslationKeys.add(key);
|
|
117
|
+
}
|
|
118
|
+
|
|
112
119
|
const screens: ScreenDefinition[] = [];
|
|
113
120
|
const navs: NavDefinition[] = [];
|
|
114
121
|
|
|
@@ -131,7 +138,7 @@ export function buildConfigFeatureSchema(registry: Registry): ConfigFeatureSchem
|
|
|
131
138
|
const access = rolesToAccess(group.flatMap((v) => v.roles));
|
|
132
139
|
const shortId = `${feature}-${scope}`;
|
|
133
140
|
|
|
134
|
-
screens.push(buildScreen(shortId, scope, feature, ordered, access));
|
|
141
|
+
screens.push(buildScreen(shortId, scope, feature, ordered, access, declaredTranslationKeys));
|
|
135
142
|
navs.push({
|
|
136
143
|
id: shortId,
|
|
137
144
|
label: `${feature}.settings`,
|
|
@@ -201,6 +208,7 @@ function buildScreen(
|
|
|
201
208
|
feature: string,
|
|
202
209
|
keys: readonly MaskedKey[],
|
|
203
210
|
access: AccessRule,
|
|
211
|
+
declaredTranslationKeys: ReadonlySet<string>,
|
|
204
212
|
): ConfigEditScreenDefinition {
|
|
205
213
|
const configKeys: Record<string, string> = {};
|
|
206
214
|
const fields: Record<string, FieldDefinition> = {};
|
|
@@ -226,8 +234,11 @@ function buildScreen(
|
|
|
226
234
|
// mask is the visibility gate, so collectMaskedKeys guarantees it here.
|
|
227
235
|
if (k.def.mask) fieldLabels[id] = k.def.mask.title;
|
|
228
236
|
}
|
|
237
|
+
// translate() echoes an undeclared key, so an ungated description would render raw.
|
|
238
|
+
const descriptionKey = `${feature}.settings.description`;
|
|
229
239
|
const section: EditFieldsSection = {
|
|
230
240
|
title: `${feature}.settings`,
|
|
241
|
+
...(declaredTranslationKeys.has(descriptionKey) && { description: descriptionKey }),
|
|
231
242
|
fields: keys.map(fieldId),
|
|
232
243
|
};
|
|
233
244
|
return {
|
|
@@ -237,7 +248,7 @@ function buildScreen(
|
|
|
237
248
|
configKeys,
|
|
238
249
|
fields,
|
|
239
250
|
fieldLabels,
|
|
240
|
-
layout: { sections: [section] },
|
|
251
|
+
layout: { sections: [section], width: "full" },
|
|
241
252
|
access,
|
|
242
253
|
};
|
|
243
254
|
}
|
|
@@ -20,6 +20,10 @@ export const DEFAULT_CURRENCIES = [
|
|
|
20
20
|
"INR",
|
|
21
21
|
] as const;
|
|
22
22
|
|
|
23
|
+
// --- Locale ---
|
|
24
|
+
|
|
25
|
+
export const DEFAULT_LOCALES = ["de", "en"] as const;
|
|
26
|
+
|
|
23
27
|
export function isFileField(field: FieldDefinition | undefined): field is AnyFileFieldDef {
|
|
24
28
|
if (!field) return false;
|
|
25
29
|
return (
|
package/src/engine/index.ts
CHANGED
|
@@ -424,7 +424,7 @@ export type {
|
|
|
424
424
|
WriteHandlerFn,
|
|
425
425
|
WriteResult,
|
|
426
426
|
} from "./types";
|
|
427
|
-
export { DEFAULT_CURRENCIES, HookPhases } from "./types";
|
|
427
|
+
export { DEFAULT_CURRENCIES, DEFAULT_LOCALES, HookPhases } from "./types";
|
|
428
428
|
export { isSystemTenant, isUuid, parseTenantId, SYSTEM_TENANT_ID } from "./types/identifiers";
|
|
429
429
|
export type {
|
|
430
430
|
PipelineBuildCtx,
|
|
@@ -269,6 +269,7 @@ export type {
|
|
|
269
269
|
FieldRenderer,
|
|
270
270
|
FormatSpec,
|
|
271
271
|
ListColumnSpec,
|
|
272
|
+
ListFacetSpec,
|
|
272
273
|
PlatformComponent,
|
|
273
274
|
ProjectionDetailScreenDefinition,
|
|
274
275
|
ProjectionListScreenDefinition,
|
|
@@ -299,7 +300,7 @@ export type {
|
|
|
299
300
|
LifecycleHookType,
|
|
300
301
|
OnDeleteStrategy,
|
|
301
302
|
} from "../constants";
|
|
302
|
-
export { DEFAULT_CURRENCIES, isFileField } from "../field-helpers";
|
|
303
|
+
export { DEFAULT_CURRENCIES, DEFAULT_LOCALES, isFileField } from "../field-helpers";
|
|
303
304
|
export { resolveName, withResponseData } from "../handler-helpers";
|
|
304
305
|
export { HookPhases } from "../hook-helpers";
|
|
305
306
|
export {
|
|
@@ -106,6 +106,9 @@ function pushToolbarActionKeys(out: Set<string>, action: ToolbarAction): void {
|
|
|
106
106
|
pushKey(out, action.confirm);
|
|
107
107
|
pushKey(out, action.confirmLabel);
|
|
108
108
|
}
|
|
109
|
+
// drawer-kind carries no confirm/confirmLabel — action.label (the
|
|
110
|
+
// toolbar button AND the Drawer title, see ToolbarDrawerHost) is
|
|
111
|
+
// already pushed above.
|
|
109
112
|
}
|
|
110
113
|
|
|
111
114
|
export function requiredKeysFromScreen(
|