@object-ui/layout 3.0.3 → 3.1.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/.turbo/turbo-build.log +11 -8
- package/dist/index.js +3760 -299
- package/dist/index.umd.cjs +6 -2
- package/dist/layout/src/AppSchemaRenderer.d.ts +68 -0
- package/dist/layout/src/NavigationRenderer.d.ts +104 -0
- package/dist/layout/src/SidebarNav.d.ts +11 -2
- package/dist/layout/src/index.d.ts +2 -0
- package/package.json +11 -8
- package/src/AppSchemaRenderer.tsx +480 -0
- package/src/AppShell.tsx +1 -1
- package/src/NavigationRenderer.tsx +746 -0
- package/src/SidebarNav.tsx +130 -19
- package/src/__tests__/AppSchemaRenderer.test.tsx +408 -0
- package/src/__tests__/NavigationRenderer.test.tsx +562 -0
- package/src/index.ts +26 -0
- package/src/stories/SidebarNav.stories.tsx +223 -0
|
@@ -0,0 +1,562 @@
|
|
|
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
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
10
|
+
import React from 'react';
|
|
11
|
+
import { render, screen, fireEvent } from '@testing-library/react';
|
|
12
|
+
import { MemoryRouter } from 'react-router-dom';
|
|
13
|
+
import type { NavigationItem } from '@object-ui/types';
|
|
14
|
+
import { NavigationRenderer, filterNavigationItems } from '../NavigationRenderer';
|
|
15
|
+
import { SidebarProvider } from '@object-ui/components';
|
|
16
|
+
|
|
17
|
+
/** Wrap component in required providers */
|
|
18
|
+
function renderNav(
|
|
19
|
+
items: NavigationItem[],
|
|
20
|
+
props: Partial<React.ComponentProps<typeof NavigationRenderer>> = {},
|
|
21
|
+
initialEntries: string[] = ['/'],
|
|
22
|
+
) {
|
|
23
|
+
return render(
|
|
24
|
+
<MemoryRouter initialEntries={initialEntries}>
|
|
25
|
+
<SidebarProvider defaultOpen={true}>
|
|
26
|
+
<NavigationRenderer items={items} basePath="/apps/test" {...props} />
|
|
27
|
+
</SidebarProvider>
|
|
28
|
+
</MemoryRouter>,
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
// Fixtures
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
const objectItem: NavigationItem = {
|
|
37
|
+
id: 'nav-accounts',
|
|
38
|
+
type: 'object',
|
|
39
|
+
label: 'Accounts',
|
|
40
|
+
icon: 'Users',
|
|
41
|
+
objectName: 'account',
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const dashboardItem: NavigationItem = {
|
|
45
|
+
id: 'nav-dash',
|
|
46
|
+
type: 'dashboard',
|
|
47
|
+
label: 'Sales Dashboard',
|
|
48
|
+
icon: 'LayoutDashboard',
|
|
49
|
+
dashboardName: 'sales-overview',
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const pageItem: NavigationItem = {
|
|
53
|
+
id: 'nav-page',
|
|
54
|
+
type: 'page',
|
|
55
|
+
label: 'Home Page',
|
|
56
|
+
icon: 'Home',
|
|
57
|
+
pageName: 'home',
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const reportItem: NavigationItem = {
|
|
61
|
+
id: 'nav-report',
|
|
62
|
+
type: 'report',
|
|
63
|
+
label: 'Monthly Report',
|
|
64
|
+
icon: 'BarChart',
|
|
65
|
+
reportName: 'monthly',
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const urlItem: NavigationItem = {
|
|
69
|
+
id: 'nav-url',
|
|
70
|
+
type: 'url',
|
|
71
|
+
label: 'Documentation',
|
|
72
|
+
icon: 'ExternalLink',
|
|
73
|
+
url: 'https://docs.example.com',
|
|
74
|
+
target: '_blank',
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const actionItem: NavigationItem = {
|
|
78
|
+
id: 'nav-action',
|
|
79
|
+
type: 'action',
|
|
80
|
+
label: 'Create Record',
|
|
81
|
+
icon: 'Plus',
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const groupItem: NavigationItem = {
|
|
85
|
+
id: 'nav-group',
|
|
86
|
+
type: 'group',
|
|
87
|
+
label: 'Sales',
|
|
88
|
+
icon: 'Briefcase',
|
|
89
|
+
children: [objectItem, dashboardItem],
|
|
90
|
+
defaultOpen: true,
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const separatorItem: NavigationItem = {
|
|
94
|
+
id: 'nav-sep',
|
|
95
|
+
type: 'separator',
|
|
96
|
+
label: '',
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
// Tests
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
describe('NavigationRenderer', () => {
|
|
104
|
+
// --- Basic rendering of 7 navigation types ---
|
|
105
|
+
|
|
106
|
+
it('renders object navigation item as link', () => {
|
|
107
|
+
renderNav([objectItem]);
|
|
108
|
+
expect(screen.getByText('Accounts')).toBeTruthy();
|
|
109
|
+
const link = screen.getByText('Accounts').closest('a');
|
|
110
|
+
expect(link?.getAttribute('href')).toBe('/apps/test/account');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('renders object navigation item with viewName in href', () => {
|
|
114
|
+
const calendarItem: NavigationItem = {
|
|
115
|
+
id: 'nav-calendar',
|
|
116
|
+
type: 'object',
|
|
117
|
+
label: 'Calendar',
|
|
118
|
+
icon: 'Calendar',
|
|
119
|
+
objectName: 'event',
|
|
120
|
+
viewName: 'calendar',
|
|
121
|
+
};
|
|
122
|
+
renderNav([calendarItem]);
|
|
123
|
+
const link = screen.getByText('Calendar').closest('a');
|
|
124
|
+
expect(link?.getAttribute('href')).toBe('/apps/test/event/view/calendar');
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('renders dashboard navigation item', () => {
|
|
128
|
+
renderNav([dashboardItem]);
|
|
129
|
+
expect(screen.getByText('Sales Dashboard')).toBeTruthy();
|
|
130
|
+
const link = screen.getByText('Sales Dashboard').closest('a');
|
|
131
|
+
expect(link?.getAttribute('href')).toBe('/apps/test/dashboard/sales-overview');
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('renders page navigation item', () => {
|
|
135
|
+
renderNav([pageItem]);
|
|
136
|
+
expect(screen.getByText('Home Page')).toBeTruthy();
|
|
137
|
+
const link = screen.getByText('Home Page').closest('a');
|
|
138
|
+
expect(link?.getAttribute('href')).toBe('/apps/test/page/home');
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('renders report navigation item', () => {
|
|
142
|
+
renderNav([reportItem]);
|
|
143
|
+
expect(screen.getByText('Monthly Report')).toBeTruthy();
|
|
144
|
+
const link = screen.getByText('Monthly Report').closest('a');
|
|
145
|
+
expect(link?.getAttribute('href')).toBe('/apps/test/report/monthly');
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('renders url navigation item as external link', () => {
|
|
149
|
+
renderNav([urlItem]);
|
|
150
|
+
expect(screen.getByText('Documentation')).toBeTruthy();
|
|
151
|
+
const link = screen.getByText('Documentation').closest('a');
|
|
152
|
+
expect(link?.getAttribute('href')).toBe('https://docs.example.com');
|
|
153
|
+
expect(link?.getAttribute('target')).toBe('_blank');
|
|
154
|
+
expect(link?.getAttribute('rel')).toContain('noopener');
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('renders action navigation item as button', () => {
|
|
158
|
+
const onAction = vi.fn();
|
|
159
|
+
renderNav([actionItem], { onAction });
|
|
160
|
+
expect(screen.getByText('Create Record')).toBeTruthy();
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('renders group with children', () => {
|
|
164
|
+
renderNav([groupItem]);
|
|
165
|
+
expect(screen.getByText('Sales')).toBeTruthy();
|
|
166
|
+
expect(screen.getByText('Accounts')).toBeTruthy();
|
|
167
|
+
expect(screen.getByText('Sales Dashboard')).toBeTruthy();
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('renders separator', () => {
|
|
171
|
+
const { container } = renderNav([objectItem, separatorItem, pageItem]);
|
|
172
|
+
expect(screen.getByText('Accounts')).toBeTruthy();
|
|
173
|
+
expect(screen.getByText('Home Page')).toBeTruthy();
|
|
174
|
+
// Separator is rendered as a Separator component (role="none" or hr)
|
|
175
|
+
const separators = container.querySelectorAll('[role="none"], hr, [data-orientation]');
|
|
176
|
+
expect(separators.length).toBeGreaterThan(0);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
// --- Visibility guards ---
|
|
180
|
+
|
|
181
|
+
it('hides items with visible=false', () => {
|
|
182
|
+
const hiddenItem: NavigationItem = {
|
|
183
|
+
...objectItem,
|
|
184
|
+
id: 'nav-hidden',
|
|
185
|
+
visible: false,
|
|
186
|
+
};
|
|
187
|
+
renderNav([hiddenItem]);
|
|
188
|
+
expect(screen.queryByText('Accounts')).toBeNull();
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('shows items with visible=true', () => {
|
|
192
|
+
const visibleItem: NavigationItem = {
|
|
193
|
+
...objectItem,
|
|
194
|
+
id: 'nav-visible',
|
|
195
|
+
visible: true,
|
|
196
|
+
};
|
|
197
|
+
renderNav([visibleItem]);
|
|
198
|
+
expect(screen.getByText('Accounts')).toBeTruthy();
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('evaluates visibility expression via callback', () => {
|
|
202
|
+
const item: NavigationItem = {
|
|
203
|
+
...objectItem,
|
|
204
|
+
id: 'nav-expr',
|
|
205
|
+
visible: '${user.isAdmin}',
|
|
206
|
+
};
|
|
207
|
+
const evalVis = vi.fn().mockReturnValue(false);
|
|
208
|
+
renderNav([item], { evaluateVisibility: evalVis });
|
|
209
|
+
expect(evalVis).toHaveBeenCalledWith('${user.isAdmin}');
|
|
210
|
+
expect(screen.queryByText('Accounts')).toBeNull();
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
// --- Permission guards ---
|
|
214
|
+
|
|
215
|
+
it('hides items when permission check fails', () => {
|
|
216
|
+
const item: NavigationItem = {
|
|
217
|
+
...objectItem,
|
|
218
|
+
id: 'nav-perm',
|
|
219
|
+
requiredPermissions: ['account:read'],
|
|
220
|
+
};
|
|
221
|
+
const checkPerm = vi.fn().mockReturnValue(false);
|
|
222
|
+
renderNav([item], { checkPermission: checkPerm });
|
|
223
|
+
expect(checkPerm).toHaveBeenCalledWith(['account:read']);
|
|
224
|
+
expect(screen.queryByText('Accounts')).toBeNull();
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('shows items when permission check passes', () => {
|
|
228
|
+
const item: NavigationItem = {
|
|
229
|
+
...objectItem,
|
|
230
|
+
id: 'nav-perm-ok',
|
|
231
|
+
requiredPermissions: ['account:read'],
|
|
232
|
+
};
|
|
233
|
+
const checkPerm = vi.fn().mockReturnValue(true);
|
|
234
|
+
renderNav([item], { checkPermission: checkPerm });
|
|
235
|
+
expect(screen.getByText('Accounts')).toBeTruthy();
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it('shows all items when no permission checker is provided', () => {
|
|
239
|
+
const item: NavigationItem = {
|
|
240
|
+
...objectItem,
|
|
241
|
+
id: 'nav-no-checker',
|
|
242
|
+
requiredPermissions: ['account:admin'],
|
|
243
|
+
};
|
|
244
|
+
renderNav([item]);
|
|
245
|
+
expect(screen.getByText('Accounts')).toBeTruthy();
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
// --- Badges ---
|
|
249
|
+
|
|
250
|
+
it('renders badge on navigation items', () => {
|
|
251
|
+
const item: NavigationItem = {
|
|
252
|
+
...objectItem,
|
|
253
|
+
id: 'nav-badge',
|
|
254
|
+
badge: 42,
|
|
255
|
+
};
|
|
256
|
+
renderNav([item]);
|
|
257
|
+
expect(screen.getByText('42')).toBeTruthy();
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it('renders string badge', () => {
|
|
261
|
+
const item: NavigationItem = {
|
|
262
|
+
...objectItem,
|
|
263
|
+
id: 'nav-badge-str',
|
|
264
|
+
badge: 'New',
|
|
265
|
+
};
|
|
266
|
+
renderNav([item]);
|
|
267
|
+
expect(screen.getByText('New')).toBeTruthy();
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
// --- Ordering ---
|
|
271
|
+
|
|
272
|
+
it('sorts items by order field', () => {
|
|
273
|
+
const items: NavigationItem[] = [
|
|
274
|
+
{ ...objectItem, id: 'b', label: 'B', order: 2 },
|
|
275
|
+
{ ...pageItem, id: 'a', label: 'A', order: 1 },
|
|
276
|
+
{ ...reportItem, id: 'c', label: 'C', order: 3 },
|
|
277
|
+
];
|
|
278
|
+
const { container } = renderNav(items);
|
|
279
|
+
const labels = Array.from(container.querySelectorAll('span'))
|
|
280
|
+
.map((el) => el.textContent)
|
|
281
|
+
.filter((t) => ['A', 'B', 'C'].includes(t ?? ''));
|
|
282
|
+
expect(labels).toEqual(['A', 'B', 'C']);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
// --- Mixed groups and leaf items ---
|
|
286
|
+
|
|
287
|
+
it('renders mixed groups and leaf items correctly', () => {
|
|
288
|
+
const items: NavigationItem[] = [
|
|
289
|
+
pageItem,
|
|
290
|
+
groupItem,
|
|
291
|
+
reportItem,
|
|
292
|
+
];
|
|
293
|
+
renderNav(items);
|
|
294
|
+
expect(screen.getByText('Home Page')).toBeTruthy();
|
|
295
|
+
expect(screen.getByText('Sales')).toBeTruthy();
|
|
296
|
+
expect(screen.getByText('Monthly Report')).toBeTruthy();
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
// --- Active route highlighting ---
|
|
300
|
+
|
|
301
|
+
it('highlights active route', () => {
|
|
302
|
+
renderNav([objectItem], {}, ['/apps/test/account']);
|
|
303
|
+
const link = screen.getByText('Accounts').closest('a');
|
|
304
|
+
// The parent button should have data-active="true" set by SidebarMenuButton
|
|
305
|
+
const button = link?.closest('[data-active]');
|
|
306
|
+
expect(button?.getAttribute('data-active')).toBe('true');
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
// --- Empty items ---
|
|
310
|
+
|
|
311
|
+
it('renders nothing for empty items array', () => {
|
|
312
|
+
const { container } = renderNav([]);
|
|
313
|
+
// Should render the wrapper SidebarGroup but no menu items
|
|
314
|
+
expect(container.querySelectorAll('a').length).toBe(0);
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
// --- P1.7: Search filtering ---
|
|
318
|
+
|
|
319
|
+
describe('search filtering', () => {
|
|
320
|
+
it('filters items by search query (case-insensitive)', () => {
|
|
321
|
+
const items: NavigationItem[] = [
|
|
322
|
+
{ ...objectItem, id: 'n1', label: 'Accounts' },
|
|
323
|
+
{ ...pageItem, id: 'n2', label: 'Settings' },
|
|
324
|
+
{ ...reportItem, id: 'n3', label: 'Account Report' },
|
|
325
|
+
];
|
|
326
|
+
renderNav(items, { searchQuery: 'account' });
|
|
327
|
+
expect(screen.getByText('Accounts')).toBeTruthy();
|
|
328
|
+
expect(screen.getByText('Account Report')).toBeTruthy();
|
|
329
|
+
expect(screen.queryByText('Settings')).toBeNull();
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it('shows all items when search query is empty', () => {
|
|
333
|
+
const items: NavigationItem[] = [
|
|
334
|
+
{ ...objectItem, id: 'n1', label: 'Accounts' },
|
|
335
|
+
{ ...pageItem, id: 'n2', label: 'Settings' },
|
|
336
|
+
];
|
|
337
|
+
renderNav(items, { searchQuery: '' });
|
|
338
|
+
expect(screen.getByText('Accounts')).toBeTruthy();
|
|
339
|
+
expect(screen.getByText('Settings')).toBeTruthy();
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
it('filters group children and keeps group if any child matches', () => {
|
|
343
|
+
const group: NavigationItem = {
|
|
344
|
+
id: 'g1',
|
|
345
|
+
type: 'group',
|
|
346
|
+
label: 'Sales',
|
|
347
|
+
children: [
|
|
348
|
+
{ id: 'c1', type: 'object', label: 'Opportunities', objectName: 'opp' },
|
|
349
|
+
{ id: 'c2', type: 'object', label: 'Contacts', objectName: 'contact' },
|
|
350
|
+
],
|
|
351
|
+
defaultOpen: true,
|
|
352
|
+
};
|
|
353
|
+
renderNav([group], { searchQuery: 'oppo' });
|
|
354
|
+
expect(screen.getByText('Sales')).toBeTruthy();
|
|
355
|
+
expect(screen.getByText('Opportunities')).toBeTruthy();
|
|
356
|
+
expect(screen.queryByText('Contacts')).toBeNull();
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
it('removes group entirely if no children match', () => {
|
|
360
|
+
const group: NavigationItem = {
|
|
361
|
+
id: 'g1',
|
|
362
|
+
type: 'group',
|
|
363
|
+
label: 'Sales',
|
|
364
|
+
children: [
|
|
365
|
+
{ id: 'c1', type: 'object', label: 'Opportunities', objectName: 'opp' },
|
|
366
|
+
],
|
|
367
|
+
defaultOpen: true,
|
|
368
|
+
};
|
|
369
|
+
renderNav([group], { searchQuery: 'zzz' });
|
|
370
|
+
expect(screen.queryByText('Sales')).toBeNull();
|
|
371
|
+
expect(screen.queryByText('Opportunities')).toBeNull();
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
it('excludes separators during search', () => {
|
|
375
|
+
const items: NavigationItem[] = [
|
|
376
|
+
{ ...objectItem, id: 'n1', label: 'Accounts' },
|
|
377
|
+
separatorItem,
|
|
378
|
+
{ ...pageItem, id: 'n2', label: 'Settings' },
|
|
379
|
+
];
|
|
380
|
+
const { container } = renderNav(items, { searchQuery: 'acc' });
|
|
381
|
+
expect(screen.getByText('Accounts')).toBeTruthy();
|
|
382
|
+
expect(screen.queryByText('Settings')).toBeNull();
|
|
383
|
+
});
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
// --- P1.7: Pin/Favorites ---
|
|
387
|
+
|
|
388
|
+
describe('pin favorites', () => {
|
|
389
|
+
it('renders pin button when enablePinning is true', () => {
|
|
390
|
+
renderNav([objectItem], { enablePinning: true, onPinToggle: vi.fn() });
|
|
391
|
+
expect(screen.getByLabelText('Pin Accounts')).toBeTruthy();
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
it('does not render pin button when enablePinning is false', () => {
|
|
395
|
+
renderNav([objectItem]);
|
|
396
|
+
expect(screen.queryByLabelText('Pin Accounts')).toBeNull();
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
it('calls onPinToggle with correct arguments when pin is clicked', () => {
|
|
400
|
+
const onPinToggle = vi.fn();
|
|
401
|
+
renderNav([objectItem], { enablePinning: true, onPinToggle });
|
|
402
|
+
fireEvent.click(screen.getByLabelText('Pin Accounts'));
|
|
403
|
+
expect(onPinToggle).toHaveBeenCalledWith('nav-accounts', true);
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
it('renders unpin button for pinned items', () => {
|
|
407
|
+
const pinnedItem: NavigationItem = {
|
|
408
|
+
...objectItem,
|
|
409
|
+
id: 'nav-pinned',
|
|
410
|
+
pinned: true,
|
|
411
|
+
};
|
|
412
|
+
renderNav([pinnedItem], { enablePinning: true, onPinToggle: vi.fn() });
|
|
413
|
+
// Pinned item appears in both Favorites and main nav, so use getAllBy
|
|
414
|
+
const unpinButtons = screen.getAllByLabelText('Unpin Accounts');
|
|
415
|
+
expect(unpinButtons.length).toBeGreaterThanOrEqual(1);
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
it('calls onPinToggle with false when unpinning', () => {
|
|
419
|
+
const onPinToggle = vi.fn();
|
|
420
|
+
const pinnedItem: NavigationItem = {
|
|
421
|
+
...objectItem,
|
|
422
|
+
id: 'nav-pinned',
|
|
423
|
+
pinned: true,
|
|
424
|
+
};
|
|
425
|
+
renderNav([pinnedItem], { enablePinning: true, onPinToggle });
|
|
426
|
+
// Pinned item appears in both Favorites and main nav
|
|
427
|
+
const unpinButtons = screen.getAllByLabelText('Unpin Accounts');
|
|
428
|
+
fireEvent.click(unpinButtons[0]);
|
|
429
|
+
expect(onPinToggle).toHaveBeenCalledWith('nav-pinned', false);
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
it('renders favorites section for pinned items', () => {
|
|
433
|
+
const items: NavigationItem[] = [
|
|
434
|
+
{ ...objectItem, id: 'n1', label: 'Accounts', pinned: true },
|
|
435
|
+
{ ...pageItem, id: 'n2', label: 'Settings' },
|
|
436
|
+
];
|
|
437
|
+
renderNav(items, { enablePinning: true, onPinToggle: vi.fn() });
|
|
438
|
+
expect(screen.getByText('Favorites')).toBeTruthy();
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
it('does not render favorites section when no items are pinned', () => {
|
|
442
|
+
renderNav([objectItem], { enablePinning: true, onPinToggle: vi.fn() });
|
|
443
|
+
expect(screen.queryByText('Favorites')).toBeNull();
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
it('collects pinned items from nested groups into favorites', () => {
|
|
447
|
+
const group: NavigationItem = {
|
|
448
|
+
id: 'g1',
|
|
449
|
+
type: 'group',
|
|
450
|
+
label: 'Sales',
|
|
451
|
+
children: [
|
|
452
|
+
{ id: 'c1', type: 'object', label: 'Pinned Child', objectName: 'opp', pinned: true },
|
|
453
|
+
{ id: 'c2', type: 'object', label: 'Normal Child', objectName: 'contact' },
|
|
454
|
+
],
|
|
455
|
+
defaultOpen: true,
|
|
456
|
+
};
|
|
457
|
+
renderNav([group], { enablePinning: true, onPinToggle: vi.fn() });
|
|
458
|
+
expect(screen.getByText('Favorites')).toBeTruthy();
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
it('renders pin button on action items', () => {
|
|
462
|
+
renderNav([actionItem], { enablePinning: true, onPinToggle: vi.fn() });
|
|
463
|
+
expect(screen.getByLabelText('Pin Create Record')).toBeTruthy();
|
|
464
|
+
});
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
// --- P1.7: Drag reorder ---
|
|
468
|
+
|
|
469
|
+
describe('drag reorder', () => {
|
|
470
|
+
it('renders drag handles when enableReorder is true', () => {
|
|
471
|
+
const items: NavigationItem[] = [
|
|
472
|
+
{ ...objectItem, id: 'n1', label: 'Item A' },
|
|
473
|
+
{ ...pageItem, id: 'n2', label: 'Item B' },
|
|
474
|
+
];
|
|
475
|
+
const { container } = renderNav(items, { enableReorder: true, onReorder: vi.fn() });
|
|
476
|
+
// DndContext renders sortable wrappers
|
|
477
|
+
expect(screen.getByText('Item A')).toBeTruthy();
|
|
478
|
+
expect(screen.getByText('Item B')).toBeTruthy();
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
it('does not render drag handles when enableReorder is false', () => {
|
|
482
|
+
const items: NavigationItem[] = [
|
|
483
|
+
{ ...objectItem, id: 'n1', label: 'Item A' },
|
|
484
|
+
];
|
|
485
|
+
const { container } = renderNav(items);
|
|
486
|
+
// No GripVertical icons should be present
|
|
487
|
+
const grips = container.querySelectorAll('.cursor-grab');
|
|
488
|
+
expect(grips.length).toBe(0);
|
|
489
|
+
});
|
|
490
|
+
});
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
// ---------------------------------------------------------------------------
|
|
494
|
+
// filterNavigationItems (unit tests)
|
|
495
|
+
// ---------------------------------------------------------------------------
|
|
496
|
+
|
|
497
|
+
describe('filterNavigationItems', () => {
|
|
498
|
+
const items: NavigationItem[] = [
|
|
499
|
+
{ id: 'n1', type: 'object', label: 'Accounts', objectName: 'account' },
|
|
500
|
+
{ id: 'n2', type: 'page', label: 'Settings', pageName: 'settings' },
|
|
501
|
+
{ id: 'n3', type: 'dashboard', label: 'Sales Dashboard', dashboardName: 'sales' },
|
|
502
|
+
{ id: 'sep', type: 'separator', label: '' },
|
|
503
|
+
{
|
|
504
|
+
id: 'g1',
|
|
505
|
+
type: 'group',
|
|
506
|
+
label: 'Reports',
|
|
507
|
+
children: [
|
|
508
|
+
{ id: 'r1', type: 'report', label: 'Monthly Report', reportName: 'monthly' },
|
|
509
|
+
{ id: 'r2', type: 'report', label: 'Account Summary', reportName: 'account-sum' },
|
|
510
|
+
],
|
|
511
|
+
},
|
|
512
|
+
];
|
|
513
|
+
|
|
514
|
+
it('returns all items for empty query', () => {
|
|
515
|
+
expect(filterNavigationItems(items, '')).toEqual(items);
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
it('returns all items for whitespace-only query', () => {
|
|
519
|
+
expect(filterNavigationItems(items, ' ')).toEqual(items);
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
it('filters leaf items by label (case-insensitive)', () => {
|
|
523
|
+
const result = filterNavigationItems(items, 'account');
|
|
524
|
+
expect(result.map((i) => i.id)).toContain('n1');
|
|
525
|
+
expect(result.map((i) => i.id)).not.toContain('n2');
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
it('keeps groups with matching children', () => {
|
|
529
|
+
const result = filterNavigationItems(items, 'monthly');
|
|
530
|
+
const group = result.find((i) => i.id === 'g1');
|
|
531
|
+
expect(group).toBeTruthy();
|
|
532
|
+
expect(group?.children?.length).toBe(1);
|
|
533
|
+
expect(group?.children?.[0].id).toBe('r1');
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
it('removes groups with no matching children', () => {
|
|
537
|
+
const result = filterNavigationItems(items, 'settings');
|
|
538
|
+
expect(result.find((i) => i.id === 'g1')).toBeUndefined();
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
it('excludes separators during search', () => {
|
|
542
|
+
const result = filterNavigationItems(items, 'account');
|
|
543
|
+
expect(result.find((i) => i.type === 'separator')).toBeUndefined();
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
it('matches partial label substrings', () => {
|
|
547
|
+
const result = filterNavigationItems(items, 'dash');
|
|
548
|
+
expect(result.map((i) => i.id)).toContain('n3');
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
it('returns empty array when nothing matches', () => {
|
|
552
|
+
const result = filterNavigationItems(items, 'zzzzz');
|
|
553
|
+
expect(result).toEqual([]);
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
it('matches group children containing search term "account"', () => {
|
|
557
|
+
const result = filterNavigationItems(items, 'account');
|
|
558
|
+
const group = result.find((i) => i.id === 'g1');
|
|
559
|
+
expect(group).toBeTruthy();
|
|
560
|
+
expect(group?.children?.map((c) => c.id)).toEqual(['r2']);
|
|
561
|
+
});
|
|
562
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -10,6 +10,8 @@ import { Page } from './Page';
|
|
|
10
10
|
import { PageCard } from './PageCard';
|
|
11
11
|
import { SidebarNav } from './SidebarNav';
|
|
12
12
|
import { ResponsiveGrid } from './ResponsiveGrid';
|
|
13
|
+
import { NavigationRenderer } from './NavigationRenderer';
|
|
14
|
+
import { AppSchemaRenderer } from './AppSchemaRenderer';
|
|
13
15
|
|
|
14
16
|
export * from './PageHeader';
|
|
15
17
|
export * from './AppShell';
|
|
@@ -17,6 +19,8 @@ export * from './Page';
|
|
|
17
19
|
export * from './PageCard';
|
|
18
20
|
export * from './SidebarNav';
|
|
19
21
|
export * from './ResponsiveGrid';
|
|
22
|
+
export * from './NavigationRenderer';
|
|
23
|
+
export * from './AppSchemaRenderer';
|
|
20
24
|
|
|
21
25
|
export function registerLayout() {
|
|
22
26
|
ComponentRegistry.register('page-header', PageHeader, {
|
|
@@ -57,6 +61,28 @@ export function registerLayout() {
|
|
|
57
61
|
],
|
|
58
62
|
});
|
|
59
63
|
|
|
64
|
+
ComponentRegistry.register('navigation-renderer', NavigationRenderer, {
|
|
65
|
+
namespace: 'layout',
|
|
66
|
+
label: 'Navigation Renderer',
|
|
67
|
+
category: 'Layout',
|
|
68
|
+
inputs: [
|
|
69
|
+
{ name: 'items', type: 'object' },
|
|
70
|
+
{ name: 'basePath', type: 'string' },
|
|
71
|
+
],
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
ComponentRegistry.register('app-schema-renderer', AppSchemaRenderer, {
|
|
75
|
+
namespace: 'layout',
|
|
76
|
+
label: 'App Schema Renderer',
|
|
77
|
+
category: 'Layout',
|
|
78
|
+
isContainer: true,
|
|
79
|
+
inputs: [
|
|
80
|
+
{ name: 'schema', type: 'object' },
|
|
81
|
+
{ name: 'basePath', type: 'string' },
|
|
82
|
+
{ name: 'mobileNavMode', type: 'string' },
|
|
83
|
+
],
|
|
84
|
+
});
|
|
85
|
+
|
|
60
86
|
// NOTE: 'page' registration is handled by @object-ui/components PageRenderer.
|
|
61
87
|
// That renderer supports page types (record/home/app/utility), named regions,
|
|
62
88
|
// and PageVariablesProvider. Do NOT re-register 'page' here to avoid conflicts.
|