@object-ui/layout 3.3.0 → 3.3.2
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 +19 -0
- package/README.md +21 -1
- package/dist/index.js +731 -716
- package/dist/index.umd.cjs +3 -3
- package/dist/packages/layout/src/NavigationRenderer.d.ts +2 -1
- package/package.json +42 -7
- 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 -39
|
@@ -1,408 +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
|
-
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 { AppSchema, NavigationItem, NavigationArea } from '@object-ui/types';
|
|
14
|
-
import { AppSchemaRenderer } from '../AppSchemaRenderer';
|
|
15
|
-
|
|
16
|
-
/** Wrap component in MemoryRouter */
|
|
17
|
-
function renderApp(
|
|
18
|
-
schema: AppSchema,
|
|
19
|
-
props: Partial<React.ComponentProps<typeof AppSchemaRenderer>> = {},
|
|
20
|
-
initialEntries: string[] = ['/'],
|
|
21
|
-
) {
|
|
22
|
-
return render(
|
|
23
|
-
<MemoryRouter initialEntries={initialEntries}>
|
|
24
|
-
<AppSchemaRenderer schema={schema} basePath="/apps/crm" {...props}>
|
|
25
|
-
<div data-testid="page-content">Page Content</div>
|
|
26
|
-
</AppSchemaRenderer>
|
|
27
|
-
</MemoryRouter>,
|
|
28
|
-
);
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
// ---------------------------------------------------------------------------
|
|
32
|
-
// Fixtures
|
|
33
|
-
// ---------------------------------------------------------------------------
|
|
34
|
-
|
|
35
|
-
const minimalSchema: AppSchema = {
|
|
36
|
-
type: 'app',
|
|
37
|
-
name: 'crm',
|
|
38
|
-
title: 'Sales CRM',
|
|
39
|
-
};
|
|
40
|
-
|
|
41
|
-
const navItems: NavigationItem[] = [
|
|
42
|
-
{ id: 'n1', type: 'object', label: 'Accounts', icon: 'Users', objectName: 'account' },
|
|
43
|
-
{ id: 'n2', type: 'dashboard', label: 'Overview', icon: 'LayoutDashboard', dashboardName: 'overview' },
|
|
44
|
-
{ id: 'n3', type: 'page', label: 'Settings', icon: 'Settings', pageName: 'settings' },
|
|
45
|
-
];
|
|
46
|
-
|
|
47
|
-
const schemaWithNav: AppSchema = {
|
|
48
|
-
type: 'app',
|
|
49
|
-
name: 'crm',
|
|
50
|
-
title: 'Sales CRM',
|
|
51
|
-
description: 'Manage your sales pipeline',
|
|
52
|
-
navigation: navItems,
|
|
53
|
-
};
|
|
54
|
-
|
|
55
|
-
const salesArea: NavigationArea = {
|
|
56
|
-
id: 'area-sales',
|
|
57
|
-
label: 'Sales',
|
|
58
|
-
icon: 'Briefcase',
|
|
59
|
-
navigation: [
|
|
60
|
-
{ id: 'a1', type: 'object', label: 'Opportunities', icon: 'Target', objectName: 'opportunity' },
|
|
61
|
-
],
|
|
62
|
-
};
|
|
63
|
-
|
|
64
|
-
const serviceArea: NavigationArea = {
|
|
65
|
-
id: 'area-service',
|
|
66
|
-
label: 'Service',
|
|
67
|
-
icon: 'Headphones',
|
|
68
|
-
navigation: [
|
|
69
|
-
{ id: 'a2', type: 'object', label: 'Cases', icon: 'Inbox', objectName: 'case' },
|
|
70
|
-
],
|
|
71
|
-
};
|
|
72
|
-
|
|
73
|
-
const schemaWithAreas: AppSchema = {
|
|
74
|
-
type: 'app',
|
|
75
|
-
name: 'crm',
|
|
76
|
-
title: 'Sales CRM',
|
|
77
|
-
areas: [salesArea, serviceArea],
|
|
78
|
-
};
|
|
79
|
-
|
|
80
|
-
// ---------------------------------------------------------------------------
|
|
81
|
-
// Tests
|
|
82
|
-
// ---------------------------------------------------------------------------
|
|
83
|
-
|
|
84
|
-
describe('AppSchemaRenderer', () => {
|
|
85
|
-
// --- Basic rendering ---
|
|
86
|
-
|
|
87
|
-
it('renders page content', () => {
|
|
88
|
-
renderApp(minimalSchema);
|
|
89
|
-
expect(screen.getByTestId('page-content')).toBeTruthy();
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
it('renders app title in sidebar header', () => {
|
|
93
|
-
renderApp(schemaWithNav);
|
|
94
|
-
expect(screen.getByText('Sales CRM')).toBeTruthy();
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
it('renders app description', () => {
|
|
98
|
-
renderApp(schemaWithNav);
|
|
99
|
-
expect(screen.getByText('Manage your sales pipeline')).toBeTruthy();
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
// --- Navigation items ---
|
|
103
|
-
|
|
104
|
-
it('renders navigation items from schema', () => {
|
|
105
|
-
renderApp(schemaWithNav);
|
|
106
|
-
expect(screen.getByText('Accounts')).toBeTruthy();
|
|
107
|
-
expect(screen.getByText('Overview')).toBeTruthy();
|
|
108
|
-
expect(screen.getByText('Settings')).toBeTruthy();
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
it('generates correct hrefs for navigation items', () => {
|
|
112
|
-
renderApp(schemaWithNav);
|
|
113
|
-
const accountLink = screen.getByText('Accounts').closest('a');
|
|
114
|
-
expect(accountLink?.getAttribute('href')).toBe('/apps/crm/account');
|
|
115
|
-
|
|
116
|
-
const dashLink = screen.getByText('Overview').closest('a');
|
|
117
|
-
expect(dashLink?.getAttribute('href')).toBe('/apps/crm/dashboard/overview');
|
|
118
|
-
|
|
119
|
-
const pageLink = screen.getByText('Settings').closest('a');
|
|
120
|
-
expect(pageLink?.getAttribute('href')).toBe('/apps/crm/page/settings');
|
|
121
|
-
});
|
|
122
|
-
|
|
123
|
-
// --- Legacy menu migration ---
|
|
124
|
-
|
|
125
|
-
it('renders legacy menu items converted to NavigationItem', () => {
|
|
126
|
-
const legacySchema: AppSchema = {
|
|
127
|
-
type: 'app',
|
|
128
|
-
name: 'legacy',
|
|
129
|
-
title: 'Legacy App',
|
|
130
|
-
menu: [
|
|
131
|
-
{ type: 'item', label: 'Tasks', path: '/tasks', icon: 'CheckSquare' },
|
|
132
|
-
{ type: 'item', label: 'Docs', href: 'https://docs.example.com', icon: 'FileText' },
|
|
133
|
-
],
|
|
134
|
-
};
|
|
135
|
-
renderApp(legacySchema);
|
|
136
|
-
expect(screen.getByText('Tasks')).toBeTruthy();
|
|
137
|
-
expect(screen.getByText('Docs')).toBeTruthy();
|
|
138
|
-
});
|
|
139
|
-
|
|
140
|
-
// --- Area switching ---
|
|
141
|
-
|
|
142
|
-
it('renders area switcher when multiple areas defined', () => {
|
|
143
|
-
renderApp(schemaWithAreas);
|
|
144
|
-
expect(screen.getByText('Sales')).toBeTruthy();
|
|
145
|
-
expect(screen.getByText('Service')).toBeTruthy();
|
|
146
|
-
});
|
|
147
|
-
|
|
148
|
-
it('shows first area navigation by default', () => {
|
|
149
|
-
renderApp(schemaWithAreas);
|
|
150
|
-
expect(screen.getByText('Opportunities')).toBeTruthy();
|
|
151
|
-
});
|
|
152
|
-
|
|
153
|
-
it('switches area when area button is clicked', () => {
|
|
154
|
-
renderApp(schemaWithAreas);
|
|
155
|
-
// Initially shows Sales area
|
|
156
|
-
expect(screen.getByText('Opportunities')).toBeTruthy();
|
|
157
|
-
|
|
158
|
-
// Click Service area
|
|
159
|
-
fireEvent.click(screen.getByText('Service'));
|
|
160
|
-
|
|
161
|
-
// Should now show Service area navigation
|
|
162
|
-
expect(screen.getByText('Cases')).toBeTruthy();
|
|
163
|
-
});
|
|
164
|
-
|
|
165
|
-
// --- Area visibility and permissions ---
|
|
166
|
-
|
|
167
|
-
it('hides areas that fail visibility check', () => {
|
|
168
|
-
const schemaWithHiddenArea: AppSchema = {
|
|
169
|
-
type: 'app',
|
|
170
|
-
name: 'crm',
|
|
171
|
-
title: 'CRM',
|
|
172
|
-
areas: [
|
|
173
|
-
salesArea,
|
|
174
|
-
{ ...serviceArea, visible: false },
|
|
175
|
-
],
|
|
176
|
-
};
|
|
177
|
-
renderApp(schemaWithHiddenArea, {
|
|
178
|
-
evaluateVisibility: (expr) => {
|
|
179
|
-
if (expr === false) return false;
|
|
180
|
-
return true;
|
|
181
|
-
},
|
|
182
|
-
});
|
|
183
|
-
// Area switcher should not show (only 1 visible area)
|
|
184
|
-
expect(screen.queryByText('Service')).toBeNull();
|
|
185
|
-
});
|
|
186
|
-
|
|
187
|
-
it('hides areas that fail permission check', () => {
|
|
188
|
-
const schemaWithPermArea: AppSchema = {
|
|
189
|
-
type: 'app',
|
|
190
|
-
name: 'crm',
|
|
191
|
-
title: 'CRM',
|
|
192
|
-
areas: [
|
|
193
|
-
salesArea,
|
|
194
|
-
{ ...serviceArea, requiredPermissions: ['service:admin'] },
|
|
195
|
-
],
|
|
196
|
-
};
|
|
197
|
-
renderApp(schemaWithPermArea, {
|
|
198
|
-
checkPermission: (perms) => !perms.includes('service:admin'),
|
|
199
|
-
});
|
|
200
|
-
expect(screen.queryByText('Service')).toBeNull();
|
|
201
|
-
});
|
|
202
|
-
|
|
203
|
-
// --- Mobile bottom_nav mode ---
|
|
204
|
-
|
|
205
|
-
it('renders mobile bottom nav when mobileNavMode is bottom_nav', () => {
|
|
206
|
-
const { container } = renderApp(schemaWithNav, { mobileNavMode: 'bottom_nav' });
|
|
207
|
-
const bottomNav = container.querySelector('[role="navigation"][aria-label="Mobile navigation"]');
|
|
208
|
-
expect(bottomNav).toBeTruthy();
|
|
209
|
-
});
|
|
210
|
-
|
|
211
|
-
it('does not render bottom nav in drawer mode (default)', () => {
|
|
212
|
-
const { container } = renderApp(schemaWithNav);
|
|
213
|
-
const bottomNav = container.querySelector('[role="navigation"][aria-label="Mobile navigation"]');
|
|
214
|
-
expect(bottomNav).toBeNull();
|
|
215
|
-
});
|
|
216
|
-
|
|
217
|
-
// --- Sidebar footer slot ---
|
|
218
|
-
|
|
219
|
-
it('renders sidebarFooter slot', () => {
|
|
220
|
-
renderApp(schemaWithNav, {
|
|
221
|
-
sidebarFooter: <div data-testid="user-profile">User Profile</div>,
|
|
222
|
-
});
|
|
223
|
-
expect(screen.getByTestId('user-profile')).toBeTruthy();
|
|
224
|
-
});
|
|
225
|
-
|
|
226
|
-
// --- Navbar slot ---
|
|
227
|
-
|
|
228
|
-
it('renders navbar slot', () => {
|
|
229
|
-
renderApp(schemaWithNav, {
|
|
230
|
-
navbar: <div data-testid="custom-navbar">Search Bar</div>,
|
|
231
|
-
});
|
|
232
|
-
expect(screen.getByTestId('custom-navbar')).toBeTruthy();
|
|
233
|
-
});
|
|
234
|
-
|
|
235
|
-
// --- Permission & visibility on nav items ---
|
|
236
|
-
|
|
237
|
-
it('hides navigation items based on visibility', () => {
|
|
238
|
-
const schema: AppSchema = {
|
|
239
|
-
type: 'app',
|
|
240
|
-
name: 'crm',
|
|
241
|
-
title: 'CRM',
|
|
242
|
-
navigation: [
|
|
243
|
-
{ id: 'n1', type: 'object', label: 'Public', objectName: 'public', visible: true },
|
|
244
|
-
{ id: 'n2', type: 'object', label: 'Hidden', objectName: 'hidden', visible: false },
|
|
245
|
-
],
|
|
246
|
-
};
|
|
247
|
-
renderApp(schema, {
|
|
248
|
-
evaluateVisibility: (expr) => expr !== false,
|
|
249
|
-
});
|
|
250
|
-
expect(screen.getByText('Public')).toBeTruthy();
|
|
251
|
-
expect(screen.queryByText('Hidden')).toBeNull();
|
|
252
|
-
});
|
|
253
|
-
|
|
254
|
-
it('hides navigation items based on permissions', () => {
|
|
255
|
-
const schema: AppSchema = {
|
|
256
|
-
type: 'app',
|
|
257
|
-
name: 'crm',
|
|
258
|
-
title: 'CRM',
|
|
259
|
-
navigation: [
|
|
260
|
-
{ id: 'n1', type: 'object', label: 'Allowed', objectName: 'allowed' },
|
|
261
|
-
{ id: 'n2', type: 'object', label: 'Restricted', objectName: 'restricted', requiredPermissions: ['admin'] },
|
|
262
|
-
],
|
|
263
|
-
};
|
|
264
|
-
renderApp(schema, {
|
|
265
|
-
checkPermission: (perms) => !perms.includes('admin'),
|
|
266
|
-
});
|
|
267
|
-
expect(screen.getByText('Allowed')).toBeTruthy();
|
|
268
|
-
expect(screen.queryByText('Restricted')).toBeNull();
|
|
269
|
-
});
|
|
270
|
-
|
|
271
|
-
// --- Empty schema ---
|
|
272
|
-
|
|
273
|
-
it('renders empty shell with no navigation', () => {
|
|
274
|
-
renderApp(minimalSchema);
|
|
275
|
-
expect(screen.getByTestId('page-content')).toBeTruthy();
|
|
276
|
-
// App name should still be shown
|
|
277
|
-
expect(screen.getByText('Sales CRM')).toBeTruthy();
|
|
278
|
-
});
|
|
279
|
-
|
|
280
|
-
// --- P1.7: Search within sidebar navigation ---
|
|
281
|
-
|
|
282
|
-
describe('sidebar search', () => {
|
|
283
|
-
it('renders search input when enableSearch is true', () => {
|
|
284
|
-
renderApp(schemaWithNav, { enableSearch: true });
|
|
285
|
-
expect(screen.getByPlaceholderText('Search navigation…')).toBeTruthy();
|
|
286
|
-
});
|
|
287
|
-
|
|
288
|
-
it('does not render search input by default', () => {
|
|
289
|
-
renderApp(schemaWithNav);
|
|
290
|
-
expect(screen.queryByPlaceholderText('Search navigation…')).toBeNull();
|
|
291
|
-
});
|
|
292
|
-
|
|
293
|
-
it('filters navigation items when user types in search', () => {
|
|
294
|
-
renderApp(schemaWithNav, { enableSearch: true });
|
|
295
|
-
const searchInput = screen.getByPlaceholderText('Search navigation…');
|
|
296
|
-
fireEvent.change(searchInput, { target: { value: 'Accounts' } });
|
|
297
|
-
expect(screen.getByText('Accounts')).toBeTruthy();
|
|
298
|
-
expect(screen.queryByText('Overview')).toBeNull();
|
|
299
|
-
expect(screen.queryByText('Settings')).toBeNull();
|
|
300
|
-
});
|
|
301
|
-
|
|
302
|
-
it('shows all items when search is cleared', () => {
|
|
303
|
-
renderApp(schemaWithNav, { enableSearch: true });
|
|
304
|
-
const searchInput = screen.getByPlaceholderText('Search navigation…');
|
|
305
|
-
// Type search
|
|
306
|
-
fireEvent.change(searchInput, { target: { value: 'Accounts' } });
|
|
307
|
-
expect(screen.queryByText('Overview')).toBeNull();
|
|
308
|
-
// Clear search
|
|
309
|
-
fireEvent.change(searchInput, { target: { value: '' } });
|
|
310
|
-
expect(screen.getByText('Accounts')).toBeTruthy();
|
|
311
|
-
expect(screen.getByText('Overview')).toBeTruthy();
|
|
312
|
-
expect(screen.getByText('Settings')).toBeTruthy();
|
|
313
|
-
});
|
|
314
|
-
|
|
315
|
-
it('search input has correct aria-label', () => {
|
|
316
|
-
renderApp(schemaWithNav, { enableSearch: true });
|
|
317
|
-
expect(screen.getByLabelText('Search navigation')).toBeTruthy();
|
|
318
|
-
});
|
|
319
|
-
});
|
|
320
|
-
|
|
321
|
-
// --- P1.7: Pin favorites ---
|
|
322
|
-
|
|
323
|
-
describe('pin favorites', () => {
|
|
324
|
-
it('renders pin buttons when enablePinning is true', () => {
|
|
325
|
-
renderApp(schemaWithNav, { enablePinning: true, onPinToggle: vi.fn() });
|
|
326
|
-
expect(screen.getByLabelText('Pin Accounts')).toBeTruthy();
|
|
327
|
-
});
|
|
328
|
-
|
|
329
|
-
it('calls onPinToggle when pin button is clicked', () => {
|
|
330
|
-
const onPinToggle = vi.fn();
|
|
331
|
-
renderApp(schemaWithNav, { enablePinning: true, onPinToggle });
|
|
332
|
-
fireEvent.click(screen.getByLabelText('Pin Accounts'));
|
|
333
|
-
expect(onPinToggle).toHaveBeenCalledWith('n1', true);
|
|
334
|
-
});
|
|
335
|
-
});
|
|
336
|
-
|
|
337
|
-
// --- P1.7: Drag reorder ---
|
|
338
|
-
|
|
339
|
-
describe('drag reorder', () => {
|
|
340
|
-
it('passes enableReorder to navigation renderer', () => {
|
|
341
|
-
const onReorder = vi.fn();
|
|
342
|
-
renderApp(schemaWithNav, { enableReorder: true, onReorder });
|
|
343
|
-
// Navigation items should still render
|
|
344
|
-
expect(screen.getByText('Accounts')).toBeTruthy();
|
|
345
|
-
expect(screen.getByText('Overview')).toBeTruthy();
|
|
346
|
-
});
|
|
347
|
-
});
|
|
348
|
-
|
|
349
|
-
// --- Slot system ---
|
|
350
|
-
|
|
351
|
-
describe('slot system', () => {
|
|
352
|
-
it('renders custom sidebarHeader when provided', () => {
|
|
353
|
-
renderApp(schemaWithNav, {
|
|
354
|
-
sidebarHeader: <div data-testid="custom-header">Custom Header</div>,
|
|
355
|
-
});
|
|
356
|
-
expect(screen.getByTestId('custom-header')).toBeTruthy();
|
|
357
|
-
expect(screen.getByText('Custom Header')).toBeTruthy();
|
|
358
|
-
});
|
|
359
|
-
|
|
360
|
-
it('replaces default branding header with sidebarHeader slot', () => {
|
|
361
|
-
renderApp(schemaWithNav, {
|
|
362
|
-
sidebarHeader: <div data-testid="custom-header">App Switcher</div>,
|
|
363
|
-
});
|
|
364
|
-
// Custom header is present
|
|
365
|
-
expect(screen.getByText('App Switcher')).toBeTruthy();
|
|
366
|
-
// Default branding title should not be in the sidebar header
|
|
367
|
-
// (it may still appear elsewhere, but the slot replaces the header content)
|
|
368
|
-
expect(screen.getByTestId('custom-header')).toBeTruthy();
|
|
369
|
-
});
|
|
370
|
-
|
|
371
|
-
it('renders sidebarExtra content after navigation', () => {
|
|
372
|
-
renderApp(schemaWithNav, {
|
|
373
|
-
sidebarExtra: <div data-testid="sidebar-extra">Recent Items Section</div>,
|
|
374
|
-
});
|
|
375
|
-
expect(screen.getByTestId('sidebar-extra')).toBeTruthy();
|
|
376
|
-
expect(screen.getByText('Recent Items Section')).toBeTruthy();
|
|
377
|
-
});
|
|
378
|
-
|
|
379
|
-
it('renders sidebarFooter slot', () => {
|
|
380
|
-
renderApp(schemaWithNav, {
|
|
381
|
-
sidebarFooter: <div data-testid="user-footer">User Profile</div>,
|
|
382
|
-
});
|
|
383
|
-
expect(screen.getByTestId('user-footer')).toBeTruthy();
|
|
384
|
-
expect(screen.getByText('User Profile')).toBeTruthy();
|
|
385
|
-
});
|
|
386
|
-
|
|
387
|
-
it('renders all slots together', () => {
|
|
388
|
-
renderApp(schemaWithNav, {
|
|
389
|
-
sidebarHeader: <div data-testid="header-slot">Header</div>,
|
|
390
|
-
sidebarExtra: <div data-testid="extra-slot">Extra</div>,
|
|
391
|
-
sidebarFooter: <div data-testid="footer-slot">Footer</div>,
|
|
392
|
-
navbar: <div data-testid="navbar-slot">Navbar</div>,
|
|
393
|
-
});
|
|
394
|
-
expect(screen.getByTestId('header-slot')).toBeTruthy();
|
|
395
|
-
expect(screen.getByTestId('extra-slot')).toBeTruthy();
|
|
396
|
-
expect(screen.getByTestId('footer-slot')).toBeTruthy();
|
|
397
|
-
expect(screen.getByTestId('navbar-slot')).toBeTruthy();
|
|
398
|
-
// Navigation still renders
|
|
399
|
-
expect(screen.getByText('Accounts')).toBeTruthy();
|
|
400
|
-
});
|
|
401
|
-
|
|
402
|
-
it('uses default branding header when sidebarHeader is not provided', () => {
|
|
403
|
-
renderApp(schemaWithNav);
|
|
404
|
-
// Default header shows app title
|
|
405
|
-
expect(screen.getByText('Sales CRM')).toBeTruthy();
|
|
406
|
-
});
|
|
407
|
-
});
|
|
408
|
-
});
|