mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
feat: add ephemeral parameter dialog for workspace start/restart (#18413)
resolves #17709 FYI, blink created a first draft which was heavily modified. ## Summary This PR implements ephemeral parameter handling for workspace start/restart operations when templates use dynamic parameters (`use_classic_parameter_flow = false`). <img width="522" alt="Screenshot 2025-06-18 at 14 35 54" src="https://github.com/user-attachments/assets/450527c0-cc88-4fc3-b0fa-170bdeb5ea51" /> <img width="327" alt="Screenshot 2025-06-18 at 14 35 43" src="https://github.com/user-attachments/assets/ea74bf8e-d127-489d-b406-edfc5ec1e9a8" />  ## Changes ### 1. EphemeralParametersDialog Component - **New**: `site/src/components/EphemeralParametersDialog/` - Shows a dialog when starting/restarting workspaces with ephemeral parameters - Lists ephemeral parameters with names and descriptions - Provides options to continue without setting values or navigate to parameters page ### 2. WorkspaceReadyPage Updates - Added `checkEphemeralParameters()` function using `API.getDynamicParameters` - Modified `handleStart` and `handleRestart` to check for ephemeral parameters - Only triggers for templates with `use_classic_parameter_flow = false` - Shows dialog if ephemeral parameters exist, otherwise proceeds normally ### 3. BuildParametersPopover Updates - Added special UI for non-classic parameter flow templates with ephemeral parameters - Lists ephemeral parameters with descriptions - Explains that users must use the workspace parameters page - Provides direct link to `WorkspaceParametersPageExperimental` --------- Co-authored-by: blink-so[bot] <211532188+blink-so[bot]@users.noreply.github.com> Co-authored-by: jaaydenh <1858163+jaaydenh@users.noreply.github.com> Co-authored-by: Jaayden Halko <jaayden@coder.com>
This commit is contained in:
@@ -22,6 +22,8 @@ const badgeVariants = cva(
|
||||
"border border-solid border-border-warning bg-surface-orange text-content-warning shadow",
|
||||
destructive:
|
||||
"border border-solid border-border-destructive bg-surface-red text-highlight-red shadow",
|
||||
green:
|
||||
"border border-solid border-surface-green bg-surface-green text-highlight-green shadow",
|
||||
},
|
||||
size: {
|
||||
xs: "text-2xs font-regular h-5 [&_svg]:hidden rounded px-1.5",
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { TemplateVersionParameter } from "api/typesGenerated";
|
||||
import { Button } from "components/Button/Button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "components/Dialog/Dialog";
|
||||
import type { FC } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
interface EphemeralParametersDialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onContinue: () => void;
|
||||
ephemeralParameters: TemplateVersionParameter[];
|
||||
workspaceOwner: string;
|
||||
workspaceName: string;
|
||||
templateVersionId: string;
|
||||
}
|
||||
|
||||
export const EphemeralParametersDialog: FC<EphemeralParametersDialogProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
onContinue,
|
||||
ephemeralParameters,
|
||||
workspaceOwner,
|
||||
workspaceName,
|
||||
templateVersionId,
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleGoToParameters = () => {
|
||||
onClose();
|
||||
navigate(
|
||||
`/@${workspaceOwner}/${workspaceName}/settings/parameters?templateVersionId=${templateVersionId}`,
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Ephemeral Parameters Detected</DialogTitle>
|
||||
<DialogDescription>
|
||||
This workspace template has{" "}
|
||||
<strong className="text-content-primary">
|
||||
{ephemeralParameters.length}
|
||||
</strong>{" "}
|
||||
ephemeral parameters that will be reset to their default values
|
||||
</DialogDescription>
|
||||
<DialogDescription>
|
||||
<ul className="list-none pl-6 space-y-2">
|
||||
{ephemeralParameters.map((param) => (
|
||||
<li key={param.name}>
|
||||
<p className="text-content-primary m-0 font-bold">
|
||||
{param.display_name || param.name}
|
||||
</p>
|
||||
{param.description && (
|
||||
<p className="m-0 text-sm text-content-secondary">
|
||||
{param.description}
|
||||
</p>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</DialogDescription>
|
||||
<DialogDescription>
|
||||
Would you like to go to the workspace parameters page to review and
|
||||
update these parameters before continuing?
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button onClick={onContinue} variant="outline">
|
||||
Continue
|
||||
</Button>
|
||||
<Button onClick={handleGoToParameters}>
|
||||
Go to workspace parameters
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -211,6 +211,15 @@ export const Immutable: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
export const Ephemeral: Story = {
|
||||
args: {
|
||||
parameter: {
|
||||
...MockPreviewParameter,
|
||||
ephemeral: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const AllBadges: Story = {
|
||||
args: {
|
||||
parameter: {
|
||||
|
||||
@@ -36,6 +36,7 @@ import { useDebouncedValue } from "hooks/debounce";
|
||||
import { useEffectEvent } from "hooks/hookPolyfills";
|
||||
import {
|
||||
CircleAlert,
|
||||
Hourglass,
|
||||
Info,
|
||||
LinkIcon,
|
||||
Settings,
|
||||
@@ -162,6 +163,23 @@ const ParameterLabel: FC<ParameterLabelProps> = ({
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
{parameter.ephemeral && (
|
||||
<TooltipProvider delayDuration={100}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="flex items-center">
|
||||
<Badge size="sm" variant="green" border="none">
|
||||
<Hourglass />
|
||||
Ephemeral
|
||||
</Badge>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs">
|
||||
This parameter only applies for a single workspace start
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
{isPreset && (
|
||||
<TooltipProvider delayDuration={100}>
|
||||
<Tooltip>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useTheme } from "@emotion/react";
|
||||
import Button from "@mui/material/Button";
|
||||
import visuallyHidden from "@mui/utils/visuallyHidden";
|
||||
import { API } from "api/api";
|
||||
import type {
|
||||
@@ -7,6 +6,7 @@ import type {
|
||||
Workspace,
|
||||
WorkspaceBuildParameter,
|
||||
} from "api/typesGenerated";
|
||||
import { Button } from "components/Button/Button";
|
||||
import { FormFields } from "components/Form/Form";
|
||||
import { TopbarButton } from "components/FullPageLayout/Topbar";
|
||||
import {
|
||||
@@ -27,6 +27,7 @@ import { useFormik } from "formik";
|
||||
import { ChevronDownIcon } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { useQuery } from "react-query";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { docs } from "utils/docs";
|
||||
import { getFormHelpers } from "utils/formUtils";
|
||||
import {
|
||||
@@ -72,6 +73,7 @@ export const BuildParametersPopover: FC<BuildParametersPopoverProps> = ({
|
||||
css={{ ".MuiPaper-root": { width: 304 } }}
|
||||
>
|
||||
<BuildParametersPopoverContent
|
||||
workspace={workspace}
|
||||
ephemeralParameters={ephemeralParameters}
|
||||
buildParameters={parameters?.buildParameters}
|
||||
onSubmit={onSubmit}
|
||||
@@ -82,18 +84,67 @@ export const BuildParametersPopover: FC<BuildParametersPopoverProps> = ({
|
||||
};
|
||||
|
||||
interface BuildParametersPopoverContentProps {
|
||||
workspace: Workspace;
|
||||
ephemeralParameters?: TemplateVersionParameter[];
|
||||
buildParameters?: WorkspaceBuildParameter[];
|
||||
onSubmit: (buildParameters: WorkspaceBuildParameter[]) => void;
|
||||
}
|
||||
|
||||
const BuildParametersPopoverContent: FC<BuildParametersPopoverContentProps> = ({
|
||||
workspace,
|
||||
ephemeralParameters,
|
||||
buildParameters,
|
||||
onSubmit,
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const popover = usePopover();
|
||||
const navigate = useNavigate();
|
||||
|
||||
if (
|
||||
!workspace.template_use_classic_parameter_flow &&
|
||||
ephemeralParameters &&
|
||||
ephemeralParameters.length > 0
|
||||
) {
|
||||
const handleGoToParameters = () => {
|
||||
popover.setOpen(false);
|
||||
navigate(
|
||||
`/@${workspace.owner_name}/${workspace.name}/settings/parameters`,
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 p-5">
|
||||
<h1 className="text-xl m-0 text-content-primary font-semibold leading-none ">
|
||||
Ephemeral Parameters
|
||||
</h1>
|
||||
<p className="m-0 text-sm text-content-secondary">
|
||||
This template has ephemeral parameters that must be configured on the
|
||||
workspace parameters page
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<ul className="list-none pl-3 space-y-2">
|
||||
{ephemeralParameters.map((param) => (
|
||||
<li key={param.name}>
|
||||
<p className="text-content-primary m-0 font-bold">
|
||||
{param.display_name || param.name}
|
||||
</p>
|
||||
{param.description && (
|
||||
<p className="m-0 text-sm text-content-secondary">
|
||||
{param.description}
|
||||
</p>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<Button className="w-full" onClick={handleGoToParameters}>
|
||||
Go to workspace parameters
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -206,8 +257,6 @@ const Form: FC<FormProps> = ({
|
||||
<Button
|
||||
data-testid="build-parameters-submit"
|
||||
type="submit"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
css={{ width: "100%" }}
|
||||
>
|
||||
Build workspace
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
ConfirmDialog,
|
||||
type ConfirmDialogProps,
|
||||
} from "components/Dialogs/ConfirmDialog/ConfirmDialog";
|
||||
import { EphemeralParametersDialog } from "components/EphemeralParametersDialog/EphemeralParametersDialog";
|
||||
import { displayError } from "components/GlobalSnackbar/utils";
|
||||
import { useWorkspaceBuildLogs } from "hooks/useWorkspaceBuildLogs";
|
||||
import {
|
||||
@@ -53,6 +54,13 @@ export const WorkspaceReadyPage: FC<WorkspaceReadyPageProps> = ({
|
||||
open: boolean;
|
||||
buildParameters?: TypesGen.WorkspaceBuildParameter[];
|
||||
}>({ open: false });
|
||||
|
||||
const [ephemeralParametersDialog, setEphemeralParametersDialog] = useState<{
|
||||
open: boolean;
|
||||
action: "start" | "restart";
|
||||
buildParameters?: TypesGen.WorkspaceBuildParameter[];
|
||||
ephemeralParameters: TypesGen.TemplateVersionParameter[];
|
||||
}>({ open: false, action: "start", ephemeralParameters: [] });
|
||||
const { mutate: mutateRestartWorkspace, isPending: isRestarting } =
|
||||
useMutation({
|
||||
mutationFn: API.restartWorkspace,
|
||||
@@ -137,38 +145,76 @@ export const WorkspaceReadyPage: FC<WorkspaceReadyPageProps> = ({
|
||||
},
|
||||
});
|
||||
|
||||
const runLastBuild = (
|
||||
const checkEphemeralParameters = async (
|
||||
buildParameters?: TypesGen.WorkspaceBuildParameter[],
|
||||
) => {
|
||||
if (workspace.template_use_classic_parameter_flow) {
|
||||
return { hasEphemeral: false, ephemeralParameters: [] };
|
||||
}
|
||||
|
||||
try {
|
||||
const dynamicParameters = await API.getDynamicParameters(
|
||||
workspace.latest_build.template_version_id,
|
||||
workspace.owner_id,
|
||||
buildParameters || [],
|
||||
);
|
||||
|
||||
const ephemeralParameters = dynamicParameters.filter(
|
||||
(param) => param.ephemeral,
|
||||
);
|
||||
|
||||
return {
|
||||
hasEphemeral: ephemeralParameters.length > 0,
|
||||
ephemeralParameters,
|
||||
};
|
||||
} catch (error) {
|
||||
return { hasEphemeral: false, ephemeralParameters: [] };
|
||||
}
|
||||
};
|
||||
|
||||
const runLastBuild = async (
|
||||
buildParameters: TypesGen.WorkspaceBuildParameter[] | undefined,
|
||||
debug: boolean,
|
||||
) => {
|
||||
const logLevel = debug ? "debug" : undefined;
|
||||
|
||||
switch (workspace.latest_build.transition) {
|
||||
case "start":
|
||||
startWorkspaceMutation.mutate({
|
||||
logLevel,
|
||||
buildParameters,
|
||||
});
|
||||
break;
|
||||
case "stop":
|
||||
stopWorkspaceMutation.mutate({ logLevel });
|
||||
break;
|
||||
case "delete":
|
||||
deleteWorkspaceMutation.mutate({ log_level: logLevel });
|
||||
break;
|
||||
const { hasEphemeral, ephemeralParameters } =
|
||||
await checkEphemeralParameters(buildParameters);
|
||||
if (hasEphemeral) {
|
||||
setEphemeralParametersDialog({
|
||||
open: true,
|
||||
action: "start",
|
||||
buildParameters,
|
||||
ephemeralParameters,
|
||||
});
|
||||
} else {
|
||||
switch (workspace.latest_build.transition) {
|
||||
case "start":
|
||||
startWorkspaceMutation.mutate({
|
||||
logLevel,
|
||||
buildParameters,
|
||||
});
|
||||
break;
|
||||
case "stop":
|
||||
stopWorkspaceMutation.mutate({ logLevel });
|
||||
break;
|
||||
case "delete":
|
||||
deleteWorkspaceMutation.mutate({ log_level: logLevel });
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleRetry = (
|
||||
const handleRetry = async (
|
||||
buildParameters?: TypesGen.WorkspaceBuildParameter[],
|
||||
) => {
|
||||
runLastBuild(buildParameters, false);
|
||||
await runLastBuild(buildParameters, false);
|
||||
};
|
||||
|
||||
const handleDebug = (
|
||||
const handleDebug = async (
|
||||
buildParameters?: TypesGen.WorkspaceBuildParameter[],
|
||||
) => {
|
||||
runLastBuild(buildParameters, true);
|
||||
await runLastBuild(buildParameters, true);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -196,14 +242,36 @@ export const WorkspaceReadyPage: FC<WorkspaceReadyPageProps> = ({
|
||||
template={template}
|
||||
buildLogs={buildLogs}
|
||||
timings={timingsQuery.data}
|
||||
handleStart={(buildParameters) => {
|
||||
startWorkspaceMutation.mutate({ buildParameters });
|
||||
handleStart={async (buildParameters) => {
|
||||
const { hasEphemeral, ephemeralParameters } =
|
||||
await checkEphemeralParameters(buildParameters);
|
||||
if (hasEphemeral) {
|
||||
setEphemeralParametersDialog({
|
||||
open: true,
|
||||
action: "start",
|
||||
buildParameters,
|
||||
ephemeralParameters,
|
||||
});
|
||||
} else {
|
||||
startWorkspaceMutation.mutate({ buildParameters });
|
||||
}
|
||||
}}
|
||||
handleStop={() => {
|
||||
stopWorkspaceMutation.mutate({});
|
||||
}}
|
||||
handleRestart={(buildParameters) => {
|
||||
setConfirmingRestart({ open: true, buildParameters });
|
||||
handleRestart={async (buildParameters) => {
|
||||
const { hasEphemeral, ephemeralParameters } =
|
||||
await checkEphemeralParameters(buildParameters);
|
||||
if (hasEphemeral) {
|
||||
setEphemeralParametersDialog({
|
||||
open: true,
|
||||
action: "restart",
|
||||
buildParameters,
|
||||
ephemeralParameters,
|
||||
});
|
||||
} else {
|
||||
setConfirmingRestart({ open: true, buildParameters });
|
||||
}
|
||||
}}
|
||||
handleUpdate={workspaceUpdate.update}
|
||||
handleCancel={cancelBuildMutation.mutate}
|
||||
@@ -242,6 +310,36 @@ export const WorkspaceReadyPage: FC<WorkspaceReadyPageProps> = ({
|
||||
}
|
||||
/>
|
||||
|
||||
<EphemeralParametersDialog
|
||||
open={ephemeralParametersDialog.open}
|
||||
onClose={() =>
|
||||
setEphemeralParametersDialog({
|
||||
...ephemeralParametersDialog,
|
||||
open: false,
|
||||
})
|
||||
}
|
||||
onContinue={() => {
|
||||
if (ephemeralParametersDialog.action === "start") {
|
||||
startWorkspaceMutation.mutate({
|
||||
buildParameters: ephemeralParametersDialog.buildParameters,
|
||||
});
|
||||
} else {
|
||||
setConfirmingRestart({
|
||||
open: true,
|
||||
buildParameters: ephemeralParametersDialog.buildParameters,
|
||||
});
|
||||
}
|
||||
setEphemeralParametersDialog({
|
||||
...ephemeralParametersDialog,
|
||||
open: false,
|
||||
});
|
||||
}}
|
||||
ephemeralParameters={ephemeralParametersDialog.ephemeralParameters}
|
||||
workspaceOwner={workspace.owner_name}
|
||||
workspaceName={workspace.name}
|
||||
templateVersionId={workspace.latest_build.template_version_id}
|
||||
/>
|
||||
|
||||
<WorkspaceUpdateDialogs {...workspaceUpdate.dialogs} />
|
||||
</>
|
||||
);
|
||||
|
||||
+2
-40
@@ -74,9 +74,6 @@ export const WorkspaceParametersPageViewExperimental: FC<
|
||||
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 &&
|
||||
@@ -204,7 +201,7 @@ export const WorkspaceParametersPageViewExperimental: FC<
|
||||
)}
|
||||
|
||||
<form onSubmit={form.handleSubmit} className="flex flex-col gap-8">
|
||||
{standardParameters.length > 0 && (
|
||||
{parameters.length > 0 && (
|
||||
<section className="flex flex-col gap-9">
|
||||
<hgroup>
|
||||
<h2 className="text-xl font-medium mb-0">Parameters</h2>
|
||||
@@ -220,7 +217,7 @@ export const WorkspaceParametersPageViewExperimental: FC<
|
||||
</Link>
|
||||
</p>
|
||||
</hgroup>
|
||||
{standardParameters.map((parameter, index) => {
|
||||
{parameters.map((parameter, index) => {
|
||||
const currentParameterValueIndex =
|
||||
form.values.rich_parameter_values?.findIndex(
|
||||
(p) => p.name === parameter.name,
|
||||
@@ -260,41 +257,6 @@ export const WorkspaceParametersPageViewExperimental: FC<
|
||||
</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
|
||||
|
||||
@@ -3017,7 +3017,7 @@ export const MockPreviewParameter: TypesGen.PreviewParameter = {
|
||||
value: { valid: true, value: "" },
|
||||
diagnostics: [],
|
||||
options: [],
|
||||
ephemeral: true,
|
||||
ephemeral: false,
|
||||
required: true,
|
||||
icon: "",
|
||||
styling: {},
|
||||
|
||||
Reference in New Issue
Block a user