@cosmicdrift/kumiko-renderer 0.201.0 → 0.202.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 +3 -3
- package/src/app/__tests__/nav.test.ts +92 -1
- package/src/app/kumiko-screen.tsx +4 -12
- package/src/app/nav.tsx +43 -2
- package/src/app/qualify-screen-id.ts +6 -0
- package/src/index.ts +9 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.202.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.
|
|
19
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
18
|
+
"@cosmicdrift/kumiko-framework": "0.202.0",
|
|
19
|
+
"@cosmicdrift/kumiko-headless": "0.202.0",
|
|
20
20
|
"react": "^19.2.6",
|
|
21
21
|
"temporal-polyfill": "^0.3.2",
|
|
22
22
|
"zod": "^4.4.3"
|
|
@@ -4,7 +4,9 @@
|
|
|
4
4
|
// (mit/ohne Workspaces). Pure, kein DOM.
|
|
5
5
|
|
|
6
6
|
import { describe, expect, test } from "bun:test";
|
|
7
|
-
import {
|
|
7
|
+
import type { ScreenDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
8
|
+
import type { FeatureSchema } from "../feature-schema";
|
|
9
|
+
import { formatPath, parsePath, resolveTarget } from "../nav";
|
|
8
10
|
|
|
9
11
|
describe("parsePath — ohne Workspaces", () => {
|
|
10
12
|
test("/<screenId>", () => {
|
|
@@ -81,3 +83,92 @@ describe("Roundtrip parsePath ↔ formatPath", () => {
|
|
|
81
83
|
expect(parsePath(formatPath(t), true)).toEqual(t);
|
|
82
84
|
});
|
|
83
85
|
});
|
|
86
|
+
|
|
87
|
+
// resolveTarget: ObjectTarget -> ScreenTarget lookup (fw#2163). Custom-Screen
|
|
88
|
+
// detailFor is the main case in app repos (projectionDetail has no entity to
|
|
89
|
+
// key off), so it gets its own scenario rather than piggy-backing on the
|
|
90
|
+
// entityList one.
|
|
91
|
+
function schemaWith(featureName: string, screens: readonly ScreenDefinition[]): FeatureSchema {
|
|
92
|
+
return { featureName, entities: {}, screens };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const leaseDetailScreen: ScreenDefinition = {
|
|
96
|
+
id: "lease-detail",
|
|
97
|
+
type: "custom",
|
|
98
|
+
renderer: { react: "stub" },
|
|
99
|
+
detailFor: "lease",
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const leaseListScreen: ScreenDefinition = {
|
|
103
|
+
id: "lease-list",
|
|
104
|
+
type: "entityList",
|
|
105
|
+
entity: "lease",
|
|
106
|
+
columns: ["name"],
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const leaseEditScreen: ScreenDefinition = {
|
|
110
|
+
id: "lease-edit",
|
|
111
|
+
type: "entityEdit",
|
|
112
|
+
entity: "lease",
|
|
113
|
+
layout: { sections: [{ fields: ["name"] }] },
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const leaseEditScreenAlt: ScreenDefinition = {
|
|
117
|
+
id: "lease-edit-quick",
|
|
118
|
+
type: "entityEdit",
|
|
119
|
+
entity: "lease",
|
|
120
|
+
layout: { sections: [{ fields: ["name"] }] },
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
describe("resolveTarget", () => {
|
|
124
|
+
test("ScreenTarget passes through unchanged", () => {
|
|
125
|
+
const target = { screenId: "task-edit", entityId: "abc" };
|
|
126
|
+
expect(resolveTarget([], target)).toBe(target);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("ObjectTarget resolves via the matching custom detailFor screen", () => {
|
|
130
|
+
const schema = schemaWith("property", [leaseDetailScreen]);
|
|
131
|
+
expect(resolveTarget([schema], { entity: "lease", id: "l-1" })).toEqual({
|
|
132
|
+
screenId: "property:screen:lease-detail",
|
|
133
|
+
entityId: "l-1",
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
// solon's "property" entity has THREE entityEdit screens — "the" edit
|
|
138
|
+
// screen for an entity isn't well-defined, so resolveTarget only ever
|
|
139
|
+
// looks at detailFor. This pins that multiple same-entity entityEdit
|
|
140
|
+
// screens don't confuse detail resolution (no first-match ambiguity).
|
|
141
|
+
test("multiple screens on the same entity (list, two entityEdit forms, one detail) — only the detailFor screen resolves", () => {
|
|
142
|
+
const schema = schemaWith("property", [
|
|
143
|
+
leaseListScreen,
|
|
144
|
+
leaseEditScreen,
|
|
145
|
+
leaseEditScreenAlt,
|
|
146
|
+
leaseDetailScreen,
|
|
147
|
+
]);
|
|
148
|
+
expect(resolveTarget([schema], { entity: "lease", id: "l-1" })).toEqual({
|
|
149
|
+
screenId: "property:screen:lease-detail",
|
|
150
|
+
entityId: "l-1",
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("workspaceId carries through when set", () => {
|
|
155
|
+
const schema = schemaWith("property", [leaseDetailScreen]);
|
|
156
|
+
const resolved = resolveTarget([schema], { entity: "lease", id: "l-1", workspaceId: "admin" });
|
|
157
|
+
expect(resolved).toEqual({
|
|
158
|
+
workspaceId: "admin",
|
|
159
|
+
screenId: "property:screen:lease-detail",
|
|
160
|
+
entityId: "l-1",
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("workspaceId is omitted (not undefined) when not set", () => {
|
|
165
|
+
const schema = schemaWith("property", [leaseDetailScreen]);
|
|
166
|
+
const resolved = resolveTarget([schema], { entity: "lease", id: "l-1" });
|
|
167
|
+
expect("workspaceId" in resolved).toBe(false);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("unknown entity throws with the entity name in the message", () => {
|
|
171
|
+
const schema = schemaWith("property", [leaseDetailScreen]);
|
|
172
|
+
expect(() => resolveTarget([schema], { entity: "boat", id: "b-1" })).toThrow(/"boat"/);
|
|
173
|
+
});
|
|
174
|
+
});
|
|
@@ -49,6 +49,7 @@ import {
|
|
|
49
49
|
} from "./projection-detail-shim";
|
|
50
50
|
import { synthesizeProjectionEntity, synthesizeProjectionScreen } from "./projection-list-shim";
|
|
51
51
|
import { lastSegment, toKebab } from "./qn";
|
|
52
|
+
import { qualifyScreenId } from "./qualify-screen-id";
|
|
52
53
|
import { screenAccessAllows } from "./screen-access";
|
|
53
54
|
import { dispatcherErrorText, WriteFailedError } from "./write-failed-error";
|
|
54
55
|
|
|
@@ -100,19 +101,10 @@ export type KumikoScreenProps = {
|
|
|
100
101
|
readonly onCopyLink?: () => Promise<void> | void;
|
|
101
102
|
};
|
|
102
103
|
|
|
103
|
-
|
|
104
|
-
// <feature>:screen:<short-id>. Matches the rule in
|
|
105
|
-
// packages/framework/src/engine/qualified-name.ts so client lookups
|
|
106
|
-
// line up with server-side registry state.
|
|
107
|
-
export function qualifyScreenId(featureName: string, screenId: string): string {
|
|
108
|
-
return `${featureName}:screen:${screenId}`;
|
|
109
|
-
}
|
|
104
|
+
export { qualifyScreenId };
|
|
110
105
|
|
|
111
|
-
/**
|
|
112
|
-
*
|
|
113
|
-
* baut (z.B. WorkspaceShell-Resolver) sollte das hier durchreichen statt
|
|
114
|
-
* String-Concat damit ein zukünftiger QN-Schema-Wechsel an einer Stelle
|
|
115
|
-
* greift. */
|
|
106
|
+
/** Mirrors qualifyScreenId for nav QNs (registry form `<feature>:nav:<short-id>`).
|
|
107
|
+
* Callers building QNs (e.g. the WorkspaceShell resolver) should use this instead of string-concat, so a QN-schema change only touches one place. */
|
|
116
108
|
export function qualifyNavId(featureName: string, navId: string): string {
|
|
117
109
|
return `${featureName}:nav:${navId}`;
|
|
118
110
|
}
|
package/src/app/nav.tsx
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { createContext, type ReactNode, useContext } from "react";
|
|
2
|
+
import type { FeatureSchema } from "./feature-schema";
|
|
3
|
+
import { qualifyScreenId } from "./qualify-screen-id";
|
|
2
4
|
|
|
3
5
|
// Navigation-Contract, plattform-neutral. Types + Context + Hook leben
|
|
4
6
|
// hier; die konkrete Implementation (window.history im Web,
|
|
@@ -20,7 +22,7 @@ export type NavRoute = {
|
|
|
20
22
|
readonly entityId?: string;
|
|
21
23
|
};
|
|
22
24
|
|
|
23
|
-
export type
|
|
25
|
+
export type ScreenTarget = {
|
|
24
26
|
// Optional in workspace-aware navigate calls. Omit for cross-workspace
|
|
25
27
|
// navigation (current workspace stays); set to switch workspaces in the
|
|
26
28
|
// same call as picking a screen — e.g. WorkspaceSwitcher does this.
|
|
@@ -29,6 +31,23 @@ export type NavTarget = {
|
|
|
29
31
|
readonly entityId?: string;
|
|
30
32
|
};
|
|
31
33
|
|
|
34
|
+
// Object-based nav target — callers name WHAT they want to see (an entity's
|
|
35
|
+
// row) instead of WHICH screen shows it. resolveTarget() below turns this
|
|
36
|
+
// into a ScreenTarget by looking up the screen with matching `detailFor`.
|
|
37
|
+
// No edit-form resolution here: an entity can have several entityEdit
|
|
38
|
+
// screens (e.g. solon's "property" has three), so "the" edit screen isn't
|
|
39
|
+
// well-defined — callers that want a form still name its screenId directly.
|
|
40
|
+
export type ObjectTarget = {
|
|
41
|
+
readonly workspaceId?: string;
|
|
42
|
+
readonly entity: string;
|
|
43
|
+
readonly id: string;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
// No `kind` tag: `"screenId" in target` narrows cleanly, and a tag would
|
|
47
|
+
// force every existing NavTarget literal (every navigate/hrefFor call in
|
|
48
|
+
// every app) to add one for an additive ticket. See resolveTarget().
|
|
49
|
+
export type NavTarget = ScreenTarget | ObjectTarget;
|
|
50
|
+
|
|
32
51
|
export type NavApi = {
|
|
33
52
|
/** Current route — `undefined` when the URL is at the root / there's
|
|
34
53
|
* no route selected. Caller's initial fallback kicks in then. */
|
|
@@ -89,7 +108,7 @@ export function parsePath(pathname: string, hasWorkspaces?: boolean): NavRoute |
|
|
|
89
108
|
return { screenId, ...(entityId !== undefined && { entityId }) };
|
|
90
109
|
}
|
|
91
110
|
|
|
92
|
-
export function formatPath(target:
|
|
111
|
+
export function formatPath(target: ScreenTarget): string {
|
|
93
112
|
// Workspace-Mode: prefix the workspace short id. Order matters —
|
|
94
113
|
// workspace before screen mirrors parsePath's segment order.
|
|
95
114
|
const segments: string[] = [];
|
|
@@ -99,6 +118,28 @@ export function formatPath(target: NavTarget): string {
|
|
|
99
118
|
return `/${segments.join("/")}`;
|
|
100
119
|
}
|
|
101
120
|
|
|
121
|
+
// Resolves a NavTarget to the ScreenTarget form navigate/replace/hrefFor
|
|
122
|
+
// operate on. Pure — no context, no I/O — so callers can use it outside
|
|
123
|
+
// React too (see resolve-at-NavApi-build alternative in renderer-web).
|
|
124
|
+
export function resolveTarget(features: readonly FeatureSchema[], target: NavTarget): ScreenTarget {
|
|
125
|
+
if ("screenId" in target) return target;
|
|
126
|
+
|
|
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
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
throw new Error(
|
|
139
|
+
`resolveTarget: no detail screen for entity "${target.entity}". Add detailFor: "${target.entity}" to the screen that shows it.`,
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
102
143
|
// Context + Hook. Default ist `undefined` damit fehlender Provider
|
|
103
144
|
// laut kracht statt ein silent-no-op NavApi mit toten navigate()
|
|
104
145
|
// Aufrufen anzubieten.
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// Dependency-free: kumiko-screen.tsx already imports useNav from nav.tsx, so
|
|
2
|
+
// this can't live in either without a cycle. Form must match the registry's
|
|
3
|
+
// qualification rule (packages/framework/src/engine/qualified-name.ts).
|
|
4
|
+
export function qualifyScreenId(featureName: string, screenId: string): string {
|
|
5
|
+
return `${featureName}:screen:${screenId}`;
|
|
6
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -64,8 +64,15 @@ export type {
|
|
|
64
64
|
export { isAppSchema, toAppSchema } from "./app/feature-schema";
|
|
65
65
|
export type { KumikoScreenProps } from "./app/kumiko-screen";
|
|
66
66
|
export { KumikoScreen, qualifyNavId, qualifyScreenId } from "./app/kumiko-screen";
|
|
67
|
-
export type {
|
|
68
|
-
|
|
67
|
+
export type {
|
|
68
|
+
NavApi,
|
|
69
|
+
NavProviderProps,
|
|
70
|
+
NavRoute,
|
|
71
|
+
NavTarget,
|
|
72
|
+
ObjectTarget,
|
|
73
|
+
ScreenTarget,
|
|
74
|
+
} from "./app/nav";
|
|
75
|
+
export { formatPath, NavProvider, parsePath, resolveTarget, useNav } from "./app/nav";
|
|
69
76
|
export { lastSegment } from "./app/qn";
|
|
70
77
|
export type { VariableChipsProps } from "./app/variable-chips";
|
|
71
78
|
export { VariableChips } from "./app/variable-chips";
|