Skip to content

Commit f217fd6

Browse files
Copilothotlong
andcommitted
feat: add inline ViewConfigPanel, breadcrumb header, and record count footer
- Add ViewConfigPanel component (Airtable-style right sidebar) - Replace "Edit View" navigation with inline panel toggle - Add breadcrumb (Object > View) and description in header - Add record count footer - Add i18n keys for all 11 locales - Add 7 new tests for panel toggle, breadcrumb, description, record count Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
1 parent ad69441 commit f217fd6

13 files changed

Lines changed: 621 additions & 9 deletions

File tree

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

Lines changed: 95 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -137,11 +137,13 @@ describe('ObjectView Component', () => {
137137

138138
render(<ObjectView dataSource={mockDataSource} objects={mockObjects} onEdit={vi.fn()} />);
139139

140-
// Check Header
141-
expect(screen.getByText('Opportunity')).toBeInTheDocument();
140+
// Check Header (appears in breadcrumb and h1)
141+
const headers = screen.getAllByText('Opportunity');
142+
expect(headers.length).toBeGreaterThanOrEqual(1);
142143

143-
// Check Tabs exist
144-
expect(screen.getByText('All Opportunities')).toBeInTheDocument();
144+
// Check Tabs exist (also appears in breadcrumb)
145+
const allOppTexts = screen.getAllByText('All Opportunities');
146+
expect(allOppTexts.length).toBeGreaterThanOrEqual(1);
145147
expect(screen.getByText('Pipeline')).toBeInTheDocument();
146148

147149
// Check Grid is rendered (default)
@@ -247,4 +249,93 @@ describe('ObjectView Component', () => {
247249

248250
expect(mockNavigate).toHaveBeenCalledWith('views/new', { relative: 'path' });
249251
});
252+
253+
it('shows breadcrumb with object and view name', () => {
254+
mockUseParams.mockReturnValue({ objectName: 'opportunity' });
255+
256+
render(<ObjectView dataSource={mockDataSource} objects={mockObjects} onEdit={vi.fn()} />);
257+
258+
// Breadcrumb should show object label and active view label (may appear in tabs too)
259+
const allOppTexts = screen.getAllByText('All Opportunities');
260+
expect(allOppTexts.length).toBeGreaterThanOrEqual(2); // breadcrumb + tab
261+
});
262+
263+
it('shows object description when present', () => {
264+
const objectsWithDesc = [
265+
{
266+
...mockObjects[0],
267+
description: 'Track sales pipeline and deals',
268+
},
269+
];
270+
mockUseParams.mockReturnValue({ objectName: 'opportunity' });
271+
272+
render(<ObjectView dataSource={mockDataSource} objects={objectsWithDesc} onEdit={vi.fn()} />);
273+
274+
expect(screen.getByText('Track sales pipeline and deals')).toBeInTheDocument();
275+
});
276+
277+
it('toggles ViewConfigPanel when "Edit View" is clicked by admin', () => {
278+
mockAuthUser = { id: 'u1', name: 'Admin', role: 'admin' };
279+
mockUseParams.mockReturnValue({ objectName: 'opportunity' });
280+
281+
render(<ObjectView dataSource={mockDataSource} objects={mockObjects} onEdit={vi.fn()} />);
282+
283+
// Panel should not be visible initially
284+
expect(screen.queryByTestId('view-config-panel')).not.toBeInTheDocument();
285+
286+
// Click design tools > Edit View
287+
const designBtn = screen.getByTitle('console.objectView.designTools');
288+
fireEvent.click(designBtn);
289+
290+
const editViewBtn = screen.getByText('console.objectView.editView');
291+
fireEvent.click(editViewBtn);
292+
293+
// Panel should now be visible
294+
expect(screen.getByTestId('view-config-panel')).toBeInTheDocument();
295+
});
296+
297+
it('closes ViewConfigPanel when close button is clicked', () => {
298+
mockAuthUser = { id: 'u1', name: 'Admin', role: 'admin' };
299+
mockUseParams.mockReturnValue({ objectName: 'opportunity' });
300+
301+
render(<ObjectView dataSource={mockDataSource} objects={mockObjects} onEdit={vi.fn()} />);
302+
303+
// Open the panel
304+
const designBtn = screen.getByTitle('console.objectView.designTools');
305+
fireEvent.click(designBtn);
306+
const editViewBtn = screen.getByText('console.objectView.editView');
307+
fireEvent.click(editViewBtn);
308+
309+
expect(screen.getByTestId('view-config-panel')).toBeInTheDocument();
310+
311+
// Close the panel
312+
const closeBtn = screen.getByTitle('console.objectView.closePanel');
313+
fireEvent.click(closeBtn);
314+
315+
expect(screen.queryByTestId('view-config-panel')).not.toBeInTheDocument();
316+
});
317+
318+
it('does not show ViewConfigPanel for non-admin users', () => {
319+
mockAuthUser = { id: 'u2', name: 'Viewer', role: 'viewer' };
320+
mockUseParams.mockReturnValue({ objectName: 'opportunity' });
321+
322+
render(<ObjectView dataSource={mockDataSource} objects={mockObjects} onEdit={vi.fn()} />);
323+
324+
// Design tools are not available for non-admin users
325+
expect(screen.queryByTestId('view-config-panel')).not.toBeInTheDocument();
326+
});
327+
328+
it('shows record count footer when data is available', async () => {
329+
const mockDsWithTotal = {
330+
...mockDataSource,
331+
find: vi.fn().mockResolvedValue({ data: [], total: 42 }),
332+
};
333+
mockUseParams.mockReturnValue({ objectName: 'opportunity' });
334+
335+
render(<ObjectView dataSource={mockDsWithTotal} objects={mockObjects} onEdit={vi.fn()} />);
336+
337+
// Wait for the record count to appear
338+
const footer = await screen.findByTestId('record-count-footer');
339+
expect(footer).toBeInTheDocument();
340+
});
250341
});

apps/console/src/components/ObjectView.tsx

Lines changed: 52 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,10 @@ import '@object-ui/plugin-grid';
2121
import '@object-ui/plugin-kanban';
2222
import '@object-ui/plugin-calendar';
2323
import { Button, Empty, EmptyTitle, EmptyDescription, NavigationOverlay, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, DropdownMenuSeparator } from '@object-ui/components';
24-
import { Plus, Table as TableIcon, Settings2, Wrench, KanbanSquare, Calendar, LayoutGrid, Activity, GanttChart, MapPin, BarChart3 } from 'lucide-react';
24+
import { Plus, Table as TableIcon, Settings2, Wrench, KanbanSquare, Calendar, LayoutGrid, Activity, GanttChart, MapPin, BarChart3, ChevronRight } from 'lucide-react';
2525
import type { ListViewSchema, ViewNavigationConfig } from '@object-ui/types';
2626
import { MetadataToggle, MetadataPanel, useMetadataInspector } from './MetadataInspector';
27+
import { ViewConfigPanel } from './ViewConfigPanel';
2728
import { useObjectActions } from '../hooks/useObjectActions';
2829
import { useObjectTranslation } from '@object-ui/i18n';
2930
import { usePermissions } from '@object-ui/permissions';
@@ -62,6 +63,12 @@ export function ObjectView({ dataSource, objects, onEdit, onRowClick }: any) {
6263
const { showDebug, toggleDebug } = useMetadataInspector();
6364
const { t } = useObjectTranslation();
6465

66+
// Inline view config panel state (Airtable-style right sidebar)
67+
const [showViewConfigPanel, setShowViewConfigPanel] = useState(false);
68+
69+
// Record count tracking for footer
70+
const [recordCount, setRecordCount] = useState<number | undefined>(undefined);
71+
6572
// Admin users automatically get design tools (no toggle needed)
6673
const { user } = useAuth();
6774
const isAdmin = user?.role === 'admin';
@@ -152,6 +159,23 @@ export function ObjectView({ dataSource, objects, onEdit, onRowClick }: any) {
152159
}
153160
}, [realtimeMessage, hasConflicts, resolveAllConflicts]);
154161

162+
// Fetch record count for footer display
163+
useEffect(() => {
164+
if (dataSource?.find && objectDef.name) {
165+
dataSource.find(objectDef.name, { limit: 0 }).then((result: any) => {
166+
if (typeof result?.total === 'number') {
167+
setRecordCount(result.total);
168+
} else if (Array.isArray(result?.data)) {
169+
setRecordCount(result.data.length);
170+
} else if (Array.isArray(result)) {
171+
setRecordCount(result.length);
172+
}
173+
}).catch(() => {
174+
// Silently ignore — record count is non-critical
175+
});
176+
}
177+
}, [dataSource, objectDef.name, refreshKey]);
178+
155179
// Navigation overlay for record detail (supports drawer/modal/split/popover via config)
156180
const detailNavigation: ViewNavigationConfig = objectDef.navigation ?? { mode: 'drawer' };
157181
const drawerRecordId = searchParams.get('recordId');
@@ -301,14 +325,23 @@ export function ObjectView({ dataSource, objects, onEdit, onRowClick }: any) {
301325

302326
return (
303327
<div className="h-full flex flex-col bg-background">
304-
{/* 1. Simplified Header */}
328+
{/* 1. Header with breadcrumb + description */}
305329
<div className="flex justify-between items-center py-2.5 sm:py-3 px-3 sm:px-4 border-b shrink-0 bg-background z-10">
306330
<div className="flex items-center gap-2 sm:gap-3 min-w-0 flex-1">
307331
<div className="bg-primary/10 p-1.5 sm:p-2 rounded-md shrink-0">
308332
<TableIcon className="h-4 w-4 text-primary" />
309333
</div>
310334
<div className="min-w-0">
335+
{/* Breadcrumb: Object > View */}
336+
<div className="flex items-center gap-1 text-xs text-muted-foreground mb-0.5">
337+
<span className="truncate">{objectDef.label}</span>
338+
<ChevronRight className="h-3 w-3 shrink-0" />
339+
<span className="truncate font-medium text-foreground">{activeView?.label || t('console.objectView.allRecords')}</span>
340+
</div>
311341
<h1 className="text-base sm:text-lg font-semibold tracking-tight text-foreground truncate">{objectDef.label}</h1>
342+
{objectDef.description && (
343+
<p className="text-xs text-muted-foreground truncate hidden sm:block max-w-md">{objectDef.description}</p>
344+
)}
312345
</div>
313346
</div>
314347

@@ -353,7 +386,7 @@ export function ObjectView({ dataSource, objects, onEdit, onRowClick }: any) {
353386
{t('console.objectView.metadataInspector')}
354387
</DropdownMenuItem>
355388
<DropdownMenuSeparator />
356-
<DropdownMenuItem onClick={() => navigate(viewId ? `../../views/${viewId}` : `views/${activeViewId}`, { relative: 'path' })}>
389+
<DropdownMenuItem onClick={() => setShowViewConfigPanel(prev => !prev)}>
357390
<Settings2 className="h-4 w-4 mr-2" />
358391
{t('console.objectView.editView')}
359392
</DropdownMenuItem>
@@ -415,8 +448,8 @@ export function ObjectView({ dataSource, objects, onEdit, onRowClick }: any) {
415448

416449
{/* 2. Content — Plugin ObjectView with ViewSwitcher + Filter + Sort */}
417450
<div className="flex-1 overflow-hidden relative flex flex-row">
418-
<div className="flex-1 relative h-full">
419-
<div className="absolute inset-0 overflow-auto p-3 sm:p-4">
451+
<div className="flex-1 relative h-full flex flex-col">
452+
<div className="flex-1 relative overflow-auto p-3 sm:p-4">
420453
<PluginObjectView
421454
key={refreshKey}
422455
schema={objectViewSchema}
@@ -429,6 +462,12 @@ export function ObjectView({ dataSource, objects, onEdit, onRowClick }: any) {
429462
renderListView={renderListView}
430463
/>
431464
</div>
465+
{/* Footer — Record count */}
466+
{typeof recordCount === 'number' && (
467+
<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">
468+
{t('console.objectView.recordCount', { count: recordCount })}
469+
</div>
470+
)}
432471
</div>
433472
{/* Metadata panel only shows for admin users */}
434473
<MetadataPanel
@@ -438,6 +477,14 @@ export function ObjectView({ dataSource, objects, onEdit, onRowClick }: any) {
438477
{ title: 'Object Definition', data: objectDef },
439478
]}
440479
/>
480+
{/* Inline View Config Panel — Airtable-style right sidebar */}
481+
<ViewConfigPanel
482+
open={showViewConfigPanel && isAdmin}
483+
onClose={() => setShowViewConfigPanel(false)}
484+
activeView={activeView}
485+
objectDef={objectDef}
486+
recordCount={recordCount}
487+
/>
441488
</div>
442489

443490
{/* Record Detail Overlay — navigation mode driven by objectDef.navigation */}

0 commit comments

Comments
 (0)