Skip to content

Commit bfba4bc

Browse files
Copilothotlong
andcommitted
feat: add global search results page with route and command palette integration
Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
1 parent 2ad7cc8 commit bfba4bc

4 files changed

Lines changed: 222 additions & 1 deletion

File tree

ROADMAP.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -290,7 +290,7 @@ The v2.0.7 spec introduces 70+ new UI types across 12 domains. This section maps
290290
- [x] Toast notifications for CRUD operations (create/update/delete)
291291
- [x] Responsive sidebar auto-collapse on tablet breakpoints
292292
- [x] Onboarding walkthrough for first-time users
293-
- [ ] Global search results page (beyond command palette)
293+
- [x] Global search results page (beyond command palette)
294294
- [x] Recent items / favorites in sidebar
295295

296296
**Q2 Milestone:**

apps/console/src/App.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { ExpressionProvider } from './context/ExpressionProvider';
2424
import { ConditionalAuthWrapper } from './components/ConditionalAuthWrapper';
2525
import { KeyboardShortcutsDialog } from './components/KeyboardShortcutsDialog';
2626
import { OnboardingWalkthrough } from './components/OnboardingWalkthrough';
27+
import { SearchResultsPage } from './components/SearchResultsPage';
2728
import { useRecentItems } from './hooks/useRecentItems';
2829

2930
// Auth Pages
@@ -266,6 +267,9 @@ export function AppContent() {
266267
<Route path="page/:pageName" element={
267268
<PageView />
268269
} />
270+
<Route path="search" element={
271+
<SearchResultsPage />
272+
} />
269273

270274
{/* System Administration Routes */}
271275
<Route path="system/users" element={<UserManagementPage />} />

apps/console/src/components/CommandPalette.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
Moon,
2828
Sun,
2929
Monitor,
30+
Search,
3031
} from 'lucide-react';
3132
import { useTheme } from './theme-provider';
3233
import { useExpressionContext, evaluateVisibility } from '../context/ExpressionProvider';
@@ -205,6 +206,18 @@ export function CommandPalette({ apps, activeApp, objects: _objects, onAppChange
205206
<span>System Theme</span>
206207
</CommandItem>
207208
</CommandGroup>
209+
210+
{/* Full Search Page */}
211+
<CommandSeparator />
212+
<CommandGroup heading="Actions">
213+
<CommandItem
214+
value="search all results full page"
215+
onSelect={() => runCommand(() => navigate(`${baseUrl}/search`))}
216+
>
217+
<Search className="mr-2 h-4 w-4" />
218+
<span>Open Full Search Page</span>
219+
</CommandItem>
220+
</CommandGroup>
208221
</CommandList>
209222
</CommandDialog>
210223
);
Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
/**
2+
* SearchResultsPage
3+
*
4+
* A dedicated search results page accessible via /apps/:appName/search?q=...
5+
* Extends the command palette with a full-page search experience, showing
6+
* objects, dashboards, pages, and reports matching the query.
7+
* @module
8+
*/
9+
10+
import { useState, useMemo } from 'react';
11+
import { useSearchParams, Link, useParams } from 'react-router-dom';
12+
import {
13+
Input,
14+
Card,
15+
CardContent,
16+
Badge,
17+
} from '@object-ui/components';
18+
import {
19+
Search,
20+
Database,
21+
LayoutDashboard,
22+
FileText,
23+
BarChart3,
24+
ArrowLeft,
25+
} from 'lucide-react';
26+
import appConfig from '../../objectstack.shared';
27+
28+
interface SearchResult {
29+
id: string;
30+
label: string;
31+
href: string;
32+
type: 'object' | 'dashboard' | 'page' | 'report';
33+
description?: string;
34+
}
35+
36+
/** Flatten nested navigation groups into a flat list of leaf items */
37+
function flattenNavigation(items: any[]): any[] {
38+
const result: any[] = [];
39+
for (const item of items) {
40+
if (item.type === 'group' && item.children) {
41+
result.push(...flattenNavigation(item.children));
42+
} else {
43+
result.push(item);
44+
}
45+
}
46+
return result;
47+
}
48+
49+
const TYPE_ICONS: Record<string, React.ElementType> = {
50+
object: Database,
51+
dashboard: LayoutDashboard,
52+
page: FileText,
53+
report: BarChart3,
54+
};
55+
56+
const TYPE_COLORS: Record<string, string> = {
57+
object: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200',
58+
dashboard: 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200',
59+
page: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
60+
report: 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200',
61+
};
62+
63+
export function SearchResultsPage() {
64+
const { appName } = useParams();
65+
const [searchParams, setSearchParams] = useSearchParams();
66+
const queryParam = searchParams.get('q') || '';
67+
const [query, setQuery] = useState(queryParam);
68+
69+
const apps = appConfig.apps || [];
70+
const activeApp = apps.find((a: any) => a.name === appName) || apps[0];
71+
const baseUrl = `/apps/${appName}`;
72+
73+
// Build searchable items from navigation
74+
const allItems = useMemo((): SearchResult[] => {
75+
if (!activeApp) return [];
76+
const navItems = flattenNavigation(activeApp.navigation || []);
77+
return navItems.map((item: any) => {
78+
let href = '#';
79+
if (item.type === 'object') href = `${baseUrl}/${item.objectName}`;
80+
else if (item.type === 'dashboard') href = `${baseUrl}/dashboard/${item.dashboardName}`;
81+
else if (item.type === 'page') href = `${baseUrl}/page/${item.pageName}`;
82+
else if (item.type === 'report') href = `${baseUrl}/report/${item.reportName}`;
83+
84+
return {
85+
id: item.id,
86+
label: item.label || item.objectName || item.dashboardName || item.pageName || item.reportName || '',
87+
href,
88+
type: item.type,
89+
description: item.description,
90+
};
91+
}).filter((item: SearchResult) => item.href !== '#');
92+
}, [activeApp, baseUrl]);
93+
94+
// Filter results
95+
const results = useMemo(() => {
96+
if (!query.trim()) return allItems;
97+
const lower = query.toLowerCase();
98+
return allItems.filter(
99+
item =>
100+
item.label.toLowerCase().includes(lower) ||
101+
item.type.toLowerCase().includes(lower) ||
102+
(item.description && item.description.toLowerCase().includes(lower)),
103+
);
104+
}, [allItems, query]);
105+
106+
const handleSearch = (value: string) => {
107+
setQuery(value);
108+
setSearchParams(value ? { q: value } : {});
109+
};
110+
111+
// Group results by type
112+
const grouped = useMemo(() => {
113+
const groups: Record<string, SearchResult[]> = {};
114+
for (const r of results) {
115+
(groups[r.type] ||= []).push(r);
116+
}
117+
return groups;
118+
}, [results]);
119+
120+
return (
121+
<div className="flex flex-col gap-6 p-4 sm:p-6 max-w-4xl mx-auto">
122+
{/* Header */}
123+
<div className="flex items-center gap-3">
124+
<Link
125+
to={baseUrl}
126+
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors"
127+
>
128+
<ArrowLeft className="h-4 w-4" />
129+
Back
130+
</Link>
131+
<h1 className="text-xl font-semibold">Search</h1>
132+
</div>
133+
134+
{/* Search input */}
135+
<div className="relative">
136+
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
137+
<Input
138+
value={query}
139+
onChange={e => handleSearch(e.target.value)}
140+
placeholder="Search objects, dashboards, pages, reports..."
141+
className="pl-10 h-11 text-base"
142+
autoFocus
143+
/>
144+
</div>
145+
146+
{/* Results count */}
147+
<div className="text-sm text-muted-foreground">
148+
{query.trim()
149+
? `${results.length} result${results.length !== 1 ? 's' : ''} for "${query}"`
150+
: `${allItems.length} items available`}
151+
</div>
152+
153+
{/* Results */}
154+
{results.length === 0 ? (
155+
<div className="flex flex-col items-center justify-center py-12 text-center">
156+
<Search className="h-12 w-12 text-muted-foreground/30 mb-4" />
157+
<p className="text-lg font-medium text-muted-foreground">No results found</p>
158+
<p className="text-sm text-muted-foreground/80 mt-1">
159+
Try adjusting your search terms
160+
</p>
161+
</div>
162+
) : (
163+
<div className="space-y-6">
164+
{Object.entries(grouped).map(([type, items]) => {
165+
const TypeIcon = TYPE_ICONS[type] || Database;
166+
return (
167+
<div key={type}>
168+
<h2 className="text-sm font-medium text-muted-foreground mb-2 flex items-center gap-1.5">
169+
<TypeIcon className="h-4 w-4" />
170+
{type.charAt(0).toUpperCase() + type.slice(1)}s
171+
<Badge variant="secondary" className="ml-1 text-xs">
172+
{items.length}
173+
</Badge>
174+
</h2>
175+
<div className="grid gap-2">
176+
{items.map(item => (
177+
<Link key={item.id} to={item.href}>
178+
<Card className="hover:bg-accent/50 transition-colors cursor-pointer">
179+
<CardContent className="flex items-center gap-3 p-3">
180+
<div className={`flex h-8 w-8 items-center justify-center rounded ${TYPE_COLORS[item.type] || ''}`}>
181+
{(() => { const I = TYPE_ICONS[item.type] || Database; return <I className="h-4 w-4" />; })()}
182+
</div>
183+
<div className="flex-1 min-w-0">
184+
<p className="text-sm font-medium truncate">{item.label}</p>
185+
{item.description && (
186+
<p className="text-xs text-muted-foreground truncate">{item.description}</p>
187+
)}
188+
</div>
189+
<Badge variant="outline" className="text-xs shrink-0">
190+
{item.type}
191+
</Badge>
192+
</CardContent>
193+
</Card>
194+
</Link>
195+
))}
196+
</div>
197+
</div>
198+
);
199+
})}
200+
</div>
201+
)}
202+
</div>
203+
);
204+
}

0 commit comments

Comments
 (0)