Skip to content

Commit 2cc42e2

Browse files
[dashboard] Onboarding: Add Gitpod repository template to New workspace component (#19991)
* feat: Add Gitpod repo. template to New workspace component * also add name when template repo is selected * Add the `showExamples` option to the `CreateWorkspacePage` component in order to control whether example repositories should be shown. The option is passed down to the `StartWorkspaceOptions` component and used to conditionally render the examples section. * bring back to deep repo search when someone start searching anything on `/new?showExamples=true`
1 parent 132070d commit 2cc42e2

File tree

6 files changed

+168
-47
lines changed

6 files changed

+168
-47
lines changed

components/dashboard/src/components/RepositoryFinder.tsx

Lines changed: 114 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,15 @@ import { FC, useCallback, useEffect, useMemo, useState } from "react";
88
import { Combobox, ComboboxElement, ComboboxSelectedItem } from "./podkit/combobox/Combobox";
99
import RepositorySVG from "../icons/Repository.svg";
1010
import { ReactComponent as RepositoryIcon } from "../icons/RepositoryWithColor.svg";
11+
import { ReactComponent as GitpodRepositoryTemplate } from "../icons/GitpodRepositoryTemplate.svg";
12+
import GitpodRepositoryTemplateSVG from "../icons/GitpodRepositoryTemplate.svg";
1113
import { MiddleDot } from "./typography/MiddleDot";
1214
import { useUnifiedRepositorySearch } from "../data/git-providers/unified-repositories-search-query";
1315
import { useAuthProviderDescriptions } from "../data/auth-providers/auth-provider-descriptions-query";
1416
import { ReactComponent as Exclamation2 } from "../images/exclamation2.svg";
1517
import { AuthProviderType } from "@gitpod/public-api/lib/gitpod/v1/authprovider_pb";
1618
import { SuggestedRepository } from "@gitpod/public-api/lib/gitpod/v1/scm_pb";
19+
import { PREDEFINED_REPOS } from "../data/git-providers/predefined-repos";
1720

1821
interface RepositoryFinderProps {
1922
selectedContextURL?: string;
@@ -22,6 +25,7 @@ interface RepositoryFinderProps {
2225
expanded?: boolean;
2326
excludeConfigurations?: boolean;
2427
onlyConfigurations?: boolean;
28+
showExamples?: boolean;
2529
onChange?: (repo: SuggestedRepository) => void;
2630
}
2731

@@ -32,6 +36,7 @@ export default function RepositoryFinder({
3236
expanded,
3337
excludeConfigurations = false,
3438
onlyConfigurations = false,
39+
showExamples = false,
3540
onChange,
3641
}: RepositoryFinderProps) {
3742
const [searchString, setSearchString] = useState("");
@@ -44,35 +49,75 @@ export default function RepositoryFinder({
4449
searchString,
4550
excludeConfigurations: excludeConfigurations,
4651
onlyConfigurations: onlyConfigurations,
52+
showExamples: showExamples,
4753
});
4854

4955
const authProviders = useAuthProviderDescriptions();
5056

57+
// This approach creates a memoized Map of the predefined repos,
58+
// which can be more efficient for lookups if we would have a large number of predefined repos
59+
const memoizedPredefinedRepos = useMemo(() => {
60+
return new Map(PREDEFINED_REPOS.map((repo) => [repo.url, repo]));
61+
}, []);
62+
5163
const handleSelectionChange = useCallback(
5264
(selectedID: string) => {
53-
// selectedId is either configurationId or repo url
54-
const matchingSuggestion = repos?.find((repo) => {
55-
if (repo.configurationId) {
56-
return repo.configurationId === selectedID;
57-
}
65+
const matchingSuggestion = repos?.find(
66+
(repo) => repo.configurationId === selectedID || repo.url === selectedID,
67+
);
5868

59-
return repo.url === selectedID;
60-
});
6169
if (matchingSuggestion) {
6270
onChange?.(matchingSuggestion);
6371
return;
6472
}
6573

66-
onChange?.(
67-
new SuggestedRepository({
68-
url: selectedID,
69-
}),
70-
);
74+
const matchingPredefinedRepo = memoizedPredefinedRepos.get(selectedID);
75+
if (matchingPredefinedRepo) {
76+
onChange?.(
77+
new SuggestedRepository({
78+
url: matchingPredefinedRepo.url,
79+
repoName: matchingPredefinedRepo.repoName,
80+
}),
81+
);
82+
return;
83+
}
84+
85+
onChange?.(new SuggestedRepository({ url: selectedID }));
7186
},
72-
[onChange, repos],
87+
[onChange, repos, memoizedPredefinedRepos],
7388
);
7489

7590
const [selectedSuggestion, setSelectedSuggestion] = useState<SuggestedRepository | undefined>(undefined);
91+
const [hasStartedSearching, setHasStartedSearching] = useState(false);
92+
const [isShowingExamples, setIsShowingExamples] = useState(showExamples);
93+
94+
type PredefinedRepositoryOptionProps = {
95+
repo: {
96+
url: string;
97+
repoName: string;
98+
description: string;
99+
repoPath: string;
100+
};
101+
};
102+
103+
const PredefinedRepositoryOption: FC<PredefinedRepositoryOptionProps> = ({ repo }) => {
104+
return (
105+
<div className="flex flex-col overflow-hidden" aria-label={`Demo: ${repo.url}`}>
106+
<div className="flex items-center">
107+
<GitpodRepositoryTemplate className="w-5 h-5 text-pk-content-tertiary mr-2" />
108+
<span className="text-sm font-semibold">{repo.repoName}</span>
109+
<MiddleDot className="px-0.5 text-pk-content-tertiary" />
110+
<span
111+
className="text-sm whitespace-nowrap truncate overflow-ellipsis text-pk-content-secondary"
112+
title={repo.repoPath}
113+
>
114+
{repo.repoPath}
115+
</span>
116+
</div>
117+
<span className="text-xs text-pk-content-secondary ml-7">{repo.description}</span>
118+
</div>
119+
);
120+
};
76121

77122
// Resolve the selected context url & configurationId id props to a suggestion entry
78123
useEffect(() => {
@@ -86,9 +131,17 @@ export default function RepositoryFinder({
86131

87132
// If no match, it's a context url that was typed/pasted in, so treat it like a suggestion w/ just a url
88133
if (!match && selectedContextURL) {
89-
match = new SuggestedRepository({
90-
url: selectedContextURL,
91-
});
134+
const predefinedMatch = PREDEFINED_REPOS.find((repo) => repo.url === selectedContextURL);
135+
if (predefinedMatch) {
136+
match = new SuggestedRepository({
137+
url: predefinedMatch.url,
138+
repoName: predefinedMatch.repoName,
139+
});
140+
} else {
141+
match = new SuggestedRepository({
142+
url: selectedContextURL,
143+
});
144+
}
92145
}
93146

94147
// This means we found a matching configuration, but the context url is different
@@ -112,7 +165,7 @@ export default function RepositoryFinder({
112165

113166
// If we put the selectedSuggestion in the dependency array, it will cause an infinite loop
114167
// eslint-disable-next-line react-hooks/exhaustive-deps
115-
}, [repos, selectedContextURL, selectedConfigurationId]);
168+
}, [repos, selectedContextURL, selectedConfigurationId, isShowingExamples]);
116169

117170
const displayName = useMemo(() => {
118171
if (!selectedSuggestion) {
@@ -126,27 +179,44 @@ export default function RepositoryFinder({
126179
return selectedSuggestion?.configurationName;
127180
}, [selectedSuggestion]);
128181

182+
const handleSearchChange = (value: string) => {
183+
setSearchString(value);
184+
if (value.length > 0) {
185+
setIsShowingExamples(false);
186+
if (!hasStartedSearching) {
187+
setHasStartedSearching(true);
188+
}
189+
} else {
190+
setIsShowingExamples(showExamples);
191+
}
192+
};
193+
129194
const getElements = useCallback(
130-
// searchString ignore here as list is already pre-filtered against it
131-
// w/ mirrored state via useUnifiedRepositorySearch
132-
(searchString: string) => {
133-
const result = repos.map((repo) => {
134-
return {
135-
id: repo.configurationId || repo.url,
136-
element: <SuggestedRepositoryOption repo={repo} />,
195+
(searchString: string): ComboboxElement[] => {
196+
if (isShowingExamples && searchString.length === 0) {
197+
return PREDEFINED_REPOS.map((repo) => ({
198+
id: repo.url,
199+
element: <PredefinedRepositoryOption repo={repo} />,
137200
isSelectable: true,
138-
} as ComboboxElement;
139-
});
201+
}));
202+
}
203+
204+
const result = repos.map((repo) => ({
205+
id: repo.configurationId || repo.url,
206+
element: <SuggestedRepositoryOption repo={repo} />,
207+
isSelectable: true,
208+
}));
209+
140210
if (hasMore) {
141-
// add an element that tells the user to refine the search
142211
result.push({
143212
id: "more",
144213
element: (
145214
<div className="text-sm text-pk-content-tertiary">Repo missing? Try refining your search.</div>
146215
),
147216
isSelectable: false,
148-
} as ComboboxElement);
217+
});
149218
}
219+
150220
if (
151221
searchString.length >= 3 &&
152222
authProviders.data?.some((p) => p.type === AuthProviderType.BITBUCKET_SERVER) &&
@@ -156,16 +226,15 @@ export default function RepositoryFinder({
156226
result.push({
157227
id: "bitbucket-server",
158228
element: (
159-
<div className="text-sm text-pk-content-tertiary">
160-
<div className="flex items-center">
161-
<Exclamation2 className="w-4 h-4"></Exclamation2>
162-
<span className="ml-2">Bitbucket Server only supports searching by prefix.</span>
163-
</div>
229+
<div className="text-sm text-pk-content-tertiary flex items-center">
230+
<Exclamation2 className="w-4 h-4 mr-2" />
231+
<span>Bitbucket Server only supports searching by prefix.</span>
164232
</div>
165233
),
166234
isSelectable: false,
167-
} as ComboboxElement);
235+
});
168236
}
237+
169238
if (searchString.length < 3) {
170239
// add an element that tells the user to type more
171240
result.push({
@@ -176,13 +245,19 @@ export default function RepositoryFinder({
176245
</div>
177246
),
178247
isSelectable: false,
179-
} as ComboboxElement);
248+
});
180249
}
250+
181251
return result;
182252
},
183-
[repos, hasMore, authProviders.data, onlyConfigurations],
253+
[repos, hasMore, authProviders.data, onlyConfigurations, isShowingExamples],
184254
);
185255

256+
const resolveIcon = useCallback((contextUrl?: string) => {
257+
if (!contextUrl) return RepositorySVG;
258+
return PREDEFINED_REPOS.some((repo) => repo.url === contextUrl) ? GitpodRepositoryTemplateSVG : RepositorySVG;
259+
}, []);
260+
186261
return (
187262
<Combobox
188263
getElements={getElements}
@@ -192,11 +267,11 @@ export default function RepositoryFinder({
192267
disabled={disabled}
193268
// Only consider the isLoading prop if we're including projects in list
194269
loading={isLoading || isSearching}
195-
searchPlaceholder="Paste repository URL or type to find suggestions"
196-
onSearchChange={setSearchString}
270+
searchPlaceholder="Search repos and demos or paste a repo URL"
271+
onSearchChange={handleSearchChange}
197272
>
198273
<ComboboxSelectedItem
199-
icon={RepositorySVG}
274+
icon={resolveIcon(selectedContextURL)}
200275
htmlTitle={displayContextUrl(selectedContextURL) || "Repository"}
201276
title={<div className="truncate">{displayName || "Select a repository"}</div>}
202277
subtitle={
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
/**
2+
* Copyright (c) 2024 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+
export const PREDEFINED_REPOS = [
8+
{
9+
url: "https://github.com/gitpod-demos/voting-app",
10+
repoName: "demo-docker",
11+
description: "A fully configured demo with Docker Compose, Redis and Postgres",
12+
repoPath: "github.com/gitpod-demos/voting-app",
13+
},
14+
{
15+
url: "https://github.com/gitpod-demos/spring-petclinic",
16+
repoName: "demo-java",
17+
description: "A fully configured demo with Java, Maven and Spring Boot",
18+
repoPath: "github.com/gitpod-demos/spring-petclinic",
19+
},
20+
];

components/dashboard/src/data/git-providers/unified-repositories-search-query.ts

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import { SuggestedRepository } from "@gitpod/public-api/lib/gitpod/v1/scm_pb";
88
import { useSearchRepositories } from "./search-repositories-query";
99
import { useSuggestedRepositories } from "./suggested-repositories-query";
10+
import { PREDEFINED_REPOS } from "./predefined-repos";
1011
import { useMemo } from "react";
1112

1213
type UnifiedRepositorySearchArgs = {
@@ -15,27 +16,34 @@ type UnifiedRepositorySearchArgs = {
1516
excludeConfigurations?: boolean;
1617
// If true, only shows entries with a corresponding configuration
1718
onlyConfigurations?: boolean;
19+
// If true, only shows example repositories
20+
showExamples?: boolean;
1821
};
1922
// Combines the suggested repositories and the search repositories query into one hook
2023
export const useUnifiedRepositorySearch = ({
2124
searchString,
2225
excludeConfigurations = false,
2326
onlyConfigurations = false,
27+
showExamples = false,
2428
}: UnifiedRepositorySearchArgs) => {
2529
const suggestedQuery = useSuggestedRepositories({ excludeConfigurations });
2630
const searchLimit = 30;
2731
const searchQuery = useSearchRepositories({ searchString, limit: searchLimit });
2832

2933
const filteredRepos = useMemo(() => {
30-
const flattenedRepos = [suggestedQuery.data || [], searchQuery.data || []].flat();
34+
if (showExamples && searchString.length === 0) {
35+
return PREDEFINED_REPOS.map(
36+
(repo) =>
37+
new SuggestedRepository({
38+
url: repo.url,
39+
repoName: repo.repoName,
40+
}),
41+
);
42+
}
3143

32-
return deduplicateAndFilterRepositories(
33-
searchString,
34-
excludeConfigurations,
35-
onlyConfigurations,
36-
flattenedRepos,
37-
);
38-
}, [excludeConfigurations, onlyConfigurations, searchQuery.data, searchString, suggestedQuery.data]);
44+
const repos = [suggestedQuery.data || [], searchQuery.data || []].flat();
45+
return deduplicateAndFilterRepositories(searchString, excludeConfigurations, onlyConfigurations, repos);
46+
}, [excludeConfigurations, onlyConfigurations, showExamples, searchQuery.data, searchString, suggestedQuery.data]);
3947

4048
return {
4149
data: filteredRepos,
Lines changed: 4 additions & 0 deletions
Loading

components/dashboard/src/start/start-workspace-options.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ export interface StartWorkspaceOptions {
1010
workspaceClass?: string;
1111
ideSettings?: IDESettings;
1212
autostart?: boolean;
13+
showExamples?: boolean;
1314
}
1415
export namespace StartWorkspaceOptions {
1516
// The workspace class to use for the workspace. If not specified, the default workspace class is used.
@@ -21,6 +22,9 @@ export namespace StartWorkspaceOptions {
2122
// whether the workspace should automatically start
2223
export const AUTOSTART = "autostart";
2324

25+
// whether to show example repositories
26+
export const SHOW_EXAMPLES = "showExamples";
27+
2428
export function parseSearchParams(search: string): StartWorkspaceOptions {
2529
const params = new URLSearchParams(search);
2630
const options: StartWorkspaceOptions = {};
@@ -45,6 +49,11 @@ export namespace StartWorkspaceOptions {
4549
if (params.get(StartWorkspaceOptions.AUTOSTART)) {
4650
options.autostart = params.get(StartWorkspaceOptions.AUTOSTART) === "true";
4751
}
52+
53+
if (params.get(StartWorkspaceOptions.SHOW_EXAMPLES)) {
54+
options.showExamples = params.get(StartWorkspaceOptions.SHOW_EXAMPLES) === "true";
55+
}
56+
4857
return options;
4958
}
5059

@@ -61,6 +70,9 @@ export namespace StartWorkspaceOptions {
6170
if (options.autostart) {
6271
params.set(StartWorkspaceOptions.AUTOSTART, "true");
6372
}
73+
if (options.showExamples) {
74+
params.set(StartWorkspaceOptions.SHOW_EXAMPLES, "true");
75+
}
6476
return params.toString();
6577
}
6678

components/dashboard/src/workspaces/CreateWorkspacePage.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ export function CreateWorkspacePage() {
102102
isLoading: isLoadingWorkspaceClasses,
103103
} = useAllowedWorkspaceClassesMemo(selectedProjectID);
104104
const defaultWorkspaceClass = props.workspaceClass ?? computedDefaultClass;
105+
const showExamples = props.showExamples ?? false;
105106
const { data: orgSettings } = useOrgSettingsQuery();
106107
const [selectedWsClass, setSelectedWsClass, selectedWsClassIsDirty] = useDirtyState(defaultWorkspaceClass);
107108
const [errorWsClass, setErrorWsClass] = useState<ReactNode | undefined>(undefined);
@@ -523,6 +524,7 @@ export function CreateWorkspacePage() {
523524
selectedConfigurationId={selectedProjectID}
524525
expanded={!contextURL}
525526
disabled={createWorkspaceMutation.isStarting}
527+
showExamples={showExamples}
526528
/>
527529
</InputField>
528530

0 commit comments

Comments
 (0)