Skip to content

Commit 6f0e2a7

Browse files
authored
refactor: poll for git auth updates when creating a workspace (#9804)
1 parent 4c3b579 commit 6f0e2a7

File tree

9 files changed

+212
-117
lines changed

9 files changed

+212
-117
lines changed

site/src/api/queries/templates.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,3 +120,16 @@ export const updateActiveTemplateVersion = (
120120
},
121121
};
122122
};
123+
124+
export const templateVersionGitAuthKey = (versionId: string) => [
125+
"templateVersion",
126+
versionId,
127+
"gitAuth",
128+
];
129+
130+
export const templateVersionGitAuth = (versionId: string) => {
131+
return {
132+
queryKey: templateVersionGitAuthKey(versionId),
133+
queryFn: () => API.getTemplateVersionGitAuth(versionId),
134+
};
135+
};

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

Lines changed: 74 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,13 @@ import {
77
MockWorkspace,
88
MockWorkspaceQuota,
99
MockWorkspaceRequest,
10+
MockWorkspaceRichParametersRequest,
1011
MockTemplateVersionParameter1,
1112
MockTemplateVersionParameter2,
1213
MockTemplateVersionParameter3,
1314
MockTemplateVersionGitAuth,
1415
MockOrganization,
16+
MockTemplateVersionGitAuthAuthenticated,
1517
} from "testHelpers/entities";
1618
import {
1719
renderWithAuth,
@@ -31,17 +33,6 @@ const renderCreateWorkspacePage = () => {
3133
});
3234
};
3335

34-
Object.defineProperty(window, "BroadcastChannel", {
35-
value: class {
36-
addEventListener() {
37-
// noop
38-
}
39-
close() {
40-
// noop
41-
}
42-
},
43-
});
44-
4536
describe("CreateWorkspacePage", () => {
4637
it("succeeds with default owner", async () => {
4738
jest
@@ -71,9 +62,9 @@ describe("CreateWorkspacePage", () => {
7162
expect(API.createWorkspace).toBeCalledWith(
7263
MockUser.organization_ids[0],
7364
MockUser.id,
74-
{
75-
...MockWorkspaceRequest,
76-
},
65+
expect.objectContaining({
66+
...MockWorkspaceRichParametersRequest,
67+
}),
7768
),
7869
);
7970
});
@@ -165,6 +156,50 @@ describe("CreateWorkspacePage", () => {
165156
expect(validationError).toBeInTheDocument();
166157
});
167158

159+
it("gitauth authenticates and succeeds", async () => {
160+
jest
161+
.spyOn(API, "getWorkspaceQuota")
162+
.mockResolvedValueOnce(MockWorkspaceQuota);
163+
jest
164+
.spyOn(API, "getUsers")
165+
.mockResolvedValueOnce({ users: [MockUser], count: 1 });
166+
jest.spyOn(API, "createWorkspace").mockResolvedValueOnce(MockWorkspace);
167+
jest
168+
.spyOn(API, "getTemplateVersionGitAuth")
169+
.mockResolvedValue([MockTemplateVersionGitAuth]);
170+
171+
renderCreateWorkspacePage();
172+
await waitForLoaderToBeRemoved();
173+
174+
const nameField = await screen.findByLabelText(nameLabelText);
175+
// have to use fireEvent b/c userEvent isn't cleaning up properly between tests
176+
fireEvent.change(nameField, {
177+
target: { value: "test" },
178+
});
179+
180+
const githubButton = await screen.findByText("Login with GitHub");
181+
await userEvent.click(githubButton);
182+
183+
jest
184+
.spyOn(API, "getTemplateVersionGitAuth")
185+
.mockResolvedValue([MockTemplateVersionGitAuthAuthenticated]);
186+
187+
await screen.findByText("Authenticated with GitHub");
188+
189+
const submitButton = screen.getByText(createWorkspaceText);
190+
await userEvent.click(submitButton);
191+
192+
await waitFor(() =>
193+
expect(API.createWorkspace).toBeCalledWith(
194+
MockUser.organization_ids[0],
195+
MockUser.id,
196+
expect.objectContaining({
197+
...MockWorkspaceRequest,
198+
}),
199+
),
200+
);
201+
});
202+
168203
it("gitauth: errors if unauthenticated and submits", async () => {
169204
jest
170205
.spyOn(API, "getTemplateVersionGitAuth")
@@ -210,4 +245,29 @@ describe("CreateWorkspacePage", () => {
210245
);
211246
});
212247
});
248+
249+
it("auto create a workspace if uses mode=auto and version=version-id", async () => {
250+
const param = "first_parameter";
251+
const paramValue = "It works!";
252+
const createWorkspaceSpy = jest.spyOn(API, "createWorkspace");
253+
254+
renderWithAuth(<CreateWorkspacePage />, {
255+
route:
256+
"/templates/" +
257+
MockTemplate.name +
258+
`/workspace?param.${param}=${paramValue}&mode=auto&version=test-template-version`,
259+
path: "/templates/:template/workspace",
260+
});
261+
262+
await waitFor(() => {
263+
expect(createWorkspaceSpy).toBeCalledWith(
264+
MockOrganization.id,
265+
"me",
266+
expect.objectContaining({
267+
template_version_id: MockTemplate.active_version_id,
268+
rich_parameter_values: [{ name: param, value: paramValue }],
269+
}),
270+
);
271+
});
272+
});
213273
});

site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx

Lines changed: 51 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,11 @@
11
import { useMachine } from "@xstate/react";
22
import {
3-
Template,
4-
TemplateVersionGitAuth,
53
TemplateVersionParameter,
64
WorkspaceBuildParameter,
75
} from "api/typesGenerated";
86
import { useMe } from "hooks/useMe";
97
import { useOrganizationId } from "hooks/useOrganizationId";
10-
import { FC } from "react";
8+
import { type FC, useCallback, useState, useEffect } from "react";
119
import { Helmet } from "react-helmet-async";
1210
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
1311
import { pageTitle } from "utils/page";
@@ -25,6 +23,10 @@ import {
2523
colors,
2624
NumberDictionary,
2725
} from "unique-names-generator";
26+
import { useQuery } from "@tanstack/react-query";
27+
import { templateVersionGitAuth } from "api/queries/templates";
28+
29+
export type GitAuthPollingState = "idle" | "polling" | "abandoned";
2830

2931
const CreateWorkspacePage: FC = () => {
3032
const organizationId = useOrganizationId();
@@ -50,18 +52,49 @@ const CreateWorkspacePage: FC = () => {
5052
},
5153
},
5254
});
53-
const {
54-
template,
55-
error,
56-
parameters,
57-
permissions,
58-
gitAuth,
59-
defaultName,
60-
versionId,
61-
} = createWorkspaceState.context;
55+
const { template, parameters, permissions, defaultName, versionId } =
56+
createWorkspaceState.context;
6257
const title = createWorkspaceState.matches("autoCreating")
6358
? "Creating workspace..."
64-
: "Create Workspace";
59+
: "Create workspace";
60+
61+
const [gitAuthPollingState, setGitAuthPollingState] =
62+
useState<GitAuthPollingState>("idle");
63+
64+
const startPollingGitAuth = useCallback(() => {
65+
setGitAuthPollingState("polling");
66+
}, []);
67+
68+
const { data: gitAuth, error } = useQuery(
69+
versionId
70+
? {
71+
...templateVersionGitAuth(versionId),
72+
refetchInterval: gitAuthPollingState === "polling" ? 1000 : false,
73+
}
74+
: { enabled: false },
75+
);
76+
77+
const allSignedIn = gitAuth?.every((it) => it.authenticated);
78+
79+
useEffect(() => {
80+
if (allSignedIn) {
81+
setGitAuthPollingState("idle");
82+
return;
83+
}
84+
85+
if (gitAuthPollingState !== "polling") {
86+
return;
87+
}
88+
89+
// Poll for a maximum of one minute
90+
const quitPolling = setTimeout(
91+
() => setGitAuthPollingState("abandoned"),
92+
60_000,
93+
);
94+
return () => {
95+
clearTimeout(quitPolling);
96+
};
97+
}, [gitAuthPollingState, allSignedIn]);
6598

6699
return (
67100
<>
@@ -81,11 +114,13 @@ const CreateWorkspacePage: FC = () => {
81114
defaultOwner={me}
82115
defaultBuildParameters={defaultBuildParameters}
83116
error={error}
84-
template={template as Template}
117+
template={template!}
85118
versionId={versionId}
86-
gitAuth={gitAuth as TemplateVersionGitAuth[]}
119+
gitAuth={gitAuth ?? []}
120+
gitAuthPollingState={gitAuthPollingState}
121+
startPollingGitAuth={startPollingGitAuth}
87122
permissions={permissions as CreateWSPermissions}
88-
parameters={parameters as TemplateVersionParameter[]}
123+
parameters={parameters!}
89124
creatingWorkspace={createWorkspaceState.matches("creatingWorkspace")}
90125
onCancel={() => {
91126
navigate(-1);

site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import { CreateWSPermissions } from "xServices/createWorkspace/createWorkspaceXS
3030
import { GitAuth } from "./GitAuth";
3131
import { ErrorAlert } from "components/Alert/ErrorAlert";
3232
import { Stack } from "components/Stack/Stack";
33+
import { type GitAuthPollingState } from "./CreateWorkspacePage";
3334

3435
export interface CreateWorkspacePageViewProps {
3536
error: unknown;
@@ -38,6 +39,8 @@ export interface CreateWorkspacePageViewProps {
3839
template: TypesGen.Template;
3940
versionId?: string;
4041
gitAuth: TypesGen.TemplateVersionGitAuth[];
42+
gitAuthPollingState: GitAuthPollingState;
43+
startPollingGitAuth: () => void;
4144
parameters: TypesGen.TemplateVersionParameter[];
4245
defaultBuildParameters: TypesGen.WorkspaceBuildParameter[];
4346
permissions: CreateWSPermissions;
@@ -56,6 +59,8 @@ export const CreateWorkspacePageView: FC<CreateWorkspacePageViewProps> = ({
5659
template,
5760
versionId,
5861
gitAuth,
62+
gitAuthPollingState,
63+
startPollingGitAuth,
5964
parameters,
6065
defaultBuildParameters,
6166
permissions,
@@ -113,7 +118,7 @@ export const CreateWorkspacePageView: FC<CreateWorkspacePageViewProps> = ({
113118
>
114119
<FormFields>
115120
<SelectedTemplate template={template} />
116-
{versionId && (
121+
{versionId && versionId !== template.active_version_id && (
117122
<Stack spacing={1} className={styles.hasDescription}>
118123
<TextField
119124
disabled
@@ -161,11 +166,13 @@ export const CreateWorkspacePageView: FC<CreateWorkspacePageViewProps> = ({
161166
description="This template requires authentication to automatically perform Git operations on create."
162167
>
163168
<FormFields>
164-
{gitAuth.map((auth, index) => (
169+
{gitAuth.map((auth) => (
165170
<GitAuth
166-
key={index}
171+
key={auth.id}
167172
authenticateURL={auth.authenticate_url}
168173
authenticated={auth.authenticated}
174+
gitAuthPollingState={gitAuthPollingState}
175+
startPollingGitAuth={startPollingGitAuth}
169176
type={auth.type}
170177
error={gitAuthErrors[auth.id]}
171178
/>
@@ -229,12 +236,8 @@ type GitAuthErrors = Record<string, string>;
229236
const useGitAuthVerification = (gitAuth: TypesGen.TemplateVersionGitAuth[]) => {
230237
const [gitAuthErrors, setGitAuthErrors] = useState<GitAuthErrors>({});
231238

239+
// Clear errors when gitAuth is refreshed
232240
useEffect(() => {
233-
// templateGitAuth is refreshed automatically using a BroadcastChannel
234-
// which may change the `authenticated` property.
235-
//
236-
// If the provider becomes authenticated, we want the error message
237-
// to disappear.
238241
setGitAuthErrors({});
239242
}, [gitAuth]);
240243

site/src/pages/CreateWorkspacePage/GitAuth.tsx

Lines changed: 30 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -9,20 +9,30 @@ import { BitbucketIcon } from "components/Icons/BitbucketIcon";
99
import { GitlabIcon } from "components/Icons/GitlabIcon";
1010
import { FC } from "react";
1111
import { makeStyles } from "@mui/styles";
12+
import { type GitAuthPollingState } from "./CreateWorkspacePage";
13+
import { Stack } from "components/Stack/Stack";
14+
import ReplayIcon from "@mui/icons-material/Replay";
15+
import { LoadingButton } from "components/LoadingButton/LoadingButton";
1216

1317
export interface GitAuthProps {
1418
type: TypesGen.GitProvider;
1519
authenticated: boolean;
1620
authenticateURL: string;
21+
gitAuthPollingState: GitAuthPollingState;
22+
startPollingGitAuth: () => void;
1723
error?: string;
1824
}
1925

20-
export const GitAuth: FC<GitAuthProps> = ({
21-
type,
22-
authenticated,
23-
authenticateURL,
24-
error,
25-
}) => {
26+
export const GitAuth: FC<GitAuthProps> = (props) => {
27+
const {
28+
type,
29+
authenticated,
30+
authenticateURL,
31+
gitAuthPollingState,
32+
startPollingGitAuth,
33+
error,
34+
} = props;
35+
2636
const styles = useStyles({
2737
error: typeof error !== "undefined",
2838
});
@@ -52,12 +62,11 @@ export const GitAuth: FC<GitAuthProps> = ({
5262

5363
return (
5464
<Tooltip
55-
title={
56-
authenticated ? "You're already authenticated! No action needed." : ``
57-
}
65+
title={authenticated && `${prettyName} has already been connected.`}
5866
>
59-
<div>
60-
<Button
67+
<Stack alignItems="center" spacing={1}>
68+
<LoadingButton
69+
loading={gitAuthPollingState === "polling"}
6170
href={authenticateURL}
6271
variant="contained"
6372
size="large"
@@ -73,15 +82,21 @@ export const GitAuth: FC<GitAuthProps> = ({
7382
return;
7483
}
7584
window.open(authenticateURL, "_blank", "width=900,height=600");
85+
startPollingGitAuth();
7686
}}
7787
>
7888
{authenticated
79-
? `You're authenticated with ${prettyName}!`
80-
: `Click to login with ${prettyName}!`}
81-
</Button>
89+
? `Authenticated with ${prettyName}`
90+
: `Login with ${prettyName}`}
91+
</LoadingButton>
8292

93+
{gitAuthPollingState === "abandoned" && (
94+
<Button variant="text" onClick={startPollingGitAuth}>
95+
<ReplayIcon /> Check again
96+
</Button>
97+
)}
8398
{error && <FormHelperText error>{error}</FormHelperText>}
84-
</div>
99+
</Stack>
85100
</Tooltip>
86101
);
87102
};

0 commit comments

Comments
 (0)