@cosmicdrift/kumiko-framework 0.152.0 → 0.153.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.153.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>",
|
|
@@ -193,7 +193,7 @@
|
|
|
193
193
|
"zod": "^4.4.3"
|
|
194
194
|
},
|
|
195
195
|
"devDependencies": {
|
|
196
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
196
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.153.0",
|
|
197
197
|
"bun-types": "^1.3.13",
|
|
198
198
|
"pino-pretty": "^13.1.3"
|
|
199
199
|
},
|
|
@@ -80,6 +80,86 @@ describe("r.nav() — registration", () => {
|
|
|
80
80
|
});
|
|
81
81
|
});
|
|
82
82
|
|
|
83
|
+
describe("r.screen({ nav }) — inline nav sugar", () => {
|
|
84
|
+
test("synthesizes a nav entry from the screen's id", () => {
|
|
85
|
+
const feature = defineFeature("shop", (r) => {
|
|
86
|
+
r.entity("product", productEntity());
|
|
87
|
+
r.screen({
|
|
88
|
+
id: "products",
|
|
89
|
+
type: "entityList",
|
|
90
|
+
entity: "product",
|
|
91
|
+
columns: ["name"],
|
|
92
|
+
nav: { label: "shop:nav.products", icon: "box", order: 5 },
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
expect(feature.navs["products"]).toMatchObject({
|
|
96
|
+
id: "products",
|
|
97
|
+
label: "shop:nav.products",
|
|
98
|
+
icon: "box",
|
|
99
|
+
order: 5,
|
|
100
|
+
screen: "shop:screen:products",
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("supports parent like a standalone r.nav()", () => {
|
|
105
|
+
const feature = defineFeature("shop", (r) => {
|
|
106
|
+
r.nav({ id: "catalog", label: "x" });
|
|
107
|
+
r.entity("product", productEntity());
|
|
108
|
+
r.screen({
|
|
109
|
+
id: "products",
|
|
110
|
+
type: "entityList",
|
|
111
|
+
entity: "product",
|
|
112
|
+
columns: ["name"],
|
|
113
|
+
nav: { label: "y", parent: "shop:nav:catalog" },
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
expect(feature.navs["products"]?.parent).toBe("shop:nav:catalog");
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("passes the same boot-validation as a standalone r.nav()", () => {
|
|
120
|
+
const feature = defineFeature("shop", (r) => {
|
|
121
|
+
r.entity("product", productEntity());
|
|
122
|
+
r.screen({
|
|
123
|
+
id: "products",
|
|
124
|
+
type: "entityList",
|
|
125
|
+
entity: "product",
|
|
126
|
+
columns: ["name"],
|
|
127
|
+
nav: { label: "y" },
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
expect(() => validateBoot([feature])).not.toThrow();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("screen without nav registers no nav entry", () => {
|
|
134
|
+
const feature = defineFeature("shop", (r) => {
|
|
135
|
+
r.entity("product", productEntity());
|
|
136
|
+
r.screen({
|
|
137
|
+
id: "products",
|
|
138
|
+
type: "entityList",
|
|
139
|
+
entity: "product",
|
|
140
|
+
columns: ["name"],
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
expect(feature.navs["products"]).toBeUndefined();
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("rejects when a standalone r.nav() already used the screen's id", () => {
|
|
147
|
+
expect(() =>
|
|
148
|
+
defineFeature("shop", (r) => {
|
|
149
|
+
r.nav({ id: "products", label: "standalone" });
|
|
150
|
+
r.entity("product", productEntity());
|
|
151
|
+
r.screen({
|
|
152
|
+
id: "products",
|
|
153
|
+
type: "entityList",
|
|
154
|
+
entity: "product",
|
|
155
|
+
columns: ["name"],
|
|
156
|
+
nav: { label: "inline" },
|
|
157
|
+
});
|
|
158
|
+
}),
|
|
159
|
+
).toThrow(/already registered/);
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
83
163
|
describe("createRegistry — nav indexing", () => {
|
|
84
164
|
test("indexes nav entries by qualified name", () => {
|
|
85
165
|
const feature = defineFeature("shop", (r) => {
|
|
@@ -358,3 +358,45 @@ describe("renderPattern — single-pattern shape", () => {
|
|
|
358
358
|
expect(out).toBe('r.metric({ name: "requests", type: "counter" });');
|
|
359
359
|
});
|
|
360
360
|
});
|
|
361
|
+
|
|
362
|
+
// Regression guard for the class of bug the r.exposesApi/r.usesApi fold
|
|
363
|
+
// hit: a registrar-shape change (there, removing a method; here, adding a
|
|
364
|
+
// nested optional field) must survive the Designer's parse→render→parse
|
|
365
|
+
// cycle, not just a TS compile of the framework itself. r.screen()'s `nav`
|
|
366
|
+
// sugar is a generic nested object on an already-generic pattern (no
|
|
367
|
+
// per-field extractor/renderer), so this is the cheap case — but proving
|
|
368
|
+
// it beats assuming it.
|
|
369
|
+
const SCREEN_WITH_NAV_FEATURE = `
|
|
370
|
+
import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
371
|
+
|
|
372
|
+
defineFeature("shop", (r) => {
|
|
373
|
+
r.entity("product", { fields: { name: { type: "text" } } });
|
|
374
|
+
r.screen({
|
|
375
|
+
id: "products",
|
|
376
|
+
type: "entityList",
|
|
377
|
+
entity: "product",
|
|
378
|
+
columns: ["name"],
|
|
379
|
+
nav: { label: "shop:nav.products", icon: "box", order: 5 },
|
|
380
|
+
});
|
|
381
|
+
});
|
|
382
|
+
`;
|
|
383
|
+
|
|
384
|
+
describe("render → parse roundtrip — r.screen({ nav }) inline sugar", () => {
|
|
385
|
+
test("nested nav object survives parse → render → parse unchanged", () => {
|
|
386
|
+
const initial = parse(SCREEN_WITH_NAV_FEATURE);
|
|
387
|
+
const rendered = renderFeatureFile({
|
|
388
|
+
featureName: initial.featureName ?? "",
|
|
389
|
+
patterns: initial.patterns,
|
|
390
|
+
});
|
|
391
|
+
const reparsed = parse(rendered);
|
|
392
|
+
expect(reparsed.patterns.map(stripLocations)).toEqual(initial.patterns.map(stripLocations));
|
|
393
|
+
|
|
394
|
+
const screenPattern = reparsed.patterns.find((p) => p.kind === "screen");
|
|
395
|
+
expect(screenPattern?.kind).toBe("screen");
|
|
396
|
+
if (screenPattern?.kind === "screen") {
|
|
397
|
+
expect(screenPattern.definition).toMatchObject({
|
|
398
|
+
nav: { label: "shop:nav.products", icon: "box", order: 5 },
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
});
|
|
402
|
+
});
|
|
@@ -260,6 +260,27 @@ export function buildUiExtensionsMethods<TName extends string>(
|
|
|
260
260
|
);
|
|
261
261
|
}
|
|
262
262
|
state.screens[definition.id] = definition;
|
|
263
|
+
if (definition.nav) {
|
|
264
|
+
// Sugar for the common "one nav entry pointing at this screen"
|
|
265
|
+
// case — synthesizes id/screen from the screen's own id. Beyond
|
|
266
|
+
// label/icon/parent/order, declare a standalone r.nav() instead.
|
|
267
|
+
if (state.navs[definition.id]) {
|
|
268
|
+
throw new Error(
|
|
269
|
+
`[Feature ${name}] Nav entry "${definition.id}" already registered. ` +
|
|
270
|
+
`Nav ids must be unique per feature — remove the standalone ` +
|
|
271
|
+
`r.nav("${definition.id}", ...) call or the screen's inline nav.`,
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
const navDefinition: NavDefinition = {
|
|
275
|
+
id: definition.id,
|
|
276
|
+
label: definition.nav.label,
|
|
277
|
+
icon: definition.nav.icon,
|
|
278
|
+
parent: definition.nav.parent,
|
|
279
|
+
order: definition.nav.order,
|
|
280
|
+
screen: `${name}:screen:${definition.id}`,
|
|
281
|
+
};
|
|
282
|
+
state.navs[definition.id] = navDefinition;
|
|
283
|
+
}
|
|
263
284
|
},
|
|
264
285
|
nav(definition: NavDefinition): void {
|
|
265
286
|
// Reject kebab-drift at registration-time so the stack trace points at
|
|
@@ -684,7 +684,9 @@ export type FeatureRegistrar<TFeature extends string = string> = {
|
|
|
684
684
|
// the registry qualifies to "<feature>:screen:<id>". Boot-validation checks
|
|
685
685
|
// that entity-bound screens reference a registered entity and that the
|
|
686
686
|
// columns / form-field refs name real fields — cross-feature component-QN
|
|
687
|
-
// validation (r.uiComponent) comes in M4/M5.
|
|
687
|
+
// validation (r.uiComponent) comes in M4/M5. Optional `nav` field is
|
|
688
|
+
// sugar for a single nav entry pointing at this screen — equivalent to
|
|
689
|
+
// a standalone r.nav({ id: <same id>, screen: "<feature>:screen:<id>", ... }).
|
|
688
690
|
screen(definition: ScreenDefinition): void;
|
|
689
691
|
|
|
690
692
|
// Register a nav entry. The id is the feature-local short name (kebab-case);
|
|
@@ -727,7 +727,19 @@ export type ScreenSlots = {
|
|
|
727
727
|
|
|
728
728
|
// --- discriminated union ---
|
|
729
729
|
|
|
730
|
-
|
|
730
|
+
// Inline nav-entry sugar for `r.screen({ ..., nav: {...} })` — covers the
|
|
731
|
+
// common case of "one nav entry pointing at this screen". The nav entry's
|
|
732
|
+
// `id`/`screen` are synthesized from the screen's own id; for anything
|
|
733
|
+
// beyond label/icon/parent/order (access-gating, workspaces, actions),
|
|
734
|
+
// declare a standalone `r.nav()` entry instead.
|
|
735
|
+
export type ScreenNavSugar = {
|
|
736
|
+
readonly label: string;
|
|
737
|
+
readonly icon?: string;
|
|
738
|
+
readonly parent?: string;
|
|
739
|
+
readonly order?: number;
|
|
740
|
+
};
|
|
741
|
+
|
|
742
|
+
export type ScreenDefinition = (
|
|
731
743
|
| EntityListScreenDefinition
|
|
732
744
|
| ProjectionListScreenDefinition
|
|
733
745
|
| ProjectionDetailScreenDefinition
|
|
@@ -735,7 +747,8 @@ export type ScreenDefinition =
|
|
|
735
747
|
| EntityEditScreenDefinition
|
|
736
748
|
| ActionFormScreenDefinition
|
|
737
749
|
| ConfigEditScreenDefinition
|
|
738
|
-
| CustomScreenDefinition
|
|
750
|
+
| CustomScreenDefinition
|
|
751
|
+
) & { readonly nav?: ScreenNavSugar };
|
|
739
752
|
|
|
740
753
|
// Type guard — narrows FieldRenderer to FormatSpec. Useful for renderer
|
|
741
754
|
// authors who branch on the three FieldRenderer variants without manual
|