@cosmicdrift/kumiko-renderer 0.205.0 → 0.206.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-renderer",
3
- "version": "0.205.0",
3
+ "version": "0.206.0",
4
4
  "description": "Platform-agnostic React renderer for Kumiko screens. Contains the shared logic — primitives-contract, hooks, KumikoScreen, navigation & SSE abstractions — that any platform-specific renderer (web, native) composes. No DOM, no EventSource, no react-dom.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -15,8 +15,8 @@
15
15
  }
16
16
  },
17
17
  "dependencies": {
18
- "@cosmicdrift/kumiko-framework": "0.205.0",
19
- "@cosmicdrift/kumiko-headless": "0.205.0",
18
+ "@cosmicdrift/kumiko-framework": "0.206.0",
19
+ "@cosmicdrift/kumiko-headless": "0.206.0",
20
20
  "react": "^19.2.6",
21
21
  "temporal-polyfill": "^0.3.2",
22
22
  "zod": "^4.4.3"
@@ -6,7 +6,7 @@
6
6
  import { describe, expect, test } from "bun:test";
7
7
  import type { ScreenDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
8
8
  import type { FeatureSchema } from "../feature-schema";
9
- import { formatPath, parsePath, resolveTarget } from "../nav";
9
+ import { formatPath, hasDetailScreen, parsePath, resolveTarget } from "../nav";
10
10
 
11
11
  describe("parsePath — ohne Workspaces", () => {
12
12
  test("/<screenId>", () => {
@@ -126,10 +126,14 @@ describe("resolveTarget", () => {
126
126
  expect(resolveTarget([], target)).toBe(target);
127
127
  });
128
128
 
129
+ // Short screenId, not the qualified "property:screen:lease-detail" —
130
+ // ScreenTarget.screenId round-trips through formatPath/parsePath as a
131
+ // short id; RoutedScreen re-qualifies it itself (fw#2164 fix, previously
132
+ // double-qualified and silently fell back to the app's default screen).
129
133
  test("ObjectTarget resolves via the matching custom detailFor screen", () => {
130
134
  const schema = schemaWith("property", [leaseDetailScreen]);
131
135
  expect(resolveTarget([schema], { entity: "lease", id: "l-1" })).toEqual({
132
- screenId: "property:screen:lease-detail",
136
+ screenId: "lease-detail",
133
137
  entityId: "l-1",
134
138
  });
135
139
  });
@@ -146,7 +150,7 @@ describe("resolveTarget", () => {
146
150
  leaseDetailScreen,
147
151
  ]);
148
152
  expect(resolveTarget([schema], { entity: "lease", id: "l-1" })).toEqual({
149
- screenId: "property:screen:lease-detail",
153
+ screenId: "lease-detail",
150
154
  entityId: "l-1",
151
155
  });
152
156
  });
@@ -156,7 +160,7 @@ describe("resolveTarget", () => {
156
160
  const resolved = resolveTarget([schema], { entity: "lease", id: "l-1", workspaceId: "admin" });
157
161
  expect(resolved).toEqual({
158
162
  workspaceId: "admin",
159
- screenId: "property:screen:lease-detail",
163
+ screenId: "lease-detail",
160
164
  entityId: "l-1",
161
165
  });
162
166
  });
@@ -172,3 +176,23 @@ describe("resolveTarget", () => {
172
176
  expect(() => resolveTarget([schema], { entity: "boat", id: "b-1" })).toThrow(/"boat"/);
173
177
  });
174
178
  });
179
+
180
+ // hasDetailScreen: non-throwing existence check create-app's row-click
181
+ // default (fw#2164) uses to pick between a detailFor screen and its other
182
+ // fallbacks — resolveTarget's throw isn't usable there since not resolving
183
+ // is the expected, non-error case for most entities.
184
+ describe("hasDetailScreen", () => {
185
+ test("true when a screen declares detailFor for the entity", () => {
186
+ const schema = schemaWith("property", [leaseDetailScreen]);
187
+ expect(hasDetailScreen([schema], "lease")).toBe(true);
188
+ });
189
+
190
+ test("false when no screen declares detailFor for the entity", () => {
191
+ const schema = schemaWith("property", [leaseListScreen, leaseEditScreen]);
192
+ expect(hasDetailScreen([schema], "lease")).toBe(false);
193
+ });
194
+
195
+ test("false for an empty feature list", () => {
196
+ expect(hasDetailScreen([], "lease")).toBe(false);
197
+ });
198
+ });
package/src/app/nav.tsx CHANGED
@@ -1,6 +1,5 @@
1
1
  import { createContext, type ReactNode, useContext } from "react";
2
2
  import type { FeatureSchema } from "./feature-schema";
3
- import { qualifyScreenId } from "./qualify-screen-id";
4
3
 
5
4
  // Navigation-Contract, plattform-neutral. Types + Context + Hook leben
6
5
  // hier; die konkrete Implementation (window.history im Web,
@@ -118,21 +117,45 @@ export function formatPath(target: ScreenTarget): string {
118
117
  return `/${segments.join("/")}`;
119
118
  }
120
119
 
120
+ // Shared entity→detailFor-screen lookup — resolveTarget's ObjectTarget
121
+ // branch and hasDetailScreen (create-app's row-click default, fw#2164) both
122
+ // need "is there a detail screen for this entity", one to resolve it, the
123
+ // other to just check before falling back to a different default.
124
+ function findDetailForScreen(
125
+ features: readonly FeatureSchema[],
126
+ entity: string,
127
+ ): { readonly featureName: string; readonly screenId: string } | undefined {
128
+ for (const feature of features) {
129
+ for (const screen of feature.screens) {
130
+ if (screen.detailFor === entity)
131
+ return { featureName: feature.featureName, screenId: screen.id };
132
+ }
133
+ }
134
+ return undefined;
135
+ }
136
+
137
+ /** Non-throwing existence check for findDetailForScreen — lets a caller pick
138
+ * a different default (fw#2164's row-click fallback) instead of resolving. */
139
+ export function hasDetailScreen(features: readonly FeatureSchema[], entity: string): boolean {
140
+ return findDetailForScreen(features, entity) !== undefined;
141
+ }
142
+
121
143
  // Resolves a NavTarget to the ScreenTarget form navigate/replace/hrefFor
122
144
  // operate on. Pure — no context, no I/O — so callers can use it outside
123
145
  // React too (see resolve-at-NavApi-build alternative in renderer-web).
124
146
  export function resolveTarget(features: readonly FeatureSchema[], target: NavTarget): ScreenTarget {
125
147
  if ("screenId" in target) return target;
126
148
 
127
- for (const feature of features) {
128
- for (const screen of feature.screens) {
129
- if (screen.detailFor !== target.entity) continue;
130
- return {
131
- ...(target.workspaceId !== undefined && { workspaceId: target.workspaceId }),
132
- screenId: qualifyScreenId(feature.featureName, screen.id),
133
- entityId: target.id,
134
- };
135
- }
149
+ const found = findDetailForScreen(features, target.entity);
150
+ if (found !== undefined) {
151
+ // Short form (globally unique per validateScreenShortIdCollisions)
152
+ // ScreenTarget.screenId round-trips through formatPath/parsePath as a
153
+ // short id; qualifying it here double-qualifies at RoutedScreen.
154
+ return {
155
+ ...(target.workspaceId !== undefined && { workspaceId: target.workspaceId }),
156
+ screenId: found.screenId,
157
+ entityId: target.id,
158
+ };
136
159
  }
137
160
 
138
161
  throw new Error(
package/src/index.ts CHANGED
@@ -72,7 +72,14 @@ export type {
72
72
  ObjectTarget,
73
73
  ScreenTarget,
74
74
  } from "./app/nav";
75
- export { formatPath, NavProvider, parsePath, resolveTarget, useNav } from "./app/nav";
75
+ export {
76
+ formatPath,
77
+ hasDetailScreen,
78
+ NavProvider,
79
+ parsePath,
80
+ resolveTarget,
81
+ useNav,
82
+ } from "./app/nav";
76
83
  export { lastSegment } from "./app/qn";
77
84
  export type { VariableChipsProps } from "./app/variable-chips";
78
85
  export { VariableChips } from "./app/variable-chips";