Skip to content

Commit df7deed

Browse files
authored
Merge pull request #834 from objectstack-ai/copilot/fix-listview-live-preview
2 parents 8b93a0d + aa7b14b commit df7deed

File tree

3 files changed

+221
-2
lines changed

3 files changed

+221
-2
lines changed

ROADMAP.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,8 @@ ObjectUI is a universal Server-Driven UI (SDUI) engine built on React + Tailwind
259259
7. ~~**Export toggle broken:** ViewConfigPanel writes `allowExport: boolean` but ListView checks `exportOptions` object~~ → Export now checks both `exportOptions && allowExport !== false`; Console clears `exportOptions` when `allowExport === false` (Issue #719)
260260
8. ~~**`hasExport` logic bug:** `draft.allowExport !== false` was always true when undefined~~ → Fixed to `draft.allowExport === true || draft.exportOptions != null` (Issue #719)
261261
9. **No per-view-type integration tests:** Pending — tests verify config reaches `fullSchema`, but per-renderer integration tests still needed
262+
10. ~~**`key={refreshKey}` on PluginObjectView:** Console wrapped PluginObjectView with `key={refreshKey}`, which only changed on save/create, preventing live preview of config changes~~ → Removed `key={refreshKey}`; props changes now flow naturally without remounting (Issue #784)
263+
11. ~~**Navigation overlay not consuming `activeView.navigation`:** Detail overlay only read `objectDef.navigation`, ignoring view-level navigation config~~ → Navigation now uses priority: `activeView.navigation > objectDef.navigation > default drawer` (Issue #784)
262264

263265
**Phase 1 — Grid/Table View (baseline, already complete):**
264266
- [x] `gridSchema` includes `striped`/`bordered` from `activeView`

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

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,27 @@ vi.mock('@object-ui/plugin-calendar', () => ({
1717
ObjectCalendar: (props: any) => <div data-testid="object-calendar">Calendar View: {props.schema.dateField}</div>
1818
}));
1919

20+
// Mock ListView to a simple component that renders schema properties as test IDs
21+
// This isolates the config panel → view data flow from ListView's internal async effects
22+
vi.mock('@object-ui/plugin-list', () => ({
23+
ListView: (props: any) => {
24+
const viewType = props.schema?.viewType || 'grid';
25+
return (
26+
<div data-testid="list-view" data-view-type={viewType}>
27+
{viewType === 'grid' && <div data-testid="object-grid">Grid View: {props.schema?.objectName}</div>}
28+
{viewType === 'kanban' && <div data-testid="object-kanban">Kanban View: {props.schema?.options?.kanban?.groupField || props.schema?.groupBy}</div>}
29+
{viewType === 'calendar' && <div data-testid="object-calendar">Calendar View: {props.schema?.options?.calendar?.startDateField || props.schema?.startDateField}</div>}
30+
{props.schema?.showRecordCount && <div data-testid="schema-showRecordCount">showRecordCount</div>}
31+
{props.schema?.allowPrinting && <div data-testid="schema-allowPrinting">allowPrinting</div>}
32+
{props.schema?.navigation?.mode && <div data-testid="schema-navigation-mode">{props.schema.navigation.mode}</div>}
33+
{props.schema?.selection?.type && <div data-testid="schema-selection-type">{props.schema.selection.type}</div>}
34+
{props.schema?.addRecord?.enabled && <div data-testid="schema-addRecord-enabled">addRecord</div>}
35+
{props.schema?.addRecordViaForm && <div data-testid="schema-addRecordViaForm">addRecordViaForm</div>}
36+
</div>
37+
);
38+
},
39+
}));
40+
2041
vi.mock('@object-ui/components', async (importOriginal) => {
2142
const React = await import('react');
2243
const MockTabsContext = React.createContext({ onValueChange: ( _v: any) => {} });
@@ -543,4 +564,200 @@ describe('ObjectView Component', () => {
543564
expect(breadcrumbItems.length).toBeGreaterThanOrEqual(1);
544565
});
545566
});
567+
568+
it('does not remount PluginObjectView on config panel changes (no key={refreshKey})', async () => {
569+
mockAuthUser = { id: 'u1', name: 'Admin', role: 'admin' };
570+
mockUseParams.mockReturnValue({ objectName: 'opportunity' });
571+
572+
render(<ObjectView dataSource={mockDataSource} objects={mockObjects} onEdit={vi.fn()} />);
573+
574+
// The grid should be rendered initially
575+
expect(screen.getByTestId('object-grid')).toBeInTheDocument();
576+
577+
// Open config panel
578+
fireEvent.click(screen.getByTitle('console.objectView.designTools'));
579+
fireEvent.click(screen.getByText('console.objectView.editView'));
580+
581+
// Make a change
582+
const titleInput = await screen.findByDisplayValue('All Opportunities');
583+
fireEvent.change(titleInput, { target: { value: 'Changed Live' } });
584+
585+
// The breadcrumb updates immediately (live preview) — this verifies that
586+
// viewDraft → activeView data flow propagates config changes without save.
587+
await vi.waitFor(() => {
588+
expect(screen.getByText('Changed Live')).toBeInTheDocument();
589+
});
590+
591+
// Grid persists after config change (no remount)
592+
expect(screen.getByTestId('object-grid')).toBeInTheDocument();
593+
});
594+
595+
it('propagates showRecordCount toggle to ListView schema in real-time', async () => {
596+
mockAuthUser = { id: 'u1', name: 'Admin', role: 'admin' };
597+
mockUseParams.mockReturnValue({ objectName: 'opportunity' });
598+
599+
render(<ObjectView dataSource={mockDataSource} objects={mockObjects} onEdit={vi.fn()} />);
600+
601+
// showRecordCount should not be set initially (default is not explicitly true)
602+
expect(screen.queryByTestId('schema-showRecordCount')).not.toBeInTheDocument();
603+
604+
// Open config panel
605+
fireEvent.click(screen.getByTitle('console.objectView.designTools'));
606+
fireEvent.click(screen.getByText('console.objectView.editView'));
607+
608+
// Toggle showRecordCount on
609+
const recordCountSwitch = screen.getByTestId('toggle-showRecordCount');
610+
fireEvent.click(recordCountSwitch);
611+
612+
// Verify the schema property propagated to ListView immediately (live preview)
613+
await vi.waitFor(() => {
614+
expect(screen.getByTestId('schema-showRecordCount')).toBeInTheDocument();
615+
});
616+
});
617+
618+
it('propagates allowPrinting toggle to ListView schema in real-time', async () => {
619+
mockAuthUser = { id: 'u1', name: 'Admin', role: 'admin' };
620+
mockUseParams.mockReturnValue({ objectName: 'opportunity' });
621+
622+
render(<ObjectView dataSource={mockDataSource} objects={mockObjects} onEdit={vi.fn()} />);
623+
624+
// allowPrinting should not be set initially
625+
expect(screen.queryByTestId('schema-allowPrinting')).not.toBeInTheDocument();
626+
627+
// Open config panel
628+
fireEvent.click(screen.getByTitle('console.objectView.designTools'));
629+
fireEvent.click(screen.getByText('console.objectView.editView'));
630+
631+
// Toggle allowPrinting on
632+
const printSwitch = screen.getByTestId('toggle-allowPrinting');
633+
fireEvent.click(printSwitch);
634+
635+
// Verify the schema property propagated to ListView immediately (live preview)
636+
await vi.waitFor(() => {
637+
expect(screen.getByTestId('schema-allowPrinting')).toBeInTheDocument();
638+
});
639+
});
640+
641+
it('propagates multiple config changes without requiring save', async () => {
642+
mockAuthUser = { id: 'u1', name: 'Admin', role: 'admin' };
643+
mockUseParams.mockReturnValue({ objectName: 'opportunity' });
644+
645+
render(<ObjectView dataSource={mockDataSource} objects={mockObjects} onEdit={vi.fn()} />);
646+
647+
// Open config panel
648+
fireEvent.click(screen.getByTitle('console.objectView.designTools'));
649+
fireEvent.click(screen.getByText('console.objectView.editView'));
650+
651+
// Toggle showSearch off
652+
const searchSwitch = screen.getByTestId('toggle-showSearch');
653+
fireEvent.click(searchSwitch);
654+
655+
// Toggle showSort off
656+
const sortSwitch = screen.getByTestId('toggle-showSort');
657+
fireEvent.click(sortSwitch);
658+
659+
// Both should reflect changes immediately without save
660+
await vi.waitFor(() => {
661+
expect(screen.getByTestId('toggle-showSearch').getAttribute('aria-checked')).toBe('false');
662+
expect(screen.getByTestId('toggle-showSort').getAttribute('aria-checked')).toBe('false');
663+
});
664+
665+
// The grid should still be rendered (live preview, no remount)
666+
expect(screen.getByTestId('object-grid')).toBeInTheDocument();
667+
});
668+
669+
it('uses activeView.navigation for detail overlay with priority over objectDef', () => {
670+
mockAuthUser = { id: 'u1', name: 'Admin', role: 'admin' };
671+
const objectsWithNav = [
672+
{
673+
...mockObjects[0],
674+
navigation: { mode: 'drawer' as const },
675+
listViews: {
676+
all: {
677+
label: 'All Opportunities',
678+
type: 'grid',
679+
columns: ['name', 'stage'],
680+
navigation: { mode: 'modal' as const },
681+
},
682+
pipeline: { label: 'Pipeline', type: 'kanban', kanban: { groupField: 'stage' }, columns: ['name'] }
683+
}
684+
}
685+
];
686+
mockUseParams.mockReturnValue({ objectName: 'opportunity' });
687+
688+
// Render the component — activeView.navigation should override objectDef.navigation
689+
render(<ObjectView dataSource={mockDataSource} objects={objectsWithNav} onEdit={vi.fn()} />);
690+
691+
// The component should render without errors and ListView should receive
692+
// the view-level navigation config (modal) instead of object-level (drawer)
693+
expect(screen.getByTestId('object-grid')).toBeInTheDocument();
694+
expect(screen.getByTestId('schema-navigation-mode')).toHaveTextContent('modal');
695+
});
696+
697+
it('propagates selection mode change to ListView schema in real-time', async () => {
698+
mockAuthUser = { id: 'u1', name: 'Admin', role: 'admin' };
699+
mockUseParams.mockReturnValue({ objectName: 'opportunity' });
700+
701+
render(<ObjectView dataSource={mockDataSource} objects={mockObjects} onEdit={vi.fn()} />);
702+
703+
// Open config panel
704+
fireEvent.click(screen.getByTitle('console.objectView.designTools'));
705+
fireEvent.click(screen.getByText('console.objectView.editView'));
706+
707+
// Change selection mode to 'single'
708+
const selectionSelect = screen.getByTestId('select-selection-type');
709+
fireEvent.change(selectionSelect, { target: { value: 'single' } });
710+
711+
// Verify the selection type propagated to ListView immediately (live preview)
712+
await vi.waitFor(() => {
713+
expect(screen.getByTestId('schema-selection-type')).toHaveTextContent('single');
714+
});
715+
});
716+
717+
it('propagates addRecord toggle to ListView schema in real-time', async () => {
718+
mockAuthUser = { id: 'u1', name: 'Admin', role: 'admin' };
719+
mockUseParams.mockReturnValue({ objectName: 'opportunity' });
720+
721+
render(<ObjectView dataSource={mockDataSource} objects={mockObjects} onEdit={vi.fn()} />);
722+
723+
// addRecord should not be enabled initially
724+
expect(screen.queryByTestId('schema-addRecord-enabled')).not.toBeInTheDocument();
725+
726+
// Open config panel
727+
fireEvent.click(screen.getByTitle('console.objectView.designTools'));
728+
fireEvent.click(screen.getByText('console.objectView.editView'));
729+
730+
// Toggle addRecord on
731+
const addRecordSwitch = screen.getByTestId('toggle-addRecord-enabled');
732+
fireEvent.click(addRecordSwitch);
733+
734+
// Verify addRecord and addRecordViaForm propagated to ListView immediately
735+
await vi.waitFor(() => {
736+
expect(screen.getByTestId('schema-addRecord-enabled')).toBeInTheDocument();
737+
expect(screen.getByTestId('schema-addRecordViaForm')).toBeInTheDocument();
738+
});
739+
});
740+
741+
it('propagates navigation mode change from config panel to ListView schema in real-time', async () => {
742+
mockAuthUser = { id: 'u1', name: 'Admin', role: 'admin' };
743+
mockUseParams.mockReturnValue({ objectName: 'opportunity' });
744+
745+
render(<ObjectView dataSource={mockDataSource} objects={mockObjects} onEdit={vi.fn()} />);
746+
747+
// navigation mode should not be set initially (no explicit mode on default view)
748+
expect(screen.queryByTestId('schema-navigation-mode')).not.toBeInTheDocument();
749+
750+
// Open config panel
751+
fireEvent.click(screen.getByTitle('console.objectView.designTools'));
752+
fireEvent.click(screen.getByText('console.objectView.editView'));
753+
754+
// Change navigation mode to 'modal'
755+
const navSelect = screen.getByTestId('select-navigation-mode');
756+
fireEvent.change(navSelect, { target: { value: 'modal' } });
757+
758+
// Verify navigation mode propagated to ListView schema immediately (live preview)
759+
await vi.waitFor(() => {
760+
expect(screen.getByTestId('schema-navigation-mode')).toHaveTextContent('modal');
761+
});
762+
});
546763
});

apps/console/src/components/ObjectView.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -230,7 +230,8 @@ export function ObjectView({ dataSource, objects, onEdit, onRowClick }: any) {
230230
}, [dataSource, objectDef.name, refreshKey]);
231231

232232
// Navigation overlay for record detail (supports drawer/modal/split/popover via config)
233-
const detailNavigation: ViewNavigationConfig = objectDef.navigation ?? { mode: 'drawer' };
233+
// Priority: activeView.navigation > objectDef.navigation > default drawer
234+
const detailNavigation: ViewNavigationConfig = activeView?.navigation ?? objectDef.navigation ?? { mode: 'drawer' };
234235
const drawerRecordId = searchParams.get('recordId');
235236
const navOverlay = useNavigationOverlay({
236237
navigation: detailNavigation,
@@ -573,7 +574,6 @@ export function ObjectView({ dataSource, objects, onEdit, onRowClick }: any) {
573574
<div className="flex-1 min-w-0 relative h-full flex flex-col">
574575
<div className="flex-1 relative overflow-auto p-3 sm:p-4">
575576
<PluginObjectView
576-
key={refreshKey}
577577
schema={objectViewSchema}
578578
dataSource={dataSource}
579579
views={mergedViews}

0 commit comments

Comments
 (0)