mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
chore: remove classic parameters frontend code (#20710)
This commit is contained in:
+118
-116
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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¶m.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¶m.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…</> : "Start"}
|
||||
</TopbarButton>
|
||||
|
||||
+3
-3
@@ -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 />} />
|
||||
|
||||
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user