@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.208.2",
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.208.2",
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.208.2",
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 (
@@ -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(
@@ -80,6 +80,7 @@ export type {
80
80
  FieldRenderer,
81
81
  FormWidth,
82
82
  ListColumnSpec,
83
+ ListFacetSpec,
83
84
  PlatformComponent,
84
85
  ProjectionDetailScreenDefinition,
85
86
  ProjectionListScreenDefinition,