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

Commit744c733

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 parent23f0265 commit744c733

File tree

11 files changed

+397
-40
lines changed

11 files changed

+397
-40
lines changed

‎site/src/api/queries/workspaceBuilds.ts

Lines changed: 17 additions & 2 deletions
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*asAPIfrom"api/api";
3-
import{WorkspaceBuild,WorkspaceBuildsRequest}from"api/typesGenerated";
3+
import{
4+
typeWorkspaceBuild,
5+
typeWorkspaceBuildParameter,
6+
typeWorkspaceBuildsRequest,
7+
}from"api/typesGenerated";
8+
9+
exportfunctionworkspaceBuildParametersKey(workspaceBuildId:string){
10+
return["workspaceBuilds",workspaceBuildId,"parameters"]asconst;
11+
}
12+
13+
exportfunctionworkspaceBuildParameters(workspaceBuildId:string){
14+
return{
15+
queryKey:workspaceBuildParametersKey(workspaceBuildId),
16+
queryFn:()=>API.getWorkspaceBuildParameters(workspaceBuildId),
17+
}asconstsatisfiesQueryOptions<WorkspaceBuildParameter[]>;
18+
}
419

520
exportconstworkspaceBuildByNumber=(
621
username:string,

‎site/src/components/Alert/Alert.tsx

Lines changed: 8 additions & 1 deletion
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+
returnnull;
29+
}
30+
2431
return(
25-
<Collapsein={open}>
32+
<Collapsein>
2633
<MuiAlert
2734
{...alertProps}
2835
sx={{textAlign:"left", ...alertProps.sx}}

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

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

2425
constnameLabelText="Workspace Name";
2526
constcreateWorkspaceText="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+
constparams=newURLSearchParams({
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+
constwarningMessage=awaitscreen.findByRole("alert");
288+
constnameInput=awaitscreen.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

Lines changed: 27 additions & 18 deletions
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-
typeCreateWorkspaceMode="form"|"auto";
33+
exportconstcreateWorkspaceModes=["form","auto","duplicate"]asconst;
34+
exporttypeCreateWorkspaceMode=(typeofcreateWorkspaceModes)[number];
3435

3536
exporttypeExternalAuthPollingState="idle"|"polling"|"abandoned";
3637

@@ -41,10 +42,9 @@ const CreateWorkspacePage: FC = () => {
4142
constnavigate=useNavigate();
4243
const[searchParams,setSearchParams]=useSearchParams();
4344
constdefaultBuildParameters=getDefaultBuildParameters(searchParams);
44-
constmode=(searchParams.get("mode")??"form")asCreateWorkspaceMode;
45+
constmode=getWorkspaceMode(searchParams);
4546
constcustomVersionId=searchParams.get("version")??undefined;
46-
constdefaultName=
47-
mode==="auto" ?generateUniqueName() :searchParams.get("name")??"";
47+
constdefaultName=getDefaultName(mode,searchParams);
4848

4949
constqueryClient=useQueryClient();
5050
constautoCreateWorkspaceMutation=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
returnbuildValues;
221222
};
222223

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

247234
exportdefaultCreateWorkspacePage;
235+
236+
functiongetWorkspaceMode(params:URLSearchParams):CreateWorkspaceMode{
237+
constparamMode=params.get("mode");
238+
if(createWorkspaceModes.includes(paramModeasCreateWorkspaceMode)){
239+
returnparamModeasCreateWorkspaceMode;
240+
}
241+
242+
return"form";
243+
}
244+
245+
functiongetDefaultName(mode:CreateWorkspaceMode,params:URLSearchParams){
246+
if(mode==="auto"){
247+
returngenerateUniqueName();
248+
}
249+
250+
constparamsName=params.get("name");
251+
if(mode==="duplicate"&&paramsName){
252+
return`${paramsName}-copy`;
253+
}
254+
255+
returnparamsName??"";
256+
}

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

Lines changed: 1 addition & 0 deletions
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

Lines changed: 20 additions & 2 deletions
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{typeExternalAuthPollingState}from"./CreateWorkspacePage";
33+
import{
34+
CreateWorkspaceMode,
35+
typeExternalAuthPollingState,
36+
}from"./CreateWorkspacePage";
3437
import{useSearchParams}from"react-router-dom";
35-
importtype{CreateWSPermissions}from"./permissions";
38+
import{CreateWSPermissions}from"./permissions";
39+
import{Alert}from"components/Alert/Alert";
40+
41+
exportconstLanguage={
42+
duplicationWarning:
43+
"Duplicating a workspace only copies its parameters. No state from the old workspace is copied over.",
44+
}asconst;
3645

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

5767
exportconstCreateWorkspacePageView:FC<CreateWorkspacePageViewProps>=({
68+
mode,
5869
error,
5970
defaultName,
6071
defaultOwner,
@@ -116,6 +127,13 @@ export const CreateWorkspacePageView: FC<CreateWorkspacePageViewProps> = ({
116127
<FullPageHorizontalFormtitle="New workspace"onCancel={onCancel}>
117128
<HorizontalFormonSubmit={form.handleSubmit}>
118129
{Boolean(error)&&<ErrorAlerterror={error}/>}
130+
131+
{mode==="duplicate"&&(
132+
<Alertseverity="info"dismissible>
133+
{Language.duplicationWarning}
134+
</Alert>
135+
)}
136+
119137
{/* General info */}
120138
<FormSection
121139
title="General"
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import{waitFor}from"@testing-library/react";
2+
import*asMfrom"../../testHelpers/entities";
3+
import{typeWorkspace}from"api/typesGenerated";
4+
import{useWorkspaceDuplication}from"./useWorkspaceDuplication";
5+
import{MockWorkspace}from"testHelpers/entities";
6+
importCreateWorkspacePagefrom"./CreateWorkspacePage";
7+
import{renderHookWithAuth}from"testHelpers/renderHelpers";
8+
9+
functionrender(workspace?:Workspace){
10+
returnrenderHookWithAuth(
11+
({ workspace}:{workspace?:Workspace})=>{
12+
returnuseWorkspaceDuplication(workspace);
13+
},
14+
{
15+
initialProps:{ workspace},
16+
extraRoutes:[
17+
{
18+
path:"/templates/:template/workspace",
19+
element:<CreateWorkspacePage/>,
20+
},
21+
],
22+
},
23+
);
24+
}
25+
26+
typeRenderResult=Awaited<ReturnType<typeofrender>>;
27+
28+
asyncfunctionperformNavigation(
29+
result:RenderResult["result"],
30+
router:RenderResult["router"],
31+
){
32+
awaitwaitFor(()=>expect(result.current.isDuplicationReady).toBe(true));
33+
result.current.duplicateWorkspace();
34+
35+
returnwaitFor(()=>{
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}=awaitrender(undefined);
45+
expect(result.current.isDuplicationReady).toBe(false);
46+
47+
for(leti=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}=awaitrender(MockWorkspace);
55+
56+
expect(result.current.isDuplicationReady).toBe(false);
57+
awaitwaitFor(()=>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}=awaitrender(MockWorkspace);
62+
awaitperformNavigation(result,router);
63+
});
64+
65+
test("Navigating populates the URL search params with the workspace's build params",async()=>{
66+
const{ result, router}=awaitrender(MockWorkspace);
67+
awaitperformNavigation(result,router);
68+
69+
constparsedParams=newURLSearchParams(router.state.location.search);
70+
constmockBuildParams=[
71+
M.MockWorkspaceBuildParameter1,
72+
M.MockWorkspaceBuildParameter2,
73+
M.MockWorkspaceBuildParameter3,
74+
M.MockWorkspaceBuildParameter4,
75+
M.MockWorkspaceBuildParameter5,
76+
];
77+
78+
for(const{ name, value}ofmockBuildParams){
79+
constkey=`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}=awaitrender(MockWorkspace);
86+
awaitperformNavigation(result,router);
87+
88+
constparsedParams=newURLSearchParams(router.state.location.search);
89+
constextraMetadataEntries=[
90+
["mode","duplicate"],
91+
["name",MockWorkspace.name],
92+
["version",MockWorkspace.template_active_version_id],
93+
]asconst;
94+
95+
for(const[key,value]ofextraMetadataEntries){
96+
expect(parsedParams.get(key)).toBe(value);
97+
}
98+
});
99+
});
Lines changed: 71 additions & 0 deletions
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{typeCreateWorkspaceMode}from"./CreateWorkspacePage";
4+
import{
5+
typeWorkspace,
6+
typeWorkspaceBuildParameter,
7+
}from"api/typesGenerated";
8+
import{workspaceBuildParameters}from"api/queries/workspaceBuilds";
9+
import{useCallback}from"react";
10+
11+
functiongetDuplicationUrlParams(
12+
workspaceParams:readonlyWorkspaceBuildParameter[],
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+
constconsolidatedParams:Record<`param.${string}`,string>={};
18+
19+
for(constpofworkspaceParams){
20+
consolidatedParams[`param.${p.name}`]=p.value;
21+
}
22+
23+
returnnewURLSearchParams({
24+
...consolidatedParams,
25+
mode:"duplicate"satisfiesCreateWorkspaceMode,
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+
exportfunctionuseWorkspaceDuplication(workspace?:Workspace){
37+
constnavigate=useNavigate();
38+
constbuildParametersQuery=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+
constduplicateWorkspace=useCallback(()=>{
47+
constbuildParams=buildParametersQuery.data;
48+
if(buildParams===undefined||workspace===undefined){
49+
return;
50+
}
51+
52+
constnewUrlParams=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+
voidPromise.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+
}asconst;
71+
}

0 commit comments

Comments
 (0)

[8]ページ先頭

©2009-2025 Movatter.jp