mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
feat: add experimental workspace parameters page for dynamic params (#17841)

This commit is contained in:
@@ -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]);
|
||||
}
|
||||
|
||||
+88
@@ -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`;
|
||||
+22
-9
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
+217
@@ -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;
|
||||
+220
@@ -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
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user