@cosmicdrift/kumiko-renderer-web 0.125.2 → 0.127.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.125.2",
3
+ "version": "0.127.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.125.2",
20
- "@cosmicdrift/kumiko-headless": "0.125.2",
21
- "@cosmicdrift/kumiko-renderer": "0.125.2",
19
+ "@cosmicdrift/kumiko-dispatcher-live": "0.127.0",
20
+ "@cosmicdrift/kumiko-headless": "0.127.0",
21
+ "@cosmicdrift/kumiko-renderer": "0.127.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",
@@ -241,6 +241,25 @@ describe("NavTree", () => {
241
241
  expect(container.querySelectorAll("svg").length).toBe(2);
242
242
  });
243
243
 
244
+ test("palette, link und share rendern Lucide-Icons (Share-/Branding-Nav)", () => {
245
+ const schema = {
246
+ featureName: "app",
247
+ entities: {},
248
+ screens: [
249
+ { id: "branding", type: "entityList", entity: "x", columns: [] },
250
+ { id: "links", type: "entityList", entity: "x", columns: [] },
251
+ { id: "defaults", type: "entityList", entity: "x", columns: [] },
252
+ ],
253
+ navs: [
254
+ { id: "branding", label: "Branding", screen: "branding", order: 10, icon: "palette" },
255
+ { id: "links", label: "Links", screen: "links", order: 20, icon: "link" },
256
+ { id: "defaults", label: "Defaults", screen: "defaults", order: 30, icon: "share" },
257
+ ],
258
+ } as FeatureSchema;
259
+ const { container } = render(<NavTree schema={schema} />);
260
+ expect(container.querySelectorAll("svg").length).toBe(3);
261
+ });
262
+
244
263
  test("unbekannter icon-Key fällt sauber auf den Dot zurück (kein svg)", () => {
245
264
  const schema = {
246
265
  featureName: "showcase",
@@ -0,0 +1,82 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { ScreenDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
3
+ import { resolveDetailBreadcrumb } from "../shell-breadcrumb";
4
+
5
+ const t = (key: string): string => key;
6
+
7
+ describe("resolveDetailBreadcrumb", () => {
8
+ test("entityEdit pairs with entityList on same entity", () => {
9
+ const screens: ScreenDefinition[] = [
10
+ {
11
+ id: "user-list",
12
+ type: "entityList",
13
+ entity: "user",
14
+ columns: ["email"],
15
+ rowActions: [],
16
+ },
17
+ {
18
+ id: "user-edit",
19
+ type: "entityEdit",
20
+ entity: "user",
21
+ layout: { sections: [{ fields: ["email"] }] },
22
+ },
23
+ ];
24
+ expect(resolveDetailBreadcrumb(screens, "user-edit", t)).toEqual([
25
+ { label: "screen:user-list.title", screenId: "user-list" },
26
+ { label: "screen:user-edit.title" },
27
+ ]);
28
+ });
29
+
30
+ test("entityList navigate rowAction links to detail screen", () => {
31
+ const screens: ScreenDefinition[] = [
32
+ {
33
+ id: "export-job-list",
34
+ type: "entityList",
35
+ entity: "export-job",
36
+ columns: ["status"],
37
+ rowActions: [
38
+ {
39
+ kind: "navigate",
40
+ id: "view",
41
+ label: "kumiko.actions.view",
42
+ screen: "export-job-detail",
43
+ entityId: "id",
44
+ },
45
+ ],
46
+ },
47
+ {
48
+ id: "export-job-detail",
49
+ type: "entityEdit",
50
+ entity: "export-job",
51
+ layout: { sections: [{ fields: ["status"] }] },
52
+ },
53
+ ];
54
+ expect(resolveDetailBreadcrumb(screens, "export-job-detail", t)?.[0]?.screenId).toBe(
55
+ "export-job-list",
56
+ );
57
+ });
58
+
59
+ test("custom detail uses listScreenId", () => {
60
+ const screens: ScreenDefinition[] = [
61
+ {
62
+ id: "sysadmin-users",
63
+ type: "custom",
64
+ renderer: { react: { __component: "SysadminUsersScreen" } },
65
+ },
66
+ {
67
+ id: "sysadmin-user-detail",
68
+ type: "custom",
69
+ listScreenId: "sysadmin-users",
70
+ renderer: { react: { __component: "SysadminUserDetailScreen" } },
71
+ },
72
+ ];
73
+ expect(resolveDetailBreadcrumb(screens, "sysadmin-user-detail", t)).toEqual([
74
+ { label: "screen:sysadmin-users.title", screenId: "sysadmin-users" },
75
+ { label: "screen:sysadmin-user-detail.title" },
76
+ ]);
77
+ });
78
+
79
+ test("unknown screen returns undefined", () => {
80
+ expect(resolveDetailBreadcrumb([], "missing", t)).toBeUndefined();
81
+ });
82
+ });
@@ -41,12 +41,15 @@ import {
41
41
  Layers,
42
42
  LayoutDashboard,
43
43
  LineChart,
44
+ Link,
44
45
  List,
46
+ Palette,
45
47
  PiggyBank,
46
48
  Plus,
47
49
  Receipt,
48
50
  Search,
49
51
  Settings,
52
+ Share2,
50
53
  Shield,
51
54
  Sparkles,
52
55
  Table,
@@ -119,6 +122,9 @@ const NAV_ICONS: Readonly<Record<string, typeof Folder>> = {
119
122
  search: Search,
120
123
  tag: Tag,
121
124
  key: KeyRound,
125
+ link: Link,
126
+ palette: Palette,
127
+ share: Share2,
122
128
  };
123
129
 
124
130
  export type NavTreeProps = {
@@ -0,0 +1,46 @@
1
+ import type { ScreenDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
2
+ import { lastSegment } from "./nav-tree";
3
+
4
+ export type BreadcrumbCrumb = {
5
+ readonly label: string;
6
+ readonly screenId?: string;
7
+ };
8
+
9
+ export function screenTitleKey(screenShortId: string): string {
10
+ return `screen:${screenShortId}.title`;
11
+ }
12
+
13
+ export function resolveDetailBreadcrumb(
14
+ screens: readonly ScreenDefinition[],
15
+ detailScreenId: string,
16
+ t: (key: string) => string,
17
+ ): readonly BreadcrumbCrumb[] | undefined {
18
+ const detail = screens.find((s) => lastSegment(s.id) === detailScreenId);
19
+ if (detail === undefined) return undefined;
20
+
21
+ const listFromRowAction = screens.find((s) => {
22
+ if (s.type !== "entityList") return false;
23
+ return (s.rowActions ?? []).some((a) => a.kind === "navigate" && a.screen === detailScreenId);
24
+ });
25
+
26
+ const listFromEntity =
27
+ detail.type === "entityEdit"
28
+ ? screens.find((s) => s.type === "entityList" && s.entity === detail.entity)
29
+ : undefined;
30
+
31
+ const listFromCustomParent =
32
+ detail.type === "custom" && detail.listScreenId !== undefined
33
+ ? screens.find((s) => lastSegment(s.id) === detail.listScreenId)
34
+ : undefined;
35
+
36
+ const list = listFromRowAction ?? listFromEntity ?? listFromCustomParent;
37
+ if (list === undefined) {
38
+ return [{ label: t(screenTitleKey(detailScreenId)) }];
39
+ }
40
+
41
+ const listId = lastSegment(list.id);
42
+ return [
43
+ { label: t(screenTitleKey(listId)), screenId: listId },
44
+ { label: t(screenTitleKey(detailScreenId)) },
45
+ ];
46
+ }
@@ -9,10 +9,18 @@ import { resolveNavigation } from "@cosmicdrift/kumiko-headless";
9
9
  import type { AppSchema, FeatureSchema } from "@cosmicdrift/kumiko-renderer";
10
10
  import { toAppSchema, useNav, useTranslation } from "@cosmicdrift/kumiko-renderer";
11
11
  import { type ReactNode, useMemo } from "react";
12
- import { Breadcrumb, BreadcrumbItem, BreadcrumbList, BreadcrumbPage } from "../ui/breadcrumb";
12
+ import {
13
+ Breadcrumb,
14
+ BreadcrumbItem,
15
+ BreadcrumbLink,
16
+ BreadcrumbList,
17
+ BreadcrumbPage,
18
+ BreadcrumbSeparator,
19
+ } from "../ui/breadcrumb";
13
20
  import { Separator } from "../ui/separator";
14
21
  import { SidebarTrigger } from "../ui/sidebar";
15
22
  import { buildNavRegistrySliceForApp, lastSegment } from "./nav-tree";
23
+ import { type BreadcrumbCrumb, resolveDetailBreadcrumb } from "./shell-breadcrumb";
16
24
 
17
25
  type ShellHeaderUser = {
18
26
  readonly id: string;
@@ -30,25 +38,53 @@ export function ShellHeader({
30
38
  }): ReactNode {
31
39
  const nav = useNav();
32
40
  const t = useTranslation();
41
+ const appSchema = toAppSchema(schema);
33
42
  const tree = useMemo(() => {
34
- const source = buildNavRegistrySliceForApp(toAppSchema(schema));
43
+ const source = buildNavRegistrySliceForApp(appSchema);
35
44
  return resolveNavigation({ source, ...(user !== undefined && { user }) });
36
- }, [schema, user]);
45
+ }, [appSchema, user]);
37
46
 
47
+ const allScreens = useMemo(
48
+ () => appSchema.features.flatMap((f) => f.screens),
49
+ [appSchema.features],
50
+ );
38
51
  const screenId = nav.route?.screenId;
39
- const label = screenId !== undefined ? activeNavLabel(tree, screenId, (k) => t(k)) : undefined;
52
+ const crumbs = useMemo((): readonly BreadcrumbCrumb[] | undefined => {
53
+ if (screenId === undefined) return undefined;
54
+ const navLabel = activeNavLabel(tree, screenId, (k) => t(k));
55
+ if (navLabel !== undefined) return [{ label: navLabel }];
56
+ return resolveDetailBreadcrumb(allScreens, screenId, t);
57
+ }, [allScreens, screenId, t, tree]);
40
58
 
41
59
  return (
42
60
  <header className="flex h-16 shrink-0 items-center gap-2 border-b transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12">
43
61
  <div className="flex items-center gap-2 px-4">
44
62
  <SidebarTrigger className="-ml-1" />
45
63
  <Separator orientation="vertical" className="mr-2 data-[orientation=vertical]:h-4" />
46
- {label !== undefined && (
64
+ {crumbs !== undefined && crumbs.length > 0 && (
47
65
  <Breadcrumb>
48
66
  <BreadcrumbList>
49
- <BreadcrumbItem>
50
- <BreadcrumbPage>{label}</BreadcrumbPage>
51
- </BreadcrumbItem>
67
+ {crumbs.map((crumb, index) => {
68
+ const screenId = crumb.screenId;
69
+ return (
70
+ <BreadcrumbItem key={screenId ?? crumb.label}>
71
+ {index > 0 && <BreadcrumbSeparator />}
72
+ {screenId !== undefined && index < crumbs.length - 1 ? (
73
+ <BreadcrumbLink
74
+ href="#"
75
+ onClick={(e) => {
76
+ e.preventDefault();
77
+ nav.navigate({ screenId });
78
+ }}
79
+ >
80
+ {crumb.label}
81
+ </BreadcrumbLink>
82
+ ) : (
83
+ <BreadcrumbPage>{crumb.label}</BreadcrumbPage>
84
+ )}
85
+ </BreadcrumbItem>
86
+ );
87
+ })}
52
88
  </BreadcrumbList>
53
89
  </Breadcrumb>
54
90
  )}
@@ -69,7 +105,7 @@ function activeNavLabel(
69
105
  ): string | undefined {
70
106
  for (const node of nodes) {
71
107
  if (node.screen !== undefined && lastSegment(node.screen) === screenId) {
72
- return node.label.includes(".") ? t(node.label) : node.label;
108
+ return node.label.includes(".") || node.label.includes(":") ? t(node.label) : node.label;
73
109
  }
74
110
  const child = activeNavLabel(node.children, screenId, t);
75
111
  if (child !== undefined) return child;