chore: remove classic parameters frontend code (#20710)

This commit is contained in:
ケイラ
2025-11-25 15:07:21 -07:00
committed by GitHub
parent 4863812d8c
commit 956cbe7751
24 changed files with 1599 additions and 3418 deletions
+118 -116
View File
@@ -183,34 +183,37 @@ export const verifyParameters = async (
);
}
const parameterLabel = await page.waitForSelector(
`[data-testid='parameter-field-${richParameter.name}']`,
{ state: "visible" },
const parameterLabel = page.getByTestId(
`parameter-field-${richParameter.displayName}`,
);
await expect(parameterLabel).toBeVisible();
const muiDisabled = richParameter.mutable ? "" : ".Mui-disabled";
if (richParameter.options.length > 0) {
const parameterValue = parameterLabel.getByLabel(buildParameter.value);
const value = await parameterValue.isChecked();
expect(value).toBe(true);
continue;
}
if (richParameter.type === "bool") {
const parameterField = await parameterLabel.waitForSelector(
`[data-testid='parameter-field-bool'] .MuiRadio-root.Mui-checked${muiDisabled} input`,
);
const value = await parameterField.inputValue();
expect(value).toEqual(buildParameter.value);
} else if (richParameter.options.length > 0) {
const parameterField = await parameterLabel.waitForSelector(
`[data-testid='parameter-field-options'] .MuiRadio-root.Mui-checked${muiDisabled} input`,
);
const value = await parameterField.inputValue();
expect(value).toEqual(buildParameter.value);
} else if (richParameter.type === "list(string)") {
throw new Error("not implemented yet"); // FIXME
} else {
// text or number
const parameterField = await parameterLabel.waitForSelector(
`[data-testid='parameter-field-text'] input${muiDisabled}`,
);
const value = await parameterField.inputValue();
expect(value).toEqual(buildParameter.value);
switch (richParameter.type) {
case "bool":
{
const parameterField = parameterLabel.locator("input");
const value = await parameterField.isChecked();
expect(value.toString()).toEqual(buildParameter.value);
}
break;
case "string":
case "number":
{
const parameterField = parameterLabel.locator("input");
const value = await parameterField.inputValue();
expect(value).toEqual(buildParameter.value);
}
break;
default:
// Some types like `list(string)` are not tested
throw new Error("not implemented yet");
}
}
};
@@ -373,25 +376,22 @@ export const stopWorkspace = async (page: Page, workspaceName: string) => {
});
};
export const buildWorkspaceWithParameters = async (
export const startWorkspaceWithEphemeralParameters = async (
page: Page,
workspaceName: string,
richParameters: RichParameter[] = [],
buildParameters: WorkspaceBuildParameter[] = [],
confirm = false,
) => {
const user = currentUser(page);
await page.goto(`/@${user.username}/${workspaceName}`, {
waitUntil: "domcontentloaded",
});
await page.getByTestId("build-parameters-button").click();
await page.getByTestId("workspace-start").click();
await page.getByTestId("workspace-parameters").click();
await fillParameters(page, richParameters, buildParameters);
await page.getByTestId("build-parameters-submit").click();
if (confirm) {
await page.getByTestId("confirm-button").click();
}
await page.getByRole("button", { name: "Update and restart" }).click();
await page.waitForSelector("text=Workspace status: Running", {
state: "visible",
@@ -547,6 +547,9 @@ interface EchoProvisionerResponses {
plan?: RecursivePartial<Response>[];
// apply occurs when the workspace is built
apply?: RecursivePartial<Response>[];
// extraFiles allows the bundling of terraform files in echo provisioner tars
// in order to support dynamic parameters
extraFiles?: Map<string, string>;
}
const emptyPlan = new TextEncoder().encode("{}");
@@ -595,6 +598,13 @@ const createTemplateVersionTar = async (
}
const tar = new TarWriter();
if (responses.extraFiles) {
for (const [fileName, fileContents] of responses.extraFiles) {
tar.addFile(fileName, fileContents);
}
}
responses.parse.forEach((response, index) => {
response.parse = {
templateVariables: [],
@@ -830,6 +840,50 @@ export const findSessionToken = async (page: Page): Promise<string> => {
export const echoResponsesWithParameters = (
richParameters: RichParameter[],
): EchoProvisionerResponses => {
let tf = `terraform {
required_providers {
coder = {
source = "coder/coder"
}
}
}
`;
for (const parameter of richParameters) {
let options = "";
if (parameter.options) {
for (const option of parameter.options) {
options += `
option {
name = ${JSON.stringify(option.name)}
description = ${JSON.stringify(option.description)}
value = ${JSON.stringify(option.value)}
icon = ${JSON.stringify(option.icon)}
}
`;
}
}
tf += `
data "coder_parameter" "${parameter.name}" {
type = ${JSON.stringify(parameter.type)}
name = ${JSON.stringify(parameter.displayName)}
icon = ${JSON.stringify(parameter.icon)}
description = ${JSON.stringify(parameter.description)}
mutable = ${JSON.stringify(parameter.mutable)}`;
if (!parameter.required) {
tf += `
default = ${JSON.stringify(parameter.defaultValue)}`;
}
tf += `
order = ${JSON.stringify(parameter.order)}
ephemeral = ${JSON.stringify(parameter.ephemeral)}
${options}}
`;
}
return {
parse: [
{
@@ -854,6 +908,7 @@ export const echoResponsesWithParameters = (
},
},
],
extraFiles: new Map([["main.tf", tf]]),
};
};
@@ -903,30 +958,36 @@ const fillParameters = async (
);
}
// Use modern locator approach instead of waitForSelector
const parameterLabel = page.getByTestId(
`parameter-field-${richParameter.name}`,
`parameter-field-${richParameter.displayName}`,
);
await expect(parameterLabel).toBeVisible();
if (richParameter.type === "bool") {
const parameterField = parameterLabel
.getByTestId("parameter-field-bool")
.locator(`.MuiRadio-root input[value='${buildParameter.value}']`);
await parameterField.click();
} else if (richParameter.options.length > 0) {
const parameterField = parameterLabel
.getByTestId("parameter-field-options")
.locator(`.MuiRadio-root input[value='${buildParameter.value}']`);
await parameterField.click();
} else if (richParameter.type === "list(string)") {
throw new Error("not implemented yet"); // FIXME
} else {
// text or number
const parameterField = parameterLabel
.getByTestId("parameter-field-text")
.locator("input");
await parameterField.fill(buildParameter.value);
if (richParameter.options.length > 0) {
const parameterValue = parameterLabel.getByRole("button", {
name: buildParameter.value,
});
await parameterValue.click();
continue;
}
switch (richParameter.type) {
case "bool":
{
const parameterField = parameterLabel.locator("button");
await parameterField.click();
}
break;
case "string":
case "number":
{
const parameterField = parameterLabel.locator("input");
await parameterField.fill(buildParameter.value);
}
break;
default:
// Some types like `list(string)` are not tested
throw new Error("not implemented yet");
}
}
};
@@ -1021,27 +1082,13 @@ export const updateWorkspace = async (
await page.getByTestId("workspace-update-button").click();
await page.getByTestId("confirm-button").click();
await page.waitForSelector('[data-testid="dialog"]', { state: "visible" });
await page
.getByRole("button", { name: /go to workspace parameters/i })
.click();
await fillParameters(page, richParameters, buildParameters);
await page.getByRole("button", { name: /update parameters/i }).click();
// Wait for the update button to detach.
await page.waitForSelector(
"button[data-testid='workspace-update-button']:enabled",
{ state: "detached" },
);
// Wait for the workspace to be running again.
await page.waitForSelector("text=Workspace status: Running", {
state: "visible",
});
// Wait for the stop button to be enabled again
await page.waitForSelector(
"button[data-testid='workspace-stop-button']:enabled",
{
state: "visible",
},
);
await page.getByRole("button", { name: /update and restart/i }).click();
};
export const updateWorkspaceParameters = async (
@@ -1056,7 +1103,7 @@ export const updateWorkspaceParameters = async (
});
await fillParameters(page, richParameters, buildParameters);
await page.getByRole("button", { name: /submit and restart/i }).click();
await page.getByRole("button", { name: /update and restart/i }).click();
await page.waitForSelector("text=Workspace status: Running", {
state: "visible",
@@ -1209,48 +1256,3 @@ export async function addUserToOrganization(
}
await page.mouse.click(10, 10); // close the popover by clicking outside of it
}
/**
* disableDynamicParameters navigates to the template settings page and disables
* dynamic parameters by unchecking the "Enable dynamic parameters" checkbox.
*/
export const disableDynamicParameters = async (
page: Page,
templateName: string,
orgName = defaultOrganizationName,
) => {
await page.goto(`/templates/${orgName}/${templateName}/settings`, {
waitUntil: "domcontentloaded",
});
await page.waitForSelector("form", { state: "visible" });
// Find and uncheck the "Enable dynamic parameters" checkbox
const dynamicParamsCheckbox = page.getByRole("checkbox", {
name: /Enable dynamic parameters for workspace creation/,
});
await dynamicParamsCheckbox.waitFor({ state: "visible" });
// If the checkbox is checked, uncheck it
if (await dynamicParamsCheckbox.isChecked()) {
await dynamicParamsCheckbox.click();
}
// Save the changes
const saveButton = page.getByRole("button", { name: /save/i });
await saveButton.waitFor({ state: "visible" });
await saveButton.click();
// Wait for the success message or page to update
await page
.locator("[role='alert']:has-text('Template updated successfully')")
.first()
.waitFor({
state: "visible",
timeout: 15000,
});
// Additional wait to ensure the changes are persisted
await page.waitForTimeout(500);
};
+2
View File
@@ -53,6 +53,7 @@ export const thirdParameter: RichParameter = {
...emptyParameter,
name: "third_parameter",
displayName: "Third parameter",
type: "string",
description: "This is third parameter.",
defaultValue: "",
@@ -65,6 +66,7 @@ export const fourthParameter: RichParameter = {
...emptyParameter,
name: "fourth_parameter",
displayName: "Fourth parameter",
type: "bool",
description: "This is fourth parameter.",
defaultValue: "true",
@@ -19,7 +19,7 @@ test.beforeAll(async ({ browser }) => {
await login(page, users.templateAdmin);
const richParameters: RichParameter[] = [
{ ...emptyParameter, name: "repo", type: "string" },
{ ...emptyParameter, name: "repo", displayName: "Repo", type: "string" },
];
template = await createTemplate(
page,
@@ -3,7 +3,6 @@ import { users } from "../../constants";
import {
createTemplate,
createWorkspace,
disableDynamicParameters,
echoResponsesWithParameters,
login,
openTerminalWindow,
@@ -36,9 +35,6 @@ test("create workspace", async ({ page }) => {
apply: [{ apply: { resources: [{ name: "example" }] } }],
});
// Disable dynamic parameters to use classic parameter flow for this test
await disableDynamicParameters(page, template);
await login(page, users.member);
await createWorkspace(page, template);
});
@@ -55,9 +51,6 @@ test("create workspace with default immutable parameters", async ({ page }) => {
echoResponsesWithParameters(richParameters),
);
// Disable dynamic parameters to use classic parameter flow for this test
await disableDynamicParameters(page, template);
await login(page, users.member);
const workspaceName = await createWorkspace(page, template);
await verifyParameters(page, workspaceName, richParameters, [
@@ -75,9 +68,6 @@ test("create workspace with default mutable parameters", async ({ page }) => {
echoResponsesWithParameters(richParameters),
);
// Disable dynamic parameters to use classic parameter flow for this test
await disableDynamicParameters(page, template);
await login(page, users.member);
const workspaceName = await createWorkspace(page, template);
await verifyParameters(page, workspaceName, richParameters, [
@@ -105,9 +95,6 @@ test("create workspace with default and required parameters", async ({
echoResponsesWithParameters(richParameters),
);
// Disable dynamic parameters to use classic parameter flow for this test
await disableDynamicParameters(page, template);
await login(page, users.member);
const workspaceName = await createWorkspace(page, template, {
richParameters,
@@ -140,14 +127,16 @@ test("create workspace and overwrite default parameters", async ({ page }) => {
echoResponsesWithParameters(richParameters),
);
// Disable dynamic parameters to use classic parameter flow for this test
await disableDynamicParameters(page, template);
await login(page, users.member);
const workspaceName = await createWorkspace(page, template, {
richParameters,
buildParameters,
});
await page.waitForSelector("text=Workspace status: Running", {
state: "visible",
});
await verifyParameters(page, workspaceName, richParameters, buildParameters);
});
@@ -163,9 +152,6 @@ test("create workspace with disable_param search params", async ({ page }) => {
echoResponsesWithParameters(richParameters),
);
// Disable dynamic parameters to use classic parameter flow for this test
await disableDynamicParameters(page, templateName);
await login(page, users.member);
await page.goto(
`/templates/${templateName}/workspace?disable_params=first_parameter,second_parameter`,
@@ -184,9 +170,6 @@ test.skip("create docker workspace", async ({ context, page }) => {
await login(page, users.templateAdmin);
const template = await createTemplate(page, StarterTemplates.STARTER_DOCKER);
// Disable dynamic parameters to use classic parameter flow for this test
await disableDynamicParameters(page, template);
await login(page, users.member);
const workspaceName = await createWorkspace(page, template);
@@ -1,58 +0,0 @@
import { test } from "@playwright/test";
import { users } from "../../constants";
import {
buildWorkspaceWithParameters,
createTemplate,
createWorkspace,
disableDynamicParameters,
echoResponsesWithParameters,
login,
verifyParameters,
} from "../../helpers";
import { beforeCoderTest } from "../../hooks";
import { firstBuildOption, secondBuildOption } from "../../parameters";
import type { RichParameter } from "../../provisionerGenerated";
test.beforeEach(async ({ page }) => {
beforeCoderTest(page);
});
test("restart workspace with ephemeral parameters", async ({ page }) => {
await login(page, users.templateAdmin);
const richParameters: RichParameter[] = [firstBuildOption, secondBuildOption];
const template = await createTemplate(
page,
echoResponsesWithParameters(richParameters),
);
// Disable dynamic parameters to use classic parameter flow for this test
await disableDynamicParameters(page, template);
await login(page, users.member);
const workspaceName = await createWorkspace(page, template);
// Verify that build options are default (not selected).
await verifyParameters(page, workspaceName, richParameters, [
{ name: richParameters[0].name, value: firstBuildOption.defaultValue },
{ name: richParameters[1].name, value: secondBuildOption.defaultValue },
]);
// Now, restart the workspace with ephemeral parameters selected.
const buildParameters = [
{ name: richParameters[0].name, value: "AAAAA" },
{ name: richParameters[1].name, value: "true" },
];
await buildWorkspaceWithParameters(
page,
workspaceName,
richParameters,
buildParameters,
true,
);
// Verify that build options are default (not selected).
await verifyParameters(page, workspaceName, richParameters, [
{ name: richParameters[0].name, value: firstBuildOption.defaultValue },
{ name: richParameters[1].name, value: secondBuildOption.defaultValue },
]);
});
@@ -1,12 +1,11 @@
import { test } from "@playwright/test";
import { users } from "../../constants";
import {
buildWorkspaceWithParameters,
createTemplate,
createWorkspace,
disableDynamicParameters,
echoResponsesWithParameters,
login,
startWorkspaceWithEphemeralParameters,
stopWorkspace,
verifyParameters,
} from "../../helpers";
@@ -26,9 +25,6 @@ test("start workspace with ephemeral parameters", async ({ page }) => {
echoResponsesWithParameters(richParameters),
);
// Disable dynamic parameters to use classic parameter flow for this test
await disableDynamicParameters(page, template);
await login(page, users.member);
const workspaceName = await createWorkspace(page, template);
@@ -47,13 +43,16 @@ test("start workspace with ephemeral parameters", async ({ page }) => {
{ name: richParameters[1].name, value: "true" },
];
await buildWorkspaceWithParameters(
await startWorkspaceWithEphemeralParameters(
page,
workspaceName,
richParameters,
buildParameters,
);
// Stop the workspace
await stopWorkspace(page, workspaceName);
// Verify that build options are default (not selected).
await verifyParameters(page, workspaceName, richParameters, [
{ name: richParameters[0].name, value: firstBuildOption.defaultValue },
@@ -3,9 +3,9 @@ import { users } from "../../constants";
import {
createTemplate,
createWorkspace,
disableDynamicParameters,
echoResponsesWithParameters,
login,
stopWorkspace,
updateTemplate,
updateWorkspace,
updateWorkspaceParameters,
@@ -25,7 +25,12 @@ test.beforeEach(async ({ page }) => {
beforeCoderTest(page);
});
test("update workspace, new optional, immutable parameter added", async ({
// TODO: this needs to be fixed for the new dynamic parameters flow which
// sends you to the parameters settings page instead of prompting for new
// values in a modal, but that flow is broken! because we don't let you set
// immutable parameters on that page even if they are new, and detecting if
// they are new is non-trivial.
test.skip("update workspace, new optional, immutable parameter added", async ({
page,
}) => {
await login(page, users.templateAdmin);
@@ -35,9 +40,6 @@ test("update workspace, new optional, immutable parameter added", async ({
echoResponsesWithParameters(richParameters),
);
// Disable dynamic parameters to use classic parameter flow for this test
await disableDynamicParameters(page, template);
await login(page, users.member);
const workspaceName = await createWorkspace(page, template);
@@ -81,9 +83,6 @@ test("update workspace, new required, mutable parameter added", async ({
echoResponsesWithParameters(richParameters),
);
// Disable dynamic parameters to use classic parameter flow for this test
await disableDynamicParameters(page, template);
await login(page, users.member);
const workspaceName = await createWorkspace(page, template);
@@ -113,6 +112,10 @@ test("update workspace, new required, mutable parameter added", async ({
buildParameters,
);
await page.waitForSelector("text=Workspace status: Running", {
state: "visible",
});
// Verify parameter values.
await verifyParameters(page, workspaceName, updatedRichParameters, [
{ name: firstParameter.name, value: firstParameter.defaultValue },
@@ -129,9 +132,6 @@ test("update workspace with ephemeral parameter enabled", async ({ page }) => {
echoResponsesWithParameters(richParameters),
);
// Disable dynamic parameters to use classic parameter flow for this test
await disableDynamicParameters(page, template);
await login(page, users.member);
const workspaceName = await createWorkspace(page, template);
@@ -150,6 +150,9 @@ test("update workspace with ephemeral parameter enabled", async ({ page }) => {
buildParameters,
);
// Stop the workspace
await stopWorkspace(page, workspaceName);
// Verify that parameter values are default.
await verifyParameters(page, workspaceName, richParameters, [
{ name: firstParameter.name, value: firstParameter.defaultValue },
@@ -97,26 +97,6 @@ describe("DynamicParameter", () => {
expect(screen.getByRole("textbox")).toHaveValue("test_value");
});
it("calls onChange when input value changes", async () => {
render(
<DynamicParameter
parameter={mockStringParameter}
value=""
onChange={mockOnChange}
/>,
);
const input = screen.getByRole("textbox");
await waitFor(async () => {
await userEvent.type(input, "new_value");
});
await waitFor(() => {
expect(mockOnChange).toHaveBeenCalledWith("new_value");
});
});
it("shows required indicator for required parameters", () => {
render(
<DynamicParameter
@@ -170,25 +150,6 @@ describe("DynamicParameter", () => {
expect(screen.getByText("Textarea Parameter")).toBeInTheDocument();
expect(screen.getByRole("textbox")).toHaveValue(testValue);
});
it("handles textarea value changes", async () => {
render(
<DynamicParameter
parameter={mockTextareaParameter}
value=""
onChange={mockOnChange}
/>,
);
const textarea = screen.getByRole("textbox");
await waitFor(async () => {
await userEvent.type(textarea, "line1{enter}line2{enter}line3");
});
await waitFor(() => {
expect(mockOnChange).toHaveBeenCalledWith("line1\nline2\nline3");
});
});
});
describe("dropdown parameter", () => {
@@ -729,58 +690,6 @@ describe("DynamicParameter", () => {
});
});
describe("Debounced Input", () => {
it("debounces input changes for text inputs", async () => {
jest.useFakeTimers();
render(
<DynamicParameter
parameter={mockStringParameter}
value=""
onChange={mockOnChange}
/>,
);
const input = screen.getByRole("textbox");
fireEvent.change(input, { target: { value: "abc" } });
expect(mockOnChange).not.toHaveBeenCalled();
act(() => {
jest.runAllTimers();
});
expect(mockOnChange).toHaveBeenCalledWith("abc");
jest.useRealTimers();
});
it("debounces textarea changes", async () => {
jest.useFakeTimers();
render(
<DynamicParameter
parameter={mockTextareaParameter}
value=""
onChange={mockOnChange}
/>,
);
const textarea = screen.getByRole("textbox");
fireEvent.change(textarea, { target: { value: "line1\nline2" } });
expect(mockOnChange).not.toHaveBeenCalled();
act(() => {
jest.runAllTimers();
});
expect(mockOnChange).toHaveBeenCalledWith("line1\nline2");
jest.useRealTimers();
});
});
describe("Edge Cases", () => {
it("handles empty parameter options gracefully", () => {
const paramWithEmptyOptions = createMockParameter({
@@ -28,8 +28,6 @@ import {
TooltipProvider,
TooltipTrigger,
} from "components/Tooltip/Tooltip";
import { useDebouncedValue } from "hooks/debounce";
import { useEffectEvent } from "hooks/hookPolyfills";
import {
CircleAlert,
Eye,
@@ -40,7 +38,7 @@ import {
Settings,
TriangleAlert,
} from "lucide-react";
import { type FC, useEffect, useId, useRef, useState } from "react";
import { type FC, useId, useRef, useState } from "react";
import { cn } from "utils/cn";
import type { AutofillBuildParameter } from "utils/richParameters";
import * as Yup from "yup";
@@ -76,25 +74,13 @@ export const DynamicParameter: FC<DynamicParameterProps> = ({
autofill={autofill}
/>
<div className="max-w-lg">
{parameter.form_type === "input" ||
parameter.form_type === "textarea" ||
parameter.form_type === "slider" ? (
<DebouncedParameterField
id={id}
parameter={parameter}
value={value}
onChange={onChange}
disabled={disabled}
/>
) : (
<ParameterField
id={id}
parameter={parameter}
value={value}
onChange={onChange}
disabled={disabled}
/>
)}
<ParameterField
id={id}
parameter={parameter}
value={value}
onChange={onChange}
disabled={disabled}
/>
</div>
{parameter.form_type !== "error" && (
<ParameterDiagnostics diagnostics={parameter.diagnostics} />
@@ -244,196 +230,6 @@ const ParameterLabel: FC<ParameterLabelProps> = ({
);
};
interface DebouncedParameterFieldProps {
parameter: PreviewParameter;
value?: string;
onChange: (value: string) => void;
disabled?: boolean;
id: string;
}
const DebouncedParameterField: FC<DebouncedParameterFieldProps> = ({
parameter,
value,
onChange,
disabled,
id,
}) => {
const [localValue, setLocalValue] = useState(
value !== undefined ? value : validValue(parameter.value),
);
const [showMaskedInput, setShowMaskedInput] = useState(false);
const debouncedLocalValue = useDebouncedValue(localValue, 500);
const onChangeEvent = useEffectEvent(onChange);
// prevDebouncedValueRef is to prevent calling the onChangeEvent on the initial render
const prevDebouncedValueRef = useRef<string | undefined>(undefined);
const prevValueRef = useRef(value);
// Necessary for dynamic defaults or fields being set by preset parameters
useEffect(() => {
if (value !== undefined && value !== prevValueRef.current) {
setLocalValue(value);
prevValueRef.current = value;
}
}, [value]);
useEffect(() => {
// Only call onChangeEvent if debouncedLocalValue is different from the previously committed value
// and it's not the initial undefined state.
if (
prevDebouncedValueRef.current !== undefined &&
prevDebouncedValueRef.current !== debouncedLocalValue
) {
onChangeEvent(debouncedLocalValue);
}
// Update the ref to the current debounced value for the next comparison
prevDebouncedValueRef.current = debouncedLocalValue;
}, [debouncedLocalValue, onChangeEvent]);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const resizeTextarea = useEffectEvent(() => {
if (textareaRef.current) {
const textarea = textareaRef.current;
textarea.style.height = `${textarea.scrollHeight}px`;
}
});
useEffect(() => {
resizeTextarea();
}, [resizeTextarea]);
switch (parameter.form_type) {
case "textarea": {
return (
<Stack direction="row" spacing={0} alignItems="center">
<Textarea
ref={textareaRef}
id={id}
className={cn(
"overflow-y-auto max-h-[500px]",
parameter.styling?.mask_input &&
!showMaskedInput &&
"[-webkit-text-security:disc]",
)}
value={localValue}
onChange={(e) => {
const target = e.currentTarget;
target.style.height = "auto";
target.style.height = `${target.scrollHeight}px`;
setLocalValue(e.target.value);
}}
disabled={disabled}
placeholder={parameter.styling?.placeholder}
required={parameter.required}
/>
{parameter.styling?.mask_input && (
<Button
type="button"
variant="subtle"
size="icon"
onMouseDown={() => setShowMaskedInput(true)}
onMouseOut={() => setShowMaskedInput(false)}
onMouseUp={() => setShowMaskedInput(false)}
disabled={disabled}
>
{showMaskedInput ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</Button>
)}
</Stack>
);
}
case "input": {
const inputType =
parameter.type === "number"
? "number"
: parameter.styling?.mask_input && !showMaskedInput
? "password"
: "text";
const inputProps: Record<string, unknown> = {};
if (parameter.type === "number") {
const validations = parameter.validations[0] || {};
const { validation_min, validation_max } = validations;
if (validation_min !== null) {
inputProps.min = validation_min;
}
if (validation_max !== null) {
inputProps.max = validation_max;
}
}
return (
<Stack direction="row" spacing={0} alignItems="center">
<Input
id={id}
type={inputType}
value={localValue}
onChange={(e) => {
setLocalValue(e.target.value);
}}
disabled={disabled}
required={parameter.required}
placeholder={parameter.styling?.placeholder}
{...inputProps}
/>
{parameter.styling?.mask_input && parameter.type !== "number" && (
<Button
type="button"
variant="subtle"
size="icon"
onMouseDown={() => setShowMaskedInput(true)}
onMouseOut={() => setShowMaskedInput(false)}
onMouseUp={() => setShowMaskedInput(false)}
disabled={disabled}
>
{showMaskedInput ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</Button>
)}
</Stack>
);
}
case "slider": {
const numericValue = Number.isFinite(Number(localValue))
? Number(localValue)
: 0;
const { validation_min: min = 0, validation_max: max = 100 } =
parameter.validations[0] ?? {};
return (
<div className="flex flex-row items-baseline gap-3">
<Slider
id={id}
className="mt-2"
value={[numericValue]}
onValueChange={([value]) => {
setLocalValue(value.toString());
}}
min={min ?? undefined}
max={max ?? undefined}
disabled={disabled}
/>
<span className="w-4 font-medium">{numericValue}</span>
</div>
);
}
}
};
interface ParameterFieldProps {
parameter: PreviewParameter;
value?: string;
@@ -449,7 +245,88 @@ const ParameterField: FC<ParameterFieldProps> = ({
disabled,
id,
}) => {
if (value === undefined && parameter.value.valid) {
value = parameter.value.value;
}
switch (parameter.form_type) {
case "textarea": {
const maskInput = parameter.styling?.mask_input ?? false;
return (
<MaskableTextArea
id={id}
onChange={onChange}
value={value}
masked={maskInput}
disabled={disabled}
required={parameter.required}
placeholder={parameter.styling?.placeholder}
/>
);
}
case "input": {
let maskInput = parameter.styling?.mask_input ?? false;
const inputProps: Partial<MaskableInputProps> = {
type: "text",
};
if (parameter.type === "number") {
// Only text can be effectively masked
maskInput = false;
inputProps.type = "number";
const { validation_min, validation_max } =
parameter.validations[0] ?? {};
if (validation_min !== null) {
inputProps.min = validation_min;
}
if (validation_max !== null) {
inputProps.max = validation_max;
}
} else if (parameter.styling?.mask_input) {
inputProps.type = "password";
}
return (
<MaskableInput
id={id}
onChange={onChange}
value={value}
masked={maskInput}
disabled={disabled}
required={parameter.required}
placeholder={parameter.styling?.placeholder}
{...inputProps}
/>
);
}
case "slider": {
const numericValue = Number.isFinite(Number(value)) ? Number(value) : 0;
const { validation_min: min = 0, validation_max: max = 100 } =
parameter.validations[0] ?? {};
return (
<div className="flex flex-row items-baseline gap-3">
<Slider
id={id}
className="mt-2"
value={[numericValue]}
onValueChange={([value]) => {
onChange(value.toString());
}}
min={min ?? undefined}
max={max ?? undefined}
disabled={disabled}
/>
<span className="w-4 font-medium">{numericValue}</span>
</div>
);
}
case "dropdown": {
return (
<Combobox
@@ -596,6 +473,113 @@ const ParameterField: FC<ParameterFieldProps> = ({
}
};
type MaskableInputProps = Omit<React.ComponentProps<"input">, "onChange"> & {
onChange: (value: string) => void;
masked?: boolean;
};
const MaskableInput: FC<MaskableInputProps> = ({
id,
onChange,
value,
masked,
disabled,
required,
placeholder,
type,
...inputProps
}) => {
const [showMaskedInput, setShowMaskedInput] = useState(false);
return (
<Stack direction="row" spacing={0} alignItems="center">
<Input
id={id}
type={masked && showMaskedInput ? "text" : type}
value={value}
onChange={(e) => {
onChange(e.target.value);
}}
disabled={disabled}
required={required}
placeholder={placeholder}
{...inputProps}
/>
{masked && (
<Button
type="button"
variant="subtle"
size="icon"
onMouseDown={() => setShowMaskedInput(true)}
onMouseOut={() => setShowMaskedInput(false)}
onMouseUp={() => setShowMaskedInput(false)}
disabled={disabled}
>
{showMaskedInput ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</Button>
)}
</Stack>
);
};
const MaskableTextArea: FC<MaskableInputProps> = ({
id,
onChange,
value,
masked,
disabled,
placeholder,
required,
}) => {
const textareaRef = useRef<HTMLTextAreaElement>(null);
const [showMaskedInput, setShowMaskedInput] = useState(false);
return (
<Stack direction="row" spacing={0} alignItems="center">
<Textarea
ref={textareaRef}
id={id}
className={cn(
"overflow-y-auto max-h-[500px]",
masked && !showMaskedInput && "[-webkit-text-security:disc]",
)}
value={value}
onChange={(event) => {
const target = event.currentTarget;
target.style.height = "auto";
target.style.height = `${target.scrollHeight}px`;
onChange(event.target.value);
}}
disabled={disabled}
required={required}
placeholder={placeholder}
/>
{masked && (
<Button
type="button"
variant="subtle"
size="icon"
onMouseDown={() => setShowMaskedInput(true)}
onMouseOut={() => setShowMaskedInput(false)}
onMouseUp={() => setShowMaskedInput(false)}
disabled={disabled}
>
{showMaskedInput ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</Button>
)}
</Stack>
);
};
type ParsedValues = {
values: string[];
error: string;
@@ -76,7 +76,10 @@ export const EphemeralParametersDialog: FC<EphemeralParametersDialogProps> = ({
<Button onClick={onContinue} variant="outline">
Continue
</Button>
<Button onClick={handleGoToParameters}>
<Button
data-testid="workspace-parameters"
onClick={handleGoToParameters}
>
Go to workspace parameters
</Button>
</DialogFooter>
@@ -5,7 +5,7 @@ import {
} from "testHelpers/hooks";
import { act, waitFor } from "@testing-library/react";
import type { Workspace } from "api/typesGenerated";
import CreateWorkspacePage from "../../../pages/CreateWorkspacePage/CreateWorkspacePage";
import CreateWorkspacePage from "pages/CreateWorkspacePage/CreateWorkspacePage";
import { useWorkspaceDuplication } from "./useWorkspaceDuplication";
function render(workspace?: Workspace) {
@@ -1,10 +1,10 @@
import { workspaceBuildParameters } from "api/queries/workspaceBuilds";
import type { Workspace, WorkspaceBuildParameter } from "api/typesGenerated";
import { linkToTemplate, useLinks } from "modules/navigation";
import type { CreateWorkspaceMode } from "pages/CreateWorkspacePage/CreateWorkspacePage";
import { useCallback } from "react";
import { useQuery } from "react-query";
import { useNavigate } from "react-router";
import type { CreateWorkspaceMode } from "../../../pages/CreateWorkspacePage/CreateWorkspacePage";
function getDuplicationUrlParams(
workspaceParams: readonly WorkspaceBuildParameter[],
@@ -1,35 +0,0 @@
import { templateByName } from "api/queries/templates";
import { ErrorAlert } from "components/Alert/ErrorAlert";
import { Loader } from "components/Loader/Loader";
import type { FC } from "react";
import { useQuery } from "react-query";
import { useParams } from "react-router";
import CreateWorkspacePage from "./CreateWorkspacePage";
import CreateWorkspacePageExperimental from "./CreateWorkspacePageExperimental";
const CreateWorkspaceExperimentRouter: FC = () => {
const { organization: organizationName = "default", template: templateName } =
useParams() as { organization?: string; template: string };
const templateQuery = useQuery(
templateByName(organizationName, templateName),
);
if (templateQuery.isError) {
return <ErrorAlert error={templateQuery.error} />;
}
if (!templateQuery.data) {
return <Loader />;
}
return (
<>
{templateQuery.data?.use_classic_parameter_flow ? (
<CreateWorkspacePage />
) : (
<CreateWorkspacePageExperimental />
)}
</>
);
};
export default CreateWorkspaceExperimentRouter;
@@ -1,367 +1,609 @@
import {
MockDropdownParameter,
MockDynamicParametersResponse,
MockDynamicParametersResponseWithError,
MockPermissions,
MockSliderParameter,
MockTemplate,
MockTemplateVersionExternalAuthGithub,
MockTemplateVersionExternalAuthGithubAuthenticated,
MockTemplateVersionParameter1,
MockTemplateVersionParameter2,
MockTemplateVersionParameter3,
MockUserOwner,
MockValidationParameter,
MockWorkspace,
MockWorkspaceQuota,
MockWorkspaceRequest,
MockWorkspaceRichParametersRequest,
} from "testHelpers/entities";
import {
renderWithAuth,
waitForLoaderToBeRemoved,
} from "testHelpers/renderHelpers";
import { fireEvent, screen, waitFor } from "@testing-library/react";
import { createMockWebSocket } from "testHelpers/websockets";
import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { API } from "api/api";
import type { DynamicParametersResponse } from "api/typesGenerated";
import { act } from "react";
import CreateWorkspacePage from "./CreateWorkspacePage";
import { Language } from "./CreateWorkspacePageView";
const nameLabelText = "Workspace Name";
const createWorkspaceText = "Create workspace";
const validationNumberNotInRangeText = "Value must be between 1 and 3.";
const renderCreateWorkspacePage = () => {
return renderWithAuth(<CreateWorkspacePage />, {
route: `/templates/${MockTemplate.name}/workspace`,
path: "/templates/:template/workspace",
});
};
describe("CreateWorkspacePage", () => {
it("succeeds with default owner", async () => {
jest
.spyOn(API, "getUsers")
.mockResolvedValueOnce({ users: [MockUserOwner], count: 1 });
jest
.spyOn(API, "getWorkspaceQuota")
.mockResolvedValueOnce(MockWorkspaceQuota);
jest.spyOn(API, "createWorkspace").mockResolvedValueOnce(MockWorkspace);
jest
.spyOn(API, "getTemplateVersionRichParameters")
.mockResolvedValueOnce([MockTemplateVersionParameter1]);
renderCreateWorkspacePage();
const nameField = await screen.findByLabelText(nameLabelText);
// have to use fireEvent b/c userEvent isn't cleaning up properly between tests
fireEvent.change(nameField, {
target: { value: "test" },
});
const submitButton = screen.getByText(createWorkspaceText);
await userEvent.click(submitButton);
await waitFor(() =>
expect(API.createWorkspace).toBeCalledWith(
MockUserOwner.id,
expect.objectContaining({
...MockWorkspaceRichParametersRequest,
}),
),
);
});
it("uses default rich param values passed from the URL", async () => {
const param = "first_parameter";
const paramValue = "It works!";
jest
.spyOn(API, "getTemplateVersionRichParameters")
.mockResolvedValueOnce([MockTemplateVersionParameter1]);
renderWithAuth(<CreateWorkspacePage />, {
route: `/templates/${MockTemplate.name}/workspace?param.${param}=${paramValue}`,
const renderCreateWorkspacePage = (
route = `/templates/${MockTemplate.name}/workspace`,
) => {
return renderWithAuth(<CreateWorkspacePage />, {
route,
path: "/templates/:template/workspace",
extraRoutes: [
{
path: "/:username/:workspace",
element: <div>Workspace Page</div>,
},
],
});
};
await screen.findByDisplayValue(paramValue);
beforeEach(() => {
jest.clearAllMocks();
jest.spyOn(API, "getTemplate").mockResolvedValue(MockTemplate);
jest.spyOn(API, "getTemplateVersionExternalAuth").mockResolvedValue([]);
jest.spyOn(API, "getTemplateVersionPresets").mockResolvedValue([]);
jest.spyOn(API, "createWorkspace").mockResolvedValue(MockWorkspace);
jest.spyOn(API, "checkAuthorization").mockResolvedValue(MockPermissions);
jest
.spyOn(API, "templateVersionDynamicParameters")
.mockImplementation((_versionId, _ownerId, callbacks) => {
const [mockWebSocket, publisher] = createMockWebSocket("ws://test");
mockWebSocket.addEventListener("message", (event) => {
callbacks.onMessage(JSON.parse(event.data));
});
mockWebSocket.addEventListener("error", () => {
callbacks.onError(
new Error("Connection for dynamic parameters failed."),
);
});
mockWebSocket.addEventListener("close", () => {
callbacks.onClose();
});
publisher.publishOpen(new Event("open"));
publisher.publishMessage(
new MessageEvent("message", {
data: JSON.stringify(MockDynamicParametersResponse),
}),
);
return mockWebSocket;
});
});
it("rich parameter: number validation fails", async () => {
jest
.spyOn(API, "getTemplateVersionRichParameters")
.mockResolvedValueOnce([
MockTemplateVersionParameter1,
MockTemplateVersionParameter2,
]);
renderCreateWorkspacePage();
await waitForLoaderToBeRemoved();
const element = await screen.findByText(createWorkspaceText);
expect(element).toBeDefined();
const secondParameter = await screen.findByText(
MockTemplateVersionParameter2.description,
);
expect(secondParameter).toBeDefined();
const secondParameterField = await screen.findByLabelText(
MockTemplateVersionParameter2.name,
{ exact: false },
);
expect(secondParameterField).toBeDefined();
fireEvent.change(secondParameterField, {
target: { value: "4" },
});
fireEvent.submit(secondParameter);
const validationError = await screen.findByText(
validationNumberNotInRangeText,
);
expect(validationError).toBeDefined();
afterEach(() => {
jest.restoreAllMocks();
});
it("rich parameter: string validation fails", async () => {
jest
.spyOn(API, "getTemplateVersionRichParameters")
.mockResolvedValueOnce([
MockTemplateVersionParameter1,
MockTemplateVersionParameter3,
]);
describe("WebSocket Integration", () => {
it("establishes WebSocket connection and receives initial parameters", async () => {
renderCreateWorkspacePage();
renderCreateWorkspacePage();
await waitForLoaderToBeRemoved();
await waitForLoaderToBeRemoved();
const element = await screen.findByText(createWorkspaceText);
expect(element).toBeDefined();
const thirdParameter = await screen.findByText(
MockTemplateVersionParameter3.description,
);
expect(thirdParameter).toBeDefined();
const thirdParameterField = await screen.findByLabelText(
MockTemplateVersionParameter3.name,
{ exact: false },
);
expect(thirdParameterField).toBeDefined();
fireEvent.change(thirdParameterField, {
target: { value: "1234" },
});
fireEvent.submit(thirdParameterField);
const validationError = await screen.findByText(
MockTemplateVersionParameter3.validation_error as string,
);
expect(validationError).toBeInTheDocument();
});
it("rich parameter: number validation fails with custom error", async () => {
jest.spyOn(API, "getTemplateVersionRichParameters").mockResolvedValueOnce([
MockTemplateVersionParameter1,
{
...MockTemplateVersionParameter2,
validation_error: "These are values: {min}, {max}, and {value}.",
validation_monotonic: undefined, // only needs min-max rules
},
]);
renderCreateWorkspacePage();
await waitForLoaderToBeRemoved();
const secondParameterField = await screen.findByLabelText(
MockTemplateVersionParameter2.name,
{ exact: false },
);
expect(secondParameterField).toBeDefined();
fireEvent.change(secondParameterField, {
target: { value: "4" },
});
fireEvent.submit(secondParameterField);
const validationError = await screen.findByText(
"These are values: 1, 3, and 4.",
);
expect(validationError).toBeInTheDocument();
});
it("external auth authenticates and succeeds", async () => {
jest
.spyOn(API, "getWorkspaceQuota")
.mockResolvedValueOnce(MockWorkspaceQuota);
jest
.spyOn(API, "getUsers")
.mockResolvedValueOnce({ users: [MockUserOwner], count: 1 });
jest.spyOn(API, "createWorkspace").mockResolvedValueOnce(MockWorkspace);
jest
.spyOn(API, "getTemplateVersionExternalAuth")
.mockResolvedValue([MockTemplateVersionExternalAuthGithub]);
renderCreateWorkspacePage();
await waitForLoaderToBeRemoved();
const nameField = await screen.findByLabelText(nameLabelText);
// have to use fireEvent b/c userEvent isn't cleaning up properly between tests
fireEvent.change(nameField, {
target: { value: "test" },
});
const githubButton = await screen.findByText("Login with GitHub");
await userEvent.click(githubButton);
jest
.spyOn(API, "getTemplateVersionExternalAuth")
.mockResolvedValue([MockTemplateVersionExternalAuthGithubAuthenticated]);
await screen.findByText(
"Authenticated",
{},
{ interval: 500, timeout: 5000 },
);
const submitButton = screen.getByText(createWorkspaceText);
await userEvent.click(submitButton);
await waitFor(() =>
expect(API.createWorkspace).toBeCalledWith(
expect(API.templateVersionDynamicParameters).toHaveBeenCalledWith(
MockTemplate.active_version_id,
MockUserOwner.id,
expect.objectContaining({
...MockWorkspaceRequest,
onMessage: expect.any(Function),
onError: expect.any(Function),
onClose: expect.any(Function),
}),
),
);
});
);
it("optional external auth is optional", async () => {
jest
.spyOn(API, "getWorkspaceQuota")
.mockResolvedValueOnce(MockWorkspaceQuota);
jest
.spyOn(API, "getUsers")
.mockResolvedValueOnce({ users: [MockUserOwner], count: 1 });
jest.spyOn(API, "createWorkspace").mockResolvedValueOnce(MockWorkspace);
jest
.spyOn(API, "getTemplateVersionExternalAuth")
.mockResolvedValue([
{ ...MockTemplateVersionExternalAuthGithub, optional: true },
]);
renderCreateWorkspacePage();
await waitForLoaderToBeRemoved();
const nameField = await screen.findByLabelText(nameLabelText);
// have to use fireEvent b/c userEvent isn't cleaning up properly between tests
fireEvent.change(nameField, {
target: { value: "test" },
await waitFor(() => {
expect(screen.getByText(/instance type/i)).toBeInTheDocument();
expect(screen.getByText("CPU Count")).toBeInTheDocument();
expect(screen.getByText("Enable Monitoring")).toBeInTheDocument();
expect(screen.getByText("Tags")).toBeInTheDocument();
});
});
// Ensure we're not logged in
await screen.findByText("Login with GitHub");
it("sends parameter updates via WebSocket when form values change", async () => {
const [mockWebSocket, publisher] = createMockWebSocket("ws://test");
const submitButton = screen.getByText(createWorkspaceText);
await userEvent.click(submitButton);
jest
.spyOn(API, "templateVersionDynamicParameters")
.mockImplementation((_versionId, _ownerId, callbacks) => {
mockWebSocket.addEventListener("message", (event) => {
callbacks.onMessage(JSON.parse(event.data));
});
mockWebSocket.addEventListener("error", () => {
callbacks.onError(
new Error("Connection for dynamic parameters failed."),
);
});
mockWebSocket.addEventListener("close", () => {
callbacks.onClose();
});
await waitFor(() =>
expect(API.createWorkspace).toBeCalledWith(
MockUserOwner.id,
expect.objectContaining({
...MockWorkspaceRequest,
}),
),
);
});
it("auto create a workspace if uses mode=auto", async () => {
const param = "first_parameter";
const paramValue = "It works!";
const createWorkspaceSpy = jest.spyOn(API, "createWorkspace");
renderWithAuth(<CreateWorkspacePage />, {
route: `/templates/default/${MockTemplate.name}/workspace?param.${param}=${paramValue}&mode=auto`,
path: "/templates/:organization/:template/workspace",
});
await waitFor(() => {
expect(createWorkspaceSpy).toBeCalledWith(
"me",
expect.objectContaining({
template_version_id: MockTemplate.active_version_id,
rich_parameter_values: [
expect.objectContaining({
name: param,
source: "url",
value: paramValue,
publisher.publishOpen(new Event("open"));
publisher.publishMessage(
new MessageEvent("message", {
data: JSON.stringify(MockDynamicParametersResponse),
}),
],
}),
);
return mockWebSocket;
});
renderCreateWorkspacePage();
await waitForLoaderToBeRemoved();
expect(screen.getByText(/instance type/i)).toBeInTheDocument();
const instanceTypeSelect = screen.getByRole("button", {
name: /instance type/i,
});
expect(instanceTypeSelect).toBeInTheDocument();
jest.useFakeTimers();
await waitFor(async () => {
await userEvent.click(instanceTypeSelect);
});
let mediumOption: Element | null = null;
await waitFor(() => {
mediumOption = screen.queryByRole("option", { name: /t3\.medium/i });
expect(mediumOption).toBeTruthy();
});
await waitFor(async () => {
await userEvent.click(mediumOption!);
});
act(() => {
jest.runAllTimers();
});
expect(mockWebSocket.send).toHaveBeenCalledWith(
expect.stringContaining('"instance_type":"t3.medium"'),
);
jest.useRealTimers();
});
it("handles WebSocket error gracefully", async () => {
const [mockWebSocket, mockPublisher] = createMockWebSocket("ws://test");
jest
.spyOn(API, "templateVersionDynamicParameters")
.mockImplementation((_versionId, _ownerId, callbacks) => {
mockWebSocket.addEventListener("error", () => {
callbacks.onError(new Error("Connection failed"));
});
return mockWebSocket;
});
renderCreateWorkspacePage();
await waitFor(() => {
expect(mockPublisher).toBeDefined();
mockPublisher.publishError(new Event("Connection failed"));
expect(screen.getByText(/connection failed/i)).toBeInTheDocument();
});
});
it("handles WebSocket close event", async () => {
const [mockWebSocket, mockPublisher] = createMockWebSocket("ws://test");
jest
.spyOn(API, "templateVersionDynamicParameters")
.mockImplementation((_versionId, _ownerId, callbacks) => {
mockWebSocket.addEventListener("close", () => {
callbacks.onClose();
});
return mockWebSocket;
});
renderCreateWorkspacePage();
await waitFor(() => {
expect(mockPublisher).toBeDefined();
mockPublisher.publishClose(new Event("close") as CloseEvent);
expect(
screen.getByText(/websocket connection.*unexpectedly closed/i),
).toBeInTheDocument();
});
});
it("only parameters from latest response are displayed", async () => {
const [mockWebSocket, mockPublisher] = createMockWebSocket("ws://test");
jest
.spyOn(API, "templateVersionDynamicParameters")
.mockImplementation((_versionId, _ownerId, callbacks) => {
mockWebSocket.addEventListener("message", (event) => {
callbacks.onMessage(JSON.parse(event.data));
});
mockPublisher.publishOpen(new Event("open"));
mockPublisher.publishMessage(
new MessageEvent("message", {
data: JSON.stringify({
id: 0,
parameters: [MockDropdownParameter],
diagnostics: [],
}),
}),
);
return mockWebSocket;
});
renderCreateWorkspacePage();
await waitForLoaderToBeRemoved();
const response1: DynamicParametersResponse = {
id: 1,
parameters: [MockDropdownParameter],
diagnostics: [],
};
const response2: DynamicParametersResponse = {
id: 4,
parameters: [MockSliderParameter],
diagnostics: [],
};
await waitFor(() => {
mockPublisher.publishMessage(
new MessageEvent("message", { data: JSON.stringify(response1) }),
);
mockPublisher.publishMessage(
new MessageEvent("message", { data: JSON.stringify(response2) }),
);
});
expect(screen.queryByText("CPU Count")).toBeInTheDocument();
expect(screen.queryByText("Instance Type")).not.toBeInTheDocument();
});
});
describe("Dynamic Parameter Types", () => {
it("displays parameter validation errors", async () => {
jest
.spyOn(API, "templateVersionDynamicParameters")
.mockImplementation((_versionId, _ownerId, callbacks) => {
const [mockWebSocket, publisher] = createMockWebSocket("ws://test");
mockWebSocket.addEventListener("message", (event) => {
callbacks.onMessage(JSON.parse(event.data));
});
publisher.publishMessage(
new MessageEvent("message", {
data: JSON.stringify(MockDynamicParametersResponseWithError),
}),
);
return mockWebSocket;
});
renderCreateWorkspacePage();
await waitForLoaderToBeRemoved();
await waitFor(() => {
expect(screen.getByText("Validation failed")).toBeInTheDocument();
expect(
screen.getByText(
"The selected instance type is not available in this region",
),
).toBeInTheDocument();
});
});
it("displays parameter validation errors for min/max constraints", async () => {
const mockResponseInitial: DynamicParametersResponse = {
id: 1,
parameters: [MockValidationParameter],
diagnostics: [],
};
const mockResponseWithError: DynamicParametersResponse = {
id: 2,
parameters: [
{
...MockValidationParameter,
value: { value: "200", valid: false },
diagnostics: [
{
severity: "error",
summary:
"Invalid parameter value according to 'validation' block",
detail: "value 200 is more than the maximum 100",
extra: {
code: "",
},
},
],
},
],
diagnostics: [],
};
jest
.spyOn(API, "templateVersionDynamicParameters")
.mockImplementation((_versionId, _ownerId, callbacks) => {
const [mockWebSocket, publisher] = createMockWebSocket("ws://test");
mockWebSocket.addEventListener("message", (event) => {
callbacks.onMessage(JSON.parse(event.data));
});
publisher.publishOpen(new Event("open"));
publisher.publishMessage(
new MessageEvent("message", {
data: JSON.stringify(mockResponseInitial),
}),
);
const originalSend = mockWebSocket.send;
mockWebSocket.send = jest.fn((data) => {
originalSend.call(mockWebSocket, data);
if (typeof data === "string" && data.includes('"200"')) {
publisher.publishMessage(
new MessageEvent("message", {
data: JSON.stringify(mockResponseWithError),
}),
);
}
});
return mockWebSocket;
});
renderCreateWorkspacePage();
await waitForLoaderToBeRemoved();
await waitFor(() => {
expect(screen.getByText("Invalid Parameter")).toBeInTheDocument();
});
const numberInput = screen.getByDisplayValue("50");
expect(numberInput).toBeInTheDocument();
await waitFor(async () => {
await userEvent.clear(numberInput);
await userEvent.type(numberInput, "200");
});
await waitFor(() => {
expect(screen.getByDisplayValue("200")).toBeInTheDocument();
});
await waitFor(() => {
expect(
screen.getByText(
"Invalid parameter value according to 'validation' block",
),
).toBeInTheDocument();
});
await waitFor(() => {
expect(
screen.getByText("value 200 is more than the maximum 100"),
).toBeInTheDocument();
});
const errorElement = screen.getByText(
"value 200 is more than the maximum 100",
);
expect(errorElement.closest("div")).toHaveClass(
"text-content-destructive",
);
});
});
it("disables mode=auto if a required external auth provider is not connected", async () => {
const param = "first_parameter";
const paramValue = "It works!";
const createWorkspaceSpy = jest.spyOn(API, "createWorkspace");
describe("External Authentication", () => {
it("displays external auth providers", async () => {
jest
.spyOn(API, "getTemplateVersionExternalAuth")
.mockResolvedValue([MockTemplateVersionExternalAuthGithub]);
const externalAuthSpy = jest
.spyOn(API, "getTemplateVersionExternalAuth")
.mockResolvedValue([MockTemplateVersionExternalAuthGithub]);
renderCreateWorkspacePage();
await waitForLoaderToBeRemoved();
renderWithAuth(<CreateWorkspacePage />, {
route: `/templates/default/${MockTemplate.name}/workspace?param.${param}=${paramValue}&mode=auto`,
path: "/templates/:organization/:template/workspace",
});
await waitForLoaderToBeRemoved();
const warning =
"This template requires an external authentication provider that is not connected.";
expect(await screen.findByText(warning)).toBeInTheDocument();
expect(createWorkspaceSpy).not.toBeCalled();
// We don't need to do this on any other tests out of hundreds of very, very,
// very similar tests, and yet here, I find it to be absolutely necessary for
// some reason that I certainly do not understand. - Kayla
externalAuthSpy.mockReset();
});
it("auto create a workspace if uses mode=auto and version=version-id", async () => {
const param = "first_parameter";
const paramValue = "It works!";
const createWorkspaceSpy = jest.spyOn(API, "createWorkspace");
renderWithAuth(<CreateWorkspacePage />, {
route: `/templates/default/${MockTemplate.name}/workspace?param.${param}=${paramValue}&mode=auto&version=test-template-version`,
path: "/templates/:organization/:template/workspace",
await waitFor(() => {
expect(screen.getByText("GitHub")).toBeInTheDocument();
expect(
screen.getByRole("button", { name: /login with github/i }),
).toBeInTheDocument();
});
});
await waitFor(() => {
expect(createWorkspaceSpy).toBeCalledWith(
"me",
expect.objectContaining({
template_version_id: MockTemplate.active_version_id,
rich_parameter_values: [
expect.objectContaining({ name: param, value: paramValue }),
],
}),
it("shows authenticated state for connected providers", async () => {
jest
.spyOn(API, "getTemplateVersionExternalAuth")
.mockResolvedValue([
MockTemplateVersionExternalAuthGithubAuthenticated,
]);
renderCreateWorkspacePage();
await waitForLoaderToBeRemoved();
await waitFor(() => {
expect(screen.getByText("GitHub")).toBeInTheDocument();
expect(screen.getByText(/authenticated/i)).toBeInTheDocument();
});
});
it("prevents auto-creation when required external auth is missing", async () => {
jest
.spyOn(API, "getTemplateVersionExternalAuth")
.mockResolvedValue([MockTemplateVersionExternalAuthGithub]);
renderCreateWorkspacePage(
`/templates/${MockTemplate.name}/workspace?mode=auto&version=${MockTemplate.id}`,
);
await waitForLoaderToBeRemoved();
await waitFor(() => {
expect(
screen.getByText(
/external authentication provider that is not connected/i,
),
).toBeInTheDocument();
expect(
screen.getByText(/auto-creation has been disabled/i),
).toBeInTheDocument();
});
});
});
it("Detects when a workspace is being created with the 'duplicate' mode", async () => {
const params = new URLSearchParams({
mode: "duplicate",
name: `${MockWorkspace.name}-copy`,
version: MockWorkspace.template_active_version_id,
describe("Auto-creation Mode", () => {
it("falls back to form mode when auto-creation fails", async () => {
jest
.spyOn(API, "getTemplateVersionExternalAuth")
.mockResolvedValue([
MockTemplateVersionExternalAuthGithubAuthenticated,
]);
jest
.spyOn(API, "createWorkspace")
.mockRejectedValue(new Error("Auto-creation failed"));
renderCreateWorkspacePage(
`/templates/${MockTemplate.name}/workspace?mode=auto`,
);
await waitForLoaderToBeRemoved();
expect(screen.getByText(/instance type/i)).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByText("Create workspace")).toBeInTheDocument();
expect(
screen.getByRole("button", { name: /create workspace/i }),
).toBeInTheDocument();
});
});
});
describe("Form Submission", () => {
it("creates workspace with correct parameters", async () => {
renderCreateWorkspacePage();
await waitForLoaderToBeRemoved();
expect(screen.getByText(/instance type/i)).toBeInTheDocument();
const nameInput = screen.getByRole("textbox", {
name: /workspace name/i,
});
await waitFor(async () => {
await userEvent.clear(nameInput);
await userEvent.type(nameInput, "my-test-workspace");
});
const createButton = screen.getByRole("button", {
name: /create workspace/i,
});
await waitFor(async () => {
await userEvent.click(createButton);
});
await waitFor(() => {
expect(API.createWorkspace).toHaveBeenCalledWith(
"test-user",
expect.objectContaining({
name: "my-test-workspace",
template_version_id: MockTemplate.active_version_id,
template_id: undefined,
rich_parameter_values: [
expect.objectContaining({ name: "instance_type", value: "" }),
expect.objectContaining({ name: "cpu_count", value: "2" }),
expect.objectContaining({
name: "enable_monitoring",
value: "true",
}),
expect.objectContaining({ name: "tags", value: "[]" }),
expect.objectContaining({ name: "ides", value: "[]" }),
],
}),
);
});
});
});
describe("URL Parameters", () => {
it("pre-fills parameters from URL", async () => {
renderCreateWorkspacePage(
`/templates/${MockTemplate.name}/workspace?param.instance_type=t3.large&param.cpu_count=4`,
);
await waitForLoaderToBeRemoved();
expect(screen.getByText(/instance type/i)).toBeInTheDocument();
expect(screen.getByText("CPU Count")).toBeInTheDocument();
});
renderWithAuth(<CreateWorkspacePage />, {
path: "/templates/:organization/:template/workspace",
route: `/templates/default/${
MockWorkspace.name
}/workspace?${params.toString()}`,
it("uses custom template version when specified", async () => {
const customVersionId = "custom-version-123";
renderCreateWorkspacePage(
`/templates/${MockTemplate.name}/workspace?version=${customVersionId}`,
);
await waitFor(() => {
expect(API.templateVersionDynamicParameters).toHaveBeenCalledWith(
customVersionId,
MockUserOwner.id,
expect.any(Object),
);
});
});
const warningMessage = await screen.findByTestId("duplication-warning");
const nameInput = await screen.findByRole("textbox", {
name: "Workspace Name",
});
it("pre-fills workspace name from URL", async () => {
const workspaceName = "my-custom-workspace";
expect(warningMessage).toHaveTextContent(Language.duplicationWarning);
expect(nameInput).toHaveValue(`${MockWorkspace.name}-copy`);
renderCreateWorkspacePage(
`/templates/${MockTemplate.name}/workspace?name=${workspaceName}`,
);
await waitForLoaderToBeRemoved();
await waitFor(() => {
const nameInput = screen.getByRole("textbox", {
name: /workspace name/i,
});
expect(nameInput).toHaveValue(workspaceName);
});
});
});
describe("Navigation", () => {
it("navigates to workspace after successful creation", async () => {
const { router } = renderCreateWorkspacePage();
await waitForLoaderToBeRemoved();
const nameInput = screen.getByRole("textbox", {
name: /workspace name/i,
});
await waitFor(async () => {
await userEvent.clear(nameInput);
await userEvent.type(nameInput, "my-test-workspace");
});
// Submit form
const createButton = screen.getByRole("button", {
name: /create workspace/i,
});
await waitFor(async () => {
await userEvent.click(createButton);
});
await waitFor(() => {
expect(router.state.location.pathname).toBe(
`/@${MockWorkspace.owner_name}/${MockWorkspace.name}`,
);
});
});
});
});
@@ -1,30 +1,35 @@
import { API } from "api/api";
import type { ApiErrorResponse } from "api/errors";
import { type ApiErrorResponse, DetailedError } from "api/errors";
import { checkAuthorization } from "api/queries/authCheck";
import {
richParameters,
templateByName,
templateVersionExternalAuth,
templateVersionPresets,
} from "api/queries/templates";
import { autoCreateWorkspace, createWorkspace } from "api/queries/workspaces";
import type {
Template,
TemplateVersionParameter,
UserParameter,
DynamicParametersRequest,
DynamicParametersResponse,
PreviewParameter,
Workspace,
} from "api/typesGenerated";
import { Loader } from "components/Loader/Loader";
import { useAuthenticated } from "hooks";
import { useEffectEvent } from "hooks/hookPolyfills";
import { useExternalAuth } from "hooks/useExternalAuth";
import { useDashboard } from "modules/dashboard/useDashboard";
import { getInitialParameterValues } from "modules/workspaces/DynamicParameter/DynamicParameter";
import { generateWorkspaceName } from "modules/workspaces/generateWorkspaceName";
import { type FC, useCallback, useEffect, useRef, useState } from "react";
import {
type FC,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { useMutation, useQuery, useQueryClient } from "react-query";
import { useNavigate, useParams, useSearchParams } from "react-router";
import { pageTitle } from "utils/page";
import type { AutofillBuildParameter } from "utils/richParameters";
import { paramsUsedToCreateWorkspace } from "utils/workspace";
import { CreateWorkspacePageView } from "./CreateWorkspacePageView";
import {
type CreateWorkspacePermissions,
@@ -33,6 +38,7 @@ import {
const createWorkspaceModes = ["form", "auto", "duplicate"] as const;
export type CreateWorkspaceMode = (typeof createWorkspaceModes)[number];
type ExternalAuthPollingState = "idle" | "polling" | "abandoned";
const CreateWorkspacePage: FC = () => {
const { organization: organizationName = "default", template: templateName } =
@@ -40,7 +46,13 @@ const CreateWorkspacePage: FC = () => {
const { user: me } = useAuthenticated();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const { experiments } = useDashboard();
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 initialParamsSentRef = useRef(false);
const customVersionId = searchParams.get("version") ?? undefined;
const defaultName = searchParams.get("name");
@@ -48,6 +60,8 @@ const CreateWorkspacePage: FC = () => {
const [mode, setMode] = useState(() => getWorkspaceMode(searchParams));
const [autoCreateError, setAutoCreateError] =
useState<ApiErrorResponse | null>(null);
const defaultOwner = me;
const [owner, setOwner] = useState(defaultOwner);
const queryClient = useQueryClient();
const autoCreateWorkspaceMutation = useMutation(
@@ -71,30 +85,102 @@ const CreateWorkspacePage: FC = () => {
}),
enabled: !!templateQuery.data,
});
const templatePermissionsQuery = useQuery({
...checkAuthorization({
checks: {
canUpdateTemplate: {
object: {
resource_type: "template",
resource_id: templateQuery.data?.id ?? "",
},
action: "update",
},
},
}),
enabled: !!templateQuery.data,
});
const realizedVersionId =
customVersionId ?? templateQuery.data?.active_version_id;
const organizationId = templateQuery.data?.organization_id;
const richParametersQuery = useQuery({
...richParameters(realizedVersionId ?? ""),
enabled: realizedVersionId !== undefined,
const autofillParameters = getAutofillParameters(searchParams);
const sendMessage = useEffectEvent(
(formValues: Record<string, string>, ownerId?: string) => {
const request: DynamicParametersRequest = {
id: wsResponseId.current + 1,
owner_id: ownerId ?? owner.id,
inputs: formValues,
};
if (ws.current && ws.current.readyState === WebSocket.OPEN) {
ws.current.send(JSON.stringify(request));
wsResponseId.current = wsResponseId.current + 1;
}
},
);
// On page load, sends all initial parameter values to the websocket
// (including defaults and autofilled from the url)
// This ensures the backend has the complete initial state of the form,
// which is vital for correctly rendering dynamic UI elements where parameter visibility
// or options might depend on the initial values of other parameters.
const sendInitialParameters = useEffectEvent(
(parameters: PreviewParameter[]) => {
if (initialParamsSentRef.current) return;
if (parameters.length === 0) return;
const initialFormValues = getInitialParameterValues(
parameters,
autofillParameters,
);
if (initialFormValues.length === 0) return;
const initialParamsToSend: Record<string, string> = {};
for (const param of initialFormValues) {
if (param.name && param.value) {
initialParamsToSend[param.name] = param.value;
}
}
if (Object.keys(initialParamsToSend).length === 0) return;
sendMessage(initialParamsToSend);
initialParamsSentRef.current = true;
},
);
const onMessage = useEffectEvent((response: DynamicParametersResponse) => {
if (latestResponse && latestResponse?.id >= response.id) {
return;
}
if (!initialParamsSentRef.current && response.parameters?.length > 0) {
sendInitialParameters([...response.parameters]);
}
setLatestResponse(response);
});
const realizedParameters = richParametersQuery.data
? richParametersQuery.data.filter(paramsUsedToCreateWorkspace)
: undefined;
// Initialize the WebSocket connection when there is a valid template version ID
useEffect(() => {
if (!realizedVersionId) return;
const socket = API.templateVersionDynamicParameters(
realizedVersionId,
defaultOwner.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();
};
}, [realizedVersionId, onMessage, defaultOwner.id]);
const organizationId = templateQuery.data?.organization_id;
const {
externalAuth,
@@ -104,15 +190,10 @@ const CreateWorkspacePage: FC = () => {
} = useExternalAuth(realizedVersionId);
const isLoadingFormData =
ws.current?.readyState === WebSocket.CONNECTING ||
templateQuery.isLoading ||
permissionsQuery.isLoading ||
templatePermissionsQuery.isLoading ||
richParametersQuery.isLoading;
const loadFormDataError =
templateQuery.error ??
permissionsQuery.error ??
templatePermissionsQuery.error ??
richParametersQuery.error;
permissionsQuery.isLoading;
const loadFormDataError = templateQuery.error ?? permissionsQuery.error;
const title = autoCreateWorkspaceMutation.isPending
? "Creating workspace..."
@@ -125,18 +206,6 @@ const CreateWorkspacePage: FC = () => {
[navigate],
);
// Auto fill parameters
const autofillEnabled = experiments.includes("auto-fill-parameters");
const userParametersQuery = useQuery({
queryKey: ["userParameters"],
queryFn: () => API.getUserParameters(templateQuery.data?.id ?? ""),
enabled: autofillEnabled && templateQuery.isSuccess,
});
const autofillParameters = getAutofillParameters(
searchParams,
userParametersQuery.data ? userParametersQuery.data : [],
);
const autoCreationStartedRef = useRef(false);
const automateWorkspaceCreation = useEffectEvent(async () => {
if (autoCreationStartedRef.current || !organizationId) {
@@ -165,13 +234,11 @@ const CreateWorkspacePage: FC = () => {
externalAuth?.every((auth) => auth.optional || auth.authenticated),
);
let autoCreateReady =
mode === "auto" &&
(!autofillEnabled || userParametersQuery.isSuccess) &&
hasAllRequiredExternalAuth;
let autoCreateReady = mode === "auto" && hasAllRequiredExternalAuth;
// `mode=auto` was set, but a prerequisite has failed, and so auto-mode should be abandoned.
if (
Boolean(realizedVersionId) &&
mode === "auto" &&
!isLoadingExternalAuth &&
!hasAllRequiredExternalAuth
@@ -200,45 +267,63 @@ const CreateWorkspacePage: FC = () => {
}
}, [automateWorkspaceCreation, autoCreateReady]);
const sortedParams = useMemo(() => {
if (!latestResponse?.parameters) {
return [];
}
return [...latestResponse.parameters].sort((a, b) => a.order - b.order);
}, [latestResponse?.parameters]);
const shouldShowLoader =
!templateQuery.data ||
isLoadingFormData ||
isLoadingExternalAuth ||
autoCreateReady ||
(!latestResponse && !wsError);
return (
<>
<title>{pageTitle(title)}</title>
{isLoadingFormData || isLoadingExternalAuth || autoCreateReady ? (
{shouldShowLoader ? (
<Loader />
) : (
<CreateWorkspacePageView
mode={mode}
defaultName={defaultName}
diagnostics={latestResponse?.diagnostics ?? []}
disabledParams={disabledParams}
defaultOwner={me}
defaultOwner={defaultOwner}
owner={owner}
setOwner={setOwner}
autofillParameters={autofillParameters}
canUpdateTemplate={permissionsQuery.data?.canUpdateTemplate}
error={
wsError ||
createWorkspaceMutation.error ||
autoCreateError ||
loadFormDataError ||
autoCreateWorkspaceMutation.error
}
resetMutation={createWorkspaceMutation.reset}
template={templateQuery.data as Template}
template={templateQuery.data}
versionId={realizedVersionId}
externalAuth={externalAuth ?? []}
externalAuthPollingState={externalAuthPollingState}
startPollingExternalAuth={startPollingExternalAuth}
hasAllRequiredExternalAuth={hasAllRequiredExternalAuth}
permissions={permissionsQuery.data as CreateWorkspacePermissions}
templatePermissions={
templatePermissionsQuery.data as { canUpdateTemplate: boolean }
}
parameters={realizedParameters as TemplateVersionParameter[]}
parameters={sortedParams}
presets={templateVersionPresetsQuery.data ?? []}
creatingWorkspace={createWorkspaceMutation.isPending}
sendMessage={sendMessage}
onCancel={() => {
navigate(-1);
}}
onSubmit={async (request, owner) => {
let workspaceRequest = request;
if (realizedVersionId) {
request = {
workspaceRequest = {
...request,
template_id: undefined,
template_version_id: realizedVersionId,
@@ -246,7 +331,7 @@ const CreateWorkspacePage: FC = () => {
}
const workspace = await createWorkspaceMutation.mutateAsync({
...request,
...workspaceRequest,
userId: owner.id,
});
onCreateWorkspace(workspace);
@@ -257,15 +342,53 @@ const CreateWorkspacePage: FC = () => {
);
};
const useExternalAuth = (versionId: string | undefined) => {
const [externalAuthPollingState, setExternalAuthPollingState] =
useState<ExternalAuthPollingState>("idle");
const startPollingExternalAuth = useCallback(() => {
setExternalAuthPollingState("polling");
}, []);
const { data: externalAuth, isLoading: isLoadingExternalAuth } = useQuery({
...templateVersionExternalAuth(versionId ?? ""),
enabled: Boolean(versionId),
refetchInterval: externalAuthPollingState === "polling" ? 1000 : false,
});
const allSignedIn = externalAuth?.every((it) => it.authenticated);
useEffect(() => {
if (allSignedIn) {
setExternalAuthPollingState("idle");
return;
}
if (externalAuthPollingState !== "polling") {
return;
}
// Poll for a maximum of one minute
const quitPolling = setTimeout(
() => setExternalAuthPollingState("abandoned"),
60_000,
);
return () => {
clearTimeout(quitPolling);
};
}, [externalAuthPollingState, allSignedIn]);
return {
startPollingExternalAuth,
externalAuth,
externalAuthPollingState,
isLoadingExternalAuth,
};
};
const getAutofillParameters = (
urlSearchParams: URLSearchParams,
userParameters: UserParameter[],
): AutofillBuildParameter[] => {
const userParamMap = userParameters.reduce((acc, param) => {
acc.set(param.name, param);
return acc;
}, new Map<string, UserParameter>());
const buildValues: AutofillBuildParameter[] = Array.from(
urlSearchParams.keys(),
)
@@ -273,18 +396,8 @@ const getAutofillParameters = (
.map((key) => {
const name = key.replace("param.", "");
const value = urlSearchParams.get(key) ?? "";
// URL should take precedence over user parameters
userParamMap.delete(name);
return { name, value, source: "url" };
});
for (const param of userParamMap.values()) {
buildValues.push({
name: param.name,
value: param.value,
source: "user_history",
});
}
return buildValues;
};
@@ -1,600 +0,0 @@
import {
MockDropdownParameter,
MockDynamicParametersResponse,
MockDynamicParametersResponseWithError,
MockPermissions,
MockSliderParameter,
MockTemplate,
MockTemplateVersionExternalAuthGithub,
MockTemplateVersionExternalAuthGithubAuthenticated,
MockUserOwner,
MockValidationParameter,
MockWorkspace,
} from "testHelpers/entities";
import {
renderWithAuth,
waitForLoaderToBeRemoved,
} from "testHelpers/renderHelpers";
import { createMockWebSocket } from "testHelpers/websockets";
import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { API } from "api/api";
import type { DynamicParametersResponse } from "api/typesGenerated";
import CreateWorkspacePageExperimental from "./CreateWorkspacePageExperimental";
describe("CreateWorkspacePageExperimental", () => {
const renderCreateWorkspacePageExperimental = (
route = `/templates/${MockTemplate.name}/workspace`,
) => {
return renderWithAuth(<CreateWorkspacePageExperimental />, {
route,
path: "/templates/:template/workspace",
extraRoutes: [
{
path: "/:username/:workspace",
element: <div>Workspace Page</div>,
},
],
});
};
beforeEach(() => {
jest.clearAllMocks();
jest.spyOn(API, "getTemplate").mockResolvedValue(MockTemplate);
jest.spyOn(API, "getTemplateVersionExternalAuth").mockResolvedValue([]);
jest.spyOn(API, "getTemplateVersionPresets").mockResolvedValue([]);
jest.spyOn(API, "createWorkspace").mockResolvedValue(MockWorkspace);
jest.spyOn(API, "checkAuthorization").mockResolvedValue(MockPermissions);
jest
.spyOn(API, "templateVersionDynamicParameters")
.mockImplementation((_versionId, _ownerId, callbacks) => {
const [mockWebSocket, publisher] = createMockWebSocket("ws://test");
mockWebSocket.addEventListener("message", (event) => {
callbacks.onMessage(JSON.parse(event.data));
});
mockWebSocket.addEventListener("error", () => {
callbacks.onError(
new Error("Connection for dynamic parameters failed."),
);
});
mockWebSocket.addEventListener("close", () => {
callbacks.onClose();
});
publisher.publishOpen(new Event("open"));
publisher.publishMessage(
new MessageEvent("message", {
data: JSON.stringify(MockDynamicParametersResponse),
}),
);
return mockWebSocket;
});
});
afterEach(() => {
jest.restoreAllMocks();
});
describe("WebSocket Integration", () => {
it("establishes WebSocket connection and receives initial parameters", async () => {
renderCreateWorkspacePageExperimental();
await waitForLoaderToBeRemoved();
expect(API.templateVersionDynamicParameters).toHaveBeenCalledWith(
MockTemplate.active_version_id,
MockUserOwner.id,
expect.objectContaining({
onMessage: expect.any(Function),
onError: expect.any(Function),
onClose: expect.any(Function),
}),
);
await waitFor(() => {
expect(screen.getByText(/instance type/i)).toBeInTheDocument();
expect(screen.getByText("CPU Count")).toBeInTheDocument();
expect(screen.getByText("Enable Monitoring")).toBeInTheDocument();
expect(screen.getByText("Tags")).toBeInTheDocument();
});
});
it("sends parameter updates via WebSocket when form values change", async () => {
const [mockWebSocket, publisher] = createMockWebSocket("ws://test");
jest
.spyOn(API, "templateVersionDynamicParameters")
.mockImplementation((_versionId, _ownerId, callbacks) => {
mockWebSocket.addEventListener("message", (event) => {
callbacks.onMessage(JSON.parse(event.data));
});
mockWebSocket.addEventListener("error", () => {
callbacks.onError(
new Error("Connection for dynamic parameters failed."),
);
});
mockWebSocket.addEventListener("close", () => {
callbacks.onClose();
});
publisher.publishOpen(new Event("open"));
publisher.publishMessage(
new MessageEvent("message", {
data: JSON.stringify(MockDynamicParametersResponse),
}),
);
return mockWebSocket;
});
renderCreateWorkspacePageExperimental();
await waitForLoaderToBeRemoved();
expect(screen.getByText(/instance type/i)).toBeInTheDocument();
const instanceTypeSelect = screen.getByRole("button", {
name: /instance type/i,
});
expect(instanceTypeSelect).toBeInTheDocument();
await waitFor(async () => {
await userEvent.click(instanceTypeSelect);
});
let mediumOption: Element | null = null;
await waitFor(() => {
mediumOption = screen.queryByRole("option", { name: /t3\.medium/i });
expect(mediumOption).toBeTruthy();
});
await waitFor(async () => {
await userEvent.click(mediumOption!);
});
expect(mockWebSocket.send).toHaveBeenCalledWith(
expect.stringContaining('"instance_type":"t3.medium"'),
);
});
it("handles WebSocket error gracefully", async () => {
const [mockWebSocket, mockPublisher] = createMockWebSocket("ws://test");
jest
.spyOn(API, "templateVersionDynamicParameters")
.mockImplementation((_versionId, _ownerId, callbacks) => {
mockWebSocket.addEventListener("error", () => {
callbacks.onError(new Error("Connection failed"));
});
return mockWebSocket;
});
renderCreateWorkspacePageExperimental();
await waitFor(() => {
expect(mockPublisher).toBeDefined();
mockPublisher.publishError(new Event("Connection failed"));
expect(screen.getByText(/connection failed/i)).toBeInTheDocument();
});
});
it("handles WebSocket close event", async () => {
const [mockWebSocket, mockPublisher] = createMockWebSocket("ws://test");
jest
.spyOn(API, "templateVersionDynamicParameters")
.mockImplementation((_versionId, _ownerId, callbacks) => {
mockWebSocket.addEventListener("close", () => {
callbacks.onClose();
});
return mockWebSocket;
});
renderCreateWorkspacePageExperimental();
await waitFor(() => {
expect(mockPublisher).toBeDefined();
mockPublisher.publishClose(new Event("close") as CloseEvent);
expect(
screen.getByText(/websocket connection.*unexpectedly closed/i),
).toBeInTheDocument();
});
});
it("only parameters from latest response are displayed", async () => {
const [mockWebSocket, mockPublisher] = createMockWebSocket("ws://test");
jest
.spyOn(API, "templateVersionDynamicParameters")
.mockImplementation((_versionId, _ownerId, callbacks) => {
mockWebSocket.addEventListener("message", (event) => {
callbacks.onMessage(JSON.parse(event.data));
});
mockPublisher.publishOpen(new Event("open"));
mockPublisher.publishMessage(
new MessageEvent("message", {
data: JSON.stringify({
id: 0,
parameters: [MockDropdownParameter],
diagnostics: [],
}),
}),
);
return mockWebSocket;
});
renderCreateWorkspacePageExperimental();
await waitForLoaderToBeRemoved();
const response1: DynamicParametersResponse = {
id: 1,
parameters: [MockDropdownParameter],
diagnostics: [],
};
const response2: DynamicParametersResponse = {
id: 4,
parameters: [MockSliderParameter],
diagnostics: [],
};
await waitFor(() => {
mockPublisher.publishMessage(
new MessageEvent("message", { data: JSON.stringify(response1) }),
);
mockPublisher.publishMessage(
new MessageEvent("message", { data: JSON.stringify(response2) }),
);
});
expect(screen.queryByText("CPU Count")).toBeInTheDocument();
expect(screen.queryByText("Instance Type")).not.toBeInTheDocument();
});
});
describe("Dynamic Parameter Types", () => {
it("displays parameter validation errors", async () => {
jest
.spyOn(API, "templateVersionDynamicParameters")
.mockImplementation((_versionId, _ownerId, callbacks) => {
const [mockWebSocket, publisher] = createMockWebSocket("ws://test");
mockWebSocket.addEventListener("message", (event) => {
callbacks.onMessage(JSON.parse(event.data));
});
publisher.publishMessage(
new MessageEvent("message", {
data: JSON.stringify(MockDynamicParametersResponseWithError),
}),
);
return mockWebSocket;
});
renderCreateWorkspacePageExperimental();
await waitForLoaderToBeRemoved();
await waitFor(() => {
expect(screen.getByText("Validation failed")).toBeInTheDocument();
expect(
screen.getByText(
"The selected instance type is not available in this region",
),
).toBeInTheDocument();
});
});
it("displays parameter validation errors for min/max constraints", async () => {
const mockResponseInitial: DynamicParametersResponse = {
id: 1,
parameters: [MockValidationParameter],
diagnostics: [],
};
const mockResponseWithError: DynamicParametersResponse = {
id: 2,
parameters: [
{
...MockValidationParameter,
value: { value: "200", valid: false },
diagnostics: [
{
severity: "error",
summary:
"Invalid parameter value according to 'validation' block",
detail: "value 200 is more than the maximum 100",
extra: {
code: "",
},
},
],
},
],
diagnostics: [],
};
jest
.spyOn(API, "templateVersionDynamicParameters")
.mockImplementation((_versionId, _ownerId, callbacks) => {
const [mockWebSocket, publisher] = createMockWebSocket("ws://test");
mockWebSocket.addEventListener("message", (event) => {
callbacks.onMessage(JSON.parse(event.data));
});
publisher.publishOpen(new Event("open"));
publisher.publishMessage(
new MessageEvent("message", {
data: JSON.stringify(mockResponseInitial),
}),
);
const originalSend = mockWebSocket.send;
mockWebSocket.send = jest.fn((data) => {
originalSend.call(mockWebSocket, data);
if (typeof data === "string" && data.includes('"200"')) {
publisher.publishMessage(
new MessageEvent("message", {
data: JSON.stringify(mockResponseWithError),
}),
);
}
});
return mockWebSocket;
});
renderCreateWorkspacePageExperimental();
await waitForLoaderToBeRemoved();
await waitFor(() => {
expect(screen.getByText("Invalid Parameter")).toBeInTheDocument();
});
const numberInput = screen.getByDisplayValue("50");
expect(numberInput).toBeInTheDocument();
await waitFor(async () => {
await userEvent.clear(numberInput);
await userEvent.type(numberInput, "200");
});
await waitFor(() => {
expect(screen.getByDisplayValue("200")).toBeInTheDocument();
});
await waitFor(() => {
expect(
screen.getByText(
"Invalid parameter value according to 'validation' block",
),
).toBeInTheDocument();
});
await waitFor(() => {
expect(
screen.getByText("value 200 is more than the maximum 100"),
).toBeInTheDocument();
});
const errorElement = screen.getByText(
"value 200 is more than the maximum 100",
);
expect(errorElement.closest("div")).toHaveClass(
"text-content-destructive",
);
});
});
describe("External Authentication", () => {
it("displays external auth providers", async () => {
jest
.spyOn(API, "getTemplateVersionExternalAuth")
.mockResolvedValue([MockTemplateVersionExternalAuthGithub]);
renderCreateWorkspacePageExperimental();
await waitForLoaderToBeRemoved();
await waitFor(() => {
expect(screen.getByText("GitHub")).toBeInTheDocument();
expect(
screen.getByRole("button", { name: /login with github/i }),
).toBeInTheDocument();
});
});
it("shows authenticated state for connected providers", async () => {
jest
.spyOn(API, "getTemplateVersionExternalAuth")
.mockResolvedValue([
MockTemplateVersionExternalAuthGithubAuthenticated,
]);
renderCreateWorkspacePageExperimental();
await waitForLoaderToBeRemoved();
await waitFor(() => {
expect(screen.getByText("GitHub")).toBeInTheDocument();
expect(screen.getByText(/authenticated/i)).toBeInTheDocument();
});
});
it("prevents auto-creation when required external auth is missing", async () => {
jest
.spyOn(API, "getTemplateVersionExternalAuth")
.mockResolvedValue([MockTemplateVersionExternalAuthGithub]);
renderCreateWorkspacePageExperimental(
`/templates/${MockTemplate.name}/workspace?mode=auto`,
);
await waitForLoaderToBeRemoved();
await waitFor(() => {
expect(
screen.getByText(
/external authentication providers that are not connected/i,
),
).toBeInTheDocument();
expect(
screen.getByText(/auto-creation has been disabled/i),
).toBeInTheDocument();
});
});
});
describe("Auto-creation Mode", () => {
it("falls back to form mode when auto-creation fails", async () => {
jest
.spyOn(API, "getTemplateVersionExternalAuth")
.mockResolvedValue([
MockTemplateVersionExternalAuthGithubAuthenticated,
]);
jest
.spyOn(API, "createWorkspace")
.mockRejectedValue(new Error("Auto-creation failed"));
renderCreateWorkspacePageExperimental(
`/templates/${MockTemplate.name}/workspace?mode=auto`,
);
await waitForLoaderToBeRemoved();
expect(screen.getByText(/instance type/i)).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByText("Create workspace")).toBeInTheDocument();
expect(
screen.getByRole("button", { name: /create workspace/i }),
).toBeInTheDocument();
});
});
});
describe("Form Submission", () => {
it("creates workspace with correct parameters", async () => {
renderCreateWorkspacePageExperimental();
await waitForLoaderToBeRemoved();
expect(screen.getByText(/instance type/i)).toBeInTheDocument();
const nameInput = screen.getByRole("textbox", {
name: /workspace name/i,
});
await waitFor(async () => {
await userEvent.clear(nameInput);
await userEvent.type(nameInput, "my-test-workspace");
});
const createButton = screen.getByRole("button", {
name: /create workspace/i,
});
await waitFor(async () => {
await userEvent.click(createButton);
});
await waitFor(() => {
expect(API.createWorkspace).toHaveBeenCalledWith(
"test-user",
expect.objectContaining({
name: "my-test-workspace",
template_version_id: MockTemplate.active_version_id,
template_id: undefined,
rich_parameter_values: [
expect.objectContaining({ name: "instance_type", value: "" }),
expect.objectContaining({ name: "cpu_count", value: "2" }),
expect.objectContaining({
name: "enable_monitoring",
value: "true",
}),
expect.objectContaining({ name: "tags", value: "[]" }),
expect.objectContaining({ name: "ides", value: "[]" }),
],
}),
);
});
});
});
describe("URL Parameters", () => {
it("pre-fills parameters from URL", async () => {
renderCreateWorkspacePageExperimental(
`/templates/${MockTemplate.name}/workspace?param.instance_type=t3.large&param.cpu_count=4`,
);
await waitForLoaderToBeRemoved();
expect(screen.getByText(/instance type/i)).toBeInTheDocument();
expect(screen.getByText("CPU Count")).toBeInTheDocument();
});
it("uses custom template version when specified", async () => {
const customVersionId = "custom-version-123";
renderCreateWorkspacePageExperimental(
`/templates/${MockTemplate.name}/workspace?version=${customVersionId}`,
);
await waitFor(() => {
expect(API.templateVersionDynamicParameters).toHaveBeenCalledWith(
customVersionId,
MockUserOwner.id,
expect.any(Object),
);
});
});
it("pre-fills workspace name from URL", async () => {
const workspaceName = "my-custom-workspace";
renderCreateWorkspacePageExperimental(
`/templates/${MockTemplate.name}/workspace?name=${workspaceName}`,
);
await waitForLoaderToBeRemoved();
await waitFor(() => {
const nameInput = screen.getByRole("textbox", {
name: /workspace name/i,
});
expect(nameInput).toHaveValue(workspaceName);
});
});
});
describe("Navigation", () => {
it("navigates to workspace after successful creation", async () => {
const { router } = renderCreateWorkspacePageExperimental();
await waitForLoaderToBeRemoved();
const nameInput = screen.getByRole("textbox", {
name: /workspace name/i,
});
await waitFor(async () => {
await userEvent.clear(nameInput);
await userEvent.type(nameInput, "my-test-workspace");
});
// Submit form
const createButton = screen.getByRole("button", {
name: /create workspace/i,
});
await waitFor(async () => {
await userEvent.click(createButton);
});
await waitFor(() => {
expect(router.state.location.pathname).toBe(
`/@${MockWorkspace.owner_name}/${MockWorkspace.name}`,
);
});
});
});
});
@@ -1,412 +0,0 @@
import { API } from "api/api";
import { type ApiErrorResponse, DetailedError } from "api/errors";
import { checkAuthorization } from "api/queries/authCheck";
import {
templateByName,
templateVersionExternalAuth,
templateVersionPresets,
} from "api/queries/templates";
import { autoCreateWorkspace, createWorkspace } from "api/queries/workspaces";
import type {
DynamicParametersRequest,
DynamicParametersResponse,
PreviewParameter,
Workspace,
} from "api/typesGenerated";
import { Loader } from "components/Loader/Loader";
import { useAuthenticated } from "hooks";
import { useEffectEvent } from "hooks/hookPolyfills";
import { getInitialParameterValues } from "modules/workspaces/DynamicParameter/DynamicParameter";
import { generateWorkspaceName } from "modules/workspaces/generateWorkspaceName";
import {
type FC,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { useMutation, useQuery, useQueryClient } from "react-query";
import { useNavigate, useParams, useSearchParams } from "react-router";
import { pageTitle } from "utils/page";
import type { AutofillBuildParameter } from "utils/richParameters";
import { CreateWorkspacePageViewExperimental } from "./CreateWorkspacePageViewExperimental";
import {
type CreateWorkspacePermissions,
createWorkspaceChecks,
} from "./permissions";
const createWorkspaceModes = ["form", "auto", "duplicate"] as const;
type CreateWorkspaceMode = (typeof createWorkspaceModes)[number];
type ExternalAuthPollingState = "idle" | "polling" | "abandoned";
const CreateWorkspacePageExperimental: FC = () => {
const { organization: organizationName = "default", template: templateName } =
useParams() as { organization?: string; template: string };
const { user: me } = useAuthenticated();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
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 initialParamsSentRef = useRef(false);
const customVersionId = searchParams.get("version") ?? undefined;
const defaultName = searchParams.get("name");
const disabledParams = searchParams.get("disable_params")?.split(",");
const [mode, setMode] = useState(() => getWorkspaceMode(searchParams));
const [autoCreateError, setAutoCreateError] =
useState<ApiErrorResponse | null>(null);
const defaultOwner = me;
const [owner, setOwner] = useState(defaultOwner);
const queryClient = useQueryClient();
const autoCreateWorkspaceMutation = useMutation(
autoCreateWorkspace(queryClient),
);
const createWorkspaceMutation = useMutation(createWorkspace(queryClient));
const templateQuery = useQuery(
templateByName(organizationName, templateName),
);
const templateVersionPresetsQuery = useQuery({
...templateVersionPresets(templateQuery.data?.active_version_id ?? ""),
enabled: !!templateQuery.data,
});
const permissionsQuery = useQuery({
...checkAuthorization({
checks: createWorkspaceChecks(
templateQuery.data?.organization_id ?? "",
templateQuery.data?.id,
),
}),
enabled: !!templateQuery.data,
});
const realizedVersionId =
customVersionId ?? templateQuery.data?.active_version_id;
const autofillParameters = getAutofillParameters(searchParams);
const sendMessage = useEffectEvent(
(formValues: Record<string, string>, ownerId?: string) => {
const request: DynamicParametersRequest = {
id: wsResponseId.current + 1,
owner_id: ownerId ?? owner.id,
inputs: formValues,
};
if (ws.current && ws.current.readyState === WebSocket.OPEN) {
ws.current.send(JSON.stringify(request));
wsResponseId.current = wsResponseId.current + 1;
}
},
);
// On page load, sends all initial parameter values to the websocket
// (including defaults and autofilled from the url)
// This ensures the backend has the complete initial state of the form,
// which is vital for correctly rendering dynamic UI elements where parameter visibility
// or options might depend on the initial values of other parameters.
const sendInitialParameters = useEffectEvent(
(parameters: PreviewParameter[]) => {
if (initialParamsSentRef.current) return;
if (parameters.length === 0) return;
const initialFormValues = getInitialParameterValues(
parameters,
autofillParameters,
);
if (initialFormValues.length === 0) return;
const initialParamsToSend: Record<string, string> = {};
for (const param of initialFormValues) {
if (param.name && param.value) {
initialParamsToSend[param.name] = param.value;
}
}
if (Object.keys(initialParamsToSend).length === 0) return;
sendMessage(initialParamsToSend);
initialParamsSentRef.current = true;
},
);
const onMessage = useEffectEvent((response: DynamicParametersResponse) => {
if (latestResponse && latestResponse?.id >= response.id) {
return;
}
if (!initialParamsSentRef.current && response.parameters?.length > 0) {
sendInitialParameters([...response.parameters]);
}
setLatestResponse(response);
});
// Initialize the WebSocket connection when there is a valid template version ID
useEffect(() => {
if (!realizedVersionId) return;
const socket = API.templateVersionDynamicParameters(
realizedVersionId,
defaultOwner.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();
};
}, [realizedVersionId, onMessage, defaultOwner.id]);
const organizationId = templateQuery.data?.organization_id;
const {
externalAuth,
externalAuthPollingState,
startPollingExternalAuth,
isLoadingExternalAuth,
} = useExternalAuth(realizedVersionId);
const isLoadingFormData =
ws.current?.readyState === WebSocket.CONNECTING ||
templateQuery.isLoading ||
permissionsQuery.isLoading;
const loadFormDataError = templateQuery.error ?? permissionsQuery.error;
const title = autoCreateWorkspaceMutation.isPending
? "Creating workspace..."
: "Create workspace";
const onCreateWorkspace = useCallback(
(workspace: Workspace) => {
navigate(`/@${workspace.owner_name}/${workspace.name}`);
},
[navigate],
);
const autoCreationStartedRef = useRef(false);
const automateWorkspaceCreation = useEffectEvent(async () => {
if (autoCreationStartedRef.current || !organizationId) {
return;
}
try {
autoCreationStartedRef.current = true;
const newWorkspace = await autoCreateWorkspaceMutation.mutateAsync({
organizationId,
templateName,
buildParameters: autofillParameters,
workspaceName: defaultName ?? generateWorkspaceName(),
templateVersionId: realizedVersionId,
match: searchParams.get("match"),
});
onCreateWorkspace(newWorkspace);
} catch {
setMode("form");
}
});
const hasAllRequiredExternalAuth = Boolean(
!isLoadingExternalAuth &&
externalAuth?.every((auth) => auth.optional || auth.authenticated),
);
let autoCreateReady = mode === "auto" && hasAllRequiredExternalAuth;
// `mode=auto` was set, but a prerequisite has failed, and so auto-mode should be abandoned.
if (
mode === "auto" &&
!isLoadingExternalAuth &&
!hasAllRequiredExternalAuth
) {
// Prevent suddenly resuming auto-mode if the user connects to all of the required
// external auth providers.
setMode("form");
// Ensure this is always false, so that we don't ever let `automateWorkspaceCreation`
// fire when we're trying to disable it.
autoCreateReady = false;
// Show an error message to explain _why_ the workspace was not created automatically.
const subject =
externalAuth?.length === 1
? "an external authentication provider that is"
: "external authentication providers that are";
setAutoCreateError({
message: `This template requires ${subject} not connected.`,
detail:
"Auto-creation has been disabled. Please connect all required external authentication providers before continuing.",
});
}
useEffect(() => {
if (autoCreateReady) {
void automateWorkspaceCreation();
}
}, [automateWorkspaceCreation, autoCreateReady]);
const sortedParams = useMemo(() => {
if (!latestResponse?.parameters) {
return [];
}
return [...latestResponse.parameters].sort((a, b) => a.order - b.order);
}, [latestResponse?.parameters]);
const shouldShowLoader =
!templateQuery.data ||
isLoadingFormData ||
isLoadingExternalAuth ||
autoCreateReady ||
(!latestResponse && !wsError);
return (
<>
<title>{pageTitle(title)}</title>
{shouldShowLoader ? (
<Loader />
) : (
<CreateWorkspacePageViewExperimental
mode={mode}
defaultName={defaultName}
diagnostics={latestResponse?.diagnostics ?? []}
disabledParams={disabledParams}
defaultOwner={defaultOwner}
owner={owner}
setOwner={setOwner}
autofillParameters={autofillParameters}
canUpdateTemplate={permissionsQuery.data?.canUpdateTemplate}
error={
wsError ||
createWorkspaceMutation.error ||
autoCreateError ||
loadFormDataError ||
autoCreateWorkspaceMutation.error
}
resetMutation={createWorkspaceMutation.reset}
template={templateQuery.data}
versionId={realizedVersionId}
externalAuth={externalAuth ?? []}
externalAuthPollingState={externalAuthPollingState}
startPollingExternalAuth={startPollingExternalAuth}
hasAllRequiredExternalAuth={hasAllRequiredExternalAuth}
permissions={permissionsQuery.data as CreateWorkspacePermissions}
parameters={sortedParams}
presets={templateVersionPresetsQuery.data ?? []}
creatingWorkspace={createWorkspaceMutation.isPending}
sendMessage={sendMessage}
onCancel={() => {
navigate(-1);
}}
onSubmit={async (request, owner) => {
let workspaceRequest = request;
if (realizedVersionId) {
workspaceRequest = {
...request,
template_id: undefined,
template_version_id: realizedVersionId,
};
}
const workspace = await createWorkspaceMutation.mutateAsync({
...workspaceRequest,
userId: owner.id,
});
onCreateWorkspace(workspace);
}}
/>
)}
</>
);
};
const useExternalAuth = (versionId: string | undefined) => {
const [externalAuthPollingState, setExternalAuthPollingState] =
useState<ExternalAuthPollingState>("idle");
const startPollingExternalAuth = useCallback(() => {
setExternalAuthPollingState("polling");
}, []);
const { data: externalAuth, isLoading: isLoadingExternalAuth } = useQuery({
...templateVersionExternalAuth(versionId ?? ""),
enabled: !!versionId,
refetchInterval: externalAuthPollingState === "polling" ? 1000 : false,
});
const allSignedIn = externalAuth?.every((it) => it.authenticated);
useEffect(() => {
if (allSignedIn) {
setExternalAuthPollingState("idle");
return;
}
if (externalAuthPollingState !== "polling") {
return;
}
// Poll for a maximum of one minute
const quitPolling = setTimeout(
() => setExternalAuthPollingState("abandoned"),
60_000,
);
return () => {
clearTimeout(quitPolling);
};
}, [externalAuthPollingState, allSignedIn]);
return {
startPollingExternalAuth,
externalAuth,
externalAuthPollingState,
isLoadingExternalAuth,
};
};
const getAutofillParameters = (
urlSearchParams: URLSearchParams,
): AutofillBuildParameter[] => {
const buildValues: AutofillBuildParameter[] = Array.from(
urlSearchParams.keys(),
)
.filter((key) => key.startsWith("param."))
.map((key) => {
const name = key.replace("param.", "");
const value = urlSearchParams.get(key) ?? "";
return { name, value, source: "url" };
});
return buildValues;
};
export default CreateWorkspacePageExperimental;
function getWorkspaceMode(params: URLSearchParams): CreateWorkspaceMode {
const paramMode = params.get("mode");
if (createWorkspaceModes.includes(paramMode as CreateWorkspaceMode)) {
return paramMode as CreateWorkspaceMode;
}
return "form";
}
@@ -1,388 +1,42 @@
import { chromatic } from "testHelpers/chromatic";
import {
MockTemplate,
MockTemplateVersionParameter1,
MockTemplateVersionParameter2,
MockTemplateVersionParameter3,
MockUserOwner,
mockApiError,
} from "testHelpers/entities";
import { withDashboardProvider } from "testHelpers/storybook";
import { MockTemplate, MockUserOwner } from "testHelpers/entities";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { action } from "storybook/actions";
import { expect, screen, waitFor } from "storybook/test";
import { DetailedError } from "api/errors";
import { CreateWorkspacePageView } from "./CreateWorkspacePageView";
const meta: Meta<typeof CreateWorkspacePageView> = {
title: "pages/CreateWorkspacePage",
title: "Pages/CreateWorkspacePageView",
parameters: { chromatic },
component: CreateWorkspacePageView,
args: {
autofillParameters: [],
diagnostics: [],
defaultName: "",
defaultOwner: MockUserOwner,
autofillParameters: [],
template: MockTemplate,
parameters: [],
presets: [],
externalAuth: [],
externalAuthPollingState: "idle",
hasAllRequiredExternalAuth: true,
mode: "form",
parameters: [],
permissions: {
createWorkspaceForAny: true,
canUpdateTemplate: false,
},
onCancel: action("onCancel"),
templatePermissions: { canUpdateTemplate: true },
presets: [],
sendMessage: () => {},
template: MockTemplate,
},
decorators: [withDashboardProvider],
};
export default meta;
type Story = StoryObj<typeof CreateWorkspacePageView>;
export const NoParameters: Story = {};
export const CreateWorkspaceError: Story = {
export const WebsocketError: Story = {
args: {
error: mockApiError({
message:
'Workspace "test" already exists in the "docker-amd64" template.',
validations: [
{
field: "name",
detail: "This value is already in use and should be unique.",
},
],
}),
},
};
export const SpecificVersion: Story = {
args: {
versionId: "specific-version",
},
};
export const Duplicate: Story = {
args: {
mode: "duplicate",
},
};
export const Parameters: Story = {
args: {
parameters: [
MockTemplateVersionParameter1,
MockTemplateVersionParameter2,
MockTemplateVersionParameter3,
{
name: "Region",
required: false,
description: "",
description_plaintext: "",
type: "string",
form_type: "radio",
mutable: false,
default_value: "",
icon: "/emojis/1f30e.png",
options: [
{
name: "Pittsburgh",
description: "",
value: "us-pittsburgh",
icon: "/emojis/1f1fa-1f1f8.png",
},
{
name: "Helsinki",
description: "",
value: "eu-helsinki",
icon: "/emojis/1f1eb-1f1ee.png",
},
{
name: "Sydney",
description: "",
value: "ap-sydney",
icon: "/emojis/1f1e6-1f1fa.png",
},
],
ephemeral: false,
},
],
autofillParameters: [
{
name: "first_parameter",
value: "Cool suggestion",
source: "user_history",
},
{
name: "third_parameter",
value: "aaaa",
source: "url",
},
],
},
};
export const PresetsButNoneSelected: Story = {
args: {
presets: [
{
ID: "preset-1",
Name: "Preset 1",
Description: "Preset 1 description",
Icon: "/emojis/0031-fe0f-20e3.png",
Default: false,
Parameters: [
{
Name: MockTemplateVersionParameter1.name,
Value: "preset 1 override",
},
],
DesiredPrebuildInstances: null,
},
{
ID: "preset-2",
Name: "Preset 2",
Description: "Preset 2 description",
Icon: "/emojis/0032-fe0f-20e3.png",
Default: false,
Parameters: [
{
Name: MockTemplateVersionParameter2.name,
Value: "42",
},
],
DesiredPrebuildInstances: null,
},
],
parameters: [
MockTemplateVersionParameter1,
MockTemplateVersionParameter2,
MockTemplateVersionParameter3,
],
},
};
export const PresetSelected: Story = {
args: PresetsButNoneSelected.args,
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
// Select a preset
await userEvent.click(canvas.getByRole("button", { name: "None" }));
await userEvent.click(screen.getByText("Preset 1"));
},
};
export const PresetSelectedWithVisibleParameters: Story = {
args: PresetsButNoneSelected.args,
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
// Select a preset
await userEvent.click(canvas.getByRole("button", { name: "None" }));
await userEvent.click(screen.getByText("Preset 1"));
// Toggle off the show preset parameters switch
await userEvent.click(canvas.getByLabelText("Show preset parameters"));
},
};
export const PresetReselected: Story = {
args: PresetsButNoneSelected.args,
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
// First selection of Preset 1
await userEvent.click(canvas.getByRole("button", { name: "None" }));
await userEvent.click(screen.getByText("Preset 1"));
// Reselect the same preset
await userEvent.click(canvas.getByRole("button", { name: "Preset 1" }));
await userEvent.click(canvas.getByText("Preset 1"));
},
};
export const PresetNoneSelected: Story = {
args: {
...PresetsButNoneSelected.args,
onSubmit: (request, owner) => {
action("onSubmit")(request, owner);
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
// First select a preset to set the field value
await userEvent.click(canvas.getByRole("button", { name: "None" }));
await userEvent.click(screen.getByText("Preset 1"));
// Then select "None" to unset the field value
await userEvent.click(screen.getByText("None"));
// Fill in required fields and submit to test the API call
await userEvent.type(
canvas.getByLabelText("Workspace Name"),
"test-workspace",
);
await userEvent.click(canvas.getByText("Create workspace"));
},
parameters: {
docs: {
description: {
story:
"This story tests that when 'None' preset is selected, the template_version_preset_id field is not included in the form submission. The story first selects a preset to set the field value, then selects 'None' to unset it, and finally submits the form to verify the API call behavior.",
},
},
},
};
export const PresetsWithDefault: Story = {
args: {
presets: [
{
ID: "preset-1",
Name: "Preset 1",
Description: "Preset 1 description",
Icon: "/emojis/0031-fe0f-20e3.png",
Default: false,
Parameters: [
{
Name: MockTemplateVersionParameter1.name,
Value: "preset 1 override",
},
],
DesiredPrebuildInstances: null,
},
{
ID: "preset-2",
Name: "Preset 2",
Description: "Preset 2 description",
Icon: "/emojis/0032-fe0f-20e3.png",
Default: true,
Parameters: [
{
Name: MockTemplateVersionParameter2.name,
Value: "150189",
},
],
DesiredPrebuildInstances: null,
},
],
parameters: [
MockTemplateVersionParameter1,
MockTemplateVersionParameter2,
MockTemplateVersionParameter3,
],
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
// Should have the default preset listed first
await waitFor(() =>
expect(canvas.getByRole("button", { name: "Preset 2 (Default)" })),
);
// Wait for the switch to be available since preset parameters are populated asynchronously
await canvas.findByLabelText("Show preset parameters");
// Toggle off the show preset parameters switch
await userEvent.click(canvas.getByLabelText("Show preset parameters"));
},
};
export const ExternalAuth: Story = {
args: {
externalAuth: [
{
id: "github",
type: "github",
authenticated: false,
authenticate_url: "",
display_icon: "/icon/github.svg",
display_name: "GitHub",
},
{
id: "gitlab",
type: "gitlab",
authenticated: true,
authenticate_url: "",
display_icon: "/icon/gitlab.svg",
display_name: "GitLab",
optional: true,
},
],
hasAllRequiredExternalAuth: false,
},
};
export const ExternalAuthError: Story = {
args: {
error: true,
externalAuth: [
{
id: "github",
type: "github",
authenticated: false,
authenticate_url: "",
display_icon: "/icon/github.svg",
display_name: "GitHub",
},
{
id: "gitlab",
type: "gitlab",
authenticated: false,
authenticate_url: "",
display_icon: "/icon/gitlab.svg",
display_name: "GitLab",
optional: true,
},
],
hasAllRequiredExternalAuth: false,
},
};
export const ExternalAuthAllRequiredConnected: Story = {
args: {
externalAuth: [
{
id: "github",
type: "github",
authenticated: true,
authenticate_url: "",
display_icon: "/icon/github.svg",
display_name: "GitHub",
},
{
id: "gitlab",
type: "gitlab",
authenticated: false,
authenticate_url: "",
display_icon: "/icon/gitlab.svg",
display_name: "GitLab",
optional: true,
},
],
},
};
export const ExternalAuthAllConnected: Story = {
args: {
externalAuth: [
{
id: "github",
type: "github",
authenticated: true,
authenticate_url: "",
display_icon: "/icon/github.svg",
display_name: "GitHub",
},
{
id: "gitlab",
type: "gitlab",
authenticated: true,
authenticate_url: "",
display_icon: "/icon/gitlab.svg",
display_name: "GitLab",
optional: true,
},
],
error: new DetailedError(
"Websocket connection for dynamic parameters unexpectedly closed.",
"Refresh the page to reset the form.",
),
},
};
@@ -400,7 +54,7 @@ export const WithViewSourceButton: Story = {
docs: {
description: {
story:
"This story shows the View Source button that appears for template administrators. The button allows quick navigation to the template editor from the workspace creation page.",
"This story shows the View Source button that appears for template administrators in the experimental workspace creation page. The button allows quick navigation to the template editor.",
},
},
},
@@ -1,134 +1,149 @@
import type { Interpolation, Theme } from "@emotion/react";
import FormHelperText from "@mui/material/FormHelperText";
import TextField from "@mui/material/TextField";
import type * as TypesGen from "api/typesGenerated";
import type { FriendlyDiagnostic, PreviewParameter } from "api/typesGenerated";
import { Alert } from "components/Alert/Alert";
import { ErrorAlert } from "components/Alert/ErrorAlert";
import { Avatar } from "components/Avatar/Avatar";
import { Badge } from "components/Badge/Badge";
import { Button } from "components/Button/Button";
import { Combobox } from "components/Combobox/Combobox";
import {
FormFields,
FormFooter,
FormSection,
HorizontalForm,
} from "components/Form/Form";
import { Margins } from "components/Margins/Margins";
import {
PageHeader,
PageHeaderSubtitle,
PageHeaderTitle,
} from "components/PageHeader/PageHeader";
import { Pill } from "components/Pill/Pill";
import { RichParameterInput } from "components/RichParameterInput/RichParameterInput";
import { Input } from "components/Input/Input";
import { Label } from "components/Label/Label";
import { Link } from "components/Link/Link";
import { Spinner } from "components/Spinner/Spinner";
import { Stack } from "components/Stack/Stack";
import { Switch } from "components/Switch/Switch";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "components/Tooltip/Tooltip";
import { UserAutocomplete } from "components/UserAutocomplete/UserAutocomplete";
import { type FormikContextType, useFormik } from "formik";
import { useDebouncedFunction } from "hooks/debounce";
import type { ExternalAuthPollingState } from "hooks/useExternalAuth";
import { ExternalLinkIcon } from "lucide-react";
import { linkToTemplate, useLinks } from "modules/navigation";
import { ClassicParameterFlowDeprecationWarning } from "modules/workspaces/ClassicParameterFlowDeprecationWarning/ClassicParameterFlowDeprecationWarning";
import { ArrowLeft, CircleHelp, ExternalLinkIcon } from "lucide-react";
import { useSyncFormParameters } from "modules/hooks/useSyncFormParameters";
import {
Diagnostics,
DynamicParameter,
getInitialParameterValues,
useValidationSchemaForDynamicParameters,
} from "modules/workspaces/DynamicParameter/DynamicParameter";
import { generateWorkspaceName } from "modules/workspaces/generateWorkspaceName";
import { type FC, useCallback, useEffect, useMemo, useState } from "react";
import { Link } from "react-router";
import {
getFormHelpers,
nameValidator,
onChangeTrimmed,
} from "utils/formUtils";
import {
type AutofillBuildParameter,
getInitialRichParameterValues,
useValidationSchemaForRichParameters,
} from "utils/richParameters";
type FC,
useCallback,
useEffect,
useId,
useRef,
useState,
} from "react";
import { Link as RouterLink } from "react-router";
import { docs } from "utils/docs";
import { nameValidator } from "utils/formUtils";
import type { AutofillBuildParameter } from "utils/richParameters";
import * as Yup from "yup";
import type { CreateWorkspaceMode } from "./CreateWorkspacePage";
import { ExternalAuthButton } from "./ExternalAuthButton";
import type { CreateWorkspacePermissions } from "./permissions";
export const Language = {
duplicationWarning:
"Duplicating a workspace only copies its parameters. No state from the old workspace is copied over.",
} as const;
interface CreateWorkspacePageViewProps {
mode: CreateWorkspaceMode;
autofillParameters: AutofillBuildParameter[];
canUpdateTemplate?: boolean;
creatingWorkspace: boolean;
defaultName?: string | null;
defaultOwner: TypesGen.User;
diagnostics: readonly FriendlyDiagnostic[];
disabledParams?: string[];
error: unknown;
resetMutation: () => void;
defaultOwner: TypesGen.User;
template: TypesGen.Template;
versionId?: string;
externalAuth: TypesGen.TemplateVersionExternalAuth[];
externalAuthPollingState: ExternalAuthPollingState;
startPollingExternalAuth: () => void;
hasAllRequiredExternalAuth: boolean;
parameters: TypesGen.TemplateVersionParameter[];
autofillParameters: AutofillBuildParameter[];
presets: TypesGen.Preset[];
mode: CreateWorkspaceMode;
parameters: PreviewParameter[];
permissions: CreateWorkspacePermissions;
templatePermissions: { canUpdateTemplate: boolean };
creatingWorkspace: boolean;
canUpdateTemplate?: boolean;
presets: TypesGen.Preset[];
template: TypesGen.Template;
versionId?: string;
onCancel: () => void;
onSubmit: (
req: TypesGen.CreateWorkspaceRequest,
owner: TypesGen.User,
) => void;
resetMutation: () => void;
sendMessage: (message: Record<string, string>, ownerId?: string) => void;
startPollingExternalAuth: () => void;
owner: TypesGen.User;
setOwner: (user: TypesGen.User) => void;
}
export const CreateWorkspacePageView: FC<CreateWorkspacePageViewProps> = ({
mode,
autofillParameters,
canUpdateTemplate,
creatingWorkspace,
defaultName,
defaultOwner,
diagnostics,
disabledParams,
error,
resetMutation,
defaultOwner,
template,
versionId,
externalAuth,
externalAuthPollingState,
startPollingExternalAuth,
hasAllRequiredExternalAuth,
mode,
parameters,
autofillParameters,
presets = [],
permissions,
templatePermissions,
creatingWorkspace,
canUpdateTemplate,
presets = [],
template,
versionId,
onSubmit,
onCancel,
resetMutation,
sendMessage,
startPollingExternalAuth,
owner,
setOwner,
}) => {
const getLink = useLinks();
const [owner, setOwner] = useState(defaultOwner);
const [suggestedName, setSuggestedName] = useState(() =>
generateWorkspaceName(),
);
const [suggestedName, setSuggestedName] = useState(generateWorkspaceName);
const [showPresetParameters, setShowPresetParameters] = useState(false);
const id = useId();
const workspaceNameInputRef = useRef<HTMLInputElement>(null);
const rerollSuggestedName = useCallback(() => {
setSuggestedName(() => generateWorkspaceName());
}, []);
const autofillByName = Object.fromEntries(
autofillParameters.map((param) => [param.name, param]),
);
// Only touched fields are sent to the websocket
// Autofilled parameters are marked as touched since they have been modified
const initialTouched = Object.fromEntries(
parameters.filter((p) => autofillByName[p.name]).map((p) => [p.name, true]),
);
// The form parameters values hold the working state of the parameters that will be submitted when creating a workspace
// 1. The form parameter values are initialized from the websocket response when the form is mounted
// 2. Only touched form fields are sent to the websocket, a field is touched if edited by the user or set by autofill
// 3. The websocket response may add or remove parameters, these are added or removed from the form values in the useSyncFormParameters hook
// 4. All existing form parameters are updated to match the websocket response in the useSyncFormParameters hook
const form: FormikContextType<TypesGen.CreateWorkspaceRequest> =
useFormik<TypesGen.CreateWorkspaceRequest>({
initialValues: {
name: defaultName ?? "",
template_id: template.id,
rich_parameter_values: getInitialRichParameterValues(
rich_parameter_values: getInitialParameterValues(
parameters,
autofillParameters,
),
},
initialTouched,
validationSchema: Yup.object({
name: nameValidator("Workspace Name"),
rich_parameter_values: useValidationSchemaForRichParameters(parameters),
rich_parameter_values:
useValidationSchemaForDynamicParameters(parameters),
}),
enableReinitialize: true,
enableReinitialize: false,
validateOnChange: true,
validateOnBlur: true,
onSubmit: (request) => {
if (!hasAllRequiredExternalAuth) {
return;
@@ -144,18 +159,15 @@ export const CreateWorkspacePageView: FC<CreateWorkspacePageViewProps> = ({
}
}, [error]);
const getFieldHelpers = getFormHelpers<TypesGen.CreateWorkspaceRequest>(
form,
error,
);
const autofillByName = useMemo(
() =>
Object.fromEntries(
autofillParameters.map((param) => [param.name, param]),
),
[autofillParameters],
);
useEffect(() => {
if (form.submitCount > 0 && Object.keys(form.errors).length > 0) {
workspaceNameInputRef.current?.scrollIntoView({
behavior: "smooth",
block: "center",
});
workspaceNameInputRef.current?.focus();
}
}, [form.submitCount, form.errors]);
const [presetOptions, setPresetOptions] = useState([
{ displayName: "None", value: "undefined", icon: "", description: "" },
@@ -187,6 +199,37 @@ export const CreateWorkspacePageView: FC<CreateWorkspacePageViewProps> = ({
const [presetParameterNames, setPresetParameterNames] = useState<string[]>(
[],
);
// include any modified parameters and all touched parameters to the websocket request
const { debounced: sendDynamicParamsRequest } = useDebouncedFunction(
(
parameters: Array<{ parameter: PreviewParameter; value: string }>,
ownerId?: string,
) => {
const formInputs: Record<string, string> = {};
const formParameters = form.values.rich_parameter_values ?? [];
for (const { parameter, value } of parameters) {
formInputs[parameter.name] = value;
}
for (const [fieldName, isTouched] of Object.entries(form.touched)) {
if (
isTouched &&
!parameters.some((p) => p.parameter.name === fieldName)
) {
const param = formParameters.find((p) => p.name === fieldName);
if (param?.value) {
formInputs[fieldName] = param.value;
}
}
}
sendMessage(formInputs, ownerId);
},
500,
);
useEffect(() => {
const selectedPresetOption = presetOptions[selectedPresetIndex];
let selectedPreset: TypesGen.Preset | undefined;
@@ -204,6 +247,15 @@ export const CreateWorkspacePageView: FC<CreateWorkspacePageViewProps> = ({
setPresetParameterNames(selectedPreset.Parameters.map((p) => p.Name));
const currentValues = form.values.rich_parameter_values ?? [];
const updates: Array<{
field: string;
fieldValue: TypesGen.WorkspaceBuildParameter;
parameter: PreviewParameter;
presetValue: string;
}> = [];
for (const presetParameter of selectedPreset.Parameters) {
const parameterIndex = parameters.findIndex(
(p) => p.name === presetParameter.Name,
@@ -211,189 +263,312 @@ export const CreateWorkspacePageView: FC<CreateWorkspacePageViewProps> = ({
if (parameterIndex === -1) continue;
const parameterField = `rich_parameter_values.${parameterIndex}`;
const parameter = parameters[parameterIndex];
const currentValue = currentValues.find(
(p) => p.name === presetParameter.Name,
)?.value;
form.setFieldValue(parameterField, {
name: presetParameter.Name,
value: presetParameter.Value,
});
if (currentValue !== presetParameter.Value) {
updates.push({
field: parameterField,
fieldValue: {
name: presetParameter.Name,
value: presetParameter.Value,
},
parameter,
presetValue: presetParameter.Value,
});
}
}
if (updates.length > 0) {
for (const update of updates) {
form.setFieldValue(update.field, update.fieldValue);
form.setFieldTouched(update.parameter.name, true);
}
sendDynamicParamsRequest(
updates.map((update) => ({
parameter: update.parameter,
value: update.presetValue,
})),
);
}
}, [
presetOptions,
selectedPresetIndex,
presets,
parameters,
form.setFieldValue,
form.setFieldTouched,
parameters,
form.values.rich_parameter_values,
sendDynamicParamsRequest,
]);
const handleOwnerChange = (user: TypesGen.User) => {
setOwner(user);
sendDynamicParamsRequest([], user.id);
};
const handleChange = async (
parameter: PreviewParameter,
parameterField: string,
value: string,
) => {
const currentFormValue = form.values.rich_parameter_values?.find(
(p) => p.name === parameter.name,
)?.value;
await form.setFieldValue(parameterField, {
name: parameter.name,
value,
});
// Only send the request if the value has changed from the form value
if (currentFormValue !== value) {
form.setFieldTouched(parameter.name, true);
sendDynamicParamsRequest([{ parameter, value }]);
}
};
useSyncFormParameters({
parameters,
formValues: form.values.rich_parameter_values ?? [],
setFieldValue: form.setFieldValue,
});
const disabled =
creatingWorkspace ||
!hasAllRequiredExternalAuth ||
diagnostics.some((diagnostic) => diagnostic.severity === "error") ||
parameters.some((parameter) =>
parameter.diagnostics.some(
(diagnostic) => diagnostic.severity === "error",
),
);
return (
<Margins size="medium">
<PageHeader
actions={
<Stack direction="row" spacing={2}>
<>
<div className="sticky top-5 ml-10">
<button
onClick={onCancel}
type="button"
className="flex items-center gap-2 bg-transparent border-none text-content-secondary hover:text-content-primary translate-y-12"
>
<ArrowLeft size={20} />
Go back
</button>
</div>
<div className="flex flex-col gap-6 max-w-screen-md mx-auto">
<header className="flex flex-col items-start gap-3 mt-10">
<div className="flex items-center gap-2 justify-between w-full">
<span className="flex items-center gap-2">
<Avatar
variant="icon"
size="md"
src={template.icon}
fallback={template.name}
/>
<p className="text-base font-medium m-0">
{template.display_name.length > 0
? template.display_name
: template.name}
</p>
{template.deprecated && (
<Badge variant="warning" size="sm">
Deprecated
</Badge>
)}
</span>
{canUpdateTemplate && (
<Button asChild size="sm" variant="outline">
<Link
<RouterLink
to={`/templates/${template.organization_name}/${template.name}/versions/${versionId}/edit`}
>
<ExternalLinkIcon />
View source
</Link>
</RouterLink>
</Button>
)}
<Button size="sm" variant="outline" onClick={onCancel}>
Cancel
</Button>
</Stack>
}
>
<Stack direction="row">
<Avatar
variant="icon"
size="lg"
src={template.icon}
fallback={template.name}
/>
<div>
<PageHeaderTitle>
{template.display_name.length > 0
? template.display_name
: template.name}
</PageHeaderTitle>
<PageHeaderSubtitle condensed>New workspace</PageHeaderSubtitle>
</div>
<span className="flex flex-row items-center gap-2">
<h1 className="text-3xl font-semibold m-0">New workspace</h1>
{template.deprecated && <Pill type="warning">Deprecated</Pill>}
</Stack>
</PageHeader>
<ClassicParameterFlowDeprecationWarning
templateSettingsLink={`${getLink(
linkToTemplate(template.organization_name, template.name),
)}/settings`}
isEnabled={templatePermissions.canUpdateTemplate}
/>
<HorizontalForm
name="create-workspace-form"
onSubmit={form.handleSubmit}
css={{ padding: "16px 0" }}
>
{Boolean(error) && <ErrorAlert error={error} />}
{mode === "duplicate" && (
<Alert severity="info" dismissible data-testid="duplication-warning">
{Language.duplicationWarning}
</Alert>
)}
{/* General info */}
<FormSection
title="General"
description={
permissions.createWorkspaceForAny
? "The name of the workspace and its owner. Only admins can create workspaces for other users."
: "The name of your new workspace."
}
>
<FormFields>
{versionId && versionId !== template.active_version_id && (
<Stack spacing={1} css={styles.hasDescription}>
<TextField
disabled
fullWidth
value={versionId}
label="Version ID"
/>
<span css={styles.description}>
This parameter has been preset, and cannot be modified.
</span>
</Stack>
)}
<div>
<TextField
{...getFieldHelpers("name")}
disabled={creatingWorkspace}
// resetMutation facilitates the clearing of validation errors
onChange={onChangeTrimmed(form, resetMutation)}
fullWidth
label="Workspace Name"
/>
<FormHelperText data-chromatic="ignore">
Need a suggestion?{" "}
<Button
variant="subtle"
size="sm"
css={styles.nameSuggestion}
onClick={async () => {
await form.setFieldValue("name", suggestedName);
rerollSuggestedName();
}}
<Tooltip>
<TooltipTrigger asChild>
<CircleHelp className="size-icon-xs text-content-secondary" />
</TooltipTrigger>
<TooltipContent className="max-w-xs text-sm">
Dynamic Parameters enhances Coder's existing parameter system
with real-time validation, conditional parameter behavior, and
richer input types.
<br />
<Link
href={docs(
"/admin/templates/extending-templates/dynamic-parameters",
)}
>
{suggestedName}
</Button>
</FormHelperText>
</div>
View docs
</Link>
</TooltipContent>
</Tooltip>
</span>
</header>
{permissions.createWorkspaceForAny && (
<UserAutocomplete
value={owner}
onChange={(user) => {
setOwner(user ?? defaultOwner);
}}
label="Owner"
size="medium"
/>
)}
</FormFields>
</FormSection>
<form
onSubmit={form.handleSubmit}
aria-label="Create workspace form"
className="flex flex-col gap-10 w-full border border-border-default border-solid rounded-lg p-6"
>
{Boolean(error) && <ErrorAlert error={error} />}
{externalAuth && externalAuth.length > 0 && (
<FormSection
title="External Authentication"
description="This template uses external services for authentication."
>
<FormFields>
{Boolean(error) && !hasAllRequiredExternalAuth && (
<Alert severity="error">
To create a workspace using this template, please connect to
all required external authentication providers listed below.
</Alert>
{mode === "duplicate" && (
<Alert
severity="info"
dismissible
data-testid="duplication-warning"
>
Duplicating a workspace only copies its parameters. No state from
the old workspace is copied over.
</Alert>
)}
<section className="flex flex-col gap-4">
<hgroup>
<h2 className="text-xl font-semibold m-0">General</h2>
<p className="text-sm text-content-secondary mt-0">
{permissions.createWorkspaceForAny
? "Only admins can create workspaces for other users."
: "The name of your new workspace."}
</p>
</hgroup>
<div>
{versionId && versionId !== template.active_version_id && (
<div className="flex flex-col gap-2 pb-4">
<Label className="text-sm" htmlFor={`${id}-version-id`}>
Version ID
</Label>
<Input id={`${id}-version-id`} value={versionId} disabled />
<span className="text-xs text-content-secondary">
This parameter has been preset, and cannot be modified.
</span>
</div>
)}
{externalAuth.map((auth) => (
<ExternalAuthButton
key={auth.id}
error={error}
auth={auth}
isLoading={externalAuthPollingState === "polling"}
onStartPolling={startPollingExternalAuth}
displayRetry={externalAuthPollingState === "abandoned"}
/>
))}
</FormFields>
</FormSection>
)}
<div className="flex gap-4 flex-wrap">
<div className="flex flex-col gap-2 flex-1">
<Label className="text-sm" htmlFor={`${id}-workspace-name`}>
Workspace name
</Label>
<div className="flex flex-col">
<Input
id={`${id}-workspace-name`}
ref={workspaceNameInputRef}
value={form.values.name}
onChange={(e) => {
form.setFieldValue("name", e.target.value.trim());
resetMutation();
}}
disabled={creatingWorkspace}
/>
{form.touched.name && form.errors.name && (
<div className="text-content-destructive text-xs mt-2">
{form.errors.name}
</div>
)}
<div className="flex gap-2 text-xs text-content-secondary items-center">
Need a suggestion?
<Button
variant="subtle"
size="sm"
onClick={async () => {
await form.setFieldValue("name", suggestedName);
rerollSuggestedName();
}}
>
{suggestedName}
</Button>
</div>
</div>
</div>
{permissions.createWorkspaceForAny && (
<div className="flex flex-col gap-2 flex-1">
<Label className="text-sm" htmlFor={`${id}-workspace-name`}>
Owner
</Label>
<UserAutocomplete
value={owner}
onChange={(user) => {
handleOwnerChange(user ?? defaultOwner);
}}
size="medium"
/>
</div>
)}
</div>
</div>
</section>
{parameters.length > 0 && (
<FormSection
title="Parameters"
description="These are the settings used by your template. Please note that immutable parameters cannot be modified once the workspace is created."
>
{/* The parameter fields are densely packed and carry significant information,
hence they require additional vertical spacing for better readability and
user experience. */}
<FormFields css={{ gap: 36 }}>
{externalAuth && externalAuth.length > 0 && (
<section>
<hgroup>
<h2 className="text-xl font-semibold m-0">
External Authentication
</h2>
<p className="text-sm text-content-secondary mt-0">
This template uses external services for authentication.
</p>
</hgroup>
<div className="flex flex-col gap-4">
{Boolean(error) && !hasAllRequiredExternalAuth && (
<Alert severity="error">
To create a workspace using this template, please connect to
all required external authentication providers listed below.
</Alert>
)}
{externalAuth.map((auth) => (
<ExternalAuthButton
key={auth.id}
error={error}
auth={auth}
isLoading={externalAuthPollingState === "polling"}
onStartPolling={startPollingExternalAuth}
displayRetry={externalAuthPollingState === "abandoned"}
/>
))}
</div>
</section>
)}
{parameters.length === 0 && diagnostics.length > 0 && (
<Diagnostics diagnostics={diagnostics} />
)}
{parameters.length > 0 && (
<section className="flex flex-col gap-9">
<hgroup>
<h2 className="text-xl font-semibold m-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.
<Link
href={docs(
"/admin/templates/extending-templates/dynamic-parameters",
)}
>
View docs
</Link>
</p>
</hgroup>
{diagnostics.length > 0 && (
<Diagnostics diagnostics={diagnostics} />
)}
{presets.length > 0 && (
<Stack direction="column" spacing={2}>
<Stack direction="row" spacing={2} alignItems="center">
<span css={styles.description}>
Select a preset to get started
</span>
</Stack>
<Stack direction="column" spacing={2}>
<Stack direction="row" spacing={2}>
<div className="flex flex-col gap-2">
<div className="flex gap-2 items-center">
<Label className="text-sm">Preset</Label>
</div>
<div className="flex flex-col gap-4">
<div className="max-w-lg">
<Combobox
value={
presetOptions[selectedPresetIndex]?.displayName || ""
@@ -418,104 +593,88 @@ export const CreateWorkspacePageView: FC<CreateWorkspacePageViewProps> = ({
);
}}
/>
</Stack>
{/* Only show the preset parameter visibility toggle if preset parameters are actually being modified, otherwise it has no effect. */}
</div>
{/* Only show the preset parameter visibility toggle if preset parameters are actually being modified, otherwise it is ineffectual */}
{presetParameterNames.length > 0 && (
<div
css={{
display: "flex",
alignItems: "center",
gap: "8px",
}}
>
<span className="flex items-center gap-3">
<Switch
id="show-preset-parameters"
checked={showPresetParameters}
onCheckedChange={setShowPresetParameters}
/>
<label
htmlFor="show-preset-parameters"
css={styles.description}
>
<Label htmlFor="show-preset-parameters">
Show preset parameters
</label>
</div>
</Label>
</span>
)}
</Stack>
</Stack>
</div>
</div>
)}
{parameters.map((parameter, index) => {
const parameterField = `rich_parameter_values.${index}`;
const parameterInputName = `${parameterField}.value`;
const isPresetParameter = presetParameterNames.includes(
parameter.name,
);
const isDisabled =
disabledParams?.includes(
parameter.name.toLowerCase().replace(/ /g, "_"),
) ||
creatingWorkspace ||
isPresetParameter;
<div className="flex flex-col gap-9">
{parameters.map((parameter, index) => {
const currentParameterValueIndex =
form.values.rich_parameter_values?.findIndex(
(p) => p.name === parameter.name,
);
const parameterFieldIndex =
currentParameterValueIndex !== undefined
? currentParameterValueIndex
: index;
// Get the form value by parameter name to ensure correct value mapping
const formValue =
currentParameterValueIndex !== undefined
? form.values?.rich_parameter_values?.[
currentParameterValueIndex
]?.value || ""
: "";
const parameterField = `rich_parameter_values.${parameterFieldIndex}`;
const isPresetParameter = presetParameterNames.includes(
parameter.name,
);
const isDisabled =
disabledParams?.includes(
parameter.name.toLowerCase().replace(/ /g, "_"),
) ||
parameter.styling?.disabled ||
creatingWorkspace ||
isPresetParameter;
// Hide preset parameters if showPresetParameters is false
if (!showPresetParameters && isPresetParameter) {
return null;
}
// Always show preset parameters if they have any diagnostics
if (
!showPresetParameters &&
isPresetParameter &&
parameter.diagnostics.length === 0
) {
return null;
}
return (
<div key={parameter.name}>
<RichParameterInput
{...getFieldHelpers(parameterInputName)}
onChange={async (value) => {
await form.setFieldValue(parameterField, {
name: parameter.name,
value,
});
}}
return (
<DynamicParameter
key={parameter.name}
parameter={parameter}
parameterAutofill={autofillByName[parameter.name]}
onChange={(value) =>
handleChange(parameter, parameterField, value)
}
disabled={isDisabled}
isPreset={isPresetParameter}
autofill={autofillByName[parameter.name] !== undefined}
value={formValue}
/>
</div>
);
})}
</FormFields>
</FormSection>
)}
);
})}
</div>
</section>
)}
<FormFooter>
<Button onClick={onCancel} variant="outline">
Cancel
</Button>
<Button
type="submit"
disabled={creatingWorkspace || !hasAllRequiredExternalAuth}
>
<Spinner loading={creatingWorkspace} />
Create workspace
</Button>
</FormFooter>
</HorizontalForm>
</Margins>
<div className="flex flex-row justify-end">
<Button type="submit" disabled={disabled}>
<Spinner loading={creatingWorkspace} />
Create workspace
</Button>
</div>
</form>
</div>
</>
);
};
const styles = {
nameSuggestion: (theme) => ({
color: theme.roles.notice.fill.solid,
padding: "4px 8px",
lineHeight: "inherit",
fontSize: "inherit",
height: "unset",
minWidth: "unset",
}),
hasDescription: {
paddingBottom: 16,
},
description: (theme) => ({
fontSize: 13,
color: theme.palette.text.secondary,
}),
} satisfies Record<string, Interpolation<Theme>>;
@@ -1,61 +0,0 @@
import { chromatic } from "testHelpers/chromatic";
import { MockTemplate, MockUserOwner } from "testHelpers/entities";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { DetailedError } from "api/errors";
import { CreateWorkspacePageViewExperimental } from "./CreateWorkspacePageViewExperimental";
const meta: Meta<typeof CreateWorkspacePageViewExperimental> = {
title: "Pages/CreateWorkspacePageViewExperimental",
parameters: { chromatic },
component: CreateWorkspacePageViewExperimental,
args: {
autofillParameters: [],
diagnostics: [],
defaultName: "",
defaultOwner: MockUserOwner,
externalAuth: [],
externalAuthPollingState: "idle",
hasAllRequiredExternalAuth: true,
mode: "form",
parameters: [],
permissions: {
createWorkspaceForAny: true,
canUpdateTemplate: false,
},
presets: [],
sendMessage: () => {},
template: MockTemplate,
},
};
export default meta;
type Story = StoryObj<typeof CreateWorkspacePageViewExperimental>;
export const WebsocketError: Story = {
args: {
error: new DetailedError(
"Websocket connection for dynamic parameters unexpectedly closed.",
"Refresh the page to reset the form.",
),
},
};
export const WithViewSourceButton: Story = {
args: {
canUpdateTemplate: true,
versionId: "template-version-123",
template: {
...MockTemplate,
organization_name: "default",
name: "docker-template",
},
},
parameters: {
docs: {
description: {
story:
"This story shows the View Source button that appears for template administrators in the experimental workspace creation page. The button allows quick navigation to the template editor.",
},
},
},
};
@@ -1,685 +0,0 @@
import type * as TypesGen from "api/typesGenerated";
import type { FriendlyDiagnostic, PreviewParameter } from "api/typesGenerated";
import { Alert } from "components/Alert/Alert";
import { ErrorAlert } from "components/Alert/ErrorAlert";
import { Avatar } from "components/Avatar/Avatar";
import { Badge } from "components/Badge/Badge";
import { Button } from "components/Button/Button";
import { Combobox } from "components/Combobox/Combobox";
import { Input } from "components/Input/Input";
import { Label } from "components/Label/Label";
import { Link } from "components/Link/Link";
import { Spinner } from "components/Spinner/Spinner";
import { Switch } from "components/Switch/Switch";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "components/Tooltip/Tooltip";
import { UserAutocomplete } from "components/UserAutocomplete/UserAutocomplete";
import { type FormikContextType, useFormik } from "formik";
import type { ExternalAuthPollingState } from "hooks/useExternalAuth";
import { ArrowLeft, CircleHelp, ExternalLinkIcon } from "lucide-react";
import { useSyncFormParameters } from "modules/hooks/useSyncFormParameters";
import {
Diagnostics,
DynamicParameter,
getInitialParameterValues,
useValidationSchemaForDynamicParameters,
} from "modules/workspaces/DynamicParameter/DynamicParameter";
import { generateWorkspaceName } from "modules/workspaces/generateWorkspaceName";
import {
type FC,
useCallback,
useEffect,
useId,
useRef,
useState,
} from "react";
import { Link as RouterLink } from "react-router";
import { docs } from "utils/docs";
import { nameValidator } from "utils/formUtils";
import type { AutofillBuildParameter } from "utils/richParameters";
import * as Yup from "yup";
import type { CreateWorkspaceMode } from "./CreateWorkspacePage";
import { ExternalAuthButton } from "./ExternalAuthButton";
import type { CreateWorkspacePermissions } from "./permissions";
interface CreateWorkspacePageViewExperimentalProps {
autofillParameters: AutofillBuildParameter[];
canUpdateTemplate?: boolean;
creatingWorkspace: boolean;
defaultName?: string | null;
defaultOwner: TypesGen.User;
diagnostics: readonly FriendlyDiagnostic[];
disabledParams?: string[];
error: unknown;
externalAuth: TypesGen.TemplateVersionExternalAuth[];
externalAuthPollingState: ExternalAuthPollingState;
hasAllRequiredExternalAuth: boolean;
mode: CreateWorkspaceMode;
parameters: PreviewParameter[];
permissions: CreateWorkspacePermissions;
presets: TypesGen.Preset[];
template: TypesGen.Template;
versionId?: string;
onCancel: () => void;
onSubmit: (
req: TypesGen.CreateWorkspaceRequest,
owner: TypesGen.User,
) => void;
resetMutation: () => void;
sendMessage: (message: Record<string, string>, ownerId?: string) => void;
startPollingExternalAuth: () => void;
owner: TypesGen.User;
setOwner: (user: TypesGen.User) => void;
}
export const CreateWorkspacePageViewExperimental: FC<
CreateWorkspacePageViewExperimentalProps
> = ({
autofillParameters,
canUpdateTemplate,
creatingWorkspace,
defaultName,
defaultOwner,
diagnostics,
disabledParams,
error,
externalAuth,
externalAuthPollingState,
hasAllRequiredExternalAuth,
mode,
parameters,
permissions,
presets = [],
template,
versionId,
onSubmit,
onCancel,
resetMutation,
sendMessage,
startPollingExternalAuth,
owner,
setOwner,
}) => {
const [suggestedName, setSuggestedName] = useState(generateWorkspaceName);
const [showPresetParameters, setShowPresetParameters] = useState(false);
const id = useId();
const workspaceNameInputRef = useRef<HTMLInputElement>(null);
const rerollSuggestedName = useCallback(() => {
setSuggestedName(() => generateWorkspaceName());
}, []);
const autofillByName = Object.fromEntries(
autofillParameters.map((param) => [param.name, param]),
);
// Only touched fields are sent to the websocket
// Autofilled parameters are marked as touched since they have been modified
const initialTouched = Object.fromEntries(
parameters.filter((p) => autofillByName[p.name]).map((p) => [p.name, true]),
);
// The form parameters values hold the working state of the parameters that will be submitted when creating a workspace
// 1. The form parameter values are initialized from the websocket response when the form is mounted
// 2. Only touched form fields are sent to the websocket, a field is touched if edited by the user or set by autofill
// 3. The websocket response may add or remove parameters, these are added or removed from the form values in the useSyncFormParameters hook
// 4. All existing form parameters are updated to match the websocket response in the useSyncFormParameters hook
const form: FormikContextType<TypesGen.CreateWorkspaceRequest> =
useFormik<TypesGen.CreateWorkspaceRequest>({
initialValues: {
name: defaultName ?? "",
template_id: template.id,
rich_parameter_values: getInitialParameterValues(
parameters,
autofillParameters,
),
},
initialTouched,
validationSchema: Yup.object({
name: nameValidator("Workspace Name"),
rich_parameter_values:
useValidationSchemaForDynamicParameters(parameters),
}),
enableReinitialize: false,
validateOnChange: true,
validateOnBlur: true,
onSubmit: (request) => {
if (!hasAllRequiredExternalAuth) {
return;
}
onSubmit(request, owner);
},
});
useEffect(() => {
if (error) {
window.scrollTo(0, 0);
}
}, [error]);
useEffect(() => {
if (form.submitCount > 0 && Object.keys(form.errors).length > 0) {
workspaceNameInputRef.current?.scrollIntoView({
behavior: "smooth",
block: "center",
});
workspaceNameInputRef.current?.focus();
}
}, [form.submitCount, form.errors]);
const [presetOptions, setPresetOptions] = useState([
{ displayName: "None", value: "undefined", icon: "", description: "" },
]);
const [selectedPresetIndex, setSelectedPresetIndex] = useState(0);
// Build options and keep default label/value in sync
useEffect(() => {
const options = [
{ displayName: "None", value: "undefined", icon: "", description: "" },
...presets.map((preset) => ({
displayName: preset.Default ? `${preset.Name} (Default)` : preset.Name,
value: preset.ID,
icon: preset.Icon,
description: preset.Description,
})),
];
setPresetOptions(options);
const defaultPreset = presets.find((p) => p.Default);
if (defaultPreset) {
const idx = presets.indexOf(defaultPreset) + 1; // +1 for "None"
setSelectedPresetIndex(idx);
form.setFieldValue("template_version_preset_id", defaultPreset.ID);
} else {
setSelectedPresetIndex(0); // Explicitly set to "None"
form.setFieldValue("template_version_preset_id", undefined);
}
}, [presets, form.setFieldValue]);
const [presetParameterNames, setPresetParameterNames] = useState<string[]>(
[],
);
// include any modified parameters and all touched parameters to the websocket request
const sendDynamicParamsRequest = useCallback(
(
parameters: Array<{ parameter: PreviewParameter; value: string }>,
ownerId?: string,
) => {
const formInputs: Record<string, string> = {};
const formParameters = form.values.rich_parameter_values ?? [];
for (const { parameter, value } of parameters) {
formInputs[parameter.name] = value;
}
for (const [fieldName, isTouched] of Object.entries(form.touched)) {
if (
isTouched &&
!parameters.some((p) => p.parameter.name === fieldName)
) {
const param = formParameters.find((p) => p.name === fieldName);
if (param?.value) {
formInputs[fieldName] = param.value;
}
}
}
sendMessage(formInputs, ownerId);
},
[form.touched, form.values.rich_parameter_values, sendMessage],
);
useEffect(() => {
const selectedPresetOption = presetOptions[selectedPresetIndex];
let selectedPreset: TypesGen.Preset | undefined;
for (const preset of presets) {
if (preset.ID === selectedPresetOption.value) {
selectedPreset = preset;
break;
}
}
if (!selectedPreset || !selectedPreset.Parameters) {
setPresetParameterNames([]);
return;
}
setPresetParameterNames(selectedPreset.Parameters.map((p) => p.Name));
const currentValues = form.values.rich_parameter_values ?? [];
const updates: Array<{
field: string;
fieldValue: TypesGen.WorkspaceBuildParameter;
parameter: PreviewParameter;
presetValue: string;
}> = [];
for (const presetParameter of selectedPreset.Parameters) {
const parameterIndex = parameters.findIndex(
(p) => p.name === presetParameter.Name,
);
if (parameterIndex === -1) continue;
const parameterField = `rich_parameter_values.${parameterIndex}`;
const parameter = parameters[parameterIndex];
const currentValue = currentValues.find(
(p) => p.name === presetParameter.Name,
)?.value;
if (currentValue !== presetParameter.Value) {
updates.push({
field: parameterField,
fieldValue: {
name: presetParameter.Name,
value: presetParameter.Value,
},
parameter,
presetValue: presetParameter.Value,
});
}
}
if (updates.length > 0) {
for (const update of updates) {
form.setFieldValue(update.field, update.fieldValue);
form.setFieldTouched(update.parameter.name, true);
}
sendDynamicParamsRequest(
updates.map((update) => ({
parameter: update.parameter,
value: update.presetValue,
})),
);
}
}, [
presetOptions,
selectedPresetIndex,
presets,
form.setFieldValue,
form.setFieldTouched,
parameters,
form.values.rich_parameter_values,
sendDynamicParamsRequest,
]);
const handleOwnerChange = (user: TypesGen.User) => {
setOwner(user);
sendDynamicParamsRequest([], user.id);
};
const handleChange = async (
parameter: PreviewParameter,
parameterField: string,
value: string,
) => {
const currentFormValue = form.values.rich_parameter_values?.find(
(p) => p.name === parameter.name,
)?.value;
await form.setFieldValue(parameterField, {
name: parameter.name,
value,
});
// Only send the request if the value has changed from the form value
if (currentFormValue !== value) {
form.setFieldTouched(parameter.name, true);
sendDynamicParamsRequest([{ parameter, value }]);
}
};
useSyncFormParameters({
parameters,
formValues: form.values.rich_parameter_values ?? [],
setFieldValue: form.setFieldValue,
});
return (
<>
<div className="sticky top-5 ml-10">
<button
onClick={onCancel}
type="button"
className="flex items-center gap-2 bg-transparent border-none text-content-secondary hover:text-content-primary translate-y-12"
>
<ArrowLeft size={20} />
Go back
</button>
</div>
<div className="flex flex-col gap-6 max-w-screen-md mx-auto">
<header className="flex flex-col items-start gap-3 mt-10">
<div className="flex items-center gap-2 justify-between w-full">
<span className="flex items-center gap-2">
<Avatar
variant="icon"
size="md"
src={template.icon}
fallback={template.name}
/>
<p className="text-base font-medium m-0">
{template.display_name.length > 0
? template.display_name
: template.name}
</p>
{template.deprecated && (
<Badge variant="warning" size="sm">
Deprecated
</Badge>
)}
</span>
{canUpdateTemplate && (
<Button asChild size="sm" variant="outline">
<RouterLink
to={`/templates/${template.organization_name}/${template.name}/versions/${versionId}/edit`}
>
<ExternalLinkIcon />
View source
</RouterLink>
</Button>
)}
</div>
<span className="flex flex-row items-center gap-2">
<h1 className="text-3xl font-semibold m-0">New workspace</h1>
<Tooltip>
<TooltipTrigger asChild>
<CircleHelp className="size-icon-xs text-content-secondary" />
</TooltipTrigger>
<TooltipContent className="max-w-xs text-sm">
Dynamic Parameters enhances Coder's existing parameter system
with real-time validation, conditional parameter behavior, and
richer input types.
<br />
<Link
href={docs(
"/admin/templates/extending-templates/dynamic-parameters",
)}
>
View docs
</Link>
</TooltipContent>
</Tooltip>
</span>
</header>
<form
onSubmit={form.handleSubmit}
aria-label="Create workspace form"
className="flex flex-col gap-10 w-full border border-border-default border-solid rounded-lg p-6"
>
{Boolean(error) && <ErrorAlert error={error} />}
{mode === "duplicate" && (
<Alert
severity="info"
dismissible
data-testid="duplication-warning"
>
Duplicating a workspace only copies its parameters. No state from
the old workspace is copied over.
</Alert>
)}
<section className="flex flex-col gap-4">
<hgroup>
<h2 className="text-xl font-semibold m-0">General</h2>
<p className="text-sm text-content-secondary mt-0">
{permissions.createWorkspaceForAny
? "Only admins can create workspaces for other users."
: "The name of your new workspace."}
</p>
</hgroup>
<div>
{versionId && versionId !== template.active_version_id && (
<div className="flex flex-col gap-2 pb-4">
<Label className="text-sm" htmlFor={`${id}-version-id`}>
Version ID
</Label>
<Input id={`${id}-version-id`} value={versionId} disabled />
<span className="text-xs text-content-secondary">
This parameter has been preset, and cannot be modified.
</span>
</div>
)}
<div className="flex gap-4 flex-wrap">
<div className="flex flex-col gap-2 flex-1">
<Label className="text-sm" htmlFor={`${id}-workspace-name`}>
Workspace name
</Label>
<div className="flex flex-col">
<Input
id={`${id}-workspace-name`}
ref={workspaceNameInputRef}
value={form.values.name}
onChange={(e) => {
form.setFieldValue("name", e.target.value.trim());
resetMutation();
}}
disabled={creatingWorkspace}
/>
{form.touched.name && form.errors.name && (
<div className="text-content-destructive text-xs mt-2">
{form.errors.name}
</div>
)}
<div className="flex gap-2 text-xs text-content-secondary items-center">
Need a suggestion?
<Button
variant="subtle"
size="sm"
onClick={async () => {
await form.setFieldValue("name", suggestedName);
rerollSuggestedName();
}}
>
{suggestedName}
</Button>
</div>
</div>
</div>
{permissions.createWorkspaceForAny && (
<div className="flex flex-col gap-2 flex-1">
<Label className="text-sm" htmlFor={`${id}-workspace-name`}>
Owner
</Label>
<UserAutocomplete
value={owner}
onChange={(user) => {
handleOwnerChange(user ?? defaultOwner);
}}
size="medium"
/>
</div>
)}
</div>
</div>
</section>
{externalAuth && externalAuth.length > 0 && (
<section>
<hgroup>
<h2 className="text-xl font-semibold m-0">
External Authentication
</h2>
<p className="text-sm text-content-secondary mt-0">
This template uses external services for authentication.
</p>
</hgroup>
<div className="flex flex-col gap-4">
{Boolean(error) && !hasAllRequiredExternalAuth && (
<Alert severity="error">
To create a workspace using this template, please connect to
all required external authentication providers listed below.
</Alert>
)}
{externalAuth.map((auth) => (
<ExternalAuthButton
key={auth.id}
error={error}
auth={auth}
isLoading={externalAuthPollingState === "polling"}
onStartPolling={startPollingExternalAuth}
displayRetry={externalAuthPollingState === "abandoned"}
/>
))}
</div>
</section>
)}
{parameters.length === 0 && diagnostics.length > 0 && (
<Diagnostics diagnostics={diagnostics} />
)}
{parameters.length > 0 && (
<section className="flex flex-col gap-9">
<hgroup>
<h2 className="text-xl font-semibold m-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.
<Link
href={docs(
"/admin/templates/extending-templates/dynamic-parameters",
)}
>
View docs
</Link>
</p>
</hgroup>
{diagnostics.length > 0 && (
<Diagnostics diagnostics={diagnostics} />
)}
{presets.length > 0 && (
<div className="flex flex-col gap-2">
<div className="flex gap-2 items-center">
<Label className="text-sm">Preset</Label>
</div>
<div className="flex flex-col gap-4">
<div className="max-w-lg">
<Combobox
value={
presetOptions[selectedPresetIndex]?.displayName || ""
}
options={presetOptions}
placeholder="Select a preset"
onSelect={(value) => {
const index = presetOptions.findIndex(
(preset) => preset.value === value,
);
if (index === -1) {
return;
}
setSelectedPresetIndex(index);
form.setFieldValue(
"template_version_preset_id",
// "undefined" string is equivalent to using None option
// Combobox requires a value in order to correctly highlight the None option
presetOptions[index].value === "undefined"
? undefined
: presetOptions[index].value,
);
}}
/>
</div>
{/* Only show the preset parameter visibility toggle if preset parameters are actually being modified, otherwise it is ineffectual */}
{presetParameterNames.length > 0 && (
<span className="flex items-center gap-3">
<Switch
id="show-preset-parameters"
checked={showPresetParameters}
onCheckedChange={setShowPresetParameters}
/>
<Label htmlFor="show-preset-parameters">
Show preset parameters
</Label>
</span>
)}
</div>
</div>
)}
<div className="flex flex-col gap-9">
{parameters.map((parameter, index) => {
const currentParameterValueIndex =
form.values.rich_parameter_values?.findIndex(
(p) => p.name === parameter.name,
);
const parameterFieldIndex =
currentParameterValueIndex !== undefined
? currentParameterValueIndex
: index;
// Get the form value by parameter name to ensure correct value mapping
const formValue =
currentParameterValueIndex !== undefined
? form.values?.rich_parameter_values?.[
currentParameterValueIndex
]?.value || ""
: "";
const parameterField = `rich_parameter_values.${parameterFieldIndex}`;
const isPresetParameter = presetParameterNames.includes(
parameter.name,
);
const isDisabled =
disabledParams?.includes(
parameter.name.toLowerCase().replace(/ /g, "_"),
) ||
parameter.styling?.disabled ||
creatingWorkspace ||
isPresetParameter;
// Always show preset parameters if they have any diagnostics
if (
!showPresetParameters &&
isPresetParameter &&
parameter.diagnostics.length === 0
) {
return null;
}
return (
<DynamicParameter
key={parameter.name}
parameter={parameter}
onChange={(value) =>
handleChange(parameter, parameterField, value)
}
disabled={isDisabled}
isPreset={isPresetParameter}
autofill={autofillByName[parameter.name] !== undefined}
value={formValue}
/>
);
})}
</div>
</section>
)}
<div className="flex flex-row justify-end">
<Button
type="submit"
disabled={
creatingWorkspace ||
!hasAllRequiredExternalAuth ||
diagnostics.some(
(diagnostic) => diagnostic.severity === "error",
) ||
parameters.some((parameter) =>
parameter.diagnostics.some(
(diagnostic) => diagnostic.severity === "error",
),
)
}
>
<Spinner loading={creatingWorkspace} />
Create workspace
</Button>
</div>
</form>
</div>
</>
);
};
@@ -86,7 +86,11 @@ export const StartButton: FC<ActionButtonPropsWithWorkspace> = ({
tooltipText,
}) => {
let mainButton = (
<TopbarButton onClick={() => handleAction()} disabled={disabled || loading}>
<TopbarButton
data-testid="workspace-start"
onClick={() => handleAction()}
disabled={disabled || loading}
>
<PlayIcon />
{loading ? <>Starting&hellip;</> : "Start"}
</TopbarButton>
+3 -3
View File
@@ -103,8 +103,8 @@ const TemplateResourcesPage = lazy(
() =>
import("./pages/TemplatePage/TemplateResourcesPage/TemplateResourcesPage"),
);
const CreateWorkspaceExperimentRouter = lazy(
() => import("./pages/CreateWorkspacePage/CreateWorkspaceExperimentRouter"),
const CreateWorkspacePage = lazy(
() => import("./pages/CreateWorkspacePage/CreateWorkspacePage"),
);
const OverviewPage = lazy(
() => import("./pages/DeploymentSettingsPage/OverviewPage/OverviewPage"),
@@ -363,7 +363,7 @@ const templateRouter = () => {
<Route path="insights" element={<TemplateInsightsPage />} />
</Route>
<Route path="workspace" element={<CreateWorkspaceExperimentRouter />} />
<Route path="workspace" element={<CreateWorkspacePage />} />
<Route path="settings" element={<TemplateSettingsLayout />}>
<Route index element={<TemplateSettingsPage />} />
-25
View File
@@ -1792,31 +1792,6 @@ export const MockTemplateVersionVariable5: TypesGen.TemplateVersionVariable = {
sensitive: false,
};
export const MockWorkspaceRequest: TypesGen.CreateWorkspaceRequest = {
name: "test",
template_version_id: "test-template-version",
rich_parameter_values: [],
};
export const MockWorkspaceRichParametersRequest: TypesGen.CreateWorkspaceRequest =
{
name: "test",
template_version_id: "test-template-version",
rich_parameter_values: [
{
name: MockTemplateVersionParameter1.name,
value: MockTemplateVersionParameter1.default_value,
},
],
};
const _MockUserAgent = {
browser: "Chrome 99.0.4844",
device: "Other",
ip_address: "11.22.33.44",
os: "Windows 10",
};
export const MockAuthMethodsPasswordOnly: TypesGen.AuthMethods = {
password: { enabled: true },
github: { enabled: false, default_provider_configured: true },