Skip to content

Commit 08df39f

Browse files
authored
Merge pull request #709 from objectstack-ai/copilot/update-navigation-drag-reorder
2 parents 1ab58ce + 78accc8 commit 08df39f

7 files changed

Lines changed: 477 additions & 286 deletions

File tree

ROADMAP.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,9 @@ ObjectUI is a universal Server-Driven UI (SDUI) engine built on React + Tailwind
117117
- [x] Search within sidebar navigation
118118
- [x] Console integration: Navigation search filtering (`filterNavigationItems` + `SidebarInput`)
119119
- [x] Console integration: Badge indicators on navigation items (`badge` + `badgeVariant`)
120+
- [x] Console integration: Drag reorder upgrade — replace HTML5 DnD with `@dnd-kit` via `NavigationRenderer`
121+
- [x] Console integration: Navigation pin — `useNavPins` hook + `NavigationRenderer` `enablePinning`/`onPinToggle`
122+
- [x] Console integration: `AppSchemaRenderer` slot system — `sidebarHeader`, `sidebarExtra`, `sidebarFooter` slots for Console customization
120123

121124
### P1.8 Console — View Config Panel (Phase 20)
122125

apps/console/src/__tests__/AppSidebar.test.tsx

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,16 @@ vi.mock('../hooks/useFavorites', () => ({
7272
useFavorites: () => ({ favorites: [], removeFavorite: vi.fn() }),
7373
}));
7474

75+
vi.mock('../hooks/useNavPins', () => ({
76+
useNavPins: () => ({
77+
pinnedIds: [],
78+
togglePin: vi.fn(),
79+
isPinned: () => false,
80+
applyPins: (items: any[]) => items,
81+
clearPins: vi.fn(),
82+
}),
83+
}));
84+
7585
vi.mock('../utils', () => ({
7686
resolveI18nLabel: (label: any) => (typeof label === 'string' ? label : label?.en || ''),
7787
}));
@@ -251,4 +261,57 @@ describe('AppSidebar', () => {
251261
expect(badges?.length ?? 0).toBe(0);
252262
});
253263
});
264+
265+
// --- Navigation Drag Reorder (@dnd-kit integration) ---
266+
267+
describe('Navigation Drag Reorder', () => {
268+
it('renders navigation items via NavigationRenderer (no draggable HTML5 attributes)', async () => {
269+
renderSidebar();
270+
271+
await waitFor(() => {
272+
expect(screen.getByText('Accounts')).toBeInTheDocument();
273+
expect(screen.getByText('Contacts')).toBeInTheDocument();
274+
});
275+
276+
// Verify items are not using HTML5 draggable attribute (old DnD removed)
277+
const accountsLink = screen.getByText('Accounts');
278+
const menuItem = accountsLink.closest('[data-sidebar="menu-item"]');
279+
// The old code set draggable="true" on SidebarMenuItem; the new code
280+
// delegates to NavigationRenderer which uses @dnd-kit (no draggable attr on the item itself)
281+
expect(menuItem?.getAttribute('draggable')).not.toBe('true');
282+
});
283+
284+
it('persists reorder state to localStorage via useNavOrder', () => {
285+
// useNavOrder uses localStorage key `objectui-nav-order-{appName}`
286+
const storageKey = 'objectui-nav-order-crm';
287+
288+
// Simulate a saved order in localStorage
289+
const savedOrder = { '__root__': ['nav-contacts', 'nav-accounts', 'nav-dash', 'nav-settings', 'nav-group-reports'] };
290+
localStorage.setItem(storageKey, JSON.stringify(savedOrder));
291+
292+
renderSidebar();
293+
294+
// Items should render (order applied internally by useNavOrder + NavigationRenderer)
295+
expect(screen.getByText('Accounts')).toBeInTheDocument();
296+
expect(screen.getByText('Contacts')).toBeInTheDocument();
297+
});
298+
});
299+
300+
// --- Navigation Pin Integration ---
301+
302+
describe('Navigation Pin', () => {
303+
it('renders navigation items with pin support enabled', async () => {
304+
renderSidebar();
305+
306+
await waitFor(() => {
307+
expect(screen.getByText('Accounts')).toBeInTheDocument();
308+
});
309+
310+
// NavigationRenderer receives enablePinning=true
311+
// Pin buttons are rendered by NavigationRenderer when enablePinning is set
312+
// We verify the items render correctly with pin support enabled
313+
expect(screen.getByText('Contacts')).toBeInTheDocument();
314+
expect(screen.getByText('Monthly Dashboard')).toBeInTheDocument();
315+
});
316+
});
254317
});
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import { describe, it, expect, beforeEach } from 'vitest';
2+
import { renderHook, act } from '@testing-library/react';
3+
import { useNavPins } from '../hooks/useNavPins';
4+
import type { NavigationItem } from '@object-ui/types';
5+
6+
describe('useNavPins', () => {
7+
beforeEach(() => {
8+
localStorage.clear();
9+
});
10+
11+
it('starts with empty pins', () => {
12+
const { result } = renderHook(() => useNavPins());
13+
expect(result.current.pinnedIds).toEqual([]);
14+
});
15+
16+
it('pins a navigation item', () => {
17+
const { result } = renderHook(() => useNavPins());
18+
19+
act(() => {
20+
result.current.togglePin('nav-accounts', true);
21+
});
22+
23+
expect(result.current.pinnedIds).toContain('nav-accounts');
24+
expect(result.current.isPinned('nav-accounts')).toBe(true);
25+
});
26+
27+
it('unpins a navigation item', () => {
28+
const { result } = renderHook(() => useNavPins());
29+
30+
act(() => {
31+
result.current.togglePin('nav-accounts', true);
32+
});
33+
expect(result.current.isPinned('nav-accounts')).toBe(true);
34+
35+
act(() => {
36+
result.current.togglePin('nav-accounts', false);
37+
});
38+
expect(result.current.isPinned('nav-accounts')).toBe(false);
39+
expect(result.current.pinnedIds).not.toContain('nav-accounts');
40+
});
41+
42+
it('persists pins to localStorage', () => {
43+
const { result } = renderHook(() => useNavPins());
44+
45+
act(() => {
46+
result.current.togglePin('nav-contacts', true);
47+
});
48+
49+
const stored = JSON.parse(localStorage.getItem('objectui-nav-pins') || '[]');
50+
expect(stored).toContain('nav-contacts');
51+
});
52+
53+
it('loads pins from localStorage on mount', () => {
54+
localStorage.setItem('objectui-nav-pins', JSON.stringify(['nav-accounts', 'nav-contacts']));
55+
56+
const { result } = renderHook(() => useNavPins());
57+
expect(result.current.pinnedIds).toEqual(['nav-accounts', 'nav-contacts']);
58+
expect(result.current.isPinned('nav-accounts')).toBe(true);
59+
expect(result.current.isPinned('nav-contacts')).toBe(true);
60+
});
61+
62+
it('handles corrupted localStorage gracefully', () => {
63+
localStorage.setItem('objectui-nav-pins', 'not-valid-json');
64+
65+
const { result } = renderHook(() => useNavPins());
66+
expect(result.current.pinnedIds).toEqual([]);
67+
});
68+
69+
it('handles non-array localStorage gracefully', () => {
70+
localStorage.setItem('objectui-nav-pins', JSON.stringify({ foo: 'bar' }));
71+
72+
const { result } = renderHook(() => useNavPins());
73+
expect(result.current.pinnedIds).toEqual([]);
74+
});
75+
76+
it('clears all pins', () => {
77+
const { result } = renderHook(() => useNavPins());
78+
79+
act(() => {
80+
result.current.togglePin('nav-accounts', true);
81+
result.current.togglePin('nav-contacts', true);
82+
});
83+
expect(result.current.pinnedIds.length).toBe(2);
84+
85+
act(() => {
86+
result.current.clearPins();
87+
});
88+
expect(result.current.pinnedIds).toEqual([]);
89+
expect(JSON.parse(localStorage.getItem('objectui-nav-pins') || '[]')).toEqual([]);
90+
});
91+
92+
it('prevents duplicate pins', () => {
93+
const { result } = renderHook(() => useNavPins());
94+
95+
act(() => {
96+
result.current.togglePin('nav-accounts', true);
97+
});
98+
act(() => {
99+
result.current.togglePin('nav-accounts', true);
100+
});
101+
102+
// Should only appear once
103+
const count = result.current.pinnedIds.filter(id => id === 'nav-accounts').length;
104+
expect(count).toBe(1);
105+
});
106+
107+
it('applies pin state to navigation items', () => {
108+
const { result } = renderHook(() => useNavPins());
109+
110+
act(() => {
111+
result.current.togglePin('nav-accounts', true);
112+
});
113+
114+
const items: NavigationItem[] = [
115+
{ id: 'nav-accounts', label: 'Accounts', type: 'object', objectName: 'account' },
116+
{ id: 'nav-contacts', label: 'Contacts', type: 'object', objectName: 'contact' },
117+
];
118+
119+
const pinned = result.current.applyPins(items);
120+
expect(pinned[0].pinned).toBe(true);
121+
expect(pinned[1].pinned).toBeFalsy();
122+
});
123+
124+
it('applies pin state to nested navigation items', () => {
125+
const { result } = renderHook(() => useNavPins());
126+
127+
act(() => {
128+
result.current.togglePin('nav-report-sales', true);
129+
});
130+
131+
const items: NavigationItem[] = [
132+
{
133+
id: 'nav-group-reports',
134+
label: 'Reports',
135+
type: 'group',
136+
children: [
137+
{ id: 'nav-report-sales', label: 'Sales Report', type: 'report', reportName: 'sales' },
138+
{ id: 'nav-report-inv', label: 'Inventory Report', type: 'report', reportName: 'inventory' },
139+
],
140+
},
141+
];
142+
143+
const pinned = result.current.applyPins(items);
144+
expect(pinned[0].children![0].pinned).toBe(true);
145+
expect(pinned[0].children![1].pinned).toBeFalsy();
146+
});
147+
});

0 commit comments

Comments
 (0)