@cosmicdrift/kumiko-renderer-web 0.155.1 → 0.156.1
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-web",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.156.1",
|
|
4
4
|
"description": "Web-platform bindings for @cosmicdrift/kumiko-renderer. HTML default-primitives, browser history-based navigation, EventSource-backed live events, and a one-call createKumikoApp that mounts the whole stack via react-dom.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -16,9 +16,9 @@
|
|
|
16
16
|
"./styles.css": "./src/styles.css"
|
|
17
17
|
},
|
|
18
18
|
"dependencies": {
|
|
19
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
20
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
21
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
19
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.156.1",
|
|
20
|
+
"@cosmicdrift/kumiko-headless": "0.156.1",
|
|
21
|
+
"@cosmicdrift/kumiko-renderer": "0.156.1",
|
|
22
22
|
"@radix-ui/react-dialog": "^1.1.15",
|
|
23
23
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
|
24
24
|
"@radix-ui/react-label": "^2.1.8",
|
|
@@ -6,7 +6,12 @@ import type {
|
|
|
6
6
|
EntityListScreenDefinition,
|
|
7
7
|
} from "@cosmicdrift/kumiko-framework/ui-types";
|
|
8
8
|
import type { Dispatcher } from "@cosmicdrift/kumiko-headless";
|
|
9
|
-
import type {
|
|
9
|
+
import type {
|
|
10
|
+
AppSchema,
|
|
11
|
+
ColumnRendererProps,
|
|
12
|
+
FeatureSchema,
|
|
13
|
+
NavApi,
|
|
14
|
+
} from "@cosmicdrift/kumiko-renderer";
|
|
10
15
|
import { createStaticLocaleResolver } from "@cosmicdrift/kumiko-renderer";
|
|
11
16
|
import { act, screen, waitFor } from "@testing-library/react";
|
|
12
17
|
import type { ReactNode } from "react";
|
|
@@ -150,6 +155,54 @@ describe("createKumikoApp", () => {
|
|
|
150
155
|
);
|
|
151
156
|
});
|
|
152
157
|
|
|
158
|
+
test("fallback screen skips access-restricted screens across features (#1176)", async () => {
|
|
159
|
+
mountRoot();
|
|
160
|
+
const restrictedFeature: FeatureSchema = {
|
|
161
|
+
featureName: "user",
|
|
162
|
+
entities: { task: taskEntity },
|
|
163
|
+
screens: [{ ...listScreen, id: "user-list", access: { roles: ["SystemAdmin"] } }],
|
|
164
|
+
};
|
|
165
|
+
const openFeature: FeatureSchema = { ...baseSchema, featureName: "tasks" };
|
|
166
|
+
const schema: AppSchema = { features: [restrictedFeature, openFeature] };
|
|
167
|
+
await mountApp({ schema, dispatcher: makeDispatcher() });
|
|
168
|
+
// Landing on user-list (SystemAdmin-only) would show an access-denied
|
|
169
|
+
// banner instead — the tasks:task-edit form must win instead.
|
|
170
|
+
await waitFor(() => expect(screen.getByTestId("render-edit-form")).toBeTruthy());
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("fallback screen: an openToAll screen counts as accessible", async () => {
|
|
174
|
+
mountRoot();
|
|
175
|
+
const restrictedFeature: FeatureSchema = {
|
|
176
|
+
featureName: "user",
|
|
177
|
+
entities: { task: taskEntity },
|
|
178
|
+
screens: [{ ...editScreen, id: "user-edit", access: { roles: ["SystemAdmin"] } }],
|
|
179
|
+
};
|
|
180
|
+
const openFeature: FeatureSchema = {
|
|
181
|
+
featureName: "tasks",
|
|
182
|
+
entities: { task: taskEntity },
|
|
183
|
+
screens: [{ ...listScreen, access: { openToAll: true } }],
|
|
184
|
+
};
|
|
185
|
+
const schema: AppSchema = { features: [restrictedFeature, openFeature] };
|
|
186
|
+
await mountApp({ schema, dispatcher: makeDispatcher() });
|
|
187
|
+
expect(await screen.findByTestId("render-list-table-empty")).toBeTruthy();
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("all screens access-restricted → throws (no accessible fallback)", () => {
|
|
191
|
+
mountRoot();
|
|
192
|
+
const restrictedOnly: AppSchema = {
|
|
193
|
+
features: [
|
|
194
|
+
{
|
|
195
|
+
featureName: "user",
|
|
196
|
+
entities: { task: taskEntity },
|
|
197
|
+
screens: [{ ...editScreen, access: { roles: ["SystemAdmin"] } }],
|
|
198
|
+
},
|
|
199
|
+
],
|
|
200
|
+
};
|
|
201
|
+
expect(() => createKumikoApp({ schema: restrictedOnly, dispatcher: makeDispatcher() })).toThrow(
|
|
202
|
+
/no screens/,
|
|
203
|
+
);
|
|
204
|
+
});
|
|
205
|
+
|
|
153
206
|
test("clientFeatures.columnRenderers → bei Key-Kollision warnt + last-wins gewinnt", async () => {
|
|
154
207
|
// Zwei Features liefern denselben Renderer-Key — der Merge in
|
|
155
208
|
// create-app warnt und behält den späteren Eintrag (Last-Wins).
|
|
@@ -314,6 +314,68 @@ describe("KumikoScreen", () => {
|
|
|
314
314
|
expect(screen.queryByTestId("render-edit-delete")).toBeNull();
|
|
315
315
|
});
|
|
316
316
|
|
|
317
|
+
// Issue #912 — Copy-Link-Action. KumikoScreen threads an already-bound
|
|
318
|
+
// onCopyLink callback down to RenderEdit (the actual URL-build/clipboard
|
|
319
|
+
// impl lives in renderer-web's RoutedScreen, outside this test — here we
|
|
320
|
+
// only verify the wiring: button renders in update-mode, fires the
|
|
321
|
+
// callback, and shows the "copied" label afterwards).
|
|
322
|
+
test("entityEdit update-mode: Copy-Link-Button feuert onCopyLink + zeigt 'copied'-Label", async () => {
|
|
323
|
+
const user = userEvent.setup();
|
|
324
|
+
const onCopyLink = mock(() => Promise.resolve());
|
|
325
|
+
const dispatcher = makeDispatcher({
|
|
326
|
+
query: (async () => ({
|
|
327
|
+
isSuccess: true,
|
|
328
|
+
data: { id: "task-1", version: 1, title: "loaded", count: 3, done: false },
|
|
329
|
+
})) as unknown as Dispatcher["query"],
|
|
330
|
+
});
|
|
331
|
+
render(
|
|
332
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
333
|
+
<KumikoScreen
|
|
334
|
+
schema={schema}
|
|
335
|
+
qn="tasks:screen:task-edit"
|
|
336
|
+
entityId="task-1"
|
|
337
|
+
onCopyLink={onCopyLink}
|
|
338
|
+
/>
|
|
339
|
+
</DispatcherProvider>,
|
|
340
|
+
);
|
|
341
|
+
await waitFor(() => expect(screen.queryByTestId("kumiko-screen-loading")).toBeNull());
|
|
342
|
+
|
|
343
|
+
const button = screen.getByTestId("render-edit-copy-link");
|
|
344
|
+
expect(button.textContent).toBe("Copy link");
|
|
345
|
+
await user.click(button);
|
|
346
|
+
expect(onCopyLink).toHaveBeenCalledTimes(1);
|
|
347
|
+
await waitFor(() => expect(button.textContent).toBe("Copied!"));
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
test("entityEdit create-mode: kein Copy-Link-Button (keine entity-id → kein Permalink)", () => {
|
|
351
|
+
render(
|
|
352
|
+
<DispatcherProvider dispatcher={makeDispatcher()}>
|
|
353
|
+
<KumikoScreen
|
|
354
|
+
schema={schema}
|
|
355
|
+
qn="tasks:screen:task-edit"
|
|
356
|
+
onCopyLink={() => Promise.resolve()}
|
|
357
|
+
/>
|
|
358
|
+
</DispatcherProvider>,
|
|
359
|
+
);
|
|
360
|
+
expect(screen.queryByTestId("render-edit-copy-link")).toBeNull();
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
test("entityEdit update-mode ohne onCopyLink-Prop: kein Copy-Link-Button", async () => {
|
|
364
|
+
const dispatcher = makeDispatcher({
|
|
365
|
+
query: (async () => ({
|
|
366
|
+
isSuccess: true,
|
|
367
|
+
data: { id: "task-1", version: 1, title: "loaded", count: 3, done: false },
|
|
368
|
+
})) as unknown as Dispatcher["query"],
|
|
369
|
+
});
|
|
370
|
+
render(
|
|
371
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
372
|
+
<KumikoScreen schema={schema} qn="tasks:screen:task-edit" entityId="task-1" />
|
|
373
|
+
</DispatcherProvider>,
|
|
374
|
+
);
|
|
375
|
+
await waitFor(() => expect(screen.queryByTestId("kumiko-screen-loading")).toBeNull());
|
|
376
|
+
expect(screen.queryByTestId("render-edit-copy-link")).toBeNull();
|
|
377
|
+
});
|
|
378
|
+
|
|
317
379
|
test("entityEdit update-mode: version_conflict → Banner + 'Neu laden' triggert detail-refetch", async () => {
|
|
318
380
|
let detailCalls = 0;
|
|
319
381
|
const dispatcher = makeDispatcher({
|
package/src/app/create-app.tsx
CHANGED
|
@@ -129,6 +129,22 @@ function readInjectedSchema(): AppSchema | FeatureSchema | undefined {
|
|
|
129
129
|
return w.__KUMIKO_SCHEMA__;
|
|
130
130
|
}
|
|
131
131
|
|
|
132
|
+
// Erstes Screen über alle Features in deklarierter Reihenfolge, dessen
|
|
133
|
+
// `access` niemanden ausschließt (undefined oder openToAll). Die Landing-
|
|
134
|
+
// Route wird VOR Auth-Resolution gewählt, kennt also keine User-Rollen —
|
|
135
|
+
// ein role-restricted Screen (z.B. bundled user/tenant, SystemAdmin-only)
|
|
136
|
+
// darf hier nie gewinnen, sonst landet jeder Nicht-Admin auf einem
|
|
137
|
+
// Access-Denied-Screen (#1176).
|
|
138
|
+
function firstOpenScreenQn(features: readonly FeatureSchema[]): string | undefined {
|
|
139
|
+
for (const feature of features) {
|
|
140
|
+
const openScreen = feature.screens.find(
|
|
141
|
+
(s) => s.access === undefined || "openToAll" in s.access,
|
|
142
|
+
);
|
|
143
|
+
if (openScreen !== undefined) return qualifyScreenId(feature.featureName, openScreen.id);
|
|
144
|
+
}
|
|
145
|
+
return undefined;
|
|
146
|
+
}
|
|
147
|
+
|
|
132
148
|
export function createKumikoApp(options: CreateKumikoAppOptions = {}): { readonly root: Root } {
|
|
133
149
|
const rootId = options.rootId ?? "root";
|
|
134
150
|
const container = document.getElementById(rootId);
|
|
@@ -154,19 +170,11 @@ export function createKumikoApp(options: CreateKumikoAppOptions = {}): { readonl
|
|
|
154
170
|
}
|
|
155
171
|
const app = toAppSchema(rawSchema);
|
|
156
172
|
|
|
157
|
-
// Fallback-Screen
|
|
158
|
-
|
|
159
|
-
// feature with screens.
|
|
160
|
-
const firstFeatureWithScreens = app.features.find((f) => f.screens.length > 0);
|
|
161
|
-
const firstScreen = firstFeatureWithScreens?.screens[0];
|
|
162
|
-
const fallbackQn =
|
|
163
|
-
options.screenQn ??
|
|
164
|
-
(firstScreen !== undefined && firstFeatureWithScreens !== undefined
|
|
165
|
-
? qualifyScreenId(firstFeatureWithScreens.featureName, firstScreen.id)
|
|
166
|
-
: undefined);
|
|
173
|
+
// Fallback-Screen falls kein explizites screenQn übergeben wurde.
|
|
174
|
+
const fallbackQn = options.screenQn ?? firstOpenScreenQn(app.features);
|
|
167
175
|
if (!fallbackQn) {
|
|
168
176
|
throw new Error(
|
|
169
|
-
"createKumikoApp: schema contains no screens. Add at least one entry to `schema.screens` or pass `screenQn` explicitly.",
|
|
177
|
+
"createKumikoApp: schema contains no screens accessible without a role restriction. Add at least one entry to `schema.screens` without `access.roles`, or pass `screenQn` explicitly.",
|
|
170
178
|
);
|
|
171
179
|
}
|
|
172
180
|
|
|
@@ -455,6 +463,25 @@ function RoutedScreen({
|
|
|
455
463
|
};
|
|
456
464
|
}, [onRowClick, app.features, nav]);
|
|
457
465
|
|
|
466
|
+
// Copy-Link-Action (Issue #912) für entityEdit-Update-Screens. Baut die
|
|
467
|
+
// absolute Permalink-URL aus der aktuellen Route + kopiert sie —
|
|
468
|
+
// `navigator`/`window` sind hier erlaubt (renderer-web, kein
|
|
469
|
+
// platform-neutrales Package). Kein Button ohne entityId (create-mode).
|
|
470
|
+
// Silent-catch bei Clipboard-Fehler (non-secure context) mirrort das
|
|
471
|
+
// bestehende Muster in pat-tokens-screen.tsx.
|
|
472
|
+
const effectiveOnCopyLink = useMemo<(() => Promise<void> | void) | undefined>(() => {
|
|
473
|
+
const route = nav.route;
|
|
474
|
+
if (route?.entityId === undefined) return undefined;
|
|
475
|
+
return async () => {
|
|
476
|
+
const url = `${window.location.origin}${nav.hrefFor(route)}`;
|
|
477
|
+
try {
|
|
478
|
+
await navigator.clipboard.writeText(url);
|
|
479
|
+
} catch {
|
|
480
|
+
// clipboard blocked (non-secure context) — no fallback UI needed here
|
|
481
|
+
}
|
|
482
|
+
};
|
|
483
|
+
}, [nav]);
|
|
484
|
+
|
|
458
485
|
// KumikoScreen will nach wie vor ein single-feature schema. Wir
|
|
459
486
|
// füttern es mit dem owning Feature — es enthält Entity-Defs +
|
|
460
487
|
// Screen-Defs für den aktiven Render-Pfad. Kein Owner gefunden → wir
|
|
@@ -474,6 +501,7 @@ function RoutedScreen({
|
|
|
474
501
|
{...(translate !== undefined && { translate })}
|
|
475
502
|
{...(entityId !== undefined && { entityId })}
|
|
476
503
|
onRowClick={effectiveOnRowClick}
|
|
504
|
+
{...(effectiveOnCopyLink !== undefined && { onCopyLink: effectiveOnCopyLink })}
|
|
477
505
|
/>
|
|
478
506
|
);
|
|
479
507
|
}
|
|
@@ -128,6 +128,25 @@ describe("buildNavRegistrySliceForApp", () => {
|
|
|
128
128
|
expect(slice.topLevel[0]?.access).toEqual({ roles: ["Admin", "Editor"] });
|
|
129
129
|
});
|
|
130
130
|
|
|
131
|
+
test("nav ohne eigene access bleibt ohne access, wenn der Ziel-Screen offen ist (kein Verstecken eines openToAll-Screens)", () => {
|
|
132
|
+
const app: AppSchema = {
|
|
133
|
+
features: [
|
|
134
|
+
featureWithScreens(
|
|
135
|
+
[{ id: "pub", label: "Pub", screen: "pub" }],
|
|
136
|
+
[
|
|
137
|
+
{
|
|
138
|
+
id: "pub",
|
|
139
|
+
type: "custom",
|
|
140
|
+
renderer: { react: { __component: "Pub" } },
|
|
141
|
+
},
|
|
142
|
+
],
|
|
143
|
+
),
|
|
144
|
+
],
|
|
145
|
+
};
|
|
146
|
+
const slice = buildNavRegistrySliceForApp(app);
|
|
147
|
+
expect(slice.topLevel[0]?.access).toBeUndefined();
|
|
148
|
+
});
|
|
149
|
+
|
|
131
150
|
test("nav ohne screen bleibt ohne access (nichts zum Erben da)", () => {
|
|
132
151
|
const app: AppSchema = {
|
|
133
152
|
features: [feature([{ id: "group", label: "Group" }])],
|