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:
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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.
|
||||
|
||||
220
packages/twenty-front/src/pages/onboarding/InviteTeam.tsx
Normal file
220
packages/twenty-front/src/pages/onboarding/InviteTeam.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -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}>
|
||||
|
||||
@ -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');
|
||||
},
|
||||
};
|
||||
@ -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,
|
||||
},
|
||||
},
|
||||
});
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user