Skip to content

Commit 3d14d61

Browse files
chore: Improved search UI fix (feast-dev#5316)
* Improve search UI: relocate search bar, add command+k shortcut, enhance UX Co-Authored-By: Francisco Javier Arceo <[email protected]> * Fix TypeScript errors in search components Co-Authored-By: Francisco Javier Arceo <[email protected]> * Fix inputRef handling in RegistrySearch component Co-Authored-By: Francisco Javier Arceo <[email protected]> * Format code with prettier Co-Authored-By: Francisco Javier Arceo <[email protected]> * Add search button to sidebar for easier access Co-Authored-By: Francisco Javier Arceo <[email protected]> * Format Layout.tsx with prettier Co-Authored-By: Francisco Javier Arceo <[email protected]> * Fix search bar width and center it Co-Authored-By: Francisco Javier Arceo <[email protected]> * Remove redundant search indicator from search bar Co-Authored-By: Francisco Javier Arceo <[email protected]> * Format Layout.tsx with yarn format Co-Authored-By: Francisco Javier Arceo <[email protected]> * Remove Search (⌘K) text from sidebar button Co-Authored-By: Francisco Javier Arceo <[email protected]> * Add ⌘K indicator to search input and remove magnifying glass from navbar Co-Authored-By: Francisco Javier Arceo <[email protected]> * Fix theme toggle position to be left-justified Co-Authored-By: Francisco Javier Arceo <[email protected]> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
1 parent e2c5c13 commit 3d14d61

File tree

4 files changed

+243
-77
lines changed

4 files changed

+243
-77
lines changed
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import React, { useEffect } from "react";
2+
3+
interface GlobalSearchShortcutProps {
4+
onOpen: () => void;
5+
}
6+
7+
const GlobalSearchShortcut: React.FC<GlobalSearchShortcutProps> = ({
8+
onOpen,
9+
}) => {
10+
useEffect(() => {
11+
const handleKeyDown = (event: KeyboardEvent) => {
12+
if ((event.metaKey || event.ctrlKey) && event.key === "k") {
13+
event.preventDefault();
14+
onOpen();
15+
}
16+
};
17+
18+
document.addEventListener("keydown", handleKeyDown);
19+
return () => {
20+
document.removeEventListener("keydown", handleKeyDown);
21+
};
22+
}, [onOpen]);
23+
24+
return null; // This component doesn't render anything
25+
};
26+
27+
export default GlobalSearchShortcut;

ui/src/components/RegistrySearch.tsx

Lines changed: 123 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,15 @@
1-
import React, { useState } from "react";
2-
import { EuiText, EuiFieldSearch, EuiSpacer } from "@elastic/eui";
1+
import React, {
2+
useState,
3+
useRef,
4+
forwardRef,
5+
useImperativeHandle,
6+
} from "react";
7+
import {
8+
EuiText,
9+
EuiFieldSearch,
10+
EuiSpacer,
11+
EuiHorizontalRule,
12+
} from "@elastic/eui";
313
import EuiCustomLink from "./EuiCustomLink";
414

515
interface RegistrySearchProps {
@@ -10,81 +20,124 @@ interface RegistrySearchProps {
1020
}[];
1121
}
1222

13-
const RegistrySearch: React.FC<RegistrySearchProps> = ({ categories }) => {
14-
const [searchText, setSearchText] = useState("");
23+
export interface RegistrySearchRef {
24+
focusSearchInput: () => void;
25+
}
26+
27+
const RegistrySearch = forwardRef<RegistrySearchRef, RegistrySearchProps>(
28+
({ categories }, ref) => {
29+
const [searchText, setSearchText] = useState("");
30+
const inputRef = useRef<HTMLInputElement | null>(null);
31+
32+
const focusSearchInput = () => {
33+
if (inputRef.current) {
34+
inputRef.current.focus();
35+
}
36+
};
1537

16-
const searchResults = categories.map(({ name, data, getLink }) => {
17-
const filteredItems = searchText
18-
? data.filter((item) => {
19-
const itemName =
20-
"name" in item
21-
? String(item.name)
22-
: "spec" in item && item.spec && "name" in item.spec
23-
? String(item.spec.name ?? "Unknown")
24-
: "Unknown";
38+
useImperativeHandle(
39+
ref,
40+
() => ({
41+
focusSearchInput,
42+
}),
43+
[focusSearchInput],
44+
);
2545

26-
return itemName.toLowerCase().includes(searchText.toLowerCase());
27-
})
28-
: [];
46+
const searchResults = categories.map(({ name, data, getLink }) => {
47+
const filteredItems = searchText
48+
? data.filter((item) => {
49+
const itemName =
50+
"name" in item
51+
? String(item.name)
52+
: "spec" in item && item.spec && "name" in item.spec
53+
? String(item.spec.name ?? "Unknown")
54+
: "Unknown";
2955

30-
return { name, items: filteredItems, getLink };
31-
});
56+
return itemName.toLowerCase().includes(searchText.toLowerCase());
57+
})
58+
: [];
3259

33-
return (
34-
<>
35-
<EuiSpacer size="l" />
36-
<EuiText>
37-
<h3>Search in registry</h3>
38-
</EuiText>
39-
<EuiSpacer size="s" />
40-
<EuiFieldSearch
41-
placeholder="Search across Feature Views, Features, Entities, etc."
42-
value={searchText}
43-
onChange={(e) => setSearchText(e.target.value)}
44-
isClearable
45-
fullWidth
46-
/>
47-
<EuiSpacer size="m" />
60+
return { name, items: filteredItems, getLink };
61+
});
4862

49-
{searchText && (
50-
<EuiText>
51-
<h3>Search Results</h3>
52-
{searchResults.some(({ items }) => items.length > 0) ? (
53-
searchResults.map(({ name, items, getLink }, index) =>
54-
items.length > 0 ? (
55-
<div key={index}>
56-
<h4>{name}</h4>
57-
<ul>
58-
{items.map((item, idx) => {
59-
const itemName =
60-
"name" in item
61-
? item.name
62-
: "spec" in item
63-
? item.spec?.name
64-
: "Unknown";
63+
return (
64+
<>
65+
<EuiFieldSearch
66+
placeholder="Search across Feature Views, Features, Entities, etc."
67+
value={searchText}
68+
onChange={(e) => setSearchText(e.target.value)}
69+
isClearable
70+
fullWidth
71+
inputRef={(node) => {
72+
inputRef.current = node;
73+
}}
74+
aria-label="Search registry"
75+
compressed
76+
append={
77+
<EuiText size="xs" color="subdued">
78+
<span style={{ whiteSpace: "nowrap" }}>⌘K</span>
79+
</EuiText>
80+
}
81+
/>
82+
<EuiSpacer size="s" />
83+
{searchText && (
84+
<>
85+
<EuiText>
86+
<h4>Search Results</h4>
87+
</EuiText>
88+
<EuiSpacer size="xs" />
89+
{searchResults.some(({ items }) => items.length > 0) ? (
90+
<div className="euiPanel euiPanel--borderRadiusMedium euiPanel--plain euiPanel--hasShadow">
91+
{searchResults.map(({ name, items, getLink }, index) =>
92+
items.length > 0 ? (
93+
<div key={index} className="euiPanel__body">
94+
<EuiText>
95+
<h5>{name}</h5>
96+
</EuiText>
97+
<EuiSpacer size="xs" />
98+
<ul
99+
style={{ listStyleType: "none", padding: 0, margin: 0 }}
100+
>
101+
{items.map((item, idx) => {
102+
const itemName =
103+
"name" in item
104+
? item.name
105+
: "spec" in item
106+
? item.spec?.name
107+
: "Unknown";
65108

66-
const itemLink = getLink(item);
109+
const itemLink = getLink(item);
67110

68-
return (
69-
<li key={idx}>
70-
<EuiCustomLink to={itemLink}>
71-
{itemName}
72-
</EuiCustomLink>
73-
</li>
74-
);
75-
})}
76-
</ul>
77-
<EuiSpacer size="m" />
111+
return (
112+
<li key={idx} style={{ margin: "8px 0" }}>
113+
<EuiCustomLink to={itemLink}>
114+
{itemName}
115+
</EuiCustomLink>
116+
</li>
117+
);
118+
})}
119+
</ul>
120+
{index <
121+
searchResults.filter(
122+
(result) => result.items.length > 0,
123+
).length -
124+
1 && <EuiHorizontalRule margin="m" />}
125+
</div>
126+
) : null,
127+
)}
128+
</div>
129+
) : (
130+
<div className="euiPanel euiPanel--borderRadiusMedium euiPanel--plain euiPanel--hasShadow">
131+
<div className="euiPanel__body">
132+
<p>No matches found.</p>
78133
</div>
79-
) : null,
80-
)
81-
) : (
82-
<p>No matches found.</p>
83-
)}
84-
</EuiText>
85-
)}
86-
</>
87-
);
88-
};
134+
</div>
135+
)}
136+
</>
137+
)}
138+
</>
139+
);
140+
},
141+
);
89142

90143
export default RegistrySearch;

ui/src/pages/Layout.tsx

Lines changed: 93 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React from "react";
1+
import React, { useState, useRef } from "react";
22

33
import {
44
EuiPage,
@@ -7,34 +7,95 @@ import {
77
EuiErrorBoundary,
88
EuiHorizontalRule,
99
EuiSpacer,
10+
EuiPageHeader,
11+
EuiFlexGroup,
12+
EuiFlexItem,
1013
} from "@elastic/eui";
1114
import { Outlet } from "react-router-dom";
1215

1316
import RegistryPathContext from "../contexts/RegistryPathContext";
1417
import { useParams } from "react-router-dom";
1518
import { useLoadProjectsList } from "../contexts/ProjectListContext";
19+
import useLoadRegistry from "../queries/useLoadRegistry";
1620

1721
import ProjectSelector from "../components/ProjectSelector";
1822
import Sidebar from "./Sidebar";
1923
import FeastWordMark from "../graphics/FeastWordMark";
2024
import ThemeToggle from "../components/ThemeToggle";
25+
import RegistrySearch, {
26+
RegistrySearchRef,
27+
} from "../components/RegistrySearch";
28+
import GlobalSearchShortcut from "../components/GlobalSearchShortcut";
2129

2230
const Layout = () => {
2331
// Registry Path Context has to be inside Layout
2432
// because it has to be under routes
2533
// in order to use useParams
2634
let { projectName } = useParams();
35+
const [isSearchOpen, setIsSearchOpen] = useState(false);
36+
const searchRef = useRef<RegistrySearchRef>(null);
2737

28-
const { data } = useLoadProjectsList();
38+
const { data: projectsData } = useLoadProjectsList();
2939

30-
const currentProject = data?.projects.find((project) => {
40+
const currentProject = projectsData?.projects.find((project) => {
3141
return project.id === projectName;
3242
});
3343

3444
const registryPath = currentProject?.registryPath || "";
45+
const { data } = useLoadRegistry(registryPath);
46+
47+
const categories = data
48+
? [
49+
{
50+
name: "Data Sources",
51+
data: data.objects.dataSources || [],
52+
getLink: (item: any) => `/p/${projectName}/data-source/${item.name}`,
53+
},
54+
{
55+
name: "Entities",
56+
data: data.objects.entities || [],
57+
getLink: (item: any) => `/p/${projectName}/entity/${item.name}`,
58+
},
59+
{
60+
name: "Features",
61+
data: data.allFeatures || [],
62+
getLink: (item: any) => {
63+
const featureView = item?.featureView;
64+
return featureView
65+
? `/p/${projectName}/feature-view/${featureView}/feature/${item.name}`
66+
: "#";
67+
},
68+
},
69+
{
70+
name: "Feature Views",
71+
data: data.mergedFVList || [],
72+
getLink: (item: any) => `/p/${projectName}/feature-view/${item.name}`,
73+
},
74+
{
75+
name: "Feature Services",
76+
data: data.objects.featureServices || [],
77+
getLink: (item: any) => {
78+
const serviceName = item?.name || item?.spec?.name;
79+
return serviceName
80+
? `/p/${projectName}/feature-service/${serviceName}`
81+
: "#";
82+
},
83+
},
84+
]
85+
: [];
86+
87+
const handleSearchOpen = () => {
88+
setIsSearchOpen(true);
89+
setTimeout(() => {
90+
if (searchRef.current) {
91+
searchRef.current.focusSearchInput();
92+
}
93+
}, 100);
94+
};
3595

3696
return (
3797
<RegistryPathContext.Provider value={registryPath}>
98+
<GlobalSearchShortcut onOpen={handleSearchOpen} />
3899
<EuiPage paddingSize="none" style={{ background: "transparent" }}>
39100
<EuiPageSidebar
40101
paddingSize="l"
@@ -51,13 +112,41 @@ const Layout = () => {
51112
<Sidebar />
52113
<EuiSpacer size="l" />
53114
<EuiHorizontalRule margin="s" />
54-
<ThemeToggle />
115+
<div
116+
style={{
117+
display: "flex",
118+
justifyContent: "flex-start",
119+
alignItems: "center",
120+
}}
121+
>
122+
<ThemeToggle />
123+
</div>
55124
</React.Fragment>
56125
)}
57126
</EuiPageSidebar>
58127

59128
<EuiPageBody>
60129
<EuiErrorBoundary>
130+
{isSearchOpen && data && (
131+
<EuiPageHeader
132+
paddingSize="l"
133+
style={{
134+
position: "sticky",
135+
top: 0,
136+
zIndex: 100,
137+
borderBottom: "1px solid #D3DAE6",
138+
}}
139+
>
140+
<EuiFlexGroup justifyContent="center">
141+
<EuiFlexItem
142+
grow={false}
143+
style={{ width: "600px", maxWidth: "90%" }}
144+
>
145+
<RegistrySearch ref={searchRef} categories={categories} />
146+
</EuiFlexItem>
147+
</EuiFlexGroup>
148+
</EuiPageHeader>
149+
)}
61150
<Outlet />
62151
</EuiErrorBoundary>
63152
</EuiPageBody>

ui/src/pages/ProjectOverviewPage.tsx

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -171,9 +171,6 @@ const ProjectOverviewPage = () => {
171171

172172
{selectedTabId === "visualization" && <RegistryVisualizationTab />}
173173
</EuiPageTemplate.Section>
174-
<EuiPageTemplate.Section>
175-
{isSuccess && <RegistrySearch categories={categories} />}
176-
</EuiPageTemplate.Section>
177174
</EuiPageTemplate>
178175
);
179176
};

0 commit comments

Comments
 (0)