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"
/>

![Screenshot 2025-06-18 at 14 41
41](https://github.com/user-attachments/assets/52f1ab99-f3bf-4540-91ac-e385c632de8c)


## 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:
blink-so[bot]
2025-06-20 15:04:36 -04:00
committed by GitHub
parent 556b095d0f
commit 4fe0a4bca2
8 changed files with 290 additions and 66 deletions
+2
View File
@@ -22,6 +22,8 @@ const badgeVariants = cva(
"border border-solid border-border-warning bg-surface-orange text-content-warning shadow", "border border-solid border-border-warning bg-surface-orange text-content-warning shadow",
destructive: destructive:
"border border-solid border-border-destructive bg-surface-red text-highlight-red shadow", "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: { size: {
xs: "text-2xs font-regular h-5 [&_svg]:hidden rounded px-1.5", 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 = { export const AllBadges: Story = {
args: { args: {
parameter: { parameter: {
@@ -36,6 +36,7 @@ import { useDebouncedValue } from "hooks/debounce";
import { useEffectEvent } from "hooks/hookPolyfills"; import { useEffectEvent } from "hooks/hookPolyfills";
import { import {
CircleAlert, CircleAlert,
Hourglass,
Info, Info,
LinkIcon, LinkIcon,
Settings, Settings,
@@ -162,6 +163,23 @@ const ParameterLabel: FC<ParameterLabelProps> = ({
</Tooltip> </Tooltip>
</TooltipProvider> </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 && ( {isPreset && (
<TooltipProvider delayDuration={100}> <TooltipProvider delayDuration={100}>
<Tooltip> <Tooltip>
@@ -1,5 +1,4 @@
import { useTheme } from "@emotion/react"; import { useTheme } from "@emotion/react";
import Button from "@mui/material/Button";
import visuallyHidden from "@mui/utils/visuallyHidden"; import visuallyHidden from "@mui/utils/visuallyHidden";
import { API } from "api/api"; import { API } from "api/api";
import type { import type {
@@ -7,6 +6,7 @@ import type {
Workspace, Workspace,
WorkspaceBuildParameter, WorkspaceBuildParameter,
} from "api/typesGenerated"; } from "api/typesGenerated";
import { Button } from "components/Button/Button";
import { FormFields } from "components/Form/Form"; import { FormFields } from "components/Form/Form";
import { TopbarButton } from "components/FullPageLayout/Topbar"; import { TopbarButton } from "components/FullPageLayout/Topbar";
import { import {
@@ -27,6 +27,7 @@ import { useFormik } from "formik";
import { ChevronDownIcon } from "lucide-react"; import { ChevronDownIcon } from "lucide-react";
import type { FC } from "react"; import type { FC } from "react";
import { useQuery } from "react-query"; import { useQuery } from "react-query";
import { useNavigate } from "react-router-dom";
import { docs } from "utils/docs"; import { docs } from "utils/docs";
import { getFormHelpers } from "utils/formUtils"; import { getFormHelpers } from "utils/formUtils";
import { import {
@@ -72,6 +73,7 @@ export const BuildParametersPopover: FC<BuildParametersPopoverProps> = ({
css={{ ".MuiPaper-root": { width: 304 } }} css={{ ".MuiPaper-root": { width: 304 } }}
> >
<BuildParametersPopoverContent <BuildParametersPopoverContent
workspace={workspace}
ephemeralParameters={ephemeralParameters} ephemeralParameters={ephemeralParameters}
buildParameters={parameters?.buildParameters} buildParameters={parameters?.buildParameters}
onSubmit={onSubmit} onSubmit={onSubmit}
@@ -82,18 +84,67 @@ export const BuildParametersPopover: FC<BuildParametersPopoverProps> = ({
}; };
interface BuildParametersPopoverContentProps { interface BuildParametersPopoverContentProps {
workspace: Workspace;
ephemeralParameters?: TemplateVersionParameter[]; ephemeralParameters?: TemplateVersionParameter[];
buildParameters?: WorkspaceBuildParameter[]; buildParameters?: WorkspaceBuildParameter[];
onSubmit: (buildParameters: WorkspaceBuildParameter[]) => void; onSubmit: (buildParameters: WorkspaceBuildParameter[]) => void;
} }
const BuildParametersPopoverContent: FC<BuildParametersPopoverContentProps> = ({ const BuildParametersPopoverContent: FC<BuildParametersPopoverContentProps> = ({
workspace,
ephemeralParameters, ephemeralParameters,
buildParameters, buildParameters,
onSubmit, onSubmit,
}) => { }) => {
const theme = useTheme(); const theme = useTheme();
const popover = usePopover(); 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 ( return (
<> <>
@@ -206,8 +257,6 @@ const Form: FC<FormProps> = ({
<Button <Button
data-testid="build-parameters-submit" data-testid="build-parameters-submit"
type="submit" type="submit"
variant="contained"
color="primary"
css={{ width: "100%" }} css={{ width: "100%" }}
> >
Build workspace Build workspace
@@ -15,6 +15,7 @@ import {
ConfirmDialog, ConfirmDialog,
type ConfirmDialogProps, type ConfirmDialogProps,
} from "components/Dialogs/ConfirmDialog/ConfirmDialog"; } from "components/Dialogs/ConfirmDialog/ConfirmDialog";
import { EphemeralParametersDialog } from "components/EphemeralParametersDialog/EphemeralParametersDialog";
import { displayError } from "components/GlobalSnackbar/utils"; import { displayError } from "components/GlobalSnackbar/utils";
import { useWorkspaceBuildLogs } from "hooks/useWorkspaceBuildLogs"; import { useWorkspaceBuildLogs } from "hooks/useWorkspaceBuildLogs";
import { import {
@@ -53,6 +54,13 @@ export const WorkspaceReadyPage: FC<WorkspaceReadyPageProps> = ({
open: boolean; open: boolean;
buildParameters?: TypesGen.WorkspaceBuildParameter[]; buildParameters?: TypesGen.WorkspaceBuildParameter[];
}>({ open: false }); }>({ 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 } = const { mutate: mutateRestartWorkspace, isPending: isRestarting } =
useMutation({ useMutation({
mutationFn: API.restartWorkspace, mutationFn: API.restartWorkspace,
@@ -137,12 +145,49 @@ 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, buildParameters: TypesGen.WorkspaceBuildParameter[] | undefined,
debug: boolean, debug: boolean,
) => { ) => {
const logLevel = debug ? "debug" : undefined; const logLevel = debug ? "debug" : undefined;
const { hasEphemeral, ephemeralParameters } =
await checkEphemeralParameters(buildParameters);
if (hasEphemeral) {
setEphemeralParametersDialog({
open: true,
action: "start",
buildParameters,
ephemeralParameters,
});
} else {
switch (workspace.latest_build.transition) { switch (workspace.latest_build.transition) {
case "start": case "start":
startWorkspaceMutation.mutate({ startWorkspaceMutation.mutate({
@@ -157,18 +202,19 @@ export const WorkspaceReadyPage: FC<WorkspaceReadyPageProps> = ({
deleteWorkspaceMutation.mutate({ log_level: logLevel }); deleteWorkspaceMutation.mutate({ log_level: logLevel });
break; break;
} }
}
}; };
const handleRetry = ( const handleRetry = async (
buildParameters?: TypesGen.WorkspaceBuildParameter[], buildParameters?: TypesGen.WorkspaceBuildParameter[],
) => { ) => {
runLastBuild(buildParameters, false); await runLastBuild(buildParameters, false);
}; };
const handleDebug = ( const handleDebug = async (
buildParameters?: TypesGen.WorkspaceBuildParameter[], buildParameters?: TypesGen.WorkspaceBuildParameter[],
) => { ) => {
runLastBuild(buildParameters, true); await runLastBuild(buildParameters, true);
}; };
return ( return (
@@ -196,14 +242,36 @@ export const WorkspaceReadyPage: FC<WorkspaceReadyPageProps> = ({
template={template} template={template}
buildLogs={buildLogs} buildLogs={buildLogs}
timings={timingsQuery.data} timings={timingsQuery.data}
handleStart={(buildParameters) => { handleStart={async (buildParameters) => {
const { hasEphemeral, ephemeralParameters } =
await checkEphemeralParameters(buildParameters);
if (hasEphemeral) {
setEphemeralParametersDialog({
open: true,
action: "start",
buildParameters,
ephemeralParameters,
});
} else {
startWorkspaceMutation.mutate({ buildParameters }); startWorkspaceMutation.mutate({ buildParameters });
}
}} }}
handleStop={() => { handleStop={() => {
stopWorkspaceMutation.mutate({}); stopWorkspaceMutation.mutate({});
}} }}
handleRestart={(buildParameters) => { handleRestart={async (buildParameters) => {
const { hasEphemeral, ephemeralParameters } =
await checkEphemeralParameters(buildParameters);
if (hasEphemeral) {
setEphemeralParametersDialog({
open: true,
action: "restart",
buildParameters,
ephemeralParameters,
});
} else {
setConfirmingRestart({ open: true, buildParameters }); setConfirmingRestart({ open: true, buildParameters });
}
}} }}
handleUpdate={workspaceUpdate.update} handleUpdate={workspaceUpdate.update}
handleCancel={cancelBuildMutation.mutate} 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} /> <WorkspaceUpdateDialogs {...workspaceUpdate.dialogs} />
</> </>
); );
@@ -74,9 +74,6 @@ export const WorkspaceParametersPageViewExperimental: FC<
validateOnChange: true, validateOnChange: true,
validateOnBlur: true, validateOnBlur: true,
}); });
// Group parameters by ephemeral status
const ephemeralParameters = parameters.filter((p) => p.ephemeral);
const standardParameters = parameters.filter((p) => !p.ephemeral);
const disabled = const disabled =
workspace.outdated && workspace.outdated &&
@@ -204,7 +201,7 @@ export const WorkspaceParametersPageViewExperimental: FC<
)} )}
<form onSubmit={form.handleSubmit} className="flex flex-col gap-8"> <form onSubmit={form.handleSubmit} className="flex flex-col gap-8">
{standardParameters.length > 0 && ( {parameters.length > 0 && (
<section className="flex flex-col gap-9"> <section className="flex flex-col gap-9">
<hgroup> <hgroup>
<h2 className="text-xl font-medium mb-0">Parameters</h2> <h2 className="text-xl font-medium mb-0">Parameters</h2>
@@ -220,7 +217,7 @@ export const WorkspaceParametersPageViewExperimental: FC<
</Link> </Link>
</p> </p>
</hgroup> </hgroup>
{standardParameters.map((parameter, index) => { {parameters.map((parameter, index) => {
const currentParameterValueIndex = const currentParameterValueIndex =
form.values.rich_parameter_values?.findIndex( form.values.rich_parameter_values?.findIndex(
(p) => p.name === parameter.name, (p) => p.name === parameter.name,
@@ -260,41 +257,6 @@ export const WorkspaceParametersPageViewExperimental: FC<
</section> </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"> <div className="flex justify-end gap-2">
<Button onClick={onCancel} variant="outline"> <Button onClick={onCancel} variant="outline">
Cancel Cancel
+1 -1
View File
@@ -3017,7 +3017,7 @@ export const MockPreviewParameter: TypesGen.PreviewParameter = {
value: { valid: true, value: "" }, value: { valid: true, value: "" },
diagnostics: [], diagnostics: [],
options: [], options: [],
ephemeral: true, ephemeral: false,
required: true, required: true,
icon: "", icon: "",
styling: {}, styling: {},