diff --git a/site/src/components/Checkbox/Checkbox.tsx b/site/src/components/Checkbox/Checkbox.tsx index 304a04ad5b4ca..6bc1338955122 100644 --- a/site/src/components/Checkbox/Checkbox.tsx +++ b/site/src/components/Checkbox/Checkbox.tsx @@ -8,6 +8,9 @@ import * as React from "react"; import { cn } from "utils/cn"; +/** + * To allow for an indeterminate state the checkbox must be controlled, otherwise the checked prop would remain undefined + */ export const Checkbox = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef diff --git a/site/src/components/MultiSelectCombobox/MultiSelectCombobox.stories.tsx b/site/src/components/MultiSelectCombobox/MultiSelectCombobox.stories.tsx index fd35842e0fddc..109a60e60448d 100644 --- a/site/src/components/MultiSelectCombobox/MultiSelectCombobox.stories.tsx +++ b/site/src/components/MultiSelectCombobox/MultiSelectCombobox.stories.tsx @@ -16,7 +16,7 @@ const meta: Meta = { All organizations selected

), - defaultOptions: organizations.map((org) => ({ + options: organizations.map((org) => ({ label: org.display_name, value: org.id, })), diff --git a/site/src/components/MultiSelectCombobox/MultiSelectCombobox.tsx b/site/src/components/MultiSelectCombobox/MultiSelectCombobox.tsx index 83f2aeed41cd4..249af7918df28 100644 --- a/site/src/components/MultiSelectCombobox/MultiSelectCombobox.tsx +++ b/site/src/components/MultiSelectCombobox/MultiSelectCombobox.tsx @@ -203,9 +203,11 @@ export const MultiSelectCombobox = forwardRef< const [open, setOpen] = useState(false); const [onScrollbar, setOnScrollbar] = useState(false); const [isLoading, setIsLoading] = useState(false); - const dropdownRef = useRef(null); // Added this + const dropdownRef = useRef(null); - const [selected, setSelected] = useState(value || []); + const [selected, setSelected] = useState( + arrayDefaultOptions ?? [], + ); const [options, setOptions] = useState( transitionToGroupOption(arrayDefaultOptions, groupBy), ); diff --git a/site/src/components/RadioGroup/RadioGroup.tsx b/site/src/components/RadioGroup/RadioGroup.tsx index 9be24d6e26f33..3b63a91f40087 100644 --- a/site/src/components/RadioGroup/RadioGroup.tsx +++ b/site/src/components/RadioGroup/RadioGroup.tsx @@ -34,7 +34,7 @@ export const RadioGroupItem = React.forwardRef< focus:outline-none focus-visible:ring-2 focus-visible:ring-content-link focus-visible:ring-offset-4 focus-visible:ring-offset-surface-primary disabled:cursor-not-allowed disabled:opacity-25 disabled:border-surface-invert-primary - hover:border-border-hover`, + hover:border-border-hover data-[state=checked]:border-border-hover`, className, )} {...props} diff --git a/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx b/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx new file mode 100644 index 0000000000000..d3f2cbbd69fa6 --- /dev/null +++ b/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx @@ -0,0 +1,579 @@ +import type { + PreviewParameter, + PreviewParameterOption, + WorkspaceBuildParameter, +} from "api/typesGenerated"; +import { Badge } from "components/Badge/Badge"; +import { Checkbox } from "components/Checkbox/Checkbox"; +import { ExternalImage } from "components/ExternalImage/ExternalImage"; +import { Input } from "components/Input/Input"; +import { Label } from "components/Label/Label"; +import { MemoizedMarkdown } from "components/Markdown/Markdown"; +import { + MultiSelectCombobox, + type Option, +} from "components/MultiSelectCombobox/MultiSelectCombobox"; +import { RadioGroup, RadioGroupItem } from "components/RadioGroup/RadioGroup"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "components/Select/Select"; +import { Switch } from "components/Switch/Switch"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "components/Tooltip/Tooltip"; +import { Info, Settings, TriangleAlert } from "lucide-react"; +import { type FC, useId } from "react"; +import type { AutofillBuildParameter } from "utils/richParameters"; +import * as Yup from "yup"; + +export interface DynamicParameterProps { + parameter: PreviewParameter; + onChange: (value: string) => void; + disabled?: boolean; + isPreset?: boolean; +} + +export const DynamicParameter: FC = ({ + parameter, + onChange, + disabled, + isPreset, +}) => { + const id = useId(); + + return ( +
+ + + {parameter.diagnostics.length > 0 && ( + + )} +
+ ); +}; + +interface ParameterLabelProps { + parameter: PreviewParameter; + isPreset?: boolean; +} + +const ParameterLabel: FC = ({ parameter, isPreset }) => { + const hasDescription = parameter.description && parameter.description !== ""; + const displayName = parameter.display_name + ? parameter.display_name + : parameter.name; + + return ( +
+ {parameter.icon && ( + + + + )} + +
+ + + {hasDescription && ( +
+ + {parameter.description} + +
+ )} +
+
+ ); +}; + +interface ParameterFieldProps { + parameter: PreviewParameter; + onChange: (value: string) => void; + disabled?: boolean; + id: string; +} + +const ParameterField: FC = ({ + parameter, + onChange, + disabled, + id, +}) => { + const value = parameter.value.valid ? parameter.value.value : ""; + const defaultValue = parameter.default_value.valid + ? parameter.default_value.value + : ""; + + switch (parameter.form_type) { + case "dropdown": + return ( + + ); + + case "multi-select": { + // Map parameter options to MultiSelectCombobox options format + const comboboxOptions: Option[] = parameter.options.map((opt) => ({ + value: opt.value.value, + label: opt.name, + disable: false, + })); + + const defaultOptions: Option[] = JSON.parse(defaultValue).map( + (val: string) => { + const option = parameter.options.find((o) => o.value.value === val); + return { + value: val, + label: option?.name || val, + disable: false, + }; + }, + ); + + return ( + { + const values = newValues.map((option) => option.value); + onChange(JSON.stringify(values)); + }} + hidePlaceholderWhenSelected + placeholder="Select option" + emptyIndicator={ +

+ No results found +

+ } + disabled={disabled} + /> + ); + } + + case "switch": + return ( + { + onChange(checked ? "true" : "false"); + }} + disabled={disabled} + /> + ); + + case "radio": + return ( + + {parameter.options.map((option) => ( +
+ + +
+ ))} +
+ ); + + case "checkbox": + return ( +
+ { + onChange(checked ? "true" : "false"); + }} + disabled={disabled} + /> + +
+ ); + case "input": { + const inputType = parameter.type === "number" ? "number" : "text"; + const inputProps: Record = {}; + + if (parameter.type === "number") { + const validations = parameter.validations[0] || {}; + const { validation_min, validation_max } = validations; + + if (validation_min !== null) { + inputProps.min = validation_min; + } + + if (validation_max !== null) { + inputProps.max = validation_max; + } + } + + return ( + onChange(e.target.value)} + disabled={disabled} + placeholder={ + (parameter.styling as { placehholder?: string })?.placehholder + } + {...inputProps} + /> + ); + } + } +}; + +interface OptionDisplayProps { + option: PreviewParameterOption; +} + +const OptionDisplay: FC = ({ option }) => { + return ( +
+ {option.icon && ( + + )} + {option.name} + {option.description && ( + + + + + + + {option.description} + + + + )} +
+ ); +}; + +interface ParameterDiagnosticsProps { + diagnostics: PreviewParameter["diagnostics"]; +} + +const ParameterDiagnostics: FC = ({ + diagnostics, +}) => { + return ( +
+ {diagnostics.map((diagnostic, index) => ( +
+
{diagnostic.summary}
+ {diagnostic.detail &&
{diagnostic.detail}
} +
+ ))} +
+ ); +}; + +export const getInitialParameterValues = ( + params: PreviewParameter[], + autofillParams?: AutofillBuildParameter[], +): WorkspaceBuildParameter[] => { + return params.map((parameter) => { + // Short-circuit for ephemeral parameters, which are always reset to + // the template-defined default. + if (parameter.ephemeral) { + return { + name: parameter.name, + value: parameter.default_value.valid + ? parameter.default_value.value + : "", + }; + } + + const autofillParam = autofillParams?.find( + ({ name }) => name === parameter.name, + ); + + return { + name: parameter.name, + value: + autofillParam && + isValidValue(parameter, autofillParam) && + autofillParam.value + ? autofillParam.value + : "", + }; + }); +}; + +const isValidValue = ( + previewParam: PreviewParameter, + buildParam: WorkspaceBuildParameter, +) => { + if (previewParam.options.length > 0) { + const validValues = previewParam.options.map( + (option) => option.value.value, + ); + return validValues.includes(buildParam.value); + } + + return true; +}; + +export const useValidationSchemaForDynamicParameters = ( + parameters?: PreviewParameter[], + lastBuildParameters?: WorkspaceBuildParameter[], +): Yup.AnySchema => { + if (!parameters) { + return Yup.object(); + } + + return Yup.array() + .of( + Yup.object().shape({ + name: Yup.string().required(), + value: Yup.string() + .test("verify with template", (val, ctx) => { + const name = ctx.parent.name; + const parameter = parameters.find( + (parameter) => parameter.name === name, + ); + if (parameter) { + switch (parameter.type) { + case "number": { + const minValidation = parameter.validations.find( + (v) => v.validation_min !== null, + ); + const maxValidation = parameter.validations.find( + (v) => v.validation_max !== null, + ); + + if ( + minValidation && + minValidation.validation_min !== null && + !maxValidation && + Number(val) < minValidation.validation_min + ) { + return ctx.createError({ + path: ctx.path, + message: + parameterError(parameter, val) ?? + `Value must be greater than ${minValidation.validation_min}.`, + }); + } + + if ( + !minValidation && + maxValidation && + maxValidation.validation_max !== null && + Number(val) > maxValidation.validation_max + ) { + return ctx.createError({ + path: ctx.path, + message: + parameterError(parameter, val) ?? + `Value must be less than ${maxValidation.validation_max}.`, + }); + } + + if ( + minValidation && + minValidation.validation_min !== null && + maxValidation && + maxValidation.validation_max !== null && + (Number(val) < minValidation.validation_min || + Number(val) > maxValidation.validation_max) + ) { + return ctx.createError({ + path: ctx.path, + message: + parameterError(parameter, val) ?? + `Value must be between ${minValidation.validation_min} and ${maxValidation.validation_max}.`, + }); + } + + const monotonic = parameter.validations.find( + (v) => + v.validation_monotonic !== null && + v.validation_monotonic !== "", + ); + + if (monotonic && lastBuildParameters) { + const lastBuildParameter = lastBuildParameters.find( + (last: { name: string }) => last.name === name, + ); + if (lastBuildParameter) { + switch (monotonic.validation_monotonic) { + case "increasing": + if (Number(lastBuildParameter.value) > Number(val)) { + return ctx.createError({ + path: ctx.path, + message: `Value must only ever increase (last value was ${lastBuildParameter.value})`, + }); + } + break; + case "decreasing": + if (Number(lastBuildParameter.value) < Number(val)) { + return ctx.createError({ + path: ctx.path, + message: `Value must only ever decrease (last value was ${lastBuildParameter.value})`, + }); + } + break; + } + } + } + break; + } + case "string": { + const regex = parameter.validations.find( + (v) => + v.validation_regex !== null && v.validation_regex !== "", + ); + if (!regex || !regex.validation_regex) { + return true; + } + + if (val && !new RegExp(regex.validation_regex).test(val)) { + return ctx.createError({ + path: ctx.path, + message: parameterError(parameter, val), + }); + } + break; + } + } + } + return true; + }), + }), + ) + .required(); +}; + +const parameterError = ( + parameter: PreviewParameter, + value?: string, +): string | undefined => { + const validation_error = parameter.validations.find( + (v) => v.validation_error !== null, + ); + const minValidation = parameter.validations.find( + (v) => v.validation_min !== null, + ); + const maxValidation = parameter.validations.find( + (v) => v.validation_max !== null, + ); + + if (!validation_error || !value) { + return; + } + + const r = new Map([ + [ + "{min}", + minValidation ? (minValidation.validation_min?.toString() ?? "") : "", + ], + [ + "{max}", + maxValidation ? (maxValidation.validation_max?.toString() ?? "") : "", + ], + ["{value}", value], + ]); + return validation_error.validation_error.replace( + /{min}|{max}|{value}/g, + (match) => r.get(match) || "", + ); +}; diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageExperimental.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageExperimental.tsx index 8598085c948e5..14f34a2e29f0b 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageExperimental.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageExperimental.tsx @@ -1,30 +1,34 @@ -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, + DynamicParametersRequest, + DynamicParametersResponse, + Template, 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 { generateWorkspaceName } from "modules/workspaces/generateWorkspaceName"; -import { type FC, useCallback, useEffect, useRef, useState } from "react"; +import { + type FC, + useCallback, + useEffect, + useMemo, + 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]; @@ -32,7 +36,6 @@ import { type CreateWorkspacePermissions, createWorkspaceChecks, } from "./permissions"; - export type ExternalAuthPollingState = "idle" | "polling" | "abandoned"; const CreateWorkspacePageExperimental: FC = () => { @@ -41,7 +44,11 @@ const CreateWorkspacePageExperimental: FC = () => { const { user: me } = useAuthenticated(); const navigate = useNavigate(); const [searchParams] = useSearchParams(); - const { experiments } = useDashboard(); + + const [currentResponse, setCurrentResponse] = + useState(null); + const [wsResponseId, setWSResponseId] = useState(0); + const sendMessage = (message: DynamicParametersRequest) => {}; const customVersionId = searchParams.get("version") ?? undefined; const defaultName = searchParams.get("name"); @@ -72,14 +79,8 @@ const CreateWorkspacePageExperimental: FC = () => { ); 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, @@ -89,11 +90,8 @@ const CreateWorkspacePageExperimental: FC = () => { } = useExternalAuth(realizedVersionId); const isLoadingFormData = - templateQuery.isLoading || - permissionsQuery.isLoading || - richParametersQuery.isLoading; - const loadFormDataError = - templateQuery.error ?? permissionsQuery.error ?? richParametersQuery.error; + templateQuery.isLoading || permissionsQuery.isLoading; + const loadFormDataError = templateQuery.error ?? permissionsQuery.error; const title = autoCreateWorkspaceMutation.isLoading ? "Creating workspace..." @@ -107,16 +105,7 @@ const CreateWorkspacePageExperimental: FC = () => { ); // 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 autofillParameters = getAutofillParameters(searchParams); const autoCreationStartedRef = useRef(false); const automateWorkspaceCreation = useEffectEvent(async () => { @@ -146,10 +135,7 @@ const CreateWorkspacePageExperimental: FC = () => { externalAuth?.every((auth) => auth.optional || auth.authenticated), ); - let autoCreateReady = - mode === "auto" && - (!autofillEnabled || userParametersQuery.isSuccess) && - hasAllRequiredExternalAuth; + let autoCreateReady = mode === "auto" && hasAllRequiredExternalAuth; // `mode=auto` was set, but a prerequisite has failed, and so auto-mode should be abandoned. if ( @@ -181,17 +167,29 @@ const CreateWorkspacePageExperimental: FC = () => { } }, [automateWorkspaceCreation, autoCreateReady]); + const sortedParams = useMemo(() => { + if (!currentResponse?.parameters) { + return []; + } + return [...currentResponse.parameters].sort((a, b) => a.order - b.order); + }, [currentResponse?.parameters]); + return ( <> {pageTitle(title)} - {isLoadingFormData || isLoadingExternalAuth || autoCreateReady ? ( + {!currentResponse || + !templateQuery.data || + isLoadingFormData || + isLoadingExternalAuth || + autoCreateReady ? ( ) : ( { autoCreateWorkspaceMutation.error } resetMutation={createWorkspaceMutation.reset} - template={templateQuery.data!} + template={templateQuery.data} versionId={realizedVersionId} externalAuth={externalAuth ?? []} externalAuthPollingState={externalAuthPollingState} startPollingExternalAuth={startPollingExternalAuth} hasAllRequiredExternalAuth={hasAllRequiredExternalAuth} permissions={permissionsQuery.data as CreateWorkspacePermissions} - parameters={realizedParameters as TemplateVersionParameter[]} + parameters={sortedParams} presets={templateVersionPresetsQuery.data ?? []} creatingWorkspace={createWorkspaceMutation.isLoading} + setWSResponseId={setWSResponseId} + sendMessage={sendMessage} onCancel={() => { navigate(-1); }} onSubmit={async (request, owner) => { + let workspaceRequest = request; if (realizedVersionId) { - request = { + workspaceRequest = { ...request, template_id: undefined, template_version_id: realizedVersionId, @@ -225,7 +226,7 @@ const CreateWorkspacePageExperimental: FC = () => { } const workspace = await createWorkspaceMutation.mutateAsync({ - ...request, + ...workspaceRequest, userId: owner.id, }); onCreateWorkspace(workspace); @@ -286,13 +287,7 @@ const useExternalAuth = (versionId: string | undefined) => { 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(), ) @@ -300,18 +295,8 @@ const getAutofillParameters = ( .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; }; diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx index ff8c2836be311..49fd6e9188960 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx @@ -1,5 +1,9 @@ -import type { Interpolation, Theme } from "@emotion/react"; import type * as TypesGen from "api/typesGenerated"; +import type { + DynamicParametersRequest, + PreviewDiagnostics, + PreviewParameter, +} from "api/typesGenerated"; import { Alert } from "components/Alert/Alert"; import { ErrorAlert } from "components/Alert/ErrorAlert"; import { Avatar } from "components/Avatar/Avatar"; @@ -9,12 +13,18 @@ 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 { Switch } from "components/Switch/Switch"; import { UserAutocomplete } from "components/UserAutocomplete/UserAutocomplete"; import { type FormikContextType, useFormik } from "formik"; +import { useDebouncedFunction } from "hooks/debounce"; import { ArrowLeft } from "lucide-react"; +import { + DynamicParameter, + getInitialParameterValues, + useValidationSchemaForDynamicParameters, +} from "modules/workspaces/DynamicParameter/DynamicParameter"; import { generateWorkspaceName } from "modules/workspaces/generateWorkspaceName"; import { type FC, @@ -25,11 +35,7 @@ import { useState, } from "react"; import { getFormHelpers, nameValidator } from "utils/formUtils"; -import { - type AutofillBuildParameter, - getInitialRichParameterValues, - useValidationSchemaForRichParameters, -} from "utils/richParameters"; +import type { AutofillBuildParameter } from "utils/richParameters"; import * as Yup from "yup"; import type { CreateWorkspaceMode, @@ -37,65 +43,67 @@ import type { } from "./CreateWorkspacePage"; import { ExternalAuthButton } from "./ExternalAuthButton"; import type { CreateWorkspacePermissions } from "./permissions"; -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; + autofillParameters: AutofillBuildParameter[]; + creatingWorkspace: boolean; defaultName?: string | null; + defaultOwner: TypesGen.User; + diagnostics: PreviewDiagnostics; 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[]; + mode: CreateWorkspaceMode; + parameters: PreviewParameter[]; permissions: CreateWorkspacePermissions; - creatingWorkspace: boolean; + presets: TypesGen.Preset[]; + template: TypesGen.Template; + versionId?: string; onCancel: () => void; onSubmit: ( req: TypesGen.CreateWorkspaceRequest, owner: TypesGen.User, ) => void; + resetMutation: () => void; + sendMessage: (message: DynamicParametersRequest) => void; + setWSResponseId: (value: React.SetStateAction) => void; + startPollingExternalAuth: () => void; } export const CreateWorkspacePageViewExperimental: FC< CreateWorkspacePageViewExperimentalProps > = ({ - mode, + autofillParameters, + creatingWorkspace, defaultName, + defaultOwner, + diagnostics, disabledParams, error, - resetMutation, - defaultOwner, - template, - versionId, externalAuth, externalAuthPollingState, - startPollingExternalAuth, hasAllRequiredExternalAuth, + mode, parameters, - autofillParameters, - presets = [], permissions, - creatingWorkspace, + presets = [], + template, + versionId, onSubmit, onCancel, + resetMutation, + sendMessage, + setWSResponseId, + startPollingExternalAuth, }) => { const [owner, setOwner] = useState(defaultOwner); const [suggestedName, setSuggestedName] = useState(() => generateWorkspaceName(), ); + const [showPresetParameters, setShowPresetParameters] = useState(false); const id = useId(); - const rerollSuggestedName = useCallback(() => { setSuggestedName(() => generateWorkspaceName()); }, []); @@ -105,16 +113,19 @@ export const CreateWorkspacePageViewExperimental: FC< initialValues: { name: defaultName ?? "", template_id: template.id, - rich_parameter_values: getInitialRichParameterValues( + rich_parameter_values: getInitialParameterValues( parameters, autofillParameters, ), }, validationSchema: Yup.object({ name: nameValidator("Workspace Name"), - rich_parameter_values: useValidationSchemaForRichParameters(parameters), + rich_parameter_values: + useValidationSchemaForDynamicParameters(parameters), }), enableReinitialize: true, + validateOnChange: false, + validateOnBlur: true, onSubmit: (request) => { if (!hasAllRequiredExternalAuth) { return; @@ -195,10 +206,64 @@ export const CreateWorkspacePageViewExperimental: FC< presetOptions, selectedPresetIndex, presets, - parameters, form.setFieldValue, + parameters, ]); + const sendDynamicParamsRequest = ( + parameter: PreviewParameter, + value: string, + ) => { + const formInputs = Object.fromEntries( + form.values.rich_parameter_values?.map((value) => { + return [value.name, value.value]; + }) ?? [], + ); + // Update the input for the changed parameter + formInputs[parameter.name] = value; + + setWSResponseId((prevId) => { + const newId = prevId + 1; + const request: DynamicParametersRequest = { + id: newId, + inputs: formInputs, + }; + sendMessage(request); + return newId; + }); + }; + + const { debounced: handleChangeDebounced } = useDebouncedFunction( + async ( + parameter: PreviewParameter, + parameterField: string, + value: string, + ) => { + await form.setFieldValue(parameterField, { + name: parameter.form_type, + value, + }); + sendDynamicParamsRequest(parameter, value); + }, + 500, + ); + + const handleChange = async ( + parameter: PreviewParameter, + parameterField: string, + value: string, + ) => { + if (parameter.form_type === "input" || parameter.form_type === "textarea") { + handleChangeDebounced(parameter, parameterField, value); + } else { + await form.setFieldValue(parameterField, { + name: parameter.form_type, + value, + }); + sendDynamicParamsRequest(parameter, value); + } + }; + return ( <>
@@ -244,7 +309,8 @@ export const CreateWorkspacePageViewExperimental: FC< dismissible data-testid="duplication-warning" > - {Language.duplicationWarning} + Duplicating a workspace only copies its parameters. No state from + the old workspace is copied over. )} @@ -353,9 +419,8 @@ export const CreateWorkspacePageViewExperimental: FC<

Parameters

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

{presets.length > 0 && ( @@ -382,6 +447,16 @@ export const CreateWorkspacePageViewExperimental: FC< selectedOption={presetOptions[selectedPresetIndex]} />
+ + + + )} @@ -390,26 +465,32 @@ export const CreateWorkspacePageViewExperimental: FC< {parameters.map((parameter, index) => { const parameterField = `rich_parameter_values.${index}`; const parameterInputName = `${parameterField}.value`; + const isPresetParameter = presetParameterNames.includes( + parameter.name, + ); const isDisabled = disabledParams?.includes( parameter.name.toLowerCase().replace(/ /g, "_"), ) || + (parameter.styling as { disabled?: boolean })?.disabled || creatingWorkspace || - presetParameterNames.includes(parameter.name); + isPresetParameter; + + // Hide preset parameters if showPresetParameters is false + if (!showPresetParameters && isPresetParameter) { + return null; + } return ( - { - await form.setFieldValue(parameterField, { - name: parameter.name, - value, - }); - }} key={parameter.name} parameter={parameter} - parameterAutofill={autofillByName[parameter.name]} + onChange={(value) => + handleChange(parameter, parameterField, value) + } disabled={isDisabled} + isPreset={isPresetParameter} /> ); })} @@ -431,10 +512,3 @@ export const CreateWorkspacePageViewExperimental: FC< ); }; - -const styles = { - description: (theme) => ({ - fontSize: 13, - color: theme.palette.text.secondary, - }), -} satisfies Record>; diff --git a/site/src/pages/DeploymentSettingsPage/IdpOrgSyncPage/IdpOrgSyncPageView.tsx b/site/src/pages/DeploymentSettingsPage/IdpOrgSyncPage/IdpOrgSyncPageView.tsx index aa39906f09370..f99c1d04fee14 100644 --- a/site/src/pages/DeploymentSettingsPage/IdpOrgSyncPage/IdpOrgSyncPageView.tsx +++ b/site/src/pages/DeploymentSettingsPage/IdpOrgSyncPage/IdpOrgSyncPageView.tsx @@ -257,7 +257,7 @@ export const IdpOrgSyncPageView: FC = ({ className="min-w-60 max-w-3xl" value={coderOrgs} onChange={setCoderOrgs} - defaultOptions={organizations.map((org) => ({ + options={organizations.map((org) => ({ label: org.display_name, value: org.id, }))} diff --git a/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpGroupSyncForm.tsx b/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpGroupSyncForm.tsx index 5340ec99dda79..284267f4487e1 100644 --- a/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpGroupSyncForm.tsx +++ b/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpGroupSyncForm.tsx @@ -259,7 +259,7 @@ export const IdpGroupSyncForm: FC = ({ className="min-w-60 max-w-3xl" value={coderGroups} onChange={setCoderGroups} - defaultOptions={groups.map((group) => ({ + options={groups.map((group) => ({ label: group.display_name || group.name, value: group.id, }))} diff --git a/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpRoleSyncForm.tsx b/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpRoleSyncForm.tsx index faeaf0773dffd..0825ab4217395 100644 --- a/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpRoleSyncForm.tsx +++ b/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpRoleSyncForm.tsx @@ -200,7 +200,7 @@ export const IdpRoleSyncForm: FC = ({ className="min-w-60 max-w-3xl" value={coderRoles} onChange={setCoderRoles} - defaultOptions={roles.map((role) => ({ + options={roles.map((role) => ({ label: role.display_name || role.name, value: role.name, }))} pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy