@cosmicdrift/kumiko-renderer-web 1.0.0 → 2.0.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 +4 -4
- package/src/__tests__/action-menu.test.tsx +75 -0
- package/src/__tests__/breadcrumb.test.tsx +50 -0
- package/src/__tests__/create-app.test.tsx +171 -1
- package/src/__tests__/dashboard-screen.test.tsx +393 -0
- package/src/__tests__/default-app-shell.test.tsx +47 -1
- package/src/__tests__/form-action-bar.test.tsx +9 -0
- package/src/__tests__/kumiko-screen.test.tsx +155 -1
- package/src/__tests__/lightbox.test.tsx +46 -0
- package/src/__tests__/nav-tree.test.tsx +122 -0
- package/src/__tests__/primitives.test.tsx +32 -1
- package/src/__tests__/profile-menu.test.tsx +29 -0
- package/src/__tests__/projection-detail.test.tsx +101 -0
- package/src/__tests__/projection-list-actions.test.tsx +131 -0
- package/src/__tests__/render-list.test.tsx +11 -3
- package/src/__tests__/test-utils.tsx +1 -0
- package/src/__tests__/toast.test.tsx +9 -9
- package/src/__tests__/use-mutation.test.tsx +86 -0
- package/src/__tests__/workspace-shell.test.tsx +102 -47
- package/src/__tests__/xss-safety.test.tsx +62 -0
- package/src/app/__tests__/first-open-screen-qn.test.ts +77 -0
- package/src/app/browser-locale.ts +1 -1
- package/src/app/create-app.tsx +96 -29
- package/src/app/dashboard-body.tsx +454 -0
- package/src/index.ts +75 -1
- package/src/layout/__tests__/nav-registry.test.ts +105 -0
- package/src/layout/__tests__/shell-breadcrumb.test.ts +82 -0
- package/src/layout/default-app-shell.tsx +9 -71
- package/src/layout/nav-tree.tsx +167 -37
- package/src/layout/shell-breadcrumb.ts +46 -0
- package/src/layout/shell-header.tsx +114 -0
- package/src/layout/workspace-shell.tsx +45 -18
- package/src/primitives/__tests__/data-table-logic.test.ts +27 -0
- package/src/primitives/__tests__/input-testid.test.tsx +45 -0
- package/src/primitives/__tests__/money-input.test.tsx +13 -1
- package/src/primitives/dialog.tsx +47 -76
- package/src/primitives/index.tsx +215 -37
- package/src/primitives/layout.tsx +39 -0
- package/src/primitives/lightbox.tsx +39 -0
- package/src/primitives/modal-shell.tsx +62 -0
- package/src/primitives/money-input.tsx +20 -10
- package/src/primitives/toast.tsx +14 -9
- package/src/sse/live-events.ts +6 -1
- package/src/styles.css +25 -0
- package/src/tokens.ts +4 -0
- package/src/ui/__tests__/avatar.test.tsx +66 -0
- package/src/ui/__tests__/sheet.test.tsx +102 -0
- package/src/version/__tests__/update-checker.test.tsx +115 -0
- package/src/widgets/__tests__/ai-text-field.test.tsx +267 -0
- package/src/widgets/__tests__/drawer.test.tsx +35 -0
- package/src/widgets/__tests__/form-kit.test.tsx +368 -0
- package/src/widgets/__tests__/infinity-list.test.tsx +132 -0
- package/src/widgets/__tests__/query-table.test.tsx +118 -0
- package/src/widgets/__tests__/result-panel.test.tsx +117 -0
- package/src/widgets/__tests__/widgets.test.tsx +243 -0
- package/src/widgets/ai-text-field.tsx +370 -0
- package/src/widgets/charts.tsx +228 -0
- package/src/widgets/collapsible-section.tsx +42 -0
- package/src/widgets/detail-list.tsx +43 -0
- package/src/widgets/drawer.tsx +49 -0
- package/src/widgets/feed-list.tsx +35 -0
- package/src/widgets/form-fields.tsx +326 -0
- package/src/widgets/index.ts +59 -0
- package/src/widgets/infinity-list.tsx +112 -0
- package/src/widgets/mode-switch.tsx +41 -0
- package/src/widgets/progress-bar.tsx +27 -0
- package/src/widgets/progress-list.tsx +38 -0
- package/src/widgets/query-table.tsx +103 -0
- package/src/widgets/result-panel.tsx +251 -0
- package/src/widgets/section-card.tsx +30 -0
- package/src/widgets/stat.tsx +181 -0
- package/src/widgets/states.tsx +89 -0
- package/src/widgets/status-badge.tsx +51 -0
- package/src/widgets/use-draft.ts +40 -0
- package/src/ui/card.tsx +0 -93
- package/src/ui/collapsible.tsx +0 -34
- package/src/ui/dropdown-menu.tsx +0 -258
- package/src/ui/select.tsx +0 -191
- /package/src/__tests__/{download.test.ts → download.test.tsx} +0 -0
|
@@ -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
|
+
});
|
|
@@ -14,13 +14,12 @@
|
|
|
14
14
|
// Apps die feingranulare Kontrolle wollen, gehen direkt auf die ui/-Teile.
|
|
15
15
|
// Wer ein Topbar mit Workspace-Switcher braucht, nutzt <WorkspaceShell>.
|
|
16
16
|
|
|
17
|
-
import
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
import {
|
|
23
|
-
import { Separator } from "../ui/separator";
|
|
17
|
+
import {
|
|
18
|
+
type AppSchema,
|
|
19
|
+
type FeatureSchema,
|
|
20
|
+
UserRolesProvider,
|
|
21
|
+
} from "@cosmicdrift/kumiko-renderer";
|
|
22
|
+
import type { ReactNode } from "react";
|
|
24
23
|
import {
|
|
25
24
|
Sidebar,
|
|
26
25
|
SidebarContent,
|
|
@@ -30,9 +29,9 @@ import {
|
|
|
30
29
|
SidebarInset,
|
|
31
30
|
SidebarProvider,
|
|
32
31
|
SidebarRail,
|
|
33
|
-
SidebarTrigger,
|
|
34
32
|
} from "../ui/sidebar";
|
|
35
|
-
import {
|
|
33
|
+
import { NavTree } from "./nav-tree";
|
|
34
|
+
import { ShellHeader } from "./shell-header";
|
|
36
35
|
|
|
37
36
|
export type DefaultAppShellUser = {
|
|
38
37
|
readonly id: string;
|
|
@@ -117,70 +116,9 @@ export function DefaultAppShell({
|
|
|
117
116
|
{...(headerActions !== undefined && { headerActions })}
|
|
118
117
|
/>
|
|
119
118
|
<main className={fill === true ? "min-h-0 flex-1 overflow-auto" : "flex-1 overflow-auto"}>
|
|
120
|
-
{children}
|
|
119
|
+
<UserRolesProvider roles={user?.roles}>{children}</UserRolesProvider>
|
|
121
120
|
</main>
|
|
122
121
|
</SidebarInset>
|
|
123
122
|
</SidebarProvider>
|
|
124
123
|
);
|
|
125
124
|
}
|
|
126
|
-
|
|
127
|
-
// Inset-Header: hostet den SidebarTrigger (öffnet das Mobile-Sheet / toggled
|
|
128
|
-
// die Rail) und einen Breadcrumb mit dem aktiven Screen. Höhe == Sidebar-
|
|
129
|
-
// Header (h-12), damit Rail und Panel oben bündig sind.
|
|
130
|
-
function ShellHeader({
|
|
131
|
-
schema,
|
|
132
|
-
user,
|
|
133
|
-
headerActions,
|
|
134
|
-
}: {
|
|
135
|
-
readonly schema: AppSchema | FeatureSchema;
|
|
136
|
-
readonly user?: DefaultAppShellUser;
|
|
137
|
-
readonly headerActions?: ReactNode;
|
|
138
|
-
}): ReactNode {
|
|
139
|
-
const nav = useNav();
|
|
140
|
-
const t = useTranslation();
|
|
141
|
-
const tree = useMemo(() => {
|
|
142
|
-
const source = buildNavRegistrySliceForApp(toAppSchema(schema));
|
|
143
|
-
return resolveNavigation({ source, ...(user !== undefined && { user }) });
|
|
144
|
-
}, [schema, user]);
|
|
145
|
-
|
|
146
|
-
const screenId = nav.route?.screenId;
|
|
147
|
-
const label = screenId !== undefined ? activeNavLabel(tree, screenId, (k) => t(k)) : undefined;
|
|
148
|
-
|
|
149
|
-
return (
|
|
150
|
-
<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">
|
|
151
|
-
<div className="flex items-center gap-2 px-4">
|
|
152
|
-
<SidebarTrigger className="-ml-1" />
|
|
153
|
-
<Separator orientation="vertical" className="mr-2 data-[orientation=vertical]:h-4" />
|
|
154
|
-
{label !== undefined && (
|
|
155
|
-
<Breadcrumb>
|
|
156
|
-
<BreadcrumbList>
|
|
157
|
-
<BreadcrumbItem>
|
|
158
|
-
<BreadcrumbPage>{label}</BreadcrumbPage>
|
|
159
|
-
</BreadcrumbItem>
|
|
160
|
-
</BreadcrumbList>
|
|
161
|
-
</Breadcrumb>
|
|
162
|
-
)}
|
|
163
|
-
</div>
|
|
164
|
-
{headerActions !== undefined && (
|
|
165
|
-
<div data-kumiko-layout="header-actions" className="ml-auto flex items-center gap-2 px-4">
|
|
166
|
-
{headerActions}
|
|
167
|
-
</div>
|
|
168
|
-
)}
|
|
169
|
-
</header>
|
|
170
|
-
);
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
function activeNavLabel(
|
|
174
|
-
nodes: readonly NavNode[],
|
|
175
|
-
screenId: string,
|
|
176
|
-
t: (key: string) => string,
|
|
177
|
-
): string | undefined {
|
|
178
|
-
for (const node of nodes) {
|
|
179
|
-
if (node.screen !== undefined && lastSegment(node.screen) === screenId) {
|
|
180
|
-
return node.label.includes(".") ? t(node.label) : node.label;
|
|
181
|
-
}
|
|
182
|
-
const child = activeNavLabel(node.children, screenId, t);
|
|
183
|
-
if (child !== undefined) return child;
|
|
184
|
-
}
|
|
185
|
-
return undefined;
|
|
186
|
-
}
|
package/src/layout/nav-tree.tsx
CHANGED
|
@@ -11,7 +11,12 @@
|
|
|
11
11
|
// auf/zu. State lebt lokal im NavTree (useState); Default expanded
|
|
12
12
|
// für alles, Caller kann später localStorage-Persistenz drüberlegen.
|
|
13
13
|
|
|
14
|
-
import type {
|
|
14
|
+
import type {
|
|
15
|
+
AccessRule,
|
|
16
|
+
TargetRef,
|
|
17
|
+
TreeAction,
|
|
18
|
+
TreeNode,
|
|
19
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
15
20
|
import type { NavDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
16
21
|
import type { NavNode, NavRegistrySlice } from "@cosmicdrift/kumiko-headless";
|
|
17
22
|
import { resolveNavigation } from "@cosmicdrift/kumiko-headless";
|
|
@@ -33,22 +38,34 @@ import {
|
|
|
33
38
|
ChevronRight,
|
|
34
39
|
Coins,
|
|
35
40
|
CreditCard,
|
|
41
|
+
Download,
|
|
36
42
|
FileText,
|
|
37
43
|
Folder,
|
|
44
|
+
FolderOpen,
|
|
38
45
|
Gauge,
|
|
46
|
+
Hash,
|
|
39
47
|
Home,
|
|
48
|
+
KeyRound,
|
|
40
49
|
Layers,
|
|
41
50
|
LayoutDashboard,
|
|
42
51
|
LineChart,
|
|
52
|
+
Link,
|
|
43
53
|
List,
|
|
54
|
+
Lock,
|
|
55
|
+
Mail,
|
|
56
|
+
Palette,
|
|
44
57
|
PiggyBank,
|
|
45
58
|
Plus,
|
|
46
59
|
Receipt,
|
|
60
|
+
Rocket,
|
|
47
61
|
Search,
|
|
62
|
+
Server,
|
|
48
63
|
Settings,
|
|
64
|
+
Share2,
|
|
49
65
|
Shield,
|
|
50
66
|
Sparkles,
|
|
51
67
|
Table,
|
|
68
|
+
Tag,
|
|
52
69
|
TrendingUp,
|
|
53
70
|
User,
|
|
54
71
|
Users,
|
|
@@ -71,6 +88,7 @@ import {
|
|
|
71
88
|
SidebarGroup,
|
|
72
89
|
SidebarGroupContent,
|
|
73
90
|
SidebarGroupLabel,
|
|
91
|
+
SidebarInput,
|
|
74
92
|
SidebarMenu,
|
|
75
93
|
SidebarMenuAction,
|
|
76
94
|
SidebarMenuButton,
|
|
@@ -108,6 +126,7 @@ const NAV_ICONS: Readonly<Record<string, typeof Folder>> = {
|
|
|
108
126
|
calendar: CalendarDays,
|
|
109
127
|
file: FileText,
|
|
110
128
|
folder: Folder,
|
|
129
|
+
"folder-open": FolderOpen,
|
|
111
130
|
home: Home,
|
|
112
131
|
bell: Bell,
|
|
113
132
|
shield: Shield,
|
|
@@ -115,6 +134,17 @@ const NAV_ICONS: Readonly<Record<string, typeof Folder>> = {
|
|
|
115
134
|
users: Users,
|
|
116
135
|
user: User,
|
|
117
136
|
search: Search,
|
|
137
|
+
tag: Tag,
|
|
138
|
+
key: KeyRound,
|
|
139
|
+
link: Link,
|
|
140
|
+
palette: Palette,
|
|
141
|
+
share: Share2,
|
|
142
|
+
server: Server,
|
|
143
|
+
mail: Mail,
|
|
144
|
+
lock: Lock,
|
|
145
|
+
hash: Hash,
|
|
146
|
+
download: Download,
|
|
147
|
+
rocket: Rocket,
|
|
118
148
|
};
|
|
119
149
|
|
|
120
150
|
export type NavTreeProps = {
|
|
@@ -139,6 +169,22 @@ export type NavTreeProps = {
|
|
|
139
169
|
const EMPTY_BADGES: ReadonlyMap<string, ReactNode> = new Map();
|
|
140
170
|
const NavBadgesContext = createContext<ReadonlyMap<string, ReactNode>>(EMPTY_BADGES);
|
|
141
171
|
|
|
172
|
+
// Sidebar-Filter: `q` leer = inaktiv (matches akzeptiert alles). `matches`
|
|
173
|
+
// bekommt das ROHE Label (i18n-Key oder Klartext) und übersetzt intern — so
|
|
174
|
+
// filtert das Suchfeld auf den sichtbaren Text, nicht den Key.
|
|
175
|
+
type NavFilter = { readonly q: string; readonly matches: (rawLabel: string) => boolean };
|
|
176
|
+
const NavFilterContext = createContext<NavFilter>({ q: "", matches: () => true });
|
|
177
|
+
|
|
178
|
+
// Ein Knoten überlebt den Filter, wenn er selbst oder ein Nachfahre matcht.
|
|
179
|
+
// Provider-Kinder sind zur Filterzeit schon materialisiert (Provider-Knoten
|
|
180
|
+
// sind default-expanded → eager geladen), darum reicht die statische
|
|
181
|
+
// children-Rekursion. ponytail: ein zur Filterzeit noch NICHT geladener
|
|
182
|
+
// (user-zugeklappter) Provider wird über den force-expand in useNavNodeState
|
|
183
|
+
// aufgeklappt, lädt und filtert dann mit.
|
|
184
|
+
function subtreeMatches(node: NavNode, matches: (rawLabel: string) => boolean): boolean {
|
|
185
|
+
return matches(node.label) || node.children.some((c) => subtreeMatches(c, matches));
|
|
186
|
+
}
|
|
187
|
+
|
|
142
188
|
export function NavTree({
|
|
143
189
|
schema,
|
|
144
190
|
user,
|
|
@@ -165,25 +211,47 @@ export function NavTree({
|
|
|
165
211
|
});
|
|
166
212
|
}, []);
|
|
167
213
|
|
|
214
|
+
const t = useTranslation();
|
|
215
|
+
const [filter, setFilter] = useState("");
|
|
216
|
+
const q = filter.trim().toLowerCase();
|
|
217
|
+
const matches = useCallback(
|
|
218
|
+
(raw: string): boolean => {
|
|
219
|
+
const label = raw.includes(".") ? t(raw) : raw;
|
|
220
|
+
return label.toLowerCase().includes(q);
|
|
221
|
+
},
|
|
222
|
+
[t, q],
|
|
223
|
+
);
|
|
224
|
+
const navFilter = useMemo<NavFilter>(() => ({ q, matches }), [q, matches]);
|
|
225
|
+
|
|
168
226
|
return (
|
|
169
|
-
<
|
|
170
|
-
<
|
|
171
|
-
{tree
|
|
172
|
-
|
|
173
|
-
<
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
227
|
+
<NavFilterContext.Provider value={navFilter}>
|
|
228
|
+
<NavBadgesContext.Provider value={navBadges ?? EMPTY_BADGES}>
|
|
229
|
+
<div data-testid={testId} data-kumiko-layout="nav-tree" className="flex w-full flex-col">
|
|
230
|
+
<div className="px-2 pt-2 pb-1">
|
|
231
|
+
<SidebarInput
|
|
232
|
+
value={filter}
|
|
233
|
+
onChange={(e) => setFilter(e.target.value)}
|
|
234
|
+
placeholder={t("kumiko.nav.search")}
|
|
235
|
+
aria-label={t("kumiko.nav.search")}
|
|
178
236
|
/>
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
237
|
+
</div>
|
|
238
|
+
{tree.map((node) =>
|
|
239
|
+
isPureSection(node) ? (
|
|
240
|
+
<NavSection
|
|
241
|
+
key={node.qualifiedName}
|
|
242
|
+
node={node}
|
|
243
|
+
collapsed={collapsed}
|
|
244
|
+
onToggle={onToggle}
|
|
245
|
+
/>
|
|
246
|
+
) : (
|
|
247
|
+
<SidebarMenu key={node.qualifiedName} className="px-2 py-1">
|
|
248
|
+
<NavMenuNode node={node} collapsed={collapsed} onToggle={onToggle} />
|
|
249
|
+
</SidebarMenu>
|
|
250
|
+
),
|
|
251
|
+
)}
|
|
252
|
+
</div>
|
|
253
|
+
</NavBadgesContext.Provider>
|
|
254
|
+
</NavFilterContext.Provider>
|
|
187
255
|
);
|
|
188
256
|
}
|
|
189
257
|
|
|
@@ -192,8 +260,14 @@ export function NavTree({
|
|
|
192
260
|
// schiebt ihn ans rechte Ende; das truncate-Label gibt nach.
|
|
193
261
|
// ponytail: nur an screen/target-Leaves verdrahtet, nicht an Container-
|
|
194
262
|
// Knoten (die tragen den ml-auto-Chevron) — Tier-Badge ist ein Leaf.
|
|
263
|
+
// Defense-in-depth (555/4): every current call site already guards on
|
|
264
|
+
// node.screen/node.target !== undefined before rendering <NavBadge>, so this
|
|
265
|
+
// early-return changes no current behavior — it's here so a future call
|
|
266
|
+
// site added without that guard can't silently show a leaf's badge on an
|
|
267
|
+
// unrelated container node that happens to share its last-segment.
|
|
195
268
|
function NavBadge({ node }: { readonly node: NavNode }): ReactNode {
|
|
196
269
|
const badges = useContext(NavBadgesContext);
|
|
270
|
+
if (node.screen === undefined && node.target === undefined) return null;
|
|
197
271
|
const badge = badges.get(lastSegment(node.qualifiedName));
|
|
198
272
|
if (badge === undefined) return null;
|
|
199
273
|
return <span className="ml-auto shrink-0">{badge}</span>;
|
|
@@ -229,8 +303,17 @@ function useLabel(node: NavNode): string {
|
|
|
229
303
|
|
|
230
304
|
// Icon-or-Dot: bekannter icon-Key → Lucide-Icon, sonst ein dezenter Dot.
|
|
231
305
|
// Aktiv = accent-foreground, inaktiv = gedimmt.
|
|
232
|
-
function NavLeadingIcon({
|
|
233
|
-
|
|
306
|
+
function NavLeadingIcon({
|
|
307
|
+
node,
|
|
308
|
+
active,
|
|
309
|
+
expanded = false,
|
|
310
|
+
}: {
|
|
311
|
+
node: NavNode;
|
|
312
|
+
active: boolean;
|
|
313
|
+
expanded?: boolean;
|
|
314
|
+
}): ReactNode {
|
|
315
|
+
const iconKey = expanded && node.icon === "folder" ? "folder-open" : node.icon;
|
|
316
|
+
const NavIcon = iconKey !== undefined ? NAV_ICONS[iconKey] : undefined;
|
|
234
317
|
if (NavIcon !== undefined) return <NavIcon aria-hidden="true" className="shrink-0" />;
|
|
235
318
|
return (
|
|
236
319
|
<span
|
|
@@ -350,6 +433,7 @@ type NavNodeState = {
|
|
|
350
433
|
readonly isExpanded: boolean;
|
|
351
434
|
readonly active: boolean;
|
|
352
435
|
readonly childNodes: readonly NavNode[];
|
|
436
|
+
readonly hidden: boolean;
|
|
353
437
|
readonly providerLoading: boolean;
|
|
354
438
|
readonly providerError: string | null;
|
|
355
439
|
readonly workspaceId: string | undefined;
|
|
@@ -361,10 +445,14 @@ type NavNodeState = {
|
|
|
361
445
|
// in den shadcn-Primitives, nicht in dieser Logik.
|
|
362
446
|
function useNavNodeState(node: NavNode, collapsed: ReadonlySet<string>): NavNodeState {
|
|
363
447
|
const nav = useNav();
|
|
448
|
+
const { q: filterQ, matches } = useContext(NavFilterContext);
|
|
449
|
+
const filterActive = filterQ !== "";
|
|
364
450
|
const displayLabel = useLabel(node);
|
|
365
451
|
const isCollapsed = collapsed.has(node.qualifiedName);
|
|
366
452
|
const expandable = node.children.length > 0 || node.provider === true;
|
|
367
|
-
|
|
453
|
+
// Aktiver Filter klappt jeden Container auf, damit Treffer unter zugeklappten
|
|
454
|
+
// Ordnern sichtbar werden (und Provider-Kinder nachladen).
|
|
455
|
+
const isExpanded = expandable && (!isCollapsed || filterActive);
|
|
368
456
|
const { nodes: providerChildren, error: providerError } = useNavProviderChildren(
|
|
369
457
|
node.qualifiedName,
|
|
370
458
|
node.provider === true && isExpanded,
|
|
@@ -377,13 +465,19 @@ function useNavNodeState(node: NavNode, collapsed: ReadonlySet<string>): NavNode
|
|
|
377
465
|
node.screen !== undefined && nav.route?.screenId === lastSegment(node.screen);
|
|
378
466
|
const targetActive = node.target !== undefined && targetsEqual(node.target, activeTarget);
|
|
379
467
|
const childNodes = node.provider === true ? (providerChildren ?? []) : node.children;
|
|
468
|
+
const visibleChildNodes = filterActive
|
|
469
|
+
? childNodes.filter((c) => subtreeMatches(c, matches))
|
|
470
|
+
: childNodes;
|
|
380
471
|
return {
|
|
381
472
|
displayLabel,
|
|
382
473
|
isCollapsed,
|
|
383
474
|
expandable,
|
|
384
475
|
isExpanded,
|
|
385
476
|
active: screenActive || targetActive,
|
|
386
|
-
childNodes,
|
|
477
|
+
childNodes: visibleChildNodes,
|
|
478
|
+
// Ein gefilterter Knoten verschwindet nur, wenn weder er selbst noch ein
|
|
479
|
+
// sichtbar gebliebenes Kind matcht — sonst bliebe ein leerer Ordner-Header.
|
|
480
|
+
hidden: filterActive && !matches(node.label) && visibleChildNodes.length === 0,
|
|
387
481
|
providerLoading: node.provider === true && isExpanded && providerChildren === null,
|
|
388
482
|
providerError,
|
|
389
483
|
workspaceId: nav.route?.workspaceId,
|
|
@@ -472,6 +566,16 @@ function ProviderStatus({
|
|
|
472
566
|
// gehört auf Items MIT children, nicht auf die Section selbst.
|
|
473
567
|
function NavSection({ node, collapsed, onToggle }: NavSubProps): ReactNode {
|
|
474
568
|
const displayLabel = useLabel(node);
|
|
569
|
+
const { q, matches } = useContext(NavFilterContext);
|
|
570
|
+
// Provider-Kinder (statisch leer bis geladen) halten die Section offen — ihr
|
|
571
|
+
// Match lässt sich hier nicht statisch prüfen; der Knoten selbst self-hidet.
|
|
572
|
+
if (
|
|
573
|
+
q !== "" &&
|
|
574
|
+
!matches(node.label) &&
|
|
575
|
+
!node.children.some((c) => c.provider === true || subtreeMatches(c, matches))
|
|
576
|
+
) {
|
|
577
|
+
return null;
|
|
578
|
+
}
|
|
475
579
|
return (
|
|
476
580
|
<SidebarGroup className="py-1">
|
|
477
581
|
<SidebarGroupLabel>{displayLabel}</SidebarGroupLabel>
|
|
@@ -502,6 +606,7 @@ function NavMenuNode({ node, collapsed, onToggle }: NavSubProps): ReactNode {
|
|
|
502
606
|
const t = useTranslation();
|
|
503
607
|
const dispatch = useDispatchTarget();
|
|
504
608
|
const s = useNavNodeState(node, collapsed);
|
|
609
|
+
if (s.hidden) return null;
|
|
505
610
|
|
|
506
611
|
const sub = s.isExpanded ? (
|
|
507
612
|
<SidebarMenuSub>
|
|
@@ -519,11 +624,11 @@ function NavMenuNode({ node, collapsed, onToggle }: NavSubProps): ReactNode {
|
|
|
519
624
|
|
|
520
625
|
const chevron = s.expandable ? (
|
|
521
626
|
<SidebarMenuAction
|
|
522
|
-
aria-label={t(s.
|
|
523
|
-
aria-expanded={
|
|
627
|
+
aria-label={t(s.isExpanded ? "kumiko.nav.collapse" : "kumiko.nav.expand")}
|
|
628
|
+
aria-expanded={s.isExpanded}
|
|
524
629
|
onClick={() => onToggle(node.qualifiedName)}
|
|
525
630
|
>
|
|
526
|
-
{s.
|
|
631
|
+
{s.isExpanded ? <ChevronDown /> : <ChevronRight />}
|
|
527
632
|
</SidebarMenuAction>
|
|
528
633
|
) : null;
|
|
529
634
|
|
|
@@ -575,15 +680,15 @@ function NavMenuNode({ node, collapsed, onToggle }: NavSubProps): ReactNode {
|
|
|
575
680
|
onClick={() => {
|
|
576
681
|
if (s.expandable) onToggle(node.qualifiedName);
|
|
577
682
|
}}
|
|
578
|
-
{...(s.expandable && { "aria-expanded":
|
|
683
|
+
{...(s.expandable && { "aria-expanded": s.isExpanded })}
|
|
579
684
|
>
|
|
580
|
-
<NavLeadingIcon node={node} active={false} />
|
|
685
|
+
<NavLeadingIcon node={node} active={false} expanded={s.isExpanded} />
|
|
581
686
|
<span className="truncate">{s.displayLabel}</span>
|
|
582
687
|
{s.expandable &&
|
|
583
|
-
(s.
|
|
584
|
-
<ChevronRight className="ml-auto" />
|
|
585
|
-
) : (
|
|
688
|
+
(s.isExpanded ? (
|
|
586
689
|
<ChevronDown className="ml-auto" />
|
|
690
|
+
) : (
|
|
691
|
+
<ChevronRight className="ml-auto" />
|
|
587
692
|
))}
|
|
588
693
|
</SidebarMenuButton>
|
|
589
694
|
<NodeActions node={node} />
|
|
@@ -601,9 +706,14 @@ function NavSubNode({ node, collapsed, onToggle }: NavSubProps): ReactNode {
|
|
|
601
706
|
const t = useTranslation();
|
|
602
707
|
const dispatch = useDispatchTarget();
|
|
603
708
|
const s = useNavNodeState(node, collapsed);
|
|
709
|
+
if (s.hidden) return null;
|
|
604
710
|
|
|
711
|
+
// Kinder in ein eigenes SidebarMenuSub (<ul>) — ein Sub-Item ist ein <li>,
|
|
712
|
+
// verschachtelte <li> ohne <ul> dazwischen sind invalides HTML (nested
|
|
713
|
+
// Content-Ordner: Marketing/ > Block). Der <ul> trägt zugleich die
|
|
714
|
+
// Einrück-/Border-Stufe pro Tiefe.
|
|
605
715
|
const deeper = s.isExpanded ? (
|
|
606
|
-
|
|
716
|
+
<SidebarMenuSub>
|
|
607
717
|
<ProviderStatus loading={s.providerLoading} error={s.providerError} />
|
|
608
718
|
{s.childNodes.map((child) => (
|
|
609
719
|
<NavSubNode
|
|
@@ -613,21 +723,21 @@ function NavSubNode({ node, collapsed, onToggle }: NavSubProps): ReactNode {
|
|
|
613
723
|
onToggle={onToggle}
|
|
614
724
|
/>
|
|
615
725
|
))}
|
|
616
|
-
|
|
726
|
+
</SidebarMenuSub>
|
|
617
727
|
) : null;
|
|
618
728
|
|
|
619
729
|
const chevron = s.expandable ? (
|
|
620
730
|
<button
|
|
621
731
|
type="button"
|
|
622
|
-
aria-label={t(s.
|
|
623
|
-
aria-expanded={
|
|
732
|
+
aria-label={t(s.isExpanded ? "kumiko.nav.collapse" : "kumiko.nav.expand")}
|
|
733
|
+
aria-expanded={s.isExpanded}
|
|
624
734
|
onClick={(e) => {
|
|
625
735
|
e.stopPropagation();
|
|
626
736
|
onToggle(node.qualifiedName);
|
|
627
737
|
}}
|
|
628
738
|
className="absolute top-1 right-1 flex size-5 items-center justify-center rounded-md text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
|
|
629
739
|
>
|
|
630
|
-
{s.
|
|
740
|
+
{s.isExpanded ? <ChevronDown className="size-3.5" /> : <ChevronRight className="size-3.5" />}
|
|
631
741
|
</button>
|
|
632
742
|
) : null;
|
|
633
743
|
|
|
@@ -679,7 +789,7 @@ function NavSubNode({ node, collapsed, onToggle }: NavSubProps): ReactNode {
|
|
|
679
789
|
if (s.expandable) onToggle(node.qualifiedName);
|
|
680
790
|
}}
|
|
681
791
|
>
|
|
682
|
-
<NavLeadingIcon node={node} active={false} />
|
|
792
|
+
<NavLeadingIcon node={node} active={false} expanded={s.isExpanded} />
|
|
683
793
|
<span className="truncate">{s.displayLabel}</span>
|
|
684
794
|
</button>
|
|
685
795
|
</SidebarMenuSubButton>
|
|
@@ -711,14 +821,24 @@ export function buildNavRegistrySliceForApp(
|
|
|
711
821
|
app: AppSchema,
|
|
712
822
|
allowedNavQns?: ReadonlySet<string>,
|
|
713
823
|
): NavRegistrySlice {
|
|
824
|
+
const screenAccessByQn = buildScreenAccessMap(app);
|
|
714
825
|
const qualified: NavDefinition[] = [];
|
|
715
826
|
for (const feature of app.features) {
|
|
716
827
|
for (const n of feature.navs ?? []) {
|
|
828
|
+
const screen =
|
|
829
|
+
n.screen !== undefined ? qualifyScreenId(feature.featureName, n.screen) : undefined;
|
|
830
|
+
// Nav entries without their own `access` inherit the referenced
|
|
831
|
+
// screen's access rule — otherwise a broader workspace/parent access
|
|
832
|
+
// lets a role see a nav entry whose target screen 403s them (#1099).
|
|
833
|
+
// An explicit `n.access` always wins; inheritance only fills the gap.
|
|
834
|
+
const inheritedAccess =
|
|
835
|
+
n.access ?? (screen !== undefined ? screenAccessByQn.get(screen) : undefined);
|
|
717
836
|
qualified.push({
|
|
718
837
|
...n,
|
|
719
838
|
id: qualifyNavId(feature.featureName, n.id),
|
|
720
839
|
...(n.parent !== undefined && { parent: qualifyNavId(feature.featureName, n.parent) }),
|
|
721
|
-
...(
|
|
840
|
+
...(screen !== undefined && { screen }),
|
|
841
|
+
...(inheritedAccess !== undefined && { access: inheritedAccess }),
|
|
722
842
|
});
|
|
723
843
|
}
|
|
724
844
|
}
|
|
@@ -758,6 +878,16 @@ function qualifyScreenId(feature: string, id: string): string {
|
|
|
758
878
|
return id.includes(":screen:") ? id : `${feature}:screen:${id}`;
|
|
759
879
|
}
|
|
760
880
|
|
|
881
|
+
function buildScreenAccessMap(app: AppSchema): Map<string, AccessRule | undefined> {
|
|
882
|
+
const map = new Map<string, AccessRule | undefined>();
|
|
883
|
+
for (const feature of app.features) {
|
|
884
|
+
for (const s of feature.screens) {
|
|
885
|
+
map.set(qualifyScreenId(feature.featureName, s.id), s.access);
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
return map;
|
|
889
|
+
}
|
|
890
|
+
|
|
761
891
|
// `lastSegment` lebt jetzt in @cosmicdrift/kumiko-renderer (./app/qn) — eine
|
|
762
892
|
// Quelle, beide Pakete teilen sie.
|
|
763
893
|
export { lastSegment };
|
|
@@ -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
|
+
}
|