Skip to content

Commit 67c2c32

Browse files
committed
integrate searchRepositories in RepositoryFinder
1 parent 320ed82 commit 67c2c32

File tree

8 files changed

+206
-37
lines changed

8 files changed

+206
-37
lines changed

components/dashboard/src/components/DropDown2.tsx

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ export interface DropDown2Props {
3232
disableSearch?: boolean;
3333
expanded?: boolean;
3434
onSelectionChange: (id: string) => void;
35+
// Meant to allow consumers to react to search changes even though state is managed internally
36+
onSearchChange?: (searchString: string) => void;
3537
allOptions?: string;
3638
}
3739

@@ -45,6 +47,7 @@ export const DropDown2: FunctionComponent<DropDown2Props> = ({
4547
disableSearch,
4648
children,
4749
onSelectionChange,
50+
onSearchChange,
4851
}) => {
4952
const [showDropDown, setShowDropDown] = useState<boolean>(!disabled && !!expanded);
5053
const nodeRef: RefObject<HTMLDivElement> = useRef(null);
@@ -61,7 +64,7 @@ export const DropDown2: FunctionComponent<DropDown2Props> = ({
6164

6265
// reset search when the drop down is expanded or closed
6366
useEffect(() => {
64-
setSearch("");
67+
updateSearch("");
6568
if (allOptions) {
6669
setSelectedElementTemp(allOptions);
6770
}
@@ -72,6 +75,16 @@ export const DropDown2: FunctionComponent<DropDown2Props> = ({
7275
// eslint-disable-next-line react-hooks/exhaustive-deps
7376
}, [showDropDown]);
7477

78+
const updateSearch = useCallback(
79+
(value: string) => {
80+
setSearch(value);
81+
if (onSearchChange) {
82+
onSearchChange(value);
83+
}
84+
},
85+
[onSearchChange],
86+
);
87+
7588
const toggleDropDown = useCallback(() => {
7689
if (disabled) {
7790
return;
@@ -198,7 +211,7 @@ export const DropDown2: FunctionComponent<DropDown2Props> = ({
198211
className={"w-full focus rounded-lg"}
199212
placeholder={searchPlaceholder}
200213
value={search}
201-
onChange={(e) => setSearch(e.target.value)}
214+
onChange={(e) => updateSearch(e.target.value)}
202215
/>
203216
</div>
204217
)}

components/dashboard/src/components/RepositoryFinder.tsx

Lines changed: 39 additions & 31 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 { 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,9 @@ export default function RepositoryFinder(props: RepositoryFinderProps) {
4446
);
4547
}
4648

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

5053
// TODO: remove this once includeProjectsOnCreateWorkspace is fully enabled
5154
useEffect(() => {
@@ -60,7 +63,7 @@ export default function RepositoryFinder(props: RepositoryFinderProps) {
6063
const handleSelectionChange = useCallback(
6164
(selectedID: string) => {
6265
// selectedId is either projectId or repo url
63-
const matchingSuggestion = suggestedRepos?.find((repo) => {
66+
const matchingSuggestion = normalizedRepos?.find((repo) => {
6467
if (repo.projectId) {
6568
return repo.projectId === selectedID;
6669
}
@@ -75,12 +78,12 @@ export default function RepositoryFinder(props: RepositoryFinderProps) {
7578
// If we have no matching suggestion, it's a context URL they typed/pasted in, so just use that as the url
7679
props.setSelection(selectedID);
7780
},
78-
[props, suggestedRepos],
81+
[props, normalizedRepos],
7982
);
8083

8184
// Resolve the selected context url & project id props to a suggestion entry
8285
const selectedSuggestion = useMemo(() => {
83-
let match = suggestedRepos?.find((repo) => {
86+
let match = normalizedRepos?.find((repo) => {
8487
if (props.selectedProjectID) {
8588
return repo.projectId === props.selectedProjectID;
8689
}
@@ -105,65 +108,69 @@ export default function RepositoryFinder(props: RepositoryFinderProps) {
105108
}
106109

107110
return match;
108-
}, [props.selectedProjectID, props.selectedContextURL, suggestedRepos]);
111+
}, [normalizedRepos, props.selectedContextURL, props.selectedProjectID]);
109112

110113
const getElements = useCallback(
111114
(searchString: string) => {
112-
const results = filterRepos(searchString, suggestedRepoURLs);
113-
115+
// TODO: remove once includeProjectsOnCreateWorkspace is fully enabled
114116
// With the flag off, we only want to show the suggestedContextURLs
115117
if (!includeProjectsOnCreateWorkspace) {
116-
return results.map(
118+
// w/o the flag on we still need to filter the repo as the search string changes
119+
const filteredResults = filterRepos(searchString, normalizedRepos);
120+
return filteredResults.map(
117121
(repo) =>
118-
({
119-
id: repo.url,
120-
element: (
121-
<div className="flex-col ml-1 mt-1 flex-grow">
122-
<div className="flex">
123-
<div className="text-gray-700 dark:text-gray-300 font-semibold">
124-
{stripOffProtocol(repo.url)}
125-
</div>
126-
<div className="ml-1 text-gray-400">{}</div>
122+
({
123+
id: repo.url,
124+
element: (
125+
<div className="flex-col ml-1 mt-1 flex-grow">
126+
<div className="flex">
127+
<div className="text-gray-700 dark:text-gray-300 font-semibold">
128+
{stripOffProtocol(repo.url)}
127129
</div>
128-
<div className="flex text-xs text-gray-400">{}</div>
130+
<div className="ml-1 text-gray-400">{ }</div>
129131
</div>
130-
),
131-
isSelectable: true,
132-
} as DropDown2Element),
132+
<div className="flex text-xs text-gray-400">{ }</div>
133+
</div>
134+
),
135+
isSelectable: true,
136+
} as DropDown2Element),
133137
);
134138
}
135139

136-
// Otherwise we show the suggestedRepos
137-
return results.map((repo) => {
140+
// Otherwise we show the suggestedRepos (already filtered)
141+
return normalizedRepos.map((repo) => {
138142
return {
139143
id: repo.projectId || repo.url,
140144
element: <SuggestedRepositoryOption repo={repo} />,
141145
isSelectable: true,
142146
} as DropDown2Element;
143147
});
144148
},
145-
[includeProjectsOnCreateWorkspace, suggestedRepoURLs],
149+
[includeProjectsOnCreateWorkspace, normalizedRepos],
146150
);
147151

148152
return (
149153
<DropDown2
150154
getElements={getElements}
151155
expanded={!props.selectedContextURL}
156+
// we use this to track the search string so we can search for repos via the api
152157
onSelectionChange={handleSelectionChange}
153158
disabled={props.disabled}
154159
// Only consider the isLoading prop if we're including projects in list
155160
loading={isLoading && includeProjectsOnCreateWorkspace}
156161
searchPlaceholder="Paste repository URL or type to find suggestions"
162+
onSearchChange={setSearchString}
157163
>
164+
{/* TODO: add a subtle indicator for the isSearching state */}
158165
<DropDown2SelectedElement
159166
icon={RepositorySVG}
160167
htmlTitle={displayContextUrl(props.selectedContextURL) || "Repository"}
161168
title={
162169
<div className="truncate w-80">
163170
{displayContextUrl(
164171
selectedSuggestion?.projectName ||
165-
selectedSuggestion?.repositoryName ||
166-
selectedSuggestion?.url,
172+
selectedSuggestion?.repositoryName ||
173+
selectedSuggestion?.url,
167174
) || "Select a repository"}
168175
</div>
169176
}
@@ -238,6 +245,7 @@ function saveSearchData(searchData: string[]): void {
238245
}
239246
}
240247

248+
// TODO: remove this and import from unified-repositories-search-query
241249
function filterRepos(searchString: string, suggestedRepos: SuggestedRepository[]) {
242250
let results = suggestedRepos;
243251
const normalizedSearchString = searchString.trim().toLowerCase();
@@ -252,7 +260,7 @@ function filterRepos(searchString: string, suggestedRepos: SuggestedRepository[]
252260
// If the normalizedSearchString is a URL, and it's not present in the proposed results, "artificially" add it here.
253261
new URL(normalizedSearchString);
254262
results.push({ url: normalizedSearchString });
255-
} catch {}
263+
} catch { }
256264
}
257265
}
258266

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
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+
12+
export const useSearchRepositories = ({ searchString }: { searchString: string }) => {
13+
const { data: org } = useCurrentOrg();
14+
const debouncedSearchString = useDebounce(searchString);
15+
16+
return useQuery(
17+
["searchRepositories", { organizationId: org?.id || "", searchString: debouncedSearchString }],
18+
async () => {
19+
const result = await getGitpodService().server.searchRepositories({
20+
searchString,
21+
organizationId: org?.id ?? "",
22+
});
23+
return result;
24+
},
25+
{
26+
enabled: searchString.length >= 3 && !!org,
27+
// Need this to keep previous results while we wait for a new search to complete since debouncedSearchString changes and updates the key
28+
keepPreviousData: true,
29+
},
30+
);
31+
};
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
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+
export const useUnifiedRepositorySearch = ({ searchString }: { searchString: string }) => {
13+
const suggestedQuery = useSuggestedRepositories();
14+
const searchQuery = useSearchRepositories({ searchString });
15+
16+
const filteredRepos = useMemo(() => {
17+
const repoMap = new Map<string, SuggestedRepository>((suggestedQuery.data || []).map((r) => [r.url, r]));
18+
19+
// Merge the search results into the suggested results
20+
for (const repo of searchQuery.data || []) {
21+
if (!repoMap.has(repo.url)) {
22+
repoMap.set(repo.url, repo);
23+
}
24+
}
25+
26+
return filterRepos(searchString, Array.from(repoMap.values()));
27+
}, [searchQuery.data, searchString, suggestedQuery.data]);
28+
29+
return {
30+
data: filteredRepos,
31+
isLoading: suggestedQuery.isLoading,
32+
isSearching: searchQuery.isFetching,
33+
isError: suggestedQuery.isError || searchQuery.isError,
34+
error: suggestedQuery.error || searchQuery.error,
35+
};
36+
};
37+
38+
const filterRepos = (searchString: string, suggestedRepos: SuggestedRepository[]) => {
39+
let results = suggestedRepos;
40+
const normalizedSearchString = searchString.trim().toLowerCase();
41+
42+
if (normalizedSearchString.length > 1) {
43+
results = suggestedRepos.filter((r) => {
44+
return `${r.url}${r.projectName || ""}`.toLowerCase().includes(normalizedSearchString);
45+
});
46+
47+
if (results.length === 0) {
48+
try {
49+
// If the normalizedSearchString is a URL, and it's not present in the proposed results, "artificially" add it here.
50+
new URL(normalizedSearchString);
51+
results.push({ url: normalizedSearchString });
52+
} catch {}
53+
}
54+
}
55+
56+
// Limit what we show to 200 results
57+
return results.length > 200 ? results.slice(0, 200) : results;
58+
};
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
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+
export const useDebounce = <T>(value: T, delay = 500): T => {
11+
const [debouncedValue, setDebouncedValue] = useState<T>(value);
12+
13+
const debouncedSetValue = useMemo(() => {
14+
return debounce(setDebouncedValue, delay);
15+
}, [delay]);
16+
17+
useEffect(() => {
18+
debouncedSetValue(value);
19+
}, [value, debouncedSetValue]);
20+
21+
return debouncedValue;
22+
};

components/server/src/bitbucket/bitbucket-repository-provider.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,16 @@ export class BitbucketRepositoryProvider implements RepositoryProvider {
159159

160160
// TODO: implement repo search
161161
public async searchRepos(user: User, searchString: string): Promise<RepositoryInfo[]> {
162+
// const api = await this.apiFactory.create(user);
163+
164+
// const workspaces = await api.workspaces.getWorkspaces({ pagelen: 5, sort: "-updated_on" });
165+
166+
// const workspaceSlugs = workspaces.data.values?.map(workspace => {
167+
// return workspace.slug
168+
// })
169+
170+
// const response = await api.repositories.list({ q: searchString, pagelen: 10, sort: "-updated_on", workspace: });
171+
162172
return [];
163173
}
164174
}

0 commit comments

Comments
 (0)