-
Notifications
You must be signed in to change notification settings - Fork 894
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
Changes from 22 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
Jump to
Diff view
Diff view
There are no files selected for viewing
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> | ||
); | ||
} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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? There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
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 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 I went through things again, and my take is that we're stuck choosing from two less-than-ideal compromises:
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 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:
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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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? There was a problem hiding this comment. Choose a reason for hiding this commentThe 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); | ||
} | ||
}); | ||
}); |
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.
There was a problem hiding this comment.
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:
mutable
property is alwaystrue
inside themutable
array, and alwaysfalse
inside theimmutable
array