@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.
|
|
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.
|
|
20
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
21
|
-
"@cosmicdrift/kumiko-renderer": "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",
|
|
@@ -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
|
+
});
|
package/src/layout/nav-tree.tsx
CHANGED
|
@@ -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 {
|
|
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;
|