@object-ui/layout 3.1.5 → 3.3.1
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/CHANGELOG.md +28 -0
- package/README.md +21 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +174 -152
- package/dist/index.umd.cjs +3 -3
- package/package.json +45 -10
- package/.turbo/turbo-build.log +0 -38
- package/src/AppSchemaRenderer.tsx +0 -480
- package/src/AppShell.tsx +0 -149
- package/src/NavigationRenderer.tsx +0 -746
- package/src/Page.tsx +0 -39
- package/src/PageCard.tsx +0 -12
- package/src/PageHeader.tsx +0 -35
- package/src/ResponsiveGrid.tsx +0 -118
- package/src/SidebarNav.tsx +0 -164
- package/src/__tests__/AppSchemaRenderer.test.tsx +0 -408
- package/src/__tests__/NavigationRenderer.test.tsx +0 -562
- package/src/index.ts +0 -96
- package/src/stories/AppShell.stories.tsx +0 -110
- package/src/stories/ResponsiveGrid.stories.tsx +0 -110
- package/src/stories/SidebarNav.stories.tsx +0 -223
- package/tsconfig.json +0 -9
- package/vite.config.ts +0 -38
- /package/dist/{layout → packages/layout}/src/AppSchemaRenderer.d.ts +0 -0
- /package/dist/{layout → packages/layout}/src/AppShell.d.ts +0 -0
- /package/dist/{layout → packages/layout}/src/NavigationRenderer.d.ts +0 -0
- /package/dist/{layout → packages/layout}/src/Page.d.ts +0 -0
- /package/dist/{layout → packages/layout}/src/PageCard.d.ts +0 -0
- /package/dist/{layout → packages/layout}/src/PageHeader.d.ts +0 -0
- /package/dist/{layout → packages/layout}/src/ResponsiveGrid.d.ts +0 -0
- /package/dist/{layout → packages/layout}/src/SidebarNav.d.ts +0 -0
- /package/dist/{layout → packages/layout}/src/index.d.ts +0 -0
|
@@ -1,746 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* ObjectUI
|
|
3
|
-
* Copyright (c) 2024-present ObjectStack Inc.
|
|
4
|
-
*
|
|
5
|
-
* This source code is licensed under the MIT license found in the
|
|
6
|
-
* LICENSE file in the root directory of this source tree.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* @object-ui/layout - Navigation Renderer
|
|
11
|
-
*
|
|
12
|
-
* Renders a `NavigationItem[]` tree from AppSchema JSON into a Shadcn sidebar.
|
|
13
|
-
* Supports all 7 navigation item types: object, dashboard, page, report,
|
|
14
|
-
* url, action, group — plus separators, badges, visibility expressions,
|
|
15
|
-
* and RBAC permission guards.
|
|
16
|
-
*
|
|
17
|
-
* Enhanced with:
|
|
18
|
-
* - Search filtering across navigation tree
|
|
19
|
-
* - Pin/favorite navigation items (pinned items in "Favorites" section)
|
|
20
|
-
* - Drag-to-reorder navigation items via @dnd-kit
|
|
21
|
-
*
|
|
22
|
-
* @module NavigationRenderer
|
|
23
|
-
*/
|
|
24
|
-
|
|
25
|
-
import React, { useState, useMemo } from 'react';
|
|
26
|
-
import { Link, useLocation } from 'react-router-dom';
|
|
27
|
-
import * as LucideIcons from 'lucide-react';
|
|
28
|
-
import {
|
|
29
|
-
DndContext,
|
|
30
|
-
closestCenter,
|
|
31
|
-
KeyboardSensor,
|
|
32
|
-
PointerSensor,
|
|
33
|
-
useSensor,
|
|
34
|
-
useSensors,
|
|
35
|
-
type DragEndEvent,
|
|
36
|
-
} from '@dnd-kit/core';
|
|
37
|
-
import {
|
|
38
|
-
SortableContext,
|
|
39
|
-
verticalListSortingStrategy,
|
|
40
|
-
useSortable,
|
|
41
|
-
arrayMove,
|
|
42
|
-
} from '@dnd-kit/sortable';
|
|
43
|
-
import { CSS } from '@dnd-kit/utilities';
|
|
44
|
-
import {
|
|
45
|
-
SidebarGroup,
|
|
46
|
-
SidebarGroupLabel,
|
|
47
|
-
SidebarGroupContent,
|
|
48
|
-
SidebarMenu,
|
|
49
|
-
SidebarMenuItem,
|
|
50
|
-
SidebarMenuButton,
|
|
51
|
-
SidebarMenuAction,
|
|
52
|
-
Collapsible,
|
|
53
|
-
CollapsibleTrigger,
|
|
54
|
-
CollapsibleContent,
|
|
55
|
-
Badge,
|
|
56
|
-
Separator,
|
|
57
|
-
} from '@object-ui/components';
|
|
58
|
-
import type { NavigationItem } from '@object-ui/types';
|
|
59
|
-
|
|
60
|
-
// ---------------------------------------------------------------------------
|
|
61
|
-
// Types
|
|
62
|
-
// ---------------------------------------------------------------------------
|
|
63
|
-
|
|
64
|
-
/**
|
|
65
|
-
* Callback to evaluate a visibility expression.
|
|
66
|
-
* Return `true` if the item should be visible.
|
|
67
|
-
* When not provided, all items default to visible.
|
|
68
|
-
*/
|
|
69
|
-
export type VisibilityEvaluator = (
|
|
70
|
-
expression: string | boolean | undefined,
|
|
71
|
-
) => boolean;
|
|
72
|
-
|
|
73
|
-
/**
|
|
74
|
-
* Callback to check whether the current user satisfies **all** of the
|
|
75
|
-
* given permission strings. Each string is opaque — the consumer decides
|
|
76
|
-
* the format (e.g. `"object:action"` or a named role).
|
|
77
|
-
* When not provided, all items default to permitted.
|
|
78
|
-
*/
|
|
79
|
-
export type PermissionChecker = (permissions: string[]) => boolean;
|
|
80
|
-
|
|
81
|
-
export interface NavigationRendererProps {
|
|
82
|
-
/** Navigation items to render */
|
|
83
|
-
items: NavigationItem[];
|
|
84
|
-
|
|
85
|
-
/**
|
|
86
|
-
* Base URL prefix prepended to generated hrefs.
|
|
87
|
-
* @example "/apps/crm"
|
|
88
|
-
*/
|
|
89
|
-
basePath?: string;
|
|
90
|
-
|
|
91
|
-
/** Optional visibility evaluator for `visible` expressions */
|
|
92
|
-
evaluateVisibility?: VisibilityEvaluator;
|
|
93
|
-
|
|
94
|
-
/** Optional permission checker for `requiredPermissions` */
|
|
95
|
-
checkPermission?: PermissionChecker;
|
|
96
|
-
|
|
97
|
-
/** Called when an `action`-type item is clicked */
|
|
98
|
-
onAction?: (item: NavigationItem) => void;
|
|
99
|
-
|
|
100
|
-
// --- P1.7 Navigation Enhancements ---
|
|
101
|
-
|
|
102
|
-
/** Search query to filter navigation items by label */
|
|
103
|
-
searchQuery?: string;
|
|
104
|
-
|
|
105
|
-
/** Enable pin/favorite toggle on navigation items */
|
|
106
|
-
enablePinning?: boolean;
|
|
107
|
-
|
|
108
|
-
/** Called when a navigation item is pinned or unpinned */
|
|
109
|
-
onPinToggle?: (itemId: string, pinned: boolean) => void;
|
|
110
|
-
|
|
111
|
-
/** Enable drag-to-reorder for navigation items */
|
|
112
|
-
enableReorder?: boolean;
|
|
113
|
-
|
|
114
|
-
/** Called when navigation items are reordered via drag */
|
|
115
|
-
onReorder?: (reorderedItems: NavigationItem[]) => void;
|
|
116
|
-
|
|
117
|
-
/**
|
|
118
|
-
* Optional label resolver for object-type navigation items.
|
|
119
|
-
* When provided, called with `(objectName, fallbackLabel)` for items
|
|
120
|
-
* where `item.type === 'object'` and `item.label` is a plain string.
|
|
121
|
-
* Enables convention-based i18n auto-resolution without coupling
|
|
122
|
-
* the layout package to i18n.
|
|
123
|
-
*/
|
|
124
|
-
resolveObjectLabel?: (objectName: string, fallbackLabel: string) => string;
|
|
125
|
-
|
|
126
|
-
/**
|
|
127
|
-
* Optional i18n translation function for resolving I18nLabel objects
|
|
128
|
-
* (`{ key, defaultValue }`). When provided, labels are translated
|
|
129
|
-
* through i18next; otherwise falls back to `defaultValue`.
|
|
130
|
-
*/
|
|
131
|
-
t?: (key: string, options?: any) => string;
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
// ---------------------------------------------------------------------------
|
|
135
|
-
// Icon Helper
|
|
136
|
-
// ---------------------------------------------------------------------------
|
|
137
|
-
|
|
138
|
-
const iconCache = new Map<string, React.ComponentType<any>>();
|
|
139
|
-
|
|
140
|
-
/**
|
|
141
|
-
* Resolve a Lucide icon component by name string.
|
|
142
|
-
* Supports PascalCase, camelCase, and kebab-case.
|
|
143
|
-
*/
|
|
144
|
-
export function resolveIcon(name?: string): React.ComponentType<any> {
|
|
145
|
-
if (!name) return LucideIcons.FileText;
|
|
146
|
-
|
|
147
|
-
const cached = iconCache.get(name);
|
|
148
|
-
if (cached) return cached;
|
|
149
|
-
|
|
150
|
-
// Direct match
|
|
151
|
-
if ((LucideIcons as any)[name]) {
|
|
152
|
-
iconCache.set(name, (LucideIcons as any)[name]);
|
|
153
|
-
return (LucideIcons as any)[name];
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
// kebab-case → PascalCase
|
|
157
|
-
const pascal = name
|
|
158
|
-
.split('-')
|
|
159
|
-
.filter(Boolean)
|
|
160
|
-
.map((p) => p.charAt(0).toUpperCase() + p.slice(1))
|
|
161
|
-
.join('');
|
|
162
|
-
if ((LucideIcons as any)[pascal]) {
|
|
163
|
-
iconCache.set(name, (LucideIcons as any)[pascal]);
|
|
164
|
-
return (LucideIcons as any)[pascal];
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
return LucideIcons.FileText;
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
// ---------------------------------------------------------------------------
|
|
171
|
-
// I18nLabel resolver
|
|
172
|
-
// ---------------------------------------------------------------------------
|
|
173
|
-
|
|
174
|
-
/**
|
|
175
|
-
* Resolve a NavigationItem label to a plain string.
|
|
176
|
-
* Handles both plain strings and I18nLabel objects { key, defaultValue }.
|
|
177
|
-
* When a `t` function is provided, I18nLabel objects are translated via i18next.
|
|
178
|
-
*/
|
|
179
|
-
export function resolveLabel(
|
|
180
|
-
label: string | { key: string; defaultValue?: string; params?: Record<string, any> },
|
|
181
|
-
t?: (key: string, options?: any) => string,
|
|
182
|
-
): string {
|
|
183
|
-
if (typeof label === 'string') return label;
|
|
184
|
-
if (t) {
|
|
185
|
-
const result = t(label.key, { defaultValue: label.defaultValue, ...label.params });
|
|
186
|
-
if (result && result !== label.key) return result;
|
|
187
|
-
}
|
|
188
|
-
return label.defaultValue || label.key;
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
/**
|
|
192
|
-
* Resolve a navigation item label, applying:
|
|
193
|
-
* 1. i18n translation for I18nLabel objects (when `t` is provided)
|
|
194
|
-
* 2. Convention-based i18n for object-type items with plain string labels
|
|
195
|
-
* (when `resolveObjectLabel` is provided)
|
|
196
|
-
*/
|
|
197
|
-
function resolveItemLabel(
|
|
198
|
-
item: NavigationItem,
|
|
199
|
-
resolver?: (objectName: string, fallbackLabel: string) => string,
|
|
200
|
-
t?: (key: string, options?: any) => string,
|
|
201
|
-
): string {
|
|
202
|
-
const base = resolveLabel(item.label, t);
|
|
203
|
-
// Only apply convention-based resolution for object-type items with plain string labels.
|
|
204
|
-
// I18nLabel objects (with explicit key/defaultValue) already have their own translation keys.
|
|
205
|
-
if (resolver && item.type === 'object' && item.objectName && typeof item.label === 'string') {
|
|
206
|
-
return resolver(item.objectName, base);
|
|
207
|
-
}
|
|
208
|
-
return base;
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
// ---------------------------------------------------------------------------
|
|
212
|
-
// Default evaluators (always-visible, always-permitted)
|
|
213
|
-
// ---------------------------------------------------------------------------
|
|
214
|
-
|
|
215
|
-
const defaultVisibility: VisibilityEvaluator = (expr) => {
|
|
216
|
-
if (expr === false || expr === 'false') return false;
|
|
217
|
-
return true;
|
|
218
|
-
};
|
|
219
|
-
|
|
220
|
-
const defaultPermission: PermissionChecker = () => true;
|
|
221
|
-
|
|
222
|
-
// ---------------------------------------------------------------------------
|
|
223
|
-
// Internal helper: resolve href from NavigationItem
|
|
224
|
-
// ---------------------------------------------------------------------------
|
|
225
|
-
|
|
226
|
-
function resolveHref(item: NavigationItem, basePath: string): { href: string; external: boolean } {
|
|
227
|
-
switch (item.type) {
|
|
228
|
-
case 'object': {
|
|
229
|
-
const objectPath = `${basePath}/${item.objectName ?? ''}`;
|
|
230
|
-
return { href: item.viewName ? `${objectPath}/view/${item.viewName}` : objectPath, external: false };
|
|
231
|
-
}
|
|
232
|
-
case 'dashboard':
|
|
233
|
-
return { href: item.dashboardName ? `${basePath}/dashboard/${item.dashboardName}` : '#', external: false };
|
|
234
|
-
case 'page':
|
|
235
|
-
return { href: item.pageName ? `${basePath}/page/${item.pageName}` : '#', external: false };
|
|
236
|
-
case 'report':
|
|
237
|
-
return { href: item.reportName ? `${basePath}/report/${item.reportName}` : '#', external: false };
|
|
238
|
-
case 'url':
|
|
239
|
-
return { href: item.url ?? '#', external: item.target === '_blank' };
|
|
240
|
-
default:
|
|
241
|
-
return { href: '#', external: false };
|
|
242
|
-
}
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
// ---------------------------------------------------------------------------
|
|
246
|
-
// Search filter helper
|
|
247
|
-
// ---------------------------------------------------------------------------
|
|
248
|
-
|
|
249
|
-
/**
|
|
250
|
-
* Recursively filter navigation items by search query (case-insensitive label match).
|
|
251
|
-
* Groups are kept if any child matches, with non-matching children pruned.
|
|
252
|
-
*/
|
|
253
|
-
export function filterNavigationItems(
|
|
254
|
-
items: NavigationItem[],
|
|
255
|
-
query: string,
|
|
256
|
-
): NavigationItem[] {
|
|
257
|
-
if (!query.trim()) return items;
|
|
258
|
-
const lowerQuery = query.toLowerCase().trim();
|
|
259
|
-
|
|
260
|
-
return items.reduce<NavigationItem[]>((acc, item) => {
|
|
261
|
-
// Separators are excluded during search
|
|
262
|
-
if (item.type === 'separator') return acc;
|
|
263
|
-
|
|
264
|
-
// Groups: recursively filter children
|
|
265
|
-
if (item.type === 'group' && item.children?.length) {
|
|
266
|
-
const filteredChildren = filterNavigationItems(item.children, query);
|
|
267
|
-
if (filteredChildren.length > 0) {
|
|
268
|
-
acc.push({ ...item, children: filteredChildren });
|
|
269
|
-
}
|
|
270
|
-
return acc;
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
// Leaf items: match label
|
|
274
|
-
if (resolveLabel(item.label).toLowerCase().includes(lowerQuery)) {
|
|
275
|
-
acc.push(item);
|
|
276
|
-
}
|
|
277
|
-
return acc;
|
|
278
|
-
}, []);
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
/** Minimum drag distance in pixels to activate reorder */
|
|
282
|
-
const DRAG_ACTIVATION_DISTANCE = 5;
|
|
283
|
-
|
|
284
|
-
// ---------------------------------------------------------------------------
|
|
285
|
-
// SortableNavigationItem (drag-reorder wrapper)
|
|
286
|
-
// ---------------------------------------------------------------------------
|
|
287
|
-
|
|
288
|
-
function SortableNavigationItem({
|
|
289
|
-
item,
|
|
290
|
-
basePath,
|
|
291
|
-
evalVis,
|
|
292
|
-
checkPerm,
|
|
293
|
-
onAction,
|
|
294
|
-
enablePinning,
|
|
295
|
-
onPinToggle,
|
|
296
|
-
enableReorder,
|
|
297
|
-
resolveObjectLabel,
|
|
298
|
-
t: tProp,
|
|
299
|
-
}: {
|
|
300
|
-
item: NavigationItem;
|
|
301
|
-
basePath: string;
|
|
302
|
-
evalVis: VisibilityEvaluator;
|
|
303
|
-
checkPerm: PermissionChecker;
|
|
304
|
-
onAction?: (item: NavigationItem) => void;
|
|
305
|
-
enablePinning?: boolean;
|
|
306
|
-
onPinToggle?: (itemId: string, pinned: boolean) => void;
|
|
307
|
-
enableReorder?: boolean;
|
|
308
|
-
resolveObjectLabel?: (objectName: string, fallbackLabel: string) => string;
|
|
309
|
-
t?: (key: string, options?: any) => string;
|
|
310
|
-
}) {
|
|
311
|
-
const {
|
|
312
|
-
attributes,
|
|
313
|
-
listeners,
|
|
314
|
-
setNodeRef,
|
|
315
|
-
transform,
|
|
316
|
-
transition,
|
|
317
|
-
isDragging,
|
|
318
|
-
} = useSortable({ id: item.id, disabled: !enableReorder });
|
|
319
|
-
|
|
320
|
-
const style: React.CSSProperties = {
|
|
321
|
-
transform: CSS.Transform.toString(transform),
|
|
322
|
-
transition,
|
|
323
|
-
opacity: isDragging ? 0.5 : undefined,
|
|
324
|
-
zIndex: isDragging ? 10 : undefined,
|
|
325
|
-
};
|
|
326
|
-
|
|
327
|
-
return (
|
|
328
|
-
<div ref={setNodeRef} style={style} {...attributes}>
|
|
329
|
-
<NavigationItemRenderer
|
|
330
|
-
item={item}
|
|
331
|
-
basePath={basePath}
|
|
332
|
-
evalVis={evalVis}
|
|
333
|
-
checkPerm={checkPerm}
|
|
334
|
-
onAction={onAction}
|
|
335
|
-
enablePinning={enablePinning}
|
|
336
|
-
onPinToggle={onPinToggle}
|
|
337
|
-
dragListeners={enableReorder ? listeners : undefined}
|
|
338
|
-
resolveObjectLabel={resolveObjectLabel}
|
|
339
|
-
t={tProp}
|
|
340
|
-
/>
|
|
341
|
-
</div>
|
|
342
|
-
);
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
// ---------------------------------------------------------------------------
|
|
346
|
-
// NavigationItemRenderer (recursive)
|
|
347
|
-
// ---------------------------------------------------------------------------
|
|
348
|
-
|
|
349
|
-
function NavigationItemRenderer({
|
|
350
|
-
item,
|
|
351
|
-
basePath,
|
|
352
|
-
evalVis,
|
|
353
|
-
checkPerm,
|
|
354
|
-
onAction,
|
|
355
|
-
enablePinning,
|
|
356
|
-
onPinToggle,
|
|
357
|
-
dragListeners,
|
|
358
|
-
resolveObjectLabel,
|
|
359
|
-
t: tProp,
|
|
360
|
-
}: {
|
|
361
|
-
item: NavigationItem;
|
|
362
|
-
basePath: string;
|
|
363
|
-
evalVis: VisibilityEvaluator;
|
|
364
|
-
checkPerm: PermissionChecker;
|
|
365
|
-
onAction?: (item: NavigationItem) => void;
|
|
366
|
-
enablePinning?: boolean;
|
|
367
|
-
onPinToggle?: (itemId: string, pinned: boolean) => void;
|
|
368
|
-
dragListeners?: Record<string, any>;
|
|
369
|
-
resolveObjectLabel?: (objectName: string, fallbackLabel: string) => string;
|
|
370
|
-
t?: (key: string, options?: any) => string;
|
|
371
|
-
}) {
|
|
372
|
-
const location = useLocation();
|
|
373
|
-
const [isOpen, setIsOpen] = useState(item.defaultOpen !== false);
|
|
374
|
-
|
|
375
|
-
// --- Visibility guard ---
|
|
376
|
-
if (!evalVis(item.visible)) return null;
|
|
377
|
-
|
|
378
|
-
// --- Permission guard ---
|
|
379
|
-
if (item.requiredPermissions?.length && !checkPerm(item.requiredPermissions)) return null;
|
|
380
|
-
|
|
381
|
-
// --- Separator ---
|
|
382
|
-
if (item.type === 'separator') {
|
|
383
|
-
return <Separator className="my-2" />;
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
// --- Group (collapsible) ---
|
|
387
|
-
if (item.type === 'group') {
|
|
388
|
-
const children = (item.children ?? [])
|
|
389
|
-
.slice()
|
|
390
|
-
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
|
|
391
|
-
|
|
392
|
-
return (
|
|
393
|
-
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
|
|
394
|
-
<SidebarGroup>
|
|
395
|
-
<SidebarGroupLabel asChild>
|
|
396
|
-
<CollapsibleTrigger className="flex w-full items-center justify-between">
|
|
397
|
-
{resolveLabel(item.label, tProp)}
|
|
398
|
-
<LucideIcons.ChevronRight
|
|
399
|
-
className={`ml-auto h-4 w-4 transition-transform ${isOpen ? 'rotate-90' : ''}`}
|
|
400
|
-
/>
|
|
401
|
-
</CollapsibleTrigger>
|
|
402
|
-
</SidebarGroupLabel>
|
|
403
|
-
<CollapsibleContent>
|
|
404
|
-
<SidebarGroupContent>
|
|
405
|
-
<SidebarMenu>
|
|
406
|
-
{children.map((child) => (
|
|
407
|
-
<NavigationItemRenderer
|
|
408
|
-
key={child.id}
|
|
409
|
-
item={child}
|
|
410
|
-
basePath={basePath}
|
|
411
|
-
evalVis={evalVis}
|
|
412
|
-
checkPerm={checkPerm}
|
|
413
|
-
onAction={onAction}
|
|
414
|
-
enablePinning={enablePinning}
|
|
415
|
-
onPinToggle={onPinToggle}
|
|
416
|
-
resolveObjectLabel={resolveObjectLabel}
|
|
417
|
-
t={tProp}
|
|
418
|
-
/>
|
|
419
|
-
))}
|
|
420
|
-
</SidebarMenu>
|
|
421
|
-
</SidebarGroupContent>
|
|
422
|
-
</CollapsibleContent>
|
|
423
|
-
</SidebarGroup>
|
|
424
|
-
</Collapsible>
|
|
425
|
-
);
|
|
426
|
-
}
|
|
427
|
-
|
|
428
|
-
// --- Action ---
|
|
429
|
-
if (item.type === 'action') {
|
|
430
|
-
const Icon = resolveIcon(item.icon);
|
|
431
|
-
return (
|
|
432
|
-
<SidebarMenuItem>
|
|
433
|
-
{dragListeners && (
|
|
434
|
-
<span className="absolute left-0.5 top-1/2 -translate-y-1/2 cursor-grab text-muted-foreground" aria-label="Drag to reorder" {...dragListeners}>
|
|
435
|
-
<LucideIcons.GripVertical className="h-3.5 w-3.5" />
|
|
436
|
-
</span>
|
|
437
|
-
)}
|
|
438
|
-
<SidebarMenuButton
|
|
439
|
-
tooltip={resolveLabel(item.label, tProp)}
|
|
440
|
-
onClick={() => onAction?.(item)}
|
|
441
|
-
>
|
|
442
|
-
<Icon className="h-4 w-4" />
|
|
443
|
-
<span>{resolveLabel(item.label, tProp)}</span>
|
|
444
|
-
{item.badge != null && (
|
|
445
|
-
<Badge variant={item.badgeVariant ?? 'default'} className="ml-auto text-[10px] px-1.5 py-0">
|
|
446
|
-
{item.badge}
|
|
447
|
-
</Badge>
|
|
448
|
-
)}
|
|
449
|
-
</SidebarMenuButton>
|
|
450
|
-
{enablePinning && onPinToggle && (
|
|
451
|
-
<SidebarMenuAction
|
|
452
|
-
showOnHover
|
|
453
|
-
onClick={() => onPinToggle(item.id, !item.pinned)}
|
|
454
|
-
aria-label={item.pinned ? `Unpin ${resolveLabel(item.label, tProp)}` : `Pin ${resolveLabel(item.label, tProp)}`}
|
|
455
|
-
>
|
|
456
|
-
{item.pinned ? (
|
|
457
|
-
<LucideIcons.PinOff className="h-3.5 w-3.5" />
|
|
458
|
-
) : (
|
|
459
|
-
<LucideIcons.Pin className="h-3.5 w-3.5" />
|
|
460
|
-
)}
|
|
461
|
-
</SidebarMenuAction>
|
|
462
|
-
)}
|
|
463
|
-
</SidebarMenuItem>
|
|
464
|
-
);
|
|
465
|
-
}
|
|
466
|
-
|
|
467
|
-
// --- Leaf items (object / dashboard / page / report / url) ---
|
|
468
|
-
const Icon = resolveIcon(item.icon);
|
|
469
|
-
const { href, external } = resolveHref(item, basePath);
|
|
470
|
-
const isActive = href !== '#' && location.pathname.startsWith(href);
|
|
471
|
-
const itemLabel = resolveItemLabel(item, resolveObjectLabel, tProp);
|
|
472
|
-
|
|
473
|
-
const content = (
|
|
474
|
-
<>
|
|
475
|
-
<Icon className="h-4 w-4" />
|
|
476
|
-
<span>{itemLabel}</span>
|
|
477
|
-
{item.badge != null && (
|
|
478
|
-
<Badge variant={item.badgeVariant ?? 'default'} className="ml-auto text-[10px] px-1.5 py-0">
|
|
479
|
-
{item.badge}
|
|
480
|
-
</Badge>
|
|
481
|
-
)}
|
|
482
|
-
</>
|
|
483
|
-
);
|
|
484
|
-
|
|
485
|
-
return (
|
|
486
|
-
<SidebarMenuItem>
|
|
487
|
-
{dragListeners && (
|
|
488
|
-
<span className="absolute left-0.5 top-1/2 -translate-y-1/2 cursor-grab text-muted-foreground" aria-label="Drag to reorder" {...dragListeners}>
|
|
489
|
-
<LucideIcons.GripVertical className="h-3.5 w-3.5" />
|
|
490
|
-
</span>
|
|
491
|
-
)}
|
|
492
|
-
<SidebarMenuButton asChild isActive={isActive} tooltip={itemLabel}>
|
|
493
|
-
{external ? (
|
|
494
|
-
<a href={href} target="_blank" rel="noopener noreferrer">
|
|
495
|
-
{content}
|
|
496
|
-
</a>
|
|
497
|
-
) : (
|
|
498
|
-
<Link to={href}>
|
|
499
|
-
{content}
|
|
500
|
-
</Link>
|
|
501
|
-
)}
|
|
502
|
-
</SidebarMenuButton>
|
|
503
|
-
{enablePinning && onPinToggle && (
|
|
504
|
-
<SidebarMenuAction
|
|
505
|
-
showOnHover
|
|
506
|
-
onClick={() => onPinToggle(item.id, !item.pinned)}
|
|
507
|
-
aria-label={item.pinned ? `Unpin ${itemLabel}` : `Pin ${itemLabel}`}
|
|
508
|
-
>
|
|
509
|
-
{item.pinned ? (
|
|
510
|
-
<LucideIcons.PinOff className="h-3.5 w-3.5" />
|
|
511
|
-
) : (
|
|
512
|
-
<LucideIcons.Pin className="h-3.5 w-3.5" />
|
|
513
|
-
)}
|
|
514
|
-
</SidebarMenuAction>
|
|
515
|
-
)}
|
|
516
|
-
</SidebarMenuItem>
|
|
517
|
-
);
|
|
518
|
-
}
|
|
519
|
-
|
|
520
|
-
// ---------------------------------------------------------------------------
|
|
521
|
-
// NavigationRenderer (main export)
|
|
522
|
-
// ---------------------------------------------------------------------------
|
|
523
|
-
|
|
524
|
-
/**
|
|
525
|
-
* Renders a `NavigationItem[]` tree into Shadcn Sidebar components.
|
|
526
|
-
*
|
|
527
|
-
* Features:
|
|
528
|
-
* - 7 navigation item types + separators
|
|
529
|
-
* - Nested collapsible groups
|
|
530
|
-
* - Badge indicators
|
|
531
|
-
* - Visibility expression evaluation
|
|
532
|
-
* - RBAC permission guards
|
|
533
|
-
* - Active-route highlighting
|
|
534
|
-
* - Search filtering across navigation tree
|
|
535
|
-
* - Pin/favorite items with dedicated "Favorites" section
|
|
536
|
-
* - Drag-to-reorder navigation items
|
|
537
|
-
*
|
|
538
|
-
* @example
|
|
539
|
-
* ```tsx
|
|
540
|
-
* <NavigationRenderer
|
|
541
|
-
* items={appSchema.navigation}
|
|
542
|
-
* basePath="/apps/crm"
|
|
543
|
-
* evaluateVisibility={(expr) => evaluateVisibility(expr, evaluator)}
|
|
544
|
-
* checkPermission={(perms) => perms.every(p => can(p))}
|
|
545
|
-
* searchQuery={searchTerm}
|
|
546
|
-
* enablePinning
|
|
547
|
-
* onPinToggle={(id, pinned) => updatePin(id, pinned)}
|
|
548
|
-
* enableReorder
|
|
549
|
-
* onReorder={(items) => saveOrder(items)}
|
|
550
|
-
* />
|
|
551
|
-
* ```
|
|
552
|
-
*/
|
|
553
|
-
export function NavigationRenderer({
|
|
554
|
-
items,
|
|
555
|
-
basePath = '',
|
|
556
|
-
evaluateVisibility: evalVis = defaultVisibility,
|
|
557
|
-
checkPermission: checkPerm = defaultPermission,
|
|
558
|
-
onAction,
|
|
559
|
-
searchQuery,
|
|
560
|
-
enablePinning,
|
|
561
|
-
onPinToggle,
|
|
562
|
-
enableReorder,
|
|
563
|
-
onReorder,
|
|
564
|
-
resolveObjectLabel,
|
|
565
|
-
t: tProp,
|
|
566
|
-
}: NavigationRendererProps) {
|
|
567
|
-
// --- Search filtering ---
|
|
568
|
-
const filteredItems = useMemo(
|
|
569
|
-
() => (searchQuery ? filterNavigationItems(items, searchQuery) : items),
|
|
570
|
-
[items, searchQuery],
|
|
571
|
-
);
|
|
572
|
-
|
|
573
|
-
// --- Pinned items (favorites section) ---
|
|
574
|
-
const pinnedItems = useMemo(
|
|
575
|
-
() => collectPinnedItems(filteredItems),
|
|
576
|
-
[filteredItems],
|
|
577
|
-
);
|
|
578
|
-
|
|
579
|
-
// --- Sort top-level items by order ---
|
|
580
|
-
const sorted = filteredItems.slice().sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
|
|
581
|
-
|
|
582
|
-
// --- Drag-reorder sensors ---
|
|
583
|
-
const sensors = useSensors(
|
|
584
|
-
useSensor(PointerSensor, { activationConstraint: { distance: DRAG_ACTIVATION_DISTANCE } }),
|
|
585
|
-
useSensor(KeyboardSensor),
|
|
586
|
-
);
|
|
587
|
-
|
|
588
|
-
const handleDragEnd = (event: DragEndEvent) => {
|
|
589
|
-
const { active, over } = event;
|
|
590
|
-
if (!over || active.id === over.id || !onReorder) return;
|
|
591
|
-
|
|
592
|
-
const oldIndex = sorted.findIndex((i) => i.id === active.id);
|
|
593
|
-
const newIndex = sorted.findIndex((i) => i.id === over.id);
|
|
594
|
-
if (oldIndex === -1 || newIndex === -1) return;
|
|
595
|
-
|
|
596
|
-
const reordered = arrayMove(sorted, oldIndex, newIndex).map((item, idx) => ({
|
|
597
|
-
...item,
|
|
598
|
-
order: idx,
|
|
599
|
-
}));
|
|
600
|
-
onReorder(reordered);
|
|
601
|
-
};
|
|
602
|
-
|
|
603
|
-
// --- Shared renderer props ---
|
|
604
|
-
const itemProps = {
|
|
605
|
-
basePath,
|
|
606
|
-
evalVis,
|
|
607
|
-
checkPerm,
|
|
608
|
-
onAction,
|
|
609
|
-
enablePinning,
|
|
610
|
-
onPinToggle,
|
|
611
|
-
resolveObjectLabel,
|
|
612
|
-
t: tProp,
|
|
613
|
-
};
|
|
614
|
-
|
|
615
|
-
const hasGroups = sorted.some((i) => i.type === 'group');
|
|
616
|
-
|
|
617
|
-
// --- Favorites section (pinned items) ---
|
|
618
|
-
const favoritesSection = pinnedItems.length > 0 && enablePinning ? (
|
|
619
|
-
<SidebarGroup>
|
|
620
|
-
<SidebarGroupLabel className="flex items-center gap-1.5">
|
|
621
|
-
<LucideIcons.Star className="h-3.5 w-3.5" />
|
|
622
|
-
Favorites
|
|
623
|
-
</SidebarGroupLabel>
|
|
624
|
-
<SidebarGroupContent>
|
|
625
|
-
<SidebarMenu>
|
|
626
|
-
{pinnedItems.map((item) => (
|
|
627
|
-
<NavigationItemRenderer
|
|
628
|
-
key={`fav-${item.id}`}
|
|
629
|
-
item={item}
|
|
630
|
-
{...itemProps}
|
|
631
|
-
/>
|
|
632
|
-
))}
|
|
633
|
-
</SidebarMenu>
|
|
634
|
-
</SidebarGroupContent>
|
|
635
|
-
</SidebarGroup>
|
|
636
|
-
) : null;
|
|
637
|
-
|
|
638
|
-
// --- No explicit groups → wrap in a single SidebarGroup ---
|
|
639
|
-
if (!hasGroups) {
|
|
640
|
-
const topLevelIds = sorted.filter((i) => i.type !== 'group').map((i) => i.id);
|
|
641
|
-
|
|
642
|
-
const menuContent = enableReorder ? (
|
|
643
|
-
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
|
644
|
-
<SortableContext items={topLevelIds} strategy={verticalListSortingStrategy}>
|
|
645
|
-
<SidebarMenu>
|
|
646
|
-
{sorted.map((item) => (
|
|
647
|
-
<SortableNavigationItem
|
|
648
|
-
key={item.id}
|
|
649
|
-
item={item}
|
|
650
|
-
enableReorder={enableReorder}
|
|
651
|
-
{...itemProps}
|
|
652
|
-
/>
|
|
653
|
-
))}
|
|
654
|
-
</SidebarMenu>
|
|
655
|
-
</SortableContext>
|
|
656
|
-
</DndContext>
|
|
657
|
-
) : (
|
|
658
|
-
<SidebarMenu>
|
|
659
|
-
{sorted.map((item) => (
|
|
660
|
-
<NavigationItemRenderer
|
|
661
|
-
key={item.id}
|
|
662
|
-
item={item}
|
|
663
|
-
{...itemProps}
|
|
664
|
-
/>
|
|
665
|
-
))}
|
|
666
|
-
</SidebarMenu>
|
|
667
|
-
);
|
|
668
|
-
|
|
669
|
-
return (
|
|
670
|
-
<>
|
|
671
|
-
{favoritesSection}
|
|
672
|
-
<SidebarGroup>
|
|
673
|
-
<SidebarGroupContent>
|
|
674
|
-
{menuContent}
|
|
675
|
-
</SidebarGroupContent>
|
|
676
|
-
</SidebarGroup>
|
|
677
|
-
</>
|
|
678
|
-
);
|
|
679
|
-
}
|
|
680
|
-
|
|
681
|
-
// Mixed content: render groups inline, wrap consecutive leaf items
|
|
682
|
-
const fragments: React.ReactNode[] = [];
|
|
683
|
-
let leafBuffer: NavigationItem[] = [];
|
|
684
|
-
|
|
685
|
-
const flushLeaves = (key: string) => {
|
|
686
|
-
if (leafBuffer.length === 0) return;
|
|
687
|
-
const leaves = leafBuffer;
|
|
688
|
-
leafBuffer = [];
|
|
689
|
-
fragments.push(
|
|
690
|
-
<SidebarGroup key={key}>
|
|
691
|
-
<SidebarGroupContent>
|
|
692
|
-
<SidebarMenu>
|
|
693
|
-
{leaves.map((item) => (
|
|
694
|
-
<NavigationItemRenderer
|
|
695
|
-
key={item.id}
|
|
696
|
-
item={item}
|
|
697
|
-
{...itemProps}
|
|
698
|
-
/>
|
|
699
|
-
))}
|
|
700
|
-
</SidebarMenu>
|
|
701
|
-
</SidebarGroupContent>
|
|
702
|
-
</SidebarGroup>,
|
|
703
|
-
);
|
|
704
|
-
};
|
|
705
|
-
|
|
706
|
-
sorted.forEach((item, idx) => {
|
|
707
|
-
if (item.type === 'group') {
|
|
708
|
-
flushLeaves(`leaf-${idx}`);
|
|
709
|
-
fragments.push(
|
|
710
|
-
<NavigationItemRenderer
|
|
711
|
-
key={item.id}
|
|
712
|
-
item={item}
|
|
713
|
-
{...itemProps}
|
|
714
|
-
/>,
|
|
715
|
-
);
|
|
716
|
-
} else {
|
|
717
|
-
leafBuffer.push(item);
|
|
718
|
-
}
|
|
719
|
-
});
|
|
720
|
-
|
|
721
|
-
flushLeaves('leaf-end');
|
|
722
|
-
|
|
723
|
-
return (
|
|
724
|
-
<>
|
|
725
|
-
{favoritesSection}
|
|
726
|
-
{fragments}
|
|
727
|
-
</>
|
|
728
|
-
);
|
|
729
|
-
}
|
|
730
|
-
|
|
731
|
-
// ---------------------------------------------------------------------------
|
|
732
|
-
// Helper: collect all pinned items (leaf-only) from a navigation tree
|
|
733
|
-
// ---------------------------------------------------------------------------
|
|
734
|
-
|
|
735
|
-
function collectPinnedItems(items: NavigationItem[]): NavigationItem[] {
|
|
736
|
-
const pinned: NavigationItem[] = [];
|
|
737
|
-
for (const item of items) {
|
|
738
|
-
if (item.pinned && item.type !== 'group' && item.type !== 'separator') {
|
|
739
|
-
pinned.push(item);
|
|
740
|
-
}
|
|
741
|
-
if (item.children?.length) {
|
|
742
|
-
pinned.push(...collectPinnedItems(item.children));
|
|
743
|
-
}
|
|
744
|
-
}
|
|
745
|
-
return pinned;
|
|
746
|
-
}
|