@cosmicdrift/kumiko-framework 0.32.1 → 0.34.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.
|
|
3
|
+
"version": "0.34.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>",
|
|
@@ -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:
|
|
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
|
|
349
|
-
//
|
|
350
|
-
//
|
|
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
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
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
|
-
//
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
export type FieldCondition
|
|
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
|
|
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
|
|
132
|
-
//
|
|
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
|
|
135
|
-
// "
|
|
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
|
|
151
|
-
*
|
|
152
|
-
readonly payload?:
|
|
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
|
|
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
|
-
/**
|
|
181
|
-
*
|
|
182
|
-
*
|
|
183
|
-
*
|
|
184
|
-
readonly entityId?:
|
|
185
|
-
/**
|
|
186
|
-
* Targets als initial values gelesen (
|
|
187
|
-
*
|
|
188
|
-
|
|
189
|
-
|
|
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
|
-
/**
|
|
218
|
-
readonly payload?:
|
|
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;
|