Skip to content

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

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 6 commits into from
Jul 9, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion site/e2e/parameters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { RichParameter } from "./provisionerGenerated";

// Rich parameters

const emptyParameter: RichParameter = {
export const emptyParameter: RichParameter = {
name: "",
description: "",
type: "",
Expand Down
65 changes: 65 additions & 0 deletions site/e2e/tests/workspaces/autoCreateWorkspace.spec.ts
Original file line number Diff line number Diff line change
@@ -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(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I haven't worked with the E2E stuff much yet, so just to make sure I'm understanding: these tests aren't isolated, right? That's how the second test is able to have a workspace already exist, even though it didn't explicitly make two of them?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

They are not isolated, but I ensure that a previous workspace with the right match exists in line 39.

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();
});
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ import {
openTerminalWindow,
requireTerraformProvisioner,
verifyParameters,
} from "../helpers";
import { beforeCoderTest } from "../hooks";
} from "../../helpers";
import { beforeCoderTest } from "../../hooks";
import {
secondParameter,
fourthParameter,
Expand All @@ -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));

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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));

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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));

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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));

Expand Down
13 changes: 13 additions & 0 deletions site/src/api/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.";
}
Expand All @@ -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);
}
}
72 changes: 56 additions & 16 deletions site/src/api/queries/workspaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand All @@ -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,
Expand All @@ -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 () => {
Expand All @@ -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;
Expand Down
21 changes: 12 additions & 9 deletions site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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;
Expand Down Expand Up @@ -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");
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm wondering if it might still be good to update the search params on top of the state change, but good catch with this

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've thought about this and I don't think it makes much of a difference in the user experience, based on what I can see. Since no other part of the component uses it, I just removed it and decided to set the mode only in the state.

}
});

Expand Down Expand Up @@ -175,7 +174,6 @@ const CreateWorkspacePage: FC = () => {
<Helmet>
<title>{pageTitle(title)}</title>
</Helmet>
{loadFormDataError && <ErrorAlert error={loadFormDataError} />}
{isLoadingFormData || isLoadingExternalAuth || autoCreateReady ? (
<Loader />
) : (
Expand All @@ -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}
Expand Down
Loading