Skip to content

Commit 72c9709

Browse files
authored
Merge pull request #886 from objectstack-ai/copilot/fix-listview-navigation-issues
2 parents 788f4ab + 00cb5cd commit 72c9709

File tree

9 files changed

+289
-6
lines changed

9 files changed

+289
-6
lines changed

ROADMAP.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,13 @@ ObjectUI is a universal Server-Driven UI (SDUI) engine built on React + Tailwind
200200
- [x] Navigation Sync Service — `useNavigationSync` hook auto-syncs App navigation tree on Page/Dashboard CRUD (create, delete, rename) with toast + undo
201201
- [x] Navigation Sync auto-detection — `NavigationSyncEffect` component monitors metadata changes and auto-syncs navigation across ALL apps when pages/dashboards are added or removed
202202
- [x] Navigation Sync all-apps convenience API — `sync*AllApps` methods iterate all apps without requiring explicit `appName`
203+
- [x] **ListView Navigation Mode Fix** — All 6 navigation modes (page/drawer/modal/split/popover/new_window) now work correctly on row click:
204+
-`page` mode navigates to `/record/:recordId` detail page via React Router
205+
-`new_window` mode opens correct Console URL in a new browser tab (delegates to `onNavigate`)
206+
-`split` mode renders resizable split panels with main content + detail panel
207+
-`popover` mode falls back to compact dialog when no `popoverTrigger` is provided
208+
-`useNavigationOverlay` hook delegates `new_window` to `onNavigate` when available for app-specific URL control
209+
- ✅ plugin-view `handleRowClick` supports `split` and `popover` branches
203210

204211
### P1.8 Console — View Config Panel (Phase 20)
205212

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

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ vi.mock('@object-ui/plugin-list', () => ({
3333
{props.schema?.selection?.type && <div data-testid="schema-selection-type">{props.schema.selection.type}</div>}
3434
{props.schema?.addRecord?.enabled && <div data-testid="schema-addRecord-enabled">addRecord</div>}
3535
{props.schema?.addRecordViaForm && <div data-testid="schema-addRecordViaForm">addRecordViaForm</div>}
36+
<button data-testid="list-row-click" onClick={() => props.onRowClick?.({ _id: 'rec-1', id: 'rec-1', name: 'Test Record' })}>Click Row</button>
3637
</div>
3738
);
3839
},
@@ -762,6 +763,102 @@ describe('ObjectView Component', () => {
762763
expect(screen.getByTestId('object-grid')).toBeInTheDocument();
763764
});
764765

766+
it('navigates to record detail page for page navigation mode via onNavigate', () => {
767+
const objectsWithPage = [
768+
{
769+
...mockObjects[0],
770+
navigation: { mode: 'page' as const },
771+
}
772+
];
773+
mockUseParams.mockReturnValue({ objectName: 'opportunity' });
774+
775+
render(<ObjectView dataSource={mockDataSource} objects={objectsWithPage} onEdit={vi.fn()} />);
776+
777+
// Click a list row — should trigger page navigation
778+
fireEvent.click(screen.getByTestId('list-row-click'));
779+
780+
// Verify React Router navigate was called with the record detail path
781+
expect(mockNavigate).toHaveBeenCalledWith('record/rec-1');
782+
});
783+
784+
it('opens new window with correct URL for new_window navigation mode', () => {
785+
const mockOpen = vi.fn();
786+
const originalOpen = window.open;
787+
window.open = mockOpen;
788+
789+
const objectsWithNewWindow = [
790+
{
791+
...mockObjects[0],
792+
navigation: { mode: 'new_window' as const },
793+
}
794+
];
795+
mockUseParams.mockReturnValue({ objectName: 'opportunity' });
796+
797+
render(<ObjectView dataSource={mockDataSource} objects={objectsWithNewWindow} onEdit={vi.fn()} />);
798+
799+
// Click a list row — should open new window
800+
fireEvent.click(screen.getByTestId('list-row-click'));
801+
802+
// Verify window.open was called with a URL containing /record/rec-1
803+
expect(mockOpen).toHaveBeenCalledTimes(1);
804+
expect(mockOpen.mock.calls[0][0]).toContain('/record/rec-1');
805+
expect(mockOpen.mock.calls[0][1]).toBe('_blank');
806+
807+
window.open = originalOpen;
808+
});
809+
810+
it('renders split layout with mainContent when split mode is active', async () => {
811+
const objectsWithSplit = [
812+
{
813+
...mockObjects[0],
814+
navigation: { mode: 'split' as const },
815+
}
816+
];
817+
mockUseParams.mockReturnValue({ objectName: 'opportunity' });
818+
819+
const dataSourceWithFindOne = {
820+
...mockDataSource,
821+
findOne: vi.fn().mockResolvedValue({ _id: 'rec-1', id: 'rec-1', name: 'Test' }),
822+
};
823+
824+
render(<ObjectView dataSource={dataSourceWithFindOne} objects={objectsWithSplit} onEdit={vi.fn()} />);
825+
826+
// Click a list row — should open the split detail panel
827+
fireEvent.click(screen.getByTestId('list-row-click'));
828+
829+
// The grid should still render inside the split layout
830+
await vi.waitFor(() => {
831+
expect(screen.getByTestId('object-grid')).toBeInTheDocument();
832+
// Split mode should render the close button for the detail panel
833+
expect(screen.getByLabelText('Close panel')).toBeInTheDocument();
834+
});
835+
});
836+
837+
it('renders popover overlay without popoverTrigger using fallback dialog', async () => {
838+
const objectsWithPopover = [
839+
{
840+
...mockObjects[0],
841+
navigation: { mode: 'popover' as const },
842+
}
843+
];
844+
mockUseParams.mockReturnValue({ objectName: 'opportunity' });
845+
846+
const dataSourceWithFindOne = {
847+
...mockDataSource,
848+
findOne: vi.fn().mockResolvedValue({ _id: 'rec-1', id: 'rec-1', name: 'Test' }),
849+
};
850+
851+
render(<ObjectView dataSource={dataSourceWithFindOne} objects={objectsWithPopover} onEdit={vi.fn()} />);
852+
853+
// Click a list row — should open the popover fallback dialog
854+
fireEvent.click(screen.getByTestId('list-row-click'));
855+
856+
// The popover fallback dialog should render (dialog role from Radix)
857+
await vi.waitFor(() => {
858+
expect(screen.getByRole('dialog')).toBeInTheDocument();
859+
});
860+
});
861+
765862
it('renders RecordChatterPanel inside drawer overlay when navigation mode is drawer', async () => {
766863
mockAuthUser = { id: 'u1', name: 'Admin', role: 'admin' };
767864
// Provide recordId in URL to trigger overlay open

apps/console/src/components/ObjectView.tsx

Lines changed: 65 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -425,10 +425,19 @@ export function ObjectView({ dataSource, objects, onEdit, onRowClick }: any) {
425425
const navOverlay = useNavigationOverlay({
426426
navigation: detailNavigation,
427427
objectName: objectDef.name,
428-
onNavigate: (recordId: string | number, _action?: string) => {
429-
const newParams = new URLSearchParams(searchParams);
430-
newParams.set('recordId', String(recordId));
431-
setSearchParams(newParams);
428+
onNavigate: (recordId: string | number, action?: string) => {
429+
if (action === 'new_window') {
430+
// Open record detail in a new browser tab with Console-correct URL
431+
const basePath = window.location.pathname.replace(/\/view\/.*$/, '');
432+
window.open(`${basePath}/record/${String(recordId)}`, '_blank');
433+
return;
434+
}
435+
// page / view mode — navigate to record detail page
436+
if (viewId) {
437+
navigate(`../../record/${String(recordId)}`, { relative: 'path' });
438+
} else {
439+
navigate(`record/${String(recordId)}`);
440+
}
432441
},
433442
});
434443
const handleDrawerClose = () => {
@@ -633,9 +642,15 @@ export function ObjectView({ dataSource, objects, onEdit, onRowClick }: any) {
633642
onNavigate: (recordId: string | number, mode: 'view' | 'edit') => {
634643
if (mode === 'edit') {
635644
onEdit?.({ _id: recordId, id: recordId });
645+
} else if (mode === 'view') {
646+
if (viewId) {
647+
navigate(`../../record/${String(recordId)}`, { relative: 'path' });
648+
} else {
649+
navigate(`record/${String(recordId)}`);
650+
}
636651
}
637652
},
638-
}), [objectDef.name, onEdit, activeView?.showSearch, activeView?.showFilters, activeView?.showSort]);
653+
}), [objectDef.name, onEdit, activeView?.showSearch, activeView?.showFilters, activeView?.showSort, navigate, viewId]);
639654

640655
return (
641656
<div className="h-full flex flex-col bg-background min-w-0 overflow-hidden">
@@ -767,6 +782,48 @@ export function ObjectView({ dataSource, objects, onEdit, onRowClick }: any) {
767782

768783
{/* 2. Content — Plugin ObjectView with ViewSwitcher + Filter + Sort */}
769784
<div className="flex-1 overflow-hidden relative flex flex-row">
785+
{navOverlay.mode === 'split' && navOverlay.isOpen ? (
786+
<NavigationOverlay
787+
{...navOverlay}
788+
setIsOpen={(open: boolean) => { if (!open) handleDrawerClose(); }}
789+
title={objectDef.label}
790+
mainContent={
791+
<div className="flex-1 min-w-0 relative h-full flex flex-col">
792+
<div className="flex-1 relative overflow-auto p-3 sm:p-4">
793+
<PluginObjectView
794+
schema={objectViewSchema}
795+
dataSource={dataSource}
796+
views={mergedViews}
797+
activeViewId={activeViewId}
798+
onViewChange={handleViewChange}
799+
onEdit={(record: any) => onEdit?.(record)}
800+
onRowClick={onRowClick || ((record: any) => {
801+
navOverlay.handleClick(record);
802+
})}
803+
renderListView={renderListView}
804+
/>
805+
</div>
806+
{typeof recordCount === 'number' && (
807+
<div data-testid="record-count-footer" className="border-t px-3 sm:px-4 py-1.5 text-xs text-muted-foreground bg-muted/5 shrink-0">
808+
{t('console.objectView.recordCount', { count: recordCount })}
809+
</div>
810+
)}
811+
</div>
812+
}
813+
>
814+
{(record: Record<string, unknown>) => {
815+
const recordId = (record._id || record.id) as string;
816+
return (
817+
<DrawerDetailContent
818+
objectDef={objectDef}
819+
recordId={recordId}
820+
dataSource={dataSource}
821+
onEdit={onEdit}
822+
/>
823+
);
824+
}}
825+
</NavigationOverlay>
826+
) : (
770827
<div className="flex-1 min-w-0 relative h-full flex flex-col">
771828
<div className="flex-1 relative overflow-auto p-3 sm:p-4">
772829
<PluginObjectView
@@ -789,6 +846,7 @@ export function ObjectView({ dataSource, objects, onEdit, onRowClick }: any) {
789846
</div>
790847
)}
791848
</div>
849+
)}
792850
{/* Metadata panel only shows for admin users */}
793851
<MetadataPanel
794852
open={showDebug && isAdmin}
@@ -819,6 +877,7 @@ export function ObjectView({ dataSource, objects, onEdit, onRowClick }: any) {
819877
</div>
820878

821879
{/* Record Detail Overlay — navigation mode driven by objectDef.navigation */}
880+
{navOverlay.mode !== 'split' && (
822881
<NavigationOverlay
823882
{...navOverlay}
824883
setIsOpen={(open: boolean) => { if (!open) handleDrawerClose(); }}
@@ -837,6 +896,7 @@ export function ObjectView({ dataSource, objects, onEdit, onRowClick }: any) {
837896
);
838897
}}
839898
</NavigationOverlay>
899+
)}
840900
</div>
841901
);
842902
}

packages/components/src/__tests__/navigation-overlay.test.tsx

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,31 @@ describe('NavigationOverlay', () => {
219219
);
220220
expect(screen.getByText('Quick View')).toBeInTheDocument();
221221
});
222+
223+
it('should render fallback dialog when no popoverTrigger is provided', () => {
224+
render(
225+
<NavigationOverlay
226+
{...createProps({
227+
mode: 'popover',
228+
title: 'Preview',
229+
})}
230+
/>
231+
);
232+
expect(screen.getByText('Preview')).toBeInTheDocument();
233+
expect(screen.getByText('Test Record')).toBeInTheDocument();
234+
});
235+
236+
it('should not render fallback dialog when closed and no popoverTrigger', () => {
237+
const { container } = render(
238+
<NavigationOverlay
239+
{...createProps({
240+
mode: 'popover',
241+
isOpen: false,
242+
})}
243+
/>
244+
);
245+
expect(container.innerHTML).toBe('');
246+
});
222247
});
223248

224249
// ============================================================

packages/components/src/custom/navigation-overlay.tsx

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,26 @@ export const NavigationOverlay: React.FC<NavigationOverlayProps> = ({
285285

286286
// --- Popover Mode ---
287287
if (mode === 'popover') {
288+
if (!popoverTrigger) {
289+
// Fallback: render as a compact floating card when no trigger element is provided
290+
if (!isOpen) return null;
291+
return (
292+
<Dialog open={isOpen} onOpenChange={setIsOpen}>
293+
<DialogContent
294+
className={cn('w-96 max-h-[80vh] overflow-y-auto p-4', className)}
295+
style={widthStyle}
296+
>
297+
<DialogHeader>
298+
<DialogTitle className="text-sm">{resolvedTitle}</DialogTitle>
299+
{description && <DialogDescription className="text-xs">{description}</DialogDescription>}
300+
</DialogHeader>
301+
<div className="mt-2">
302+
{renderContent(selectedRecord)}
303+
</div>
304+
</DialogContent>
305+
</Dialog>
306+
);
307+
}
288308
return (
289309
<Popover open={isOpen} onOpenChange={setIsOpen}>
290310
{popoverTrigger && (

packages/plugin-view/src/ObjectView.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -425,6 +425,12 @@ export const ObjectView: React.FC<ObjectViewProps> = ({
425425
}
426426
return;
427427
}
428+
if (navigationConfig.mode === 'split' || navigationConfig.mode === 'popover') {
429+
setFormMode('view');
430+
setSelectedRecord(record);
431+
setIsFormOpen(true);
432+
return;
433+
}
428434
}
429435

430436
// Default behavior

packages/plugin-view/src/__tests__/ObjectView.test.tsx

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -288,6 +288,36 @@ describe('ObjectView', () => {
288288

289289
expect(onNavigate).toHaveBeenCalledWith('1', 'view');
290290
});
291+
292+
it('should open form in view mode when split navigation mode is clicked', () => {
293+
const schema: ObjectViewSchema = {
294+
type: 'object-view',
295+
objectName: 'contacts',
296+
navigation: { mode: 'split' },
297+
};
298+
299+
render(<ObjectView schema={schema} dataSource={mockDataSource} />);
300+
301+
fireEvent.click(screen.getByTestId('grid-row'));
302+
303+
// Split mode should trigger form display in view mode
304+
expect(screen.getByTestId('object-form')).toBeDefined();
305+
});
306+
307+
it('should open form in view mode when popover navigation mode is clicked', () => {
308+
const schema: ObjectViewSchema = {
309+
type: 'object-view',
310+
objectName: 'contacts',
311+
navigation: { mode: 'popover' },
312+
};
313+
314+
render(<ObjectView schema={schema} dataSource={mockDataSource} />);
315+
316+
fireEvent.click(screen.getByTestId('grid-row'));
317+
318+
// Popover mode should trigger form display in view mode
319+
expect(screen.getByTestId('object-form')).toBeDefined();
320+
});
291321
});
292322

293323
// ============================

packages/react/src/__tests__/useNavigationOverlay.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,40 @@ describe('useNavigationOverlay', () => {
198198

199199
expect(mockWindowOpen).toHaveBeenCalledWith('/users/456', '_blank');
200200
});
201+
202+
it('should delegate to onNavigate with new_window action when onNavigate is provided', () => {
203+
const onNavigate = vi.fn();
204+
const { result } = renderHook(() =>
205+
useNavigationOverlay({
206+
navigation: { mode: 'new_window' },
207+
objectName: 'contacts',
208+
onNavigate,
209+
})
210+
);
211+
212+
act(() => {
213+
result.current.handleClick({ _id: '789' });
214+
});
215+
216+
expect(onNavigate).toHaveBeenCalledWith('789', 'new_window');
217+
expect(mockWindowOpen).not.toHaveBeenCalled();
218+
expect(result.current.isOpen).toBe(false);
219+
});
220+
221+
it('should fallback to window.open when onNavigate is not provided', () => {
222+
const { result } = renderHook(() =>
223+
useNavigationOverlay({
224+
navigation: { mode: 'new_window' },
225+
objectName: 'accounts',
226+
})
227+
);
228+
229+
act(() => {
230+
result.current.handleClick({ _id: '101' });
231+
});
232+
233+
expect(mockWindowOpen).toHaveBeenCalledWith('/accounts/101', '_blank');
234+
});
201235
});
202236

203237
// ============================================================

0 commit comments

Comments
 (0)