feat: add preset query parameter for workspace creation deeplinks (#24328)

Co-authored-by: Atif Ali <atif@coder.com>
This commit is contained in:
Sas Swart
2026-05-28 07:42:53 +02:00
committed by GitHub
parent ca7f07142e
commit 3b8a9ff802
7 changed files with 441 additions and 16 deletions
+3
View File
@@ -148,6 +148,7 @@ type AutoCreateWorkspaceOptions = {
match: string | null;
templateVersionId?: string;
buildParameters?: WorkspaceBuildParameter[];
templateVersionPresetId?: string;
};
export const autoCreateWorkspace = (queryClient: QueryClient) => {
@@ -158,6 +159,7 @@ export const autoCreateWorkspace = (queryClient: QueryClient) => {
workspaceName,
templateVersionId,
buildParameters,
templateVersionPresetId,
match,
}: AutoCreateWorkspaceOptions) => {
if (match) {
@@ -185,6 +187,7 @@ export const autoCreateWorkspace = (queryClient: QueryClient) => {
...templateVersionParameters,
name: workspaceName,
rich_parameter_values: buildParameters,
template_version_preset_id: templateVersionPresetId,
});
},
onSuccess: async () => {
@@ -1,5 +1,5 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { fn } from "storybook/test";
import { expect, fn, screen } from "storybook/test";
import { AutoCreateConsentDialog } from "./AutoCreateConsentDialog";
const meta: Meta<typeof AutoCreateConsentDialog> = {
@@ -77,6 +77,19 @@ export const WithLongValues: Story = {
},
};
export const WithPreset: Story = {
args: {
presetName: "gpu-large",
autofillParameters: [
{ name: "instance_type", value: "g6.4xlarge", source: "url" },
],
},
play: async () => {
expect(screen.getAllByText("Preset:").length).toBeGreaterThan(0);
expect(screen.getAllByText("gpu-large").length).toBeGreaterThan(0);
},
};
export const NoParameters: Story = {
args: {
autofillParameters: [],
@@ -14,6 +14,7 @@ import type { AutofillBuildParameter } from "#/utils/richParameters";
interface AutoCreateConsentDialogProps {
open: boolean;
autofillParameters: AutofillBuildParameter[];
presetName?: string;
onConfirm: () => void;
onDeny: () => void;
}
@@ -21,6 +22,7 @@ interface AutoCreateConsentDialogProps {
export const AutoCreateConsentDialog: FC<AutoCreateConsentDialogProps> = ({
open,
autofillParameters,
presetName,
onConfirm,
onDeny,
}) => {
@@ -43,6 +45,17 @@ export const AutoCreateConsentDialog: FC<AutoCreateConsentDialogProps> = ({
</DialogDescription>
</DialogHeader>
{presetName && (
<div className="flex min-w-0 flex-col gap-2">
<span className="text-sm font-semibold text-content-primary">
Preset:
</span>
<code className="block whitespace-pre overflow-x-auto">
{presetName}
</code>
</div>
)}
{autofillParameters.length > 0 && (
<div className="flex min-w-0 flex-col gap-2">
<span className="text-sm font-semibold text-content-primary">
@@ -2,7 +2,7 @@ import { screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { act } from "react";
import { API } from "#/api/api";
import type { DynamicParametersResponse } from "#/api/typesGenerated";
import type { DynamicParametersResponse, Preset } from "#/api/typesGenerated";
import {
MockDropdownParameter,
MockDynamicParametersResponse,
@@ -11,6 +11,7 @@ import {
MockPreviewParameter,
MockSliderParameter,
MockTemplate,
MockTemplateVersion,
MockTemplateVersionExternalAuthGithub,
MockTemplateVersionExternalAuthGithubAuthenticated,
MockUserOwner,
@@ -40,10 +41,24 @@ describe("CreateWorkspacePage", () => {
});
};
const mockGpuPreset: Preset = {
ID: "preset-gpu",
Name: "gpu-large",
Parameters: [
{ Name: "instance_type", Value: "t3.medium" },
{ Name: "cpu_count", Value: "4" },
],
Default: false,
DesiredPrebuildInstances: null,
Description: "GPU Large preset",
Icon: "",
};
beforeEach(() => {
vi.clearAllMocks();
vi.spyOn(API, "getTemplate").mockResolvedValue(MockTemplate);
vi.spyOn(API, "getTemplateVersion").mockResolvedValue(MockTemplateVersion);
vi.spyOn(API, "getTemplateVersionExternalAuth").mockResolvedValue([]);
vi.spyOn(API, "getTemplateVersionPresets").mockResolvedValue([]);
vi.spyOn(API, "createWorkspace").mockResolvedValue(MockWorkspace);
@@ -446,7 +461,7 @@ describe("CreateWorkspacePage", () => {
`/templates/${MockTemplate.name}/workspace?mode=auto`,
);
// Consent dialog appears for mode=auto — confirm to proceed.
// Consent dialog appears for mode=auto. Confirm to proceed.
const confirmButton = await screen.findByRole("button", {
name: /confirm and create/i,
});
@@ -550,6 +565,158 @@ describe("CreateWorkspacePage", () => {
});
});
describe("URL Presets", () => {
it("resolves a preset from the URL and selects it in the form", async () => {
vi.spyOn(API, "getTemplateVersionPresets").mockResolvedValue([
mockGpuPreset,
]);
renderCreateWorkspacePage(
`/templates/${MockTemplate.name}/workspace?preset=gpu-large`,
);
await waitForLoaderToBeRemoved();
expect(
screen.getByRole("button", { name: /gpu-large/i }),
).toBeInTheDocument();
});
it("resolves a preset against the pinned template version", async () => {
const getTemplateVersionPresetsSpy = vi
.spyOn(API, "getTemplateVersionPresets")
.mockResolvedValue([mockGpuPreset]);
renderCreateWorkspacePage(
`/templates/${MockTemplate.name}/workspace?version=custom-version&preset=gpu-large`,
);
await waitFor(() => {
expect(getTemplateVersionPresetsSpy).toHaveBeenCalledWith(
"custom-version",
);
});
});
it("falls back to form mode when auto-create cannot resolve the preset", async () => {
vi.spyOn(API, "getTemplateVersionExternalAuth").mockResolvedValue([
MockTemplateVersionExternalAuthGithubAuthenticated,
]);
vi.spyOn(API, "getTemplateVersionPresets").mockResolvedValue([
mockGpuPreset,
]);
renderCreateWorkspacePage(
`/templates/${MockTemplate.name}/workspace?mode=auto&preset=missing`,
);
await waitForLoaderToBeRemoved();
expect(
screen.queryByRole("button", { name: /confirm and create/i }),
).not.toBeInTheDocument();
expect(
screen.getByText(/auto-creation has been disabled/i),
).toBeInTheDocument();
expect(
screen.getByText(
/preset "missing" not found on template version "test-version"/i,
),
).toBeInTheDocument();
expect(API.createWorkspace).not.toHaveBeenCalled();
});
it("falls back to form mode when presets fail to load", async () => {
vi.spyOn(API, "getTemplateVersionExternalAuth").mockResolvedValue([
MockTemplateVersionExternalAuthGithubAuthenticated,
]);
vi.spyOn(API, "getTemplateVersionPresets").mockRejectedValue(
new Error("presets unavailable"),
);
renderCreateWorkspacePage(
`/templates/${MockTemplate.name}/workspace?mode=auto&preset=gpu-large`,
);
await waitForLoaderToBeRemoved();
expect(
screen.queryByRole("button", { name: /confirm and create/i }),
).not.toBeInTheDocument();
expect(
screen.getByText(/auto-creation has been disabled/i),
).toBeInTheDocument();
expect(
screen.getByText(/failed to load presets: presets unavailable/i),
).toBeInTheDocument();
expect(API.createWorkspace).not.toHaveBeenCalled();
});
it("uses preset parameters instead of param values", async () => {
vi.spyOn(API, "getTemplateVersionPresets").mockResolvedValue([
mockGpuPreset,
]);
renderCreateWorkspacePage(
`/templates/${MockTemplate.name}/workspace?preset=gpu-large&param.instance_type=t3.small&param.cpu_count=99`,
);
await waitForLoaderToBeRemoved();
expect(screen.getAllByText(/param\.\*/i).length).toBeGreaterThan(0);
const nameInput = screen.getByRole("textbox", {
name: /workspace name/i,
});
await userEvent.type(nameInput, "preset-workspace");
await userEvent.click(
screen.getByRole("button", { name: /create workspace/i }),
);
await waitFor(() => {
expect(API.createWorkspace).toHaveBeenCalledWith(
"test-user",
expect.objectContaining({
template_version_preset_id: mockGpuPreset.ID,
rich_parameter_values: expect.arrayContaining([
expect.objectContaining({
name: "instance_type",
value: "t3.medium",
}),
expect.objectContaining({ name: "cpu_count", value: "4" }),
]),
}),
);
});
});
it("auto-creates with the preset ID after the preset resolves", async () => {
vi.spyOn(API, "getTemplateVersionExternalAuth").mockResolvedValue([
MockTemplateVersionExternalAuthGithubAuthenticated,
]);
vi.spyOn(API, "getTemplateVersionPresets").mockResolvedValue([
mockGpuPreset,
]);
renderCreateWorkspacePage(
`/templates/${MockTemplate.name}/workspace?mode=auto&preset=gpu-large&name=preset-workspace`,
);
const confirmButton = await screen.findByRole("button", {
name: /confirm and create/i,
});
await userEvent.click(confirmButton);
await waitFor(() => {
expect(API.createWorkspace).toHaveBeenCalledWith(
"me",
expect.objectContaining({
name: "preset-workspace",
template_version_preset_id: mockGpuPreset.ID,
rich_parameter_values: [],
}),
);
});
});
});
describe("Navigation", () => {
it("navigates to workspace after successful creation", async () => {
const { router } = renderCreateWorkspacePage();
@@ -60,6 +60,7 @@ const CreateWorkspacePage: FC = () => {
const customVersionId = searchParams.get("version") ?? undefined;
const defaultName = searchParams.get("name");
const disabledParams = searchParams.get("disable_params")?.split(",");
const presetName = searchParams.get("preset") || undefined;
const [mode, setMode] = useState(() => getWorkspaceMode(searchParams));
const [autoCreateConsented, setAutoCreateConsented] = useState(false);
const [autoCreateError, setAutoCreateError] =
@@ -76,9 +77,12 @@ const CreateWorkspacePage: FC = () => {
const templateQuery = useQuery(
templateByName(organizationName, templateName),
);
const realizedVersionId =
customVersionId ?? templateQuery.data?.active_version_id;
const templateVersionPresetsQuery = useQuery({
...templateVersionPresets(templateQuery.data?.active_version_id ?? ""),
enabled: Boolean(templateQuery.data),
...templateVersionPresets(realizedVersionId ?? ""),
enabled: realizedVersionId !== undefined,
});
const permissionsQuery = useQuery({
...checkAuthorization({
@@ -89,15 +93,68 @@ const CreateWorkspacePage: FC = () => {
}),
enabled: Boolean(templateQuery.data),
});
const realizedVersionId =
customVersionId ?? templateQuery.data?.active_version_id;
const templateVersionQuery = useQuery({
...templateVersion(realizedVersionId ?? ""),
enabled: realizedVersionId !== undefined,
});
const autofillParameters = getAutofillParameters(searchParams);
const effectivePresetName = mode === "duplicate" ? undefined : presetName;
const presets = templateVersionPresetsQuery.data ?? [];
const urlPresetResult = useMemo(() => {
if (!effectivePresetName) return { preset: undefined, error: undefined };
if (templateVersionPresetsQuery.isError) {
return {
preset: undefined,
error: `Failed to load presets: ${templateVersionPresetsQuery.error?.message ?? "unknown error"}. Please try refreshing the page.`,
};
}
if (!templateVersionPresetsQuery.isSuccess) {
return { preset: undefined, error: undefined }; // Still loading
}
const found = presets.find((p) => p.Name === effectivePresetName);
if (!found) {
const versionLabel = templateVersionQuery.data?.name ?? realizedVersionId;
return {
preset: undefined,
error: `Preset "${effectivePresetName}" not found on template version "${versionLabel}". Check that the preset name matches exactly (names are case-sensitive).`,
};
}
return { preset: found, error: undefined };
}, [
effectivePresetName,
presets,
templateVersionPresetsQuery.isSuccess,
templateVersionPresetsQuery.isError,
templateVersionPresetsQuery.error,
realizedVersionId,
templateVersionQuery.data?.name,
]);
const urlAutofillParameters = useMemo(
() => getAutofillParameters(searchParams),
[searchParams],
);
const autofillParameters = useMemo(() => {
if (!urlPresetResult.preset) return urlAutofillParameters;
const presetParams: AutofillBuildParameter[] =
urlPresetResult.preset.Parameters.map((p) => ({
name: p.Name,
value: p.Value,
source: "url" as const,
}));
return presetParams;
}, [urlPresetResult.preset, urlAutofillParameters]);
const hasIgnoredUrlParams =
urlAutofillParameters.length > 0 && urlPresetResult.preset !== undefined;
const sendMessage = (
formValues: Record<string, string>,
@@ -227,10 +284,11 @@ const CreateWorkspacePage: FC = () => {
const newWorkspace = await autoCreateWorkspaceMutation.mutateAsync({
organizationId,
templateName,
buildParameters: autofillParameters,
buildParameters: urlPresetResult.preset ? [] : autofillParameters,
workspaceName: defaultName ?? generateWorkspaceName(),
templateVersionId: realizedVersionId,
match: searchParams.get("match"),
templateVersionPresetId: urlPresetResult.preset?.ID,
});
onCreateWorkspace(newWorkspace);
@@ -244,11 +302,22 @@ const CreateWorkspacePage: FC = () => {
externalAuth?.every((auth) => auth.optional || auth.authenticated),
);
const presetResolved =
!effectivePresetName ||
(templateVersionPresetsQuery.isSuccess &&
urlPresetResult.preset !== undefined);
let autoCreateReady =
mode === "auto" && hasAllRequiredExternalAuth && autoCreateConsented;
mode === "auto" &&
hasAllRequiredExternalAuth &&
autoCreateConsented &&
presetResolved;
const showAutoCreateConsent =
mode === "auto" && !autoCreateConsented && !autoCreateError;
mode === "auto" &&
!autoCreateConsented &&
!autoCreateError &&
presetResolved;
// `mode=auto` was set, but a prerequisite has failed, and so auto-mode should be abandoned.
if (
@@ -275,6 +344,23 @@ const CreateWorkspacePage: FC = () => {
});
}
if (
mode === "auto" &&
hasAllRequiredExternalAuth &&
effectivePresetName &&
((templateVersionPresetsQuery.isSuccess && !urlPresetResult.preset) ||
templateVersionPresetsQuery.isError)
) {
setMode("form");
autoCreateReady = false;
setAutoCreateError({
message: "Auto-creation has been disabled.",
detail:
urlPresetResult.error ??
"The requested preset could not be resolved. Please check the preset value before continuing.",
});
}
useEffect(() => {
if (autoCreateReady) {
void automateWorkspaceCreation();
@@ -293,7 +379,10 @@ const CreateWorkspacePage: FC = () => {
isLoadingFormData ||
isLoadingExternalAuth ||
autoCreateReady ||
(!latestResponse && !wsError);
(!latestResponse && !wsError) ||
(effectivePresetName &&
!templateVersionPresetsQuery.isSuccess &&
!templateVersionPresetsQuery.isError);
return (
<>
@@ -301,6 +390,7 @@ const CreateWorkspacePage: FC = () => {
<AutoCreateConsentDialog
open={showAutoCreateConsent}
presetName={effectivePresetName}
autofillParameters={autofillParameters}
onConfirm={() => setAutoCreateConsented(true)}
onDeny={() => setMode("form")}
@@ -336,7 +426,14 @@ const CreateWorkspacePage: FC = () => {
hasAllRequiredExternalAuth={hasAllRequiredExternalAuth}
permissions={permissionsQuery.data as CreateWorkspacePermissions}
parameters={sortedParams}
presets={templateVersionPresetsQuery.data ?? []}
presets={presets}
urlPreset={urlPresetResult.preset}
urlPresetError={
autoCreateError?.detail === urlPresetResult.error
? undefined
: urlPresetResult.error
}
hasIgnoredUrlParams={hasIgnoredUrlParams}
creatingWorkspace={createWorkspaceMutation.isPending}
sendMessage={sendMessage}
onCancel={() => {
@@ -1,7 +1,7 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, screen, within } from "storybook/test";
import { DetailedError } from "#/api/errors";
import type { PreviewParameter } from "#/api/typesGenerated";
import type { Preset, PreviewParameter } from "#/api/typesGenerated";
import { chromatic } from "#/testHelpers/chromatic";
import { MockTemplate, MockUserOwner } from "#/testHelpers/entities";
import { CreateWorkspacePageView } from "./CreateWorkspacePageView";
@@ -277,6 +277,39 @@ const parameterTextarea: PreviewParameter = {
ephemeral: false,
};
const gpuLargePreset: Preset = {
ID: "preset-1",
Name: "GPU Large",
Description: "GPU Large preset",
Parameters: [
{ Name: "instance_type", Value: "t3.large" },
{ Name: "enable_gpu", Value: "true" },
],
Default: false,
DesiredPrebuildInstances: null,
Icon: "/emojis/1f4bb.png",
};
const cpuSmallPreset: Preset = {
ID: "preset-2",
Name: "CPU Small",
Description: "CPU Small preset",
Parameters: [{ Name: "instance_type", Value: "t3.micro" }],
Default: false,
DesiredPrebuildInstances: null,
Icon: "/emojis/1f4bc.png",
};
const urlPreset: Preset = {
ID: "preset-url",
Name: "URL Preset",
Description: "The URL-specified preset",
Parameters: [{ Name: "instance_type", Value: "t3.large" }],
Default: false,
DesiredPrebuildInstances: null,
Icon: "/emojis/1f534.png",
};
const parameterCheckbox: PreviewParameter = {
name: "auto_stop",
display_name: "Auto-stop",
@@ -356,3 +389,70 @@ export const WithPresets: Story = {
parameters: [parameterInput, parameterDropdown],
},
};
export const WithUrlPreset: Story = {
args: {
presets: [gpuLargePreset, cpuSmallPreset],
urlPreset: gpuLargePreset,
parameters: [parameterDropdown, parameterSwitch],
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(
canvas.getByRole("button", { name: /GPU Large/i }),
).toBeInTheDocument();
},
};
export const WithUrlPresetNotFound: Story = {
args: {
presets: [gpuLargePreset],
urlPresetError:
'Preset "gpu-large" not found on template version "test-version". Check that the preset name matches exactly (names are case-sensitive).',
parameters: [parameterDropdown],
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(
canvas.getByText(/Preset "gpu-large" not found on template version/i),
).toBeVisible();
},
};
export const WithUrlPresetAndIgnoredParams: Story = {
args: {
presets: [gpuLargePreset],
urlPreset: gpuLargePreset,
hasIgnoredUrlParams: true,
parameters: [parameterDropdown],
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getAllByText(/param\.\*/i).length).toBeGreaterThan(0);
},
};
export const WithUrlPresetOverridesDefault: Story = {
args: {
presets: [
{
ID: "preset-default",
Name: "Default Preset",
Description: "The default preset",
Parameters: [{ Name: "instance_type", Value: "t3.micro" }],
Default: true,
DesiredPrebuildInstances: null,
Icon: "/emojis/1f7e2.png",
},
urlPreset,
],
urlPreset,
parameters: [parameterDropdown],
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(
canvas.getByRole("button", { name: /URL Preset/i }),
).toBeInTheDocument();
},
};
@@ -68,11 +68,14 @@ interface CreateWorkspacePageViewProps {
externalAuth: TypesGen.TemplateVersionExternalAuth[];
externalAuthPollingState: ExternalAuthPollingState;
hasAllRequiredExternalAuth: boolean;
hasIgnoredUrlParams?: boolean;
mode: CreateWorkspaceMode;
parameters: PreviewParameter[];
permissions: CreateWorkspacePermissions;
presets: TypesGen.Preset[];
template: TypesGen.Template;
urlPreset?: TypesGen.Preset;
urlPresetError?: string;
versionId?: string;
versionName?: string;
onCancel: () => void;
@@ -99,11 +102,14 @@ export const CreateWorkspacePageView: FC<CreateWorkspacePageViewProps> = ({
externalAuth,
externalAuthPollingState,
hasAllRequiredExternalAuth,
hasIgnoredUrlParams,
mode,
parameters,
permissions,
presets = [],
template,
urlPreset,
urlPresetError,
versionId,
versionName,
onSubmit,
@@ -202,6 +208,15 @@ export const CreateWorkspacePageView: FC<CreateWorkspacePageViewProps> = ({
})),
];
setPresetOptions(options);
// URL preset takes precedence over default preset.
if (urlPreset) {
const idx = presets.findIndex((p) => p.ID === urlPreset.ID) + 1;
setSelectedPresetIndex(idx);
form.setFieldValue("template_version_preset_id", urlPreset.ID);
return;
}
const defaultPreset = presets.find((p) => p.Default);
if (defaultPreset) {
const idx = presets.indexOf(defaultPreset) + 1; // +1 for "None"
@@ -211,7 +226,7 @@ export const CreateWorkspacePageView: FC<CreateWorkspacePageViewProps> = ({
setSelectedPresetIndex(0); // Explicitly set to "None"
form.setFieldValue("template_version_preset_id", undefined);
}
}, [presets, form.setFieldValue]);
}, [presets, form.setFieldValue, urlPreset]);
const [presetParameterNames, setPresetParameterNames] = useState<string[]>(
[],
@@ -451,6 +466,20 @@ export const CreateWorkspacePageView: FC<CreateWorkspacePageViewProps> = ({
>
{Boolean(error) && <ErrorAlert error={error} />}
{urlPresetError && (
<Alert severity="warning" dismissible>
{urlPresetError}
</Alert>
)}
{hasIgnoredUrlParams && urlPreset && (
<Alert severity="info" dismissible>
Preset selected. <code>param.*</code> URL parameters have been
ignored. Use either <code>preset</code> or <code>param.*</code>,
not both.
</Alert>
)}
{mode === "duplicate" && (
<Alert
severity="info"
@@ -713,7 +742,10 @@ export const CreateWorkspacePageView: FC<CreateWorkspacePageViewProps> = ({
}
disabled={isDisabled}
isPreset={isPresetParameter}
autofill={autofillByName[parameter.name] !== undefined}
autofill={
!isPresetParameter &&
autofillByName[parameter.name] !== undefined
}
value={formValue}
/>
);