diff --git a/components/dashboard/package.json b/components/dashboard/package.json index eb1074a84b2d44..4c21c5ad9e6658 100644 --- a/components/dashboard/package.json +++ b/components/dashboard/package.json @@ -41,7 +41,7 @@ "js-cookie": "^3.0.1", "lite-youtube-embed": "^0.3.2", "lodash": "^4.17.21", - "lucide-react": "^0.287.0", + "lucide-react": "^0.474.0", "pretty-bytes": "^6.1.0", "process": "^0.11.10", "query-string": "^7.1.1", diff --git a/components/dashboard/src/components/RepositoryFinder.tsx b/components/dashboard/src/components/RepositoryFinder.tsx index bac47e1af3ad38..856a1b0157bb06 100644 --- a/components/dashboard/src/components/RepositoryFinder.tsx +++ b/components/dashboard/src/components/RepositoryFinder.tsx @@ -8,7 +8,6 @@ import { FC, useCallback, useEffect, useMemo, useState } from "react"; import { Combobox, ComboboxElement, ComboboxSelectedItem } from "./podkit/combobox/Combobox"; import RepositorySVG from "../icons/Repository.svg"; import { ReactComponent as RepositoryIcon } from "../icons/RepositoryWithColor.svg"; -import { ReactComponent as GitpodRepositoryTemplate } from "../icons/GitpodRepositoryTemplate.svg"; import GitpodRepositoryTemplateSVG from "../icons/GitpodRepositoryTemplate.svg"; import { MiddleDot } from "./typography/MiddleDot"; import { @@ -25,9 +24,15 @@ import { useConfiguration, useListConfigurations } from "../data/configurations/ import { useUserLoader } from "../hooks/use-user-loader"; import { conjunctScmProviders, getDeduplicatedScmProviders } from "../utils"; import { cn } from "@podkit/lib/cn"; +import { useOrgSuggestedRepos } from "../data/organizations/suggested-repositories-query"; +import { toRemoteURL } from "../projects/render-utils"; -const isPredefined = (repo: SuggestedRepository): boolean => { - return PREDEFINED_REPOS.some((predefined) => predefined.url === repo.url) && !repo.configurationId; +type PredefinedRepoOption = typeof PREDEFINED_REPOS[number]; +const isPredefined = (repo: SuggestedRepository | PredefinedRepoOption): boolean => { + return ( + PREDEFINED_REPOS.some((predefined) => predefined.url === repo.url) && + !(repo as SuggestedRepository).configurationId + ); }; const resolveIcon = (contextUrl?: string): string => { @@ -35,7 +40,32 @@ const resolveIcon = (contextUrl?: string): string => { return PREDEFINED_REPOS.some((repo) => repo.url === contextUrl) ? GitpodRepositoryTemplateSVG : RepositorySVG; }; -interface RepositoryFinderProps { +type PredefinedRepositoryOptionProps = { + repo: PredefinedRepoOption; +}; +const PredefinedRepositoryOption: FC = ({ repo }) => { + const prettyUrl = toRemoteURL(repo.url); + const icon = resolveIcon(repo.url); + + return ( +
+
+ + {repo.repoName} + + + {prettyUrl} + +
+ {repo.description} +
+ ); +}; + +type RepositoryFinderProps = { selectedContextURL?: string; selectedConfigurationId?: string; disabled?: boolean; @@ -44,8 +74,7 @@ interface RepositoryFinderProps { onlyConfigurations?: boolean; showExamples?: boolean; onChange?: (repo: SuggestedRepository) => void; -} - +}; export default function RepositoryFinder({ selectedContextURL, selectedConfigurationId, @@ -70,6 +99,8 @@ export default function RepositoryFinder({ onlyConfigurations, }); + const { data: orgSuggestedRepos } = useOrgSuggestedRepos(); + // We search for the current context URL in order to have data for the selected suggestion const selectedItemSearch = useListConfigurations({ sortBy: "name", @@ -162,29 +193,6 @@ export default function RepositoryFinder({ const [hasStartedSearching, setHasStartedSearching] = useState(false); const [isShowingExamples, setIsShowingExamples] = useState(showExamples); - type PredefinedRepositoryOptionProps = { - repo: typeof PREDEFINED_REPOS[number]; - }; - - const PredefinedRepositoryOption: FC = ({ repo }) => { - return ( -
-
- - {repo.repoName} - - - {repo.repoPath} - -
- {repo.description} -
- ); - }; - // Resolve the selected context url & configurationId id props to a suggestion entry useEffect(() => { let match = repos?.find((repo) => { @@ -267,13 +275,21 @@ export default function RepositoryFinder({ }; const filteredPredefinedRepos = useMemo(() => { + if (orgSuggestedRepos?.length) { + return orgSuggestedRepos.map((repo) => ({ + url: repo.url, + repoName: repo.repoName, + description: "", + })); + } + return PREDEFINED_REPOS.filter((repo) => { const url = new URL(repo.url); const isMatchingAuthProviderAvailable = authProviders.data?.some((provider) => provider.host === url.host) ?? false; return isMatchingAuthProviderAvailable; }); - }, [authProviders.data]); + }, [authProviders.data, orgSuggestedRepos]); const getElements = useCallback( (searchString: string): ComboboxElement[] => { diff --git a/components/dashboard/src/components/podkit/dropdown/DropDown.tsx b/components/dashboard/src/components/podkit/dropdown/DropDown.tsx index a852784d9f29f8..8af6546bebf8e8 100644 --- a/components/dashboard/src/components/podkit/dropdown/DropDown.tsx +++ b/components/dashboard/src/components/podkit/dropdown/DropDown.tsx @@ -97,6 +97,29 @@ const DropdownMenuItem = React.forwardRef< )); DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName; +const DropdownLinkMenuItem = React.forwardRef< + HTMLAnchorElement, + React.AnchorHTMLAttributes & { + inset?: boolean; + } +>(({ className, inset, ...props }, ref) => ( + + + {props.children} + + +)); +DropdownLinkMenuItem.displayName = "DropdownLinkMenuItem"; + const DropdownMenuCheckboxItem = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef @@ -189,4 +212,5 @@ export { DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuRadioGroup, + DropdownLinkMenuItem, }; diff --git a/components/dashboard/src/data/git-providers/predefined-repos.ts b/components/dashboard/src/data/git-providers/predefined-repos.ts index 7ffc8930cce43c..1b431d8e5b2a91 100644 --- a/components/dashboard/src/data/git-providers/predefined-repos.ts +++ b/components/dashboard/src/data/git-providers/predefined-repos.ts @@ -9,12 +9,10 @@ export const PREDEFINED_REPOS = [ url: "https://github.com/gitpod-demos/voting-app", repoName: "demo-docker", description: "A fully configured demo with Docker Compose, Redis and Postgres", - repoPath: "github.com/gitpod-demos/voting-app", }, { url: "https://github.com/gitpod-demos/spring-petclinic", repoName: "demo-java", description: "A fully configured demo with Java, Maven and Spring Boot", - repoPath: "github.com/gitpod-demos/spring-petclinic", }, -] as const; +]; diff --git a/components/dashboard/src/data/organizations/suggested-repositories-query.ts b/components/dashboard/src/data/organizations/suggested-repositories-query.ts new file mode 100644 index 00000000000000..c7117f588890b0 --- /dev/null +++ b/components/dashboard/src/data/organizations/suggested-repositories-query.ts @@ -0,0 +1,71 @@ +/** + * Copyright (c) 2025 Gitpod GmbH. All rights reserved. + * Licensed under the GNU Affero General Public License (AGPL). + * See License.AGPL.txt in the project root for license information. + */ + +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useCallback } from "react"; +import { configurationClient, organizationClient } from "../../service/public-api"; +import { useCurrentOrg } from "./orgs-query"; +import { SuggestedRepository } from "@gitpod/public-api/lib/gitpod/v1/scm_pb"; +import { PlainMessage } from "@bufbuild/protobuf"; +import { Configuration } from "@gitpod/public-api/lib/gitpod/v1/configuration_pb"; + +export function useOrgRepoSuggestionsInvalidator() { + const organizationId = useCurrentOrg().data?.id; + const queryClient = useQueryClient(); + return useCallback(() => { + queryClient.invalidateQueries(getQueryKey(organizationId)); + }, [organizationId, queryClient]); +} + +export type SuggestedOrgRepository = PlainMessage & { + orgSuggested: true; + configuration: Configuration; +}; + +export function useOrgSuggestedRepos() { + const organizationId = useCurrentOrg().data?.id; + const query = useQuery( + getQueryKey(organizationId), + async () => { + const response = await organizationClient.getOrganizationSettings({ + organizationId, + }); + const repos = response.settings?.onboardingSettings?.recommendedRepositories ?? []; + + const suggestions: SuggestedOrgRepository[] = []; + for (const configurationId of repos) { + const { configuration } = await configurationClient.getConfiguration({ + configurationId: configurationId, + }); + if (!configuration) { + continue; + } + const suggestion: SuggestedOrgRepository = { + configurationId: configurationId, + configurationName: configuration.name ?? "", + repoName: configuration.name ?? "", + url: configuration.cloneUrl ?? "", + orgSuggested: true, + configuration, + }; + + suggestions.push(suggestion); + } + + return suggestions; + }, + { + enabled: !!organizationId, + cacheTime: 1000 * 60 * 60 * 24 * 7, // 1 week + staleTime: 1000 * 60 * 5, // 5 minutes + }, + ); + return query; +} + +export function getQueryKey(organizationId: string | undefined) { + return ["org-suggested-repositories", organizationId ?? "undefined"]; +} diff --git a/components/dashboard/src/data/organizations/update-org-settings-mutation.ts b/components/dashboard/src/data/organizations/update-org-settings-mutation.ts index 475bfde4100919..6538555a0ab1d3 100644 --- a/components/dashboard/src/data/organizations/update-org-settings-mutation.ts +++ b/components/dashboard/src/data/organizations/update-org-settings-mutation.ts @@ -12,6 +12,7 @@ import { OrganizationSettings } from "@gitpod/public-api/lib/gitpod/v1/organizat import { ErrorCode } from "@gitpod/gitpod-protocol/lib/messaging/error"; import { useOrgWorkspaceClassesQueryInvalidator } from "./org-workspace-classes-query"; import { PlainMessage } from "@bufbuild/protobuf"; +import { useOrgRepoSuggestionsInvalidator } from "./suggested-repositories-query"; type UpdateOrganizationSettingsArgs = Partial< Pick< @@ -34,7 +35,8 @@ export const useUpdateOrgSettingsMutation = () => { const org = useCurrentOrg().data; const invalidateOrgSettings = useOrgSettingsQueryInvalidator(); const invalidateWorkspaceClasses = useOrgWorkspaceClassesQueryInvalidator(); - const teamId = org?.id ?? ""; + const invalidateOrgRepoSuggestions = useOrgRepoSuggestionsInvalidator(); + const organizationId = org?.id ?? ""; return useMutation({ mutationFn: async ({ @@ -51,7 +53,7 @@ export const useUpdateOrgSettingsMutation = () => { annotateGitCommits, }) => { const settings = await organizationClient.updateOrganizationSettings({ - organizationId: teamId, + organizationId, workspaceSharingDisabled: workspaceSharingDisabled ?? false, defaultWorkspaceImage, allowedWorkspaceClasses, @@ -72,6 +74,7 @@ export const useUpdateOrgSettingsMutation = () => { onSuccess: () => { invalidateOrgSettings(); invalidateWorkspaceClasses(); + invalidateOrgRepoSuggestions(); }, onError: (err) => { if (!ErrorCode.isUserError((err as any)?.["code"])) { diff --git a/components/dashboard/src/repositories/list/RepoListItem.tsx b/components/dashboard/src/repositories/list/RepoListItem.tsx index c36f79e6c3f2e1..a213354f246359 100644 --- a/components/dashboard/src/repositories/list/RepoListItem.tsx +++ b/components/dashboard/src/repositories/list/RepoListItem.tsx @@ -10,13 +10,24 @@ import { TextMuted } from "@podkit/typography/TextMuted"; import { Text } from "@podkit/typography/Text"; import { LinkButton } from "@podkit/buttons/LinkButton"; import type { Configuration } from "@gitpod/public-api/lib/gitpod/v1/configuration_pb"; -import { AlertTriangleIcon, CheckCircle2Icon } from "lucide-react"; +import { AlertTriangleIcon, CheckCircle2Icon, SquareArrowOutUpRight, Ellipsis } from "lucide-react"; import { TableCell, TableRow } from "@podkit/tables/Table"; +import { Button } from "@podkit/buttons/Button"; +import { + DropdownLinkMenuItem, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@podkit/dropdown/DropDown"; +import PillLabel from "../../components/PillLabel"; type Props = { configuration: Configuration; + isSuggested: boolean; + handleModifySuggestedRepository?: (configurationId: string, suggested: boolean) => void; }; -export const RepositoryListItem: FC = ({ configuration }) => { +export const RepositoryListItem: FC = ({ configuration, isSuggested, handleModifySuggestedRepository }) => { const url = usePrettyRepoURL(configuration.cloneUrl); const prebuildsEnabled = !!configuration.prebuildSettings?.enabled; const created = @@ -27,8 +38,18 @@ export const RepositoryListItem: FC = ({ configuration }) => { return ( -
- {configuration.name} +
+ + {configuration.name} + {isSuggested && ( + + Suggested + + )} + {/* We show the url on a 2nd line for smaller screens since we hide the column */} {url}
@@ -52,10 +73,45 @@ export const RepositoryListItem: FC = ({ configuration }) => {
- + View + {handleModifySuggestedRepository && ( + + + + + + {isSuggested ? ( + handleModifySuggestedRepository(configuration.id, false)} + > + Remove from suggested repos + + ) : ( + <> + handleModifySuggestedRepository(configuration.id, true)} + > + Add to suggested repos + + + Learn about suggestions + + + + )} + + + )}
); diff --git a/components/dashboard/src/repositories/list/RepositoryTable.tsx b/components/dashboard/src/repositories/list/RepositoryTable.tsx index b865a07b7405e0..18dad407a9f25f 100644 --- a/components/dashboard/src/repositories/list/RepositoryTable.tsx +++ b/components/dashboard/src/repositories/list/RepositoryTable.tsx @@ -17,6 +17,9 @@ import { SortableTableHead, TableSortOrder } from "@podkit/tables/SortableTable" import { LoadingState } from "@podkit/loading/LoadingState"; import { Button } from "@podkit/buttons/Button"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@podkit/select/Select"; +import { useUpdateOrgSettingsMutation } from "../../data/organizations/update-org-settings-mutation"; +import { useOrgSettingsQuery } from "../../data/organizations/org-settings-query"; +import { useToast } from "../../components/toasts/Toasts"; type Props = { configurations: Configuration[]; @@ -51,6 +54,32 @@ export const RepositoryTable: FC = ({ onLoadNextPage, onSort, }) => { + const updateTeamSettings = useUpdateOrgSettingsMutation(); + const { data: settings } = useOrgSettingsQuery(); + const { toast } = useToast(); + + const updateRecommendedRepository = async (configurationId: string, suggested: boolean) => { + const newRepositories = new Set(settings?.onboardingSettings?.recommendedRepositories ?? []); + if (suggested) { + newRepositories.add(configurationId); + } else { + newRepositories.delete(configurationId); + } + + await updateTeamSettings.mutateAsync( + { + onboardingSettings: { + recommendedRepositories: [...newRepositories], + }, + }, + { + onError: (error) => { + toast(`Failed to update recommended repositories: ${error.message}`); + }, + }, + ); + }; + return ( <> {/* Search/Filter bar */} @@ -86,7 +115,7 @@ export const RepositoryTable: FC = ({ = ({ {configurations.map((configuration) => { - return ; + return ( + + ); })} diff --git a/components/dashboard/src/teams/TeamOnboarding.tsx b/components/dashboard/src/teams/TeamOnboarding.tsx index 05c0e0c531c823..c799f507a04472 100644 --- a/components/dashboard/src/teams/TeamOnboarding.tsx +++ b/components/dashboard/src/teams/TeamOnboarding.tsx @@ -19,6 +19,11 @@ import type { PlainMessage } from "@bufbuild/protobuf"; import { InputField } from "../components/forms/InputField"; import { TextInput } from "../components/forms/TextInputField"; import { LoadingButton } from "@podkit/buttons/LoadingButton"; +import { Link } from "react-router-dom"; +import { useOrgSuggestedRepos } from "../data/organizations/suggested-repositories-query"; +import { RepositoryListItem } from "../repositories/list/RepoListItem"; +import { LoadingState } from "@podkit/loading/LoadingState"; +import { Table, TableHeader, TableRow, TableHead, TableBody } from "@podkit/tables/Table"; export default function TeamOnboardingPage() { useDocumentTitle("Organization Settings - Onboarding"); @@ -29,6 +34,8 @@ export default function TeamOnboardingPage() { const { data: settings } = useOrgSettingsQuery(); const updateTeamSettings = useUpdateOrgSettingsMutation(); + const { data: suggestedRepos, isLoading: isLoadingSuggestedRepos } = useOrgSuggestedRepos(); + const [internalLink, setInternalLink] = useState(undefined); const handleUpdateTeamSettings = useCallback( @@ -60,9 +67,14 @@ export default function TeamOnboardingPage() { async (e: FormEvent) => { e.preventDefault(); - await handleUpdateTeamSettings({ onboardingSettings: { internalLink } }); + await handleUpdateTeamSettings({ + onboardingSettings: { + internalLink, + recommendedRepositories: settings?.onboardingSettings?.recommendedRepositories ?? [], + }, + }); }, - [handleUpdateTeamSettings, internalLink], + [handleUpdateTeamSettings, internalLink, settings?.onboardingSettings?.recommendedRepositories], ); useEffect(() => { @@ -75,11 +87,11 @@ export default function TeamOnboardingPage() {
- Policies - Restrict workspace classes, editors and sharing across your organization. + Onboarding + Customize the onboarding experience for your organization members.
- Internal dashboard + Internal landing page The link to your internal landing page. This link will be shown to your organization members during the onboarding process. You can disable showing a link by leaving this field empty. @@ -99,6 +111,52 @@ export default function TeamOnboardingPage() { + + + Suggested repositories + + A list of repositories suggested to new organization members. To manage recommended + repositories, visit the{" "} + + Repository settings + {" "} + page and add / remove repositories from the list using the context menu on the corresponding + repository's row. + + {(suggestedRepos ?? []).length > 0 && ( + + + + Name + Repository + + Created + + + Prebuilds + + {/* Action column, loading status in header */} + + {isLoadingSuggestedRepos && ( +
+ +
+ )} +
+
+
+ + {(suggestedRepos ?? []).map((repo) => ( + + ))} + +
+ )} +
); diff --git a/components/dashboard/src/workspaces/Workspaces.tsx b/components/dashboard/src/workspaces/Workspaces.tsx index 5fd634411fa419..5198be9af12114 100644 --- a/components/dashboard/src/workspaces/Workspaces.tsx +++ b/components/dashboard/src/workspaces/Workspaces.tsx @@ -20,7 +20,7 @@ import { Workspace, WorkspacePhase_Phase } from "@gitpod/public-api/lib/gitpod/v import { Button } from "@podkit/buttons/Button"; import { VideoCarousel } from "./VideoCarousel"; import { BlogBanners } from "./BlogBanners"; -import { Book, BookOpen, Building, ChevronRight, Code, GraduationCap } from "lucide-react"; +import { Book, BookOpen, Building, ChevronRight, Code, Video } from "lucide-react"; import { ReactComponent as GitpodStrokedSVG } from "../icons/gitpod-stroked.svg"; import PersonalizedContent from "./PersonalizedContent"; import { useListenToWorkspacesWSMessages as useListenToWorkspacesStatusUpdates } from "../data/workspaces/listen-to-workspace-ws-messages"; @@ -37,6 +37,7 @@ import { useUpdateCurrentUserMutation } from "../data/current-user/update-mutati import { useUserLoader } from "../hooks/use-user-loader"; import Tooltip from "../components/Tooltip"; import { useFeatureFlag } from "../data/featureflag-query"; +import { useOrgSuggestedRepos } from "../data/organizations/suggested-repositories-query"; export const GETTING_STARTED_DISMISSAL_KEY = "workspace-list-getting-started"; @@ -129,6 +130,8 @@ const WorkspacesPage: FunctionComponent = () => { } }, [user?.profile?.coachmarksDismissals]); + const { data: orgSuggestedRepos } = useOrgSuggestedRepos(); + const toggleGettingStarted = useCallback( (show: boolean) => { setShowGettingStarted(show); @@ -191,7 +194,7 @@ const WorkspacesPage: FunctionComponent = () => { {showGettingStarted && (
setVideoModalVisible(true)}> - +