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)
This commit is contained in:
Jaayden Halko
2025-05-21 18:48:35 +01:00
committed by GitHub
parent 3a6d5f5bba
commit cb7ce18592
7 changed files with 607 additions and 61 deletions
@@ -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]);
}
@@ -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,
@@ -656,52 +657,3 @@ const Diagnostics: FC<DiagnosticsProps> = ({ diagnostics }) => {
</div>
);
};
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]);
}
@@ -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 <Loader />;
}
if (!optOutQuery.data) {
return <ErrorAlert error={optOutQuery.error} />;
}
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 (
<ExperimentalFormContext.Provider value={{ toggleOptedOut }}>
{optOutQuery.data.optedOut ? (
<WorkspaceParametersPage />
) : (
<WorkspaceParametersPageExperimental />
)}
</ExperimentalFormContext.Provider>
);
}
return <WorkspaceParametersPage />;
};
export default WorkspaceParametersExperimentRouter;
const optOutKey = (id: string) => `parameters.${id}.optOut`;
@@ -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 (
<>
<PageHeader css={{ paddingTop: 0 }}>
<PageHeaderTitle>Workspace parameters</PageHeaderTitle>
</PageHeader>
<div className="flex flex-col gap-10">
<header className="flex flex-col items-start gap-2">
<span className="flex flex-row justify-between items-center gap-2">
<h1 className="text-3xl m-0">Workspace parameters</h1>
</span>
{experimentalFormContext && (
<ShadcnButton
size="sm"
variant="outline"
onClick={experimentalFormContext.toggleOptedOut}
>
Try out the new workspace parameters
</ShadcnButton>
)}
</header>
{submitError && !isApiValidationError(submitError) && (
{submitError && !isApiValidationError(submitError) ? (
<ErrorAlert error={submitError} css={{ marginBottom: 48 }} />
)}
) : null}
{data ? (
data.templateVersionRichParameters.length > 0 ? (
@@ -161,7 +174,7 @@ export const WorkspaceParametersPageView: FC<
) : (
<Loader />
)}
</>
</div>
);
};
@@ -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<DynamicParametersResponse | null>(null);
const wsResponseId = useRef<number>(-1);
const ws = useRef<WebSocket | null>(null);
const [wsError, setWsError] = useState<Error | null>(null);
const sendMessage = useCallback((formValues: Record<string, string>) => {
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 <Loader />;
}
return (
<div className="flex flex-col gap-6 max-w-screen-md mx-auto">
<Helmet>
<title>{pageTitle(workspace.name, "Parameters")}</title>
</Helmet>
<header className="flex flex-col items-start gap-2">
<span className="flex flex-row items-center gap-2">
<h1 className="text-3xl m-0">Workspace parameters</h1>
<FeatureStageBadge contentType={"beta"} />
</span>
{experimentalFormContext && (
<Button
size="sm"
variant="outline"
onClick={experimentalFormContext.toggleOptedOut}
>
Go back to the classic workspace parameters view
</Button>
)}
</header>
{Boolean(error) && <ErrorAlert error={error} />}
{sortedParams.length > 0 ? (
<WorkspaceParametersPageViewExperimental
workspace={workspace}
canChangeVersions={canChangeVersions}
parameters={sortedParams}
diagnostics={latestResponse.diagnostics}
isSubmitting={updateParameters.isLoading}
onSubmit={handleSubmit}
onCancel={() =>
navigate(`/@${workspace.owner_name}/${workspace.name}`)
}
sendMessage={sendMessage}
/>
) : (
<EmptyState
className="border border-border border-solid rounded-md"
message="This workspace has no parameters"
cta={
<Link
href={docs("/admin/templates/extending-templates/parameters")}
>
Learn more about parameters
</Link>
}
/>
)}
</div>
);
};
export default WorkspaceParametersPageExperimental;
@@ -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<string, string>) => 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<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);
};
useSyncFormParameters({
parameters,
formValues: form.values.rich_parameter_values ?? [],
setFieldValue: form.setFieldValue,
});
return (
<>
{disabled && (
<Alert severity="warning" className="mb-8">
The template for this workspace requires automatic updates. Update the
workspace to edit parameters.
</Alert>
)}
{diagnostics && diagnostics.length > 0 && (
<div className="flex flex-col gap-4 mb-8">
{diagnostics.map((diagnostic, index) => (
<div
key={`diagnostic-${diagnostic.summary}-${index}`}
className={`text-xs flex flex-col rounded-md border px-4 pb-3 border-solid
${
diagnostic.severity === "error"
? " text-content-destructive border-border-destructive"
: " text-content-warning border-border-warning"
}`}
>
<div className="flex items-center m-0">
<p className="font-medium">{diagnostic.summary}</p>
</div>
{diagnostic.detail && (
<p className="m-0 pb-0">{diagnostic.detail}</p>
)}
</div>
))}
</div>
)}
<form onSubmit={form.handleSubmit} className="flex flex-col gap-8">
{standardParameters.length > 0 && (
<section className="flex flex-col gap-9">
<hgroup>
<h2 className="text-xl font-medium mb-0">Parameters</h2>
<p className="text-sm text-content-secondary m-0">
These are the settings used by your template. Immutable
parameters cannot be modified once the workspace is created.
</p>
</hgroup>
{standardParameters.map((parameter, index) => {
const parameterField = `rich_parameter_values.${index}`;
const isDisabled =
disabled ||
parameter.styling?.disabled ||
!parameter.mutable ||
isSubmitting;
return (
<DynamicParameter
key={parameter.name}
parameter={parameter}
onChange={(value) =>
handleChange(parameter, parameterField, value)
}
autofill={false}
disabled={isDisabled}
value={
form.values?.rich_parameter_values?.[index]?.value || ""
}
/>
);
})}
</section>
)}
{ephemeralParameters.length > 0 && (
<section className="flex flex-col gap-6">
<hgroup>
<h2 className="text-xl font-medium mb-1">Ephemeral Parameters</h2>
<p className="text-sm text-content-secondary m-0">
These parameters only apply for a single workspace start
</p>
</hgroup>
<div className="flex flex-col gap-9">
{ephemeralParameters.map((parameter, index) => {
const actualIndex = standardParameters.length + index;
const parameterField = `rich_parameter_values.${actualIndex}`;
const isDisabled =
disabled || parameter.styling?.disabled || isSubmitting;
return (
<DynamicParameter
key={parameter.name}
parameter={parameter}
onChange={(value) =>
handleChange(parameter, parameterField, value)
}
autofill={false}
disabled={isDisabled}
value={
form.values?.rich_parameter_values?.[index]?.value || ""
}
/>
);
})}
</div>
</section>
)}
<div className="flex justify-end gap-2">
<Button onClick={onCancel} variant="outline">
Cancel
</Button>
<Button
type="submit"
disabled={isSubmitting || disabled || !form.dirty}
>
<Spinner loading={isSubmitting} />
Submit and restart
</Button>
</div>
</form>
</>
);
};
+6 -3
View File
@@ -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={<WorkspaceSettingsLayout />}
>
<Route index element={<WorkspaceSettingsPage />} />
<Route path="parameters" element={<WorkspaceParametersPage />} />
<Route
path="parameters"
element={<WorkspaceParametersExperimentRouter />}
/>
<Route path="schedule" element={<WorkspaceSchedulePage />} />
</Route>