@cosmicdrift/kumiko-renderer-web 0.155.0 → 0.156.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-web",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.156.0",
|
|
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.0",
|
|
20
|
+
"@cosmicdrift/kumiko-headless": "0.156.0",
|
|
21
|
+
"@cosmicdrift/kumiko-renderer": "0.156.0",
|
|
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",
|
|
@@ -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({
|
|
@@ -67,7 +67,7 @@ export function createBrowserLocaleResolver(
|
|
|
67
67
|
timeZone: () => timeZone,
|
|
68
68
|
subscribe: store.subscribe,
|
|
69
69
|
setLocale: (next) => {
|
|
70
|
-
//
|
|
70
|
+
// skip: locale unchanged, avoid redundant localStorage write
|
|
71
71
|
// own Object.is-Gate would already block listener notification,
|
|
72
72
|
// but the persistence side-effect lives outside the store.
|
|
73
73
|
if (next === store.getSnapshot()) return;
|
package/src/app/create-app.tsx
CHANGED
|
@@ -455,6 +455,25 @@ function RoutedScreen({
|
|
|
455
455
|
};
|
|
456
456
|
}, [onRowClick, app.features, nav]);
|
|
457
457
|
|
|
458
|
+
// Copy-Link-Action (Issue #912) für entityEdit-Update-Screens. Baut die
|
|
459
|
+
// absolute Permalink-URL aus der aktuellen Route + kopiert sie —
|
|
460
|
+
// `navigator`/`window` sind hier erlaubt (renderer-web, kein
|
|
461
|
+
// platform-neutrales Package). Kein Button ohne entityId (create-mode).
|
|
462
|
+
// Silent-catch bei Clipboard-Fehler (non-secure context) mirrort das
|
|
463
|
+
// bestehende Muster in pat-tokens-screen.tsx.
|
|
464
|
+
const effectiveOnCopyLink = useMemo<(() => Promise<void> | void) | undefined>(() => {
|
|
465
|
+
const route = nav.route;
|
|
466
|
+
if (route?.entityId === undefined) return undefined;
|
|
467
|
+
return async () => {
|
|
468
|
+
const url = `${window.location.origin}${nav.hrefFor(route)}`;
|
|
469
|
+
try {
|
|
470
|
+
await navigator.clipboard.writeText(url);
|
|
471
|
+
} catch {
|
|
472
|
+
// clipboard blocked (non-secure context) — no fallback UI needed here
|
|
473
|
+
}
|
|
474
|
+
};
|
|
475
|
+
}, [nav]);
|
|
476
|
+
|
|
458
477
|
// KumikoScreen will nach wie vor ein single-feature schema. Wir
|
|
459
478
|
// füttern es mit dem owning Feature — es enthält Entity-Defs +
|
|
460
479
|
// Screen-Defs für den aktiven Render-Pfad. Kein Owner gefunden → wir
|
|
@@ -474,6 +493,7 @@ function RoutedScreen({
|
|
|
474
493
|
{...(translate !== undefined && { translate })}
|
|
475
494
|
{...(entityId !== undefined && { entityId })}
|
|
476
495
|
onRowClick={effectiveOnRowClick}
|
|
496
|
+
{...(effectiveOnCopyLink !== undefined && { onCopyLink: effectiveOnCopyLink })}
|
|
477
497
|
/>
|
|
478
498
|
);
|
|
479
499
|
}
|
|
@@ -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" }])],
|
package/src/sse/live-events.ts
CHANGED
|
@@ -41,7 +41,7 @@ export function createEventSourceLiveEvents(
|
|
|
41
41
|
try {
|
|
42
42
|
parsed = JSON.parse(raw) as LiveEvent["data"];
|
|
43
43
|
} catch {
|
|
44
|
-
//
|
|
44
|
+
// skip: malformed SSE payload, drop it rather than crash all subscribers
|
|
45
45
|
// zu crashen und alle anderen subscribers mitzureißen.
|
|
46
46
|
return;
|
|
47
47
|
}
|
|
@@ -52,12 +52,15 @@ export function createEventSourceLiveEvents(
|
|
|
52
52
|
};
|
|
53
53
|
|
|
54
54
|
const ensureConnected = (): void => {
|
|
55
|
+
// skip: EventSource already connected
|
|
55
56
|
if (source !== undefined) return;
|
|
57
|
+
// skip: no window/EventSource available (SSR/non-browser), nothing to connect
|
|
56
58
|
if (typeof window === "undefined" || typeof EventSource === "undefined") return;
|
|
57
59
|
source = new EventSource(url);
|
|
58
60
|
};
|
|
59
61
|
|
|
60
62
|
const ensureListenersForEntity = (entityName: string): void => {
|
|
63
|
+
// skip: not connected yet, listeners get wired once ensureConnected runs
|
|
61
64
|
if (source === undefined) return;
|
|
62
65
|
for (const verb of VERBS) {
|
|
63
66
|
const type = `${entityName}.${verb}`;
|
|
@@ -70,7 +73,9 @@ export function createEventSourceLiveEvents(
|
|
|
70
73
|
};
|
|
71
74
|
|
|
72
75
|
const closeIfEmpty = (): void => {
|
|
76
|
+
// skip: subscribers remain, connection still needed
|
|
73
77
|
if (subscribers.size > 0) return;
|
|
78
|
+
// skip: already closed, nothing to close
|
|
74
79
|
if (source === undefined) return;
|
|
75
80
|
source.close();
|
|
76
81
|
source = undefined;
|
package/src/tokens.ts
CHANGED
|
@@ -55,6 +55,7 @@ function persistMode(mode: ThemeMode): void {
|
|
|
55
55
|
* in der Host-HTML (siehe Header-Kommentar) macht dasselbe synchron
|
|
56
56
|
* vor dem ersten Paint. */
|
|
57
57
|
export function applyStoredThemeMode(): void {
|
|
58
|
+
// skip: no document (SSR/non-DOM context), nothing to apply
|
|
58
59
|
if (typeof document === "undefined") return;
|
|
59
60
|
let stored: string | null = null;
|
|
60
61
|
try {
|
|
@@ -63,6 +64,7 @@ export function applyStoredThemeMode(): void {
|
|
|
63
64
|
// skip: localStorage kann werfen (Private-Mode) — ohne gespeicherte
|
|
64
65
|
// Wahl bleibt der Server-/HTML-Default stehen.
|
|
65
66
|
}
|
|
67
|
+
// skip: no valid persisted mode stored, keep server/HTML default
|
|
66
68
|
if (stored !== "dark" && stored !== "light") return;
|
|
67
69
|
document.documentElement.classList.toggle("dark", stored === "dark");
|
|
68
70
|
notifyThemeChange();
|
|
@@ -99,12 +101,14 @@ export function useBrowserTokensApi(): TokensApi {
|
|
|
99
101
|
tokens: cssVarTokens,
|
|
100
102
|
mode,
|
|
101
103
|
setMode: (next) => {
|
|
104
|
+
// skip: no document (SSR/non-DOM context), nothing to toggle
|
|
102
105
|
if (typeof document === "undefined") return;
|
|
103
106
|
document.documentElement.classList.toggle("dark", next === "dark");
|
|
104
107
|
persistMode(next);
|
|
105
108
|
notifyThemeChange();
|
|
106
109
|
},
|
|
107
110
|
toggleMode: () => {
|
|
111
|
+
// skip: no document (SSR/non-DOM context), nothing to toggle
|
|
108
112
|
if (typeof document === "undefined") return;
|
|
109
113
|
const nowDark = document.documentElement.classList.toggle("dark");
|
|
110
114
|
persistMode(nowDark ? "dark" : "light");
|