Movatterモバイル変換


[0]ホーム

URL:


Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

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

Merged
Parkreiner merged 24 commits intomainfrommes/workspace-clone-feat
Nov 3, 2023
Merged
Show file tree
Hide file tree
Changes fromall commits
Commits
Show all changes
24 commits
Select commitHold shift + click to select a range
9e4f999
chore: add queries for workspace build info
ParkreinerOct 20, 2023
112bc95
refactor: clean up logic for CreateWorkspacePage to support multiple …
ParkreinerOct 20, 2023
15fdfbf
chore: add custom workspace duplication hook
ParkreinerOct 20, 2023
d007b86
chore: integrate mode into CreateWorkspacePageView
ParkreinerOct 20, 2023
294156e
fix: add mode to CreateWorkspacePageView stories
ParkreinerOct 20, 2023
4554895
refactor: extract workspace duplication outside CreateWorkspacePage file
ParkreinerOct 20, 2023
25bacf2
chore: integrate useWorkspaceDuplication into WorkspaceActions
ParkreinerOct 20, 2023
0947031
chore: delete unnecessary function
ParkreinerOct 20, 2023
1d4d4d7
Merge branch 'main' into mes/workspace-clone-feat
ParkreinerOct 31, 2023
d71acf6
refactor: swap useReducer for useState
ParkreinerOct 31, 2023
c0a8c56
fix: swap warning alert for info alert
ParkreinerOct 31, 2023
0b3e954
refactor: move info alert message
ParkreinerOct 31, 2023
7a763a9
refactor: simplify UI logic for mode alerts
ParkreinerOct 31, 2023
da488fa
fix: prevent dismissed Alerts from affecting layouts
ParkreinerOct 31, 2023
5c7242f
fix: remove unnecessary prop binding
ParkreinerOct 31, 2023
98d1b1b
docs: reword comment for clarity
ParkreinerOct 31, 2023
aeacda5
chore: update msw build params to return multiple params
ParkreinerOct 31, 2023
230a4f1
chore: rename duplicationReady to isDuplicationReady
ParkreinerOct 31, 2023
75b1839
chore: expose root component for testing/re-rendering
ParkreinerNov 1, 2023
7cf446f
chore: get tests in place (still have act warnings)
ParkreinerNov 1, 2023
bf21656
refactor: move stuff around for clarity
ParkreinerNov 1, 2023
38ba3b2
chore: finish tests
ParkreinerNov 1, 2023
923d080
chore: revamp tests
ParkreinerNov 3, 2023
8b3d4dd
chore: merge main into branch
ParkreinerNov 3, 2023
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
19 changes: 17 additions & 2 deletionssite/src/api/queries/workspaceBuilds.ts
View file
Open in desktop
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,21 @@
import { UseInfiniteQueryOptions } from "react-query";
import {QueryOptions,UseInfiniteQueryOptions } from "react-query";
import * as API from "api/api";
import { WorkspaceBuild, WorkspaceBuildsRequest } from "api/typesGenerated";
import {
type WorkspaceBuild,
type WorkspaceBuildParameter,
type WorkspaceBuildsRequest,
} from "api/typesGenerated";

export function workspaceBuildParametersKey(workspaceBuildId: string) {
return ["workspaceBuilds", workspaceBuildId, "parameters"] as const;
}

export function workspaceBuildParameters(workspaceBuildId: string) {
return {
queryKey: workspaceBuildParametersKey(workspaceBuildId),
queryFn: () => API.getWorkspaceBuildParameters(workspaceBuildId),
} as const satisfies QueryOptions<WorkspaceBuildParameter[]>;
}

export const workspaceBuildByNumber = (
username: string,
Expand Down
9 changes: 8 additions & 1 deletionsite/src/components/Alert/Alert.tsx
View file
Open in desktop
Original file line numberDiff line numberDiff line change
Expand Up@@ -21,8 +21,15 @@ export const Alert: FC<AlertProps> = ({
}) => {
const [open, setOpen] = useState(true);

// Can't only rely on MUI's hiding behavior inside flex layouts, because even
// though MUI will make a dismissed alert have zero height, the alert will
// still behave as a flex child and introduce extra row/column gaps
if (!open) {
return null;
}

return (
<Collapse in={open}>
<Collapse in>
<MuiAlert
{...alertProps}
sx={{ textAlign: "left", ...alertProps.sx }}
Expand Down
View file
Open in desktop
Original file line numberDiff line numberDiff line change
Expand Up@@ -20,6 +20,7 @@ import {
waitForLoaderToBeRemoved,
} from "testHelpers/renderHelpers";
import CreateWorkspacePage from "./CreateWorkspacePage";
import { Language } from "./CreateWorkspacePageView";

const nameLabelText = "Workspace Name";
const createWorkspaceText = "Create Workspace";
Expand DownExpand Up@@ -270,4 +271,25 @@ describe("CreateWorkspacePage", () => {
);
});
});

it("Detects when a workspace is being created with the 'duplicate' mode", async () => {
const params = new URLSearchParams({
mode: "duplicate",
name: MockWorkspace.name,
version: MockWorkspace.template_active_version_id,
});

renderWithAuth(<CreateWorkspacePage />, {
path: "/templates/:template/workspace",
route: `/templates/${MockWorkspace.name}/workspace?${params.toString()}`,
});

const warningMessage = await screen.findByRole("alert");
const nameInput = await screen.findByRole("textbox", {
name: "Workspace Name",
});

expect(warningMessage).toHaveTextContent(Language.duplicationWarning);
expect(nameInput).toHaveValue(`${MockWorkspace.name}-copy`);
});
});
45 changes: 27 additions & 18 deletionssite/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx
View file
Open in desktop
Original file line numberDiff line numberDiff line change
Expand Up@@ -30,7 +30,8 @@ import { CreateWSPermissions, createWorkspaceChecks } from "./permissions";
import { paramsUsedToCreateWorkspace } from "utils/workspace";
import { useEffectEvent } from "hooks/hookPolyfills";

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

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

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

const queryClient = useQueryClient();
const autoCreateWorkspaceMutation = useMutation(
Expand DownExpand Up@@ -122,6 +122,7 @@ const CreateWorkspacePage: FC = () => {
<Loader />
) : (
<CreateWorkspacePageView
mode={mode}
defaultName={defaultName}
defaultOwner={me}
defaultBuildParameters={defaultBuildParameters}
Expand DownExpand Up@@ -220,20 +221,6 @@ const getDefaultBuildParameters = (
return buildValues;
};

export const orderedTemplateParameters = (
Copy link
MemberAuthor

@ParkreinerParkreinerOct 20, 2023
edited
Loading

Choose a reason for hiding this comment

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

Didn't see this function get used anywhere, but also, one concern I have with the function is that even though it groups the mutable/immutable params, they're still all in one array, so you have to iterate through it to see where one group starts, and the other stops

I feel like, if this does need to get added back down the line, I'd consider these changes:

  1. Returning this as an object, so that you know which one is which from a glance
    typeReturn={immutable:TemplateVersionParameter[];mutable:TemplateVersionParameter[];}
  2. Maybe use some custom type predicates under the hood, so that TypeScript is able to say that themutable property is alwaystrue inside themutable array, and alwaysfalse inside theimmutable array

templateParameters?: TemplateVersionParameter[],
): TemplateVersionParameter[] => {
if (!templateParameters) {
return [];
}

const immutables = templateParameters.filter(
(parameter) => !parameter.mutable,
);
const mutables = templateParameters.filter((parameter) => parameter.mutable);
return [...immutables, ...mutables];
};

const generateUniqueName = () => {
const numberDictionary = NumberDictionary.generate({ min: 0, max: 99 });
return uniqueNamesGenerator({
Expand All@@ -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 ?? "";
}
View file
Open in desktop
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,6 +19,7 @@ const meta: Meta<typeof CreateWorkspacePageView> = {
template: MockTemplate,
parameters: [],
externalAuth: [],
mode: "form",
permissions: {
createWorkspaceForUser: true,
},
Expand Down
View file
Open in desktop
Original file line numberDiff line numberDiff line change
Expand Up@@ -30,11 +30,21 @@ import {
import { ExternalAuth } from "./ExternalAuth";
import { ErrorAlert } from "components/Alert/ErrorAlert";
import { Stack } from "components/Stack/Stack";
import { type ExternalAuthPollingState } from "./CreateWorkspacePage";
import {
CreateWorkspaceMode,
type ExternalAuthPollingState,
} from "./CreateWorkspacePage";
import { useSearchParams } from "react-router-dom";
import type { CreateWSPermissions } from "./permissions";
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;

export interface CreateWorkspacePageViewProps {
mode: CreateWorkspaceMode;
error: unknown;
defaultName: string;
defaultOwner: TypesGen.User;
Expand All@@ -55,6 +65,7 @@ export interface CreateWorkspacePageViewProps {
}

export const CreateWorkspacePageView: FC<CreateWorkspacePageViewProps> = ({
mode,
error,
defaultName,
defaultOwner,
Expand DownExpand Up@@ -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"
Expand Down
View file
Open in desktop
Original file line numberDiff line numberDiff 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);
}
});
});
71 changes: 71 additions & 0 deletionssite/src/pages/CreateWorkspacePage/useWorkspaceDuplication.ts
View file
Open in desktop
Original file line numberDiff line numberDiff 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;
}
Loading

[8]ページ先頭

©2009-2025 Movatter.jp