@cosmicdrift/kumiko-framework 0.309.0 → 0.310.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.310.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,8 +198,8 @@
|
|
|
198
198
|
"./package.json": "./package.json"
|
|
199
199
|
},
|
|
200
200
|
"dependencies": {
|
|
201
|
-
"@cosmicdrift/kumiko-http": "0.
|
|
202
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
201
|
+
"@cosmicdrift/kumiko-http": "0.310.0",
|
|
202
|
+
"@cosmicdrift/kumiko-types": "0.310.0",
|
|
203
203
|
"bullmq": "^5.76.7",
|
|
204
204
|
"bun-types": "^1.3.13",
|
|
205
205
|
"hono": "^4.13.1",
|
|
@@ -215,7 +215,7 @@
|
|
|
215
215
|
"zod": "^4.4.3"
|
|
216
216
|
},
|
|
217
217
|
"devDependencies": {
|
|
218
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
218
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.310.0",
|
|
219
219
|
"bun-types": "^1.3.13",
|
|
220
220
|
"pino-pretty": "^13.1.3"
|
|
221
221
|
},
|
package/src/changes.json
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.310.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "Boot validator checks the target tab of navigate actions",
|
|
6
|
+
"detail": "A navigate action with `tab` fails boot when its target is not a\nprojectionDetail with layout.mode \"tabs\" or has no section with that id.\nThe check covers list rowActions, relatedList rowActions, screen actions,\nsection actions and relatedList emptyState actions."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.309.0",
|
|
4
10
|
"type": "fix",
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import * as z from "zod";
|
|
3
|
+
import { withBootValidatorFixture } from "../../testing/boot-validator-fixture";
|
|
4
|
+
import { validateBoot as validateBootRaw } from "../boot-validator";
|
|
5
|
+
import { defineFeature } from "../define-feature";
|
|
6
|
+
import { createEntity, createTextField } from "../factories";
|
|
7
|
+
|
|
8
|
+
function validateBoot(features: Parameters<typeof validateBootRaw>[0]): void {
|
|
9
|
+
validateBootRaw(withBootValidatorFixture(features));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function buildFeature(options: {
|
|
13
|
+
readonly detailTabs: boolean;
|
|
14
|
+
readonly tab: string;
|
|
15
|
+
readonly via: "entityEditSection" | "entityListRowAction";
|
|
16
|
+
}) {
|
|
17
|
+
return defineFeature("app", (r) => {
|
|
18
|
+
r.entity(
|
|
19
|
+
"vehicle",
|
|
20
|
+
createEntity({
|
|
21
|
+
fields: { name: createTextField({ personal: false, reason: "test_fixture" }) },
|
|
22
|
+
}),
|
|
23
|
+
);
|
|
24
|
+
r.queryHandler("vehicle:detail", z.object({}), async () => ({ name: "x" }), {
|
|
25
|
+
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
26
|
+
});
|
|
27
|
+
r.screen({
|
|
28
|
+
id: "vehicle-detail",
|
|
29
|
+
type: "projectionDetail",
|
|
30
|
+
query: "app:query:vehicle:detail",
|
|
31
|
+
layout: options.detailTabs
|
|
32
|
+
? {
|
|
33
|
+
mode: "tabs",
|
|
34
|
+
sections: [
|
|
35
|
+
{ id: "overview", title: "Overview", fields: ["name"] },
|
|
36
|
+
{ id: "channels", title: "Channels", fields: ["name"] },
|
|
37
|
+
],
|
|
38
|
+
}
|
|
39
|
+
: { sections: [{ title: "Overview", fields: ["name"] }] },
|
|
40
|
+
});
|
|
41
|
+
const navigateAction = {
|
|
42
|
+
kind: "navigate" as const,
|
|
43
|
+
id: "view-channels",
|
|
44
|
+
label: "app.actions.viewChannels",
|
|
45
|
+
screen: "vehicle-detail",
|
|
46
|
+
tab: options.tab,
|
|
47
|
+
};
|
|
48
|
+
if (options.via === "entityEditSection") {
|
|
49
|
+
r.screen({
|
|
50
|
+
id: "vehicle-edit",
|
|
51
|
+
type: "entityEdit",
|
|
52
|
+
entity: "vehicle",
|
|
53
|
+
layout: {
|
|
54
|
+
sections: [
|
|
55
|
+
{
|
|
56
|
+
id: "main",
|
|
57
|
+
title: "Main",
|
|
58
|
+
columns: 1,
|
|
59
|
+
fields: ["name"],
|
|
60
|
+
actions: [{ ...navigateAction, entityId: "vehicleId" }],
|
|
61
|
+
},
|
|
62
|
+
],
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
} else {
|
|
66
|
+
r.screen({
|
|
67
|
+
id: "vehicle-list",
|
|
68
|
+
type: "entityList",
|
|
69
|
+
entity: "vehicle",
|
|
70
|
+
columns: ["name"],
|
|
71
|
+
rowActions: [navigateAction],
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
describe("validateBoot — RowActionNavigate.tab", () => {
|
|
78
|
+
test("entityEdit section action navigates to a known tab boots cleanly", () => {
|
|
79
|
+
const feature = buildFeature({ detailTabs: true, tab: "channels", via: "entityEditSection" });
|
|
80
|
+
expect(() => validateBoot([feature])).not.toThrow();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("entityEdit section action navigates to an unknown tab throws", () => {
|
|
84
|
+
const feature = buildFeature({ detailTabs: true, tab: "unknown", via: "entityEditSection" });
|
|
85
|
+
expect(() => validateBoot([feature])).toThrow(
|
|
86
|
+
/navigates to tab "unknown", which is not a section id on target screen/,
|
|
87
|
+
);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("entityEdit section action navigating to a non-tabs projectionDetail throws", () => {
|
|
91
|
+
const feature = buildFeature({ detailTabs: false, tab: "overview", via: "entityEditSection" });
|
|
92
|
+
expect(() => validateBoot([feature])).toThrow(
|
|
93
|
+
/is not a projectionDetail with layout.mode "tabs"/,
|
|
94
|
+
);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("entityList rowAction navigating to an unknown tab throws (central resolution path)", () => {
|
|
98
|
+
const feature = buildFeature({
|
|
99
|
+
detailTabs: true,
|
|
100
|
+
tab: "unknown",
|
|
101
|
+
via: "entityListRowAction",
|
|
102
|
+
});
|
|
103
|
+
expect(() => validateBoot([feature])).toThrow(
|
|
104
|
+
/navigates to tab "unknown", which is not a section id on target screen/,
|
|
105
|
+
);
|
|
106
|
+
});
|
|
107
|
+
});
|
|
@@ -542,11 +542,48 @@ type RowActionNavigateRuntime = RowActionNavigateBase & {
|
|
|
542
542
|
readonly entity?: string;
|
|
543
543
|
};
|
|
544
544
|
|
|
545
|
+
// The target can be any screen, so this checks the resolved target's
|
|
546
|
+
// sections, not the host's.
|
|
547
|
+
function validateRowActionNavigateTab(
|
|
548
|
+
featureName: string,
|
|
549
|
+
screenId: string,
|
|
550
|
+
screenType: "entityList" | "projectionList" | "projectionDetail" | "entityEdit",
|
|
551
|
+
actionLabel: string,
|
|
552
|
+
action: RowActionNavigateRuntime,
|
|
553
|
+
target: { readonly featureName: string; readonly screen: ScreenDefinition } | undefined,
|
|
554
|
+
): void {
|
|
555
|
+
// skip: no tab declared — nothing to check against the target's sections
|
|
556
|
+
if (action.tab === undefined) return;
|
|
557
|
+
if (target === undefined) {
|
|
558
|
+
throw new Error(
|
|
559
|
+
`[Feature ${featureName}] Screen "${screenId}" (${screenType}) ${actionLabel} "${action.id}" ` +
|
|
560
|
+
`sets tab "${action.tab}", but its navigate-target could not be resolved to a screen.`,
|
|
561
|
+
);
|
|
562
|
+
}
|
|
563
|
+
if (target.screen.type !== "projectionDetail" || target.screen.layout.mode !== "tabs") {
|
|
564
|
+
throw new Error(
|
|
565
|
+
`[Feature ${featureName}] Screen "${screenId}" (${screenType}) ${actionLabel} "${action.id}" ` +
|
|
566
|
+
`sets tab "${action.tab}", but navigate-target screen "${target.screen.id}" is not a ` +
|
|
567
|
+
`projectionDetail with layout.mode "tabs" — only those read the tab param.`,
|
|
568
|
+
);
|
|
569
|
+
}
|
|
570
|
+
const sectionIds = target.screen.layout.sections
|
|
571
|
+
.map((section) => section.id)
|
|
572
|
+
.filter((id): id is string => id !== undefined);
|
|
573
|
+
if (!sectionIds.includes(action.tab)) {
|
|
574
|
+
throw new Error(
|
|
575
|
+
`[Feature ${featureName}] Screen "${screenId}" (${screenType}) ${actionLabel} "${action.id}" ` +
|
|
576
|
+
`navigates to tab "${action.tab}", which is not a section id on target screen ` +
|
|
577
|
+
`"${target.screen.id}". Available: ${sectionIds.join(", ") || "(none)"}.`,
|
|
578
|
+
);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
545
582
|
function resolveRowActionNavigateTarget(
|
|
546
583
|
featureName: string,
|
|
547
584
|
screenId: string,
|
|
548
585
|
screenType: "entityList" | "projectionList" | "projectionDetail" | "entityEdit",
|
|
549
|
-
actionLabel:
|
|
586
|
+
actionLabel: string,
|
|
550
587
|
action: RowActionNavigateRuntime,
|
|
551
588
|
allScreenQns: ReadonlySet<string>,
|
|
552
589
|
navTargetShortIds: ReadonlySet<string>,
|
|
@@ -586,6 +623,7 @@ function resolveRowActionNavigateTarget(
|
|
|
586
623
|
`detailFor: "${action.entity}".`,
|
|
587
624
|
);
|
|
588
625
|
}
|
|
626
|
+
validateRowActionNavigateTab(featureName, screenId, screenType, actionLabel, action, detail);
|
|
589
627
|
return detail;
|
|
590
628
|
}
|
|
591
629
|
if (action.screen === undefined) {
|
|
@@ -601,7 +639,9 @@ function resolveRowActionNavigateTarget(
|
|
|
601
639
|
`navigate-target "${action.screen}" does not resolve to a registered screen in any feature.`,
|
|
602
640
|
);
|
|
603
641
|
}
|
|
604
|
-
|
|
642
|
+
const target = screensByShortId.get(action.screen)?.[0];
|
|
643
|
+
validateRowActionNavigateTab(featureName, screenId, screenType, actionLabel, action, target);
|
|
644
|
+
return target;
|
|
605
645
|
}
|
|
606
646
|
|
|
607
647
|
// Shared by the mint step's own handler (screen.handler) and its optional
|
|
@@ -1225,6 +1265,19 @@ export function validateScreens(
|
|
|
1225
1265
|
`section "${sectionLabel}" action`,
|
|
1226
1266
|
action,
|
|
1227
1267
|
);
|
|
1268
|
+
if (action.kind === "navigate" && action.tab !== undefined) {
|
|
1269
|
+
resolveRowActionNavigateTarget(
|
|
1270
|
+
feature.name,
|
|
1271
|
+
screenId,
|
|
1272
|
+
"projectionDetail",
|
|
1273
|
+
`section "${sectionLabel}" action`,
|
|
1274
|
+
action,
|
|
1275
|
+
allScreenQns,
|
|
1276
|
+
navTargetShortIds,
|
|
1277
|
+
screensByShortId,
|
|
1278
|
+
detailForScreens,
|
|
1279
|
+
);
|
|
1280
|
+
}
|
|
1228
1281
|
}
|
|
1229
1282
|
}
|
|
1230
1283
|
if (section.kind === "relatedList" && section.emptyState?.action !== undefined) {
|
|
@@ -1235,6 +1288,22 @@ export function validateScreens(
|
|
|
1235
1288
|
`section "${sectionLabel}" emptyState action`,
|
|
1236
1289
|
section.emptyState.action,
|
|
1237
1290
|
);
|
|
1291
|
+
if (
|
|
1292
|
+
section.emptyState.action.kind === "navigate" &&
|
|
1293
|
+
section.emptyState.action.tab !== undefined
|
|
1294
|
+
) {
|
|
1295
|
+
resolveRowActionNavigateTarget(
|
|
1296
|
+
feature.name,
|
|
1297
|
+
screenId,
|
|
1298
|
+
"projectionDetail",
|
|
1299
|
+
`section "${sectionLabel}" emptyState action`,
|
|
1300
|
+
section.emptyState.action,
|
|
1301
|
+
allScreenQns,
|
|
1302
|
+
navTargetShortIds,
|
|
1303
|
+
screensByShortId,
|
|
1304
|
+
detailForScreens,
|
|
1305
|
+
);
|
|
1306
|
+
}
|
|
1238
1307
|
}
|
|
1239
1308
|
if (isExtensionEditSection(section)) {
|
|
1240
1309
|
// projectionDetail is read-only (no composed form submit) — an
|
|
@@ -1983,6 +2052,19 @@ export function validateScreens(
|
|
|
1983
2052
|
`section "${sectionLabel}" action`,
|
|
1984
2053
|
action,
|
|
1985
2054
|
);
|
|
2055
|
+
if (action.kind === "navigate" && action.tab !== undefined) {
|
|
2056
|
+
resolveRowActionNavigateTarget(
|
|
2057
|
+
feature.name,
|
|
2058
|
+
screenId,
|
|
2059
|
+
"entityEdit",
|
|
2060
|
+
`section "${sectionLabel}" action`,
|
|
2061
|
+
action,
|
|
2062
|
+
allScreenQns,
|
|
2063
|
+
navTargetShortIds,
|
|
2064
|
+
screensByShortId,
|
|
2065
|
+
detailForScreens,
|
|
2066
|
+
);
|
|
2067
|
+
}
|
|
1986
2068
|
}
|
|
1987
2069
|
}
|
|
1988
2070
|
if (isExtensionEditSection(section)) {
|