Skip to content

Commit 2b6c733

Browse files
committed
feat: add NavigationOverlay component and useNavigationOverlay hook for enhanced navigation support
- Implemented NavigationOverlay component to handle various overlay modes (drawer, modal, split, popover) for displaying record details. - Created useNavigationOverlay hook to manage navigation state and behavior based on ViewNavigationConfig. - Updated ObjectGrid and ListView components to utilize the new navigation features, allowing for improved user interaction and record detail display. - Enhanced ListView and ObjectGrid schemas to support navigation configuration and callbacks for row clicks. - Added tests for NavigationOverlay and useNavigationOverlay to ensure functionality and reliability.
1 parent 63971ef commit 2b6c733

File tree

14 files changed

+1352
-26
lines changed

14 files changed

+1352
-26
lines changed

ROADMAP.md

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ Action.params: ActionParam[]
102102

103103
---
104104

105-
#### 3. NavigationConfig
105+
#### 3. NavigationConfig ✅ (v0.5.0)
106106

107107
**Spec Requirement:**
108108
```typescript
@@ -114,17 +114,29 @@ NavigationConfig: {
114114
}
115115
```
116116

117-
**Current State:** Not implemented. No TypeScript interface in @object-ui/types.
117+
**Current State:** Fully implemented. `ViewNavigationConfig` interface in @object-ui/types.
118+
Reusable `useNavigationOverlay` hook in @object-ui/react + `NavigationOverlay` component in @object-ui/components.
119+
120+
**Completed:**
121+
- [x] `ViewNavigationConfig` interface in @object-ui/types (7 modes, width, view, preventNavigation, openNewTab)
122+
- [x] `navigation?: ViewNavigationConfig` on ObjectGridSchema, ListViewSchema, ObjectViewSchema
123+
- [x] `onNavigate` callback on ObjectGridSchema, ListViewSchema, DetailViewSchema
124+
- [x] `useNavigationOverlay` hook (state management, click handler, overlay control)
125+
- [x] `NavigationOverlay` component (Sheet/Dialog/Popover/ResizablePanelGroup rendering)
126+
- [x] Implement in plugin-grid (row click → drawer/modal/split/popover/page/new_window/none)
127+
- [x] Implement in plugin-list (onRowClick passthrough to child views + overlay rendering)
128+
- [x] Implement in plugin-detail (SPA-aware back/edit/delete navigation via onNavigate)
129+
- [x] Drawer navigation mode (Sheet, right-side panel)
130+
- [x] Modal navigation mode (Dialog, center overlay)
131+
- [x] Split view navigation mode (ResizablePanelGroup, side-by-side)
132+
- [x] Popover preview mode (Popover, hover/click card)
133+
- [x] 51 useNavigationOverlay hook tests + 20 NavigationOverlay component tests
118134

119-
**Missing Features:**
120-
- [ ] Add NavigationConfig to @object-ui/types
121-
- [ ] Implement in plugin-grid (row click behavior)
122-
- [ ] Implement in plugin-list (item click behavior)
123-
- [ ] Implement in plugin-detail (related list navigation)
124-
- [ ] Drawer navigation mode
125-
- [ ] Modal navigation mode
126-
- [ ] Split view navigation mode
127-
- [ ] Popover preview mode
135+
**Remaining:**
136+
- [ ] `navigation.view` property — target view/form schema lookup (currently renders field list)
137+
- [ ] ObjectForm integration in overlay content (render forms when editing)
138+
- [ ] ActionParam UI collection (before execution) — param form dialog
139+
- [ ] FormField.dependsOn (field dependencies) — type defined, runtime evaluation pending
128140

129141
---
130142

Lines changed: 273 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,273 @@
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 { render, screen, fireEvent } from '@testing-library/react';
11+
import React from 'react';
12+
import { NavigationOverlay } from '../custom/navigation-overlay';
13+
import type { NavigationOverlayProps } from '../custom/navigation-overlay';
14+
15+
// Helper to create default props
16+
function createProps(overrides: Partial<NavigationOverlayProps> = {}): NavigationOverlayProps {
17+
return {
18+
isOpen: true,
19+
selectedRecord: { _id: '1', name: 'Test Record', email: 'test@example.com' },
20+
mode: 'drawer',
21+
close: vi.fn(),
22+
setIsOpen: vi.fn(),
23+
isOverlay: true,
24+
children: (record: Record<string, unknown>) => (
25+
<div data-testid="record-content">
26+
<span>{String(record.name)}</span>
27+
</div>
28+
),
29+
...overrides,
30+
};
31+
}
32+
33+
describe('NavigationOverlay', () => {
34+
// ============================================================
35+
// Non-overlay modes
36+
// ============================================================
37+
38+
describe('non-overlay modes', () => {
39+
it('should render nothing for mode: page', () => {
40+
const { container } = render(
41+
<NavigationOverlay {...createProps({ mode: 'page' })} />
42+
);
43+
expect(container.innerHTML).toBe('');
44+
});
45+
46+
it('should render nothing for mode: new_window', () => {
47+
const { container } = render(
48+
<NavigationOverlay {...createProps({ mode: 'new_window' })} />
49+
);
50+
expect(container.innerHTML).toBe('');
51+
});
52+
53+
it('should render nothing for mode: none', () => {
54+
const { container } = render(
55+
<NavigationOverlay {...createProps({ mode: 'none' })} />
56+
);
57+
expect(container.innerHTML).toBe('');
58+
});
59+
60+
it('should render nothing when selectedRecord is null', () => {
61+
const { container } = render(
62+
<NavigationOverlay {...createProps({ selectedRecord: null })} />
63+
);
64+
expect(container.innerHTML).toBe('');
65+
});
66+
});
67+
68+
// ============================================================
69+
// Drawer mode (Sheet)
70+
// ============================================================
71+
72+
describe('drawer mode', () => {
73+
it('should render Sheet with record content', () => {
74+
render(<NavigationOverlay {...createProps({ mode: 'drawer' })} />);
75+
expect(screen.getByText('Test Record')).toBeInTheDocument();
76+
});
77+
78+
it('should render title', () => {
79+
render(
80+
<NavigationOverlay
81+
{...createProps({ mode: 'drawer', title: 'Contact Detail' })}
82+
/>
83+
);
84+
expect(screen.getByText('Contact Detail')).toBeInTheDocument();
85+
});
86+
87+
it('should render description when provided', () => {
88+
render(
89+
<NavigationOverlay
90+
{...createProps({
91+
mode: 'drawer',
92+
title: 'Detail',
93+
description: 'View record details',
94+
})}
95+
/>
96+
);
97+
expect(screen.getByText('View record details')).toBeInTheDocument();
98+
});
99+
100+
it('should use default title when none provided', () => {
101+
render(<NavigationOverlay {...createProps({ mode: 'drawer' })} />);
102+
expect(screen.getByText('Record Detail')).toBeInTheDocument();
103+
});
104+
});
105+
106+
// ============================================================
107+
// Modal mode (Dialog)
108+
// ============================================================
109+
110+
describe('modal mode', () => {
111+
it('should render Dialog with record content', () => {
112+
render(<NavigationOverlay {...createProps({ mode: 'modal' })} />);
113+
expect(screen.getByText('Test Record')).toBeInTheDocument();
114+
});
115+
116+
it('should render title in dialog', () => {
117+
render(
118+
<NavigationOverlay
119+
{...createProps({ mode: 'modal', title: 'Account Detail' })}
120+
/>
121+
);
122+
expect(screen.getByText('Account Detail')).toBeInTheDocument();
123+
});
124+
125+
it('should render description in dialog', () => {
126+
render(
127+
<NavigationOverlay
128+
{...createProps({
129+
mode: 'modal',
130+
description: 'View account information',
131+
})}
132+
/>
133+
);
134+
expect(screen.getByText('View account information')).toBeInTheDocument();
135+
});
136+
});
137+
138+
// ============================================================
139+
// Split mode (ResizablePanelGroup)
140+
// ============================================================
141+
142+
describe('split mode', () => {
143+
it('should render split panels with main content and record detail', () => {
144+
render(
145+
<NavigationOverlay
146+
{...createProps({
147+
mode: 'split',
148+
mainContent: <div data-testid="main">Main Content</div>,
149+
})}
150+
/>
151+
);
152+
expect(screen.getByTestId('main')).toBeInTheDocument();
153+
expect(screen.getByText('Test Record')).toBeInTheDocument();
154+
});
155+
156+
it('should render nothing when mainContent is not provided', () => {
157+
const { container } = render(
158+
<NavigationOverlay
159+
{...createProps({ mode: 'split', mainContent: undefined })}
160+
/>
161+
);
162+
expect(container.innerHTML).toBe('');
163+
});
164+
165+
it('should render close button in split panel', () => {
166+
render(
167+
<NavigationOverlay
168+
{...createProps({
169+
mode: 'split',
170+
mainContent: <div>Main</div>,
171+
})}
172+
/>
173+
);
174+
expect(screen.getByLabelText('Close panel')).toBeInTheDocument();
175+
});
176+
177+
it('should call close when close button clicked', () => {
178+
const close = vi.fn();
179+
render(
180+
<NavigationOverlay
181+
{...createProps({
182+
mode: 'split',
183+
close,
184+
mainContent: <div>Main</div>,
185+
})}
186+
/>
187+
);
188+
189+
fireEvent.click(screen.getByLabelText('Close panel'));
190+
expect(close).toHaveBeenCalled();
191+
});
192+
});
193+
194+
// ============================================================
195+
// Popover mode
196+
// ============================================================
197+
198+
describe('popover mode', () => {
199+
it('should render popover with record content when open', () => {
200+
render(
201+
<NavigationOverlay
202+
{...createProps({
203+
mode: 'popover',
204+
popoverTrigger: <button>Trigger</button>,
205+
})}
206+
/>
207+
);
208+
expect(screen.getByText('Test Record')).toBeInTheDocument();
209+
});
210+
211+
it('should render title in popover', () => {
212+
render(
213+
<NavigationOverlay
214+
{...createProps({
215+
mode: 'popover',
216+
title: 'Quick View',
217+
})}
218+
/>
219+
);
220+
expect(screen.getByText('Quick View')).toBeInTheDocument();
221+
});
222+
});
223+
224+
// ============================================================
225+
// Width handling
226+
// ============================================================
227+
228+
describe('width handling', () => {
229+
it('should render drawer with string width without error', () => {
230+
render(
231+
<NavigationOverlay
232+
{...createProps({ mode: 'drawer', width: '600px' })}
233+
/>
234+
);
235+
expect(screen.getByText('Test Record')).toBeInTheDocument();
236+
});
237+
238+
it('should render modal with numeric width without error', () => {
239+
render(
240+
<NavigationOverlay
241+
{...createProps({ mode: 'modal', width: 800 })}
242+
/>
243+
);
244+
expect(screen.getByText('Test Record')).toBeInTheDocument();
245+
});
246+
});
247+
248+
// ============================================================
249+
// Children render prop
250+
// ============================================================
251+
252+
describe('children render prop', () => {
253+
it('should pass the selected record to children', () => {
254+
const record = { _id: '42', name: 'Jane Doe', status: 'active' };
255+
render(
256+
<NavigationOverlay
257+
{...createProps({
258+
mode: 'drawer',
259+
selectedRecord: record,
260+
children: (r: Record<string, unknown>) => (
261+
<div>
262+
<span data-testid="name">{String(r.name)}</span>
263+
<span data-testid="status">{String(r.status)}</span>
264+
</div>
265+
),
266+
})}
267+
/>
268+
);
269+
expect(screen.getByTestId('name')).toHaveTextContent('Jane Doe');
270+
expect(screen.getByTestId('status')).toHaveTextContent('active');
271+
});
272+
});
273+
});

packages/components/src/custom/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,5 +8,6 @@ export * from './input-group';
88
export * from './item';
99
export * from './kbd';
1010
export * from './native-select';
11+
export * from './navigation-overlay';
1112
export * from './spinner';
1213
export * from './sort-builder';

0 commit comments

Comments
 (0)