feat(site): support match option for auto create workspace flow (#13836)

This commit is contained in:
Bruno Quaresma
2024-07-09 16:14:08 +00:00
committed by GitHub
parent 8c33b028d2
commit 6bf7e5af91
9 changed files with 163 additions and 42 deletions
+1 -1
View File
@@ -2,7 +2,7 @@ import type { RichParameter } from "./provisionerGenerated";
// Rich parameters
const emptyParameter: RichParameter = {
export const emptyParameter: RichParameter = {
name: "",
description: "",
type: "",
@@ -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&param.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&param.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&param.repo=example&name=new-name&match=not-valid-query:${prevWorkspace}`,
{
waitUntil: "domcontentloaded",
},
);
await expect(page.getByText("Invalid match value")).toBeVisible();
});
@@ -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));
@@ -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));
@@ -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));
@@ -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));
+13
View File
@@ -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);
}
}
+56 -16
View File
@@ -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<Workspace | undefined> {
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;
@@ -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 = () => {
<Helmet>
<title>{pageTitle(title)}</title>
</Helmet>
{loadFormDataError && <ErrorAlert error={loadFormDataError} />}
{isLoadingFormData || isLoadingExternalAuth || autoCreateReady ? (
<Loader />
) : (
@@ -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}