mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat(site): support match option for auto create workspace flow (#13836)
This commit is contained in:
@@ -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¶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();
|
||||
});
|
||||
+4
-4
@@ -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));
|
||||
|
||||
+4
-4
@@ -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));
|
||||
|
||||
+4
-4
@@ -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));
|
||||
|
||||
+4
-4
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user