Skip to content

Commit d016608

Browse files
committed
wip
1 parent 6813b77 commit d016608

File tree

9 files changed

+472
-165
lines changed

9 files changed

+472
-165
lines changed
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
/**
2+
* Copyright (c) 2022 Gitpod GmbH. All rights reserved.
3+
* Licensed under the GNU Affero General Public License (AGPL).
4+
* See License.AGPL.txt in the project root for license information.
5+
*/
6+
7+
import React, { FunctionComponent, useMemo, useState } from "react";
8+
import Arrow from "./Arrow";
9+
10+
export interface DropDown2Element {
11+
id: string;
12+
element: JSX.Element;
13+
isSelectable?: boolean;
14+
}
15+
16+
export interface DropDown2Props {
17+
getElements: (searchString: string) => DropDown2Element[];
18+
searchPlaceholder: string;
19+
onSelectionChange: (id: string) => void;
20+
}
21+
22+
export const DropDown2: FunctionComponent<DropDown2Props> = (props) => {
23+
const [showDropDown, setShowDropDown] = useState<boolean>(false);
24+
const onSelected = useMemo(
25+
() => (elementId: string) => {
26+
props.onSelectionChange(elementId);
27+
setShowDropDown(false);
28+
},
29+
[props],
30+
);
31+
const [search, setSearch] = useState<string>("");
32+
const filteredOptions = props.getElements(search);
33+
const [selectedElementTemp, setSelectedElementTemp] = useState<string | undefined>(filteredOptions[0]?.id);
34+
35+
const onKeyDown = useMemo(
36+
() => (e: React.KeyboardEvent) => {
37+
if (e.key === "ArrowDown") {
38+
e.preventDefault();
39+
let idx = filteredOptions.findIndex((e) => e.id === selectedElementTemp);
40+
while (idx++ < filteredOptions.length - 1) {
41+
const candidate = filteredOptions[idx];
42+
if (candidate.isSelectable) {
43+
setSelectedElementTemp(candidate.id);
44+
return;
45+
}
46+
}
47+
return;
48+
}
49+
if (e.key === "ArrowUp") {
50+
e.preventDefault();
51+
let idx = filteredOptions.findIndex((e) => e.id === selectedElementTemp);
52+
while (idx-- > 0) {
53+
const candidate = filteredOptions[idx];
54+
if (candidate.isSelectable) {
55+
setSelectedElementTemp(candidate.id);
56+
return;
57+
}
58+
}
59+
return;
60+
}
61+
if (e.key === "Escape") {
62+
setShowDropDown(false);
63+
e.preventDefault();
64+
}
65+
if (e.key === "Enter" && selectedElementTemp && filteredOptions.some((e) => e.id === selectedElementTemp)) {
66+
e.preventDefault();
67+
props.onSelectionChange(selectedElementTemp);
68+
setShowDropDown(false);
69+
}
70+
},
71+
[filteredOptions, props, selectedElementTemp],
72+
);
73+
74+
const onBlur = useMemo(
75+
() => (e: React.FocusEvent) => {
76+
setShowDropDown(false);
77+
},
78+
[setShowDropDown],
79+
);
80+
81+
const doShowDropDown = useMemo(() => () => setShowDropDown(true), []);
82+
return (
83+
<div onKeyDown={onKeyDown} onBlur={onBlur} className="relative flex flex-col">
84+
{showDropDown ? (
85+
<input
86+
type="text"
87+
autoFocus
88+
className="w-full focus my-1 rounded-lg h-10"
89+
placeholder={props.searchPlaceholder}
90+
value={search}
91+
onChange={(e) => setSearch(e.target.value)}
92+
/>
93+
) : (
94+
<div
95+
className="h-12 rounded-lg bg-gray-200 dark:bg-gray-800 hover:bg-gray-300 dark:hover:bg-gray-700 cursor-pointer flex items-center px-2 border border-gray-200 dark:border-gray-500"
96+
onClick={doShowDropDown}
97+
>
98+
{props.children}
99+
<div className="flex-grow" />
100+
<div>
101+
<Arrow direction={"down"} />
102+
</div>
103+
</div>
104+
)}
105+
{showDropDown && (
106+
<ul className="absolute w-full top-11 bg-gray-100 dark:bg-gray-900 max-h-72 overflow-auto rounded-lg mt-3 z-50">
107+
{filteredOptions.length > 0 ? (
108+
filteredOptions.map((element) => {
109+
let selectionClasses = `bg-gray-100 dark:bg-gray-900 cursor-pointer`;
110+
if (element.id === selectedElementTemp) {
111+
selectionClasses = `bg-gray-300 dark:bg-gray-700 cursor-pointer`;
112+
}
113+
if (!element.isSelectable) {
114+
selectionClasses = ``;
115+
}
116+
return (
117+
<li
118+
key={element.id}
119+
className={
120+
"h-12 rounded-lg flex items-center px-2 border border-gray-200 dark:border-gray-500 " +
121+
selectionClasses
122+
}
123+
onMouseDown={() => {
124+
if (element.isSelectable) {
125+
setSelectedElementTemp(element.id);
126+
onSelected(element.id);
127+
}
128+
}}
129+
onMouseOver={() => setSelectedElementTemp(element.id)}
130+
>
131+
{element.element}
132+
</li>
133+
);
134+
})
135+
) : (
136+
<li key="no-elements" className={"rounded-md "}>
137+
<div className="h-12 pl-8 py-3 text-gray-800 dark:text-gray-200">No results</div>
138+
</li>
139+
)}
140+
</ul>
141+
)}
142+
</div>
143+
);
144+
};

components/dashboard/src/components/RepositoryFinder.tsx

Lines changed: 55 additions & 154 deletions
Original file line numberDiff line numberDiff line change
@@ -4,137 +4,65 @@
44
* See License.AGPL.txt in the project root for license information.
55
*/
66

7-
import { User } from "@gitpod/gitpod-protocol";
8-
import React, { useContext, useEffect, useState } from "react";
7+
import { useEffect, useMemo, useState } from "react";
98
import { getGitpodService } from "../service/service";
10-
import { UserContext } from "../user-context";
11-
12-
type SearchResult = string;
13-
type SearchData = SearchResult[];
9+
import { DropDown2, DropDown2Element } from "./DropDown2";
1410

1511
const LOCAL_STORAGE_KEY = "open-in-gitpod-search-data";
16-
const MAX_DISPLAYED_ITEMS = 20;
17-
18-
export default function RepositoryFinder(props: { initialQuery?: string }) {
19-
const { user } = useContext(UserContext);
20-
const [searchQuery, setSearchQuery] = useState<string>(props.initialQuery || "");
21-
const [searchResults, setSearchResults] = useState<SearchResult[]>([]);
22-
const [selectedSearchResult, setSelectedSearchResult] = useState<SearchResult | undefined>();
23-
24-
const onResults = (results: SearchResult[]) => {
25-
if (JSON.stringify(results) !== JSON.stringify(searchResults)) {
26-
setSearchResults(results);
27-
setSelectedSearchResult(results[0]);
28-
}
29-
};
30-
31-
const search = async (query: string) => {
32-
setSearchQuery(query);
33-
await findResults(query, onResults);
34-
if (await refreshSearchData(query, user)) {
35-
// Re-run search if the underlying search data has changed
36-
await findResults(query, onResults);
37-
}
38-
};
3912

40-
useEffect(() => {
41-
search("");
42-
}, []);
43-
44-
// Up/Down keyboard navigation between results
45-
const onKeyDown = (event: React.KeyboardEvent) => {
46-
if (!selectedSearchResult) {
47-
return;
48-
}
49-
const selectedIndex = searchResults.indexOf(selectedSearchResult);
50-
const select = (index: number) => {
51-
// Implement a true modulus in order to "wrap around" (e.g. `select(-1)` should select the last result)
52-
// Source: https://stackoverflow.com/a/4467559/3461173
53-
const n = Math.min(searchResults.length, MAX_DISPLAYED_ITEMS);
54-
setSelectedSearchResult(searchResults[((index % n) + n) % n]);
55-
};
56-
if (event.key === "ArrowDown") {
57-
event.preventDefault();
58-
select(selectedIndex + 1);
59-
return;
60-
}
61-
if (event.key === "ArrowUp") {
62-
event.preventDefault();
63-
select(selectedIndex - 1);
64-
return;
65-
}
66-
};
13+
interface RepositoryFinderProps {
14+
initialValue?: string;
15+
maxDisplayItems?: number;
16+
setSelection: (selection: string) => void;
17+
}
6718

19+
export default function RepositoryFinder(props: RepositoryFinderProps) {
20+
const [suggestedContextURLs, setSuggestedContextURLs] = useState<string[]>(loadSearchData());
6821
useEffect(() => {
69-
const element = document.querySelector(`a[href='/#${selectedSearchResult}']`);
70-
if (element) {
71-
element.scrollIntoView({ behavior: "smooth", block: "nearest" });
72-
}
73-
}, [selectedSearchResult]);
74-
75-
const onSubmit = (event: React.FormEvent) => {
76-
event.preventDefault();
77-
if (selectedSearchResult) {
78-
window.location.href = "/#" + selectedSearchResult;
79-
}
80-
};
22+
getGitpodService()
23+
.server.getSuggestedContextURLs()
24+
.then((urls) => {
25+
setSuggestedContextURLs(urls);
26+
saveSearchData(urls);
27+
});
28+
}, [suggestedContextURLs]);
29+
30+
const getElements = useMemo(
31+
() => (searchString: string) => {
32+
const result = [...suggestedContextURLs];
33+
try {
34+
// If the searchString is a URL, and it's not present in the proposed results, "artificially" add it here.
35+
new URL(searchString);
36+
if (!result.includes(searchString)) {
37+
result.push(searchString);
38+
}
39+
} catch {}
40+
return result
41+
.filter((e) => e.toLowerCase().indexOf(searchString.toLowerCase()) !== -1)
42+
.map(
43+
(e) =>
44+
({
45+
id: e,
46+
element: <div className="px-4 py-3 text-ellipsis overflow-hidden">{e}</div>,
47+
isSelectable: true,
48+
} as DropDown2Element),
49+
);
50+
},
51+
[suggestedContextURLs],
52+
);
8153

8254
return (
83-
<form onSubmit={onSubmit}>
84-
<div className="flex px-4 rounded-xl border border-gray-300 dark:border-gray-500">
85-
<div className="py-4">
86-
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 16 16" width="16" height="16">
87-
<path
88-
fill="#A8A29E"
89-
d="M6 2a4 4 0 100 8 4 4 0 000-8zM0 6a6 6 0 1110.89 3.477l4.817 4.816a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 010 6z"
90-
/>
91-
</svg>
92-
</div>
93-
<input
94-
type="search"
95-
className="flex-grow"
96-
placeholder="Paste repository URL or type to find suggestions"
97-
autoFocus
98-
value={searchQuery}
99-
onChange={(e) => search(e.target.value)}
100-
onKeyDown={onKeyDown}
101-
/>
102-
</div>
103-
<div className="mt-3 -mx-5 px-5 flex flex-col space-y-2 h-64 overflow-y-auto">
104-
{searchResults.slice(0, MAX_DISPLAYED_ITEMS).map((result, index) => (
105-
<a
106-
className={
107-
`px-4 py-3 rounded-xl` +
108-
(result === selectedSearchResult ? " bg-gray-600 text-gray-50 dark:bg-gray-700" : "")
109-
}
110-
href={`/#${result}`}
111-
key={`search-result-${index}`}
112-
onMouseEnter={() => setSelectedSearchResult(result)}
113-
>
114-
{searchQuery.length < 2 ? (
115-
<span>{result}</span>
116-
) : (
117-
result.split(searchQuery).map((segment, index) => (
118-
<span>
119-
{index === 0 ? <></> : <strong>{searchQuery}</strong>}
120-
{segment}
121-
</span>
122-
))
123-
)}
124-
</a>
125-
))}
126-
{searchResults.length > MAX_DISPLAYED_ITEMS && (
127-
<span className="mt-3 px-4 py-2 text-sm text-gray-400 dark:text-gray-500">
128-
{searchResults.length - MAX_DISPLAYED_ITEMS} more result
129-
{searchResults.length - MAX_DISPLAYED_ITEMS === 1 ? "" : "s"} found
130-
</span>
131-
)}
132-
</div>
133-
</form>
55+
<DropDown2
56+
getElements={getElements}
57+
onSelectionChange={props.setSelection}
58+
searchPlaceholder="Paste repository URL or type to find suggestions"
59+
>
60+
<div className="m-3 text-ellipsis overflow-hidden">{props.initialValue || "Select Repository"}</div>
61+
</DropDown2>
13462
);
13563
}
13664

137-
function loadSearchData(): SearchData {
65+
function loadSearchData(): string[] {
13866
const string = localStorage.getItem(LOCAL_STORAGE_KEY);
13967
if (!string) {
14068
return [];
@@ -148,45 +76,18 @@ function loadSearchData(): SearchData {
14876
}
14977
}
15078

151-
function saveSearchData(searchData: SearchData): void {
79+
function saveSearchData(searchData: string[]): void {
15280
try {
15381
window.localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(searchData));
15482
} catch (error) {
15583
console.warn("Could not save search data into local storage", error);
15684
}
15785
}
15886

159-
let refreshSearchDataPromise: Promise<boolean> | undefined;
160-
export async function refreshSearchData(query: string, user: User | undefined): Promise<boolean> {
161-
if (refreshSearchDataPromise) {
162-
// Another refresh is already in progress, no need to run another one in parallel.
163-
return refreshSearchDataPromise;
164-
}
165-
refreshSearchDataPromise = actuallyRefreshSearchData(query, user);
166-
const didChange = await refreshSearchDataPromise;
167-
refreshSearchDataPromise = undefined;
168-
return didChange;
169-
}
170-
171-
// Fetch all possible search results and cache them into local storage
172-
async function actuallyRefreshSearchData(query: string, user: User | undefined): Promise<boolean> {
173-
const oldData = loadSearchData();
174-
const newData = await getGitpodService().server.getSuggestedContextURLs();
175-
if (JSON.stringify(oldData) !== JSON.stringify(newData)) {
176-
saveSearchData(newData);
177-
return true;
178-
}
179-
return false;
180-
}
181-
182-
async function findResults(query: string, onResults: (results: string[]) => void) {
183-
const searchData = loadSearchData();
184-
try {
185-
// If the query is a URL, and it's not present in the proposed results, "artificially" add it here.
186-
new URL(query);
187-
if (!searchData.includes(query)) {
188-
searchData.push(query);
189-
}
190-
} catch {}
191-
onResults(searchData.filter((result) => result.toLowerCase().includes(query.toLowerCase())));
87+
export function refreshSearchData() {
88+
getGitpodService()
89+
.server.getSuggestedContextURLs()
90+
.then((urls) => {
91+
saveSearchData(urls);
92+
});
19293
}

0 commit comments

Comments
 (0)