@cosmicdrift/kumiko-renderer-web 0.126.0 → 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.126.0",
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.126.0",
20
- "@cosmicdrift/kumiko-headless": "0.126.0",
21
- "@cosmicdrift/kumiko-renderer": "0.126.0",
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",
@@ -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
+ });
@@ -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;