@cosmicdrift/kumiko-framework 0.259.0 → 0.260.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.260.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>",
|
|
@@ -198,7 +198,7 @@
|
|
|
198
198
|
"./package.json": "./package.json"
|
|
199
199
|
},
|
|
200
200
|
"dependencies": {
|
|
201
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
201
|
+
"@cosmicdrift/kumiko-types": "0.260.0",
|
|
202
202
|
"bullmq": "^5.76.7",
|
|
203
203
|
"bun-types": "^1.3.13",
|
|
204
204
|
"hono": "^4.13.1",
|
|
@@ -214,7 +214,7 @@
|
|
|
214
214
|
"zod": "^4.4.3"
|
|
215
215
|
},
|
|
216
216
|
"devDependencies": {
|
|
217
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
217
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.260.0",
|
|
218
218
|
"bun-types": "^1.3.13",
|
|
219
219
|
"pino-pretty": "^13.1.3"
|
|
220
220
|
},
|
|
@@ -3167,7 +3167,10 @@ describe("boot-validator", () => {
|
|
|
3167
3167
|
// Post-save navigation target, same validation as actionForm's redirect:
|
|
3168
3168
|
// short screen-ID, same feature, must resolve to a registered screen.
|
|
3169
3169
|
describe("entityEdit redirect", () => {
|
|
3170
|
-
function makeFeature(
|
|
3170
|
+
function makeFeature(
|
|
3171
|
+
redirect?: string | { readonly screen: string; readonly idFrom: string },
|
|
3172
|
+
extraScreens: readonly string[] = [],
|
|
3173
|
+
) {
|
|
3171
3174
|
return defineFeature("shop", (r) => {
|
|
3172
3175
|
r.entity(
|
|
3173
3176
|
"product",
|
|
@@ -3224,6 +3227,27 @@ describe("boot-validator", () => {
|
|
|
3224
3227
|
/redirect "statements:screen:ghost-screen" does not resolve to a registered screen/,
|
|
3225
3228
|
);
|
|
3226
3229
|
});
|
|
3230
|
+
|
|
3231
|
+
// --- redirect object form with idFrom (same rule as actionForm's) ---
|
|
3232
|
+
test("redirect object → existing screen-id → kein Throw", () => {
|
|
3233
|
+
expect(() =>
|
|
3234
|
+
validateBoot([
|
|
3235
|
+
makeFeature({ screen: "product-list", idFrom: "parentId" }, ["product-list"]),
|
|
3236
|
+
]),
|
|
3237
|
+
).not.toThrow();
|
|
3238
|
+
});
|
|
3239
|
+
|
|
3240
|
+
test("redirect object → unknown screen-id → Throw", () => {
|
|
3241
|
+
expect(() =>
|
|
3242
|
+
validateBoot([makeFeature({ screen: "ghost-screen", idFrom: "parentId" })]),
|
|
3243
|
+
).toThrow(/redirect "ghost-screen" does not resolve to a registered screen/);
|
|
3244
|
+
});
|
|
3245
|
+
|
|
3246
|
+
test("redirect object mit leerem idFrom → Throw", () => {
|
|
3247
|
+
expect(() =>
|
|
3248
|
+
validateBoot([makeFeature({ screen: "product-list", idFrom: " " }, ["product-list"])]),
|
|
3249
|
+
).toThrow(/redirect\.idFrom is empty/);
|
|
3250
|
+
});
|
|
3227
3251
|
});
|
|
3228
3252
|
|
|
3229
3253
|
// --- no-widget field types + required (#1925) ---
|
|
@@ -168,6 +168,49 @@ describe("validateBoot — projectionDetail tabs (fw record-layout)", () => {
|
|
|
168
168
|
);
|
|
169
169
|
});
|
|
170
170
|
|
|
171
|
+
test("metric navigate.tab with no screen/entity — unknown section id throws", () => {
|
|
172
|
+
const feature = defineFeature("app", (r) => {
|
|
173
|
+
r.screen({
|
|
174
|
+
id: "rent-detail",
|
|
175
|
+
type: "projectionDetail",
|
|
176
|
+
query: "app:query:rent:detail",
|
|
177
|
+
metrics: [{ field: "balance", label: "rent.balance", navigate: { tab: "unknown" } }],
|
|
178
|
+
layout: {
|
|
179
|
+
mode: "tabs",
|
|
180
|
+
sections: [
|
|
181
|
+
{ id: "overview", title: "Overview", fields: ["description"] },
|
|
182
|
+
{ id: "history", title: "History", fields: ["notes"] },
|
|
183
|
+
],
|
|
184
|
+
},
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
expect(() => validateBoot([feature])).toThrow(
|
|
188
|
+
/navigates to tab "unknown", which is not a section id on this screen/,
|
|
189
|
+
);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("metric navigate.tab with no screen/entity — a known section id boots cleanly", () => {
|
|
193
|
+
const feature = defineFeature("app", (r) => {
|
|
194
|
+
r.queryHandler("rent:detail", z.object({}), async () => ({ description: "x" }), {
|
|
195
|
+
access: { openToAll: true },
|
|
196
|
+
});
|
|
197
|
+
r.screen({
|
|
198
|
+
id: "rent-detail",
|
|
199
|
+
type: "projectionDetail",
|
|
200
|
+
query: "app:query:rent:detail",
|
|
201
|
+
metrics: [{ field: "balance", label: "rent.balance", navigate: { tab: "history" } }],
|
|
202
|
+
layout: {
|
|
203
|
+
mode: "tabs",
|
|
204
|
+
sections: [
|
|
205
|
+
{ id: "overview", title: "Overview", fields: ["description"] },
|
|
206
|
+
{ id: "history", title: "History", fields: ["notes"] },
|
|
207
|
+
],
|
|
208
|
+
},
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
expect(() => validateBoot([feature])).not.toThrow();
|
|
212
|
+
});
|
|
213
|
+
|
|
171
214
|
test("valid tabs + header + metrics declaration boots cleanly", () => {
|
|
172
215
|
const feature = defineFeature("app", (r) => {
|
|
173
216
|
r.queryHandler("rent:detail", z.object({}), async () => ({ description: "x" }), {
|
|
@@ -1469,6 +1469,26 @@ describe("validateBoot — screen validation", () => {
|
|
|
1469
1469
|
expect(() => validateBoot([feature])).not.toThrow();
|
|
1470
1470
|
});
|
|
1471
1471
|
|
|
1472
|
+
test("wizard entityEdit with slots.footer passes boot", () => {
|
|
1473
|
+
const feature = defineFeature("shop", (r) => {
|
|
1474
|
+
r.entity("product", productEntity());
|
|
1475
|
+
r.screen({
|
|
1476
|
+
id: "edit-wizard",
|
|
1477
|
+
type: "entityEdit",
|
|
1478
|
+
entity: "product",
|
|
1479
|
+
layout: {
|
|
1480
|
+
mode: "wizard",
|
|
1481
|
+
sections: [
|
|
1482
|
+
{ title: "shop:section.basics", fields: ["name"] },
|
|
1483
|
+
{ title: "shop:section.details", fields: ["sku"] },
|
|
1484
|
+
],
|
|
1485
|
+
},
|
|
1486
|
+
slots: { footer: { react: { __component: "f" } } },
|
|
1487
|
+
});
|
|
1488
|
+
});
|
|
1489
|
+
expect(() => validateBoot([feature])).not.toThrow();
|
|
1490
|
+
});
|
|
1491
|
+
|
|
1472
1492
|
test("entityList with empty columns fails boot", () => {
|
|
1473
1493
|
// Blank columns list renders as a blank table — almost always an author
|
|
1474
1494
|
// oversight. Locked down at boot rather than silently producing an empty
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
import type { EntityDefinition, FeatureDefinition, FieldDefinition } from "../types";
|
|
19
19
|
import { metricField } from "../types";
|
|
20
20
|
import type {
|
|
21
|
+
ActionFormRedirect,
|
|
21
22
|
ActionFormScreenDefinition,
|
|
22
23
|
DashboardCustomPanel,
|
|
23
24
|
DashboardFilterDefinition,
|
|
@@ -631,6 +632,42 @@ function validateFormLayoutSections(
|
|
|
631
632
|
}
|
|
632
633
|
}
|
|
633
634
|
|
|
635
|
+
// redirect is either a short screen id (same-feature, e.g. "item-list") or
|
|
636
|
+
// a fully-qualified cross-feature QN (`<feature>:screen:<id>`) — the
|
|
637
|
+
// renderer strips the latter to the short id (lastSegment) when
|
|
638
|
+
// navigating, which the nav-router resolves app-wide (#1946). The object
|
|
639
|
+
// form (fw#2670) carries the same target under `screen` plus the payload
|
|
640
|
+
// field `idFrom`. Shared by actionForm/secretMint (validateInlineFormNavTargets)
|
|
641
|
+
// and entityEdit — same rule, same error message, one place to keep them in sync.
|
|
642
|
+
function validateRedirectTarget(
|
|
643
|
+
feature: FeatureDefinition,
|
|
644
|
+
screenId: string,
|
|
645
|
+
screenKind: "actionForm" | "secretMint" | "entityEdit",
|
|
646
|
+
redirect: string | ActionFormRedirect,
|
|
647
|
+
allScreenQns: ReadonlySet<string>,
|
|
648
|
+
): void {
|
|
649
|
+
const redirectTarget = typeof redirect === "string" ? redirect : redirect.screen;
|
|
650
|
+
if (
|
|
651
|
+
typeof redirect !== "string" &&
|
|
652
|
+
(typeof redirect.idFrom !== "string" || redirect.idFrom.trim() === "")
|
|
653
|
+
) {
|
|
654
|
+
throw new Error(
|
|
655
|
+
`[Feature ${feature.name}] Screen "${screenId}" (${screenKind}) redirect.idFrom is empty or not a string — ` +
|
|
656
|
+
`name the success-payload field carrying the navigation id, or use the plain string ` +
|
|
657
|
+
`redirect form to navigate with the handler's own "id".`,
|
|
658
|
+
);
|
|
659
|
+
}
|
|
660
|
+
validateScreenNavTarget(
|
|
661
|
+
feature.name,
|
|
662
|
+
screenId,
|
|
663
|
+
screenKind,
|
|
664
|
+
"redirect",
|
|
665
|
+
redirectTarget,
|
|
666
|
+
allScreenQns,
|
|
667
|
+
feature.screens,
|
|
668
|
+
);
|
|
669
|
+
}
|
|
670
|
+
|
|
634
671
|
function validateInlineFormNavTargets(
|
|
635
672
|
feature: FeatureDefinition,
|
|
636
673
|
screenId: string,
|
|
@@ -639,33 +676,7 @@ function validateInlineFormNavTargets(
|
|
|
639
676
|
allScreenQns: ReadonlySet<string>,
|
|
640
677
|
): void {
|
|
641
678
|
if (screen.redirect !== undefined) {
|
|
642
|
-
|
|
643
|
-
// or a fully-qualified cross-feature QN (`<feature>:screen:<id>`) — the
|
|
644
|
-
// renderer strips the latter to the short id (lastSegment) when
|
|
645
|
-
// navigating, which the nav-router resolves app-wide (#1946). The object
|
|
646
|
-
// form (fw#2670, actionForm only) carries the same target under `screen`
|
|
647
|
-
// plus the payload field `idFrom`.
|
|
648
|
-
const redirectTarget =
|
|
649
|
-
typeof screen.redirect === "string" ? screen.redirect : screen.redirect.screen;
|
|
650
|
-
if (
|
|
651
|
-
typeof screen.redirect !== "string" &&
|
|
652
|
-
(typeof screen.redirect.idFrom !== "string" || screen.redirect.idFrom.trim() === "")
|
|
653
|
-
) {
|
|
654
|
-
throw new Error(
|
|
655
|
-
`[Feature ${feature.name}] Screen "${screenId}" (${kind}) redirect.idFrom is empty or not a string — ` +
|
|
656
|
-
`name the success-payload field carrying the navigation id, or use the plain string ` +
|
|
657
|
-
`redirect form to navigate with the handler's own "id".`,
|
|
658
|
-
);
|
|
659
|
-
}
|
|
660
|
-
validateScreenNavTarget(
|
|
661
|
-
feature.name,
|
|
662
|
-
screenId,
|
|
663
|
-
kind,
|
|
664
|
-
"redirect",
|
|
665
|
-
redirectTarget,
|
|
666
|
-
allScreenQns,
|
|
667
|
-
feature.screens,
|
|
668
|
-
);
|
|
679
|
+
validateRedirectTarget(feature, screenId, kind, screen.redirect, allScreenQns);
|
|
669
680
|
}
|
|
670
681
|
if (typeof screen.cancelTarget === "string") {
|
|
671
682
|
// Same rule as redirect — `false` (no Cancel button) needs no validation.
|
|
@@ -986,6 +997,20 @@ export function validateScreens(
|
|
|
986
997
|
`there is no fallback to the raw column name.`,
|
|
987
998
|
);
|
|
988
999
|
}
|
|
1000
|
+
const navigate = typeof metric === "string" ? undefined : metric.navigate;
|
|
1001
|
+
// Cross-screen navigate+tab isn't checked here — the target screen
|
|
1002
|
+
// validates its own section ids when the loop reaches it.
|
|
1003
|
+
if (
|
|
1004
|
+
navigate?.tab !== undefined &&
|
|
1005
|
+
navigate.screen === undefined &&
|
|
1006
|
+
navigate.entity === undefined &&
|
|
1007
|
+
!screen.layout.sections.some((section) => section.id === navigate.tab)
|
|
1008
|
+
) {
|
|
1009
|
+
throw new Error(
|
|
1010
|
+
`[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) metric "${field}" ` +
|
|
1011
|
+
`navigates to tab "${navigate.tab}", which is not a section id on this screen.`,
|
|
1012
|
+
);
|
|
1013
|
+
}
|
|
989
1014
|
}
|
|
990
1015
|
}
|
|
991
1016
|
for (const section of screen.layout.sections) {
|
|
@@ -1785,17 +1810,7 @@ export function validateScreens(
|
|
|
1785
1810
|
}
|
|
1786
1811
|
}
|
|
1787
1812
|
if (screen.redirect !== undefined) {
|
|
1788
|
-
|
|
1789
|
-
// or a fully-qualified cross-feature QN (#1946).
|
|
1790
|
-
validateScreenNavTarget(
|
|
1791
|
-
feature.name,
|
|
1792
|
-
screenId,
|
|
1793
|
-
"entityEdit",
|
|
1794
|
-
"redirect",
|
|
1795
|
-
screen.redirect,
|
|
1796
|
-
allScreenQns,
|
|
1797
|
-
feature.screens,
|
|
1798
|
-
);
|
|
1813
|
+
validateRedirectTarget(feature, screenId, "entityEdit", screen.redirect, allScreenQns);
|
|
1799
1814
|
}
|
|
1800
1815
|
}
|
|
1801
1816
|
}
|