mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
feat: add preset query parameter for workspace creation deeplinks (#24328)
Co-authored-by: Atif Ali <atif@coder.com>
This commit is contained in:
@@ -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¶m.instance_type=t3.small¶m.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}
|
||||
/>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user