Skip to content

Commit 89af3c0

Browse files
Adding searchRepositories jsonrpc method (#18827)
* adding searchRepositories method w/ github impl * fix a few things and add bbs impl for searchRepos * integrate searchRepositories in RepositoryFinder * adjusting loading indicators * cleanup * wrap repository search behind feature flag * Changing queries for github repo search * fix enabled to use debounced value * fix org filters * drop variables for query * fixing encoding
1 parent 777a9cb commit 89af3c0

File tree

14 files changed

+309
-45
lines changed

14 files changed

+309
-45
lines changed

components/dashboard/src/components/DropDown2.tsx

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import React, {
1717
} from "react";
1818
import Arrow from "./Arrow";
1919
import classNames from "classnames";
20+
import { ReactComponent as Spinner } from "../icons/Spinner.svg";
2021

2122
export interface DropDown2Element {
2223
id: string;
@@ -32,6 +33,8 @@ export interface DropDown2Props {
3233
disableSearch?: boolean;
3334
expanded?: boolean;
3435
onSelectionChange: (id: string) => void;
36+
// Meant to allow consumers to react to search changes even though state is managed internally
37+
onSearchChange?: (searchString: string) => void;
3538
allOptions?: string;
3639
}
3740

@@ -45,6 +48,7 @@ export const DropDown2: FunctionComponent<DropDown2Props> = ({
4548
disableSearch,
4649
children,
4750
onSelectionChange,
51+
onSearchChange,
4852
}) => {
4953
const [showDropDown, setShowDropDown] = useState<boolean>(!disabled && !!expanded);
5054
const nodeRef: RefObject<HTMLDivElement> = useRef(null);
@@ -61,7 +65,7 @@ export const DropDown2: FunctionComponent<DropDown2Props> = ({
6165

6266
// reset search when the drop down is expanded or closed
6367
useEffect(() => {
64-
setSearch("");
68+
updateSearch("");
6569
if (allOptions) {
6670
setSelectedElementTemp(allOptions);
6771
}
@@ -72,6 +76,16 @@ export const DropDown2: FunctionComponent<DropDown2Props> = ({
7276
// eslint-disable-next-line react-hooks/exhaustive-deps
7377
}, [showDropDown]);
7478

79+
const updateSearch = useCallback(
80+
(value: string) => {
81+
setSearch(value);
82+
if (onSearchChange) {
83+
onSearchChange(value);
84+
}
85+
},
86+
[onSearchChange],
87+
);
88+
7589
const toggleDropDown = useCallback(() => {
7690
if (disabled) {
7791
return;
@@ -158,6 +172,9 @@ export const DropDown2: FunctionComponent<DropDown2Props> = ({
158172
[setShowDropDown],
159173
);
160174

175+
const showInputLoadingIndicator = filteredOptions.length > 0 && loading;
176+
const showResultsLoadingIndicator = filteredOptions.length === 0 && loading;
177+
161178
return (
162179
<div
163180
onKeyDown={onKeyDown}
@@ -191,25 +208,30 @@ export const DropDown2: FunctionComponent<DropDown2Props> = ({
191208
{showDropDown && (
192209
<div className="absolute w-full top-12 bg-gray-100 dark:bg-gray-800 rounded-b-lg mt-3 z-50 p-2 filter drop-shadow-xl">
193210
{!disableSearch && (
194-
<div className="h-12">
211+
<div className="relative mb-2">
195212
<input
196213
type="text"
197214
autoFocus
198215
className={"w-full focus rounded-lg"}
199216
placeholder={searchPlaceholder}
200217
value={search}
201-
onChange={(e) => setSearch(e.target.value)}
218+
onChange={(e) => updateSearch(e.target.value)}
202219
/>
220+
{showInputLoadingIndicator && (
221+
<div className="absolute top-0 right-0 h-full flex items-center pr-2">
222+
<Spinner className="h-4 w-4 opacity-25 animate-spin" />
223+
</div>
224+
)}
203225
</div>
204226
)}
205227
<ul className="max-h-60 overflow-auto">
206-
{loading && (
228+
{showResultsLoadingIndicator && (
207229
<div className="flex-col space-y-2 animate-pulse">
208230
<div className="bg-gray-300 dark:bg-gray-500 h-4 rounded" />
209231
<div className="bg-gray-300 dark:bg-gray-500 h-4 rounded" />
210232
</div>
211233
)}
212-
{!loading && filteredOptions.length > 0 ? (
234+
{!showResultsLoadingIndicator && filteredOptions.length > 0 ? (
213235
filteredOptions.map((element) => {
214236
let selectionClasses = `dark:bg-gray-800 cursor-pointer`;
215237
if (element.id === selectedElementTemp) {
@@ -237,7 +259,7 @@ export const DropDown2: FunctionComponent<DropDown2Props> = ({
237259
</li>
238260
);
239261
})
240-
) : !loading ? (
262+
) : !showResultsLoadingIndicator ? (
241263
<li key="no-elements" className={"rounded-md "}>
242264
<div className="h-12 pl-8 py-3 text-gray-800 dark:text-gray-200">No results</div>
243265
</li>

components/dashboard/src/components/RepositoryFinder.tsx

Lines changed: 21 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,10 @@ import { getGitpodService } from "../service/service";
99
import { DropDown2, DropDown2Element, DropDown2SelectedElement } from "./DropDown2";
1010
import RepositorySVG from "../icons/Repository.svg";
1111
import { ReactComponent as RepositoryIcon } from "../icons/RepositoryWithColor.svg";
12-
import { useSuggestedRepositories } from "../data/git-providers/suggested-repositories-query";
1312
import { useFeatureFlag } from "../data/featureflag-query";
1413
import { SuggestedRepository } from "@gitpod/gitpod-protocol";
1514
import { MiddleDot } from "./typography/MiddleDot";
15+
import { filterRepos, useUnifiedRepositorySearch } from "../data/git-providers/unified-repositories-search-query";
1616

1717
// TODO: Remove this once we've fully enabled `includeProjectsOnCreateWorkspace`
1818
// flag (caches w/ react-query instead of local storage)
@@ -31,10 +31,12 @@ export default function RepositoryFinder(props: RepositoryFinderProps) {
3131
const includeProjectsOnCreateWorkspace = useFeatureFlag("includeProjectsOnCreateWorkspace");
3232

3333
const [suggestedContextURLs, setSuggestedContextURLs] = useState<string[]>(loadSearchData());
34-
const { data: suggestedRepos, isLoading } = useSuggestedRepositories();
34+
35+
const [searchString, setSearchString] = useState("");
36+
const { data: repos, isLoading, isSearching } = useUnifiedRepositorySearch({ searchString });
3537

3638
// TODO: remove this once includeProjectsOnCreateWorkspace is fully enabled
37-
const suggestedRepoURLs = useMemo(() => {
39+
const normalizedRepos = useMemo(() => {
3840
// If the flag is disabled continue to use suggestedContextURLs, but convert into SuggestedRepository objects
3941
if (!includeProjectsOnCreateWorkspace) {
4042
return suggestedContextURLs.map(
@@ -44,8 +46,8 @@ export default function RepositoryFinder(props: RepositoryFinderProps) {
4446
);
4547
}
4648

47-
return suggestedRepos || [];
48-
}, [suggestedContextURLs, suggestedRepos, includeProjectsOnCreateWorkspace]);
49+
return repos;
50+
}, [includeProjectsOnCreateWorkspace, repos, suggestedContextURLs]);
4951

5052
// TODO: remove this once includeProjectsOnCreateWorkspace is fully enabled
5153
useEffect(() => {
@@ -60,7 +62,7 @@ export default function RepositoryFinder(props: RepositoryFinderProps) {
6062
const handleSelectionChange = useCallback(
6163
(selectedID: string) => {
6264
// selectedId is either projectId or repo url
63-
const matchingSuggestion = suggestedRepos?.find((repo) => {
65+
const matchingSuggestion = normalizedRepos?.find((repo) => {
6466
if (repo.projectId) {
6567
return repo.projectId === selectedID;
6668
}
@@ -75,12 +77,12 @@ export default function RepositoryFinder(props: RepositoryFinderProps) {
7577
// If we have no matching suggestion, it's a context URL they typed/pasted in, so just use that as the url
7678
props.setSelection(selectedID);
7779
},
78-
[props, suggestedRepos],
80+
[props, normalizedRepos],
7981
);
8082

8183
// Resolve the selected context url & project id props to a suggestion entry
8284
const selectedSuggestion = useMemo(() => {
83-
let match = suggestedRepos?.find((repo) => {
85+
let match = normalizedRepos?.find((repo) => {
8486
if (props.selectedProjectID) {
8587
return repo.projectId === props.selectedProjectID;
8688
}
@@ -105,15 +107,16 @@ export default function RepositoryFinder(props: RepositoryFinderProps) {
105107
}
106108

107109
return match;
108-
}, [props.selectedProjectID, props.selectedContextURL, suggestedRepos]);
110+
}, [normalizedRepos, props.selectedContextURL, props.selectedProjectID]);
109111

110112
const getElements = useCallback(
111113
(searchString: string) => {
112-
const results = filterRepos(searchString, suggestedRepoURLs);
113-
114+
// TODO: remove once includeProjectsOnCreateWorkspace is fully enabled
114115
// With the flag off, we only want to show the suggestedContextURLs
115116
if (!includeProjectsOnCreateWorkspace) {
116-
return results.map(
117+
// w/o the flag on we still need to filter the repo as the search string changes
118+
const filteredResults = filterRepos(searchString, normalizedRepos);
119+
return filteredResults.map(
117120
(repo) =>
118121
({
119122
id: repo.url,
@@ -133,27 +136,29 @@ export default function RepositoryFinder(props: RepositoryFinderProps) {
133136
);
134137
}
135138

136-
// Otherwise we show the suggestedRepos
137-
return results.map((repo) => {
139+
// Otherwise we show the suggestedRepos (already filtered)
140+
return normalizedRepos.map((repo) => {
138141
return {
139142
id: repo.projectId || repo.url,
140143
element: <SuggestedRepositoryOption repo={repo} />,
141144
isSelectable: true,
142145
} as DropDown2Element;
143146
});
144147
},
145-
[includeProjectsOnCreateWorkspace, suggestedRepoURLs],
148+
[includeProjectsOnCreateWorkspace, normalizedRepos],
146149
);
147150

148151
return (
149152
<DropDown2
150153
getElements={getElements}
151154
expanded={!props.selectedContextURL}
155+
// we use this to track the search string so we can search for repos via the api
152156
onSelectionChange={handleSelectionChange}
153157
disabled={props.disabled}
154158
// Only consider the isLoading prop if we're including projects in list
155-
loading={isLoading && includeProjectsOnCreateWorkspace}
159+
loading={(isLoading || isSearching) && includeProjectsOnCreateWorkspace}
156160
searchPlaceholder="Paste repository URL or type to find suggestions"
161+
onSearchChange={setSearchString}
157162
>
158163
<DropDown2SelectedElement
159164
icon={RepositorySVG}
@@ -201,7 +206,6 @@ const SuggestedRepositoryOption: FC<SuggestedRepositoryOptionProps> = ({ repo })
201206
</>
202207
)}
203208

204-
{/* TODO: refine some Text* components a bit to make it easy to set the right colors for dark/light mode */}
205209
<span className="text-sm whitespace-nowrap truncate overflow-ellipsis text-gray-500 dark:text-gray-400">
206210
{stripOffProtocol(repo.url)}
207211
</span>
@@ -238,28 +242,6 @@ function saveSearchData(searchData: string[]): void {
238242
}
239243
}
240244

241-
function filterRepos(searchString: string, suggestedRepos: SuggestedRepository[]) {
242-
let results = suggestedRepos;
243-
const normalizedSearchString = searchString.trim().toLowerCase();
244-
245-
if (normalizedSearchString.length > 1) {
246-
results = suggestedRepos.filter((r) => {
247-
return `${r.url}${r.projectName || ""}`.toLowerCase().includes(normalizedSearchString);
248-
});
249-
250-
if (results.length === 0) {
251-
try {
252-
// If the normalizedSearchString is a URL, and it's not present in the proposed results, "artificially" add it here.
253-
new URL(normalizedSearchString);
254-
results.push({ url: normalizedSearchString });
255-
} catch {}
256-
}
257-
}
258-
259-
// Limit what we show to 200 results
260-
return results.length > 200 ? results.slice(0, 200) : results;
261-
}
262-
263245
function stripOffProtocol(url: string): string {
264246
if (!url.startsWith("http")) {
265247
return url;

components/dashboard/src/data/featureflag-query.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ const featureFlags = {
2828
enabledOrbitalDiscoveries: "",
2929
newProjectIncrementalRepoSearchBBS: false,
3030
includeProjectsOnCreateWorkspace: false,
31+
repositoryFinderSearch: false,
3132
};
3233

3334
type FeatureFlags = typeof featureFlags;
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
/**
2+
* Copyright (c) 2023 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 { useQuery } from "@tanstack/react-query";
8+
import { getGitpodService } from "../../service/service";
9+
import { useCurrentOrg } from "../organizations/orgs-query";
10+
import { useDebounce } from "../../hooks/use-debounce";
11+
import { useFeatureFlag } from "../featureflag-query";
12+
13+
export const useSearchRepositories = ({ searchString }: { searchString: string }) => {
14+
// This disables the search behavior when flag is disabled
15+
const repositoryFinderSearchEnabled = useFeatureFlag("repositoryFinderSearch");
16+
const { data: org } = useCurrentOrg();
17+
const debouncedSearchString = useDebounce(searchString);
18+
19+
return useQuery(
20+
["search-repositories", { organizationId: org?.id || "", searchString: debouncedSearchString }],
21+
async () => {
22+
return await getGitpodService().server.searchRepositories({
23+
searchString,
24+
organizationId: org?.id ?? "",
25+
});
26+
},
27+
{
28+
enabled: repositoryFinderSearchEnabled && !!org && debouncedSearchString.length >= 3,
29+
// Need this to keep previous results while we wait for a new search to complete since debouncedSearchString changes and updates the key
30+
keepPreviousData: true,
31+
// We intentionally don't want to trigger refetches here to avoid a loading state side effect of focusing
32+
refetchOnWindowFocus: false,
33+
refetchOnReconnect: false,
34+
},
35+
);
36+
};
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
/**
2+
* Copyright (c) 2023 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 { SuggestedRepository } from "@gitpod/gitpod-protocol";
8+
import { useSearchRepositories } from "./search-repositories-query";
9+
import { useSuggestedRepositories } from "./suggested-repositories-query";
10+
import { useMemo } from "react";
11+
12+
// Combines the suggested repositories and the search repositories query into one hook
13+
export const useUnifiedRepositorySearch = ({ searchString }: { searchString: string }) => {
14+
const suggestedQuery = useSuggestedRepositories();
15+
const searchQuery = useSearchRepositories({ searchString });
16+
17+
const filteredRepos = useMemo(() => {
18+
const repoMap = new Map<string, SuggestedRepository>((suggestedQuery.data || []).map((r) => [r.url, r]));
19+
20+
// Merge the search results into the suggested results
21+
for (const repo of searchQuery.data || []) {
22+
if (!repoMap.has(repo.url)) {
23+
repoMap.set(repo.url, repo);
24+
}
25+
}
26+
27+
return filterRepos(searchString, Array.from(repoMap.values()));
28+
}, [searchQuery.data, searchString, suggestedQuery.data]);
29+
30+
return {
31+
data: filteredRepos,
32+
isLoading: suggestedQuery.isLoading,
33+
isSearching: searchQuery.isFetching,
34+
isError: suggestedQuery.isError || searchQuery.isError,
35+
error: suggestedQuery.error || searchQuery.error,
36+
};
37+
};
38+
39+
export const filterRepos = (searchString: string, suggestedRepos: SuggestedRepository[]) => {
40+
let results = suggestedRepos;
41+
const normalizedSearchString = searchString.trim().toLowerCase();
42+
43+
if (normalizedSearchString.length > 1) {
44+
results = suggestedRepos.filter((r) => {
45+
return `${r.url}${r.projectName || ""}`.toLowerCase().includes(normalizedSearchString);
46+
});
47+
48+
if (results.length === 0) {
49+
try {
50+
// If the normalizedSearchString is a URL, and it's not present in the proposed results, "artificially" add it here.
51+
new URL(normalizedSearchString);
52+
results.push({ url: normalizedSearchString });
53+
} catch {}
54+
}
55+
}
56+
57+
// Limit what we show to 200 results
58+
return results.length > 200 ? results.slice(0, 200) : results;
59+
};
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
/**
2+
* Copyright (c) 2023 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 { useEffect, useMemo, useState } from "react";
8+
import debounce from "lodash.debounce";
9+
10+
type DebounceOptions = {
11+
leading?: boolean;
12+
trailing?: boolean;
13+
maxWait?: number;
14+
};
15+
16+
export const useDebounce = <T>(value: T, delay = 500, options?: DebounceOptions): T => {
17+
const [debouncedValue, setDebouncedValue] = useState<T>(value);
18+
19+
const debouncedSetValue = useMemo(() => {
20+
return debounce(setDebouncedValue, delay, {
21+
leading: options?.leading || false,
22+
trailing: options?.trailing || true,
23+
maxWait: options?.maxWait ?? undefined,
24+
});
25+
}, [delay, options?.leading, options?.maxWait, options?.trailing]);
26+
27+
useEffect(() => {
28+
debouncedSetValue(value);
29+
}, [value, debouncedSetValue]);
30+
31+
return debouncedValue;
32+
};

0 commit comments

Comments
 (0)