Skip to content

Commit 3ae2f7f

Browse files
feat:(Console): add search feature (apache#187)
* Console: add search feature * update icon * refactored * Address review comments
1 parent 9500956 commit 3ae2f7f

7 files changed

Lines changed: 693 additions & 3 deletions

File tree

Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
20+
import { useEffect, useState } from "react"
21+
import { useNavigate } from "react-router-dom"
22+
import { Database, Table2, Eye, FolderOpen, User, Clock, Loader2 } from "lucide-react"
23+
import { Badge } from "@/components/ui/badge"
24+
import { Dialog, DialogContent } from "@/components/ui/dialog"
25+
import {
26+
Command,
27+
CommandEmpty,
28+
CommandGroup,
29+
CommandInput,
30+
CommandItem,
31+
CommandList,
32+
CommandSeparator,
33+
} from "@/components/ui/command"
34+
import { useSearchData, type SearchResult, type SearchResultType } from "@/hooks/useSearchData"
35+
import { useRecentlyViewed, type RecentItem } from "@/hooks/useRecentlyViewed"
36+
37+
interface GlobalSearchProps {
38+
open: boolean
39+
onOpenChange: (open: boolean) => void
40+
}
41+
42+
const TYPE_ICONS: Record<SearchResultType | "recent", React.ReactNode> = {
43+
catalog: <Database className="h-4 w-4 shrink-0 text-muted-foreground" />,
44+
namespace: <FolderOpen className="h-4 w-4 shrink-0 text-muted-foreground" />,
45+
table: <Table2 className="h-4 w-4 shrink-0 text-muted-foreground" />,
46+
view: <Eye className="h-4 w-4 shrink-0 text-muted-foreground" />,
47+
principal: <User className="h-4 w-4 shrink-0 text-muted-foreground" />,
48+
recent: <Clock className="h-4 w-4 shrink-0 text-muted-foreground" />,
49+
}
50+
51+
const TYPE_LABELS: Record<SearchResultType, string> = {
52+
catalog: "Catalog",
53+
namespace: "Namespace",
54+
table: "Table",
55+
view: "View",
56+
principal: "Principal",
57+
}
58+
59+
const TYPE_ORDER: SearchResultType[] = ["catalog", "namespace", "table", "view", "principal"]
60+
61+
/**
62+
* Word-prefix match: splits both the query and the text on common delimiters.
63+
* Every query word must match the start of at least one text segment.
64+
* "on" → matches "online_store" ✓ (segment "online" starts with "on")
65+
* "sto" → matches "online_store" ✓ (segment "store" starts with "sto")
66+
* "a" → does NOT match "online_store" ✗ (no segment starts with "a")
67+
* "on sto" → matches "online_store" ✓ ("on" matches "online", "sto" matches "store")
68+
* "on xyz" → does NOT match "online_store" ✗ (no segment starts with "xyz")
69+
*/
70+
function matchesQuery(text: string, query: string): boolean {
71+
const segments = text.toLowerCase().split(/[\s_\-./]+/)
72+
return query
73+
.toLowerCase()
74+
.split(/\s+/)
75+
.filter(Boolean)
76+
.every((qw) => segments.some((seg) => seg.startsWith(qw)))
77+
}
78+
79+
export function GlobalSearch({ open, onOpenChange }: GlobalSearchProps) {
80+
const navigate = useNavigate()
81+
const { items: recentItems, trackVisit, clearAll } = useRecentlyViewed()
82+
const { results: allResults, isLoading } = useSearchData(open)
83+
const [query, setQuery] = useState("")
84+
85+
useEffect(() => {
86+
if (!open) setQuery("")
87+
}, [open])
88+
89+
const filtered =
90+
query.trim().length > 0
91+
? allResults.filter((r) => matchesQuery(r.label, query) || matchesQuery(r.sublabel, query))
92+
: []
93+
94+
const grouped = filtered.reduce<Partial<Record<SearchResultType, SearchResult[]>>>((acc, r) => {
95+
if (!acc[r.type]) acc[r.type] = []
96+
acc[r.type]!.push(r)
97+
return acc
98+
}, {})
99+
100+
const handleSelect = (item: SearchResult | RecentItem) => {
101+
trackVisit({
102+
id: item.id,
103+
type: item.type,
104+
label: item.label,
105+
sublabel: item.sublabel,
106+
path: item.path,
107+
})
108+
navigate(item.path)
109+
onOpenChange(false)
110+
}
111+
112+
const hasResults = filtered.length > 0
113+
const showRecent = query.trim().length === 0 && recentItems.length > 0
114+
115+
return (
116+
<Dialog open={open} onOpenChange={onOpenChange}>
117+
<DialogContent className="overflow-hidden p-0">
118+
<Command
119+
shouldFilter={false}
120+
className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-separator]]:mx-2"
121+
>
122+
<CommandInput
123+
placeholder="Search catalogs, namespaces, tables, views, principals…"
124+
value={query}
125+
onValueChange={setQuery}
126+
/>
127+
<CommandList>
128+
{!hasResults && !showRecent && (
129+
<CommandEmpty>
130+
{isLoading ? (
131+
<span className="flex items-center justify-center gap-2 text-muted-foreground">
132+
<Loader2 className="h-4 w-4 animate-spin" />
133+
Loading…
134+
</span>
135+
) : query.trim().length > 0 ? (
136+
"No results found."
137+
) : (
138+
"Start typing to search…"
139+
)}
140+
</CommandEmpty>
141+
)}
142+
143+
{showRecent && (
144+
<CommandGroup
145+
heading={
146+
<span className="flex items-center justify-between">
147+
<span>Recently Viewed</span>
148+
<button
149+
onClick={clearAll}
150+
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
151+
>
152+
Clear
153+
</button>
154+
</span>
155+
}
156+
>
157+
{recentItems.map((item: RecentItem) => (
158+
<CommandItem
159+
key={item.id}
160+
value={`recent-${item.id}`}
161+
onSelect={() => handleSelect(item)}
162+
>
163+
{TYPE_ICONS.recent}
164+
<span className="flex-1 truncate">{item.label}</span>
165+
{item.sublabel && (
166+
<span className="truncate text-xs text-muted-foreground max-w-[180px]">
167+
{item.sublabel}
168+
</span>
169+
)}
170+
<Badge variant="secondary" className="ml-2 text-xs">
171+
{TYPE_LABELS[item.type]}
172+
</Badge>
173+
</CommandItem>
174+
))}
175+
</CommandGroup>
176+
)}
177+
178+
{(() => {
179+
let firstGroup = true
180+
return TYPE_ORDER.map((type) => {
181+
const results = grouped[type]
182+
if (!results?.length) return null
183+
const showSep = !firstGroup
184+
firstGroup = false
185+
return (
186+
<div key={type}>
187+
{showSep && <CommandSeparator />}
188+
<CommandGroup heading={`${TYPE_LABELS[type]}s`}>
189+
{results.map((result) => (
190+
<CommandItem
191+
key={result.id}
192+
value={result.id}
193+
onSelect={() => handleSelect(result)}
194+
>
195+
{TYPE_ICONS[result.type]}
196+
<span className="flex-1 truncate">{result.label}</span>
197+
{result.sublabel && (
198+
<span className="truncate text-xs text-muted-foreground max-w-[200px]">
199+
{result.sublabel}
200+
</span>
201+
)}
202+
</CommandItem>
203+
))}
204+
</CommandGroup>
205+
</div>
206+
)
207+
})
208+
})()}
209+
</CommandList>
210+
</Command>
211+
</DialogContent>
212+
</Dialog>
213+
)
214+
}

console/src/components/layout/Header.tsx

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
* under the License.
1818
*/
1919

20-
import { LogOut, ChevronDown, Sun, Moon, Monitor } from "lucide-react"
20+
import { LogOut, ChevronDown, Sun, Moon, Monitor, Search } from "lucide-react"
2121
import { useAuth } from "@/hooks/useAuth"
2222
import { useCurrentUser } from "@/hooks/useCurrentUser"
2323
import { useTheme } from "@/hooks/useTheme"
@@ -32,7 +32,11 @@ import {
3232
} from "@/components/ui/dropdown-menu"
3333
import { Button } from "@/components/ui/button"
3434

35-
export function Header() {
35+
interface HeaderProps {
36+
onSearchOpen: () => void
37+
}
38+
39+
export function Header({ onSearchOpen }: HeaderProps) {
3640
const { logout } = useAuth()
3741
const { principal, principalRoles, loading } = useCurrentUser()
3842
const { theme, setTheme } = useTheme()
@@ -93,6 +97,21 @@ export function Header() {
9397
</DropdownMenuContent>
9498
</DropdownMenu>
9599

100+
{/* Search Trigger - Center */}
101+
<Button
102+
variant="outline"
103+
onClick={onSearchOpen}
104+
className="flex h-9 w-72 items-center justify-between gap-2 rounded-md border px-3 text-sm text-muted-foreground shadow-none hover:text-foreground"
105+
>
106+
<span className="flex items-center gap-2">
107+
<Search className="h-4 w-4" />
108+
Search…
109+
</span>
110+
<span className="pointer-events-none hidden select-none rounded border bg-muted px-1.5 py-0.5 text-xs font-mono text-muted-foreground sm:inline-flex">
111+
Ctrl+K
112+
</span>
113+
</Button>
114+
96115
{/* User Profile with Dropdown - Right Side */}
97116
<DropdownMenu>
98117
<DropdownMenuTrigger asChild>

console/src/components/layout/Layout.tsx

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,22 +17,40 @@
1717
* under the License.
1818
*/
1919

20+
import { useCallback, useEffect, useState } from "react"
2021
import { Outlet } from "react-router-dom"
2122
import { Sidebar } from "./Sidebar"
2223
import { Header } from "./Header"
2324
import { Footer } from "./Footer"
25+
import { GlobalSearch } from "@/components/GlobalSearch"
2426

2527
export function Layout() {
28+
const [searchOpen, setSearchOpen] = useState(false)
29+
30+
const openSearch = useCallback(() => setSearchOpen(true), [])
31+
32+
useEffect(() => {
33+
const handleKeyDown = (e: KeyboardEvent) => {
34+
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
35+
e.preventDefault()
36+
setSearchOpen((prev) => !prev)
37+
}
38+
}
39+
window.addEventListener("keydown", handleKeyDown)
40+
return () => window.removeEventListener("keydown", handleKeyDown)
41+
}, [])
42+
2643
return (
2744
<div className="flex h-screen overflow-hidden">
2845
<Sidebar />
2946
<div className="flex flex-1 flex-col overflow-hidden">
30-
<Header />
47+
<Header onSearchOpen={openSearch} />
3148
<main className="flex-1 overflow-y-auto">
3249
<Outlet />
3350
</main>
3451
<Footer />
3552
</div>
53+
<GlobalSearch open={searchOpen} onOpenChange={setSearchOpen} />
3654
</div>
3755
)
3856
}

0 commit comments

Comments
 (0)