@cosmicdrift/kumiko-renderer-web 0.126.0 → 0.128.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.128.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.128.0",
|
|
20
|
+
"@cosmicdrift/kumiko-headless": "0.128.0",
|
|
21
|
+
"@cosmicdrift/kumiko-renderer": "0.128.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 {
|
|
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(
|
|
43
|
+
const source = buildNavRegistrySliceForApp(appSchema);
|
|
35
44
|
return resolveNavigation({ source, ...(user !== undefined && { user }) });
|
|
36
|
-
}, [
|
|
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
|
|
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
|
-
{
|
|
64
|
+
{crumbs !== undefined && crumbs.length > 0 && (
|
|
47
65
|
<Breadcrumb>
|
|
48
66
|
<BreadcrumbList>
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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;
|