import type * as TypesGen from "api/typesGenerated"; import type { FriendlyDiagnostic, PreviewParameter } from "api/typesGenerated"; import { Alert } from "components/Alert/Alert"; import { ErrorAlert } from "components/Alert/ErrorAlert"; import { Avatar } from "components/Avatar/Avatar"; import { Badge } from "components/Badge/Badge"; import { Button } from "components/Button/Button"; import { Combobox, ComboboxButton, ComboboxContent, ComboboxItem, ComboboxTrigger, } from "components/Combobox/Combobox"; import { Input } from "components/Input/Input"; import { Label } from "components/Label/Label"; import { Link } from "components/Link/Link"; import { Spinner } from "components/Spinner/Spinner"; import { Switch } from "components/Switch/Switch"; import { Tooltip, TooltipContent, TooltipTrigger, } from "components/Tooltip/Tooltip"; import { WorkspaceUserAutocomplete } from "components/UserAutocomplete/UserAutocomplete"; import { type FormikContextType, useFormik } from "formik"; import { useDebouncedFunction } from "hooks/debounce"; import type { ExternalAuthPollingState } from "hooks/useExternalAuth"; import { ArrowLeft, CircleHelp, ExternalLinkIcon } from "lucide-react"; import { useSyncFormParameters } from "modules/hooks/useSyncFormParameters"; import { Diagnostics, DynamicParameter, getInitialParameterValues, useValidationSchemaForDynamicParameters, } from "modules/workspaces/DynamicParameter/DynamicParameter"; import { generateWorkspaceName } from "modules/workspaces/generateWorkspaceName"; import { type FC, useCallback, useEffect, useId, useRef, useState, } from "react"; import { Link as RouterLink } from "react-router"; import { docs } from "utils/docs"; import { nameValidator } from "utils/formUtils"; import type { AutofillBuildParameter } from "utils/richParameters"; import * as Yup from "yup"; import type { CreateWorkspaceMode } from "./CreateWorkspacePage"; import { ExternalAuthButton } from "./ExternalAuthButton"; import type { CreateWorkspacePermissions } from "./permissions"; interface CreateWorkspacePageViewProps { autofillParameters: AutofillBuildParameter[]; canUpdateTemplate?: boolean; creatingWorkspace: boolean; defaultName?: string | null; defaultOwner: TypesGen.MinimalUser; diagnostics: readonly FriendlyDiagnostic[]; disabledParams?: string[]; error: unknown; externalAuth: TypesGen.TemplateVersionExternalAuth[]; externalAuthPollingState: ExternalAuthPollingState; hasAllRequiredExternalAuth: boolean; mode: CreateWorkspaceMode; parameters: PreviewParameter[]; permissions: CreateWorkspacePermissions; presets: TypesGen.Preset[]; template: TypesGen.Template; versionId?: string; versionName?: string; onCancel: () => void; onSubmit: ( req: TypesGen.CreateWorkspaceRequest, owner: TypesGen.MinimalUser, ) => void; resetMutation: () => void; sendMessage: (message: Record, ownerId?: string) => void; startPollingExternalAuth: () => void; owner: TypesGen.MinimalUser; setOwner: (user: TypesGen.MinimalUser) => void; } export const CreateWorkspacePageView: FC = ({ autofillParameters, canUpdateTemplate, creatingWorkspace, defaultName, defaultOwner, diagnostics, disabledParams, error, externalAuth, externalAuthPollingState, hasAllRequiredExternalAuth, mode, parameters, permissions, presets = [], template, versionId, versionName, onSubmit, onCancel, resetMutation, sendMessage, startPollingExternalAuth, owner, setOwner, }) => { const [suggestedName, setSuggestedName] = useState(generateWorkspaceName); const [showPresetParameters, setShowPresetParameters] = useState(false); const id = useId(); const workspaceNameInputRef = useRef(null); const rerollSuggestedName = useCallback(() => { setSuggestedName(() => generateWorkspaceName()); }, []); const autofillByName = Object.fromEntries( autofillParameters.map((param) => [param.name, param]), ); // Only touched fields are sent to the websocket // Autofilled parameters are marked as touched since they have been modified const initialTouched = Object.fromEntries( parameters.filter((p) => autofillByName[p.name]).map((p) => [p.name, true]), ); // The form parameters values hold the working state of the parameters that will be submitted when creating a workspace // 1. The form parameter values are initialized from the websocket response when the form is mounted // 2. Only touched form fields are sent to the websocket, a field is touched if edited by the user or set by autofill // 3. The websocket response may add or remove parameters, these are added or removed from the form values in the useSyncFormParameters hook // 4. All existing form parameters are updated to match the websocket response in the useSyncFormParameters hook const form: FormikContextType = useFormik({ initialValues: { name: defaultName ?? "", template_id: template.id, rich_parameter_values: getInitialParameterValues( parameters, autofillParameters, ), }, initialTouched, validationSchema: Yup.object({ name: nameValidator("Workspace Name"), rich_parameter_values: useValidationSchemaForDynamicParameters(parameters), }), enableReinitialize: false, validateOnChange: true, validateOnBlur: true, onSubmit: (request) => { if (!hasAllRequiredExternalAuth) { return; } onSubmit(request, owner); }, }); useEffect(() => { if (error) { window.scrollTo(0, 0); } }, [error]); useEffect(() => { if (form.submitCount > 0 && Object.keys(form.errors).length > 0) { workspaceNameInputRef.current?.scrollIntoView({ behavior: "smooth", block: "center", }); workspaceNameInputRef.current?.focus(); } }, [form.submitCount, form.errors]); const [presetOptions, setPresetOptions] = useState([ { label: "None", value: "undefined", icon: "", description: "" }, ]); const [selectedPresetIndex, setSelectedPresetIndex] = useState(0); // Build options and keep default label/value in sync useEffect(() => { const options = [ { label: "None", value: "undefined", icon: "", description: "" }, ...presets.map((preset) => ({ label: preset.Default ? `${preset.Name} (Default)` : preset.Name, value: preset.ID, icon: preset.Icon, description: preset.Description, })), ]; setPresetOptions(options); const defaultPreset = presets.find((p) => p.Default); if (defaultPreset) { const idx = presets.indexOf(defaultPreset) + 1; // +1 for "None" setSelectedPresetIndex(idx); form.setFieldValue("template_version_preset_id", defaultPreset.ID); } else { setSelectedPresetIndex(0); // Explicitly set to "None" form.setFieldValue("template_version_preset_id", undefined); } }, [presets, form.setFieldValue]); const [presetParameterNames, setPresetParameterNames] = useState( [], ); // include any modified parameters and all touched parameters to the websocket request const { debounced: sendDynamicParamsRequest } = useDebouncedFunction( ( parameters: Array<{ parameter: PreviewParameter; value: string }>, ownerId?: string, ) => { const formInputs: Record = {}; const formParameters = form.values.rich_parameter_values ?? []; for (const { parameter, value } of parameters) { formInputs[parameter.name] = value; } for (const [fieldName, isTouched] of Object.entries(form.touched)) { if ( isTouched && !parameters.some((p) => p.parameter.name === fieldName) ) { const param = formParameters.find((p) => p.name === fieldName); if (param?.value) { formInputs[fieldName] = param.value; } } } sendMessage(formInputs, ownerId); }, 500, ); 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)); const currentValues = form.values.rich_parameter_values ?? []; const updates: Array<{ field: string; fieldValue: TypesGen.WorkspaceBuildParameter; parameter: PreviewParameter; presetValue: string; }> = []; 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}`; const parameter = parameters[parameterIndex]; const currentValue = currentValues.find( (p) => p.name === presetParameter.Name, )?.value; if (currentValue !== presetParameter.Value) { updates.push({ field: parameterField, fieldValue: { name: presetParameter.Name, value: presetParameter.Value, }, parameter, presetValue: presetParameter.Value, }); } } if (updates.length > 0) { for (const update of updates) { form.setFieldValue(update.field, update.fieldValue); form.setFieldTouched(update.parameter.name, true); } sendDynamicParamsRequest( updates.map((update) => ({ parameter: update.parameter, value: update.presetValue, })), ); } }, [ presetOptions, selectedPresetIndex, presets, form.setFieldValue, form.setFieldTouched, parameters, form.values.rich_parameter_values, sendDynamicParamsRequest, ]); const handleOwnerChange = (user: TypesGen.MinimalUser) => { setOwner(user); sendDynamicParamsRequest([], user.id); }; const handleChange = async ( parameter: PreviewParameter, parameterField: string, value: string, ) => { const currentFormValue = form.values.rich_parameter_values?.find( (p) => p.name === parameter.name, )?.value; await form.setFieldValue(parameterField, { name: parameter.name, value, }); // Only send the request if the value has changed from the form value if (currentFormValue !== value) { form.setFieldTouched(parameter.name, true); sendDynamicParamsRequest([{ parameter, value }]); } }; useSyncFormParameters({ parameters, formValues: form.values.rich_parameter_values ?? [], setFieldValue: form.setFieldValue, }); const disabled = creatingWorkspace || !hasAllRequiredExternalAuth || diagnostics.some((diagnostic) => diagnostic.severity === "error") || parameters.some((parameter) => parameter.diagnostics.some( (diagnostic) => diagnostic.severity === "error", ), ); return ( <>

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

{template.deprecated && ( Deprecated )}
{canUpdateTemplate && versionName && ( )}

New workspace

Dynamic Parameters enhances Coder's existing parameter system with real-time validation, conditional parameter behavior, and richer input types.
View docs
{Boolean(error) && } {mode === "duplicate" && ( Duplicating a workspace only copies its parameters. No state from the old workspace is copied over. )}

General

{permissions.createWorkspaceForAny ? "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} /> {form.touched.name && form.errors.name && (
{form.errors.name}
)}
Need a suggestion?
{permissions.createWorkspaceForAny && (
{ handleOwnerChange(user ?? defaultOwner); }} />
)}
{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 && diagnostics.length > 0 && ( )} {parameters.length > 0 && (

Parameters

These are the settings used by your template. Immutable parameters cannot be modified once the workspace is created. View docs

{diagnostics.length > 0 && ( )} {presets.length > 0 && (
{ const index = presetOptions.findIndex( (preset) => preset.value === value, ); if (index === -1) { return; } setSelectedPresetIndex(index); form.setFieldValue( "template_version_preset_id", // "undefined" string is equivalent to using None option. // Combobox requires a value in order to correctly // highlight the None option. presetOptions[index].value === "undefined" ? undefined : presetOptions[index].value, ); }} > {presetOptions.map((preset) => ( {preset.icon && ( {preset.label} )} {preset.label} ))}
{/* Only show the preset parameter visibility toggle if preset parameters are actually being modified, otherwise it is ineffectual */} {presetParameterNames.length > 0 && ( )}
)}
{parameters.map((parameter, index) => { const currentParameterValueIndex = form.values.rich_parameter_values?.findIndex( (p) => p.name === parameter.name, ); const parameterFieldIndex = currentParameterValueIndex !== undefined ? currentParameterValueIndex : index; // Get the form value by parameter name to ensure correct value mapping const formValue = currentParameterValueIndex !== undefined ? form.values?.rich_parameter_values?.[ currentParameterValueIndex ]?.value || "" : ""; const parameterField = `rich_parameter_values.${parameterFieldIndex}`; const isPresetParameter = presetParameterNames.includes( parameter.name, ); const isDisabled = disabledParams?.includes( parameter.name.toLowerCase().replace(/ /g, "_"), ) || parameter.styling?.disabled || creatingWorkspace || isPresetParameter; // Always show preset parameters if they have any diagnostics if ( !showPresetParameters && isPresetParameter && parameter.diagnostics.length === 0 ) { return null; } return ( handleChange(parameter, parameterField, value) } disabled={isDisabled} isPreset={isPresetParameter} autofill={autofillByName[parameter.name] !== undefined} value={formValue} /> ); })}
)}
); };