@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.259.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.259.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.259.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(redirect?: string, extraScreens: readonly string[] = []) {
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
- // redirect is either a short screen id (same-feature, e.g. "item-list")
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
- // Same rule as actionForm's redirect: short screen-ID (same-feature)
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
  }