diff --git a/ROADMAP.md b/ROADMAP.md index 771ba20e1..8474cb72c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -259,6 +259,8 @@ ObjectUI is a universal Server-Driven UI (SDUI) engine built on React + Tailwind 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) 8. ~~**`hasExport` logic bug:** `draft.allowExport !== false` was always true when undefined~~ → Fixed to `draft.allowExport === true || draft.exportOptions != null` (Issue #719) 9. **No per-view-type integration tests:** Pending — tests verify config reaches `fullSchema`, but per-renderer integration tests still needed +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) +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) **Phase 1 — Grid/Table View (baseline, already complete):** - [x] `gridSchema` includes `striped`/`bordered` from `activeView` diff --git a/apps/console/src/__tests__/ObjectView.test.tsx b/apps/console/src/__tests__/ObjectView.test.tsx index 6321dc9a4..80edce5bf 100644 --- a/apps/console/src/__tests__/ObjectView.test.tsx +++ b/apps/console/src/__tests__/ObjectView.test.tsx @@ -17,6 +17,27 @@ vi.mock('@object-ui/plugin-calendar', () => ({ ObjectCalendar: (props: any) =>
Calendar View: {props.schema.dateField}
})); +// Mock ListView to a simple component that renders schema properties as test IDs +// This isolates the config panel → view data flow from ListView's internal async effects +vi.mock('@object-ui/plugin-list', () => ({ + ListView: (props: any) => { + const viewType = props.schema?.viewType || 'grid'; + return ( +
+ {viewType === 'grid' &&
Grid View: {props.schema?.objectName}
} + {viewType === 'kanban' &&
Kanban View: {props.schema?.options?.kanban?.groupField || props.schema?.groupBy}
} + {viewType === 'calendar' &&
Calendar View: {props.schema?.options?.calendar?.startDateField || props.schema?.startDateField}
} + {props.schema?.showRecordCount &&
showRecordCount
} + {props.schema?.allowPrinting &&
allowPrinting
} + {props.schema?.navigation?.mode &&
{props.schema.navigation.mode}
} + {props.schema?.selection?.type &&
{props.schema.selection.type}
} + {props.schema?.addRecord?.enabled &&
addRecord
} + {props.schema?.addRecordViaForm &&
addRecordViaForm
} +
+ ); + }, +})); + vi.mock('@object-ui/components', async (importOriginal) => { const React = await import('react'); const MockTabsContext = React.createContext({ onValueChange: ( _v: any) => {} }); @@ -543,4 +564,200 @@ describe('ObjectView Component', () => { expect(breadcrumbItems.length).toBeGreaterThanOrEqual(1); }); }); + + it('does not remount PluginObjectView on config panel changes (no key={refreshKey})', async () => { + mockAuthUser = { id: 'u1', name: 'Admin', role: 'admin' }; + mockUseParams.mockReturnValue({ objectName: 'opportunity' }); + + render(); + + // The grid should be rendered initially + expect(screen.getByTestId('object-grid')).toBeInTheDocument(); + + // Open config panel + fireEvent.click(screen.getByTitle('console.objectView.designTools')); + fireEvent.click(screen.getByText('console.objectView.editView')); + + // Make a change + const titleInput = await screen.findByDisplayValue('All Opportunities'); + fireEvent.change(titleInput, { target: { value: 'Changed Live' } }); + + // The breadcrumb updates immediately (live preview) — this verifies that + // viewDraft → activeView data flow propagates config changes without save. + await vi.waitFor(() => { + expect(screen.getByText('Changed Live')).toBeInTheDocument(); + }); + + // Grid persists after config change (no remount) + expect(screen.getByTestId('object-grid')).toBeInTheDocument(); + }); + + it('propagates showRecordCount toggle to ListView schema in real-time', async () => { + mockAuthUser = { id: 'u1', name: 'Admin', role: 'admin' }; + mockUseParams.mockReturnValue({ objectName: 'opportunity' }); + + render(); + + // showRecordCount should not be set initially (default is not explicitly true) + expect(screen.queryByTestId('schema-showRecordCount')).not.toBeInTheDocument(); + + // Open config panel + fireEvent.click(screen.getByTitle('console.objectView.designTools')); + fireEvent.click(screen.getByText('console.objectView.editView')); + + // Toggle showRecordCount on + const recordCountSwitch = screen.getByTestId('toggle-showRecordCount'); + fireEvent.click(recordCountSwitch); + + // Verify the schema property propagated to ListView immediately (live preview) + await vi.waitFor(() => { + expect(screen.getByTestId('schema-showRecordCount')).toBeInTheDocument(); + }); + }); + + it('propagates allowPrinting toggle to ListView schema in real-time', async () => { + mockAuthUser = { id: 'u1', name: 'Admin', role: 'admin' }; + mockUseParams.mockReturnValue({ objectName: 'opportunity' }); + + render(); + + // allowPrinting should not be set initially + expect(screen.queryByTestId('schema-allowPrinting')).not.toBeInTheDocument(); + + // Open config panel + fireEvent.click(screen.getByTitle('console.objectView.designTools')); + fireEvent.click(screen.getByText('console.objectView.editView')); + + // Toggle allowPrinting on + const printSwitch = screen.getByTestId('toggle-allowPrinting'); + fireEvent.click(printSwitch); + + // Verify the schema property propagated to ListView immediately (live preview) + await vi.waitFor(() => { + expect(screen.getByTestId('schema-allowPrinting')).toBeInTheDocument(); + }); + }); + + it('propagates multiple config changes without requiring save', async () => { + mockAuthUser = { id: 'u1', name: 'Admin', role: 'admin' }; + mockUseParams.mockReturnValue({ objectName: 'opportunity' }); + + render(); + + // Open config panel + fireEvent.click(screen.getByTitle('console.objectView.designTools')); + fireEvent.click(screen.getByText('console.objectView.editView')); + + // Toggle showSearch off + const searchSwitch = screen.getByTestId('toggle-showSearch'); + fireEvent.click(searchSwitch); + + // Toggle showSort off + const sortSwitch = screen.getByTestId('toggle-showSort'); + fireEvent.click(sortSwitch); + + // Both should reflect changes immediately without save + await vi.waitFor(() => { + expect(screen.getByTestId('toggle-showSearch').getAttribute('aria-checked')).toBe('false'); + expect(screen.getByTestId('toggle-showSort').getAttribute('aria-checked')).toBe('false'); + }); + + // The grid should still be rendered (live preview, no remount) + expect(screen.getByTestId('object-grid')).toBeInTheDocument(); + }); + + it('uses activeView.navigation for detail overlay with priority over objectDef', () => { + mockAuthUser = { id: 'u1', name: 'Admin', role: 'admin' }; + const objectsWithNav = [ + { + ...mockObjects[0], + navigation: { mode: 'drawer' as const }, + listViews: { + all: { + label: 'All Opportunities', + type: 'grid', + columns: ['name', 'stage'], + navigation: { mode: 'modal' as const }, + }, + pipeline: { label: 'Pipeline', type: 'kanban', kanban: { groupField: 'stage' }, columns: ['name'] } + } + } + ]; + mockUseParams.mockReturnValue({ objectName: 'opportunity' }); + + // Render the component — activeView.navigation should override objectDef.navigation + render(); + + // The component should render without errors and ListView should receive + // the view-level navigation config (modal) instead of object-level (drawer) + expect(screen.getByTestId('object-grid')).toBeInTheDocument(); + expect(screen.getByTestId('schema-navigation-mode')).toHaveTextContent('modal'); + }); + + it('propagates selection mode change to ListView schema in real-time', async () => { + mockAuthUser = { id: 'u1', name: 'Admin', role: 'admin' }; + mockUseParams.mockReturnValue({ objectName: 'opportunity' }); + + render(); + + // Open config panel + fireEvent.click(screen.getByTitle('console.objectView.designTools')); + fireEvent.click(screen.getByText('console.objectView.editView')); + + // Change selection mode to 'single' + const selectionSelect = screen.getByTestId('select-selection-type'); + fireEvent.change(selectionSelect, { target: { value: 'single' } }); + + // Verify the selection type propagated to ListView immediately (live preview) + await vi.waitFor(() => { + expect(screen.getByTestId('schema-selection-type')).toHaveTextContent('single'); + }); + }); + + it('propagates addRecord toggle to ListView schema in real-time', async () => { + mockAuthUser = { id: 'u1', name: 'Admin', role: 'admin' }; + mockUseParams.mockReturnValue({ objectName: 'opportunity' }); + + render(); + + // addRecord should not be enabled initially + expect(screen.queryByTestId('schema-addRecord-enabled')).not.toBeInTheDocument(); + + // Open config panel + fireEvent.click(screen.getByTitle('console.objectView.designTools')); + fireEvent.click(screen.getByText('console.objectView.editView')); + + // Toggle addRecord on + const addRecordSwitch = screen.getByTestId('toggle-addRecord-enabled'); + fireEvent.click(addRecordSwitch); + + // Verify addRecord and addRecordViaForm propagated to ListView immediately + await vi.waitFor(() => { + expect(screen.getByTestId('schema-addRecord-enabled')).toBeInTheDocument(); + expect(screen.getByTestId('schema-addRecordViaForm')).toBeInTheDocument(); + }); + }); + + it('propagates navigation mode change from config panel to ListView schema in real-time', async () => { + mockAuthUser = { id: 'u1', name: 'Admin', role: 'admin' }; + mockUseParams.mockReturnValue({ objectName: 'opportunity' }); + + render(); + + // navigation mode should not be set initially (no explicit mode on default view) + expect(screen.queryByTestId('schema-navigation-mode')).not.toBeInTheDocument(); + + // Open config panel + fireEvent.click(screen.getByTitle('console.objectView.designTools')); + fireEvent.click(screen.getByText('console.objectView.editView')); + + // Change navigation mode to 'modal' + const navSelect = screen.getByTestId('select-navigation-mode'); + fireEvent.change(navSelect, { target: { value: 'modal' } }); + + // Verify navigation mode propagated to ListView schema immediately (live preview) + await vi.waitFor(() => { + expect(screen.getByTestId('schema-navigation-mode')).toHaveTextContent('modal'); + }); + }); }); diff --git a/apps/console/src/components/ObjectView.tsx b/apps/console/src/components/ObjectView.tsx index 88d71275a..4f8e3bc46 100644 --- a/apps/console/src/components/ObjectView.tsx +++ b/apps/console/src/components/ObjectView.tsx @@ -230,7 +230,8 @@ export function ObjectView({ dataSource, objects, onEdit, onRowClick }: any) { }, [dataSource, objectDef.name, refreshKey]); // Navigation overlay for record detail (supports drawer/modal/split/popover via config) - const detailNavigation: ViewNavigationConfig = objectDef.navigation ?? { mode: 'drawer' }; + // Priority: activeView.navigation > objectDef.navigation > default drawer + const detailNavigation: ViewNavigationConfig = activeView?.navigation ?? objectDef.navigation ?? { mode: 'drawer' }; const drawerRecordId = searchParams.get('recordId'); const navOverlay = useNavigationOverlay({ navigation: detailNavigation, @@ -573,7 +574,6 @@ export function ObjectView({ dataSource, objects, onEdit, onRowClick }: any) {