Skip to content

Commit 0c94247

Browse files
authored
feat: auto-join workspace after sign-up (#8432)
* feat: get initial query * feat: introduce join workspace form * styles * feat: dismissable invite section * fix: loading next button while fetching * fix: enable/disable submit button
1 parent 466e1c4 commit 0c94247

File tree

5 files changed

+227
-7
lines changed

5 files changed

+227
-7
lines changed

packages/app/src/app/components/WorkspaceSetup/steps/Create.tsx

Lines changed: 125 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import React, { useEffect, useRef, useState } from 'react';
2-
import { useActions, useAppState } from 'app/overmind';
2+
import { useActions, useAppState, useEffects } from 'app/overmind';
3+
import { useHistory } from 'react-router-dom';
34
import {
45
Stack,
56
Button,
@@ -34,6 +35,8 @@ export const Create: React.FC<StepProps> = ({
3435
const [loading, setLoading] = useState(false);
3536
const [error, setError] = useState<string>('');
3637
const inputRef = useRef<HTMLInputElement>(null);
38+
const [disableButton, setDisableButton] = useState(false);
39+
const [loadingButton, setLoadingButton] = useState(false);
3740

3841
const urlWorkspaceId = getQueryParam('workspace');
3942
const teamIsAlreadyCreated = !!urlWorkspaceId;
@@ -143,6 +146,7 @@ export const Create: React.FC<StepProps> = ({
143146
defaultValue={teamIsAlreadyCreated ? activeTeamInfo.name : ''}
144147
onChange={handleInput}
145148
ref={inputRef}
149+
disabled={disableButton || loading}
146150
/>
147151

148152
{error && (
@@ -157,25 +161,141 @@ export const Create: React.FC<StepProps> = ({
157161
</Stack>
158162

159163
<Button
160-
loading={loading}
161-
disabled={loading || !!error}
164+
loading={loadingButton || loading}
165+
disabled={disableButton || loading || !!error}
162166
type="submit"
163167
size="large"
164168
>
165169
Next
166170
</Button>
167171
</Stack>
172+
173+
<JoinWorkspace
174+
onStart={() => setLoadingButton(true)}
175+
onDidFinish={() => setLoadingButton(false)}
176+
onDidFindWorkspace={() => setDisableButton(true)}
177+
onRejectWorkspace={() => setDisableButton(false)}
178+
/>
179+
168180
<Link
169181
href={docsUrl('/learn/plans/workspace')}
170182
target="_blank"
171183
rel="noreferrer"
172184
>
173-
<Stack gap={1} align="center">
185+
<Stack gap={2} align="center">
174186
<Text>More about teams and workspaces</Text>
175-
<Icon name="external" size={16} />
187+
<Icon name="external" size={14} />
176188
</Stack>
177189
</Link>
178190
</Stack>
179191
</AnimatedStep>
180192
);
181193
};
194+
195+
const JoinWorkspace: React.FC<{
196+
onDidFindWorkspace: () => void;
197+
onRejectWorkspace: () => void;
198+
onStart: () => void;
199+
onDidFinish: () => void;
200+
}> = ({ onDidFindWorkspace, onStart, onDidFinish, onRejectWorkspace }) => {
201+
const [hidden, setHidden] = useState(true);
202+
const effects = useEffects();
203+
const actions = useActions();
204+
const [eligibleWorkspace, setEligibleWorkspace] = useState<{
205+
id: string;
206+
name: string;
207+
}>(null);
208+
const history = useHistory();
209+
const [loading, setLoading] = useState(false);
210+
211+
useEffect(() => {
212+
onStart();
213+
214+
effects.gql.queries
215+
.getEligibleWorkspaces({})
216+
.then(result => {
217+
onDidFindWorkspace();
218+
setEligibleWorkspace(result.me.eligibleWorkspaces[0]);
219+
})
220+
.catch(e => {})
221+
.finally(() => {
222+
onDidFinish();
223+
});
224+
}, []);
225+
226+
const joinWorkspace = () => {
227+
setLoading(true);
228+
effects.gql.mutations
229+
.joinEligibleWorkspace({
230+
workspaceId: eligibleWorkspace.id,
231+
})
232+
.then(async () => {
233+
await actions.setActiveTeam({ id: eligibleWorkspace.id });
234+
await actions.dashboard.getTeams();
235+
236+
history.push(`/dashboard/recent?workspace=${eligibleWorkspace.id}`);
237+
});
238+
};
239+
240+
if (eligibleWorkspace && hidden) {
241+
return (
242+
<>
243+
<Text css={{ textAlign: 'center' }}>or</Text>
244+
<Stack
245+
direction="vertical"
246+
css={{
247+
background: '#1A1A1A',
248+
padding: 18,
249+
marginLeft: -18,
250+
marginRight: -18,
251+
borderRadius: 4,
252+
}}
253+
>
254+
<Text
255+
margin={0}
256+
as="h1"
257+
color="#fff"
258+
weight="medium"
259+
fontFamily="everett"
260+
size={24}
261+
>
262+
You have been invited to join the {eligibleWorkspace.name} workspace
263+
</Text>
264+
<p>
265+
Your email matches the domain of the {eligibleWorkspace.name}{' '}
266+
workspace.
267+
</p>
268+
269+
<Stack gap={4} css={{ marginTop: 32 }}>
270+
<Button
271+
autoWidth
272+
loading={loading}
273+
type="submit"
274+
size="large"
275+
onClick={joinWorkspace}
276+
css={{ flex: 1 }}
277+
>
278+
Join workspace
279+
</Button>
280+
281+
<Button
282+
autoWidth
283+
type="submit"
284+
size="large"
285+
css={{ flex: 1 }}
286+
variant="secondary"
287+
onClick={() => {
288+
setHidden(false);
289+
onRejectWorkspace();
290+
}}
291+
>
292+
Reject
293+
</Button>
294+
</Stack>
295+
</Stack>
296+
</>
297+
);
298+
}
299+
300+
return null;
301+
};

packages/app/src/app/components/dashboard/InputText.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ const StyledInput = styled.input<{ isInvalid?: boolean }>`
1616
1717
${props => (props.isInvalid ? 'outline: 1px solid #EB5E5E;' : '')}
1818
19-
&:hover {
19+
&:hover:not(:disabled) {
2020
box-shadow: 0 0 0 2px #e5e5e51a;
2121
}
2222

packages/app/src/app/graphql/types.ts

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,8 +155,24 @@ export type RootQueryType = {
155155
* ```
156156
*/
157157
recentTeamsByRepository: Array<TeamPreview>;
158-
/** Get a sandbox */
158+
/**
159+
* Get a sandbox by its (short) ID
160+
*
161+
* Requires the current user have read authorization (or that the sandbox is public). Otherwise
162+
* returns an error (`"unauthorized"`).
163+
*/
159164
sandbox: Maybe<Sandbox>;
165+
/**
166+
* Returns a sandbox's team ID if the current user is eligible to join that workspace
167+
*
168+
* This query is designed for use in 404 experience where the current user does not have access
169+
* to the resource but *might* have access if they accept an open invitation to its workspace.
170+
* Returns null if no such open invitation exists, or an error if no user is authenticated.
171+
*
172+
* For a list of all workspaces the user is eligible to join, see `query eligibleWorkspaces`.
173+
* The ID returned by this query can be used in `mutation joinEligibleWorkspace`.
174+
*/
175+
sandboxEligibleWorkspace: Maybe<TeamPreview>;
160176
/** A team from an invite token */
161177
teamByToken: Maybe<TeamPreview>;
162178
};
@@ -227,6 +243,10 @@ export type RootQueryTypeSandboxArgs = {
227243
sandboxId: Scalars['ID'];
228244
};
229245

246+
export type RootQueryTypeSandboxEligibleWorkspaceArgs = {
247+
sandboxId: Scalars['ID'];
248+
};
249+
230250
export type RootQueryTypeTeamByTokenArgs = {
231251
inviteToken: Scalars['String'];
232252
};
@@ -390,6 +410,8 @@ export type Team = {
390410
legacy: Scalars['Boolean'];
391411
limits: TeamLimits;
392412
members: Array<TeamMember>;
413+
/** Additional user-provided metadata about the workspace */
414+
metadata: TeamMetadata;
393415
name: Scalars['String'];
394416
privateRegistry: Maybe<PrivateRegistry>;
395417
/**
@@ -535,6 +557,13 @@ export type TeamMember = {
535557
username: Scalars['String'];
536558
};
537559

560+
/** Additional user-provided metadata about a workspace */
561+
export type TeamMetadata = {
562+
__typename?: 'TeamMetadata';
563+
/** Use-cases for the workspace provided during creation */
564+
useCases: Array<Scalars['String']>;
565+
};
566+
538567
/** A private package registry */
539568
export type PrivateRegistry = {
540569
__typename?: 'PrivateRegistry';
@@ -2071,6 +2100,8 @@ export type RootMutationType = {
20712100
setTeamDescription: Team;
20722101
/** Set user-editable limits for the workspace */
20732102
setTeamLimits: Scalars['String'];
2103+
/** Set user-provided metadata about the workspace */
2104+
setTeamMetadata: Team;
20742105
/** Set minimum privacy level for workspace */
20752106
setTeamMinimumPrivacy: WorkspaceSandboxSettings;
20762107
/** Set the name of the team */
@@ -2595,6 +2626,11 @@ export type RootMutationTypeSetTeamLimitsArgs = {
25952626
teamId: Scalars['UUID4'];
25962627
};
25972628

2629+
export type RootMutationTypeSetTeamMetadataArgs = {
2630+
metadata: TeamMetadataInput;
2631+
teamId: Scalars['UUID4'];
2632+
};
2633+
25982634
export type RootMutationTypeSetTeamMinimumPrivacyArgs = {
25992635
minimumPrivacy: Scalars['Int'];
26002636
teamId: Scalars['UUID4'];
@@ -2812,6 +2848,12 @@ export type BillingDetails = {
28122848
date: Scalars['String'];
28132849
};
28142850

2851+
/** Additional user-provided metadata about a workspace */
2852+
export type TeamMetadataInput = {
2853+
/** Use-cases for the workspace */
2854+
useCases: Array<Scalars['String']>;
2855+
};
2856+
28152857
export type RootSubscriptionType = {
28162858
__typename?: 'RootSubscriptionType';
28172859
/** Receive updates for events related to the specified branch. */
@@ -5603,6 +5645,15 @@ export type UpdateProjectVmTierMutation = {
56035645
};
56045646
};
56055647

5648+
export type JoinEligibleWorkspaceMutationVariables = Exact<{
5649+
workspaceId: Scalars['ID'];
5650+
}>;
5651+
5652+
export type JoinEligibleWorkspaceMutation = {
5653+
__typename?: 'RootMutationType';
5654+
joinEligibleWorkspace: { __typename?: 'Team'; id: any };
5655+
};
5656+
56065657
export type RecentlyDeletedTeamSandboxesQueryVariables = Exact<{
56075658
teamId: Scalars['UUID4'];
56085659
}>;
@@ -6549,6 +6600,24 @@ export type GetSandboxWithTemplateQuery = {
65496600
} | null;
65506601
};
65516602

6603+
export type GetEligibleWorkspacesQueryVariables = Exact<{
6604+
[key: string]: never;
6605+
}>;
6606+
6607+
export type GetEligibleWorkspacesQuery = {
6608+
__typename?: 'RootQueryType';
6609+
me: {
6610+
__typename?: 'CurrentUser';
6611+
eligibleWorkspaces: Array<{
6612+
__typename?: 'TeamPreview';
6613+
id: any;
6614+
avatarUrl: string | null;
6615+
name: string;
6616+
shortid: string;
6617+
}>;
6618+
} | null;
6619+
};
6620+
65526621
export type RecentNotificationFragment = {
65536622
__typename?: 'Notification';
65546623
id: any;

packages/app/src/app/overmind/effects/gql/dashboard/mutations.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,8 @@ import {
6161
UpdateProjectVmTierMutation,
6262
UpdateUsageSubscriptionMutationVariables,
6363
UpdateUsageSubscriptionMutation,
64+
JoinEligibleWorkspaceMutation,
65+
JoinEligibleWorkspaceMutationVariables,
6466
} from 'app/graphql/types';
6567
import { gql, Query } from 'overmind-graphql';
6668

@@ -476,3 +478,14 @@ export const updateProjectVmTier: Query<
476478
}
477479
}
478480
`;
481+
482+
export const joinEligibleWorkspace: Query<
483+
JoinEligibleWorkspaceMutation,
484+
JoinEligibleWorkspaceMutationVariables
485+
> = gql`
486+
mutation JoinEligibleWorkspace($workspaceId: ID!) {
487+
joinEligibleWorkspace(workspaceId: $workspaceId) {
488+
id
489+
}
490+
}
491+
`;

packages/app/src/app/overmind/effects/gql/dashboard/queries.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ import {
4141
GetFullGitHubOrganizationReposQueryVariables,
4242
GetSandboxWithTemplateQuery,
4343
GetSandboxWithTemplateQueryVariables,
44+
GetEligibleWorkspacesQuery,
45+
GetEligibleWorkspacesQueryVariables,
4446
} from 'app/graphql/types';
4547
import { gql, Query } from 'overmind-graphql';
4648

@@ -399,3 +401,19 @@ export const getSandboxWithTemplate: Query<
399401
}
400402
}
401403
`;
404+
405+
export const getEligibleWorkspaces: Query<
406+
GetEligibleWorkspacesQuery,
407+
GetEligibleWorkspacesQueryVariables
408+
> = gql`
409+
query GetEligibleWorkspaces {
410+
me {
411+
eligibleWorkspaces {
412+
id
413+
avatarUrl
414+
name
415+
shortid
416+
}
417+
}
418+
}
419+
`;

0 commit comments

Comments
 (0)