@cosmicdrift/kumiko-framework 0.65.0 → 0.66.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.65.0",
3
+ "version": "0.66.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>",
@@ -256,6 +256,71 @@ describe("event-store-executor.list — filter (Tier 2.7c)", () => {
256
256
  expect(res.rows).toHaveLength(4);
257
257
  expect(res.total).toBe(4);
258
258
  });
259
+
260
+ test("filters[] AND: zwei dynamische Filter werden mit AND verknüpft", async () => {
261
+ await seed(10);
262
+ const res = await exec.list(
263
+ {
264
+ limit: 50,
265
+ sort: "rank",
266
+ sortDirection: "asc",
267
+ filters: [
268
+ { field: "rank", op: "gt", value: 2 },
269
+ { field: "rank", op: "lt", value: 6 },
270
+ ],
271
+ },
272
+ admin,
273
+ tdb,
274
+ );
275
+ expect(res.rows.map((r) => r["rank"])).toEqual([3, 4, 5]);
276
+ });
277
+
278
+ test("filters[] in: Faceted-Multi-Select rank in [2,4,8]", async () => {
279
+ await seed(10);
280
+ const res = await exec.list(
281
+ {
282
+ limit: 50,
283
+ sort: "rank",
284
+ sortDirection: "asc",
285
+ filters: [{ field: "rank", op: "in", value: [2, 4, 8] }],
286
+ },
287
+ admin,
288
+ tdb,
289
+ );
290
+ expect(res.rows.map((r) => r["rank"])).toEqual([2, 4, 8]);
291
+ });
292
+
293
+ test("statischer filter + dynamische filters[] kombinieren mit AND", async () => {
294
+ await seed(10);
295
+ const res = await exec.list(
296
+ {
297
+ limit: 50,
298
+ sort: "rank",
299
+ sortDirection: "asc",
300
+ filter: { field: "rank", op: "gt", value: 2 },
301
+ filters: [{ field: "rank", op: "in", value: [1, 3, 5, 9] }],
302
+ },
303
+ admin,
304
+ tdb,
305
+ );
306
+ // gt:2 AND in[1,3,5,9] → 3,5,9 (1 fällt durch gt:2 raus)
307
+ expect(res.rows.map((r) => r["rank"])).toEqual([3, 5, 9]);
308
+ });
309
+
310
+ test("filters[] mit leerem in-Array: leeres Resultat (kein Match-All)", async () => {
311
+ await seed(5);
312
+ const res = await exec.list(
313
+ {
314
+ limit: 50,
315
+ sort: "rank",
316
+ sortDirection: "asc",
317
+ filters: [{ field: "rank", op: "in", value: [] }],
318
+ },
319
+ admin,
320
+ tdb,
321
+ );
322
+ expect(res.rows).toHaveLength(0);
323
+ });
259
324
  });
260
325
 
261
326
  describe("event-store-executor.list — runtime SearchAdapter (Tier 2.7e Audit-Fix #1)", () => {
@@ -200,6 +200,15 @@ export type EventStoreExecutor = {
200
200
  readonly value: unknown;
201
201
  }
202
202
  | undefined;
203
+ // User-gewählte Faceted-Filter (dynamisch, additiv zum statischen
204
+ // `filter`). Alle werden mit AND verknüpft.
205
+ filters?:
206
+ | ReadonlyArray<{
207
+ readonly field: string;
208
+ readonly op: "eq" | "ne" | "lt" | "gt" | "in";
209
+ readonly value: unknown;
210
+ }>
211
+ | undefined;
203
212
  },
204
213
  user: SessionUser,
205
214
  db: TenantDb,
@@ -875,45 +884,49 @@ export function createEventStoreExecutor(
875
884
  whereSql.push(shifted.sqlText);
876
885
  for (const p of shifted.params) params.push(p);
877
886
  }
878
- if (payload.filter !== undefined) {
879
- const col = table[payload.filter.field];
880
- if (col !== undefined) {
881
- const screen = buildFilterWhere(
882
- payload.filter.field,
883
- payload.filter.op,
884
- payload.filter.value,
885
- );
886
- if (screen === null) {
887
- whereSql.push("FALSE");
888
- } else {
889
- for (const [field, value] of Object.entries(screen)) {
890
- if (Array.isArray(value)) {
891
- const placeholders = value.map((v) => {
892
- params.push(v);
893
- return `$${params.length}`;
894
- });
895
- whereSql.push(`${colSql(field)} IN (${placeholders.join(", ")})`);
896
- } else if (typeof value === "object" && value !== null) {
897
- const opMap: Record<string, string> = {
898
- gt: ">",
899
- gte: ">=",
900
- lt: "<",
901
- lte: "<=",
902
- ne: "<>",
903
- };
904
- for (const [opKey, opSym] of Object.entries(opMap)) {
905
- if (!(opKey in value)) continue;
906
- params.push((value as Record<string, unknown>)[opKey]);
907
- whereSql.push(`${colSql(field)} ${opSym} $${params.length}`);
908
- }
909
- } else {
910
- params.push(value);
911
- whereSql.push(`${colSql(field)} = $${params.length}`);
912
- }
887
+ const applyFilter = (f: {
888
+ readonly field: string;
889
+ readonly op: "eq" | "ne" | "lt" | "gt" | "in";
890
+ readonly value: unknown;
891
+ }): void => {
892
+ if (table[f.field] === undefined) {
893
+ // skip: unknown field — not a real column, drop the filter (injection guard)
894
+ return;
895
+ }
896
+ const screen = buildFilterWhere(f.field, f.op, f.value);
897
+ if (screen === null) {
898
+ whereSql.push("FALSE");
899
+ // skip: filter is unsatisfiable → emit FALSE, no params to bind
900
+ return;
901
+ }
902
+ for (const [field, value] of Object.entries(screen)) {
903
+ if (Array.isArray(value)) {
904
+ const placeholders = value.map((v) => {
905
+ params.push(v);
906
+ return `$${params.length}`;
907
+ });
908
+ whereSql.push(`${colSql(field)} IN (${placeholders.join(", ")})`);
909
+ } else if (typeof value === "object" && value !== null) {
910
+ const opMap: Record<string, string> = {
911
+ gt: ">",
912
+ gte: ">=",
913
+ lt: "<",
914
+ lte: "<=",
915
+ ne: "<>",
916
+ };
917
+ for (const [opKey, opSym] of Object.entries(opMap)) {
918
+ if (!(opKey in value)) continue;
919
+ params.push((value as Record<string, unknown>)[opKey]);
920
+ whereSql.push(`${colSql(field)} ${opSym} $${params.length}`);
913
921
  }
922
+ } else {
923
+ params.push(value);
924
+ whereSql.push(`${colSql(field)} = $${params.length}`);
914
925
  }
915
926
  }
916
- }
927
+ };
928
+ if (payload.filter !== undefined) applyFilter(payload.filter);
929
+ if (payload.filters !== undefined) for (const f of payload.filters) applyFilter(f);
917
930
 
918
931
  const orderByClause =
919
932
  payload.sort && table[payload.sort]
@@ -134,6 +134,27 @@ describe("buildAppSchema", () => {
134
134
  expect(fields["label"]?.["default"]).toBe("");
135
135
  });
136
136
 
137
+ test("filterable kommt ins Client-Schema (steuert die Faceted-Filter)", () => {
138
+ const entity = {
139
+ fields: {
140
+ status: { type: "select", options: ["draft", "published"], filterable: true },
141
+ name: { type: "text" },
142
+ },
143
+ } as unknown as EntityDefinition;
144
+ const f = defineFeature("ent", (r) => {
145
+ r.entity("thing", entity);
146
+ });
147
+ const app = buildAppSchema(createRegistry([f]));
148
+ const fields = (
149
+ app.features[0]?.entities["thing"] as unknown as {
150
+ fields: Record<string, Record<string, unknown>>;
151
+ }
152
+ ).fields;
153
+ expect(fields["status"]?.["filterable"]).toBe(true);
154
+ // Felder ohne filterable tragen den Key nicht (kein false-Müll).
155
+ expect(fields["name"]?.["filterable"]).toBeUndefined();
156
+ });
157
+
137
158
  test("AppSchema ist via JSON.stringify roundtrip-sicher", () => {
138
159
  // Echter Smoke-Test des Vertrags — wenn jemand in den project-
139
160
  // Helper eine Function reinschmuggelt, würde das hier brennen.
@@ -311,6 +311,9 @@ function projectField(fieldDef: FieldDefinition): FieldDefinition {
311
311
  if (typeof def["type"] === "string") out["type"] = def["type"];
312
312
  if (typeof def["required"] === "boolean") out["required"] = def["required"];
313
313
  if (typeof def["sortable"] === "boolean") out["sortable"] = def["sortable"];
314
+ // filterable steuert die Faceted-Filter-Dropdowns im Renderer (select/
315
+ // boolean) — muss daher ins Client-Schema.
316
+ if (typeof def["filterable"] === "boolean") out["filterable"] = def["filterable"];
314
317
  if (isLiteral(def["default"])) out["default"] = def["default"];
315
318
  // Select: options-Liste ist plain JSON, durchschicken.
316
319
  if (Array.isArray(def["options"])) out["options"] = def["options"];
@@ -95,6 +95,17 @@ const listSchema = z.object({
95
95
  value: z.unknown(),
96
96
  })
97
97
  .optional(),
98
+ // User-gewählte Faceted-Filter (dynamisch). Additiv zum statischen
99
+ // `filter` — executor.list verknüpft alle mit AND.
100
+ filters: z
101
+ .array(
102
+ z.object({
103
+ field: z.string(),
104
+ op: z.enum(["eq", "ne", "lt", "gt", "in"]),
105
+ value: z.unknown(),
106
+ }),
107
+ )
108
+ .optional(),
98
109
  });
99
110
 
100
111
  function parseHandlerName<TVerb extends string>(
@@ -1,4 +1,6 @@
1
1
  import type { AccessRule } from "./handlers";
2
+ import type { TargetRef } from "./target-ref";
3
+ import type { TreeAction } from "./tree-node";
2
4
 
3
5
  // Nav entry declaration. Every feature that wants to appear in the app's
4
6
  // navigation tree registers one or more entries via r.nav(). The engine
@@ -34,6 +36,21 @@ export type NavDefinition = {
34
36
  // ("<feature>:screen:<id>"). Omit for pure grouping entries (a parent-only
35
37
  // nav node that renders a sub-tree but has no target screen itself).
36
38
  readonly screen?: string;
39
+ // Polymorphes Klick-Ziel (öffnet die EditorPanel-Maske via Target-
40
+ // Resolver) — Alternative zu `screen`. Ein Knoten trägt screen XOR
41
+ // target; der Renderer dispatcht das target statt einen Route-Link zu
42
+ // rendern. Gespiegelt aus dem alten Visual-Tree (TreeNode.target).
43
+ readonly target?: TargetRef;
44
+ // Hover-Actions rechts in der Zeile (VS-Code-Pattern) — erst bei Hover
45
+ // sichtbar. Reihenfolge wie deklariert.
46
+ readonly actions?: readonly TreeAction[];
47
+ // „+"-Affordance am Knoten. Klick dispatcht createAction.target; der
48
+ // Provider weiß was „leer befüllen" für ihn heißt (neuer Page-Slug etc.).
49
+ readonly createAction?: TreeAction;
50
+ // Children kommen zur Laufzeit aus einem registrierten nav-provider
51
+ // (lazy beim Ausklappen, SSE-live via treeEntities), keyed auf diese
52
+ // Nav-QN. Macht den Knoten expandable auch ohne statische children.
53
+ readonly provider?: boolean;
37
54
  // Role / openToAll gate. The nav resolver hides entries the user can't
38
55
  // reach; leave unset to always show (engine stays un-opinionated about
39
56
  // who sees what — apps that need default-deny can set { roles: [] }).
@@ -309,7 +309,10 @@ export type EditSectionSpec = EditFieldsSection | EditExtensionSection;
309
309
 
310
310
  export type EditFieldsSection = {
311
311
  readonly kind?: "fields";
312
- readonly title: string;
312
+ /** Optional. Ohne Titel rendert die Section nur ihre Felder (keine h3-
313
+ * Überschrift) — für flache Forms (Card-Titel + Felder direkt, ein
314
+ * einzelner Abschnitt) wie bei den meisten shadcn-Form-Mustern. */
315
+ readonly title?: string;
313
316
  readonly columns?: number;
314
317
  readonly fields: readonly EditFieldSpec[];
315
318
  };
@@ -333,6 +336,10 @@ export type EntityEditScreenDefinition = {
333
336
  readonly type: "entityEdit";
334
337
  readonly entity: string;
335
338
  readonly layout: EditLayout;
339
+ /** Optionaler i18n-Key (oder Roh-String) für den Submit-Button. Default
340
+ * `kumiko.actions.save`. Lässt den Auto-Edit-Screen domain-spezifische
341
+ * CTAs zeigen ("Save Address", "Create item") statt generisch "Speichern". */
342
+ readonly submitLabel?: string;
336
343
  /** Default true. `false` für Entities deren Create über einen eigenen
337
344
  * Lifecycle-Write läuft (z.B. incident:open mit Event-Stream + Joins)
338
345
  * statt über `<entity>:create`: unterdrückt den automatischen
@@ -74,5 +74,7 @@ export {
74
74
  normalizeEditField,
75
75
  normalizeListColumn,
76
76
  } from "../engine/types/screen";
77
+ export type { TargetRef } from "../engine/types/target-ref";
78
+ export type { TreeAction, TreeNode, TreeNodeState } from "../engine/types/tree-node";
77
79
  export type { WorkspaceDefinition } from "../engine/types/workspace";
78
80
  export type { AppSchema, FeatureSchema, WorkspaceSchema } from "./app-schema";