diff --git a/site/src/modules/hooks/useSyncFormParameters.ts b/site/src/modules/hooks/useSyncFormParameters.ts new file mode 100644 index 0000000000000..4f6952331eaaf --- /dev/null +++ b/site/src/modules/hooks/useSyncFormParameters.ts @@ -0,0 +1,53 @@ +import type * as TypesGen from "api/typesGenerated"; +import { useEffect, useRef } from "react"; + +import type { PreviewParameter } from "api/typesGenerated"; + +type UseSyncFormParametersProps = { + parameters: readonly PreviewParameter[]; + formValues: readonly TypesGen.WorkspaceBuildParameter[]; + setFieldValue: ( + field: string, + value: TypesGen.WorkspaceBuildParameter[], + ) => void; +}; + +export function useSyncFormParameters({ + parameters, + formValues, + setFieldValue, +}: UseSyncFormParametersProps) { + // Form values only needs to be updated when parameters change + // Keep track of form values in a ref to avoid unnecessary updates to rich_parameter_values + const formValuesRef = useRef(formValues); + + useEffect(() => { + formValuesRef.current = formValues; + }, [formValues]); + + useEffect(() => { + if (!parameters) return; + const currentFormValues = formValuesRef.current; + + const newParameterValues = parameters.map((param) => ({ + name: param.name, + value: param.value.valid ? param.value.value : "", + })); + + const currentFormValuesMap = new Map( + currentFormValues.map((value) => [value.name, value.value]), + ); + + const isChanged = + currentFormValues.length !== newParameterValues.length || + newParameterValues.some( + (p) => + !currentFormValuesMap.has(p.name) || + currentFormValuesMap.get(p.name) !== p.value, + ); + + if (isChanged) { + setFieldValue("rich_parameter_values", newParameterValues); + } + }, [parameters, setFieldValue]); +} diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx index 630faf8e806d2..d29955ea3aa7d 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx @@ -20,6 +20,7 @@ import { Switch } from "components/Switch/Switch"; import { UserAutocomplete } from "components/UserAutocomplete/UserAutocomplete"; import { type FormikContextType, useFormik } from "formik"; import { ArrowLeft, CircleAlert, TriangleAlert } from "lucide-react"; +import { useSyncFormParameters } from "modules/hooks/useSyncFormParameters"; import { DynamicParameter, getInitialParameterValues, @@ -652,52 +653,3 @@ const Diagnostics: FC = ({ diagnostics }) => { ); }; - -type UseSyncFormParametersProps = { - parameters: readonly PreviewParameter[]; - formValues: readonly TypesGen.WorkspaceBuildParameter[]; - setFieldValue: ( - field: string, - value: TypesGen.WorkspaceBuildParameter[], - ) => void; -}; - -function useSyncFormParameters({ - parameters, - formValues, - setFieldValue, -}: UseSyncFormParametersProps) { - // Form values only needs to be updated when parameters change - // Keep track of form values in a ref to avoid unnecessary updates to rich_parameter_values - const formValuesRef = useRef(formValues); - - useEffect(() => { - formValuesRef.current = formValues; - }, [formValues]); - - useEffect(() => { - if (!parameters) return; - const currentFormValues = formValuesRef.current; - - const newParameterValues = parameters.map((param) => { - return { - name: param.name, - value: param.value.valid ? param.value.value : "", - }; - }); - - const isChanged = - currentFormValues.length !== newParameterValues.length || - newParameterValues.some( - (p) => - !currentFormValues.find( - (formValue) => - formValue.name === p.name && formValue.value === p.value, - ), - ); - - if (isChanged) { - setFieldValue("rich_parameter_values", newParameterValues); - } - }, [parameters, setFieldValue]); -} diff --git a/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersExperimentRouter.tsx b/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersExperimentRouter.tsx new file mode 100644 index 0000000000000..58c9435c0da4c --- /dev/null +++ b/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersExperimentRouter.tsx @@ -0,0 +1,88 @@ +import { ErrorAlert } from "components/Alert/ErrorAlert"; +import { Loader } from "components/Loader/Loader"; +import { useDashboard } from "modules/dashboard/useDashboard"; +import type { FC } from "react"; +import { useQuery } from "react-query"; +import { ExperimentalFormContext } from "../../CreateWorkspacePage/ExperimentalFormContext"; +import { useWorkspaceSettings } from "../WorkspaceSettingsLayout"; +import WorkspaceParametersPage from "./WorkspaceParametersPage"; +import WorkspaceParametersPageExperimental from "./WorkspaceParametersPageExperimental"; + +const WorkspaceParametersExperimentRouter: FC = () => { + const { experiments } = useDashboard(); + const workspace = useWorkspaceSettings(); + const dynamicParametersEnabled = experiments.includes("dynamic-parameters"); + + const optOutQuery = useQuery( + dynamicParametersEnabled + ? { + queryKey: [ + "workspace", + workspace.id, + "template_id", + workspace.template_id, + "optOut", + ], + queryFn: () => { + const templateId = workspace.template_id; + const workspaceId = workspace.id; + const localStorageKey = optOutKey(templateId); + const storedOptOutString = localStorage.getItem(localStorageKey); + + let optOutResult: boolean; + + if (storedOptOutString !== null) { + optOutResult = storedOptOutString === "true"; + } else { + optOutResult = Boolean( + workspace.template_use_classic_parameter_flow, + ); + } + + return { + templateId, + workspaceId, + optedOut: optOutResult, + }; + }, + } + : { enabled: false }, + ); + + if (dynamicParametersEnabled) { + if (optOutQuery.isLoading) { + return ; + } + if (!optOutQuery.data) { + return ; + } + + const toggleOptedOut = () => { + const key = optOutKey(optOutQuery.data.templateId); + const storedValue = localStorage.getItem(key); + + const current = storedValue + ? storedValue === "true" + : Boolean(workspace.template_use_classic_parameter_flow); + + localStorage.setItem(key, (!current).toString()); + optOutQuery.refetch(); + }; + + return ( + + {optOutQuery.data.optedOut ? ( + + ) : ( + + )} + + ); + } + + return ; +}; + +export default WorkspaceParametersExperimentRouter; + +const optOutKey = (id: string) => `parameters.${id}.optOut`; diff --git a/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersPage.tsx b/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersPage.tsx index f17bb246966bf..bb41cdf79ae3a 100644 --- a/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersPage.tsx +++ b/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersPage.tsx @@ -4,11 +4,11 @@ import { isApiValidationError } from "api/errors"; import { checkAuthorization } from "api/queries/authCheck"; import type { Workspace, WorkspaceBuildParameter } from "api/typesGenerated"; import { ErrorAlert } from "components/Alert/ErrorAlert"; +import { Button as ShadcnButton } from "components/Button/Button"; import { EmptyState } from "components/EmptyState/EmptyState"; import { Loader } from "components/Loader/Loader"; -import { PageHeader, PageHeaderTitle } from "components/PageHeader/PageHeader"; import { ExternalLinkIcon } from "lucide-react"; -import type { FC } from "react"; +import { type FC, useContext } from "react"; import { Helmet } from "react-helmet-async"; import { useMutation, useQuery } from "react-query"; import { useNavigate } from "react-router-dom"; @@ -18,6 +18,7 @@ import { type WorkspacePermissions, workspaceChecks, } from "../../../modules/workspaces/permissions"; +import { ExperimentalFormContext } from "../../CreateWorkspacePage/ExperimentalFormContext"; import { useWorkspaceSettings } from "../WorkspaceSettingsLayout"; import { WorkspaceParametersForm, @@ -112,15 +113,27 @@ export const WorkspaceParametersPageView: FC< isSubmitting, onCancel, }) => { + const experimentalFormContext = useContext(ExperimentalFormContext); return ( - <> - - Workspace parameters - +
+
+ +

Workspace parameters

+
+ {experimentalFormContext && ( + + Try out the new workspace parameters ✨ + + )} +
- {submitError && !isApiValidationError(submitError) && ( + {submitError && !isApiValidationError(submitError) ? ( - )} + ) : null} {data ? ( data.templateVersionRichParameters.length > 0 ? ( @@ -161,7 +174,7 @@ export const WorkspaceParametersPageView: FC< ) : ( )} - +
); }; diff --git a/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersPageExperimental.tsx b/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersPageExperimental.tsx new file mode 100644 index 0000000000000..156298be26e13 --- /dev/null +++ b/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersPageExperimental.tsx @@ -0,0 +1,217 @@ +import { API } from "api/api"; +import { DetailedError } from "api/errors"; +import { checkAuthorization } from "api/queries/authCheck"; +import type { + DynamicParametersRequest, + DynamicParametersResponse, + WorkspaceBuildParameter, +} from "api/typesGenerated"; +import { ErrorAlert } from "components/Alert/ErrorAlert"; +import { Button } from "components/Button/Button"; +import { EmptyState } from "components/EmptyState/EmptyState"; +import { FeatureStageBadge } from "components/FeatureStageBadge/FeatureStageBadge"; +import { Link } from "components/Link/Link"; +import { Loader } from "components/Loader/Loader"; +import { useEffectEvent } from "hooks/hookPolyfills"; +import type { FC } from "react"; +import { + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { Helmet } from "react-helmet-async"; +import { useMutation, useQuery } from "react-query"; +import { useNavigate } from "react-router-dom"; +import { docs } from "utils/docs"; +import { pageTitle } from "utils/page"; +import { + type WorkspacePermissions, + workspaceChecks, +} from "../../../modules/workspaces/permissions"; +import { ExperimentalFormContext } from "../../CreateWorkspacePage/ExperimentalFormContext"; +import { useWorkspaceSettings } from "../WorkspaceSettingsLayout"; +import { WorkspaceParametersPageViewExperimental } from "./WorkspaceParametersPageViewExperimental"; + +const WorkspaceParametersPageExperimental: FC = () => { + const workspace = useWorkspaceSettings(); + const navigate = useNavigate(); + const experimentalFormContext = useContext(ExperimentalFormContext); + + const [latestResponse, setLatestResponse] = + useState(null); + const wsResponseId = useRef(-1); + const ws = useRef(null); + const [wsError, setWsError] = useState(null); + + const sendMessage = useCallback((formValues: Record) => { + const request: DynamicParametersRequest = { + id: wsResponseId.current + 1, + inputs: formValues, + }; + if (ws.current && ws.current.readyState === WebSocket.OPEN) { + ws.current.send(JSON.stringify(request)); + wsResponseId.current = wsResponseId.current + 1; + } + }, []); + + const onMessage = useEffectEvent((response: DynamicParametersResponse) => { + if (latestResponse && latestResponse?.id >= response.id) { + return; + } + + setLatestResponse(response); + }); + + useEffect(() => { + if (!workspace.latest_build.template_version_id) return; + + const socket = API.templateVersionDynamicParameters( + workspace.owner_id, + workspace.latest_build.template_version_id, + { + onMessage, + onError: (error) => { + if (ws.current === socket) { + setWsError(error); + } + }, + onClose: () => { + if (ws.current === socket) { + setWsError( + new DetailedError( + "Websocket connection for dynamic parameters unexpectedly closed.", + "Refresh the page to reset the form.", + ), + ); + } + }, + }, + ); + + ws.current = socket; + + return () => { + socket.close(); + }; + }, [ + workspace.owner_id, + workspace.latest_build.template_version_id, + onMessage, + ]); + + const updateParameters = useMutation({ + mutationFn: (buildParameters: WorkspaceBuildParameter[]) => + API.postWorkspaceBuild(workspace.id, { + transition: "start", + rich_parameter_values: buildParameters, + }), + onSuccess: () => { + navigate(`/@${workspace.owner_name}/${workspace.name}`); + }, + }); + + const checks = workspace ? workspaceChecks(workspace) : {}; + const permissionsQuery = useQuery({ + ...checkAuthorization({ checks }), + enabled: workspace !== undefined, + }); + const permissions = permissionsQuery.data as WorkspacePermissions | undefined; + const canChangeVersions = Boolean(permissions?.updateWorkspaceVersion); + + const handleSubmit = (values: { + rich_parameter_values: WorkspaceBuildParameter[]; + }) => { + if (!latestResponse || !latestResponse.parameters) { + return; + } + + // Only submit mutable parameters + const onlyMutableValues = latestResponse.parameters + .filter((p) => p.mutable) + .map((p) => { + const value = values.rich_parameter_values.find( + (v) => v.name === p.name, + ); + if (!value) { + throw new Error(`Missing value for parameter ${p.name}`); + } + return value; + }); + + updateParameters.mutate(onlyMutableValues); + }; + + const sortedParams = useMemo(() => { + if (!latestResponse?.parameters) { + return []; + } + return [...latestResponse.parameters].sort((a, b) => a.order - b.order); + }, [latestResponse?.parameters]); + + const error = wsError || updateParameters.error; + + if ( + !latestResponse || + (ws.current && ws.current.readyState === WebSocket.CONNECTING) + ) { + return ; + } + + return ( +
+ + {pageTitle(workspace.name, "Parameters")} + + +
+ +

Workspace parameters

+ +
+ {experimentalFormContext && ( + + )} +
+ + {Boolean(error) && } + + {sortedParams.length > 0 ? ( + + navigate(`/@${workspace.owner_name}/${workspace.name}`) + } + sendMessage={sendMessage} + /> + ) : ( + + Learn more about parameters + + } + /> + )} +
+ ); +}; + +export default WorkspaceParametersPageExperimental; diff --git a/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersPageViewExperimental.tsx b/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersPageViewExperimental.tsx new file mode 100644 index 0000000000000..0fcae58c5ffe6 --- /dev/null +++ b/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersPageViewExperimental.tsx @@ -0,0 +1,220 @@ +import type { + PreviewParameter, + Workspace, + WorkspaceBuildParameter, +} from "api/typesGenerated"; +import { Alert } from "components/Alert/Alert"; +import { Button } from "components/Button/Button"; +import { Spinner } from "components/Spinner/Spinner"; +import { useFormik } from "formik"; +import { useSyncFormParameters } from "modules/hooks/useSyncFormParameters"; +import { + DynamicParameter, + getInitialParameterValues, + useValidationSchemaForDynamicParameters, +} from "modules/workspaces/DynamicParameter/DynamicParameter"; +import type { FC } from "react"; +export type WorkspaceParametersPageViewExperimentalProps = { + workspace: Workspace; + parameters: PreviewParameter[]; + diagnostics: PreviewParameter["diagnostics"]; + canChangeVersions: boolean; + isSubmitting: boolean; + onCancel: () => void; + onSubmit: (values: { + rich_parameter_values: WorkspaceBuildParameter[]; + }) => void; + sendMessage: (formValues: Record) => void; +}; + +export const WorkspaceParametersPageViewExperimental: FC< + WorkspaceParametersPageViewExperimentalProps +> = ({ + workspace, + parameters, + diagnostics, + canChangeVersions, + isSubmitting, + onSubmit, + sendMessage, + onCancel, +}) => { + const form = useFormik({ + onSubmit, + initialValues: { + rich_parameter_values: getInitialParameterValues(parameters), + }, + validationSchema: useValidationSchemaForDynamicParameters(parameters), + enableReinitialize: false, + validateOnChange: true, + validateOnBlur: true, + }); + + // Group parameters by ephemeral status + const ephemeralParameters = parameters.filter((p) => p.ephemeral); + const standardParameters = parameters.filter((p) => !p.ephemeral); + + const disabled = + workspace.outdated && + workspace.template_require_active_version && + !canChangeVersions; + + const handleChange = async ( + parameter: PreviewParameter, + parameterField: string, + value: string, + ) => { + await form.setFieldValue(parameterField, { + name: parameter.name, + value, + }); + form.setFieldTouched(parameter.name, true); + sendDynamicParamsRequest(parameter, value); + }; + + // Send the changed parameter and all touched parameters to the websocket + const sendDynamicParamsRequest = ( + parameter: PreviewParameter, + value: string, + ) => { + const formInputs: Record = {}; + formInputs[parameter.name] = value; + const parameters = form.values.rich_parameter_values ?? []; + + for (const [fieldName, isTouched] of Object.entries(form.touched)) { + if (isTouched && fieldName !== parameter.name) { + const param = parameters.find((p) => p.name === fieldName); + if (param?.value) { + formInputs[fieldName] = param.value; + } + } + } + + sendMessage(formInputs); + }; + + useSyncFormParameters({ + parameters, + formValues: form.values.rich_parameter_values ?? [], + setFieldValue: form.setFieldValue, + }); + + return ( + <> + {disabled && ( + + The template for this workspace requires automatic updates. Update the + workspace to edit parameters. + + )} + + {diagnostics && diagnostics.length > 0 && ( +
+ {diagnostics.map((diagnostic, index) => ( +
+
+

{diagnostic.summary}

+
+ {diagnostic.detail && ( +

{diagnostic.detail}

+ )} +
+ ))} +
+ )} + +
+ {standardParameters.length > 0 && ( +
+
+

Parameters

+

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

+
+ {standardParameters.map((parameter, index) => { + const parameterField = `rich_parameter_values.${index}`; + const isDisabled = + disabled || + parameter.styling?.disabled || + !parameter.mutable || + isSubmitting; + + return ( + + handleChange(parameter, parameterField, value) + } + autofill={false} + disabled={isDisabled} + value={ + form.values?.rich_parameter_values?.[index]?.value || "" + } + /> + ); + })} +
+ )} + + {ephemeralParameters.length > 0 && ( +
+
+

Ephemeral Parameters

+

+ These parameters only apply for a single workspace start +

+
+ +
+ {ephemeralParameters.map((parameter, index) => { + const actualIndex = standardParameters.length + index; + const parameterField = `rich_parameter_values.${actualIndex}`; + const isDisabled = + disabled || parameter.styling?.disabled || isSubmitting; + + return ( + + handleChange(parameter, parameterField, value) + } + autofill={false} + disabled={isDisabled} + value={ + form.values?.rich_parameter_values?.[index]?.value || "" + } + /> + ); + })} +
+
+ )} + +
+ + +
+
+ + ); +}; diff --git a/site/src/router.tsx b/site/src/router.tsx index 5784696a16f2d..54269138a5b7b 100644 --- a/site/src/router.tsx +++ b/site/src/router.tsx @@ -82,10 +82,10 @@ const WorkspaceSchedulePage = lazy( "./pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceSchedulePage" ), ); -const WorkspaceParametersPage = lazy( +const WorkspaceParametersExperimentRouter = lazy( () => import( - "./pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersPage" + "./pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersExperimentRouter" ), ); const TerminalPage = lazy(() => import("./pages/TerminalPage/TerminalPage")); @@ -541,7 +541,10 @@ export const router = createBrowserRouter( element={} > } /> - } /> + } + /> } />