5623 add an inviteteam onboarding step (#5769)

## Changes
- add a new invite Team onboarding step
- update currentUser.state to currentUser.onboardingStep

## Edge cases
We will never display invite team onboarding step 
- if number of workspaceMember > 1
- if a workspaceMember as been deleted

## Important changes
Update typeorm package version to 0.3.20 because we needed a fix on
`indexPredicates` pushed in 0.3.20 version
(https://github.com/typeorm/typeorm/issues/10191)

## Result
<img width="844" alt="image"
src="https://github.com/twentyhq/twenty/assets/29927851/0dab54cf-7c66-4c64-b0c9-b0973889a148">



https://github.com/twentyhq/twenty/assets/29927851/13268d0a-cfa7-42a4-84c6-9e1fbbe48912
This commit is contained in:
martmull
2024-06-12 21:13:18 +02:00
committed by GitHub
parent 2fdd2f4949
commit 3986824017
60 changed files with 1009 additions and 372 deletions

View File

@ -143,7 +143,7 @@ export const ChooseYourPlan = () => {
return (
prices?.getProductPrices?.productPrices && (
<>
<Title withMarginTop={false}>Choose your Plan</Title>
<Title noMarginTop>Choose your Plan</Title>
<SubTitle>
Enjoy a {billing?.billingFreeTrialDurationInDays}-day free trial
</SubTitle>

View File

@ -55,9 +55,7 @@ type Form = z.infer<typeof validationSchema>;
export const CreateProfile = () => {
const onboardingStatus = useOnboardingStatus();
const { enqueueSnackBar } = useSnackBar();
const [currentWorkspaceMember, setCurrentWorkspaceMember] = useRecoilState(
currentWorkspaceMemberState,
);
@ -145,7 +143,7 @@ export const CreateProfile = () => {
return (
<>
<Title withMarginTop={false}>Create profile</Title>
<Title noMarginTop>Create profile</Title>
<SubTitle>How you'll be identified on the app.</SubTitle>
<StyledContentContainer>
<StyledSectionContainer>

View File

@ -111,7 +111,7 @@ export const CreateWorkspace = () => {
return (
<>
<Title withMarginTop={false}>Create your workspace</Title>
<Title noMarginTop>Create your workspace</Title>
<SubTitle>
A shared environment where you will be able to manage your customer
relations with your team.

View File

@ -0,0 +1,220 @@
import { useCallback } from 'react';
import {
Controller,
SubmitHandler,
useFieldArray,
useForm,
} from 'react-hook-form';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { zodResolver } from '@hookform/resolvers/zod';
import { useRecoilValue } from 'recoil';
import { Key } from 'ts-key-enum';
import { IconCopy } from 'twenty-ui';
import { z } from 'zod';
import { SubTitle } from '@/auth/components/SubTitle';
import { Title } from '@/auth/components/Title';
import { currentUserState } from '@/auth/states/currentUserState';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { useSetNextOnboardingStep } from '@/onboarding/hooks/useSetNextOnboardingStep';
import { PageHotkeyScope } from '@/types/PageHotkeyScope';
import { SeparatorLineText } from '@/ui/display/text/components/SeparatorLineText';
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { LightButton } from '@/ui/input/button/components/LightButton';
import { MainButton } from '@/ui/input/button/components/MainButton';
import { TextInputV2 } from '@/ui/input/components/TextInputV2';
import { AnimatedTranslation } from '@/ui/utilities/animation/components/AnimatedTranslation';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { OnboardingStep, useSendInviteLinkMutation } from '~/generated/graphql';
import { isDefined } from '~/utils/isDefined';
const StyledAnimatedContainer = styled.div`
display: flex;
flex-direction: column;
padding: ${({ theme }) => theme.spacing(8)} 0;
gap: ${({ theme }) => theme.spacing(4)};
overflow-y: scroll;
overflow-x: hidden;
width: 100%;
`;
const StyledActionLinkContainer = styled.div`
display: flex;
justify-content: center;
`;
const StyledButtonContainer = styled.div`
display: flex;
justify-content: center;
width: 200px;
`;
const validationSchema = z.object({
emails: z.array(
z.object({ email: z.union([z.literal(''), z.string().email()]) }),
),
});
type FormInput = z.infer<typeof validationSchema>;
export const InviteTeam = () => {
const theme = useTheme();
const { enqueueSnackBar } = useSnackBar();
const [sendInviteLink] = useSendInviteLinkMutation();
const setNextOnboardingStep = useSetNextOnboardingStep();
const currentUser = useRecoilValue(currentUserState);
const currentWorkspace = useRecoilValue(currentWorkspaceState);
const {
control,
handleSubmit,
watch,
formState: { isValid, isSubmitting },
} = useForm<FormInput>({
mode: 'onChange',
defaultValues: {
emails: [{ email: '' }, { email: '' }, { email: '' }],
},
resolver: zodResolver(validationSchema),
});
const { fields, append, remove } = useFieldArray({
control,
name: 'emails',
});
watch(({ emails }) => {
if (!emails) {
return;
}
const emailValues = emails.map((email) => email?.email);
if (emailValues[emailValues.length - 1] !== '') {
append({ email: '' });
}
if (emailValues.length > 3 && emailValues[emailValues.length - 2] === '') {
remove(emailValues.length - 1);
}
});
const getPlaceholder = (emailIndex: number) => {
if (emailIndex === 0) {
return 'tim@apple.dev';
}
if (emailIndex === 1) {
return 'craig@apple.dev';
}
if (emailIndex === 2) {
return 'mike@apple.dev';
}
return 'phil@apple.dev';
};
const copyInviteLink = () => {
if (isDefined(currentWorkspace?.inviteHash)) {
const inviteLink = `${window.location.origin}/invite/${currentWorkspace?.inviteHash}`;
navigator.clipboard.writeText(inviteLink);
enqueueSnackBar('Link copied to clipboard', {
variant: SnackBarVariant.Success,
icon: <IconCopy size={theme.icon.size.md} />,
duration: 2000,
});
}
};
const onSubmit: SubmitHandler<FormInput> = useCallback(
async (data) => {
const emails = Array.from(
new Set(
data.emails
.map((emailData) => emailData.email.trim())
.filter((email) => email.length > 0),
),
);
const result = await sendInviteLink({ variables: { emails } });
setNextOnboardingStep(OnboardingStep.InviteTeam);
if (isDefined(result.errors)) {
throw result.errors;
}
if (emails.length > 0) {
enqueueSnackBar('Invite link sent to email addresses', {
variant: SnackBarVariant.Success,
duration: 2000,
});
}
},
[enqueueSnackBar, sendInviteLink, setNextOnboardingStep],
);
useScopedHotkeys(
[Key.Enter],
() => {
handleSubmit(onSubmit)();
},
PageHotkeyScope.InviteTeam,
[handleSubmit],
);
if (currentUser?.onboardingStep !== OnboardingStep.InviteTeam) {
return <></>;
}
return (
<>
<Title noMarginTop>Invite your team</Title>
<SubTitle>
Get the most out of your workspace by inviting your team.
</SubTitle>
<StyledAnimatedContainer>
{fields.map((field, index) => (
<Controller
key={index}
name={`emails.${index}.email`}
control={control}
render={({
field: { onChange, onBlur, value },
fieldState: { error },
}) => (
<AnimatedTranslation>
<TextInputV2
autoFocus={index === 0}
type="email"
value={value}
placeholder={getPlaceholder(index)}
onBlur={onBlur}
error={error?.message}
onChange={onChange}
noErrorHelper
fullWidth
/>
</AnimatedTranslation>
)}
/>
))}
{isDefined(currentWorkspace?.inviteHash) && (
<>
<SeparatorLineText>Or</SeparatorLineText>
<StyledActionLinkContainer>
<LightButton
title="Copy invitation link"
accent="tertiary"
onClick={copyInviteLink}
Icon={IconCopy}
/>
</StyledActionLinkContainer>
</>
)}
</StyledAnimatedContainer>
<StyledButtonContainer>
<MainButton
title="Finish"
disabled={!isValid || isSubmitting}
onClick={handleSubmit(onSubmit)}
fullWidth
/>
</StyledButtonContainer>
</>
);
};

View File

@ -1,21 +1,25 @@
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { useSetRecoilState } from 'recoil';
import { useRecoilValue } from 'recoil';
import { Key } from 'ts-key-enum';
import { IconGoogle } from 'twenty-ui';
import { SubTitle } from '@/auth/components/SubTitle';
import { Title } from '@/auth/components/Title';
import { isCurrentUserLoadedState } from '@/auth/states/isCurrentUserLoadingState';
import { currentUserState } from '@/auth/states/currentUserState';
import { OnboardingSyncEmailsSettingsCard } from '@/onboarding/components/OnboardingSyncEmailsSettingsCard';
import { useSetNextOnboardingStep } from '@/onboarding/hooks/useSetNextOnboardingStep';
import { useTriggerGoogleApisOAuth } from '@/settings/accounts/hooks/useTriggerGoogleApisOAuth';
import { AppPath } from '@/types/AppPath';
import { PageHotkeyScope } from '@/types/PageHotkeyScope';
import { MainButton } from '@/ui/input/button/components/MainButton';
import { ActionLink } from '@/ui/navigation/link/components/ActionLink';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import {
CalendarChannelVisibility,
MessageChannelVisibility,
OnboardingStep,
useSkipSyncEmailOnboardingStepMutation,
} from '~/generated/graphql';
@ -35,9 +39,9 @@ const StyledActionLinkContainer = styled.div`
export const SyncEmails = () => {
const theme = useTheme();
const navigate = useNavigate();
const { triggerGoogleApisOAuth } = useTriggerGoogleApisOAuth();
const setIsCurrentUserLoaded = useSetRecoilState(isCurrentUserLoadedState);
const setNextOnboardingStep = useSetNextOnboardingStep();
const currentUser = useRecoilValue(currentUserState);
const [visibility, setVisibility] = useState<MessageChannelVisibility>(
MessageChannelVisibility.ShareEverything,
);
@ -59,15 +63,25 @@ export const SyncEmails = () => {
const continueWithoutSync = async () => {
await skipSyncEmailOnboardingStepMutation();
setIsCurrentUserLoaded(false);
navigate(AppPath.Index);
setNextOnboardingStep(OnboardingStep.SyncEmail);
};
const isSubmitting = false;
useScopedHotkeys(
[Key.Enter],
async () => {
await continueWithoutSync();
},
PageHotkeyScope.SyncEmail,
[continueWithoutSync],
);
if (currentUser?.onboardingStep !== OnboardingStep.SyncEmail) {
return <></>;
}
return (
<>
<Title withMarginTop={false}>Emails and Calendar</Title>
<Title noMarginTop>Emails and Calendar</Title>
<SubTitle>
Sync your Emails and Calendar with Twenty. Choose your privacy settings.
</SubTitle>
@ -82,7 +96,6 @@ export const SyncEmails = () => {
onClick={handleButtonClick}
width={200}
Icon={() => <IconGoogle size={theme.icon.size.sm} />}
disabled={isSubmitting}
/>
<StyledActionLinkContainer>
<ActionLink onClick={continueWithoutSync}>

View File

@ -0,0 +1,50 @@
import { getOperationName } from '@apollo/client/utilities';
import { Meta, StoryObj } from '@storybook/react';
import { within } from '@storybook/test';
import { graphql, HttpResponse } from 'msw';
import { OnboardingStep } from '~/generated/graphql';
import { AppPath } from '~/modules/types/AppPath';
import { GET_CURRENT_USER } from '~/modules/users/graphql/queries/getCurrentUser';
import { InviteTeam } from '~/pages/onboarding/InviteTeam';
import {
PageDecorator,
PageDecoratorArgs,
} from '~/testing/decorators/PageDecorator';
import { graphqlMocks } from '~/testing/graphqlMocks';
import { mockedOnboardingUsersData } from '~/testing/mock-data/users';
const meta: Meta<PageDecoratorArgs> = {
title: 'Pages/Onboarding/InviteTeam',
component: InviteTeam,
decorators: [PageDecorator],
args: { routePath: AppPath.InviteTeam },
parameters: {
msw: {
handlers: [
graphql.query(getOperationName(GET_CURRENT_USER) ?? '', () => {
return HttpResponse.json({
data: {
currentUser: {
...mockedOnboardingUsersData[0],
onboardingStep: OnboardingStep.InviteTeam,
},
},
});
}),
graphqlMocks.handlers,
],
},
},
};
export default meta;
export type Story = StoryObj<typeof InviteTeam>;
export const Default: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await canvas.findByText('Invite your team');
},
};

View File

@ -3,6 +3,7 @@ import { Meta, StoryObj } from '@storybook/react';
import { within } from '@storybook/test';
import { graphql, HttpResponse } from 'msw';
import { OnboardingStep } from '~/generated/graphql';
import { AppPath } from '~/modules/types/AppPath';
import { GET_CURRENT_USER } from '~/modules/users/graphql/queries/getCurrentUser';
import { SyncEmails } from '~/pages/onboarding/SyncEmails';
@ -24,7 +25,10 @@ const meta: Meta<PageDecoratorArgs> = {
graphql.query(getOperationName(GET_CURRENT_USER) ?? '', () => {
return HttpResponse.json({
data: {
currentUser: mockedOnboardingUsersData[0],
currentUser: {
...mockedOnboardingUsersData[0],
onboardingStep: OnboardingStep.SyncEmail,
},
},
});
}),