diff --git a/components/dashboard/src/app/AppRoutes.tsx b/components/dashboard/src/app/AppRoutes.tsx index 7bf6e08ae9aa0a..c405d6801aea20 100644 --- a/components/dashboard/src/app/AppRoutes.tsx +++ b/components/dashboard/src/app/AppRoutes.tsx @@ -4,7 +4,7 @@ * See License.AGPL.txt in the project root for license information. */ -import React, { FunctionComponent, useState } from "react"; +import React, { FunctionComponent, useContext, useState } from "react"; import { ContextURL, User, Team } from "@gitpod/gitpod-protocol"; import SelectIDEModal from "../settings/SelectIDEModal"; import { StartPage, StartPhase } from "../start/StartPage"; @@ -48,6 +48,8 @@ import { Blocked } from "./Blocked"; // TODO: Can we bundle-split/lazy load these like other pages? import { BlockedRepositories } from "../admin/BlockedRepositories"; import PersonalAccessTokenCreateView from "../settings/PersonalAccessTokensCreateView"; +import { StartWorkspaceModalContext } from "../workspaces/start-workspace-modal-context"; +import { StartWorkspaceOptions } from "../start/start-workspace-options"; const Setup = React.lazy(() => import(/* webpackPrefetch: true */ "../Setup")); const Workspaces = React.lazy(() => import(/* webpackPrefetch: true */ "../workspaces/Workspaces")); @@ -94,7 +96,7 @@ type AppRoutesProps = { }; export const AppRoutes: FunctionComponent = ({ user, teams }) => { const hash = getURLHash(); - + const { startWorkspaceModalProps, setStartWorkspaceModalProps } = useContext(StartWorkspaceModalContext); const [isWhatsNewShown, setWhatsNewShown] = useState(shouldSeeWhatsNew(user)); // Prefix with `/#referrer` will specify an IDE for workspace @@ -126,6 +128,19 @@ export const AppRoutes: FunctionComponent = ({ user, teams }) => setShowUserIdePreference(false)} /> ); + } else if (new URLSearchParams(window.location.search).has("showOptions")) { + const props = StartWorkspaceOptions.parseSearchParams(window.location.search); + return ( + + ); } else { // return
create workspace yay {hash}
; return ; @@ -294,7 +309,12 @@ export const AppRoutes: FunctionComponent = ({ user, teams }) => }} > - + {startWorkspaceModalProps && ( + setStartWorkspaceModalProps(undefined))} + /> + )} ); diff --git a/components/dashboard/src/components/RepositoryFinder.tsx b/components/dashboard/src/components/RepositoryFinder.tsx index a1c1f63511fdef..f7a4a203bf6e4d 100644 --- a/components/dashboard/src/components/RepositoryFinder.tsx +++ b/components/dashboard/src/components/RepositoryFinder.tsx @@ -14,7 +14,8 @@ const LOCAL_STORAGE_KEY = "open-in-gitpod-search-data"; interface RepositoryFinderProps { initialValue?: string; maxDisplayItems?: number; - setSelection: (selection: string) => void; + setSelection?: (selection: string) => void; + onError?: (error: string) => void; } function stripOffProtocol(url: string): string { @@ -67,6 +68,27 @@ export default function RepositoryFinder(props: RepositoryFinderProps) { [suggestedContextURLs], ); + const element = ( +
+
+ logo +
+
+
+
Context URL
+
+
+ {displayContextUrl(props.initialValue) || "Select a repository"} +
+
+
+ ); + + if (!props.setSelection) { + // readonly display value + return
{element}
; + } + return ( -
-
- logo -
-
-
-
Repository
-
-
- {displayContextUrl(props.initialValue) || "Select a repository"} -
-
-
+ {element}
); } diff --git a/components/dashboard/src/components/SelectIDEComponent.tsx b/components/dashboard/src/components/SelectIDEComponent.tsx index 15297364f12345..a7cfe7bf5fc170 100644 --- a/components/dashboard/src/components/SelectIDEComponent.tsx +++ b/components/dashboard/src/components/SelectIDEComponent.tsx @@ -14,6 +14,7 @@ interface SelectIDEComponentProps { selectedIdeOption?: string; useLatest?: boolean; onSelectionChange: (ide: string, latest: boolean) => void; + setError?: (error?: string) => void; } export default function SelectIDEComponent(props: SelectIDEComponentProps) { @@ -51,8 +52,20 @@ export default function SelectIDEComponent(props: SelectIDEComponentProps) { const internalOnSelectionChange = (id: string) => { const { ide, useLatest } = parseId(id); props.onSelectionChange(ide, useLatest); + if (props.setError) { + props.setError(undefined); + } }; const ide = props.selectedIdeOption || ideOptions?.defaultIde || ""; + useEffect(() => { + if (!ideOptions) { + return; + } + const option = ideOptions.options[ide]; + if (!option) { + props.setError?.(`The editor '${ide}' is not supported.`); + } + }, [ide, ideOptions, props]); return ( ; + title = "Select Editor"; + } else { + version = useLatest ? option.latestImageVersion : option.imageVersion; + label = option.type; + title = option.title; } - const version = useLatest ? option.latestImageVersion : option.imageVersion; - const label = option.type; return ( -
+
logo
Editor
-
{option.title}
+
{title}
{version && ( <>
·
diff --git a/components/dashboard/src/components/SelectWorkspaceClassComponent.tsx b/components/dashboard/src/components/SelectWorkspaceClassComponent.tsx index c3352ee1b0accc..986bbd506e22ad 100644 --- a/components/dashboard/src/components/SelectWorkspaceClassComponent.tsx +++ b/components/dashboard/src/components/SelectWorkspaceClassComponent.tsx @@ -5,7 +5,7 @@ */ import { SupportedWorkspaceClass } from "@gitpod/gitpod-protocol/lib/workspace-class"; -import { useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { getGitpodService } from "../service/service"; import WorkspaceClass from "../icons/WorkspaceClass.svg"; import { DropDown2, DropDown2Element } from "./DropDown2"; @@ -13,6 +13,7 @@ import { DropDown2, DropDown2Element } from "./DropDown2"; interface SelectWorkspaceClassProps { selectedWorkspaceClass?: string; onSelectionChange: (workspaceClass: string) => void; + setError?: (error?: string) => void; } export default function SelectWorkspaceClassComponent(props: SelectWorkspaceClassProps) { @@ -37,35 +38,64 @@ export default function SelectWorkspaceClassComponent(props: SelectWorkspaceClas ]; }; }, [workspaceClasses]); + useEffect(() => { + if (!workspaceClasses) { + return; + } + // if the selected workspace class is not supported, we set an error and ask the user to pick one + if (props.selectedWorkspaceClass && !workspaceClasses.find((c) => c.id === props.selectedWorkspaceClass)) { + props.setError?.(`The workspace class '${props.selectedWorkspaceClass}' is not supported.`); + } + }, [workspaceClasses, props.selectedWorkspaceClass, props.setError, props]); + const internalOnSelectionChange = useCallback( + (id: string) => { + props.onSelectionChange(id); + if (props.setError) { + props.setError(undefined); + } + }, + [props], + ); + const selectedWsClass = useMemo( + () => + workspaceClasses?.find( + (ws) => ws.id === (props.selectedWorkspaceClass || workspaceClasses.find((ws) => ws.isDefault)?.id), + ), + [props.selectedWorkspaceClass, workspaceClasses], + ); return ( - ws.id === (props.selectedWorkspaceClass || workspaceClasses.find((ws) => ws.isDefault)?.id), - )} - /> + ); } function WorkspaceClassDropDownElementSelected(props: { wsClass?: SupportedWorkspaceClass }): JSX.Element { const c = props.wsClass; + let title = "Select class"; + if (c) { + title = c.displayName; + } return ( -
+
logo
Class
-
{c?.displayName}
-
·
-
{c?.description}
+
{title}
+ {c?.description && ( + <> +
·
+
{c?.description}
+ + )}
diff --git a/components/dashboard/src/start/CreateWorkspace.tsx b/components/dashboard/src/start/CreateWorkspace.tsx index a3593587acd63a..d48f9f33f71216 100644 --- a/components/dashboard/src/start/CreateWorkspace.tsx +++ b/components/dashboard/src/start/CreateWorkspace.tsx @@ -27,7 +27,7 @@ import { isGitpodIo } from "../utils"; import { BillingAccountSelector } from "../components/BillingAccountSelector"; import { FeatureFlagContext } from "../contexts/FeatureFlagContext"; import { UsageLimitReachedModal } from "../components/UsageLimitReachedModal"; -import { StartOptions } from "@gitpod/gitpod-protocol/lib/util/gitpod-host-url"; +import { StartWorkspaceOptions } from "./start-workspace-options"; export interface CreateWorkspaceProps { contextUrl: string; @@ -40,22 +40,6 @@ export interface CreateWorkspaceState { stillParsing: boolean; } -function parseSearchParams(search: string): GitpodServer.StartWorkspaceOptions { - const params = new URLSearchParams(search); - const options: GitpodServer.StartWorkspaceOptions = {}; - if (params.has(StartOptions.WORKSPACE_CLASS)) { - options.workspaceClass = params.get(StartOptions.WORKSPACE_CLASS)!; - } - if (params.has(StartOptions.EDITOR)) { - const useLatestVersion = params.get(StartOptions.USE_LATEST_EDITOR) === "true"; - options.ideSettings = { - defaultIde: params.get(StartOptions.EDITOR)!, - useLatestVersion, - }; - } - return options; -} - export default class CreateWorkspace extends React.Component { constructor(props: CreateWorkspaceProps) { super(props); @@ -72,7 +56,7 @@ export default class CreateWorkspace extends React.Component this.setState({ stillParsing: false }), 3000); diff --git a/components/dashboard/src/start/start-workspace-options.ts b/components/dashboard/src/start/start-workspace-options.ts new file mode 100644 index 00000000000000..f76665265206d5 --- /dev/null +++ b/components/dashboard/src/start/start-workspace-options.ts @@ -0,0 +1,52 @@ +/** + * Copyright (c) 2022 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 { GitpodServer } from "@gitpod/gitpod-protocol"; + +export namespace StartWorkspaceOptions { + // The workspace class to use for the workspace. If not specified, the default workspace class is used. + export const WORKSPACE_CLASS = "workspaceClass"; + + // The editor to use for the workspace. If not specified, the default editor is used. + export const EDITOR = "editor"; + + export function parseSearchParams(search: string): GitpodServer.StartWorkspaceOptions { + const params = new URLSearchParams(search); + const options: GitpodServer.StartWorkspaceOptions = {}; + const workspaceClass = params.get(StartWorkspaceOptions.WORKSPACE_CLASS); + if (workspaceClass) { + options.workspaceClass = workspaceClass; + } + const editorParam = params.get(StartWorkspaceOptions.EDITOR); + if (editorParam) { + if (editorParam?.endsWith("-latest")) { + options.ideSettings = { + defaultIde: editorParam.slice(0, -7), + useLatestVersion: true, + }; + } else { + options.ideSettings = { + defaultIde: editorParam, + useLatestVersion: false, + }; + } + } + return options; + } + + export function toSearchParams(options: GitpodServer.StartWorkspaceOptions): string { + const params = new URLSearchParams(); + if (options.workspaceClass) { + params.set(StartWorkspaceOptions.WORKSPACE_CLASS, options.workspaceClass); + } + if (options.ideSettings && options.ideSettings.defaultIde) { + const ide = options.ideSettings.defaultIde; + const latest = options.ideSettings.useLatestVersion; + params.set(StartWorkspaceOptions.EDITOR, latest ? ide + "-latest" : ide); + } + return params.toString(); + } +} diff --git a/components/dashboard/src/workspaces/StartWorkspaceModal.tsx b/components/dashboard/src/workspaces/StartWorkspaceModal.tsx index 6d4a371f0201b7..792989683d849c 100644 --- a/components/dashboard/src/workspaces/StartWorkspaceModal.tsx +++ b/components/dashboard/src/workspaces/StartWorkspaceModal.tsx @@ -4,26 +4,31 @@ * See License.AGPL.txt in the project root for license information. */ -import { StartOptions } from "@gitpod/gitpod-protocol/lib/util/gitpod-host-url"; -import { useCallback, useContext, useEffect, useState } from "react"; -import { useLocation } from "react-router"; +import { useCallback, useContext, useMemo, useState } from "react"; import Modal from "../components/Modal"; import RepositoryFinder from "../components/RepositoryFinder"; import SelectIDEComponent from "../components/SelectIDEComponent"; import SelectWorkspaceClassComponent from "../components/SelectWorkspaceClassComponent"; +import { StartWorkspaceOptions } from "../start/start-workspace-options"; import { UserContext } from "../user-context"; -import { StartWorkspaceModalContext } from "./start-workspace-modal-context"; -export function StartWorkspaceModal() { +export interface StartWorkspaceModalProps { + uselatestIde?: boolean; + ide?: string; + workspaceClass?: string; + contextUrl?: string; + onClose?: () => void; +} + +export function StartWorkspaceModal(props: StartWorkspaceModalProps) { const { user } = useContext(UserContext); - const { isStartWorkspaceModalVisible, setIsStartWorkspaceModalVisible } = useContext(StartWorkspaceModalContext); - const location = useLocation(); const [useLatestIde, setUseLatestIde] = useState( - !!user?.additionalData?.ideSettings?.useLatestVersion, + props.uselatestIde || !!user?.additionalData?.ideSettings?.useLatestVersion, ); - const [selectedIde, setSelectedIde] = useState(user?.additionalData?.ideSettings?.defaultIde); - const [selectedWsClass, setSelectedWsClass] = useState(); - const [repo, setRepo] = useState(undefined); + const [selectedIde, setSelectedIde] = useState(props.ide || user?.additionalData?.ideSettings?.defaultIde); + const [selectedWsClass, setSelectedWsClass] = useState(props.workspaceClass); + const [errorWsClass, setErrorWsClass] = useState(undefined); + const [repo, setRepo] = useState(props.contextUrl); const onSelectEditorChange = useCallback( (ide: string, useLatest: boolean) => { setSelectedIde(ide); @@ -31,69 +36,74 @@ export function StartWorkspaceModal() { }, [setSelectedIde, setUseLatestIde], ); + const [errorIde, setErrorIde] = useState(undefined); const startWorkspace = useCallback(() => { - if (!repo) { + if (!repo || errorWsClass || errorIde) { return false; } const url = new URL(window.location.href); url.pathname = ""; - const searchParams = new URLSearchParams(); - if (selectedWsClass) { - searchParams.set(StartOptions.WORKSPACE_CLASS, selectedWsClass); - } - if (selectedIde) { - searchParams.set(StartOptions.EDITOR, selectedIde); - searchParams.set(StartOptions.USE_LATEST_EDITOR, useLatestIde ? "true" : "false"); - } - url.search = searchParams.toString(); + url.search = StartWorkspaceOptions.toSearchParams({ + workspaceClass: selectedWsClass, + ideSettings: { + defaultIde: selectedIde, + useLatestVersion: useLatestIde, + }, + }); url.hash = "#" + repo; window.location.href = url.toString(); return true; - }, [repo, selectedIde, selectedWsClass, useLatestIde]); - - // Close the modal on navigation events. - useEffect(() => { - setIsStartWorkspaceModalVisible(false); - }, [location, setIsStartWorkspaceModalVisible]); + }, [repo, selectedIde, selectedWsClass, useLatestIde, errorIde, errorWsClass]); - useEffect(() => { - // reset state when visibility changes. - setSelectedIde(user?.additionalData?.ideSettings?.defaultIde); - setUseLatestIde(!!user?.additionalData?.ideSettings?.useLatestVersion); - setRepo(undefined); - }, [user, setSelectedIde, setUseLatestIde, isStartWorkspaceModalVisible]); + const buttons = useMemo(() => { + const result = [ + , + , + ]; + if (!props.onClose) { + return result.slice(1, 2); + } + return result; + }, [props.onClose, startWorkspace, repo, errorIde, errorWsClass]); return ( setIsStartWorkspaceModalVisible(false)} + onClose={props.onClose || (() => {})} + closeable={!!props.onClose} onEnter={startWorkspace} - visible={!!isStartWorkspaceModalVisible} + visible={true} title="Open in Gitpod" - buttons={[ - , - , - ]} + buttons={buttons} >
-
Select a repository and configure workspace options.
+
Start a new workspace with the following options.
- +
+ {errorIde &&
{errorIde}
}
+ {errorWsClass &&
{errorWsClass}
}
diff --git a/components/dashboard/src/workspaces/Workspaces.tsx b/components/dashboard/src/workspaces/Workspaces.tsx index c4f82188e374a3..cac132c926dc8b 100644 --- a/components/dashboard/src/workspaces/Workspaces.tsx +++ b/components/dashboard/src/workspaces/Workspaces.tsx @@ -45,7 +45,7 @@ export default function () { const [workspaceModel, setWorkspaceModel] = useState(); const [showInactive, setShowInactive] = useState(); const [deleteModalVisible, setDeleteModalVisible] = useState(); - const { setIsStartWorkspaceModalVisible } = useContext(StartWorkspaceModalContext); + const { setStartWorkspaceModalProps } = useContext(StartWorkspaceModalContext); useEffect(() => { (async () => { @@ -138,7 +138,7 @@ export default function () { ]} />
- @@ -238,7 +238,7 @@ export default function () {
-