Skip to content

Commit 370f34e

Browse files
authored
Merge pull request #633 from objectstack-ai/copilot/fix-view-add-edit-404
2 parents 192d759 + 343010d commit 370f34e

3 files changed

Lines changed: 106 additions & 32 deletions

File tree

ROADMAP_CONSOLE.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,7 @@ The Console is the **canonical proof** that ObjectUI's Server-Driven UI (SDUI) e
245245
| G11 | Collaboration data hardcoded || Presence/activity/comments now fetched from API; fallback to defaults when API unavailable |
246246
| G12 | ReportBuilder uses mock fields || `availableFields` derived from object schema via `useMetadata().objects` |
247247
| G13 | ViewDesigner save not persisted || `handleSave` calls `dataSource.create/update('sys_view', config)` |
248+
| G14 | View add/edit navigates to 404 || Fixed relative navigation in ObjectView (added `{ relative: 'path' }` to all view designer navigate calls). Fixes #628 |
248249

249250
---
250251

@@ -374,6 +375,7 @@ These were the initial tasks to bring the console prototype to production-qualit
374375
| 5.6 | Row-level security | ⚠️ Partial (server-side assumed; client `DataScopeManager` types only) |
375376
| 5.7 | Permission-denied fallback UI | ✅ Done (`PermissionGuard`) |
376377
| 5.8 | Integration with ObjectStack RBAC API | ✅ Done |
378+
| 5.9 | Admin auto-design mode (no toggle) | ✅ Done — design tools (Edit/Add View, Metadata Inspector) auto-visible for `isAdmin` users; non-admin users see no design entry. Fixes #628 |
377379

378380
---
379381

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

Lines changed: 79 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ vi.mock('@object-ui/components', async (importOriginal) => {
2424
return {
2525
...actual,
2626
cn: (...inputs: any[]) => inputs.filter(Boolean).join(' '),
27-
Button: ({ children, onClick }: any) => <button onClick={onClick}>{children}</button>,
27+
Button: ({ children, onClick, title }: any) => <button onClick={onClick} title={title}>{children}</button>,
2828
Input: (props: any) => <input {...props} data-testid="mock-input" />,
2929
ToggleGroup: ({ children, value, onValueChange }: any) => <div data-value={value} onChange={onValueChange}>{children}</div>,
3030
ToggleGroupItem: ({ children, value }: any) => <button data-value={value}>{children}</button>,
@@ -50,20 +50,33 @@ vi.mock('@object-ui/components', async (importOriginal) => {
5050
},
5151
Empty: ({ children }: any) => <div data-testid="empty">{children}</div>,
5252
EmptyTitle: ({ children }: any) => <div data-testid="empty-title">{children}</div>,
53-
EmptyDescription: ({ children }: any) => <div data-testid="empty-description">{children}</div>
53+
EmptyDescription: ({ children }: any) => <div data-testid="empty-description">{children}</div>,
54+
// Simple dropdown mocks — render children directly (no Radix portal)
55+
DropdownMenu: ({ children }: any) => <div data-testid="dropdown-menu">{children}</div>,
56+
DropdownMenuTrigger: ({ children }: any) => <>{children}</>,
57+
DropdownMenuContent: ({ children }: any) => <div data-testid="dropdown-content">{children}</div>,
58+
DropdownMenuItem: ({ children, onClick }: any) => <button onClick={onClick}>{children}</button>,
59+
DropdownMenuSeparator: () => <hr />,
5460
};
5561
});
5662

5763
// Mock React Router
5864
const mockUseParams = vi.fn();
5965
const mockSetSearchParams = vi.fn();
66+
const mockNavigate = vi.fn();
6067
// Default mock implementation
6168
let mockSearchParams = new URLSearchParams();
6269

6370
vi.mock('react-router-dom', () => ({
6471
useParams: () => mockUseParams(),
6572
useSearchParams: () => [mockSearchParams, mockSetSearchParams],
66-
useNavigate: () => vi.fn(),
73+
useNavigate: () => mockNavigate,
74+
}));
75+
76+
// Mock auth — default to non-admin; tests can override via mockAuthUser
77+
let mockAuthUser: { id: string; name: string; role: string } | null = null;
78+
vi.mock('@object-ui/auth', () => ({
79+
useAuth: () => ({ user: mockAuthUser }),
6780
}));
6881

6982
describe('ObjectView Component', () => {
@@ -108,6 +121,7 @@ describe('ObjectView Component', () => {
108121
beforeEach(() => {
109122
vi.clearAllMocks();
110123
mockSearchParams = new URLSearchParams(); // Reset params
124+
mockAuthUser = null; // Default to non-admin
111125
});
112126

113127
it('renders error when object is not found', () => {
@@ -171,4 +185,66 @@ describe('ObjectView Component', () => {
171185
expect(screen.getByTestId('object-calendar')).toBeInTheDocument();
172186
expect(screen.getByText('Calendar View: due_date')).toBeInTheDocument();
173187
});
188+
189+
it('shows design tools for admin users without toggle', () => {
190+
mockAuthUser = { id: 'u1', name: 'Admin', role: 'admin' };
191+
mockUseParams.mockReturnValue({ objectName: 'opportunity' });
192+
193+
render(<ObjectView dataSource={mockDataSource} objects={mockObjects} onEdit={vi.fn()} />);
194+
195+
// Design tools (wrench button) should be visible directly for admin
196+
expect(screen.getByTitle('console.objectView.designTools')).toBeInTheDocument();
197+
// No "Enter Design Mode" toggle should exist
198+
expect(screen.queryByText('console.objectView.enterDesignMode')).not.toBeInTheDocument();
199+
});
200+
201+
it('hides design tools for non-admin users', () => {
202+
mockAuthUser = { id: 'u2', name: 'Viewer', role: 'viewer' };
203+
mockUseParams.mockReturnValue({ objectName: 'opportunity' });
204+
205+
render(<ObjectView dataSource={mockDataSource} objects={mockObjects} onEdit={vi.fn()} />);
206+
207+
// Design tools button should not be visible
208+
expect(screen.queryByTitle('console.objectView.designTools')).not.toBeInTheDocument();
209+
});
210+
211+
it('hides design tools when user is not authenticated', () => {
212+
mockAuthUser = null;
213+
mockUseParams.mockReturnValue({ objectName: 'opportunity' });
214+
215+
render(<ObjectView dataSource={mockDataSource} objects={mockObjects} onEdit={vi.fn()} />);
216+
217+
expect(screen.queryByTitle('console.objectView.designTools')).not.toBeInTheDocument();
218+
});
219+
220+
it('navigates to view designer with relative path from nested view route', () => {
221+
mockAuthUser = { id: 'u1', name: 'Admin', role: 'admin' };
222+
mockUseParams.mockReturnValue({ objectName: 'opportunity', viewId: 'pipeline' });
223+
224+
render(<ObjectView dataSource={mockDataSource} objects={mockObjects} onEdit={vi.fn()} />);
225+
226+
// Click the design tools button, then "Add View"
227+
const designBtn = screen.getByTitle('console.objectView.designTools');
228+
fireEvent.click(designBtn);
229+
230+
const addViewBtn = screen.getByText('console.objectView.addView');
231+
fireEvent.click(addViewBtn);
232+
233+
expect(mockNavigate).toHaveBeenCalledWith('../../views/new', { relative: 'path' });
234+
});
235+
236+
it('navigates to view designer with relative path from root object route', () => {
237+
mockAuthUser = { id: 'u1', name: 'Admin', role: 'admin' };
238+
mockUseParams.mockReturnValue({ objectName: 'opportunity' });
239+
240+
render(<ObjectView dataSource={mockDataSource} objects={mockObjects} onEdit={vi.fn()} />);
241+
242+
const designBtn = screen.getByTitle('console.objectView.designTools');
243+
fireEvent.click(designBtn);
244+
245+
const addViewBtn = screen.getByText('console.objectView.addView');
246+
fireEvent.click(addViewBtn);
247+
248+
expect(mockNavigate).toHaveBeenCalledWith('views/new', { relative: 'path' });
249+
});
174250
});

apps/console/src/components/ObjectView.tsx

Lines changed: 25 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,13 @@ 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, MoreVertical, 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 } from 'lucide-react';
2525
import type { ListViewSchema, ViewNavigationConfig } from '@object-ui/types';
2626
import { MetadataToggle, MetadataPanel, useMetadataInspector } from './MetadataInspector';
2727
import { useObjectActions } from '../hooks/useObjectActions';
2828
import { useObjectTranslation } from '@object-ui/i18n';
2929
import { usePermissions } from '@object-ui/permissions';
30+
import { useAuth } from '@object-ui/auth';
3031
import { useRealtimeSubscription, useConflictResolution } from '@object-ui/collaboration';
3132
import { useNavigationOverlay } from '@object-ui/react';
3233

@@ -49,8 +50,9 @@ export function ObjectView({ dataSource, objects, onEdit, onRowClick }: any) {
4950
const { showDebug, toggleDebug } = useMetadataInspector();
5051
const { t } = useObjectTranslation();
5152

52-
// Design mode toggle - default false for end users
53-
const [designMode, setDesignMode] = useState(false);
53+
// Admin users automatically get design tools (no toggle needed)
54+
const { user } = useAuth();
55+
const isAdmin = user?.role === 'admin';
5456
const { can } = usePermissions();
5557

5658
// Get Object Definition
@@ -320,42 +322,36 @@ export function ObjectView({ dataSource, objects, onEdit, onRowClick }: any) {
320322
</Button>
321323
))}
322324

323-
{/* Design mode tools menu */}
325+
{/* Design tools menu — visible only to admin users */}
326+
{isAdmin && (
324327
<DropdownMenu>
325328
<DropdownMenuTrigger asChild>
326329
<Button
327330
size="sm"
328-
variant={designMode ? "secondary" : "ghost"}
331+
variant="ghost"
329332
className="shadow-none h-8 sm:h-9 px-2"
330333
title={t('console.objectView.designTools')}
331334
>
332-
{designMode ? <Wrench className="h-4 w-4" /> : <MoreVertical className="h-4 w-4" />}
335+
<Wrench className="h-4 w-4" />
333336
</Button>
334337
</DropdownMenuTrigger>
335338
<DropdownMenuContent align="end" className="w-48">
336-
<DropdownMenuItem onClick={() => setDesignMode(!designMode)}>
337-
<Wrench className="h-4 w-4 mr-2" />
338-
{designMode ? t('console.objectView.exitDesignMode') : t('console.objectView.enterDesignMode')}
339+
<DropdownMenuItem onClick={toggleDebug}>
340+
<MetadataToggle open={showDebug} onToggle={toggleDebug} className="hidden" />
341+
{t('console.objectView.metadataInspector')}
342+
</DropdownMenuItem>
343+
<DropdownMenuSeparator />
344+
<DropdownMenuItem onClick={() => navigate(viewId ? `../../views/${viewId}` : `views/${activeViewId}`, { relative: 'path' })}>
345+
<Settings2 className="h-4 w-4 mr-2" />
346+
{t('console.objectView.editView')}
347+
</DropdownMenuItem>
348+
<DropdownMenuItem onClick={() => navigate(viewId ? '../../views/new' : 'views/new', { relative: 'path' })}>
349+
<Plus className="h-4 w-4 mr-2" />
350+
{t('console.objectView.addView')}
339351
</DropdownMenuItem>
340-
{designMode && (
341-
<>
342-
<DropdownMenuSeparator />
343-
<DropdownMenuItem onClick={toggleDebug}>
344-
<MetadataToggle open={showDebug} onToggle={toggleDebug} className="hidden" />
345-
{t('console.objectView.metadataInspector')}
346-
</DropdownMenuItem>
347-
<DropdownMenuItem onClick={() => navigate(viewId ? `../../views/${viewId}` : `views/${activeViewId}`)}>
348-
<Settings2 className="h-4 w-4 mr-2" />
349-
{t('console.objectView.editView')}
350-
</DropdownMenuItem>
351-
<DropdownMenuItem onClick={() => navigate(viewId ? '../../views/new' : 'views/new')}>
352-
<Plus className="h-4 w-4 mr-2" />
353-
{t('console.objectView.addView')}
354-
</DropdownMenuItem>
355-
</>
356-
)}
357352
</DropdownMenuContent>
358353
</DropdownMenu>
354+
)}
359355
</div>
360356
</div>
361357

@@ -374,7 +370,7 @@ export function ObjectView({ dataSource, objects, onEdit, onRowClick }: any) {
374370
onViewChange={handleViewChange}
375371
viewTypeIcons={VIEW_TYPE_ICONS}
376372
config={objectDef.viewTabBar}
377-
onAddView={() => navigate(viewId ? '../../views/new' : 'views/new')}
373+
onAddView={isAdmin ? () => navigate(viewId ? '../../views/new' : 'views/new', { relative: 'path' }) : undefined}
378374
onRenameView={(id, newName) => {
379375
// Rename is wired for future backend integration
380376
console.info('[ViewTabBar] Rename view:', id, newName);
@@ -412,9 +408,9 @@ export function ObjectView({ dataSource, objects, onEdit, onRowClick }: any) {
412408
/>
413409
</div>
414410
</div>
415-
{/* Metadata panel only shows in design mode */}
411+
{/* Metadata panel only shows for admin users */}
416412
<MetadataPanel
417-
open={showDebug && designMode}
413+
open={showDebug && isAdmin}
418414
sections={[
419415
{ title: 'View Configuration', data: activeView },
420416
{ title: 'Object Definition', data: objectDef },

0 commit comments

Comments
 (0)