Skip to content

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 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 24 commits into from
Nov 3, 2023
Merged
Show file tree
Hide file tree
Changes from 22 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
9e4f999
chore: add queries for workspace build info
Parkreiner Oct 20, 2023
112bc95
refactor: clean up logic for CreateWorkspacePage to support multiple …
Parkreiner Oct 20, 2023
15fdfbf
chore: add custom workspace duplication hook
Parkreiner Oct 20, 2023
d007b86
chore: integrate mode into CreateWorkspacePageView
Parkreiner Oct 20, 2023
294156e
fix: add mode to CreateWorkspacePageView stories
Parkreiner Oct 20, 2023
4554895
refactor: extract workspace duplication outside CreateWorkspacePage file
Parkreiner Oct 20, 2023
25bacf2
chore: integrate useWorkspaceDuplication into WorkspaceActions
Parkreiner Oct 20, 2023
0947031
chore: delete unnecessary function
Parkreiner Oct 20, 2023
1d4d4d7
Merge branch 'main' into mes/workspace-clone-feat
Parkreiner Oct 31, 2023
d71acf6
refactor: swap useReducer for useState
Parkreiner Oct 31, 2023
c0a8c56
fix: swap warning alert for info alert
Parkreiner Oct 31, 2023
0b3e954
refactor: move info alert message
Parkreiner Oct 31, 2023
7a763a9
refactor: simplify UI logic for mode alerts
Parkreiner Oct 31, 2023
da488fa
fix: prevent dismissed Alerts from affecting layouts
Parkreiner Oct 31, 2023
5c7242f
fix: remove unnecessary prop binding
Parkreiner Oct 31, 2023
98d1b1b
docs: reword comment for clarity
Parkreiner Oct 31, 2023
aeacda5
chore: update msw build params to return multiple params
Parkreiner Oct 31, 2023
230a4f1
chore: rename duplicationReady to isDuplicationReady
Parkreiner Oct 31, 2023
75b1839
chore: expose root component for testing/re-rendering
Parkreiner Nov 1, 2023
7cf446f
chore: get tests in place (still have act warnings)
Parkreiner Nov 1, 2023
bf21656
refactor: move stuff around for clarity
Parkreiner Nov 1, 2023
38ba3b2
chore: finish tests
Parkreiner Nov 1, 2023
923d080
chore: revamp tests
Parkreiner Nov 3, 2023
8b3d4dd
chore: merge main into branch
Parkreiner Nov 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 deletions site/src/api/queries/workspaceBuilds.ts
Original file line number Diff line number Diff 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 deletion site/src/components/Alert/Alert.tsx
Original file line number Diff line number Diff 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
22 changes: 22 additions & 0 deletions site/src/pages/CreateWorkspacePage/CreateWorkspacePage.test.tsx
Original file line number Diff line number Diff 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 Down Expand 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 deletions site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx
Original file line number Diff line number Diff 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 Down Expand Up @@ -122,6 +122,7 @@ const CreateWorkspacePage: FC = () => {
<Loader />
) : (
<CreateWorkspacePageView
mode={mode}
defaultName={defaultName}
defaultOwner={me}
defaultBuildParameters={defaultBuildParameters}
Expand Down Expand Up @@ -220,20 +221,6 @@ const getDefaultBuildParameters = (
return buildValues;
};

export const orderedTemplateParameters = (
Copy link
Member Author

@Parkreiner Parkreiner Oct 20, 2023

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
    type Return = {
      immutable: TemplateVersionParameter[];
      mutable: TemplateVersionParameter[];
    }
  2. Maybe use some custom type predicates under the hood, so that TypeScript is able to say that the mutable property is always true inside the mutable array, and always false inside the immutable 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 ?? "";
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ const meta: Meta<typeof CreateWorkspacePageView> = {
template: MockTemplate,
parameters: [],
externalAuth: [],
mode: "form",
permissions: {
createWorkspaceForUser: true,
},
Expand Down
20 changes: 19 additions & 1 deletion site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,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 { 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 @@ -54,6 +64,7 @@ export interface CreateWorkspacePageViewProps {
}

export const CreateWorkspacePageView: FC<CreateWorkspacePageViewProps> = ({
mode,
error,
defaultName,
defaultOwner,
Expand Down Expand Up @@ -115,6 +126,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
115 changes: 115 additions & 0 deletions site/src/pages/CreateWorkspacePage/useWorkspaceDuplication.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { waitFor, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { createMemoryRouter } from "react-router-dom";
import { renderWithRouter } from "testHelpers/renderHelpers";

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";

// Tried really hard to get these tests working with RTL's renderHook, but I
// kept running into weird function mismatches, mostly stemming from the fact
// that React Router's RouteProvider does not accept children, meaning that you
// can't inject values into it with renderHook's wrapper
function WorkspaceMock({ workspace }: { workspace?: Workspace }) {
const { duplicateWorkspace, isDuplicationReady } =
useWorkspaceDuplication(workspace);

return (
<button onClick={duplicateWorkspace} disabled={!isDuplicationReady}>
Click me!
</button>
);
}
Copy link
Member

Choose a reason for hiding this comment

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

oh I really don't like setting this trend 💀 I don't exactly know what to recommend to fix it tho, maybe @BrunoQuaresma does?

Copy link
Member Author

@Parkreiner Parkreiner Nov 2, 2023

Choose a reason for hiding this comment

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

Yeah, I'm not a fan, either, but I wasn't sure about other options.

One alternative is using the base MemoryRouter component (which does support children), but my main worries are:

  • Then the test isn't using the the main setup function we've got defined for most other cases
  • I kind of get the sense that the base MemoryRouter component might become deprecated down the line, because createMemoryRouter is more in line with their new data loading patterns. (It's also the React Router team, so they're not afraid to ship major breaking changes).

I'll see if I can find any discussions for testing hooks with the new APIs, but if anyone can come up with an improvement, I would gladly remove this in a heartbeat

Copy link
Collaborator

Choose a reason for hiding this comment

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

The renderWithAuth function should let you pass children routes. Can you explain a bit more what issues did you face on tests?

Copy link
Member Author

Choose a reason for hiding this comment

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

So, the main issue is that I'm stuck testing an individual hook, rather than a whole component. Ideally, my function (however it's implemented) would give me back the custom hook result, as well as a React Router router object (so I can check its internal state after modifying the hook result).

But what I was running into was that I couldn't figure out how to set things up so that I could get both values back. It seemed like it was one or the other, and I chose the one that would've been harder to mock out. I can't remember the precise syntax issues, but I'll try to recreate the problem after the FE Variety in a few minutes

I guess mocking out useNavigate itself is an option, but I feel like that makes the tests get further from our real code

Copy link
Collaborator

Choose a reason for hiding this comment

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

We use https://react-hooks-testing-library.com/, not sure if that is helpful but if that is the only way, I'm good with that for now.

Copy link
Member Author

@Parkreiner Parkreiner Nov 2, 2023

Choose a reason for hiding this comment

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

Yeah, I didn't realize that the React Hooks test library was a separate initiative, but renderHook comes from there, so sadly, there's nothing in the API docs that I haven't tried yet

I went through things again, and my take is that we're stuck choosing from two less-than-ideal compromises:

  1. We render with the current approach, which is kind of cursed, but it side-steps some of the limitations of hooking renderHook up to a React Router MemoryRouter
  2. We go with renderHook and swap createMemoryRouter for the direct <MemoryRouter> component, but then we have to mock out parts of React Router itself (like useNavigate), because this approach prevents us from accessing the router object itself

Copy link
Member Author

@Parkreiner Parkreiner Nov 2, 2023

Choose a reason for hiding this comment

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

Writing this out, so that we at least have something to reference in the future:

So, the main problem stems from the fact that while renderHook lets you test custom hooks and supports some form of dependency injection via its wrapper property, the API assumes that all components being injected support direct children composition. React Router's createMemoryRouter, on the other hand, assumes that you have all components defined ahead of time, so it doesn't support children in the same way, nor does it give you the ability to add more routes after the router has been created.

If I were only testing the React Query parts, I could do this:

const testResult = renderHook(() => useWorkspaceDuplication(MockWorkspace), {
  wrapper: ({ children }) => (
    <QueryProvider client={testQueryClient}>
      {children}
    </QueryProvider>
  )
}); 

But since the code also tests React Router, there's two choices:

  1. Use the <MemoryRouter> component directly
const testResult = renderHook(() => useWorkspaceDuplication(MockWorkspace), {
  wrapper: ({ children }) => (
    <QueryProvider client={testQueryClient}>
      <MemoryRouter>
        <Routes>
          <Route path="/" element={<>{children}</>} />
          <Route path="/templates/:template/workspace" element={<CreateWorkspacePage />} />
        </Routes>
      </MemoryRouter>
    </QueryProvider>
  )
}); 

This seemed to hold up until I needed to verify that the navigation went through correctly. There wasn't really a way to access the router and verify the navigation had happened, and basically all the older StackOverflow answers said that you would need to mock out useNavigate itself

  1. Use createMemoryRouter to make a memoryRouter to feed into <RouterProvider>
    This is where things get wonky – it basically turns into a chicken-or-the-egg situation, mainly because RouterProvider does not accept children, and memoryRouter is locked tight after being created.

    I need a component that accepts children in some way, so I can hook it up to renderHook's wrapper. But createMemoryRouter can't make a memoryRouter object to hook into a provider unless it has all the page views ready to go in advance (including renderHook's mock component).

Copy link
Member Author

Choose a reason for hiding this comment

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

So, writing this out gave me a slightly more cursed idea, but it might work better with all the existing APIs. I'm going to explore it more tomorrow

Copy link
Member Author

@Parkreiner Parkreiner Nov 2, 2023

Choose a reason for hiding this comment

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

Would need to test this out (and make sure it's resilient to re-renders), but uh...it sure is something:

function renderHookWithRouter(workspace?: Workspace) {
  const queryClient = new QueryClient({
    defaultOptions: {
      queries: {
        retry: false,
        cacheTime: 0,
        refetchOnWindowFocus: false,
        networkMode: "offlineFirst",
      },
    },
  });

  // Easy to miss – there's an evil definite assignment via the !
  let escapedRouter!: ReturnType<typeof createMemoryRouter>;

  // eslint-disable-next-line testing-library/render-result-naming-convention -- Cursed stuff to make sure both router and hook result are exposed
  const renderHookResult = renderHook(
    () => useWorkspaceDuplication(workspace),
    {
      wrapper: ({ children }) => {
        // eslint-disable-next-line react-hooks/rules-of-hooks -- This is actually processed as a component; React just isn't aware of that
        const [readonlyStatefulRouter] = useState(() => {
          return createMemoryRouter([
            { path: "/", element: <>{children}</> },
            {
              path: "/templates/:template/workspace",
              element: <CreateWorkspacePage />,
            },
          ]);
        });

        escapedRouter = readonlyStatefulRouter;

        return (
          <QueryClientProvider client={queryClient}>
            <RouterProvider router={readonlyStatefulRouter} />
          </QueryClientProvider>
        );
      },
    },
  );

  return {
    ...renderHookResult,
    router: escapedRouter,
  };
}

Guess the question is: as horrific as this is, are the result and API stable enough that we can abstract the nasty implementation details behind a function, and pretend they don't exist? Is this worth it?

Copy link
Member Author

@Parkreiner Parkreiner Nov 3, 2023

Choose a reason for hiding this comment

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

Update: this is cursed and evil, but it works, and I like how it simplifies the testing, making sure the wacky mocking stays in one spot

I'm going to clean this up, make sure it supports any kind of hook, and post this in the dev channel to get people's thoughts


function renderInMemory(workspace?: Workspace) {
const router = createMemoryRouter([
{ path: "/", element: <WorkspaceMock workspace={workspace} /> },
{
path: "/templates/:template/workspace",
element: <CreateWorkspacePage />,
},
]);

return renderWithRouter(router);
}

async function performNavigation(
button: HTMLElement,
router: ReturnType<typeof createMemoryRouter>,
) {
await waitFor(() => expect(button).not.toBeDisabled());
await userEvent.click(button);

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 { rootComponent, rerender } = renderInMemory(undefined);
const button = await screen.findByRole("button");
expect(button).toBeDisabled();

for (let i = 0; i < 10; i++) {
rerender(rootComponent);
expect(button).toBeDisabled();
}
});

it("Will become ready when workspace is provided and build params are successfully fetched", async () => {
renderInMemory(MockWorkspace);
const button = await screen.findByRole("button");

expect(button).toBeDisabled();
await waitFor(() => expect(button).not.toBeDisabled());
});

it("duplicateWorkspace navigates the user to the workspace creation page", async () => {
const { router } = renderInMemory(MockWorkspace);
const button = await screen.findByRole("button");
await performNavigation(button, router);
});

test("Navigating populates the URL search params with the workspace's build params", async () => {
const { router } = renderInMemory(MockWorkspace);
const button = await screen.findByRole("button");
await performNavigation(button, 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 { router } = renderInMemory(MockWorkspace);
const button = await screen.findByRole("button");
await performNavigation(button, 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 deletions site/src/pages/CreateWorkspacePage/useWorkspaceDuplication.ts
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;
}
Loading