Skip to content

Commit cb7ce18

Browse files
authored
feat: add experimental workspace parameters page for dynamic params (#17841)
![Screenshot 2025-05-20 at 22 26 40](https://github.com/user-attachments/assets/639441d7-2349-4c92-a4ee-d8a5a724fe8e)
1 parent 3a6d5f5 commit cb7ce18

File tree

7 files changed

+607
-61
lines changed

7 files changed

+607
-61
lines changed
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import type * as TypesGen from "api/typesGenerated";
2+
import { useEffect, useRef } from "react";
3+
4+
import type { PreviewParameter } from "api/typesGenerated";
5+
6+
type UseSyncFormParametersProps = {
7+
parameters: readonly PreviewParameter[];
8+
formValues: readonly TypesGen.WorkspaceBuildParameter[];
9+
setFieldValue: (
10+
field: string,
11+
value: TypesGen.WorkspaceBuildParameter[],
12+
) => void;
13+
};
14+
15+
export function useSyncFormParameters({
16+
parameters,
17+
formValues,
18+
setFieldValue,
19+
}: UseSyncFormParametersProps) {
20+
// Form values only needs to be updated when parameters change
21+
// Keep track of form values in a ref to avoid unnecessary updates to rich_parameter_values
22+
const formValuesRef = useRef(formValues);
23+
24+
useEffect(() => {
25+
formValuesRef.current = formValues;
26+
}, [formValues]);
27+
28+
useEffect(() => {
29+
if (!parameters) return;
30+
const currentFormValues = formValuesRef.current;
31+
32+
const newParameterValues = parameters.map((param) => ({
33+
name: param.name,
34+
value: param.value.valid ? param.value.value : "",
35+
}));
36+
37+
const currentFormValuesMap = new Map(
38+
currentFormValues.map((value) => [value.name, value.value]),
39+
);
40+
41+
const isChanged =
42+
currentFormValues.length !== newParameterValues.length ||
43+
newParameterValues.some(
44+
(p) =>
45+
!currentFormValuesMap.has(p.name) ||
46+
currentFormValuesMap.get(p.name) !== p.value,
47+
);
48+
49+
if (isChanged) {
50+
setFieldValue("rich_parameter_values", newParameterValues);
51+
}
52+
}, [parameters, setFieldValue]);
53+
}

site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx

Lines changed: 1 addition & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { Switch } from "components/Switch/Switch";
2020
import { UserAutocomplete } from "components/UserAutocomplete/UserAutocomplete";
2121
import { type FormikContextType, useFormik } from "formik";
2222
import { ArrowLeft, CircleAlert, TriangleAlert } from "lucide-react";
23+
import { useSyncFormParameters } from "modules/hooks/useSyncFormParameters";
2324
import {
2425
DynamicParameter,
2526
getInitialParameterValues,
@@ -656,52 +657,3 @@ const Diagnostics: FC<DiagnosticsProps> = ({ diagnostics }) => {
656657
</div>
657658
);
658659
};
659-
660-
type UseSyncFormParametersProps = {
661-
parameters: readonly PreviewParameter[];
662-
formValues: readonly TypesGen.WorkspaceBuildParameter[];
663-
setFieldValue: (
664-
field: string,
665-
value: TypesGen.WorkspaceBuildParameter[],
666-
) => void;
667-
};
668-
669-
function useSyncFormParameters({
670-
parameters,
671-
formValues,
672-
setFieldValue,
673-
}: UseSyncFormParametersProps) {
674-
// Form values only needs to be updated when parameters change
675-
// Keep track of form values in a ref to avoid unnecessary updates to rich_parameter_values
676-
const formValuesRef = useRef(formValues);
677-
678-
useEffect(() => {
679-
formValuesRef.current = formValues;
680-
}, [formValues]);
681-
682-
useEffect(() => {
683-
if (!parameters) return;
684-
const currentFormValues = formValuesRef.current;
685-
686-
const newParameterValues = parameters.map((param) => {
687-
return {
688-
name: param.name,
689-
value: param.value.valid ? param.value.value : "",
690-
};
691-
});
692-
693-
const isChanged =
694-
currentFormValues.length !== newParameterValues.length ||
695-
newParameterValues.some(
696-
(p) =>
697-
!currentFormValues.find(
698-
(formValue) =>
699-
formValue.name === p.name && formValue.value === p.value,
700-
),
701-
);
702-
703-
if (isChanged) {
704-
setFieldValue("rich_parameter_values", newParameterValues);
705-
}
706-
}, [parameters, setFieldValue]);
707-
}
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import { ErrorAlert } from "components/Alert/ErrorAlert";
2+
import { Loader } from "components/Loader/Loader";
3+
import { useDashboard } from "modules/dashboard/useDashboard";
4+
import type { FC } from "react";
5+
import { useQuery } from "react-query";
6+
import { ExperimentalFormContext } from "../../CreateWorkspacePage/ExperimentalFormContext";
7+
import { useWorkspaceSettings } from "../WorkspaceSettingsLayout";
8+
import WorkspaceParametersPage from "./WorkspaceParametersPage";
9+
import WorkspaceParametersPageExperimental from "./WorkspaceParametersPageExperimental";
10+
11+
const WorkspaceParametersExperimentRouter: FC = () => {
12+
const { experiments } = useDashboard();
13+
const workspace = useWorkspaceSettings();
14+
const dynamicParametersEnabled = experiments.includes("dynamic-parameters");
15+
16+
const optOutQuery = useQuery(
17+
dynamicParametersEnabled
18+
? {
19+
queryKey: [
20+
"workspace",
21+
workspace.id,
22+
"template_id",
23+
workspace.template_id,
24+
"optOut",
25+
],
26+
queryFn: () => {
27+
const templateId = workspace.template_id;
28+
const workspaceId = workspace.id;
29+
const localStorageKey = optOutKey(templateId);
30+
const storedOptOutString = localStorage.getItem(localStorageKey);
31+
32+
let optOutResult: boolean;
33+
34+
if (storedOptOutString !== null) {
35+
optOutResult = storedOptOutString === "true";
36+
} else {
37+
optOutResult = Boolean(
38+
workspace.template_use_classic_parameter_flow,
39+
);
40+
}
41+
42+
return {
43+
templateId,
44+
workspaceId,
45+
optedOut: optOutResult,
46+
};
47+
},
48+
}
49+
: { enabled: false },
50+
);
51+
52+
if (dynamicParametersEnabled) {
53+
if (optOutQuery.isLoading) {
54+
return <Loader />;
55+
}
56+
if (!optOutQuery.data) {
57+
return <ErrorAlert error={optOutQuery.error} />;
58+
}
59+
60+
const toggleOptedOut = () => {
61+
const key = optOutKey(optOutQuery.data.templateId);
62+
const storedValue = localStorage.getItem(key);
63+
64+
const current = storedValue
65+
? storedValue === "true"
66+
: Boolean(workspace.template_use_classic_parameter_flow);
67+
68+
localStorage.setItem(key, (!current).toString());
69+
optOutQuery.refetch();
70+
};
71+
72+
return (
73+
<ExperimentalFormContext.Provider value={{ toggleOptedOut }}>
74+
{optOutQuery.data.optedOut ? (
75+
<WorkspaceParametersPage />
76+
) : (
77+
<WorkspaceParametersPageExperimental />
78+
)}
79+
</ExperimentalFormContext.Provider>
80+
);
81+
}
82+
83+
return <WorkspaceParametersPage />;
84+
};
85+
86+
export default WorkspaceParametersExperimentRouter;
87+
88+
const optOutKey = (id: string) => `parameters.${id}.optOut`;

site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersPage.tsx

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,11 @@ import { isApiValidationError } from "api/errors";
44
import { checkAuthorization } from "api/queries/authCheck";
55
import type { Workspace, WorkspaceBuildParameter } from "api/typesGenerated";
66
import { ErrorAlert } from "components/Alert/ErrorAlert";
7+
import { Button as ShadcnButton } from "components/Button/Button";
78
import { EmptyState } from "components/EmptyState/EmptyState";
89
import { Loader } from "components/Loader/Loader";
9-
import { PageHeader, PageHeaderTitle } from "components/PageHeader/PageHeader";
1010
import { ExternalLinkIcon } from "lucide-react";
11-
import type { FC } from "react";
11+
import { type FC, useContext } from "react";
1212
import { Helmet } from "react-helmet-async";
1313
import { useMutation, useQuery } from "react-query";
1414
import { useNavigate } from "react-router-dom";
@@ -18,6 +18,7 @@ import {
1818
type WorkspacePermissions,
1919
workspaceChecks,
2020
} from "../../../modules/workspaces/permissions";
21+
import { ExperimentalFormContext } from "../../CreateWorkspacePage/ExperimentalFormContext";
2122
import { useWorkspaceSettings } from "../WorkspaceSettingsLayout";
2223
import {
2324
WorkspaceParametersForm,
@@ -112,15 +113,27 @@ export const WorkspaceParametersPageView: FC<
112113
isSubmitting,
113114
onCancel,
114115
}) => {
116+
const experimentalFormContext = useContext(ExperimentalFormContext);
115117
return (
116-
<>
117-
<PageHeader css={{ paddingTop: 0 }}>
118-
<PageHeaderTitle>Workspace parameters</PageHeaderTitle>
119-
</PageHeader>
118+
<div className="flex flex-col gap-10">
119+
<header className="flex flex-col items-start gap-2">
120+
<span className="flex flex-row justify-between items-center gap-2">
121+
<h1 className="text-3xl m-0">Workspace parameters</h1>
122+
</span>
123+
{experimentalFormContext && (
124+
<ShadcnButton
125+
size="sm"
126+
variant="outline"
127+
onClick={experimentalFormContext.toggleOptedOut}
128+
>
129+
Try out the new workspace parameters ✨
130+
</ShadcnButton>
131+
)}
132+
</header>
120133

121-
{submitError && !isApiValidationError(submitError) && (
134+
{submitError && !isApiValidationError(submitError) ? (
122135
<ErrorAlert error={submitError} css={{ marginBottom: 48 }} />
123-
)}
136+
) : null}
124137

125138
{data ? (
126139
data.templateVersionRichParameters.length > 0 ? (
@@ -161,7 +174,7 @@ export const WorkspaceParametersPageView: FC<
161174
) : (
162175
<Loader />
163176
)}
164-
</>
177+
</div>
165178
);
166179
};
167180

0 commit comments

Comments
 (0)