Skip to content

Commit 744c733

Browse files
authored
feat: allow users to duplicate workspaces by parameters (#10362)
* chore: add queries for workspace build info * refactor: clean up logic for CreateWorkspacePage to support multiple modes * chore: add custom workspace duplication hook * chore: integrate mode into CreateWorkspacePageView * fix: add mode to CreateWorkspacePageView stories * refactor: extract workspace duplication outside CreateWorkspacePage file * chore: integrate useWorkspaceDuplication into WorkspaceActions * chore: delete unnecessary function * refactor: swap useReducer for useState * fix: swap warning alert for info alert * refactor: move info alert message * refactor: simplify UI logic for mode alerts * fix: prevent dismissed Alerts from affecting layouts * fix: remove unnecessary prop binding * docs: reword comment for clarity * chore: update msw build params to return multiple params * chore: rename duplicationReady to isDuplicationReady * chore: expose root component for testing/re-rendering * chore: get tests in place (still have act warnings) * refactor: move stuff around for clarity * chore: finish tests * chore: revamp tests
1 parent 23f0265 commit 744c733

11 files changed

+397
-40
lines changed

site/src/api/queries/workspaceBuilds.ts

+17-2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,21 @@
1-
import { UseInfiniteQueryOptions } from "react-query";
1+
import { QueryOptions, UseInfiniteQueryOptions } from "react-query";
22
import * as API from "api/api";
3-
import { WorkspaceBuild, WorkspaceBuildsRequest } from "api/typesGenerated";
3+
import {
4+
type WorkspaceBuild,
5+
type WorkspaceBuildParameter,
6+
type WorkspaceBuildsRequest,
7+
} from "api/typesGenerated";
8+
9+
export function workspaceBuildParametersKey(workspaceBuildId: string) {
10+
return ["workspaceBuilds", workspaceBuildId, "parameters"] as const;
11+
}
12+
13+
export function workspaceBuildParameters(workspaceBuildId: string) {
14+
return {
15+
queryKey: workspaceBuildParametersKey(workspaceBuildId),
16+
queryFn: () => API.getWorkspaceBuildParameters(workspaceBuildId),
17+
} as const satisfies QueryOptions<WorkspaceBuildParameter[]>;
18+
}
419

520
export const workspaceBuildByNumber = (
621
username: string,

site/src/components/Alert/Alert.tsx

+8-1
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,15 @@ export const Alert: FC<AlertProps> = ({
2121
}) => {
2222
const [open, setOpen] = useState(true);
2323

24+
// Can't only rely on MUI's hiding behavior inside flex layouts, because even
25+
// though MUI will make a dismissed alert have zero height, the alert will
26+
// still behave as a flex child and introduce extra row/column gaps
27+
if (!open) {
28+
return null;
29+
}
30+
2431
return (
25-
<Collapse in={open}>
32+
<Collapse in>
2633
<MuiAlert
2734
{...alertProps}
2835
sx={{ textAlign: "left", ...alertProps.sx }}

site/src/pages/CreateWorkspacePage/CreateWorkspacePage.test.tsx

+22
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
waitForLoaderToBeRemoved,
2121
} from "testHelpers/renderHelpers";
2222
import CreateWorkspacePage from "./CreateWorkspacePage";
23+
import { Language } from "./CreateWorkspacePageView";
2324

2425
const nameLabelText = "Workspace Name";
2526
const createWorkspaceText = "Create Workspace";
@@ -270,4 +271,25 @@ describe("CreateWorkspacePage", () => {
270271
);
271272
});
272273
});
274+
275+
it("Detects when a workspace is being created with the 'duplicate' mode", async () => {
276+
const params = new URLSearchParams({
277+
mode: "duplicate",
278+
name: MockWorkspace.name,
279+
version: MockWorkspace.template_active_version_id,
280+
});
281+
282+
renderWithAuth(<CreateWorkspacePage />, {
283+
path: "/templates/:template/workspace",
284+
route: `/templates/${MockWorkspace.name}/workspace?${params.toString()}`,
285+
});
286+
287+
const warningMessage = await screen.findByRole("alert");
288+
const nameInput = await screen.findByRole("textbox", {
289+
name: "Workspace Name",
290+
});
291+
292+
expect(warningMessage).toHaveTextContent(Language.duplicationWarning);
293+
expect(nameInput).toHaveValue(`${MockWorkspace.name}-copy`);
294+
});
273295
});

site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx

+27-18
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,8 @@ import { CreateWSPermissions, createWorkspaceChecks } from "./permissions";
3030
import { paramsUsedToCreateWorkspace } from "utils/workspace";
3131
import { useEffectEvent } from "hooks/hookPolyfills";
3232

33-
type CreateWorkspaceMode = "form" | "auto";
33+
export const createWorkspaceModes = ["form", "auto", "duplicate"] as const;
34+
export type CreateWorkspaceMode = (typeof createWorkspaceModes)[number];
3435

3536
export type ExternalAuthPollingState = "idle" | "polling" | "abandoned";
3637

@@ -41,10 +42,9 @@ const CreateWorkspacePage: FC = () => {
4142
const navigate = useNavigate();
4243
const [searchParams, setSearchParams] = useSearchParams();
4344
const defaultBuildParameters = getDefaultBuildParameters(searchParams);
44-
const mode = (searchParams.get("mode") ?? "form") as CreateWorkspaceMode;
45+
const mode = getWorkspaceMode(searchParams);
4546
const customVersionId = searchParams.get("version") ?? undefined;
46-
const defaultName =
47-
mode === "auto" ? generateUniqueName() : searchParams.get("name") ?? "";
47+
const defaultName = getDefaultName(mode, searchParams);
4848

4949
const queryClient = useQueryClient();
5050
const autoCreateWorkspaceMutation = useMutation(
@@ -122,6 +122,7 @@ const CreateWorkspacePage: FC = () => {
122122
<Loader />
123123
) : (
124124
<CreateWorkspacePageView
125+
mode={mode}
125126
defaultName={defaultName}
126127
defaultOwner={me}
127128
defaultBuildParameters={defaultBuildParameters}
@@ -220,20 +221,6 @@ const getDefaultBuildParameters = (
220221
return buildValues;
221222
};
222223

223-
export const orderedTemplateParameters = (
224-
templateParameters?: TemplateVersionParameter[],
225-
): TemplateVersionParameter[] => {
226-
if (!templateParameters) {
227-
return [];
228-
}
229-
230-
const immutables = templateParameters.filter(
231-
(parameter) => !parameter.mutable,
232-
);
233-
const mutables = templateParameters.filter((parameter) => parameter.mutable);
234-
return [...immutables, ...mutables];
235-
};
236-
237224
const generateUniqueName = () => {
238225
const numberDictionary = NumberDictionary.generate({ min: 0, max: 99 });
239226
return uniqueNamesGenerator({
@@ -245,3 +232,25 @@ const generateUniqueName = () => {
245232
};
246233

247234
export default CreateWorkspacePage;
235+
236+
function getWorkspaceMode(params: URLSearchParams): CreateWorkspaceMode {
237+
const paramMode = params.get("mode");
238+
if (createWorkspaceModes.includes(paramMode as CreateWorkspaceMode)) {
239+
return paramMode as CreateWorkspaceMode;
240+
}
241+
242+
return "form";
243+
}
244+
245+
function getDefaultName(mode: CreateWorkspaceMode, params: URLSearchParams) {
246+
if (mode === "auto") {
247+
return generateUniqueName();
248+
}
249+
250+
const paramsName = params.get("name");
251+
if (mode === "duplicate" && paramsName) {
252+
return `${paramsName}-copy`;
253+
}
254+
255+
return paramsName ?? "";
256+
}

site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.stories.tsx

+1
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ const meta: Meta<typeof CreateWorkspacePageView> = {
1919
template: MockTemplate,
2020
parameters: [],
2121
externalAuth: [],
22+
mode: "form",
2223
permissions: {
2324
createWorkspaceForUser: true,
2425
},

site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx

+20-2
Original file line numberDiff line numberDiff line change
@@ -30,11 +30,21 @@ import {
3030
import { ExternalAuth } from "./ExternalAuth";
3131
import { ErrorAlert } from "components/Alert/ErrorAlert";
3232
import { Stack } from "components/Stack/Stack";
33-
import { type ExternalAuthPollingState } from "./CreateWorkspacePage";
33+
import {
34+
CreateWorkspaceMode,
35+
type ExternalAuthPollingState,
36+
} from "./CreateWorkspacePage";
3437
import { useSearchParams } from "react-router-dom";
35-
import type { CreateWSPermissions } from "./permissions";
38+
import { CreateWSPermissions } from "./permissions";
39+
import { Alert } from "components/Alert/Alert";
40+
41+
export const Language = {
42+
duplicationWarning:
43+
"Duplicating a workspace only copies its parameters. No state from the old workspace is copied over.",
44+
} as const;
3645

3746
export interface CreateWorkspacePageViewProps {
47+
mode: CreateWorkspaceMode;
3848
error: unknown;
3949
defaultName: string;
4050
defaultOwner: TypesGen.User;
@@ -55,6 +65,7 @@ export interface CreateWorkspacePageViewProps {
5565
}
5666

5767
export const CreateWorkspacePageView: FC<CreateWorkspacePageViewProps> = ({
68+
mode,
5869
error,
5970
defaultName,
6071
defaultOwner,
@@ -116,6 +127,13 @@ export const CreateWorkspacePageView: FC<CreateWorkspacePageViewProps> = ({
116127
<FullPageHorizontalForm title="New workspace" onCancel={onCancel}>
117128
<HorizontalForm onSubmit={form.handleSubmit}>
118129
{Boolean(error) && <ErrorAlert error={error} />}
130+
131+
{mode === "duplicate" && (
132+
<Alert severity="info" dismissible>
133+
{Language.duplicationWarning}
134+
</Alert>
135+
)}
136+
119137
{/* General info */}
120138
<FormSection
121139
title="General"
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import { waitFor } from "@testing-library/react";
2+
import * as M from "../../testHelpers/entities";
3+
import { type Workspace } from "api/typesGenerated";
4+
import { useWorkspaceDuplication } from "./useWorkspaceDuplication";
5+
import { MockWorkspace } from "testHelpers/entities";
6+
import CreateWorkspacePage from "./CreateWorkspacePage";
7+
import { renderHookWithAuth } from "testHelpers/renderHelpers";
8+
9+
function render(workspace?: Workspace) {
10+
return renderHookWithAuth(
11+
({ workspace }: { workspace?: Workspace }) => {
12+
return useWorkspaceDuplication(workspace);
13+
},
14+
{
15+
initialProps: { workspace },
16+
extraRoutes: [
17+
{
18+
path: "/templates/:template/workspace",
19+
element: <CreateWorkspacePage />,
20+
},
21+
],
22+
},
23+
);
24+
}
25+
26+
type RenderResult = Awaited<ReturnType<typeof render>>;
27+
28+
async function performNavigation(
29+
result: RenderResult["result"],
30+
router: RenderResult["router"],
31+
) {
32+
await waitFor(() => expect(result.current.isDuplicationReady).toBe(true));
33+
result.current.duplicateWorkspace();
34+
35+
return waitFor(() => {
36+
expect(router.state.location.pathname).toEqual(
37+
`/templates/${MockWorkspace.template_name}/workspace`,
38+
);
39+
});
40+
}
41+
42+
describe(`${useWorkspaceDuplication.name}`, () => {
43+
it("Will never be ready when there is no workspace passed in", async () => {
44+
const { result, rerender } = await render(undefined);
45+
expect(result.current.isDuplicationReady).toBe(false);
46+
47+
for (let i = 0; i < 10; i++) {
48+
rerender({ workspace: undefined });
49+
expect(result.current.isDuplicationReady).toBe(false);
50+
}
51+
});
52+
53+
it("Will become ready when workspace is provided and build params are successfully fetched", async () => {
54+
const { result } = await render(MockWorkspace);
55+
56+
expect(result.current.isDuplicationReady).toBe(false);
57+
await waitFor(() => expect(result.current.isDuplicationReady).toBe(true));
58+
});
59+
60+
it("Is able to navigate the user to the workspace creation page", async () => {
61+
const { result, router } = await render(MockWorkspace);
62+
await performNavigation(result, router);
63+
});
64+
65+
test("Navigating populates the URL search params with the workspace's build params", async () => {
66+
const { result, router } = await render(MockWorkspace);
67+
await performNavigation(result, router);
68+
69+
const parsedParams = new URLSearchParams(router.state.location.search);
70+
const mockBuildParams = [
71+
M.MockWorkspaceBuildParameter1,
72+
M.MockWorkspaceBuildParameter2,
73+
M.MockWorkspaceBuildParameter3,
74+
M.MockWorkspaceBuildParameter4,
75+
M.MockWorkspaceBuildParameter5,
76+
];
77+
78+
for (const { name, value } of mockBuildParams) {
79+
const key = `param.${name}`;
80+
expect(parsedParams.get(key)).toEqual(value);
81+
}
82+
});
83+
84+
test("Navigating appends other necessary metadata to the search params", async () => {
85+
const { result, router } = await render(MockWorkspace);
86+
await performNavigation(result, router);
87+
88+
const parsedParams = new URLSearchParams(router.state.location.search);
89+
const extraMetadataEntries = [
90+
["mode", "duplicate"],
91+
["name", MockWorkspace.name],
92+
["version", MockWorkspace.template_active_version_id],
93+
] as const;
94+
95+
for (const [key, value] of extraMetadataEntries) {
96+
expect(parsedParams.get(key)).toBe(value);
97+
}
98+
});
99+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { useNavigate } from "react-router-dom";
2+
import { useQuery } from "react-query";
3+
import { type CreateWorkspaceMode } from "./CreateWorkspacePage";
4+
import {
5+
type Workspace,
6+
type WorkspaceBuildParameter,
7+
} from "api/typesGenerated";
8+
import { workspaceBuildParameters } from "api/queries/workspaceBuilds";
9+
import { useCallback } from "react";
10+
11+
function getDuplicationUrlParams(
12+
workspaceParams: readonly WorkspaceBuildParameter[],
13+
workspace: Workspace,
14+
): URLSearchParams {
15+
// Record type makes sure that every property key added starts with "param.";
16+
// page is also set up to parse params with this prefix for auto mode
17+
const consolidatedParams: Record<`param.${string}`, string> = {};
18+
19+
for (const p of workspaceParams) {
20+
consolidatedParams[`param.${p.name}`] = p.value;
21+
}
22+
23+
return new URLSearchParams({
24+
...consolidatedParams,
25+
mode: "duplicate" satisfies CreateWorkspaceMode,
26+
name: workspace.name,
27+
version: workspace.template_active_version_id,
28+
});
29+
}
30+
31+
/**
32+
* Takes a workspace, and returns out a function that will navigate the user to
33+
* the 'Create Workspace' page, pre-filling the form with as much information
34+
* about the workspace as possible.
35+
*/
36+
export function useWorkspaceDuplication(workspace?: Workspace) {
37+
const navigate = useNavigate();
38+
const buildParametersQuery = useQuery(
39+
workspace !== undefined
40+
? workspaceBuildParameters(workspace.latest_build.id)
41+
: { enabled: false },
42+
);
43+
44+
// Not using useEffectEvent for this, because useEffect isn't really an
45+
// intended use case for this custom hook
46+
const duplicateWorkspace = useCallback(() => {
47+
const buildParams = buildParametersQuery.data;
48+
if (buildParams === undefined || workspace === undefined) {
49+
return;
50+
}
51+
52+
const newUrlParams = getDuplicationUrlParams(buildParams, workspace);
53+
54+
// Necessary for giving modals/popups time to flush their state changes and
55+
// close the popup before actually navigating. MUI does provide the
56+
// disablePortal prop, which also side-steps this issue, but you have to
57+
// remember to put it on any component that calls this function. Better to
58+
// code defensively and have some redundancy in case someone forgets
59+
void Promise.resolve().then(() => {
60+
navigate({
61+
pathname: `/templates/${workspace.template_name}/workspace`,
62+
search: newUrlParams.toString(),
63+
});
64+
});
65+
}, [navigate, workspace, buildParametersQuery.data]);
66+
67+
return {
68+
duplicateWorkspace,
69+
isDuplicationReady: buildParametersQuery.isSuccess,
70+
} as const;
71+
}

0 commit comments

Comments
 (0)