diff --git a/site/e2e/parameters.ts b/site/e2e/parameters.ts index f7e02ad20f1ee..23f953a49e2a8 100644 --- a/site/e2e/parameters.ts +++ b/site/e2e/parameters.ts @@ -2,7 +2,7 @@ import type { RichParameter } from "./provisionerGenerated"; // Rich parameters -const emptyParameter: RichParameter = { +export const emptyParameter: RichParameter = { name: "", description: "", type: "", diff --git a/site/e2e/tests/workspaces/autoCreateWorkspace.spec.ts b/site/e2e/tests/workspaces/autoCreateWorkspace.spec.ts new file mode 100644 index 0000000000000..3a9aaee2eeb3c --- /dev/null +++ b/site/e2e/tests/workspaces/autoCreateWorkspace.spec.ts @@ -0,0 +1,65 @@ +import { test, expect } from "@playwright/test"; +import { username } from "../../constants"; +import { + createTemplate, + createWorkspace, + echoResponsesWithParameters, +} from "../../helpers"; +import { emptyParameter } from "../../parameters"; +import type { RichParameter } from "../../provisionerGenerated"; + +test("create workspace in auto mode", async ({ page }) => { + const richParameters: RichParameter[] = [ + { ...emptyParameter, name: "repo", type: "string" }, + ]; + const template = await createTemplate( + page, + echoResponsesWithParameters(richParameters), + ); + const name = "test-workspace"; + await page.goto( + `/templates/${template}/workspace?mode=auto¶m.repo=example&name=${name}`, + { + waitUntil: "domcontentloaded", + }, + ); + await expect(page).toHaveTitle(`${username}/${name} - Coder`); +}); + +test("use an existing workspace that matches the `match` parameter instead of creating a new one", async ({ + page, +}) => { + const richParameters: RichParameter[] = [ + { ...emptyParameter, name: "repo", type: "string" }, + ]; + const template = await createTemplate( + page, + echoResponsesWithParameters(richParameters), + ); + const prevWorkspace = await createWorkspace(page, template); + await page.goto( + `/templates/${template}/workspace?mode=auto¶m.repo=example&name=new-name&match=name:${prevWorkspace}`, + { + waitUntil: "domcontentloaded", + }, + ); + await expect(page).toHaveTitle(`${username}/${prevWorkspace} - Coder`); +}); + +test("show error if `match` parameter is invalid", async ({ page }) => { + const richParameters: RichParameter[] = [ + { ...emptyParameter, name: "repo", type: "string" }, + ]; + const template = await createTemplate( + page, + echoResponsesWithParameters(richParameters), + ); + const prevWorkspace = await createWorkspace(page, template); + await page.goto( + `/templates/${template}/workspace?mode=auto¶m.repo=example&name=new-name&match=not-valid-query:${prevWorkspace}`, + { + waitUntil: "domcontentloaded", + }, + ); + await expect(page.getByText("Invalid match value")).toBeVisible(); +}); diff --git a/site/e2e/tests/createWorkspace.spec.ts b/site/e2e/tests/workspaces/createWorkspace.spec.ts similarity index 96% rename from site/e2e/tests/createWorkspace.spec.ts rename to site/e2e/tests/workspaces/createWorkspace.spec.ts index 5f1713b60aaa7..affec154add06 100644 --- a/site/e2e/tests/createWorkspace.spec.ts +++ b/site/e2e/tests/workspaces/createWorkspace.spec.ts @@ -7,8 +7,8 @@ import { openTerminalWindow, requireTerraformProvisioner, verifyParameters, -} from "../helpers"; -import { beforeCoderTest } from "../hooks"; +} from "../../helpers"; +import { beforeCoderTest } from "../../hooks"; import { secondParameter, fourthParameter, @@ -18,8 +18,8 @@ import { seventhParameter, sixthParameter, randParamName, -} from "../parameters"; -import type { RichParameter } from "../provisionerGenerated"; +} from "../../parameters"; +import type { RichParameter } from "../../provisionerGenerated"; test.beforeEach(({ page }) => beforeCoderTest(page)); diff --git a/site/e2e/tests/restartWorkspace.spec.ts b/site/e2e/tests/workspaces/restartWorkspace.spec.ts similarity index 87% rename from site/e2e/tests/restartWorkspace.spec.ts rename to site/e2e/tests/workspaces/restartWorkspace.spec.ts index 0da42b1257d6a..9b45ffe3371a5 100644 --- a/site/e2e/tests/restartWorkspace.spec.ts +++ b/site/e2e/tests/workspaces/restartWorkspace.spec.ts @@ -5,10 +5,10 @@ import { createWorkspace, echoResponsesWithParameters, verifyParameters, -} from "../helpers"; -import { beforeCoderTest } from "../hooks"; -import { firstBuildOption, secondBuildOption } from "../parameters"; -import type { RichParameter } from "../provisionerGenerated"; +} from "../../helpers"; +import { beforeCoderTest } from "../../hooks"; +import { firstBuildOption, secondBuildOption } from "../../parameters"; +import type { RichParameter } from "../../provisionerGenerated"; test.beforeEach(({ page }) => beforeCoderTest(page)); diff --git a/site/e2e/tests/startWorkspace.spec.ts b/site/e2e/tests/workspaces/startWorkspace.spec.ts similarity index 87% rename from site/e2e/tests/startWorkspace.spec.ts rename to site/e2e/tests/workspaces/startWorkspace.spec.ts index eb180c3df4ff9..37f4766558e10 100644 --- a/site/e2e/tests/startWorkspace.spec.ts +++ b/site/e2e/tests/workspaces/startWorkspace.spec.ts @@ -6,10 +6,10 @@ import { echoResponsesWithParameters, stopWorkspace, verifyParameters, -} from "../helpers"; -import { beforeCoderTest } from "../hooks"; -import { firstBuildOption, secondBuildOption } from "../parameters"; -import type { RichParameter } from "../provisionerGenerated"; +} from "../../helpers"; +import { beforeCoderTest } from "../../hooks"; +import { firstBuildOption, secondBuildOption } from "../../parameters"; +import type { RichParameter } from "../../provisionerGenerated"; test.beforeEach(({ page }) => beforeCoderTest(page)); diff --git a/site/e2e/tests/updateWorkspace.spec.ts b/site/e2e/tests/workspaces/updateWorkspace.spec.ts similarity index 96% rename from site/e2e/tests/updateWorkspace.spec.ts rename to site/e2e/tests/workspaces/updateWorkspace.spec.ts index 40b62aa1fc091..5d7957e29a9ea 100644 --- a/site/e2e/tests/updateWorkspace.spec.ts +++ b/site/e2e/tests/workspaces/updateWorkspace.spec.ts @@ -7,16 +7,16 @@ import { updateWorkspace, updateWorkspaceParameters, verifyParameters, -} from "../helpers"; -import { beforeCoderTest } from "../hooks"; +} from "../../helpers"; +import { beforeCoderTest } from "../../hooks"; import { fifthParameter, firstParameter, secondParameter, sixthParameter, secondBuildOption, -} from "../parameters"; -import type { RichParameter } from "../provisionerGenerated"; +} from "../../parameters"; +import type { RichParameter } from "../../provisionerGenerated"; test.beforeEach(({ page }) => beforeCoderTest(page)); diff --git a/site/src/api/errors.ts b/site/src/api/errors.ts index 621b19856601b..8f69e06fc4dc0 100644 --- a/site/src/api/errors.ts +++ b/site/src/api/errors.ts @@ -111,6 +111,10 @@ export const getValidationErrorMessage = (error: unknown): string => { }; export const getErrorDetail = (error: unknown): string | undefined => { + if (error instanceof DetailedError) { + return error.detail; + } + if (error instanceof Error) { return "Please check the developer console for more details."; } @@ -125,3 +129,12 @@ export const getErrorDetail = (error: unknown): string | undefined => { return undefined; }; + +export class DetailedError extends Error { + constructor( + message: string, + public detail?: string, + ) { + super(message); + } +} diff --git a/site/src/api/queries/workspaces.ts b/site/src/api/queries/workspaces.ts index aa5f8f29a9783..71ac8c055f64f 100644 --- a/site/src/api/queries/workspaces.ts +++ b/site/src/api/queries/workspaces.ts @@ -5,6 +5,7 @@ import type { UseMutationOptions, } from "react-query"; import { type DeleteWorkspaceOptions, API } from "api/api"; +import { DetailedError, isApiValidationError } from "api/errors"; import type { CreateWorkspaceRequest, ProvisionerLogLevel, @@ -36,14 +37,6 @@ export const workspaceByOwnerAndName = (owner: string, name: string) => { }; }; -type AutoCreateWorkspaceOptions = { - templateName: string; - versionId?: string; - organizationId: string; - defaultBuildParameters?: WorkspaceBuildParameter[]; - defaultName: string; -}; - type CreateWorkspaceMutationVariables = CreateWorkspaceRequest & { userId: string; organizationId: string; @@ -61,19 +54,45 @@ export const createWorkspace = (queryClient: QueryClient) => { }; }; +type AutoCreateWorkspaceOptions = { + organizationId: string; + templateName: string; + workspaceName: string; + /** + * If provided, the auto-create workspace feature will attempt to find a + * matching workspace. If found, it will return the existing workspace instead + * of creating a new one. Its value supports [advanced filtering queries for + * workspaces](https://coder.com/docs/workspaces#workspace-filtering). If + * multiple values are returned, the first one will be returned. + */ + match: string | null; + templateVersionId?: string; + buildParameters?: WorkspaceBuildParameter[]; +}; + export const autoCreateWorkspace = (queryClient: QueryClient) => { return { mutationFn: async ({ - templateName, - versionId, organizationId, - defaultBuildParameters, - defaultName, + templateName, + workspaceName, + templateVersionId, + buildParameters, + match, }: AutoCreateWorkspaceOptions) => { + if (match) { + const matchWorkspace = await findMatchWorkspace( + `owner:me template:${templateName} ${match}`, + ); + if (matchWorkspace) { + return matchWorkspace; + } + } + let templateVersionParameters; - if (versionId) { - templateVersionParameters = { template_version_id: versionId }; + if (templateVersionId) { + templateVersionParameters = { template_version_id: templateVersionId }; } else { const template = await API.getTemplateByName( organizationId, @@ -84,8 +103,8 @@ export const autoCreateWorkspace = (queryClient: QueryClient) => { return API.createWorkspace(organizationId, "me", { ...templateVersionParameters, - name: defaultName, - rich_parameter_values: defaultBuildParameters, + name: workspaceName, + rich_parameter_values: buildParameters, }); }, onSuccess: async () => { @@ -94,6 +113,27 @@ export const autoCreateWorkspace = (queryClient: QueryClient) => { }; }; +async function findMatchWorkspace(q: string): Promise { + try { + const { workspaces } = await API.getWorkspaces({ q, limit: 1 }); + const matchWorkspace = workspaces.at(0); + if (matchWorkspace) { + return matchWorkspace; + } + } catch (err) { + if (isApiValidationError(err)) { + const firstValidationErrorDetail = + err.response.data.validations?.[0].detail; + throw new DetailedError( + "Invalid match value", + firstValidationErrorDetail, + ); + } + + throw err; + } +} + export function workspacesKey(config: WorkspacesRequest = {}) { const { q, limit } = config; return ["workspaces", { q, limit }] as const; diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx index c2cd9ab9da3ae..fd7182fe6fbb6 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx @@ -16,7 +16,6 @@ import type { UserParameter, Workspace, } from "api/typesGenerated"; -import { ErrorAlert } from "components/Alert/ErrorAlert"; import { Loader } from "components/Loader/Loader"; import { useAuthenticated } from "contexts/auth/RequireAuth"; import { useEffectEvent } from "hooks/hookPolyfills"; @@ -37,7 +36,7 @@ const CreateWorkspacePage: FC = () => { const { template: templateName } = useParams() as { template: string }; const { user: me } = useAuthenticated(); const navigate = useNavigate(); - const [searchParams, setSearchParams] = useSearchParams(); + const [searchParams] = useSearchParams(); const { experiments, organizationId } = useDashboard(); const customVersionId = searchParams.get("version") ?? undefined; @@ -118,15 +117,15 @@ const CreateWorkspacePage: FC = () => { const newWorkspace = await autoCreateWorkspaceMutation.mutateAsync({ templateName, organizationId, - defaultBuildParameters: autofillParameters, - defaultName: defaultName ?? generateWorkspaceName(), - versionId: realizedVersionId, + buildParameters: autofillParameters, + workspaceName: defaultName ?? generateWorkspaceName(), + templateVersionId: realizedVersionId, + match: searchParams.get("match"), }); onCreateWorkspace(newWorkspace); } catch (err) { - searchParams.delete("mode"); - setSearchParams(searchParams); + setMode("form"); } }); @@ -175,7 +174,6 @@ const CreateWorkspacePage: FC = () => { {pageTitle(title)} - {loadFormDataError && } {isLoadingFormData || isLoadingExternalAuth || autoCreateReady ? ( ) : ( @@ -185,7 +183,12 @@ const CreateWorkspacePage: FC = () => { disabledParams={disabledParams} defaultOwner={me} autofillParameters={autofillParameters} - error={createWorkspaceMutation.error || autoCreateError} + error={ + createWorkspaceMutation.error || + autoCreateError || + loadFormDataError || + autoCreateWorkspaceMutation.error + } resetMutation={createWorkspaceMutation.reset} template={templateQuery.data!} versionId={realizedVersionId}