@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.155.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.155.0",
20
- "@cosmicdrift/kumiko-headless": "0.155.0",
21
- "@cosmicdrift/kumiko-renderer": "0.155.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
- // Early-Return so we don't write localStorage on a no-op. Store's
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;
@@ -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" }])],
@@ -41,7 +41,7 @@ export function createEventSourceLiveEvents(
41
41
  try {
42
42
  parsed = JSON.parse(raw) as LiveEvent["data"];
43
43
  } catch {
44
- // Malformed payload drop it. Besser als einen fangenden Listener
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");