- Notifications
You must be signed in to change notification settings - Fork928
feat: allow users to duplicate workspaces by parameters#10362
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 ourterms of service andprivacy statement. We’ll occasionally send you account related emails.
Already on GitHub?Sign in to your account
Uh oh!
There was an error while loading.Please reload this page.
Changes fromall commits
9e4f999
112bc95
15fdfbf
d007b86
294156e
4554895
25bacf2
0947031
1d4d4d7
d71acf6
c0a8c56
0b3e954
7a763a9
da488fa
5c7242f
98d1b1b
aeacda5
230a4f1
75b1839
7cf446f
bf21656
38ba3b2
923d080
8b3d4dd
File filter
Filter by extension
Conversations
Uh oh!
There was an error while loading.Please reload this page.
Jump to
Uh oh!
There was an error while loading.Please reload this page.
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -30,7 +30,8 @@ import { CreateWSPermissions, createWorkspaceChecks } from "./permissions"; | ||
import { paramsUsedToCreateWorkspace } from "utils/workspace"; | ||
import { useEffectEvent } from "hooks/hookPolyfills"; | ||
export const createWorkspaceModes = ["form", "auto", "duplicate"] as const; | ||
export type CreateWorkspaceMode = (typeof createWorkspaceModes)[number]; | ||
export type ExternalAuthPollingState = "idle" | "polling" | "abandoned"; | ||
@@ -41,10 +42,9 @@ const CreateWorkspacePage: FC = () => { | ||
const navigate = useNavigate(); | ||
const [searchParams, setSearchParams] = useSearchParams(); | ||
const defaultBuildParameters = getDefaultBuildParameters(searchParams); | ||
const mode =getWorkspaceMode(searchParams); | ||
const customVersionId = searchParams.get("version") ?? undefined; | ||
const defaultName = getDefaultName(mode, searchParams); | ||
const queryClient = useQueryClient(); | ||
const autoCreateWorkspaceMutation = useMutation( | ||
@@ -122,6 +122,7 @@ const CreateWorkspacePage: FC = () => { | ||
<Loader /> | ||
) : ( | ||
<CreateWorkspacePageView | ||
mode={mode} | ||
defaultName={defaultName} | ||
defaultOwner={me} | ||
defaultBuildParameters={defaultBuildParameters} | ||
@@ -220,20 +221,6 @@ const getDefaultBuildParameters = ( | ||
return buildValues; | ||
}; | ||
MemberAuthor
| ||
const generateUniqueName = () => { | ||
const numberDictionary = NumberDictionary.generate({ min: 0, max: 99 }); | ||
return uniqueNamesGenerator({ | ||
@@ -245,3 +232,25 @@ const generateUniqueName = () => { | ||
}; | ||
export default CreateWorkspacePage; | ||
function getWorkspaceMode(params: URLSearchParams): CreateWorkspaceMode { | ||
const paramMode = params.get("mode"); | ||
if (createWorkspaceModes.includes(paramMode as CreateWorkspaceMode)) { | ||
return paramMode as CreateWorkspaceMode; | ||
} | ||
return "form"; | ||
} | ||
function getDefaultName(mode: CreateWorkspaceMode, params: URLSearchParams) { | ||
if (mode === "auto") { | ||
return generateUniqueName(); | ||
} | ||
const paramsName = params.get("name"); | ||
if (mode === "duplicate" && paramsName) { | ||
return `${paramsName}-copy`; | ||
} | ||
return paramsName ?? ""; | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -30,11 +30,21 @@ import { | ||
import { ExternalAuth } from "./ExternalAuth"; | ||
import { ErrorAlert } from "components/Alert/ErrorAlert"; | ||
import { Stack } from "components/Stack/Stack"; | ||
import { | ||
CreateWorkspaceMode, | ||
type ExternalAuthPollingState, | ||
} from "./CreateWorkspacePage"; | ||
import { useSearchParams } from "react-router-dom"; | ||
import { CreateWSPermissions } from "./permissions"; | ||
import { Alert } from "components/Alert/Alert"; | ||
export const Language = { | ||
duplicationWarning: | ||
"Duplicating a workspace only copies its parameters. No state from the old workspace is copied over.", | ||
} as const; | ||
aslilac marked this conversation as resolved. Show resolvedHide resolvedUh oh!There was an error while loading.Please reload this page. | ||
export interface CreateWorkspacePageViewProps { | ||
mode: CreateWorkspaceMode; | ||
error: unknown; | ||
defaultName: string; | ||
defaultOwner: TypesGen.User; | ||
@@ -55,6 +65,7 @@ export interface CreateWorkspacePageViewProps { | ||
} | ||
export const CreateWorkspacePageView: FC<CreateWorkspacePageViewProps> = ({ | ||
mode, | ||
error, | ||
defaultName, | ||
defaultOwner, | ||
@@ -116,6 +127,13 @@ export const CreateWorkspacePageView: FC<CreateWorkspacePageViewProps> = ({ | ||
<FullPageHorizontalForm title="New workspace" onCancel={onCancel}> | ||
<HorizontalForm onSubmit={form.handleSubmit}> | ||
{Boolean(error) && <ErrorAlert error={error} />} | ||
{mode === "duplicate" && ( | ||
<Alert severity="info" dismissible> | ||
{Language.duplicationWarning} | ||
</Alert> | ||
)} | ||
{/* General info */} | ||
<FormSection | ||
title="General" | ||
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,99 @@ | ||
import { waitFor } from "@testing-library/react"; | ||
import * as M from "../../testHelpers/entities"; | ||
import { type Workspace } from "api/typesGenerated"; | ||
import { useWorkspaceDuplication } from "./useWorkspaceDuplication"; | ||
import { MockWorkspace } from "testHelpers/entities"; | ||
import CreateWorkspacePage from "./CreateWorkspacePage"; | ||
import { renderHookWithAuth } from "testHelpers/renderHelpers"; | ||
function render(workspace?: Workspace) { | ||
return renderHookWithAuth( | ||
({ workspace }: { workspace?: Workspace }) => { | ||
return useWorkspaceDuplication(workspace); | ||
}, | ||
{ | ||
initialProps: { workspace }, | ||
extraRoutes: [ | ||
{ | ||
path: "/templates/:template/workspace", | ||
element: <CreateWorkspacePage />, | ||
}, | ||
], | ||
}, | ||
); | ||
} | ||
type RenderResult = Awaited<ReturnType<typeof render>>; | ||
async function performNavigation( | ||
result: RenderResult["result"], | ||
router: RenderResult["router"], | ||
) { | ||
await waitFor(() => expect(result.current.isDuplicationReady).toBe(true)); | ||
result.current.duplicateWorkspace(); | ||
return waitFor(() => { | ||
expect(router.state.location.pathname).toEqual( | ||
`/templates/${MockWorkspace.template_name}/workspace`, | ||
); | ||
}); | ||
} | ||
describe(`${useWorkspaceDuplication.name}`, () => { | ||
it("Will never be ready when there is no workspace passed in", async () => { | ||
const { result, rerender } = await render(undefined); | ||
expect(result.current.isDuplicationReady).toBe(false); | ||
for (let i = 0; i < 10; i++) { | ||
rerender({ workspace: undefined }); | ||
expect(result.current.isDuplicationReady).toBe(false); | ||
} | ||
}); | ||
it("Will become ready when workspace is provided and build params are successfully fetched", async () => { | ||
const { result } = await render(MockWorkspace); | ||
expect(result.current.isDuplicationReady).toBe(false); | ||
await waitFor(() => expect(result.current.isDuplicationReady).toBe(true)); | ||
}); | ||
it("Is able to navigate the user to the workspace creation page", async () => { | ||
const { result, router } = await render(MockWorkspace); | ||
await performNavigation(result, router); | ||
}); | ||
test("Navigating populates the URL search params with the workspace's build params", async () => { | ||
const { result, router } = await render(MockWorkspace); | ||
await performNavigation(result, router); | ||
const parsedParams = new URLSearchParams(router.state.location.search); | ||
const mockBuildParams = [ | ||
M.MockWorkspaceBuildParameter1, | ||
M.MockWorkspaceBuildParameter2, | ||
M.MockWorkspaceBuildParameter3, | ||
M.MockWorkspaceBuildParameter4, | ||
M.MockWorkspaceBuildParameter5, | ||
]; | ||
for (const { name, value } of mockBuildParams) { | ||
const key = `param.${name}`; | ||
expect(parsedParams.get(key)).toEqual(value); | ||
} | ||
}); | ||
test("Navigating appends other necessary metadata to the search params", async () => { | ||
const { result, router } = await render(MockWorkspace); | ||
await performNavigation(result, router); | ||
const parsedParams = new URLSearchParams(router.state.location.search); | ||
const extraMetadataEntries = [ | ||
["mode", "duplicate"], | ||
["name", MockWorkspace.name], | ||
["version", MockWorkspace.template_active_version_id], | ||
] as const; | ||
for (const [key, value] of extraMetadataEntries) { | ||
expect(parsedParams.get(key)).toBe(value); | ||
} | ||
}); | ||
}); |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,71 @@ | ||
import { useNavigate } from "react-router-dom"; | ||
import { useQuery } from "react-query"; | ||
import { type CreateWorkspaceMode } from "./CreateWorkspacePage"; | ||
import { | ||
type Workspace, | ||
type WorkspaceBuildParameter, | ||
} from "api/typesGenerated"; | ||
import { workspaceBuildParameters } from "api/queries/workspaceBuilds"; | ||
import { useCallback } from "react"; | ||
function getDuplicationUrlParams( | ||
workspaceParams: readonly WorkspaceBuildParameter[], | ||
workspace: Workspace, | ||
): URLSearchParams { | ||
// Record type makes sure that every property key added starts with "param."; | ||
// page is also set up to parse params with this prefix for auto mode | ||
const consolidatedParams: Record<`param.${string}`, string> = {}; | ||
for (const p of workspaceParams) { | ||
consolidatedParams[`param.${p.name}`] = p.value; | ||
} | ||
return new URLSearchParams({ | ||
...consolidatedParams, | ||
mode: "duplicate" satisfies CreateWorkspaceMode, | ||
name: workspace.name, | ||
version: workspace.template_active_version_id, | ||
}); | ||
} | ||
/** | ||
* Takes a workspace, and returns out a function that will navigate the user to | ||
* the 'Create Workspace' page, pre-filling the form with as much information | ||
* about the workspace as possible. | ||
*/ | ||
export function useWorkspaceDuplication(workspace?: Workspace) { | ||
const navigate = useNavigate(); | ||
const buildParametersQuery = useQuery( | ||
workspace !== undefined | ||
? workspaceBuildParameters(workspace.latest_build.id) | ||
: { enabled: false }, | ||
); | ||
// Not using useEffectEvent for this, because useEffect isn't really an | ||
// intended use case for this custom hook | ||
const duplicateWorkspace = useCallback(() => { | ||
const buildParams = buildParametersQuery.data; | ||
if (buildParams === undefined || workspace === undefined) { | ||
return; | ||
} | ||
const newUrlParams = getDuplicationUrlParams(buildParams, workspace); | ||
// Necessary for giving modals/popups time to flush their state changes and | ||
// close the popup before actually navigating. MUI does provide the | ||
// disablePortal prop, which also side-steps this issue, but you have to | ||
// remember to put it on any component that calls this function. Better to | ||
// code defensively and have some redundancy in case someone forgets | ||
void Promise.resolve().then(() => { | ||
navigate({ | ||
pathname: `/templates/${workspace.template_name}/workspace`, | ||
search: newUrlParams.toString(), | ||
}); | ||
}); | ||
}, [navigate, workspace, buildParametersQuery.data]); | ||
return { | ||
duplicateWorkspace, | ||
isDuplicationReady: buildParametersQuery.isSuccess, | ||
} as const; | ||
} |
Uh oh!
There was an error while loading.Please reload this page.