Skip to content

Commit 6bf7e5a

Browse files
feat(site): support match option for auto create workspace flow (#13836)
1 parent 8c33b02 commit 6bf7e5a

File tree

9 files changed

+163
-42
lines changed

9 files changed

+163
-42
lines changed

site/e2e/parameters.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import type { RichParameter } from "./provisionerGenerated";
22

33
// Rich parameters
44

5-
const emptyParameter: RichParameter = {
5+
export const emptyParameter: RichParameter = {
66
name: "",
77
description: "",
88
type: "",
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { test, expect } from "@playwright/test";
2+
import { username } from "../../constants";
3+
import {
4+
createTemplate,
5+
createWorkspace,
6+
echoResponsesWithParameters,
7+
} from "../../helpers";
8+
import { emptyParameter } from "../../parameters";
9+
import type { RichParameter } from "../../provisionerGenerated";
10+
11+
test("create workspace in auto mode", async ({ page }) => {
12+
const richParameters: RichParameter[] = [
13+
{ ...emptyParameter, name: "repo", type: "string" },
14+
];
15+
const template = await createTemplate(
16+
page,
17+
echoResponsesWithParameters(richParameters),
18+
);
19+
const name = "test-workspace";
20+
await page.goto(
21+
`/templates/${template}/workspace?mode=auto&param.repo=example&name=${name}`,
22+
{
23+
waitUntil: "domcontentloaded",
24+
},
25+
);
26+
await expect(page).toHaveTitle(`${username}/${name} - Coder`);
27+
});
28+
29+
test("use an existing workspace that matches the `match` parameter instead of creating a new one", async ({
30+
page,
31+
}) => {
32+
const richParameters: RichParameter[] = [
33+
{ ...emptyParameter, name: "repo", type: "string" },
34+
];
35+
const template = await createTemplate(
36+
page,
37+
echoResponsesWithParameters(richParameters),
38+
);
39+
const prevWorkspace = await createWorkspace(page, template);
40+
await page.goto(
41+
`/templates/${template}/workspace?mode=auto&param.repo=example&name=new-name&match=name:${prevWorkspace}`,
42+
{
43+
waitUntil: "domcontentloaded",
44+
},
45+
);
46+
await expect(page).toHaveTitle(`${username}/${prevWorkspace} - Coder`);
47+
});
48+
49+
test("show error if `match` parameter is invalid", async ({ page }) => {
50+
const richParameters: RichParameter[] = [
51+
{ ...emptyParameter, name: "repo", type: "string" },
52+
];
53+
const template = await createTemplate(
54+
page,
55+
echoResponsesWithParameters(richParameters),
56+
);
57+
const prevWorkspace = await createWorkspace(page, template);
58+
await page.goto(
59+
`/templates/${template}/workspace?mode=auto&param.repo=example&name=new-name&match=not-valid-query:${prevWorkspace}`,
60+
{
61+
waitUntil: "domcontentloaded",
62+
},
63+
);
64+
await expect(page.getByText("Invalid match value")).toBeVisible();
65+
});

site/e2e/tests/createWorkspace.spec.ts renamed to site/e2e/tests/workspaces/createWorkspace.spec.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@ import {
77
openTerminalWindow,
88
requireTerraformProvisioner,
99
verifyParameters,
10-
} from "../helpers";
11-
import { beforeCoderTest } from "../hooks";
10+
} from "../../helpers";
11+
import { beforeCoderTest } from "../../hooks";
1212
import {
1313
secondParameter,
1414
fourthParameter,
@@ -18,8 +18,8 @@ import {
1818
seventhParameter,
1919
sixthParameter,
2020
randParamName,
21-
} from "../parameters";
22-
import type { RichParameter } from "../provisionerGenerated";
21+
} from "../../parameters";
22+
import type { RichParameter } from "../../provisionerGenerated";
2323

2424
test.beforeEach(({ page }) => beforeCoderTest(page));
2525

site/e2e/tests/restartWorkspace.spec.ts renamed to site/e2e/tests/workspaces/restartWorkspace.spec.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,10 @@ import {
55
createWorkspace,
66
echoResponsesWithParameters,
77
verifyParameters,
8-
} from "../helpers";
9-
import { beforeCoderTest } from "../hooks";
10-
import { firstBuildOption, secondBuildOption } from "../parameters";
11-
import type { RichParameter } from "../provisionerGenerated";
8+
} from "../../helpers";
9+
import { beforeCoderTest } from "../../hooks";
10+
import { firstBuildOption, secondBuildOption } from "../../parameters";
11+
import type { RichParameter } from "../../provisionerGenerated";
1212

1313
test.beforeEach(({ page }) => beforeCoderTest(page));
1414

site/e2e/tests/startWorkspace.spec.ts renamed to site/e2e/tests/workspaces/startWorkspace.spec.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,10 @@ import {
66
echoResponsesWithParameters,
77
stopWorkspace,
88
verifyParameters,
9-
} from "../helpers";
10-
import { beforeCoderTest } from "../hooks";
11-
import { firstBuildOption, secondBuildOption } from "../parameters";
12-
import type { RichParameter } from "../provisionerGenerated";
9+
} from "../../helpers";
10+
import { beforeCoderTest } from "../../hooks";
11+
import { firstBuildOption, secondBuildOption } from "../../parameters";
12+
import type { RichParameter } from "../../provisionerGenerated";
1313

1414
test.beforeEach(({ page }) => beforeCoderTest(page));
1515

site/e2e/tests/updateWorkspace.spec.ts renamed to site/e2e/tests/workspaces/updateWorkspace.spec.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,16 +7,16 @@ import {
77
updateWorkspace,
88
updateWorkspaceParameters,
99
verifyParameters,
10-
} from "../helpers";
11-
import { beforeCoderTest } from "../hooks";
10+
} from "../../helpers";
11+
import { beforeCoderTest } from "../../hooks";
1212
import {
1313
fifthParameter,
1414
firstParameter,
1515
secondParameter,
1616
sixthParameter,
1717
secondBuildOption,
18-
} from "../parameters";
19-
import type { RichParameter } from "../provisionerGenerated";
18+
} from "../../parameters";
19+
import type { RichParameter } from "../../provisionerGenerated";
2020

2121
test.beforeEach(({ page }) => beforeCoderTest(page));
2222

site/src/api/errors.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,10 @@ export const getValidationErrorMessage = (error: unknown): string => {
111111
};
112112

113113
export const getErrorDetail = (error: unknown): string | undefined => {
114+
if (error instanceof DetailedError) {
115+
return error.detail;
116+
}
117+
114118
if (error instanceof Error) {
115119
return "Please check the developer console for more details.";
116120
}
@@ -125,3 +129,12 @@ export const getErrorDetail = (error: unknown): string | undefined => {
125129

126130
return undefined;
127131
};
132+
133+
export class DetailedError extends Error {
134+
constructor(
135+
message: string,
136+
public detail?: string,
137+
) {
138+
super(message);
139+
}
140+
}

site/src/api/queries/workspaces.ts

Lines changed: 56 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type {
55
UseMutationOptions,
66
} from "react-query";
77
import { type DeleteWorkspaceOptions, API } from "api/api";
8+
import { DetailedError, isApiValidationError } from "api/errors";
89
import type {
910
CreateWorkspaceRequest,
1011
ProvisionerLogLevel,
@@ -36,14 +37,6 @@ export const workspaceByOwnerAndName = (owner: string, name: string) => {
3637
};
3738
};
3839

39-
type AutoCreateWorkspaceOptions = {
40-
templateName: string;
41-
versionId?: string;
42-
organizationId: string;
43-
defaultBuildParameters?: WorkspaceBuildParameter[];
44-
defaultName: string;
45-
};
46-
4740
type CreateWorkspaceMutationVariables = CreateWorkspaceRequest & {
4841
userId: string;
4942
organizationId: string;
@@ -61,19 +54,45 @@ export const createWorkspace = (queryClient: QueryClient) => {
6154
};
6255
};
6356

57+
type AutoCreateWorkspaceOptions = {
58+
organizationId: string;
59+
templateName: string;
60+
workspaceName: string;
61+
/**
62+
* If provided, the auto-create workspace feature will attempt to find a
63+
* matching workspace. If found, it will return the existing workspace instead
64+
* of creating a new one. Its value supports [advanced filtering queries for
65+
* workspaces](https://coder.com/docs/workspaces#workspace-filtering). If
66+
* multiple values are returned, the first one will be returned.
67+
*/
68+
match: string | null;
69+
templateVersionId?: string;
70+
buildParameters?: WorkspaceBuildParameter[];
71+
};
72+
6473
export const autoCreateWorkspace = (queryClient: QueryClient) => {
6574
return {
6675
mutationFn: async ({
67-
templateName,
68-
versionId,
6976
organizationId,
70-
defaultBuildParameters,
71-
defaultName,
77+
templateName,
78+
workspaceName,
79+
templateVersionId,
80+
buildParameters,
81+
match,
7282
}: AutoCreateWorkspaceOptions) => {
83+
if (match) {
84+
const matchWorkspace = await findMatchWorkspace(
85+
`owner:me template:${templateName} ${match}`,
86+
);
87+
if (matchWorkspace) {
88+
return matchWorkspace;
89+
}
90+
}
91+
7392
let templateVersionParameters;
7493

75-
if (versionId) {
76-
templateVersionParameters = { template_version_id: versionId };
94+
if (templateVersionId) {
95+
templateVersionParameters = { template_version_id: templateVersionId };
7796
} else {
7897
const template = await API.getTemplateByName(
7998
organizationId,
@@ -84,8 +103,8 @@ export const autoCreateWorkspace = (queryClient: QueryClient) => {
84103

85104
return API.createWorkspace(organizationId, "me", {
86105
...templateVersionParameters,
87-
name: defaultName,
88-
rich_parameter_values: defaultBuildParameters,
106+
name: workspaceName,
107+
rich_parameter_values: buildParameters,
89108
});
90109
},
91110
onSuccess: async () => {
@@ -94,6 +113,27 @@ export const autoCreateWorkspace = (queryClient: QueryClient) => {
94113
};
95114
};
96115

116+
async function findMatchWorkspace(q: string): Promise<Workspace | undefined> {
117+
try {
118+
const { workspaces } = await API.getWorkspaces({ q, limit: 1 });
119+
const matchWorkspace = workspaces.at(0);
120+
if (matchWorkspace) {
121+
return matchWorkspace;
122+
}
123+
} catch (err) {
124+
if (isApiValidationError(err)) {
125+
const firstValidationErrorDetail =
126+
err.response.data.validations?.[0].detail;
127+
throw new DetailedError(
128+
"Invalid match value",
129+
firstValidationErrorDetail,
130+
);
131+
}
132+
133+
throw err;
134+
}
135+
}
136+
97137
export function workspacesKey(config: WorkspacesRequest = {}) {
98138
const { q, limit } = config;
99139
return ["workspaces", { q, limit }] as const;

site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@ import type {
1616
UserParameter,
1717
Workspace,
1818
} from "api/typesGenerated";
19-
import { ErrorAlert } from "components/Alert/ErrorAlert";
2019
import { Loader } from "components/Loader/Loader";
2120
import { useAuthenticated } from "contexts/auth/RequireAuth";
2221
import { useEffectEvent } from "hooks/hookPolyfills";
@@ -37,7 +36,7 @@ const CreateWorkspacePage: FC = () => {
3736
const { template: templateName } = useParams() as { template: string };
3837
const { user: me } = useAuthenticated();
3938
const navigate = useNavigate();
40-
const [searchParams, setSearchParams] = useSearchParams();
39+
const [searchParams] = useSearchParams();
4140
const { experiments, organizationId } = useDashboard();
4241

4342
const customVersionId = searchParams.get("version") ?? undefined;
@@ -118,15 +117,15 @@ const CreateWorkspacePage: FC = () => {
118117
const newWorkspace = await autoCreateWorkspaceMutation.mutateAsync({
119118
templateName,
120119
organizationId,
121-
defaultBuildParameters: autofillParameters,
122-
defaultName: defaultName ?? generateWorkspaceName(),
123-
versionId: realizedVersionId,
120+
buildParameters: autofillParameters,
121+
workspaceName: defaultName ?? generateWorkspaceName(),
122+
templateVersionId: realizedVersionId,
123+
match: searchParams.get("match"),
124124
});
125125

126126
onCreateWorkspace(newWorkspace);
127127
} catch (err) {
128-
searchParams.delete("mode");
129-
setSearchParams(searchParams);
128+
setMode("form");
130129
}
131130
});
132131

@@ -175,7 +174,6 @@ const CreateWorkspacePage: FC = () => {
175174
<Helmet>
176175
<title>{pageTitle(title)}</title>
177176
</Helmet>
178-
{loadFormDataError && <ErrorAlert error={loadFormDataError} />}
179177
{isLoadingFormData || isLoadingExternalAuth || autoCreateReady ? (
180178
<Loader />
181179
) : (
@@ -185,7 +183,12 @@ const CreateWorkspacePage: FC = () => {
185183
disabledParams={disabledParams}
186184
defaultOwner={me}
187185
autofillParameters={autofillParameters}
188-
error={createWorkspaceMutation.error || autoCreateError}
186+
error={
187+
createWorkspaceMutation.error ||
188+
autoCreateError ||
189+
loadFormDataError ||
190+
autoCreateWorkspaceMutation.error
191+
}
189192
resetMutation={createWorkspaceMutation.reset}
190193
template={templateQuery.data!}
191194
versionId={realizedVersionId}

0 commit comments

Comments
 (0)