@cosmicdrift/kumiko-framework 0.33.0 → 0.34.1

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.33.0",
3
+ "version": "0.34.1",
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>",
@@ -1854,6 +1854,110 @@ describe("boot-validator", () => {
1854
1854
  });
1855
1855
  });
1856
1856
 
1857
+ // --- rowAction kind="writeHandler" handler-QN-Validierung (Tier 2.7e-1 erw.) ---
1858
+ describe("entityList rowAction kind=writeHandler handler-QN", () => {
1859
+ function makeFeature(handlerQn: string, register: boolean) {
1860
+ return defineFeature("shop", (r) => {
1861
+ r.entity("product", createEntity({ fields: { name: createTextField() } }));
1862
+ r.screen({
1863
+ id: "product-list",
1864
+ type: "entityList",
1865
+ entity: "product",
1866
+ columns: ["name"],
1867
+ rowActions: [{ id: "delete", label: "actions.delete", handler: handlerQn }],
1868
+ });
1869
+ if (register) {
1870
+ r.writeHandler(
1871
+ "delete",
1872
+ z.object({}),
1873
+ async () => ({ isSuccess: true as const, data: null }),
1874
+ {
1875
+ access: { roles: ["Admin"] },
1876
+ },
1877
+ );
1878
+ }
1879
+ });
1880
+ }
1881
+
1882
+ test("handler → registriert → kein Throw", () => {
1883
+ expect(() => validateBoot([makeFeature("shop:write:delete", true)])).not.toThrow();
1884
+ });
1885
+
1886
+ test("handler → nicht registriert → Throw mit klarer Message", () => {
1887
+ expect(() => validateBoot([makeFeature("shop:write:ghost", false)])).toThrow(
1888
+ /rowAction "delete" .*handler "shop:write:ghost" is not a registered write-handler/,
1889
+ );
1890
+ });
1891
+ });
1892
+
1893
+ // --- toolbarAction navigate + writeHandler Validierung (Tier 2.7e-2) ---
1894
+ describe("entityList toolbarAction navigate (Tier 2.7e-2)", () => {
1895
+ function makeFeature(targetScreen: string, withTarget: boolean) {
1896
+ return defineFeature("shop", (r) => {
1897
+ r.entity("product", createEntity({ fields: { name: createTextField() } }));
1898
+ r.screen({
1899
+ id: "product-list",
1900
+ type: "entityList",
1901
+ entity: "product",
1902
+ columns: ["name"],
1903
+ toolbarActions: [
1904
+ { kind: "navigate", id: "open-form", label: "actions.open", screen: targetScreen },
1905
+ ],
1906
+ });
1907
+ if (withTarget) {
1908
+ r.screen({ id: targetScreen, type: "custom", renderer: { react: "stub" } });
1909
+ }
1910
+ });
1911
+ }
1912
+
1913
+ test("navigate-target → registriert → kein Throw", () => {
1914
+ expect(() => validateBoot([makeFeature("product-form", true)])).not.toThrow();
1915
+ });
1916
+
1917
+ test("navigate-target → unbekannt → Throw mit klarer Message", () => {
1918
+ expect(() => validateBoot([makeFeature("ghost-form", false)])).toThrow(
1919
+ /toolbarAction "open-form" navigate-target "ghost-form" does not resolve/,
1920
+ );
1921
+ });
1922
+ });
1923
+
1924
+ describe("entityList toolbarAction writeHandler handler-QN (Tier 2.7e-2)", () => {
1925
+ function makeFeature(handlerQn: string, register: boolean) {
1926
+ return defineFeature("shop", (r) => {
1927
+ r.entity("product", createEntity({ fields: { name: createTextField() } }));
1928
+ r.screen({
1929
+ id: "product-list",
1930
+ type: "entityList",
1931
+ entity: "product",
1932
+ columns: ["name"],
1933
+ toolbarActions: [
1934
+ { kind: "writeHandler", id: "sync", label: "actions.sync", handler: handlerQn },
1935
+ ],
1936
+ });
1937
+ if (register) {
1938
+ r.writeHandler(
1939
+ "sync",
1940
+ z.object({}),
1941
+ async () => ({ isSuccess: true as const, data: null }),
1942
+ {
1943
+ access: { roles: ["Admin"] },
1944
+ },
1945
+ );
1946
+ }
1947
+ });
1948
+ }
1949
+
1950
+ test("handler → registriert → kein Throw", () => {
1951
+ expect(() => validateBoot([makeFeature("shop:write:sync", true)])).not.toThrow();
1952
+ });
1953
+
1954
+ test("handler → nicht registriert → Throw mit klarer Message", () => {
1955
+ expect(() => validateBoot([makeFeature("shop:write:ghost", false)])).toThrow(
1956
+ /toolbarAction "sync" .*handler "shop:write:ghost" is not a registered write-handler/,
1957
+ );
1958
+ });
1959
+ });
1960
+
1857
1961
  // --- defaultSort funktioniert für ALLE Field-Types die sortable
1858
1962
  // unterstützen (Tier 2.6b Field-Erweiterung) ---
1859
1963
  // Vor Tier 2.6b war `sortable` nur auf TextFieldDef. Erweitert auf
@@ -42,10 +42,7 @@ describe("r.screen() — registration", () => {
42
42
  {
43
43
  title: "shop:section.basics",
44
44
  columns: 2,
45
- fields: [
46
- "name",
47
- { field: "sku", readOnly: (data) => Boolean((data as { sku?: string }).sku) },
48
- ],
45
+ fields: ["name", { field: "sku", readOnly: { field: "sku", ne: null } }],
49
46
  },
50
47
  ],
51
48
  },
@@ -358,7 +355,7 @@ describe("validateBoot — screen validation", () => {
358
355
  {
359
356
  title: "shop:section.basics",
360
357
  columns: 2,
361
- fields: ["name", { field: "sku", visible: () => true, required: () => true }],
358
+ fields: ["name", { field: "sku", visible: true, required: true }],
362
359
  },
363
360
  ],
364
361
  },
@@ -345,19 +345,51 @@ export function validateScreens(
345
345
  );
346
346
  }
347
347
  }
348
- // Tier 2.7e-1: rowActions mit kind:"navigate" pinst dass das
349
- // referenced screen tatsächlich existiert (selbes Feature). Ein
350
- // typo'd target landet sonst beim Klick als "Screen not found"-
351
- // Banner.
348
+ // Tier 2.7e-1: rowActions pinnen navigate-target existiert (selbes
349
+ // Feature), writeHandler-QN ist registriert. Tippfehler fallen sonst
350
+ // erst beim ersten Klick als "Screen not found" / 404 auf.
352
351
  if (screen.rowActions !== undefined) {
353
352
  for (const action of screen.rowActions) {
354
- if (action.kind !== "navigate") continue;
355
- const candidateQn = qualifyEntityName(feature.name, "screen", action.screen);
356
- if (!allScreenQns.has(candidateQn)) {
357
- throw new Error(
358
- `[Feature ${feature.name}] Screen "${screenId}" (entityList) rowAction "${action.id}" ` +
359
- `navigate-target "${action.screen}" does not resolve to a registered screen in this feature.`,
360
- );
353
+ if (action.kind === "navigate") {
354
+ const candidateQn = qualifyEntityName(feature.name, "screen", action.screen);
355
+ if (!allScreenQns.has(candidateQn)) {
356
+ throw new Error(
357
+ `[Feature ${feature.name}] Screen "${screenId}" (entityList) rowAction "${action.id}" ` +
358
+ `navigate-target "${action.screen}" does not resolve to a registered screen in this feature.`,
359
+ );
360
+ }
361
+ } else {
362
+ if (!allWriteHandlerQns.has(action.handler)) {
363
+ throw new Error(
364
+ `[Feature ${feature.name}] Screen "${screenId}" (entityList) rowAction "${action.id}" ` +
365
+ `handler "${action.handler}" is not a registered write-handler. Check the QN spelling ` +
366
+ `(expected "<feature>:write:<short>") and that the handler is declared via r.writeHandler(...).`,
367
+ );
368
+ }
369
+ }
370
+ }
371
+ }
372
+ // Tier 2.7e-2: toolbarActions — analog zu rowActions, aber bisher
373
+ // ohne Validator. Typo'd navigate-targets und unregistrierte
374
+ // writeHandler-QNs fallen bis hierhin erst beim Klick auf.
375
+ if (screen.toolbarActions !== undefined) {
376
+ for (const action of screen.toolbarActions) {
377
+ if (action.kind === "navigate") {
378
+ const candidateQn = qualifyEntityName(feature.name, "screen", action.screen);
379
+ if (!allScreenQns.has(candidateQn)) {
380
+ throw new Error(
381
+ `[Feature ${feature.name}] Screen "${screenId}" (entityList) toolbarAction "${action.id}" ` +
382
+ `navigate-target "${action.screen}" does not resolve to a registered screen in this feature.`,
383
+ );
384
+ }
385
+ } else {
386
+ if (!allWriteHandlerQns.has(action.handler)) {
387
+ throw new Error(
388
+ `[Feature ${feature.name}] Screen "${screenId}" (entityList) toolbarAction "${action.id}" ` +
389
+ `handler "${action.handler}" is not a registered write-handler. Check the QN spelling ` +
390
+ `(expected "<feature>:write:<short>") and that the handler is declared via r.writeHandler(...).`,
391
+ );
392
+ }
361
393
  }
362
394
  }
363
395
  }
@@ -37,13 +37,16 @@ export type FieldRenderer =
37
37
  | string
38
38
  | ((value: unknown, row?: Readonly<Record<string, unknown>>) => string);
39
39
 
40
- // Conditional field-state evaluator. `data` is the current form row and
41
- // `ctx` carries user / session info — the form-controller in ui-core passes
42
- // both at evaluation time. Engine-side defaults are `unknown` because the
43
- // framework has nothing to assert about the shapes; feature code can narrow
44
- // them by passing type args (e.g. `FieldCondition<OrderRow>`) to skip the
45
- // cast at call sites.
46
- export type FieldCondition<TData = unknown, TCtx = unknown> = (data: TData, ctx: TCtx) => boolean;
40
+ // Declarative field-state condition. Evaluated by the renderer against the
41
+ // current row/form values. Three forms:
42
+ // boolean — static on/off (e.g. readOnly: true)
43
+ // { field, eq } — true when row[field] === eq
44
+ // { field, ne } — true when row[field] !== ne
45
+ // JSON-safe: survives buildAppSchema → window.__KUMIKO_SCHEMA__ stringify.
46
+ export type FieldCondition =
47
+ | boolean
48
+ | { readonly field: string; readonly eq: unknown }
49
+ | { readonly field: string; readonly ne: unknown };
47
50
 
48
51
  // --- entityList ---
49
52
 
@@ -108,31 +111,31 @@ export type ScreenFilter = {
108
111
  readonly value: unknown;
109
112
  };
110
113
 
114
+ /** Deklarativer Row-Field-Extraktor — JSON-sicher (kein Function-Prop,
115
+ * überlebt window.__KUMIKO_SCHEMA__ / buildAppSchema).
116
+ *
117
+ * `pick`: extrahiert Felder 1:1. `{ pick: ["id", "version"] }` → `{ id: row.id, version: row.version }`
118
+ * `map`: benennt um. `{ map: { incidentId: "id" } }` → `{ incidentId: row.id }`
119
+ *
120
+ * Limitation: computed/template-Werte können nicht ausgedrückt werden
121
+ * — solche Logik gehört server-side in den Write-Handler. */
122
+ export type RowFieldExtractor =
123
+ | { readonly pick: readonly string[] }
124
+ | { readonly map: Readonly<Record<string, string>> };
125
+
111
126
  // RowAction — per-Row Button/Dropdown-Item das einen Write-Handler
112
- // triggert. Lebt im Schema (Author deklariert pro List-Screen welche
113
- // Aktionen möglich sind), Caller liefert die handler-QN + optional
114
- // payload-Builder + Confirm-Prompt.
127
+ // triggert oder zu einem anderen Screen navigiert.
115
128
  //
116
129
  // Pattern: row-level Lifecycle-Operations (Maintenance start/cancel/
117
130
  // complete, Incident resolve, Order ship etc.) — Sachen die in einem
118
131
  // CRUD-update kein passendes Verb haben aber als WriteHandler existieren.
119
132
  //
120
- // ⚠️ Function-Props (`payload`, `params`, `visible`) leben nur im
121
- // Monolith-Bundle-Pattern (Server + Client teilen Source-Bundle, wie
122
- // Showcase mit dev-server). In setups mit JSON-injected window.__
123
- // KUMIKO_SCHEMA__ werden Functions silent gedroppt (`buildAppSchema`
124
- // whitelist-projeziert + JSON.stringify entfernt sie). Für solche Apps:
125
- // - `payload`/`params` weglassen → Default `{ id: row.id }` greift.
126
- // - `visible` über server-side Filter im Handler statt Client-side
127
- // Visibility lösen.
128
- // Declarative Alternative kommt wenn ein konkreter Use-Case das fordert.
129
- //
130
133
  // Discriminated Union mit `kind`:
131
- // - "writeHandler" (default für Backwards-Compat): dispatched einen
132
- // Write-Handler mit Payload pro Row.
134
+ // - "writeHandler" (default): dispatched einen Write-Handler mit
135
+ // Payload pro Row.
133
136
  // - "navigate" (Tier 2.7e): navigiert zu einem anderen Screen,
134
- // optional mit URL-Search-Params aus `params(row)`. Use-case:
135
- // "Edit", "View Audit-Log", "Open in actionForm" etc.
137
+ // optional mit URL-Search-Params aus `params`. Use-case: "Edit",
138
+ // "View Audit-Log", "Open in actionForm" etc.
136
139
  export type RowAction = RowActionWriteHandler | RowActionNavigate;
137
140
 
138
141
  export type RowActionWriteHandler = {
@@ -147,9 +150,9 @@ export type RowActionWriteHandler = {
147
150
  * "publicstatus:write:maintenance:start". Wird via useDispatcher
148
151
  * dispatcht. */
149
152
  readonly handler: string;
150
- /** Payload-Builder pro Row. Default = `{ id: row.id }`. ⚠️ Function-
151
- * Form nur im Monolith-Bundle-Pattern siehe Type-Header. */
152
- readonly payload?: (row: Readonly<Record<string, unknown>>) => Record<string, unknown>;
153
+ /** Deklarativer Payload pro Row. Default = `{ id: row.id }`.
154
+ * `pick` extrahiert Felder gleichen Namens; `map` benennt um. */
155
+ readonly payload?: RowFieldExtractor;
153
156
  /** i18n-Key für die Confirm-Dialog-Description. Wenn gesetzt, öffnet
154
157
  * ein Modal vor der Ausführung — der User muss explizit bestätigen.
155
158
  * Zusammen mit `style: "danger"` ist das die Standard-Sicherheits-
@@ -160,10 +163,7 @@ export type RowActionWriteHandler = {
160
163
  * Action einen langen Namen hat ("Mark Subscription as Cancelled")
161
164
  * und der Button kürzer sein soll ("Cancel Subscription"). */
162
165
  readonly confirmLabel?: string;
163
- /** Conditional Visibility pro Row — Action erscheint nur wenn die
164
- * Bedingung true returnt. Beispiel: nur "Start" zeigen wenn
165
- * status === "scheduled". ⚠️ Function-Form nur im Monolith-Bundle-
166
- * Pattern — siehe Type-Header. */
166
+ /** Conditional Visibility pro Row. */
167
167
  readonly visible?: FieldCondition;
168
168
  /** Visual-Style. "danger" rendert rot + erzwingt einen Confirm-
169
169
  * Dialog (auch ohne expliziten `confirm`-Key). */
@@ -177,17 +177,16 @@ export type RowActionNavigate = {
177
177
  /** Screen-id (kurz, unqualified) zu dem navigiert wird. Boot-
178
178
  * Validator prüft Existenz im selben Feature. */
179
179
  readonly screen: string;
180
- /** Optional: Entity-Id für entityEdit-Targets landet als Pfad-
181
- * Segment (`/<workspace>/<screen>/<entityId>`). entityEdit liest die
182
- * Id AUSSCHLIESSLICH aus dem Pfad; ein `?id=`-Search-Param öffnet
183
- * den Create-Mode. ⚠️ Function-Form nur im Monolith-Bundle-Pattern. */
184
- readonly entityId?: (row: Readonly<Record<string, unknown>>) => string;
185
- /** Optional: URL-Search-Params aus row-Context. Wird in actionForm-
186
- * Targets als initial values gelesen ("Edit Customer X" → URL hat
187
- * `?customerId=row-uuid`, actionForm initial values pre-fillen).
188
- * ⚠️ Function-Form nur im Monolith-Bundle-Pattern. */
189
- readonly params?: (row: Readonly<Record<string, unknown>>) => Record<string, unknown>;
190
- /** Conditional Visibility pro Row — analog zu writeHandler-Variante. */
180
+ /** Feldname dessen Wert als entityId in den URL-Pfad eingebettet wird
181
+ * (`/<workspace>/<screen>/<entityId>`). entityEdit liest die Id
182
+ * AUSSCHLIESSLICH aus dem Pfad. Default: "id" wenn der Ziel-Screen
183
+ * ein entityEdit ist. */
184
+ readonly entityId?: string;
185
+ /** Deklarative URL-Search-Params aus row-Context. Wird in actionForm-
186
+ * Targets als initial values gelesen (actionForm pre-fillen).
187
+ * `pick` extrahiert Felder gleichen Namens; `map` benennt um. */
188
+ readonly params?: RowFieldExtractor;
189
+ /** Conditional Visibility pro Row. */
191
190
  readonly visible?: FieldCondition;
192
191
  readonly style?: "primary" | "secondary";
193
192
  };
@@ -195,11 +194,6 @@ export type RowActionNavigate = {
195
194
  // ToolbarAction — Button im List-Header. Zwei Varianten: navigate auf
196
195
  // einen anderen Screen (z.B. zu einem actionForm) oder direkt einen
197
196
  // Handler dispatchen (z.B. "Sync All" ohne Form).
198
- //
199
- // ⚠️ Function-Props (`payload`) leben nur im Monolith-Bundle-Pattern
200
- // (siehe RowAction-JSDoc) — JSON-injected Schemas droppen sie silent.
201
- // Für solche Apps payload weglassen oder im writeHandler server-side
202
- // die Tenant-/Session-Kontext-Werte ableiten.
203
197
  export type ToolbarAction =
204
198
  | {
205
199
  readonly kind: "navigate";
@@ -214,8 +208,8 @@ export type ToolbarAction =
214
208
  readonly id: string;
215
209
  readonly label: string;
216
210
  readonly handler: string;
217
- /** Optional: Payload-Builder ohne row-Context. Default = `{}`. */
218
- readonly payload?: () => Record<string, unknown>;
211
+ /** Statischer Payload ohne row-Context. Default = `{}`. */
212
+ readonly payload?: Record<string, unknown>;
219
213
  /** i18n-Key für Confirm-Dialog-Description. Wenn gesetzt UND/ODER
220
214
  * style="danger": Modal vor der Ausführung. */
221
215
  readonly confirm?: string;
@@ -60,6 +60,7 @@ export type {
60
60
  RowAction,
61
61
  RowActionNavigate,
62
62
  RowActionWriteHandler,
63
+ RowFieldExtractor,
63
64
  ScreenDefinition,
64
65
  ScreenFilter,
65
66
  ScreenFilterOp,