diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 134031a2fa5f0..c93af6a64a41c 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -12097,10 +12097,12 @@ const docTemplate = `{ "auto-fill-parameters", "notifications", "workspace-usage", - "web-push" + "web-push", + "dynamic-parameters" ], "x-enum-comments": { "ExperimentAutoFillParameters": "This should not be taken out of experiments until we have redesigned the feature.", + "ExperimentDynamicParameters": "Enables dynamic parameters when creating a workspace.", "ExperimentExample": "This isn't used for anything.", "ExperimentNotifications": "Sends notifications via SMTP and webhooks following certain events.", "ExperimentWebPush": "Enables web push notifications through the browser.", @@ -12111,7 +12113,8 @@ const docTemplate = `{ "ExperimentAutoFillParameters", "ExperimentNotifications", "ExperimentWorkspaceUsage", - "ExperimentWebPush" + "ExperimentWebPush", + "ExperimentDynamicParameters" ] }, "codersdk.ExternalAuth": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 66821355e7387..da4d7a4fcf41c 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -10833,10 +10833,12 @@ "auto-fill-parameters", "notifications", "workspace-usage", - "web-push" + "web-push", + "dynamic-parameters" ], "x-enum-comments": { "ExperimentAutoFillParameters": "This should not be taken out of experiments until we have redesigned the feature.", + "ExperimentDynamicParameters": "Enables dynamic parameters when creating a workspace.", "ExperimentExample": "This isn't used for anything.", "ExperimentNotifications": "Sends notifications via SMTP and webhooks following certain events.", "ExperimentWebPush": "Enables web push notifications through the browser.", @@ -10847,7 +10849,8 @@ "ExperimentAutoFillParameters", "ExperimentNotifications", "ExperimentWorkspaceUsage", - "ExperimentWebPush" + "ExperimentWebPush", + "ExperimentDynamicParameters" ] }, "codersdk.ExternalAuth": { diff --git a/codersdk/deployment.go b/codersdk/deployment.go index dc0bc36a85d5d..a67682489f81d 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -3194,6 +3194,7 @@ const ( ExperimentNotifications Experiment = "notifications" // Sends notifications via SMTP and webhooks following certain events. ExperimentWorkspaceUsage Experiment = "workspace-usage" // Enables the new workspace usage tracking. ExperimentWebPush Experiment = "web-push" // Enables web push notifications through the browser. + ExperimentDynamicParameters Experiment = "dynamic-parameters" // Enables dynamic parameters when creating a workspace. ) // ExperimentsAll should include all experiments that are safe for diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index f8af45a5e6787..4791967b53c9e 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -2845,6 +2845,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o | `notifications` | | `workspace-usage` | | `web-push` | +| `dynamic-parameters` | ## codersdk.ExternalAuth diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index ab8e58d4574f4..2df1c351d9db1 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -749,6 +749,7 @@ export const EntitlementsWarningHeader = "X-Coder-Entitlements-Warning"; // From codersdk/deployment.go export type Experiment = | "auto-fill-parameters" + | "dynamic-parameters" | "example" | "notifications" | "web-push" diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspaceExperimentRouter.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspaceExperimentRouter.tsx new file mode 100644 index 0000000000000..36cd921e28000 --- /dev/null +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspaceExperimentRouter.tsx @@ -0,0 +1,18 @@ +import { useDashboard } from "modules/dashboard/useDashboard"; +import type { FC } from "react"; +import CreateWorkspacePage from "./CreateWorkspacePage"; +import CreateWorkspacePageExperimental from "./CreateWorkspacePageExperimental"; + +const CreateWorkspaceExperimentRouter: FC = () => { + const { experiments } = useDashboard(); + + const dynamicParametersEnabled = experiments.includes("dynamic-parameters"); + + if (dynamicParametersEnabled) { + return ; + } + + return ; +}; + +export default CreateWorkspaceExperimentRouter; diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageExperimental.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageExperimental.tsx new file mode 100644 index 0000000000000..cc843798b1d4c --- /dev/null +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageExperimental.tsx @@ -0,0 +1,327 @@ +import { API } from "api/api"; +import type { ApiErrorResponse } from "api/errors"; +import { checkAuthorization } from "api/queries/authCheck"; +import { + richParameters, + templateByName, + templateVersionExternalAuth, + templateVersionPresets, +} from "api/queries/templates"; +import { autoCreateWorkspace, createWorkspace } from "api/queries/workspaces"; +import type { + TemplateVersionParameter, + UserParameter, + Workspace, +} from "api/typesGenerated"; +import { Loader } from "components/Loader/Loader"; +import { useAuthenticated } from "contexts/auth/RequireAuth"; +import { useEffectEvent } from "hooks/hookPolyfills"; +import { useDashboard } from "modules/dashboard/useDashboard"; +import { + type WorkspacePermissions, + workspacePermissionChecks, +} from "modules/permissions/workspaces"; +import { generateWorkspaceName } from "modules/workspaces/generateWorkspaceName"; +import { type FC, useCallback, useEffect, useRef, useState } from "react"; +import { Helmet } from "react-helmet-async"; +import { useMutation, useQuery, useQueryClient } from "react-query"; +import { useNavigate, useParams, useSearchParams } from "react-router-dom"; +import { pageTitle } from "utils/page"; +import type { AutofillBuildParameter } from "utils/richParameters"; +import { paramsUsedToCreateWorkspace } from "utils/workspace"; +import { CreateWorkspacePageViewExperimental } from "./CreateWorkspacePageViewExperimental"; +export const createWorkspaceModes = ["form", "auto", "duplicate"] as const; +export type CreateWorkspaceMode = (typeof createWorkspaceModes)[number]; + +export type ExternalAuthPollingState = "idle" | "polling" | "abandoned"; + +const CreateWorkspacePageExperimental: FC = () => { + const { organization: organizationName = "default", template: templateName } = + useParams() as { organization?: string; template: string }; + const { user: me } = useAuthenticated(); + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + const { experiments } = useDashboard(); + + const customVersionId = searchParams.get("version") ?? undefined; + const defaultName = searchParams.get("name"); + const disabledParams = searchParams.get("disable_params")?.split(","); + const [mode, setMode] = useState(() => getWorkspaceMode(searchParams)); + const [autoCreateError, setAutoCreateError] = + useState(null); + + const queryClient = useQueryClient(); + const autoCreateWorkspaceMutation = useMutation( + autoCreateWorkspace(queryClient), + ); + const createWorkspaceMutation = useMutation(createWorkspace(queryClient)); + + const templateQuery = useQuery( + templateByName(organizationName, templateName), + ); + const templateVersionPresetsQuery = useQuery({ + ...templateVersionPresets(templateQuery.data?.active_version_id ?? ""), + enabled: templateQuery.data !== undefined, + }); + const permissionsQuery = useQuery( + templateQuery.data + ? checkAuthorization({ + checks: workspacePermissionChecks(templateQuery.data.organization_id), + }) + : { enabled: false }, + ); + const realizedVersionId = + customVersionId ?? templateQuery.data?.active_version_id; + const organizationId = templateQuery.data?.organization_id; + const richParametersQuery = useQuery({ + ...richParameters(realizedVersionId ?? ""), + enabled: realizedVersionId !== undefined, + }); + const realizedParameters = richParametersQuery.data + ? richParametersQuery.data.filter(paramsUsedToCreateWorkspace) + : undefined; + + const { + externalAuth, + externalAuthPollingState, + startPollingExternalAuth, + isLoadingExternalAuth, + } = useExternalAuth(realizedVersionId); + + const isLoadingFormData = + templateQuery.isLoading || + permissionsQuery.isLoading || + richParametersQuery.isLoading; + const loadFormDataError = + templateQuery.error ?? permissionsQuery.error ?? richParametersQuery.error; + + const title = autoCreateWorkspaceMutation.isLoading + ? "Creating workspace..." + : "Create workspace"; + + const onCreateWorkspace = useCallback( + (workspace: Workspace) => { + navigate(`/@${workspace.owner_name}/${workspace.name}`); + }, + [navigate], + ); + + // Auto fill parameters + const autofillEnabled = experiments.includes("auto-fill-parameters"); + const userParametersQuery = useQuery({ + queryKey: ["userParameters"], + queryFn: () => API.getUserParameters(templateQuery.data!.id), + enabled: autofillEnabled && templateQuery.isSuccess, + }); + const autofillParameters = getAutofillParameters( + searchParams, + userParametersQuery.data ? userParametersQuery.data : [], + ); + + const autoCreationStartedRef = useRef(false); + const automateWorkspaceCreation = useEffectEvent(async () => { + if (autoCreationStartedRef.current || !organizationId) { + return; + } + + try { + autoCreationStartedRef.current = true; + const newWorkspace = await autoCreateWorkspaceMutation.mutateAsync({ + organizationId, + templateName, + buildParameters: autofillParameters, + workspaceName: defaultName ?? generateWorkspaceName(), + templateVersionId: realizedVersionId, + match: searchParams.get("match"), + }); + + onCreateWorkspace(newWorkspace); + } catch { + setMode("form"); + } + }); + + const hasAllRequiredExternalAuth = Boolean( + !isLoadingExternalAuth && + externalAuth?.every((auth) => auth.optional || auth.authenticated), + ); + + let autoCreateReady = + mode === "auto" && + (!autofillEnabled || userParametersQuery.isSuccess) && + hasAllRequiredExternalAuth; + + // `mode=auto` was set, but a prerequisite has failed, and so auto-mode should be abandoned. + if ( + mode === "auto" && + !isLoadingExternalAuth && + !hasAllRequiredExternalAuth + ) { + // Prevent suddenly resuming auto-mode if the user connects to all of the required + // external auth providers. + setMode("form"); + // Ensure this is always false, so that we don't ever let `automateWorkspaceCreation` + // fire when we're trying to disable it. + autoCreateReady = false; + // Show an error message to explain _why_ the workspace was not created automatically. + const subject = + externalAuth?.length === 1 + ? "an external authentication provider that is" + : "external authentication providers that are"; + setAutoCreateError({ + message: `This template requires ${subject} not connected.`, + detail: + "Auto-creation has been disabled. Please connect all required external authentication providers before continuing.", + }); + } + + useEffect(() => { + if (autoCreateReady) { + void automateWorkspaceCreation(); + } + }, [automateWorkspaceCreation, autoCreateReady]); + + return ( + <> + + {pageTitle(title)} + + {isLoadingFormData || isLoadingExternalAuth || autoCreateReady ? ( + + ) : ( + { + navigate(-1); + }} + onSubmit={async (request, owner) => { + if (realizedVersionId) { + request = { + ...request, + template_id: undefined, + template_version_id: realizedVersionId, + }; + } + + const workspace = await createWorkspaceMutation.mutateAsync({ + ...request, + userId: owner.id, + }); + onCreateWorkspace(workspace); + }} + /> + )} + + ); +}; + +const useExternalAuth = (versionId: string | undefined) => { + const [externalAuthPollingState, setExternalAuthPollingState] = + useState("idle"); + + const startPollingExternalAuth = useCallback(() => { + setExternalAuthPollingState("polling"); + }, []); + + const { data: externalAuth, isLoading: isLoadingExternalAuth } = useQuery( + versionId + ? { + ...templateVersionExternalAuth(versionId), + refetchInterval: + externalAuthPollingState === "polling" ? 1000 : false, + } + : { enabled: false }, + ); + + const allSignedIn = externalAuth?.every((it) => it.authenticated); + + useEffect(() => { + if (allSignedIn) { + setExternalAuthPollingState("idle"); + return; + } + + if (externalAuthPollingState !== "polling") { + return; + } + + // Poll for a maximum of one minute + const quitPolling = setTimeout( + () => setExternalAuthPollingState("abandoned"), + 60_000, + ); + return () => { + clearTimeout(quitPolling); + }; + }, [externalAuthPollingState, allSignedIn]); + + return { + startPollingExternalAuth, + externalAuth, + externalAuthPollingState, + isLoadingExternalAuth, + }; +}; + +const getAutofillParameters = ( + urlSearchParams: URLSearchParams, + userParameters: UserParameter[], +): AutofillBuildParameter[] => { + const userParamMap = userParameters.reduce((acc, param) => { + acc.set(param.name, param); + return acc; + }, new Map()); + + const buildValues: AutofillBuildParameter[] = Array.from( + urlSearchParams.keys(), + ) + .filter((key) => key.startsWith("param.")) + .map((key) => { + const name = key.replace("param.", ""); + const value = urlSearchParams.get(key) ?? ""; + // URL should take precedence over user parameters + userParamMap.delete(name); + return { name, value, source: "url" }; + }); + + for (const param of userParamMap.values()) { + buildValues.push({ + name: param.name, + value: param.value, + source: "user_history", + }); + } + return buildValues; +}; + +export default CreateWorkspacePageExperimental; + +function getWorkspaceMode(params: URLSearchParams): CreateWorkspaceMode { + const paramMode = params.get("mode"); + if (createWorkspaceModes.includes(paramMode as CreateWorkspaceMode)) { + return paramMode as CreateWorkspaceMode; + } + + return "form"; +} diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx new file mode 100644 index 0000000000000..2eb58f515ec3c --- /dev/null +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx @@ -0,0 +1,446 @@ +import type { Interpolation, Theme } from "@emotion/react"; +import type * as TypesGen from "api/typesGenerated"; +import { Alert } from "components/Alert/Alert"; +import { ErrorAlert } from "components/Alert/ErrorAlert"; +import { Avatar } from "components/Avatar/Avatar"; +import { Button } from "components/Button/Button"; +import { FeatureStageBadge } from "components/FeatureStageBadge/FeatureStageBadge"; +import { SelectFilter } from "components/Filter/SelectFilter"; +import { Input } from "components/Input/Input"; +import { Label } from "components/Label/Label"; +import { Pill } from "components/Pill/Pill"; +import { RichParameterInput } from "components/RichParameterInput/RichParameterInput"; +import { Spinner } from "components/Spinner/Spinner"; +import { Stack } from "components/Stack/Stack"; +import { UserAutocomplete } from "components/UserAutocomplete/UserAutocomplete"; +import { type FormikContextType, useFormik } from "formik"; +import { ArrowLeft } from "lucide-react"; +import type { WorkspacePermissions } from "modules/permissions/workspaces"; +import { generateWorkspaceName } from "modules/workspaces/generateWorkspaceName"; +import { + type FC, + useCallback, + useEffect, + useId, + useMemo, + useState, +} from "react"; +import { Link } from "react-router-dom"; +import { + getFormHelpers, + nameValidator, + onChangeTrimmed, +} from "utils/formUtils"; +import { + type AutofillBuildParameter, + getInitialRichParameterValues, + useValidationSchemaForRichParameters, +} from "utils/richParameters"; +import * as Yup from "yup"; +import type { + CreateWorkspaceMode, + ExternalAuthPollingState, +} from "./CreateWorkspacePage"; +import { ExternalAuthButton } from "./ExternalAuthButton"; + +export const Language = { + duplicationWarning: + "Duplicating a workspace only copies its parameters. No state from the old workspace is copied over.", +} as const; + +export interface CreateWorkspacePageViewExperimentalProps { + mode: CreateWorkspaceMode; + defaultName?: string | null; + disabledParams?: string[]; + error: unknown; + resetMutation: () => void; + defaultOwner: TypesGen.User; + template: TypesGen.Template; + versionId?: string; + externalAuth: TypesGen.TemplateVersionExternalAuth[]; + externalAuthPollingState: ExternalAuthPollingState; + startPollingExternalAuth: () => void; + hasAllRequiredExternalAuth: boolean; + parameters: TypesGen.TemplateVersionParameter[]; + autofillParameters: AutofillBuildParameter[]; + presets: TypesGen.Preset[]; + permissions: WorkspacePermissions; + creatingWorkspace: boolean; + onCancel: () => void; + onSubmit: ( + req: TypesGen.CreateWorkspaceRequest, + owner: TypesGen.User, + ) => void; +} + +export const CreateWorkspacePageViewExperimental: FC< + CreateWorkspacePageViewExperimentalProps +> = ({ + mode, + defaultName, + disabledParams, + error, + resetMutation, + defaultOwner, + template, + versionId, + externalAuth, + externalAuthPollingState, + startPollingExternalAuth, + hasAllRequiredExternalAuth, + parameters, + autofillParameters, + presets = [], + permissions, + creatingWorkspace, + onSubmit, + onCancel, +}) => { + const [owner, setOwner] = useState(defaultOwner); + const [suggestedName, setSuggestedName] = useState(() => + generateWorkspaceName(), + ); + const id = useId(); + + const rerollSuggestedName = useCallback(() => { + setSuggestedName(() => generateWorkspaceName()); + }, []); + + const form: FormikContextType = + useFormik({ + initialValues: { + name: defaultName ?? "", + template_id: template.id, + rich_parameter_values: getInitialRichParameterValues( + parameters, + autofillParameters, + ), + }, + validationSchema: Yup.object({ + name: nameValidator("Workspace Name"), + rich_parameter_values: useValidationSchemaForRichParameters(parameters), + }), + enableReinitialize: true, + onSubmit: (request) => { + if (!hasAllRequiredExternalAuth) { + return; + } + + onSubmit(request, owner); + }, + }); + + useEffect(() => { + if (error) { + window.scrollTo(0, 0); + } + }, [error]); + + const getFieldHelpers = getFormHelpers( + form, + error, + ); + + const autofillByName = useMemo( + () => + Object.fromEntries( + autofillParameters.map((param) => [param.name, param]), + ), + [autofillParameters], + ); + + const [presetOptions, setPresetOptions] = useState([ + { label: "None", value: "" }, + ]); + useEffect(() => { + setPresetOptions([ + { label: "None", value: "" }, + ...presets.map((preset) => ({ + label: preset.Name, + value: preset.ID, + })), + ]); + }, [presets]); + + const [selectedPresetIndex, setSelectedPresetIndex] = useState(0); + const [presetParameterNames, setPresetParameterNames] = useState( + [], + ); + + useEffect(() => { + const selectedPresetOption = presetOptions[selectedPresetIndex]; + let selectedPreset: TypesGen.Preset | undefined; + for (const preset of presets) { + if (preset.ID === selectedPresetOption.value) { + selectedPreset = preset; + break; + } + } + + if (!selectedPreset || !selectedPreset.Parameters) { + setPresetParameterNames([]); + return; + } + + setPresetParameterNames(selectedPreset.Parameters.map((p) => p.Name)); + + for (const presetParameter of selectedPreset.Parameters) { + const parameterIndex = parameters.findIndex( + (p) => p.name === presetParameter.Name, + ); + if (parameterIndex === -1) continue; + + const parameterField = `rich_parameter_values.${parameterIndex}`; + + form.setFieldValue(parameterField, { + name: presetParameter.Name, + value: presetParameter.Value, + }); + } + }, [ + presetOptions, + selectedPresetIndex, + presets, + parameters, + form.setFieldValue, + ]); + + return ( + <> +
+ +
+
+
+
+ +

+ {template.display_name.length > 0 + ? template.display_name + : template.name} +

+
+

New workspace

+ + {template.deprecated && Deprecated} +
+ +
+ {Boolean(error) && } + + {mode === "duplicate" && ( + + {Language.duplicationWarning} + + )} + +
+
+

General

+

+ {permissions.createWorkspaceForUser + ? "Only admins can create workspaces for other users." + : "The name of your new workspace."} +

+
+
+ {versionId && versionId !== template.active_version_id && ( +
+ + + + This parameter has been preset, and cannot be modified. + +
+ )} +
+
+ +
+ { + form.setFieldValue("name", e.target.value.trim()); + resetMutation(); + }} + disabled={creatingWorkspace} + /> +
+ Need a suggestion? + +
+
+
+ {permissions.createWorkspaceForUser && ( +
+ + { + setOwner(user ?? defaultOwner); + }} + size="medium" + /> +
+ )} +
+
+
+ + {externalAuth && externalAuth.length > 0 && ( +
+
+

+ External Authentication +

+

+ This template uses external services for authentication. +

+
+
+ {Boolean(error) && !hasAllRequiredExternalAuth && ( + + To create a workspace using this template, please connect to + all required external authentication providers listed below. + + )} + {externalAuth.map((auth) => ( + + ))} +
+
+ )} + + {parameters.length > 0 && ( +
+
+

Parameters

+

+ These are the settings used by your template. Please note that + immutable parameters cannot be modified once the workspace is + created. +

+
+ {presets.length > 0 && ( + +
+
+ + +
+
+ { + const index = presetOptions.findIndex( + (preset) => preset.value === option?.value, + ); + if (index === -1) { + return; + } + setSelectedPresetIndex(index); + }} + placeholder="Select a preset" + selectedOption={presetOptions[selectedPresetIndex]} + /> +
+
+
+ )} + +
+ {parameters.map((parameter, index) => { + const parameterField = `rich_parameter_values.${index}`; + const parameterInputName = `${parameterField}.value`; + const isDisabled = + disabledParams?.includes( + parameter.name.toLowerCase().replace(/ /g, "_"), + ) || + creatingWorkspace || + presetParameterNames.includes(parameter.name); + + return ( + { + await form.setFieldValue(parameterField, { + name: parameter.name, + value, + }); + }} + key={parameter.name} + parameter={parameter} + parameterAutofill={autofillByName[parameter.name]} + disabled={isDisabled} + /> + ); + })} +
+
+ )} + +
+ +
+ +
+ + ); +}; + +const styles = { + description: (theme) => ({ + fontSize: 13, + color: theme.palette.text.secondary, + }), +} satisfies Record>; diff --git a/site/src/router.tsx b/site/src/router.tsx index d1e3e903eb3fa..4f9ba95d1e05c 100644 --- a/site/src/router.tsx +++ b/site/src/router.tsx @@ -95,8 +95,8 @@ const TemplatePermissionsPage = lazy( const TemplateSummaryPage = lazy( () => import("./pages/TemplatePage/TemplateSummaryPage/TemplateSummaryPage"), ); -const CreateWorkspacePage = lazy( - () => import("./pages/CreateWorkspacePage/CreateWorkspacePage"), +const CreateWorkspaceExperimentRouter = lazy( + () => import("./pages/CreateWorkspacePage/CreateWorkspaceExperimentRouter"), ); const OverviewPage = lazy( () => import("./pages/DeploymentSettingsPage/OverviewPage/OverviewPage"), @@ -334,7 +334,7 @@ const templateRouter = () => { } /> - } /> + } /> }> } />