From dd83a00de9c98392e5c320d7f3725e732330724c Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Wed, 14 May 2025 22:52:13 +0000 Subject: [PATCH 1/7] feat: add experimental workspace parameters page for dynamic params --- .../WorkspaceParametersExperimentRouter.tsx | 67 ++++++ .../WorkspaceParametersPage.tsx | 20 +- .../WorkspaceParametersPageExperimental.tsx | 218 +++++++++++++++++ ...orkspaceParametersPageViewExperimental.tsx | 227 ++++++++++++++++++ site/src/router.tsx | 9 +- 5 files changed, 536 insertions(+), 5 deletions(-) create mode 100644 site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersExperimentRouter.tsx create mode 100644 site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersPageExperimental.tsx create mode 100644 site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersPageViewExperimental.tsx diff --git a/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersExperimentRouter.tsx b/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersExperimentRouter.tsx new file mode 100644 index 0000000000000..bafea1eede9b1 --- /dev/null +++ b/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersExperimentRouter.tsx @@ -0,0 +1,67 @@ +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: () => ({ + templateId: workspace.template_id, + workspaceId: workspace.id, + optedOut: + localStorage.getItem(optOutKey(workspace.template_id)) === "true", + }), + } + : { enabled: false }, + ); + + if (dynamicParametersEnabled) { + if (optOutQuery.isLoading) { + return ; + } + if (!optOutQuery.data) { + return ; + } + + const toggleOptedOut = () => { + const key = optOutKey(optOutQuery.data.templateId); + const current = localStorage.getItem(key) === "true"; + 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..65b053d4bb6e3 100644 --- a/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersPage.tsx +++ b/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersPage.tsx @@ -4,11 +4,12 @@ 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 +19,7 @@ import { type WorkspacePermissions, workspaceChecks, } from "../../../modules/workspaces/permissions"; +import { ExperimentalFormContext } from "../../CreateWorkspacePage/ExperimentalFormContext"; import { useWorkspaceSettings } from "../WorkspaceSettingsLayout"; import { WorkspaceParametersForm, @@ -112,9 +114,23 @@ export const WorkspaceParametersPageView: FC< isSubmitting, onCancel, }) => { + const experimentalFormContext = useContext(ExperimentalFormContext); return ( <> - + + Try out the new workspace parameters ✨ + + ) + } + > Workspace parameters diff --git a/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersPageExperimental.tsx b/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersPageExperimental.tsx new file mode 100644 index 0000000000000..90fcbc83185a9 --- /dev/null +++ b/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersPageExperimental.tsx @@ -0,0 +1,218 @@ +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 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 [currentResponse, setCurrentResponse] = + useState(null); + const [wsResponseId, setWSResponseId] = useState(-1); + const ws = useRef(null); + const [wsError, setWsError] = useState(null); + + const onMessage = useCallback((response: DynamicParametersResponse) => { + setCurrentResponse((prev) => { + if (prev?.id === response.id) { + return prev; + } + return 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) => { + 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 sendMessage = useCallback((formValues: Record) => { + setWSResponseId((prevId) => { + const request: DynamicParametersRequest = { + id: prevId + 1, + inputs: formValues, + }; + if (ws.current && ws.current.readyState === WebSocket.OPEN) { + ws.current.send(JSON.stringify(request)); + return prevId + 1; + } + return prevId; + }); + }, []); + + 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 (!currentResponse || !currentResponse.parameters) { + return; + } + + // Only submit mutable parameters + const onlyMutableValues = currentResponse.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 (!currentResponse?.parameters) { + return []; + } + return [...currentResponse.parameters].sort((a, b) => a.order - b.order); + }, [currentResponse?.parameters]); + + const error = wsError || updateParameters.error; + + if ( + !currentResponse || + (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..2e36aa7035336 --- /dev/null +++ b/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersPageViewExperimental.tsx @@ -0,0 +1,227 @@ +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 { useDebouncedFunction } from "hooks/debounce"; +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 { debounced: handleChangeDebounced } = useDebouncedFunction( + async ( + parameter: PreviewParameter, + parameterField: string, + value: string, + ) => { + await form.setFieldValue(parameterField, { + name: parameter.name, + value, + }); + form.setFieldTouched(parameter.name, true); + 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.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: { [k: string]: string } = {}; + 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); + }; + + 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) + } + disabled={isDisabled} + /> + ); + })} +
+ )} + + {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) + } + disabled={isDisabled} + /> + ); + })} +
+
+ )} + +
+ + +
+
+ + ); +}; 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={} > } /> - } /> + } + /> } /> From f19178cb18ab99041101cbbad71f978a116fe2ce Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Thu, 15 May 2025 10:47:35 +0000 Subject: [PATCH 2/7] fix: update type --- .../WorkspaceParametersPageViewExperimental.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersPageViewExperimental.tsx b/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersPageViewExperimental.tsx index 2e36aa7035336..4c0e17515a677 100644 --- a/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersPageViewExperimental.tsx +++ b/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersPageViewExperimental.tsx @@ -98,7 +98,7 @@ export const WorkspaceParametersPageViewExperimental: FC< parameter: PreviewParameter, value: string, ) => { - const formInputs: { [k: string]: string } = {}; + const formInputs: Record = {}; formInputs[parameter.name] = value; const parameters = form.values.rich_parameter_values ?? []; From 0c7f4c14f411900291e70ad4a7becf942c4d50a7 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Mon, 19 May 2025 22:49:10 +0000 Subject: [PATCH 3/7] chore: get parity with create workspace page --- .../WorkspaceParametersExperimentRouter.tsx | 35 +++++-- .../WorkspaceParametersPage.tsx | 39 ++++---- .../WorkspaceParametersPageExperimental.tsx | 61 ++++++------ ...orkspaceParametersPageViewExperimental.tsx | 93 +++++++++++++------ 4 files changed, 141 insertions(+), 87 deletions(-) diff --git a/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersExperimentRouter.tsx b/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersExperimentRouter.tsx index bafea1eede9b1..58c9435c0da4c 100644 --- a/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersExperimentRouter.tsx +++ b/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersExperimentRouter.tsx @@ -23,12 +23,28 @@ const WorkspaceParametersExperimentRouter: FC = () => { workspace.template_id, "optOut", ], - queryFn: () => ({ - templateId: workspace.template_id, - workspaceId: workspace.id, - optedOut: - localStorage.getItem(optOutKey(workspace.template_id)) === "true", - }), + 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 }, ); @@ -43,7 +59,12 @@ const WorkspaceParametersExperimentRouter: FC = () => { const toggleOptedOut = () => { const key = optOutKey(optOutQuery.data.templateId); - const current = localStorage.getItem(key) === "true"; + const storedValue = localStorage.getItem(key); + + const current = storedValue + ? storedValue === "true" + : Boolean(workspace.template_use_classic_parameter_flow); + localStorage.setItem(key, (!current).toString()); optOutQuery.refetch(); }; diff --git a/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersPage.tsx b/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersPage.tsx index 65b053d4bb6e3..bb41cdf79ae3a 100644 --- a/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersPage.tsx +++ b/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersPage.tsx @@ -7,7 +7,6 @@ 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, useContext } from "react"; import { Helmet } from "react-helmet-async"; @@ -116,27 +115,25 @@ export const WorkspaceParametersPageView: FC< }) => { const experimentalFormContext = useContext(ExperimentalFormContext); return ( - <> - - Try out the new workspace parameters ✨ - - ) - } - > - Workspace parameters - +
+
+ +

Workspace parameters

+
+ {experimentalFormContext && ( + + Try out the new workspace parameters ✨ + + )} +
- {submitError && !isApiValidationError(submitError) && ( + {submitError && !isApiValidationError(submitError) ? ( - )} + ) : null} {data ? ( data.templateVersionRichParameters.length > 0 ? ( @@ -177,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 index 90fcbc83185a9..156298be26e13 100644 --- a/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersPageExperimental.tsx +++ b/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersPageExperimental.tsx @@ -12,6 +12,7 @@ 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, @@ -39,21 +40,31 @@ const WorkspaceParametersPageExperimental: FC = () => { const navigate = useNavigate(); const experimentalFormContext = useContext(ExperimentalFormContext); - const [currentResponse, setCurrentResponse] = + const [latestResponse, setLatestResponse] = useState(null); - const [wsResponseId, setWSResponseId] = useState(-1); + const wsResponseId = useRef(-1); const ws = useRef(null); const [wsError, setWsError] = useState(null); - const onMessage = useCallback((response: DynamicParametersResponse) => { - setCurrentResponse((prev) => { - if (prev?.id === response.id) { - return prev; - } - return response; - }); + 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; @@ -63,7 +74,9 @@ const WorkspaceParametersPageExperimental: FC = () => { { onMessage, onError: (error) => { - setWsError(error); + if (ws.current === socket) { + setWsError(error); + } }, onClose: () => { if (ws.current === socket) { @@ -89,20 +102,6 @@ const WorkspaceParametersPageExperimental: FC = () => { onMessage, ]); - const sendMessage = useCallback((formValues: Record) => { - setWSResponseId((prevId) => { - const request: DynamicParametersRequest = { - id: prevId + 1, - inputs: formValues, - }; - if (ws.current && ws.current.readyState === WebSocket.OPEN) { - ws.current.send(JSON.stringify(request)); - return prevId + 1; - } - return prevId; - }); - }, []); - const updateParameters = useMutation({ mutationFn: (buildParameters: WorkspaceBuildParameter[]) => API.postWorkspaceBuild(workspace.id, { @@ -125,12 +124,12 @@ const WorkspaceParametersPageExperimental: FC = () => { const handleSubmit = (values: { rich_parameter_values: WorkspaceBuildParameter[]; }) => { - if (!currentResponse || !currentResponse.parameters) { + if (!latestResponse || !latestResponse.parameters) { return; } // Only submit mutable parameters - const onlyMutableValues = currentResponse.parameters + const onlyMutableValues = latestResponse.parameters .filter((p) => p.mutable) .map((p) => { const value = values.rich_parameter_values.find( @@ -146,16 +145,16 @@ const WorkspaceParametersPageExperimental: FC = () => { }; const sortedParams = useMemo(() => { - if (!currentResponse?.parameters) { + if (!latestResponse?.parameters) { return []; } - return [...currentResponse.parameters].sort((a, b) => a.order - b.order); - }, [currentResponse?.parameters]); + return [...latestResponse.parameters].sort((a, b) => a.order - b.order); + }, [latestResponse?.parameters]); const error = wsError || updateParameters.error; if ( - !currentResponse || + !latestResponse || (ws.current && ws.current.readyState === WebSocket.CONNECTING) ) { return ; @@ -190,7 +189,7 @@ const WorkspaceParametersPageExperimental: FC = () => { workspace={workspace} canChangeVersions={canChangeVersions} parameters={sortedParams} - diagnostics={currentResponse.diagnostics} + diagnostics={latestResponse.diagnostics} isSubmitting={updateParameters.isLoading} onSubmit={handleSubmit} onCancel={() => diff --git a/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersPageViewExperimental.tsx b/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersPageViewExperimental.tsx index 4c0e17515a677..933488a5f6d31 100644 --- a/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersPageViewExperimental.tsx +++ b/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersPageViewExperimental.tsx @@ -1,3 +1,4 @@ +import type * as TypesGen from "api/typesGenerated"; import type { PreviewParameter, Workspace, @@ -7,14 +8,13 @@ import { Alert } from "components/Alert/Alert"; import { Button } from "components/Button/Button"; import { Spinner } from "components/Spinner/Spinner"; import { useFormik } from "formik"; -import { useDebouncedFunction } from "hooks/debounce"; import { DynamicParameter, getInitialParameterValues, useValidationSchemaForDynamicParameters, } from "modules/workspaces/DynamicParameter/DynamicParameter"; import type { FC } from "react"; - +import { useEffect, useRef } from "react"; export type WorkspaceParametersPageViewExperimentalProps = { workspace: Workspace; parameters: PreviewParameter[]; @@ -60,37 +60,17 @@ export const WorkspaceParametersPageViewExperimental: FC< workspace.template_require_active_version && !canChangeVersions; - const { debounced: handleChangeDebounced } = useDebouncedFunction( - async ( - parameter: PreviewParameter, - parameterField: string, - value: string, - ) => { - await form.setFieldValue(parameterField, { - name: parameter.name, - value, - }); - form.setFieldTouched(parameter.name, true); - 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.name, - value, - }); - form.setFieldTouched(parameter.name, true); - sendDynamicParamsRequest(parameter, value); - } + 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 @@ -114,6 +94,12 @@ export const WorkspaceParametersPageViewExperimental: FC< sendMessage(formInputs); }; + useSyncFormParameters({ + parameters, + formValues: form.values.rich_parameter_values ?? [], + setFieldValue: form.setFieldValue, + }); + return ( <> {disabled && ( @@ -171,6 +157,7 @@ export const WorkspaceParametersPageViewExperimental: FC< onChange={(value) => handleChange(parameter, parameterField, value) } + autofill={false} disabled={isDisabled} /> ); @@ -201,6 +188,7 @@ export const WorkspaceParametersPageViewExperimental: FC< onChange={(value) => handleChange(parameter, parameterField, value) } + autofill={false} disabled={isDisabled} /> ); @@ -225,3 +213,52 @@ export const WorkspaceParametersPageViewExperimental: FC< ); }; + +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]); +} From 17a2c8581353cf2d06ccb55b0a5d745dfe5cc79c Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Tue, 20 May 2025 21:24:21 +0000 Subject: [PATCH 4/7] fix: pass form value to dynamic parameter --- .../WorkspaceParametersPageViewExperimental.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersPageViewExperimental.tsx b/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersPageViewExperimental.tsx index 933488a5f6d31..6727f15ea33d6 100644 --- a/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersPageViewExperimental.tsx +++ b/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersPageViewExperimental.tsx @@ -159,6 +159,9 @@ export const WorkspaceParametersPageViewExperimental: FC< } autofill={false} disabled={isDisabled} + value={ + form.values?.rich_parameter_values?.[index]?.value || "" + } /> ); })} @@ -190,6 +193,9 @@ export const WorkspaceParametersPageViewExperimental: FC< } autofill={false} disabled={isDisabled} + value={ + form.values?.rich_parameter_values?.[index]?.value || "" + } /> ); })} From 93359a01d5f62c4e6b7ad020427d4d9d4a04f2a1 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Tue, 20 May 2025 21:57:31 +0000 Subject: [PATCH 5/7] chore: move useSyncFormParameters to shared hook --- site/src/hooks/useSyncFormParameters.ts | 53 +++++++++++++++++++ .../CreateWorkspacePageViewExperimental.tsx | 50 +---------------- ...orkspaceParametersPageViewExperimental.tsx | 52 +----------------- 3 files changed, 55 insertions(+), 100 deletions(-) create mode 100644 site/src/hooks/useSyncFormParameters.ts diff --git a/site/src/hooks/useSyncFormParameters.ts b/site/src/hooks/useSyncFormParameters.ts new file mode 100644 index 0000000000000..71b122df565f8 --- /dev/null +++ b/site/src/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) => { + 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/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx index 630faf8e806d2..6f95a6f92b9b2 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx @@ -19,6 +19,7 @@ import { Spinner } from "components/Spinner/Spinner"; import { Switch } from "components/Switch/Switch"; import { UserAutocomplete } from "components/UserAutocomplete/UserAutocomplete"; import { type FormikContextType, useFormik } from "formik"; +import { useSyncFormParameters } from "hooks/useSyncFormParameters"; import { ArrowLeft, CircleAlert, TriangleAlert } from "lucide-react"; import { DynamicParameter, @@ -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/WorkspaceParametersPageViewExperimental.tsx b/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersPageViewExperimental.tsx index 6727f15ea33d6..130b100829857 100644 --- a/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersPageViewExperimental.tsx +++ b/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersPageViewExperimental.tsx @@ -1,4 +1,3 @@ -import type * as TypesGen from "api/typesGenerated"; import type { PreviewParameter, Workspace, @@ -8,13 +7,13 @@ 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 "hooks/useSyncFormParameters"; import { DynamicParameter, getInitialParameterValues, useValidationSchemaForDynamicParameters, } from "modules/workspaces/DynamicParameter/DynamicParameter"; import type { FC } from "react"; -import { useEffect, useRef } from "react"; export type WorkspaceParametersPageViewExperimentalProps = { workspace: Workspace; parameters: PreviewParameter[]; @@ -219,52 +218,3 @@ export const WorkspaceParametersPageViewExperimental: FC< ); }; - -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]); -} From 82e0b075b3e8dda878f693619e9f3df3be9abe1f Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Wed, 21 May 2025 17:31:15 +0000 Subject: [PATCH 6/7] chore: use Map to improve performance --- site/src/hooks/useSyncFormParameters.ts | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/site/src/hooks/useSyncFormParameters.ts b/site/src/hooks/useSyncFormParameters.ts index 71b122df565f8..58eff68bde121 100644 --- a/site/src/hooks/useSyncFormParameters.ts +++ b/site/src/hooks/useSyncFormParameters.ts @@ -29,22 +29,18 @@ export function useSyncFormParameters({ if (!parameters) return; const currentFormValues = formValuesRef.current; - const newParameterValues = parameters.map((param) => { - return { + 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) => - !currentFormValues.find( - (formValue) => - formValue.name === p.name && formValue.value === p.value, - ), - ); + newParameterValues.some((p) => !currentFormValuesMap.has(p.name) || currentFormValuesMap.get(p.name) !== p.value); if (isChanged) { setFieldValue("rich_parameter_values", newParameterValues); From e827b2c188970895458eaf6b1bd8fa3135948fa0 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Wed, 21 May 2025 17:33:15 +0000 Subject: [PATCH 7/7] chore: move hook inside modules --- site/src/{ => modules}/hooks/useSyncFormParameters.ts | 10 +++++++--- .../CreateWorkspacePageViewExperimental.tsx | 2 +- .../WorkspaceParametersPageViewExperimental.tsx | 2 +- 3 files changed, 9 insertions(+), 5 deletions(-) rename site/src/{ => modules}/hooks/useSyncFormParameters.ts (85%) diff --git a/site/src/hooks/useSyncFormParameters.ts b/site/src/modules/hooks/useSyncFormParameters.ts similarity index 85% rename from site/src/hooks/useSyncFormParameters.ts rename to site/src/modules/hooks/useSyncFormParameters.ts index 58eff68bde121..4f6952331eaaf 100644 --- a/site/src/hooks/useSyncFormParameters.ts +++ b/site/src/modules/hooks/useSyncFormParameters.ts @@ -30,8 +30,8 @@ export function useSyncFormParameters({ const currentFormValues = formValuesRef.current; const newParameterValues = parameters.map((param) => ({ - name: param.name, - value: param.value.valid ? param.value.value : "", + name: param.name, + value: param.value.valid ? param.value.value : "", })); const currentFormValuesMap = new Map( @@ -40,7 +40,11 @@ export function useSyncFormParameters({ const isChanged = currentFormValues.length !== newParameterValues.length || - newParameterValues.some((p) => !currentFormValuesMap.has(p.name) || currentFormValuesMap.get(p.name) !== p.value); + newParameterValues.some( + (p) => + !currentFormValuesMap.has(p.name) || + currentFormValuesMap.get(p.name) !== p.value, + ); if (isChanged) { setFieldValue("rich_parameter_values", newParameterValues); diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx index 6f95a6f92b9b2..d29955ea3aa7d 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx @@ -19,8 +19,8 @@ import { Spinner } from "components/Spinner/Spinner"; import { Switch } from "components/Switch/Switch"; import { UserAutocomplete } from "components/UserAutocomplete/UserAutocomplete"; import { type FormikContextType, useFormik } from "formik"; -import { useSyncFormParameters } from "hooks/useSyncFormParameters"; import { ArrowLeft, CircleAlert, TriangleAlert } from "lucide-react"; +import { useSyncFormParameters } from "modules/hooks/useSyncFormParameters"; import { DynamicParameter, getInitialParameterValues, diff --git a/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersPageViewExperimental.tsx b/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersPageViewExperimental.tsx index 130b100829857..0fcae58c5ffe6 100644 --- a/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersPageViewExperimental.tsx +++ b/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersPageViewExperimental.tsx @@ -7,7 +7,7 @@ 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 "hooks/useSyncFormParameters"; +import { useSyncFormParameters } from "modules/hooks/useSyncFormParameters"; import { DynamicParameter, getInitialParameterValues,