Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
217 changes: 217 additions & 0 deletions apps/console/src/__tests__/ObjectView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,27 @@ vi.mock('@object-ui/plugin-calendar', () => ({
ObjectCalendar: (props: any) => <div data-testid="object-calendar">Calendar View: {props.schema.dateField}</div>
}));

// 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 (
<div data-testid="list-view" data-view-type={viewType}>
{viewType === 'grid' && <div data-testid="object-grid">Grid View: {props.schema?.objectName}</div>}
{viewType === 'kanban' && <div data-testid="object-kanban">Kanban View: {props.schema?.options?.kanban?.groupField || props.schema?.groupBy}</div>}
{viewType === 'calendar' && <div data-testid="object-calendar">Calendar View: {props.schema?.options?.calendar?.startDateField || props.schema?.startDateField}</div>}
{props.schema?.showRecordCount && <div data-testid="schema-showRecordCount">showRecordCount</div>}
{props.schema?.allowPrinting && <div data-testid="schema-allowPrinting">allowPrinting</div>}
{props.schema?.navigation?.mode && <div data-testid="schema-navigation-mode">{props.schema.navigation.mode}</div>}
{props.schema?.selection?.type && <div data-testid="schema-selection-type">{props.schema.selection.type}</div>}
{props.schema?.addRecord?.enabled && <div data-testid="schema-addRecord-enabled">addRecord</div>}
{props.schema?.addRecordViaForm && <div data-testid="schema-addRecordViaForm">addRecordViaForm</div>}
</div>
);
},
}));

vi.mock('@object-ui/components', async (importOriginal) => {
const React = await import('react');
const MockTabsContext = React.createContext({ onValueChange: ( _v: any) => {} });
Expand Down Expand Up @@ -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(<ObjectView dataSource={mockDataSource} objects={mockObjects} onEdit={vi.fn()} />);

// 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(<ObjectView dataSource={mockDataSource} objects={mockObjects} onEdit={vi.fn()} />);

// 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(<ObjectView dataSource={mockDataSource} objects={mockObjects} onEdit={vi.fn()} />);

// 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(<ObjectView dataSource={mockDataSource} objects={mockObjects} onEdit={vi.fn()} />);

// 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(<ObjectView dataSource={mockDataSource} objects={objectsWithNav} onEdit={vi.fn()} />);

// 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(<ObjectView dataSource={mockDataSource} objects={mockObjects} onEdit={vi.fn()} />);

// 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(<ObjectView dataSource={mockDataSource} objects={mockObjects} onEdit={vi.fn()} />);

// 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(<ObjectView dataSource={mockDataSource} objects={mockObjects} onEdit={vi.fn()} />);

// 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');
});
});
});
4 changes: 2 additions & 2 deletions apps/console/src/components/ObjectView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -573,7 +574,6 @@ export function ObjectView({ dataSource, objects, onEdit, onRowClick }: any) {
<div className="flex-1 min-w-0 relative h-full flex flex-col">
<div className="flex-1 relative overflow-auto p-3 sm:p-4">
<PluginObjectView
key={refreshKey}
schema={objectViewSchema}
dataSource={dataSource}
views={mergedViews}
Expand Down