Skip to content

Commit 692d3ae

Browse files
authored
Merge pull request #703 from objectstack-ai/copilot/evaluate-console-functionality
2 parents a180264 + 7dac97b commit 692d3ae

3 files changed

Lines changed: 291 additions & 1 deletion

File tree

ROADMAP.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,8 @@ ObjectUI is a universal Server-Driven UI (SDUI) engine built on React + Tailwind
114114
- [x] Area switcher with grouped navigation
115115
- [x] User-customizable sidebar (drag reorder, pin favorites)
116116
- [x] Search within sidebar navigation
117+
- [x] Console integration: Navigation search filtering (`filterNavigationItems` + `SidebarInput`)
118+
- [x] Console integration: Badge indicators on navigation items (`badge` + `badgeVariant`)
117119

118120
### P1.8 Console — View Config Panel (Phase 20)
119121

Lines changed: 254 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,254 @@
1+
import { describe, it, expect, vi, beforeEach } from 'vitest';
2+
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
3+
import '@testing-library/jest-dom';
4+
import { MemoryRouter, Routes, Route } from 'react-router-dom';
5+
import { AppSidebar } from '../components/AppSidebar';
6+
import { SidebarProvider } from '@object-ui/components';
7+
8+
// --- Mocks ---
9+
10+
const mockNavigation = [
11+
{ id: 'nav-accounts', label: 'Accounts', type: 'object', objectName: 'account', icon: 'Users' },
12+
{ id: 'nav-contacts', label: 'Contacts', type: 'object', objectName: 'contact', icon: 'User' },
13+
{ id: 'nav-dash', label: 'Monthly Dashboard', type: 'dashboard', dashboardName: 'monthly', icon: 'LayoutDashboard', badge: 3, badgeVariant: 'destructive' as const },
14+
{ id: 'nav-settings', label: 'Settings', type: 'page', pageName: 'settings', icon: 'Settings', badge: 'New' },
15+
{
16+
id: 'nav-group-reports',
17+
label: 'Reports',
18+
type: 'group',
19+
children: [
20+
{ id: 'nav-report-sales', label: 'Sales Report', type: 'report', reportName: 'sales', icon: 'BarChart' },
21+
{ id: 'nav-report-inv', label: 'Inventory Report', type: 'report', reportName: 'inventory', icon: 'Package' },
22+
],
23+
},
24+
];
25+
26+
vi.mock('../context/MetadataProvider', () => ({
27+
MetadataProvider: ({ children }: any) => <>{children}</>,
28+
useMetadata: () => ({
29+
apps: [
30+
{
31+
name: 'crm',
32+
label: 'CRM App',
33+
active: true,
34+
icon: 'Briefcase',
35+
navigation: mockNavigation,
36+
},
37+
],
38+
objects: [],
39+
dashboards: [],
40+
reports: [],
41+
pages: [],
42+
loading: false,
43+
error: null,
44+
refresh: vi.fn(),
45+
}),
46+
}));
47+
48+
vi.mock('../context/ExpressionProvider', () => ({
49+
useExpressionContext: () => ({ evaluator: null }),
50+
evaluateVisibility: () => true,
51+
}));
52+
53+
vi.mock('@object-ui/auth', () => ({
54+
useAuth: () => ({
55+
user: { name: 'Test User', email: 'test@test.com' },
56+
signOut: vi.fn(),
57+
}),
58+
getUserInitials: () => 'TU',
59+
}));
60+
61+
vi.mock('@object-ui/permissions', () => ({
62+
usePermissions: () => ({
63+
can: () => true,
64+
}),
65+
}));
66+
67+
vi.mock('../hooks/useRecentItems', () => ({
68+
useRecentItems: () => ({ recentItems: [] }),
69+
}));
70+
71+
vi.mock('../hooks/useFavorites', () => ({
72+
useFavorites: () => ({ favorites: [], removeFavorite: vi.fn() }),
73+
}));
74+
75+
vi.mock('../utils', () => ({
76+
resolveI18nLabel: (label: any) => (typeof label === 'string' ? label : label?.en || ''),
77+
}));
78+
79+
// Mock @object-ui/components to keep most components but simplify some
80+
vi.mock('@object-ui/components', async (importOriginal) => {
81+
const actual = await importOriginal<any>();
82+
return {
83+
...actual,
84+
TooltipProvider: ({ children }: any) => <div>{children}</div>,
85+
};
86+
});
87+
88+
// Provide minimal lucide-react mocks
89+
vi.mock('lucide-react', async (importOriginal) => {
90+
const actual = await importOriginal<any>();
91+
const MockIcon = ({ className }: any) => <span className={className} />;
92+
return {
93+
...actual,
94+
ChevronsUpDown: MockIcon,
95+
Plus: MockIcon,
96+
Settings: MockIcon,
97+
LogOut: MockIcon,
98+
Database: MockIcon,
99+
ChevronRight: MockIcon,
100+
Clock: MockIcon,
101+
Star: MockIcon,
102+
StarOff: MockIcon,
103+
Layers: MockIcon,
104+
Search: MockIcon,
105+
};
106+
});
107+
108+
describe('AppSidebar', () => {
109+
beforeEach(() => {
110+
vi.clearAllMocks();
111+
localStorage.clear();
112+
});
113+
114+
const renderSidebar = () => {
115+
return render(
116+
<MemoryRouter initialEntries={['/apps/crm/']}>
117+
<Routes>
118+
<Route
119+
path="/apps/:appName/*"
120+
element={
121+
<SidebarProvider>
122+
<AppSidebar activeAppName="crm" onAppChange={vi.fn()} />
123+
</SidebarProvider>
124+
}
125+
/>
126+
</Routes>
127+
</MemoryRouter>,
128+
);
129+
};
130+
131+
// --- Navigation Search ---
132+
133+
describe('Navigation Search', () => {
134+
it('renders a search input in the sidebar', async () => {
135+
renderSidebar();
136+
await waitFor(() => {
137+
expect(screen.getByPlaceholderText('Search navigation...')).toBeInTheDocument();
138+
});
139+
});
140+
141+
it('filters navigation items when typing in search', async () => {
142+
renderSidebar();
143+
144+
await waitFor(() => {
145+
expect(screen.getByText('Accounts')).toBeInTheDocument();
146+
});
147+
148+
const searchInput = screen.getByPlaceholderText('Search navigation...');
149+
fireEvent.change(searchInput, { target: { value: 'account' } });
150+
151+
await waitFor(() => {
152+
expect(screen.getByText('Accounts')).toBeInTheDocument();
153+
expect(screen.queryByText('Contacts')).not.toBeInTheDocument();
154+
expect(screen.queryByText('Monthly Dashboard')).not.toBeInTheDocument();
155+
});
156+
});
157+
158+
it('filters within groups', async () => {
159+
renderSidebar();
160+
161+
await waitFor(() => {
162+
expect(screen.getByText('Sales Report')).toBeInTheDocument();
163+
});
164+
165+
const searchInput = screen.getByPlaceholderText('Search navigation...');
166+
fireEvent.change(searchInput, { target: { value: 'sales' } });
167+
168+
await waitFor(() => {
169+
expect(screen.getByText('Sales Report')).toBeInTheDocument();
170+
expect(screen.queryByText('Inventory Report')).not.toBeInTheDocument();
171+
});
172+
});
173+
174+
it('shows all items when search is cleared', async () => {
175+
renderSidebar();
176+
177+
await waitFor(() => {
178+
expect(screen.getByText('Accounts')).toBeInTheDocument();
179+
});
180+
181+
const searchInput = screen.getByPlaceholderText('Search navigation...');
182+
fireEvent.change(searchInput, { target: { value: 'account' } });
183+
184+
await waitFor(() => {
185+
expect(screen.queryByText('Contacts')).not.toBeInTheDocument();
186+
});
187+
188+
fireEvent.change(searchInput, { target: { value: '' } });
189+
190+
await waitFor(() => {
191+
expect(screen.getByText('Accounts')).toBeInTheDocument();
192+
expect(screen.getByText('Contacts')).toBeInTheDocument();
193+
});
194+
});
195+
196+
it('shows no results when search matches nothing', async () => {
197+
renderSidebar();
198+
199+
await waitFor(() => {
200+
expect(screen.getByText('Accounts')).toBeInTheDocument();
201+
});
202+
203+
const searchInput = screen.getByPlaceholderText('Search navigation...');
204+
fireEvent.change(searchInput, { target: { value: 'zzzzz' } });
205+
206+
await waitFor(() => {
207+
expect(screen.queryByText('Accounts')).not.toBeInTheDocument();
208+
expect(screen.queryByText('Contacts')).not.toBeInTheDocument();
209+
expect(screen.queryByText('Monthly Dashboard')).not.toBeInTheDocument();
210+
});
211+
});
212+
});
213+
214+
// --- Badge Indicators ---
215+
216+
describe('Badge Indicators', () => {
217+
it('renders numeric badge on navigation items', async () => {
218+
renderSidebar();
219+
220+
await waitFor(() => {
221+
expect(screen.getByText('Monthly Dashboard')).toBeInTheDocument();
222+
});
223+
224+
// The badge with "3" should be rendered
225+
expect(screen.getByText('3')).toBeInTheDocument();
226+
});
227+
228+
it('renders text badge on navigation items', async () => {
229+
renderSidebar();
230+
231+
await waitFor(() => {
232+
expect(screen.getByText('Settings')).toBeInTheDocument();
233+
});
234+
235+
// The badge with "New" should be rendered
236+
expect(screen.getByText('New')).toBeInTheDocument();
237+
});
238+
239+
it('does not render badge when item has no badge property', async () => {
240+
renderSidebar();
241+
242+
await waitFor(() => {
243+
expect(screen.getByText('Accounts')).toBeInTheDocument();
244+
});
245+
246+
// "Accounts" has no badge - verify the element doesn't have a sibling badge
247+
const accountsLink = screen.getByText('Accounts');
248+
const parentButton = accountsLink.closest('[data-sidebar="menu-button"]') || accountsLink.parentElement;
249+
// Check that no Badge child with data attributes typical for badges
250+
const badges = parentButton?.querySelectorAll('.ml-auto.text-\\[10px\\]');
251+
expect(badges?.length ?? 0).toBe(0);
252+
});
253+
});
254+
});

apps/console/src/components/AppSidebar.tsx

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ import {
2222
SidebarMenuItem,
2323
SidebarMenuButton,
2424
SidebarMenuAction,
25+
SidebarInput,
26+
Badge,
2527
DropdownMenu,
2628
DropdownMenuTrigger,
2729
DropdownMenuContent,
@@ -48,7 +50,9 @@ import {
4850
Star,
4951
StarOff,
5052
Layers,
53+
Search,
5154
} from 'lucide-react';
55+
import { filterNavigationItems } from '@object-ui/layout';
5256
import { useMetadata } from '../context/MetadataProvider';
5357
import { useExpressionContext, evaluateVisibility } from '../context/ExpressionProvider';
5458
import { useAuth, getUserInitials } from '@object-ui/auth';
@@ -222,6 +226,13 @@ export function AppSidebar({ activeAppName, onAppChange }: { activeAppName: stri
222226
const activeArea = areas.find((a: any) => a.id === activeAreaId);
223227
const resolvedNavigation: any[] = activeArea?.navigation || activeApp?.navigation || [];
224228

229+
// Search filter state for sidebar navigation
230+
const [navSearchQuery, setNavSearchQuery] = React.useState('');
231+
const filteredNavigation = React.useMemo(
232+
() => navSearchQuery ? filterNavigationItems(resolvedNavigation, navSearchQuery) : resolvedNavigation,
233+
[resolvedNavigation, navSearchQuery],
234+
);
235+
225236
const dndValue = React.useMemo(() => ({
226237
dragItemId,
227238
dragGroupKey,
@@ -352,7 +363,20 @@ export function AppSidebar({ activeAppName, onAppChange }: { activeAppName: stri
352363
</SidebarGroup>
353364
)}
354365

355-
<NavigationTree items={resolvedNavigation} activeAppName={activeAppName} applyOrder={applyOrder} />
366+
{/* Navigation Search */}
367+
<SidebarGroup className="py-0">
368+
<SidebarGroupContent className="relative">
369+
<Search className="pointer-events-none absolute left-2 top-1/2 size-4 -translate-y-1/2 select-none opacity-50" />
370+
<SidebarInput
371+
placeholder="Search navigation..."
372+
value={navSearchQuery}
373+
onChange={(e) => setNavSearchQuery(e.target.value)}
374+
className="pl-8"
375+
/>
376+
</SidebarGroupContent>
377+
</SidebarGroup>
378+
379+
<NavigationTree items={filteredNavigation} activeAppName={activeAppName} applyOrder={applyOrder} />
356380

357381
{/* Favorites */}
358382
{favorites.length > 0 && (
@@ -656,11 +680,21 @@ function NavigationItemRenderer({ item, activeAppName, groupKey, groupItems, app
656680
<a href={href} target="_blank" rel="noopener noreferrer">
657681
<Icon className="h-4 w-4" />
658682
<span>{item.label}</span>
683+
{item.badge != null && (
684+
<Badge variant={item.badgeVariant ?? 'default'} className="ml-auto text-[10px] px-1.5 py-0">
685+
{item.badge}
686+
</Badge>
687+
)}
659688
</a>
660689
) : (
661690
<Link to={href} className="py-2.5 sm:py-2">
662691
<Icon className="h-4 w-4" />
663692
<span>{item.label}</span>
693+
{item.badge != null && (
694+
<Badge variant={item.badgeVariant ?? 'default'} className="ml-auto text-[10px] px-1.5 py-0">
695+
{item.badge}
696+
</Badge>
697+
)}
664698
</Link>
665699
)}
666700
</SidebarMenuButton>

0 commit comments

Comments
 (0)