diff --git a/package.json b/package.json index 1d10ba9850..bf61c2d2be 100644 --- a/package.json +++ b/package.json @@ -185,7 +185,7 @@ "tslib": "^2.3.0", "tsup": "^8.0.1", "type-fest": "4.10.1", - "typeorm": "^0.3.17", + "typeorm": "^0.3.20", "use-context-selector": "^2.0.0", "use-debounce": "^10.0.0", "uuid": "^9.0.0", diff --git a/packages/twenty-chrome-extension/src/generated/graphql.tsx b/packages/twenty-chrome-extension/src/generated/graphql.tsx index 32f757e258..69e043e63d 100644 --- a/packages/twenty-chrome-extension/src/generated/graphql.tsx +++ b/packages/twenty-chrome-extension/src/generated/graphql.tsx @@ -994,8 +994,6 @@ export type CalendarChannel = { syncCursor?: Maybe; /** Throttle Failure Count */ throttleFailureCount?: Maybe; - /** Throttle Pause Until */ - throttlePauseUntil?: Maybe; /** Update date */ updatedAt?: Maybe; /** Visibility */ @@ -1040,8 +1038,6 @@ export type CalendarChannelCreateInput = { syncCursor?: InputMaybe; /** Throttle Failure Count */ throttleFailureCount?: InputMaybe; - /** Throttle Pause Until */ - throttlePauseUntil?: InputMaybe; /** Update date */ updatedAt?: InputMaybe; /** Visibility */ @@ -1176,8 +1172,6 @@ export type CalendarChannelFilterInput = { syncCursor?: InputMaybe; /** Throttle Failure Count */ throttleFailureCount?: InputMaybe; - /** Throttle Pause Until */ - throttlePauseUntil?: InputMaybe; /** Update date */ updatedAt?: InputMaybe; /** Visibility */ @@ -1202,8 +1196,6 @@ export type CalendarChannelOrderByInput = { syncCursor?: InputMaybe; /** Throttle Failure Count */ throttleFailureCount?: InputMaybe; - /** Throttle Pause Until */ - throttlePauseUntil?: InputMaybe; /** Update date */ updatedAt?: InputMaybe; /** Visibility */ @@ -1228,8 +1220,6 @@ export type CalendarChannelUpdateInput = { syncCursor?: InputMaybe; /** Throttle Failure Count */ throttleFailureCount?: InputMaybe; - /** Throttle Pause Until */ - throttlePauseUntil?: InputMaybe; /** Update date */ updatedAt?: InputMaybe; /** Visibility */ @@ -2640,20 +2630,18 @@ export type MessageChannel = { isSyncEnabled?: Maybe; /** Messages from the channel. */ messageChannelMessageAssociations?: Maybe; - /** Ongoing sync started at */ - ongoingSyncStartedAt?: Maybe; /** Last sync cursor */ syncCursor?: Maybe; + /** Sync stage */ + syncStage?: Maybe; + /** Sync stage started at */ + syncStageStartedAt?: Maybe; /** Sync status */ syncStatus?: Maybe; - /** Sync sub status */ - syncSubStatus?: Maybe; /** Last sync date */ syncedAt?: Maybe; /** Throttle Failure Count */ throttleFailureCount?: Maybe; - /** Throttle Pause Until */ - throttlePauseUntil?: Maybe; /** Channel Type */ type?: Maybe; /** Update date */ @@ -2696,20 +2684,18 @@ export type MessageChannelCreateInput = { isContactAutoCreationEnabled?: InputMaybe; /** Is Sync Enabled */ isSyncEnabled?: InputMaybe; - /** Ongoing sync started at */ - ongoingSyncStartedAt?: InputMaybe; /** Last sync cursor */ syncCursor?: InputMaybe; + /** Sync stage */ + syncStage?: InputMaybe; + /** Sync stage started at */ + syncStageStartedAt?: InputMaybe; /** Sync status */ syncStatus?: InputMaybe; - /** Sync sub status */ - syncSubStatus?: InputMaybe; /** Last sync date */ syncedAt?: InputMaybe; /** Throttle Failure Count */ throttleFailureCount?: InputMaybe; - /** Throttle Pause Until */ - throttlePauseUntil?: InputMaybe; /** Channel Type */ type?: InputMaybe; /** Update date */ @@ -2740,21 +2726,19 @@ export type MessageChannelFilterInput = { /** Is Sync Enabled */ isSyncEnabled?: InputMaybe; not?: InputMaybe; - /** Ongoing sync started at */ - ongoingSyncStartedAt?: InputMaybe; or?: InputMaybe>>; /** Last sync cursor */ syncCursor?: InputMaybe; + /** Sync stage */ + syncStage?: InputMaybe; + /** Sync stage started at */ + syncStageStartedAt?: InputMaybe; /** Sync status */ syncStatus?: InputMaybe; - /** Sync sub status */ - syncSubStatus?: InputMaybe; /** Last sync date */ syncedAt?: InputMaybe; /** Throttle Failure Count */ throttleFailureCount?: InputMaybe; - /** Throttle Pause Until */ - throttlePauseUntil?: InputMaybe; /** Channel Type */ type?: InputMaybe; /** Update date */ @@ -2900,20 +2884,18 @@ export type MessageChannelOrderByInput = { isContactAutoCreationEnabled?: InputMaybe; /** Is Sync Enabled */ isSyncEnabled?: InputMaybe; - /** Ongoing sync started at */ - ongoingSyncStartedAt?: InputMaybe; /** Last sync cursor */ syncCursor?: InputMaybe; + /** Sync stage */ + syncStage?: InputMaybe; + /** Sync stage started at */ + syncStageStartedAt?: InputMaybe; /** Sync status */ syncStatus?: InputMaybe; - /** Sync sub status */ - syncSubStatus?: InputMaybe; /** Last sync date */ syncedAt?: InputMaybe; /** Throttle Failure Count */ throttleFailureCount?: InputMaybe; - /** Throttle Pause Until */ - throttlePauseUntil?: InputMaybe; /** Channel Type */ type?: InputMaybe; /** Update date */ @@ -2922,6 +2904,29 @@ export type MessageChannelOrderByInput = { visibility?: InputMaybe; }; +/** Sync stage */ +export enum MessageChannelSyncStageEnum { + /** Failed */ + Failed = 'FAILED', + /** Full messages list fetch pending */ + FullMessageListFetchPending = 'FULL_MESSAGE_LIST_FETCH_PENDING', + /** Messages import ongoing */ + MessagesImportOngoing = 'MESSAGES_IMPORT_ONGOING', + /** Messages import pending */ + MessagesImportPending = 'MESSAGES_IMPORT_PENDING', + /** Messages list fetch ongoing */ + MessageListFetchOngoing = 'MESSAGE_LIST_FETCH_ONGOING', + /** Partial messages list fetch pending */ + PartialMessageListFetchPending = 'PARTIAL_MESSAGE_LIST_FETCH_PENDING' +} + +export type MessageChannelSyncStageEnumFilter = { + eq?: InputMaybe; + in?: InputMaybe>>; + is?: InputMaybe; + neq?: InputMaybe; +}; + /** Sync status */ export enum MessageChannelSyncStatusEnum { /** Completed */ @@ -2949,29 +2954,6 @@ export type MessageChannelSyncStatusEnumFilter = { neq?: InputMaybe; }; -/** Sync sub status */ -export enum MessageChannelSyncSubStatusEnum { - /** Failed */ - Failed = 'FAILED', - /** Full messages list fetch pending */ - FullMessageListFetchPending = 'FULL_MESSAGE_LIST_FETCH_PENDING', - /** Messages import ongoing */ - MessagesImportOngoing = 'MESSAGES_IMPORT_ONGOING', - /** Messages import pending */ - MessagesImportPending = 'MESSAGES_IMPORT_PENDING', - /** Messages list fetch ongoing */ - MessageListFetchOngoing = 'MESSAGE_LIST_FETCH_ONGOING', - /** Partial messages list fetch pending */ - PartialMessageListFetchPending = 'PARTIAL_MESSAGE_LIST_FETCH_PENDING' -} - -export type MessageChannelSyncSubStatusEnumFilter = { - eq?: InputMaybe; - in?: InputMaybe>>; - is?: InputMaybe; - neq?: InputMaybe; -}; - /** Channel Type */ export enum MessageChannelTypeEnum { /** Email */ @@ -3001,20 +2983,18 @@ export type MessageChannelUpdateInput = { isContactAutoCreationEnabled?: InputMaybe; /** Is Sync Enabled */ isSyncEnabled?: InputMaybe; - /** Ongoing sync started at */ - ongoingSyncStartedAt?: InputMaybe; /** Last sync cursor */ syncCursor?: InputMaybe; + /** Sync stage */ + syncStage?: InputMaybe; + /** Sync stage started at */ + syncStageStartedAt?: InputMaybe; /** Sync status */ syncStatus?: InputMaybe; - /** Sync sub status */ - syncSubStatus?: InputMaybe; /** Last sync date */ syncedAt?: InputMaybe; /** Throttle Failure Count */ throttleFailureCount?: InputMaybe; - /** Throttle Pause Until */ - throttlePauseUntil?: InputMaybe; /** Channel Type */ type?: InputMaybe; /** Update date */ @@ -3572,8 +3552,9 @@ export type Mutation = { generateTransientToken: TransientToken; impersonate: Verify; renewToken: AuthTokens; + sendInviteLink: SendInviteLink; signUp: LoginToken; - skipSyncEmailOnboardingStep: SkipSyncEmailOnboardingStep; + skipSyncEmailOnboardingStep: OnboardingStepSuccess; track: Analytics; updateActivities?: Maybe>; updateActivity?: Maybe; @@ -4411,6 +4392,11 @@ export type MutationRenewTokenArgs = { }; +export type MutationSendInviteLinkArgs = { + emails: Array; +}; + + export type MutationSignUpArgs = { captchaToken?: InputMaybe; email: Scalars['String']; @@ -4817,6 +4803,17 @@ export type ObjectFieldsConnection = { pageInfo: PageInfo; }; +/** Onboarding step */ +export enum OnboardingStep { + InviteTeam = 'INVITE_TEAM', + SyncEmail = 'SYNC_EMAIL' +} + +export type OnboardingStepSuccess = { + /** Boolean that confirms query was dispatched */ + success: Scalars['Boolean']; +}; + /** An opportunity */ export type Opportunity = { /** Activities tied to the opportunity */ @@ -6186,6 +6183,11 @@ export enum RemoteTableStatus { Synced = 'SYNCED' } +export type SendInviteLink = { + /** Boolean that confirms query was dispatched */ + success: Scalars['Boolean']; +}; + export type Sentry = { dsn?: Maybe; environment?: Maybe; @@ -6196,11 +6198,6 @@ export type SessionEntity = { url?: Maybe; }; -export type SkipSyncEmailOnboardingStep = { - /** Boolean that confirms query was dispatched */ - success: Scalars['Boolean']; -}; - /** Sort Directions */ export enum SortDirection { Asc = 'ASC', @@ -6537,12 +6534,12 @@ export type User = { firstName: Scalars['String']; id: Scalars['UUID']; lastName: Scalars['String']; + onboardingStep?: Maybe; passwordHash?: Maybe; /** @deprecated field migrated into the AppTokens Table ref: https://github.com/twentyhq/twenty/issues/5021 */ passwordResetToken?: Maybe; /** @deprecated field migrated into the AppTokens Table ref: https://github.com/twentyhq/twenty/issues/5021 */ passwordResetTokenExpiresAt?: Maybe; - state: UserState; supportUserHash?: Maybe; updatedAt: Scalars['DateTime']; workspaceMember?: Maybe; @@ -6564,10 +6561,6 @@ export type UserMappingOptionsUser = { user?: Maybe; }; -export type UserState = { - skipSyncEmailOnboardingStep?: Maybe; -}; - export type UserWorkspace = { createdAt: Scalars['DateTime']; deletedAt?: Maybe; diff --git a/packages/twenty-front/src/App.tsx b/packages/twenty-front/src/App.tsx index 140ab0f0de..956a6c59e3 100644 --- a/packages/twenty-front/src/App.tsx +++ b/packages/twenty-front/src/App.tsx @@ -48,6 +48,7 @@ import { RecordShowPage } from '~/pages/object-record/RecordShowPage'; import { ChooseYourPlan } from '~/pages/onboarding/ChooseYourPlan'; import { CreateProfile } from '~/pages/onboarding/CreateProfile'; import { CreateWorkspace } from '~/pages/onboarding/CreateWorkspace'; +import { InviteTeam } from '~/pages/onboarding/InviteTeam'; import { PaymentSuccess } from '~/pages/onboarding/PaymentSuccess'; import { SyncEmails } from '~/pages/onboarding/SyncEmails'; import { SettingsAccounts } from '~/pages/settings/accounts/SettingsAccounts'; @@ -143,6 +144,7 @@ const createRouter = (isBillingEnabled?: boolean) => } /> } /> } /> + } /> } /> { setHotkeyScope(PageHotkeyScope.CreateWokspace); break; } + case isMatchingLocation(AppPath.SyncEmails): { + setHotkeyScope(PageHotkeyScope.SyncEmail); + break; + } + case isMatchingLocation(AppPath.InviteTeam): { + setHotkeyScope(PageHotkeyScope.InviteTeam); + break; + } case isMatchingLocation(AppPath.PlanRequired): { setHotkeyScope(PageHotkeyScope.PlanRequired); break; diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index 6cfa03c060..05155eaa10 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -407,7 +407,7 @@ export type Mutation = { renewToken: AuthTokens; sendInviteLink: SendInviteLink; signUp: LoginToken; - skipSyncEmailOnboardingStep: SkipSyncEmailOnboardingStep; + skipSyncEmailOnboardingStep: OnboardingStepSuccess; syncRemoteTable: RemoteTable; syncRemoteTableSchemaChanges: RemoteTable; track: Analytics; @@ -636,6 +636,18 @@ export type ObjectFieldsConnection = { pageInfo: PageInfo; }; +/** Onboarding step */ +export enum OnboardingStep { + InviteTeam = 'INVITE_TEAM', + SyncEmail = 'SYNC_EMAIL' +} + +export type OnboardingStepSuccess = { + __typename?: 'OnboardingStepSuccess'; + /** Boolean that confirms query was dispatched */ + success: Scalars['Boolean']['output']; +}; + export type PageInfo = { __typename?: 'PageInfo'; /** The cursor of the last returned record. */ @@ -888,12 +900,6 @@ export type SessionEntity = { url?: Maybe; }; -export type SkipSyncEmailOnboardingStep = { - __typename?: 'SkipSyncEmailOnboardingStep'; - /** Boolean that confirms query was dispatched */ - success: Scalars['Boolean']['output']; -}; - /** Sort Directions */ export enum SortDirection { Asc = 'ASC', @@ -1078,12 +1084,12 @@ export type User = { firstName: Scalars['String']['output']; id: Scalars['UUID']['output']; lastName: Scalars['String']['output']; + onboardingStep?: Maybe; passwordHash?: Maybe; /** @deprecated field migrated into the AppTokens Table ref: https://github.com/twentyhq/twenty/issues/5021 */ passwordResetToken?: Maybe; /** @deprecated field migrated into the AppTokens Table ref: https://github.com/twentyhq/twenty/issues/5021 */ passwordResetTokenExpiresAt?: Maybe; - state: UserState; supportUserHash?: Maybe; updatedAt: Scalars['DateTime']['output']; workspaceMember?: Maybe; @@ -1118,11 +1124,6 @@ export type UserMappingOptionsUser = { user?: Maybe; }; -export type UserState = { - __typename?: 'UserState'; - skipSyncEmailOnboardingStep?: Maybe; -}; - export type UserWorkspace = { __typename?: 'UserWorkspace'; createdAt: Scalars['DateTime']['output']; diff --git a/packages/twenty-front/src/generated/graphql.tsx b/packages/twenty-front/src/generated/graphql.tsx index 6cfd97314b..d153c73c0a 100644 --- a/packages/twenty-front/src/generated/graphql.tsx +++ b/packages/twenty-front/src/generated/graphql.tsx @@ -300,7 +300,7 @@ export type Mutation = { renewToken: AuthTokens; sendInviteLink: SendInviteLink; signUp: LoginToken; - skipSyncEmailOnboardingStep: SkipSyncEmailOnboardingStep; + skipSyncEmailOnboardingStep: OnboardingStepSuccess; track: Analytics; updateBillingSubscription: UpdateBillingEntity; updateOneObject: Object; @@ -459,6 +459,18 @@ export type ObjectFieldsConnection = { pageInfo: PageInfo; }; +/** Onboarding step */ +export enum OnboardingStep { + InviteTeam = 'INVITE_TEAM', + SyncEmail = 'SYNC_EMAIL' +} + +export type OnboardingStepSuccess = { + __typename?: 'OnboardingStepSuccess'; + /** Boolean that confirms query was dispatched */ + success: Scalars['Boolean']; +}; + export type PageInfo = { __typename?: 'PageInfo'; /** The cursor of the last returned record. */ @@ -643,12 +655,6 @@ export type SessionEntity = { url?: Maybe; }; -export type SkipSyncEmailOnboardingStep = { - __typename?: 'SkipSyncEmailOnboardingStep'; - /** Boolean that confirms query was dispatched */ - success: Scalars['Boolean']; -}; - /** Sort Directions */ export enum SortDirection { Asc = 'ASC', @@ -804,12 +810,12 @@ export type User = { firstName: Scalars['String']; id: Scalars['UUID']; lastName: Scalars['String']; + onboardingStep?: Maybe; passwordHash?: Maybe; /** @deprecated field migrated into the AppTokens Table ref: https://github.com/twentyhq/twenty/issues/5021 */ passwordResetToken?: Maybe; /** @deprecated field migrated into the AppTokens Table ref: https://github.com/twentyhq/twenty/issues/5021 */ passwordResetTokenExpiresAt?: Maybe; - state: UserState; supportUserHash?: Maybe; updatedAt: Scalars['DateTime']; workspaceMember?: Maybe; @@ -834,11 +840,6 @@ export type UserMappingOptionsUser = { user?: Maybe; }; -export type UserState = { - __typename?: 'UserState'; - skipSyncEmailOnboardingStep?: Maybe; -}; - export type UserWorkspace = { __typename?: 'UserWorkspace'; createdAt: Scalars['DateTime']; @@ -1140,7 +1141,7 @@ export type ImpersonateMutationVariables = Exact<{ }>; -export type ImpersonateMutation = { __typename?: 'Mutation', impersonate: { __typename?: 'Verify', user: { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canImpersonate: boolean, supportUserHash?: string | null, state: { __typename?: 'UserState', skipSyncEmailOnboardingStep?: boolean | null }, workspaceMember?: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale: string, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, defaultWorkspace: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, domainName?: string | null, inviteHash?: string | null, allowImpersonation: boolean, subscriptionStatus: string, activationStatus: string, currentCacheVersion?: string | null, featureFlags?: Array<{ __typename?: 'FeatureFlag', id: any, key: string, value: boolean, workspaceId: string }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: string, interval?: string | null } | null }, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: any, logo?: string | null, displayName?: string | null, domainName?: string | null } | null }> }, tokens: { __typename?: 'AuthTokenPair', accessToken: { __typename?: 'AuthToken', token: string, expiresAt: string }, refreshToken: { __typename?: 'AuthToken', token: string, expiresAt: string } } } }; +export type ImpersonateMutation = { __typename?: 'Mutation', impersonate: { __typename?: 'Verify', user: { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canImpersonate: boolean, supportUserHash?: string | null, onboardingStep?: OnboardingStep | null, workspaceMember?: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale: string, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, defaultWorkspace: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, domainName?: string | null, inviteHash?: string | null, allowImpersonation: boolean, subscriptionStatus: string, activationStatus: string, currentCacheVersion?: string | null, featureFlags?: Array<{ __typename?: 'FeatureFlag', id: any, key: string, value: boolean, workspaceId: string }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: string, interval?: string | null } | null }, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: any, logo?: string | null, displayName?: string | null, domainName?: string | null } | null }> }, tokens: { __typename?: 'AuthTokenPair', accessToken: { __typename?: 'AuthToken', token: string, expiresAt: string }, refreshToken: { __typename?: 'AuthToken', token: string, expiresAt: string } } } }; export type RenewTokenMutationVariables = Exact<{ appToken: Scalars['String']; @@ -1172,7 +1173,7 @@ export type VerifyMutationVariables = Exact<{ }>; -export type VerifyMutation = { __typename?: 'Mutation', verify: { __typename?: 'Verify', user: { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canImpersonate: boolean, supportUserHash?: string | null, state: { __typename?: 'UserState', skipSyncEmailOnboardingStep?: boolean | null }, workspaceMember?: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale: string, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, defaultWorkspace: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, domainName?: string | null, inviteHash?: string | null, allowImpersonation: boolean, subscriptionStatus: string, activationStatus: string, currentCacheVersion?: string | null, featureFlags?: Array<{ __typename?: 'FeatureFlag', id: any, key: string, value: boolean, workspaceId: string }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: string, interval?: string | null } | null }, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: any, logo?: string | null, displayName?: string | null, domainName?: string | null } | null }> }, tokens: { __typename?: 'AuthTokenPair', accessToken: { __typename?: 'AuthToken', token: string, expiresAt: string }, refreshToken: { __typename?: 'AuthToken', token: string, expiresAt: string } } } }; +export type VerifyMutation = { __typename?: 'Mutation', verify: { __typename?: 'Verify', user: { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canImpersonate: boolean, supportUserHash?: string | null, onboardingStep?: OnboardingStep | null, workspaceMember?: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale: string, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, defaultWorkspace: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, domainName?: string | null, inviteHash?: string | null, allowImpersonation: boolean, subscriptionStatus: string, activationStatus: string, currentCacheVersion?: string | null, featureFlags?: Array<{ __typename?: 'FeatureFlag', id: any, key: string, value: boolean, workspaceId: string }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: string, interval?: string | null } | null }, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: any, logo?: string | null, displayName?: string | null, domainName?: string | null } | null }> }, tokens: { __typename?: 'AuthTokenPair', accessToken: { __typename?: 'AuthToken', token: string, expiresAt: string }, refreshToken: { __typename?: 'AuthToken', token: string, expiresAt: string } } } }; export type CheckUserExistsQueryVariables = Exact<{ email: Scalars['String']; @@ -1224,9 +1225,9 @@ export type GetClientConfigQuery = { __typename?: 'Query', clientConfig: { __typ export type SkipSyncEmailOnboardingStepMutationVariables = Exact<{ [key: string]: never; }>; -export type SkipSyncEmailOnboardingStepMutation = { __typename?: 'Mutation', skipSyncEmailOnboardingStep: { __typename?: 'SkipSyncEmailOnboardingStep', success: boolean } }; +export type SkipSyncEmailOnboardingStepMutation = { __typename?: 'Mutation', skipSyncEmailOnboardingStep: { __typename?: 'OnboardingStepSuccess', success: boolean } }; -export type UserQueryFragmentFragment = { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canImpersonate: boolean, supportUserHash?: string | null, state: { __typename?: 'UserState', skipSyncEmailOnboardingStep?: boolean | null }, workspaceMember?: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale: string, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, defaultWorkspace: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, domainName?: string | null, inviteHash?: string | null, allowImpersonation: boolean, subscriptionStatus: string, activationStatus: string, currentCacheVersion?: string | null, featureFlags?: Array<{ __typename?: 'FeatureFlag', id: any, key: string, value: boolean, workspaceId: string }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: string, interval?: string | null } | null }, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: any, logo?: string | null, displayName?: string | null, domainName?: string | null } | null }> }; +export type UserQueryFragmentFragment = { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canImpersonate: boolean, supportUserHash?: string | null, onboardingStep?: OnboardingStep | null, workspaceMember?: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale: string, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, defaultWorkspace: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, domainName?: string | null, inviteHash?: string | null, allowImpersonation: boolean, subscriptionStatus: string, activationStatus: string, currentCacheVersion?: string | null, featureFlags?: Array<{ __typename?: 'FeatureFlag', id: any, key: string, value: boolean, workspaceId: string }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: string, interval?: string | null } | null }, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: any, logo?: string | null, displayName?: string | null, domainName?: string | null } | null }> }; export type DeleteUserAccountMutationVariables = Exact<{ [key: string]: never; }>; @@ -1243,7 +1244,7 @@ export type UploadProfilePictureMutation = { __typename?: 'Mutation', uploadProf export type GetCurrentUserQueryVariables = Exact<{ [key: string]: never; }>; -export type GetCurrentUserQuery = { __typename?: 'Query', currentUser: { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canImpersonate: boolean, supportUserHash?: string | null, state: { __typename?: 'UserState', skipSyncEmailOnboardingStep?: boolean | null }, workspaceMember?: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale: string, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, defaultWorkspace: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, domainName?: string | null, inviteHash?: string | null, allowImpersonation: boolean, subscriptionStatus: string, activationStatus: string, currentCacheVersion?: string | null, featureFlags?: Array<{ __typename?: 'FeatureFlag', id: any, key: string, value: boolean, workspaceId: string }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: string, interval?: string | null } | null }, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: any, logo?: string | null, displayName?: string | null, domainName?: string | null } | null }> } }; +export type GetCurrentUserQuery = { __typename?: 'Query', currentUser: { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canImpersonate: boolean, supportUserHash?: string | null, onboardingStep?: OnboardingStep | null, workspaceMember?: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale: string, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, defaultWorkspace: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, domainName?: string | null, inviteHash?: string | null, allowImpersonation: boolean, subscriptionStatus: string, activationStatus: string, currentCacheVersion?: string | null, featureFlags?: Array<{ __typename?: 'FeatureFlag', id: any, key: string, value: boolean, workspaceId: string }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: string, interval?: string | null } | null }, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: any, logo?: string | null, displayName?: string | null, domainName?: string | null } | null }> } }; export type AddUserToWorkspaceMutationVariables = Exact<{ inviteHash: Scalars['String']; @@ -1394,9 +1395,7 @@ export const UserQueryFragmentFragmentDoc = gql` email canImpersonate supportUserHash - state { - skipSyncEmailOnboardingStep - } + onboardingStep workspaceMember { id name { diff --git a/packages/twenty-front/src/hooks/__tests__/usePageChangeEffectNavigateLocation.test.ts b/packages/twenty-front/src/hooks/__tests__/usePageChangeEffectNavigateLocation.test.ts index 89a0aa4534..fff2fa65eb 100644 --- a/packages/twenty-front/src/hooks/__tests__/usePageChangeEffectNavigateLocation.test.ts +++ b/packages/twenty-front/src/hooks/__tests__/usePageChangeEffectNavigateLocation.test.ts @@ -36,6 +36,7 @@ const testCases = [ { loc: AppPath.Verify, status: OnboardingStatus.OngoingWorkspaceActivation, res: AppPath.CreateWorkspace }, { loc: AppPath.Verify, status: OnboardingStatus.OngoingProfileCreation, res: AppPath.CreateProfile }, { loc: AppPath.Verify, status: OnboardingStatus.OngoingSyncEmail, res: AppPath.SyncEmails }, + { loc: AppPath.Verify, status: OnboardingStatus.OngoingInviteTeam, res: AppPath.InviteTeam }, { loc: AppPath.Verify, status: OnboardingStatus.Completed, res: defaultHomePagePath }, { loc: AppPath.Verify, status: OnboardingStatus.CompletedWithoutSubscription, res: defaultHomePagePath }, @@ -47,6 +48,7 @@ const testCases = [ { loc: AppPath.SignInUp, status: OnboardingStatus.OngoingWorkspaceActivation, res: AppPath.CreateWorkspace }, { loc: AppPath.SignInUp, status: OnboardingStatus.OngoingProfileCreation, res: AppPath.CreateProfile }, { loc: AppPath.SignInUp, status: OnboardingStatus.OngoingSyncEmail, res: AppPath.SyncEmails }, + { loc: AppPath.SignInUp, status: OnboardingStatus.OngoingInviteTeam, res: AppPath.InviteTeam }, { loc: AppPath.SignInUp, status: OnboardingStatus.Completed, res: defaultHomePagePath }, { loc: AppPath.SignInUp, status: OnboardingStatus.CompletedWithoutSubscription, res: defaultHomePagePath }, @@ -58,6 +60,7 @@ const testCases = [ { loc: AppPath.Invite, status: OnboardingStatus.OngoingWorkspaceActivation, res: AppPath.CreateWorkspace }, { loc: AppPath.Invite, status: OnboardingStatus.OngoingProfileCreation, res: AppPath.CreateProfile }, { loc: AppPath.Invite, status: OnboardingStatus.OngoingSyncEmail, res: AppPath.SyncEmails }, + { loc: AppPath.Invite, status: OnboardingStatus.OngoingInviteTeam, res: AppPath.InviteTeam }, { loc: AppPath.Invite, status: OnboardingStatus.Completed, res: undefined }, { loc: AppPath.Invite, status: OnboardingStatus.CompletedWithoutSubscription, res: undefined }, @@ -69,6 +72,7 @@ const testCases = [ { loc: AppPath.ResetPassword, status: OnboardingStatus.OngoingWorkspaceActivation, res: AppPath.CreateWorkspace }, { loc: AppPath.ResetPassword, status: OnboardingStatus.OngoingProfileCreation, res: AppPath.CreateProfile }, { loc: AppPath.ResetPassword, status: OnboardingStatus.OngoingSyncEmail, res: AppPath.SyncEmails }, + { loc: AppPath.ResetPassword, status: OnboardingStatus.OngoingInviteTeam, res: AppPath.InviteTeam }, { loc: AppPath.ResetPassword, status: OnboardingStatus.Completed, res: undefined }, { loc: AppPath.ResetPassword, status: OnboardingStatus.CompletedWithoutSubscription, res: undefined }, @@ -80,6 +84,7 @@ const testCases = [ { loc: AppPath.CreateWorkspace, status: OnboardingStatus.OngoingWorkspaceActivation, res: undefined }, { loc: AppPath.CreateWorkspace, status: OnboardingStatus.OngoingProfileCreation, res: AppPath.CreateProfile }, { loc: AppPath.CreateWorkspace, status: OnboardingStatus.OngoingSyncEmail, res: AppPath.SyncEmails }, + { loc: AppPath.CreateWorkspace, status: OnboardingStatus.OngoingInviteTeam, res: AppPath.InviteTeam }, { loc: AppPath.CreateWorkspace, status: OnboardingStatus.Completed, res: defaultHomePagePath }, { loc: AppPath.CreateWorkspace, status: OnboardingStatus.CompletedWithoutSubscription, res: defaultHomePagePath }, @@ -91,6 +96,7 @@ const testCases = [ { loc: AppPath.CreateProfile, status: OnboardingStatus.OngoingWorkspaceActivation, res: AppPath.CreateWorkspace }, { loc: AppPath.CreateProfile, status: OnboardingStatus.OngoingProfileCreation, res: undefined }, { loc: AppPath.CreateProfile, status: OnboardingStatus.OngoingSyncEmail, res: AppPath.SyncEmails }, + { loc: AppPath.CreateProfile, status: OnboardingStatus.OngoingInviteTeam, res: AppPath.InviteTeam }, { loc: AppPath.CreateProfile, status: OnboardingStatus.Completed, res: defaultHomePagePath }, { loc: AppPath.CreateProfile, status: OnboardingStatus.CompletedWithoutSubscription, res: defaultHomePagePath }, @@ -102,9 +108,22 @@ const testCases = [ { loc: AppPath.SyncEmails, status: OnboardingStatus.OngoingWorkspaceActivation, res: AppPath.CreateWorkspace }, { loc: AppPath.SyncEmails, status: OnboardingStatus.OngoingProfileCreation, res: AppPath.CreateProfile }, { loc: AppPath.SyncEmails, status: OnboardingStatus.OngoingSyncEmail, res: undefined }, + { loc: AppPath.SyncEmails, status: OnboardingStatus.OngoingInviteTeam, res: AppPath.InviteTeam }, { loc: AppPath.SyncEmails, status: OnboardingStatus.Completed, res: defaultHomePagePath }, { loc: AppPath.SyncEmails, status: OnboardingStatus.CompletedWithoutSubscription, res: defaultHomePagePath }, + { loc: AppPath.InviteTeam, status: OnboardingStatus.Incomplete, res: AppPath.PlanRequired }, + { loc: AppPath.InviteTeam, status: OnboardingStatus.Canceled, res: '/settings/billing' }, + { loc: AppPath.InviteTeam, status: OnboardingStatus.Unpaid, res: '/settings/billing' }, + { loc: AppPath.InviteTeam, status: OnboardingStatus.PastDue, res: undefined }, + { loc: AppPath.InviteTeam, status: OnboardingStatus.OngoingUserCreation, res: AppPath.SignInUp }, + { loc: AppPath.InviteTeam, status: OnboardingStatus.OngoingWorkspaceActivation, res: AppPath.CreateWorkspace }, + { loc: AppPath.InviteTeam, status: OnboardingStatus.OngoingProfileCreation, res: AppPath.CreateProfile }, + { loc: AppPath.InviteTeam, status: OnboardingStatus.OngoingSyncEmail, res: AppPath.SyncEmails }, + { loc: AppPath.InviteTeam, status: OnboardingStatus.OngoingInviteTeam, res: undefined }, + { loc: AppPath.InviteTeam, status: OnboardingStatus.Completed, res: defaultHomePagePath }, + { loc: AppPath.InviteTeam, status: OnboardingStatus.CompletedWithoutSubscription, res: defaultHomePagePath }, + { loc: AppPath.PlanRequired, status: OnboardingStatus.Incomplete, res: undefined }, { loc: AppPath.PlanRequired, status: OnboardingStatus.Canceled, res: undefined }, { loc: AppPath.PlanRequired, status: OnboardingStatus.Unpaid, res: undefined }, @@ -113,6 +132,7 @@ const testCases = [ { loc: AppPath.PlanRequired, status: OnboardingStatus.OngoingWorkspaceActivation, res: AppPath.CreateWorkspace }, { loc: AppPath.PlanRequired, status: OnboardingStatus.OngoingProfileCreation, res: AppPath.CreateProfile }, { loc: AppPath.PlanRequired, status: OnboardingStatus.OngoingSyncEmail, res: AppPath.SyncEmails }, + { loc: AppPath.PlanRequired, status: OnboardingStatus.OngoingInviteTeam, res: AppPath.InviteTeam }, { loc: AppPath.PlanRequired, status: OnboardingStatus.Completed, res: defaultHomePagePath }, { loc: AppPath.PlanRequired, status: OnboardingStatus.CompletedWithoutSubscription, res: undefined }, @@ -124,6 +144,7 @@ const testCases = [ { loc: AppPath.PlanRequiredSuccess, status: OnboardingStatus.OngoingWorkspaceActivation, res: undefined }, { loc: AppPath.PlanRequiredSuccess, status: OnboardingStatus.OngoingProfileCreation, res: AppPath.CreateProfile }, { loc: AppPath.PlanRequiredSuccess, status: OnboardingStatus.OngoingSyncEmail, res: AppPath.SyncEmails }, + { loc: AppPath.PlanRequiredSuccess, status: OnboardingStatus.OngoingInviteTeam, res: AppPath.InviteTeam }, { loc: AppPath.PlanRequiredSuccess, status: OnboardingStatus.Completed, res: defaultHomePagePath }, { loc: AppPath.PlanRequiredSuccess, status: OnboardingStatus.CompletedWithoutSubscription, res: defaultHomePagePath }, @@ -135,6 +156,7 @@ const testCases = [ { loc: AppPath.Index, status: OnboardingStatus.OngoingWorkspaceActivation, res: AppPath.CreateWorkspace }, { loc: AppPath.Index, status: OnboardingStatus.OngoingProfileCreation, res: AppPath.CreateProfile }, { loc: AppPath.Index, status: OnboardingStatus.OngoingSyncEmail, res: AppPath.SyncEmails }, + { loc: AppPath.Index, status: OnboardingStatus.OngoingInviteTeam, res: AppPath.InviteTeam }, { loc: AppPath.Index, status: OnboardingStatus.Completed, res: defaultHomePagePath }, { loc: AppPath.Index, status: OnboardingStatus.CompletedWithoutSubscription, res: defaultHomePagePath }, @@ -146,6 +168,7 @@ const testCases = [ { loc: AppPath.TasksPage, status: OnboardingStatus.OngoingWorkspaceActivation, res: AppPath.CreateWorkspace }, { loc: AppPath.TasksPage, status: OnboardingStatus.OngoingProfileCreation, res: AppPath.CreateProfile }, { loc: AppPath.TasksPage, status: OnboardingStatus.OngoingSyncEmail, res: AppPath.SyncEmails }, + { loc: AppPath.TasksPage, status: OnboardingStatus.OngoingInviteTeam, res: AppPath.InviteTeam }, { loc: AppPath.TasksPage, status: OnboardingStatus.Completed, res: undefined }, { loc: AppPath.TasksPage, status: OnboardingStatus.CompletedWithoutSubscription, res: undefined }, @@ -157,6 +180,7 @@ const testCases = [ { loc: AppPath.OpportunitiesPage, status: OnboardingStatus.OngoingWorkspaceActivation, res: AppPath.CreateWorkspace }, { loc: AppPath.OpportunitiesPage, status: OnboardingStatus.OngoingProfileCreation, res: AppPath.CreateProfile }, { loc: AppPath.OpportunitiesPage, status: OnboardingStatus.OngoingSyncEmail, res: AppPath.SyncEmails }, + { loc: AppPath.OpportunitiesPage, status: OnboardingStatus.OngoingInviteTeam, res: AppPath.InviteTeam }, { loc: AppPath.OpportunitiesPage, status: OnboardingStatus.Completed, res: undefined }, { loc: AppPath.OpportunitiesPage, status: OnboardingStatus.CompletedWithoutSubscription, res: undefined }, @@ -168,6 +192,7 @@ const testCases = [ { loc: AppPath.RecordIndexPage, status: OnboardingStatus.OngoingWorkspaceActivation, res: AppPath.CreateWorkspace }, { loc: AppPath.RecordIndexPage, status: OnboardingStatus.OngoingProfileCreation, res: AppPath.CreateProfile }, { loc: AppPath.RecordIndexPage, status: OnboardingStatus.OngoingSyncEmail, res: AppPath.SyncEmails }, + { loc: AppPath.RecordIndexPage, status: OnboardingStatus.OngoingInviteTeam, res: AppPath.InviteTeam }, { loc: AppPath.RecordIndexPage, status: OnboardingStatus.Completed, res: undefined }, { loc: AppPath.RecordIndexPage, status: OnboardingStatus.CompletedWithoutSubscription, res: undefined }, @@ -179,6 +204,7 @@ const testCases = [ { loc: AppPath.RecordShowPage, status: OnboardingStatus.OngoingWorkspaceActivation, res: AppPath.CreateWorkspace }, { loc: AppPath.RecordShowPage, status: OnboardingStatus.OngoingProfileCreation, res: AppPath.CreateProfile }, { loc: AppPath.RecordShowPage, status: OnboardingStatus.OngoingSyncEmail, res: AppPath.SyncEmails }, + { loc: AppPath.RecordShowPage, status: OnboardingStatus.OngoingInviteTeam, res: AppPath.InviteTeam }, { loc: AppPath.RecordShowPage, status: OnboardingStatus.Completed, res: undefined }, { loc: AppPath.RecordShowPage, status: OnboardingStatus.CompletedWithoutSubscription, res: undefined }, @@ -190,6 +216,7 @@ const testCases = [ { loc: AppPath.SettingsCatchAll, status: OnboardingStatus.OngoingWorkspaceActivation, res: AppPath.CreateWorkspace }, { loc: AppPath.SettingsCatchAll, status: OnboardingStatus.OngoingProfileCreation, res: AppPath.CreateProfile }, { loc: AppPath.SettingsCatchAll, status: OnboardingStatus.OngoingSyncEmail, res: AppPath.SyncEmails }, + { loc: AppPath.SettingsCatchAll, status: OnboardingStatus.OngoingInviteTeam, res: AppPath.InviteTeam }, { loc: AppPath.SettingsCatchAll, status: OnboardingStatus.Completed, res: undefined }, { loc: AppPath.SettingsCatchAll, status: OnboardingStatus.CompletedWithoutSubscription, res: undefined }, @@ -201,6 +228,7 @@ const testCases = [ { loc: AppPath.DevelopersCatchAll, status: OnboardingStatus.OngoingWorkspaceActivation, res: AppPath.CreateWorkspace }, { loc: AppPath.DevelopersCatchAll, status: OnboardingStatus.OngoingProfileCreation, res: AppPath.CreateProfile }, { loc: AppPath.DevelopersCatchAll, status: OnboardingStatus.OngoingSyncEmail, res: AppPath.SyncEmails }, + { loc: AppPath.DevelopersCatchAll, status: OnboardingStatus.OngoingInviteTeam, res: AppPath.InviteTeam }, { loc: AppPath.DevelopersCatchAll, status: OnboardingStatus.Completed, res: undefined }, { loc: AppPath.DevelopersCatchAll, status: OnboardingStatus.CompletedWithoutSubscription, res: undefined }, @@ -212,6 +240,7 @@ const testCases = [ { loc: AppPath.Impersonate, status: OnboardingStatus.OngoingWorkspaceActivation, res: AppPath.CreateWorkspace }, { loc: AppPath.Impersonate, status: OnboardingStatus.OngoingProfileCreation, res: AppPath.CreateProfile }, { loc: AppPath.Impersonate, status: OnboardingStatus.OngoingSyncEmail, res: AppPath.SyncEmails }, + { loc: AppPath.Impersonate, status: OnboardingStatus.OngoingInviteTeam, res: AppPath.InviteTeam }, { loc: AppPath.Impersonate, status: OnboardingStatus.Completed, res: undefined }, { loc: AppPath.Impersonate, status: OnboardingStatus.CompletedWithoutSubscription, res: undefined }, @@ -223,6 +252,7 @@ const testCases = [ { loc: AppPath.Authorize, status: OnboardingStatus.OngoingWorkspaceActivation, res: AppPath.CreateWorkspace }, { loc: AppPath.Authorize, status: OnboardingStatus.OngoingProfileCreation, res: AppPath.CreateProfile }, { loc: AppPath.Authorize, status: OnboardingStatus.OngoingSyncEmail, res: AppPath.SyncEmails }, + { loc: AppPath.Authorize, status: OnboardingStatus.OngoingInviteTeam, res: AppPath.InviteTeam }, { loc: AppPath.Authorize, status: OnboardingStatus.Completed, res: undefined }, { loc: AppPath.Authorize, status: OnboardingStatus.CompletedWithoutSubscription, res: undefined }, @@ -234,6 +264,7 @@ const testCases = [ { loc: AppPath.NotFoundWildcard, status: OnboardingStatus.OngoingWorkspaceActivation, res: AppPath.CreateWorkspace }, { loc: AppPath.NotFoundWildcard, status: OnboardingStatus.OngoingProfileCreation, res: AppPath.CreateProfile }, { loc: AppPath.NotFoundWildcard, status: OnboardingStatus.OngoingSyncEmail, res: AppPath.SyncEmails }, + { loc: AppPath.NotFoundWildcard, status: OnboardingStatus.OngoingInviteTeam, res: AppPath.InviteTeam }, { loc: AppPath.NotFoundWildcard, status: OnboardingStatus.Completed, res: undefined }, { loc: AppPath.NotFoundWildcard, status: OnboardingStatus.CompletedWithoutSubscription, res: undefined }, @@ -245,6 +276,7 @@ const testCases = [ { loc: AppPath.NotFound, status: OnboardingStatus.OngoingWorkspaceActivation, res: AppPath.CreateWorkspace }, { loc: AppPath.NotFound, status: OnboardingStatus.OngoingProfileCreation, res: AppPath.CreateProfile }, { loc: AppPath.NotFound, status: OnboardingStatus.OngoingSyncEmail, res: AppPath.SyncEmails }, + { loc: AppPath.NotFound, status: OnboardingStatus.OngoingInviteTeam, res: AppPath.InviteTeam }, { loc: AppPath.NotFound, status: OnboardingStatus.Completed, res: undefined }, { loc: AppPath.NotFound, status: OnboardingStatus.CompletedWithoutSubscription, res: undefined }, ]; diff --git a/packages/twenty-front/src/hooks/usePageChangeEffectNavigateLocation.ts b/packages/twenty-front/src/hooks/usePageChangeEffectNavigateLocation.ts index 01aa101d71..6ca594173b 100644 --- a/packages/twenty-front/src/hooks/usePageChangeEffectNavigateLocation.ts +++ b/packages/twenty-front/src/hooks/usePageChangeEffectNavigateLocation.ts @@ -25,6 +25,7 @@ export const usePageChangeEffectNavigateLocation = () => { isMatchingLocation(AppPath.CreateWorkspace) || isMatchingLocation(AppPath.CreateProfile) || isMatchingLocation(AppPath.SyncEmails) || + isMatchingLocation(AppPath.InviteTeam) || isMatchingLocation(AppPath.PlanRequired) || isMatchingLocation(AppPath.PlanRequiredSuccess); @@ -79,6 +80,13 @@ export const usePageChangeEffectNavigateLocation = () => { return AppPath.SyncEmails; } + if ( + onboardingStatus === OnboardingStatus.OngoingInviteTeam && + !isMatchingLocation(AppPath.InviteTeam) + ) { + return AppPath.InviteTeam; + } + if ( onboardingStatus === OnboardingStatus.Completed && isMatchingOnboardingRoute && diff --git a/packages/twenty-front/src/modules/auth/components/Title.tsx b/packages/twenty-front/src/modules/auth/components/Title.tsx index 4fe541db96..d0a496b252 100644 --- a/packages/twenty-front/src/modules/auth/components/Title.tsx +++ b/packages/twenty-front/src/modules/auth/components/Title.tsx @@ -5,30 +5,30 @@ import { AnimatedEaseIn } from '@/ui/utilities/animation/components/AnimatedEase type TitleProps = React.PropsWithChildren & { animate?: boolean; - withMarginTop?: boolean; + noMarginTop?: boolean; }; -const StyledTitle = styled.div>` +const StyledTitle = styled.div>` color: ${({ theme }) => theme.font.color.primary}; font-size: ${({ theme }) => theme.font.size.xl}; font-weight: ${({ theme }) => theme.font.weight.semiBold}; margin-bottom: ${({ theme }) => theme.spacing(4)}; - margin-top: ${({ theme, withMarginTop }) => - withMarginTop ? theme.spacing(4) : 0}; + margin-top: ${({ theme, noMarginTop }) => + !noMarginTop ? theme.spacing(4) : 0}; `; export const Title = ({ children, animate = false, - withMarginTop = true, + noMarginTop = false, }: TitleProps) => { if (animate) { return ( - + {children} ); } - return {children}; + return {children}; }; diff --git a/packages/twenty-front/src/modules/auth/hooks/__test__/useOnboardingStatus.test.ts b/packages/twenty-front/src/modules/auth/hooks/__test__/useOnboardingStatus.test.ts index bdeaa00b65..13d8e23fda 100644 --- a/packages/twenty-front/src/modules/auth/hooks/__test__/useOnboardingStatus.test.ts +++ b/packages/twenty-front/src/modules/auth/hooks/__test__/useOnboardingStatus.test.ts @@ -12,6 +12,7 @@ import { import { isVerifyPendingState } from '@/auth/states/isVerifyPendingState'; import { tokenPairState } from '@/auth/states/tokenPairState'; import { billingState } from '@/client-config/states/billingState'; +import { OnboardingStep } from '~/generated/graphql'; const tokenPair = { accessToken: { token: 'accessToken', expiresAt: 'expiresAt' }, @@ -26,7 +27,7 @@ const currentUser = { email: 'test@test', supportUserHash: '1', canImpersonate: false, - state: { skipSyncEmailOnboardingStep: true }, + onboardingStep: null, } as CurrentUser; const currentWorkspace = { activationStatus: 'active', @@ -196,7 +197,7 @@ describe('useOnboardingStatus', () => { setBilling(billing); setCurrentUser({ ...currentUser, - state: { skipSyncEmailOnboardingStep: false }, + onboardingStep: OnboardingStep.SyncEmail, }); setCurrentWorkspace({ ...currentWorkspace, @@ -214,6 +215,39 @@ describe('useOnboardingStatus', () => { expect(result.current.onboardingStatus).toBe('ongoing_sync_email'); }); + it('should return "ongoing_invite_team"', async () => { + const { result } = renderHooks(); + const { + setTokenPair, + setBilling, + setCurrentUser, + setCurrentWorkspace, + setCurrentWorkspaceMember, + } = result.current; + + act(() => { + setTokenPair(tokenPair); + setBilling(billing); + setCurrentUser({ + ...currentUser, + onboardingStep: OnboardingStep.InviteTeam, + }); + setCurrentWorkspace({ + ...currentWorkspace, + subscriptionStatus: 'active', + }); + setCurrentWorkspaceMember({ + ...currentWorkspaceMember, + name: { + firstName: 'John', + lastName: 'Doe', + }, + }); + }); + + expect(result.current.onboardingStatus).toBe('ongoing_invite_team'); + }); + it('should return "completed"', async () => { const { result } = renderHooks(); const { diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpForm.tsx b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpForm.tsx index 06a38993ba..2c498037ed 100644 --- a/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpForm.tsx +++ b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpForm.tsx @@ -4,6 +4,7 @@ import { useTheme } from '@emotion/react'; import styled from '@emotion/styled'; import { motion } from 'framer-motion'; import { useRecoilState, useRecoilValue } from 'recoil'; +import { Key } from 'ts-key-enum'; import { IconGoogle, IconMicrosoft } from 'twenty-ui'; import { FooterNote } from '@/auth/sign-in-up/components/FooterNote'; @@ -69,7 +70,7 @@ export const SignInUpForm = () => { const handleKeyDown = async ( event: React.KeyboardEvent, ) => { - if (event.key === 'Enter') { + if (event.key === Key.Enter) { event.preventDefault(); if (signInUpStep === SignInUpStep.Init) { diff --git a/packages/twenty-front/src/modules/auth/states/currentUserState.ts b/packages/twenty-front/src/modules/auth/states/currentUserState.ts index f83c6243f6..07efc7dfb1 100644 --- a/packages/twenty-front/src/modules/auth/states/currentUserState.ts +++ b/packages/twenty-front/src/modules/auth/states/currentUserState.ts @@ -4,7 +4,7 @@ import { User } from '~/generated/graphql'; export type CurrentUser = Pick< User, - 'id' | 'email' | 'supportUserHash' | 'canImpersonate' | 'state' + 'id' | 'email' | 'supportUserHash' | 'canImpersonate' | 'onboardingStep' >; export const currentUserState = createState({ diff --git a/packages/twenty-front/src/modules/auth/utils/__test__/getOnboardingStatus.test.ts b/packages/twenty-front/src/modules/auth/utils/__test__/getOnboardingStatus.test.ts index 9087b8d1f2..813e9b3829 100644 --- a/packages/twenty-front/src/modules/auth/utils/__test__/getOnboardingStatus.test.ts +++ b/packages/twenty-front/src/modules/auth/utils/__test__/getOnboardingStatus.test.ts @@ -1,6 +1,7 @@ import { CurrentUser } from '@/auth/states/currentUserState'; import { CurrentWorkspace } from '@/auth/states/currentWorkspaceState'; import { WorkspaceMember } from '@/workspace-member/types/WorkspaceMember'; +import { OnboardingStep } from '~/generated/graphql'; import { getOnboardingStatus } from '../getOnboardingStatus'; @@ -22,7 +23,7 @@ describe('getOnboardingStatus', () => { activationStatus: 'inactive', } as CurrentWorkspace, currentUser: { - state: { skipSyncEmailOnboardingStep: true }, + onboardingStep: null, } as CurrentUser, isBillingEnabled: false, }); @@ -38,7 +39,7 @@ describe('getOnboardingStatus', () => { activationStatus: 'active', } as CurrentWorkspace, currentUser: { - state: { skipSyncEmailOnboardingStep: true }, + onboardingStep: null, } as CurrentUser, isBillingEnabled: false, }); @@ -57,7 +58,26 @@ describe('getOnboardingStatus', () => { activationStatus: 'active', } as CurrentWorkspace, currentUser: { - state: { skipSyncEmailOnboardingStep: false }, + onboardingStep: OnboardingStep.SyncEmail, + } as CurrentUser, + isBillingEnabled: false, + }); + + const ongoingInviteTeam = getOnboardingStatus({ + isLoggedIn: true, + currentWorkspaceMember: { + id: '1', + name: { + firstName: 'John', + lastName: 'Doe', + }, + } as WorkspaceMember, + currentWorkspace: { + id: '1', + activationStatus: 'active', + } as CurrentWorkspace, + currentUser: { + onboardingStep: OnboardingStep.InviteTeam, } as CurrentUser, isBillingEnabled: false, }); @@ -76,7 +96,7 @@ describe('getOnboardingStatus', () => { activationStatus: 'active', } as CurrentWorkspace, currentUser: { - state: { skipSyncEmailOnboardingStep: true }, + onboardingStep: null, } as CurrentUser, isBillingEnabled: false, }); @@ -96,7 +116,7 @@ describe('getOnboardingStatus', () => { subscriptionStatus: 'incomplete', } as CurrentWorkspace, currentUser: { - state: { skipSyncEmailOnboardingStep: true }, + onboardingStep: null, } as CurrentUser, isBillingEnabled: true, }); @@ -116,7 +136,7 @@ describe('getOnboardingStatus', () => { subscriptionStatus: 'incomplete', } as CurrentWorkspace, currentUser: { - state: { skipSyncEmailOnboardingStep: true }, + onboardingStep: null, } as CurrentUser, isBillingEnabled: false, }); @@ -136,7 +156,7 @@ describe('getOnboardingStatus', () => { subscriptionStatus: 'canceled', } as CurrentWorkspace, currentUser: { - state: { skipSyncEmailOnboardingStep: true }, + onboardingStep: null, } as CurrentUser, isBillingEnabled: true, }); @@ -145,6 +165,7 @@ describe('getOnboardingStatus', () => { expect(ongoingWorkspaceActivation).toBe('ongoing_workspace_activation'); expect(ongoingProfileCreation).toBe('ongoing_profile_creation'); expect(ongoingSyncEmail).toBe('ongoing_sync_email'); + expect(ongoingInviteTeam).toBe('ongoing_invite_team'); expect(completed).toBe('completed'); expect(incomplete).toBe('incomplete'); expect(canceled).toBe('canceled'); diff --git a/packages/twenty-front/src/modules/auth/utils/getOnboardingStatus.ts b/packages/twenty-front/src/modules/auth/utils/getOnboardingStatus.ts index 0577943fd3..0dc2547052 100644 --- a/packages/twenty-front/src/modules/auth/utils/getOnboardingStatus.ts +++ b/packages/twenty-front/src/modules/auth/utils/getOnboardingStatus.ts @@ -1,6 +1,7 @@ import { CurrentUser } from '@/auth/states/currentUserState'; import { CurrentWorkspace } from '@/auth/states/currentWorkspaceState'; import { WorkspaceMember } from '@/workspace-member/types/WorkspaceMember'; +import { OnboardingStep } from '~/generated/graphql'; export enum OnboardingStatus { Incomplete = 'incomplete', @@ -11,6 +12,7 @@ export enum OnboardingStatus { OngoingWorkspaceActivation = 'ongoing_workspace_activation', OngoingProfileCreation = 'ongoing_profile_creation', OngoingSyncEmail = 'ongoing_sync_email', + OngoingInviteTeam = 'ongoing_invite_team', Completed = 'completed', CompletedWithoutSubscription = 'completed_without_subscription', } @@ -59,10 +61,14 @@ export const getOnboardingStatus = ({ return OnboardingStatus.OngoingProfileCreation; } - if (!currentUser.state.skipSyncEmailOnboardingStep) { + if (currentUser.onboardingStep === OnboardingStep.SyncEmail) { return OnboardingStatus.OngoingSyncEmail; } + if (currentUser.onboardingStep === OnboardingStep.InviteTeam) { + return OnboardingStatus.OngoingInviteTeam; + } + if (isBillingEnabled && currentWorkspace.subscriptionStatus === 'canceled') { return OnboardingStatus.Canceled; } diff --git a/packages/twenty-front/src/modules/onboarding/hooks/useSetNextOnboardingStep.ts b/packages/twenty-front/src/modules/onboarding/hooks/useSetNextOnboardingStep.ts new file mode 100644 index 0000000000..42acf8feb0 --- /dev/null +++ b/packages/twenty-front/src/modules/onboarding/hooks/useSetNextOnboardingStep.ts @@ -0,0 +1,41 @@ +import { useRecoilCallback, useSetRecoilState } from 'recoil'; + +import { currentUserState } from '@/auth/states/currentUserState'; +import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; +import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords'; +import { WorkspaceMember } from '@/workspace-member/types/WorkspaceMember'; +import { OnboardingStep } from '~/generated/graphql'; + +const getNextOnboardingStep = ( + currentOnboardingStep: OnboardingStep, + workspaceMembers: WorkspaceMember[], +) => { + if (currentOnboardingStep === OnboardingStep.SyncEmail) { + return workspaceMembers && workspaceMembers.length > 1 + ? null + : OnboardingStep.InviteTeam; + } + return null; +}; + +export const useSetNextOnboardingStep = () => { + const setCurrentUser = useSetRecoilState(currentUserState); + const { records: workspaceMembers } = useFindManyRecords({ + objectNameSingular: CoreObjectNameSingular.WorkspaceMember, + }); + return useRecoilCallback( + () => (currentOnboardingStep: OnboardingStep) => { + setCurrentUser( + (current) => + ({ + ...current, + onboardingStep: getNextOnboardingStep( + currentOnboardingStep, + workspaceMembers, + ), + }) as any, + ); + }, + [setCurrentUser, workspaceMembers], + ); +}; diff --git a/packages/twenty-front/src/modules/types/AppPath.ts b/packages/twenty-front/src/modules/types/AppPath.ts index 39396c0662..ca6f77009d 100644 --- a/packages/twenty-front/src/modules/types/AppPath.ts +++ b/packages/twenty-front/src/modules/types/AppPath.ts @@ -9,6 +9,7 @@ export enum AppPath { CreateWorkspace = '/create/workspace', CreateProfile = '/create/profile', SyncEmails = '/sync/emails', + InviteTeam = '/invite-team', PlanRequired = '/plan-required', PlanRequiredSuccess = '/plan-required/payment-success', diff --git a/packages/twenty-front/src/modules/types/PageHotkeyScope.ts b/packages/twenty-front/src/modules/types/PageHotkeyScope.ts index db175a7925..c4f25dafc0 100644 --- a/packages/twenty-front/src/modules/types/PageHotkeyScope.ts +++ b/packages/twenty-front/src/modules/types/PageHotkeyScope.ts @@ -3,6 +3,8 @@ export enum PageHotkeyScope { CreateWokspace = 'create-workspace', SignInUp = 'sign-in-up', CreateProfile = 'create-profile', + InviteTeam = 'invite-team', + SyncEmail = 'sync-email', PlanRequired = 'plan-required', ShowPage = 'show-page', PersonShowPage = 'person-show-page', diff --git a/packages/twenty-front/src/modules/ui/display/text/components/SeparatorLineText.tsx b/packages/twenty-front/src/modules/ui/display/text/components/SeparatorLineText.tsx new file mode 100644 index 0000000000..e5b2c35a34 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/display/text/components/SeparatorLineText.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import styled from '@emotion/styled'; + +const StyledContainer = styled.div` + display: flex; + align-items: center; + width: 100%; + font-size: ${({ theme }) => theme.font.size.md}; + font-weight: ${({ theme }) => theme.font.weight.semiBold}; + color: ${({ theme }) => theme.font.color.extraLight}; + + &:before, + &:after { + content: ''; + height: 1px; + flex-grow: 1; + background: ${({ theme }) => theme.background.transparent.light}; + } + + &:before { + margin: 0 ${({ theme }) => theme.spacing(4)} 0 0; + } + &:after { + margin: 0 0 0 ${({ theme }) => theme.spacing(4)}; + } +`; + +export const SeparatorLineText = ({ + children, +}: { + children: React.ReactNode; +}) => { + return {children}; +}; diff --git a/packages/twenty-front/src/modules/ui/display/text/components/__stories__/SeparatorLineText.stories.tsx b/packages/twenty-front/src/modules/ui/display/text/components/__stories__/SeparatorLineText.stories.tsx new file mode 100644 index 0000000000..66e0cad72d --- /dev/null +++ b/packages/twenty-front/src/modules/ui/display/text/components/__stories__/SeparatorLineText.stories.tsx @@ -0,0 +1,16 @@ +import { Meta, StoryObj } from '@storybook/react'; +import { ComponentDecorator } from 'twenty-ui'; + +import { SeparatorLineText } from '../SeparatorLineText'; + +const meta: Meta = { + title: 'UI/Display/Text/SeparatorLineText', + component: SeparatorLineText, + args: { children: 'Or' }, + decorators: [ComponentDecorator], +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/packages/twenty-front/src/modules/ui/input/components/TextInputV2.tsx b/packages/twenty-front/src/modules/ui/input/components/TextInputV2.tsx index beb8586a48..19a0b92ada 100644 --- a/packages/twenty-front/src/modules/ui/input/components/TextInputV2.tsx +++ b/packages/twenty-front/src/modules/ui/input/components/TextInputV2.tsx @@ -117,6 +117,7 @@ export type TextInputV2ComponentProps = Omit< onChange?: (text: string) => void; fullWidth?: boolean; error?: string; + noErrorHelper?: boolean; RightIcon?: IconComponent; LeftIcon?: IconComponent; onKeyDown?: (event: React.KeyboardEvent) => void; @@ -134,6 +135,7 @@ const TextInputV2Component = ( onKeyDown, fullWidth, error, + noErrorHelper = false, required, type, autoFocus, @@ -207,7 +209,9 @@ const TextInputV2Component = ( )} - {error && {error}} + {error && !noErrorHelper && ( + {error} + )} ); }; diff --git a/packages/twenty-front/src/modules/ui/layout/hooks/__tests__/useShowAuthModal.test.tsx b/packages/twenty-front/src/modules/ui/layout/hooks/__tests__/useShowAuthModal.test.tsx index 906f99e11b..375a5b7ff4 100644 --- a/packages/twenty-front/src/modules/ui/layout/hooks/__tests__/useShowAuthModal.test.tsx +++ b/packages/twenty-front/src/modules/ui/layout/hooks/__tests__/useShowAuthModal.test.tsx @@ -47,6 +47,7 @@ const testCases = [ { loc: AppPath.Verify, status: OnboardingStatus.OngoingWorkspaceActivation, res: false }, { loc: AppPath.Verify, status: OnboardingStatus.OngoingProfileCreation, res: false }, { loc: AppPath.Verify, status: OnboardingStatus.OngoingSyncEmail, res: false }, + { loc: AppPath.Verify, status: OnboardingStatus.OngoingInviteTeam, res: false }, { loc: AppPath.Verify, status: OnboardingStatus.Completed, res: false }, { loc: AppPath.Verify, status: OnboardingStatus.CompletedWithoutSubscription, res: false }, @@ -58,6 +59,7 @@ const testCases = [ { loc: AppPath.SignInUp, status: OnboardingStatus.OngoingWorkspaceActivation, res: true }, { loc: AppPath.SignInUp, status: OnboardingStatus.OngoingProfileCreation, res: true }, { loc: AppPath.SignInUp, status: OnboardingStatus.OngoingSyncEmail, res: true }, + { loc: AppPath.SignInUp, status: OnboardingStatus.OngoingInviteTeam, res: true }, { loc: AppPath.SignInUp, status: OnboardingStatus.Completed, res: false }, { loc: AppPath.SignInUp, status: OnboardingStatus.CompletedWithoutSubscription, res: false }, @@ -69,6 +71,7 @@ const testCases = [ { loc: AppPath.Invite, status: OnboardingStatus.OngoingWorkspaceActivation, res: true }, { loc: AppPath.Invite, status: OnboardingStatus.OngoingProfileCreation, res: true }, { loc: AppPath.Invite, status: OnboardingStatus.OngoingSyncEmail, res: true }, + { loc: AppPath.Invite, status: OnboardingStatus.OngoingInviteTeam, res: true }, { loc: AppPath.Invite, status: OnboardingStatus.Completed, res: true }, { loc: AppPath.Invite, status: OnboardingStatus.CompletedWithoutSubscription, res: true }, @@ -80,6 +83,7 @@ const testCases = [ { loc: AppPath.ResetPassword, status: OnboardingStatus.OngoingWorkspaceActivation, res: true }, { loc: AppPath.ResetPassword, status: OnboardingStatus.OngoingProfileCreation, res: true }, { loc: AppPath.ResetPassword, status: OnboardingStatus.OngoingSyncEmail, res: true }, + { loc: AppPath.ResetPassword, status: OnboardingStatus.OngoingInviteTeam, res: true }, { loc: AppPath.ResetPassword, status: OnboardingStatus.Completed, res: true }, { loc: AppPath.ResetPassword, status: OnboardingStatus.CompletedWithoutSubscription, res: true }, @@ -91,6 +95,7 @@ const testCases = [ { loc: AppPath.CreateWorkspace, status: OnboardingStatus.OngoingWorkspaceActivation, res: true }, { loc: AppPath.CreateWorkspace, status: OnboardingStatus.OngoingProfileCreation, res: true }, { loc: AppPath.CreateWorkspace, status: OnboardingStatus.OngoingSyncEmail, res: true }, + { loc: AppPath.CreateWorkspace, status: OnboardingStatus.OngoingInviteTeam, res: true }, { loc: AppPath.CreateWorkspace, status: OnboardingStatus.Completed, res: false }, { loc: AppPath.CreateWorkspace, status: OnboardingStatus.CompletedWithoutSubscription, res: false }, @@ -102,6 +107,7 @@ const testCases = [ { loc: AppPath.CreateProfile, status: OnboardingStatus.OngoingWorkspaceActivation, res: true }, { loc: AppPath.CreateProfile, status: OnboardingStatus.OngoingProfileCreation, res: true }, { loc: AppPath.CreateProfile, status: OnboardingStatus.OngoingSyncEmail, res: true }, + { loc: AppPath.CreateProfile, status: OnboardingStatus.OngoingInviteTeam, res: true }, { loc: AppPath.CreateProfile, status: OnboardingStatus.Completed, res: false }, { loc: AppPath.CreateProfile, status: OnboardingStatus.CompletedWithoutSubscription, res: false }, @@ -113,9 +119,22 @@ const testCases = [ { loc: AppPath.SyncEmails, status: OnboardingStatus.OngoingWorkspaceActivation, res: true }, { loc: AppPath.SyncEmails, status: OnboardingStatus.OngoingProfileCreation, res: true }, { loc: AppPath.SyncEmails, status: OnboardingStatus.OngoingSyncEmail, res: true }, + { loc: AppPath.SyncEmails, status: OnboardingStatus.OngoingInviteTeam, res: true }, { loc: AppPath.SyncEmails, status: OnboardingStatus.Completed, res: false }, { loc: AppPath.SyncEmails, status: OnboardingStatus.CompletedWithoutSubscription, res: false }, + { loc: AppPath.InviteTeam, status: OnboardingStatus.Incomplete, res: true }, + { loc: AppPath.InviteTeam, status: OnboardingStatus.Canceled, res: false }, + { loc: AppPath.InviteTeam, status: OnboardingStatus.Unpaid, res: false }, + { loc: AppPath.InviteTeam, status: OnboardingStatus.PastDue, res: false }, + { loc: AppPath.InviteTeam, status: OnboardingStatus.OngoingUserCreation, res: true }, + { loc: AppPath.InviteTeam, status: OnboardingStatus.OngoingWorkspaceActivation, res: true }, + { loc: AppPath.InviteTeam, status: OnboardingStatus.OngoingProfileCreation, res: true }, + { loc: AppPath.InviteTeam, status: OnboardingStatus.OngoingSyncEmail, res: true }, + { loc: AppPath.InviteTeam, status: OnboardingStatus.OngoingInviteTeam, res: true }, + { loc: AppPath.InviteTeam, status: OnboardingStatus.Completed, res: false }, + { loc: AppPath.InviteTeam, status: OnboardingStatus.CompletedWithoutSubscription, res: false }, + { loc: AppPath.PlanRequired, status: OnboardingStatus.Incomplete, res: true }, { loc: AppPath.PlanRequired, status: OnboardingStatus.Canceled, res: true }, { loc: AppPath.PlanRequired, status: OnboardingStatus.Unpaid, res: false }, @@ -124,6 +143,7 @@ const testCases = [ { loc: AppPath.PlanRequired, status: OnboardingStatus.OngoingWorkspaceActivation, res: true }, { loc: AppPath.PlanRequired, status: OnboardingStatus.OngoingProfileCreation, res: true }, { loc: AppPath.PlanRequired, status: OnboardingStatus.OngoingSyncEmail, res: true }, + { loc: AppPath.PlanRequired, status: OnboardingStatus.OngoingInviteTeam, res: true }, { loc: AppPath.PlanRequired, status: OnboardingStatus.Completed, res: false }, { loc: AppPath.PlanRequired, status: OnboardingStatus.CompletedWithoutSubscription, res: true }, @@ -135,6 +155,7 @@ const testCases = [ { loc: AppPath.PlanRequiredSuccess, status: OnboardingStatus.OngoingWorkspaceActivation, res: true }, { loc: AppPath.PlanRequiredSuccess, status: OnboardingStatus.OngoingProfileCreation, res: true }, { loc: AppPath.PlanRequiredSuccess, status: OnboardingStatus.OngoingSyncEmail, res: true }, + { loc: AppPath.PlanRequiredSuccess, status: OnboardingStatus.OngoingInviteTeam, res: true }, { loc: AppPath.PlanRequiredSuccess, status: OnboardingStatus.Completed, res: false }, { loc: AppPath.PlanRequiredSuccess, status: OnboardingStatus.CompletedWithoutSubscription, res: false }, @@ -146,6 +167,7 @@ const testCases = [ { loc: AppPath.Index, status: OnboardingStatus.OngoingWorkspaceActivation, res: true }, { loc: AppPath.Index, status: OnboardingStatus.OngoingProfileCreation, res: true }, { loc: AppPath.Index, status: OnboardingStatus.OngoingSyncEmail, res: true }, + { loc: AppPath.Index, status: OnboardingStatus.OngoingInviteTeam, res: true }, { loc: AppPath.Index, status: OnboardingStatus.Completed, res: false }, { loc: AppPath.Index, status: OnboardingStatus.CompletedWithoutSubscription, res: false }, @@ -157,6 +179,7 @@ const testCases = [ { loc: AppPath.TasksPage, status: OnboardingStatus.OngoingWorkspaceActivation, res: true }, { loc: AppPath.TasksPage, status: OnboardingStatus.OngoingProfileCreation, res: true }, { loc: AppPath.TasksPage, status: OnboardingStatus.OngoingSyncEmail, res: true }, + { loc: AppPath.TasksPage, status: OnboardingStatus.OngoingInviteTeam, res: true }, { loc: AppPath.TasksPage, status: OnboardingStatus.Completed, res: false }, { loc: AppPath.TasksPage, status: OnboardingStatus.CompletedWithoutSubscription, res: false }, @@ -168,6 +191,7 @@ const testCases = [ { loc: AppPath.OpportunitiesPage, status: OnboardingStatus.OngoingWorkspaceActivation, res: true }, { loc: AppPath.OpportunitiesPage, status: OnboardingStatus.OngoingProfileCreation, res: true }, { loc: AppPath.OpportunitiesPage, status: OnboardingStatus.OngoingSyncEmail, res: true }, + { loc: AppPath.OpportunitiesPage, status: OnboardingStatus.OngoingInviteTeam, res: true }, { loc: AppPath.OpportunitiesPage, status: OnboardingStatus.Completed, res: false }, { loc: AppPath.OpportunitiesPage, status: OnboardingStatus.CompletedWithoutSubscription, res: false }, @@ -179,6 +203,7 @@ const testCases = [ { loc: AppPath.RecordIndexPage, status: OnboardingStatus.OngoingWorkspaceActivation, res: true }, { loc: AppPath.RecordIndexPage, status: OnboardingStatus.OngoingProfileCreation, res: true }, { loc: AppPath.RecordIndexPage, status: OnboardingStatus.OngoingSyncEmail, res: true }, + { loc: AppPath.RecordIndexPage, status: OnboardingStatus.OngoingInviteTeam, res: true }, { loc: AppPath.RecordIndexPage, status: OnboardingStatus.Completed, res: false }, { loc: AppPath.RecordIndexPage, status: OnboardingStatus.CompletedWithoutSubscription, res: false }, @@ -190,6 +215,7 @@ const testCases = [ { loc: AppPath.RecordShowPage, status: OnboardingStatus.OngoingWorkspaceActivation, res: true }, { loc: AppPath.RecordShowPage, status: OnboardingStatus.OngoingProfileCreation, res: true }, { loc: AppPath.RecordShowPage, status: OnboardingStatus.OngoingSyncEmail, res: true }, + { loc: AppPath.RecordShowPage, status: OnboardingStatus.OngoingInviteTeam, res: true }, { loc: AppPath.RecordShowPage, status: OnboardingStatus.Completed, res: false }, { loc: AppPath.RecordShowPage, status: OnboardingStatus.CompletedWithoutSubscription, res: false }, @@ -201,6 +227,7 @@ const testCases = [ { loc: AppPath.SettingsCatchAll, status: OnboardingStatus.OngoingWorkspaceActivation, res: true }, { loc: AppPath.SettingsCatchAll, status: OnboardingStatus.OngoingProfileCreation, res: true }, { loc: AppPath.SettingsCatchAll, status: OnboardingStatus.OngoingSyncEmail, res: true }, + { loc: AppPath.SettingsCatchAll, status: OnboardingStatus.OngoingInviteTeam, res: true }, { loc: AppPath.SettingsCatchAll, status: OnboardingStatus.Completed, res: false }, { loc: AppPath.SettingsCatchAll, status: OnboardingStatus.CompletedWithoutSubscription, res: false }, @@ -212,6 +239,7 @@ const testCases = [ { loc: AppPath.DevelopersCatchAll, status: OnboardingStatus.OngoingWorkspaceActivation, res: true }, { loc: AppPath.DevelopersCatchAll, status: OnboardingStatus.OngoingProfileCreation, res: true }, { loc: AppPath.DevelopersCatchAll, status: OnboardingStatus.OngoingSyncEmail, res: true }, + { loc: AppPath.DevelopersCatchAll, status: OnboardingStatus.OngoingInviteTeam, res: true }, { loc: AppPath.DevelopersCatchAll, status: OnboardingStatus.Completed, res: false }, { loc: AppPath.DevelopersCatchAll, status: OnboardingStatus.CompletedWithoutSubscription, res: false }, @@ -223,6 +251,7 @@ const testCases = [ { loc: AppPath.Impersonate, status: OnboardingStatus.OngoingWorkspaceActivation, res: true }, { loc: AppPath.Impersonate, status: OnboardingStatus.OngoingProfileCreation, res: true }, { loc: AppPath.Impersonate, status: OnboardingStatus.OngoingSyncEmail, res: true }, + { loc: AppPath.Impersonate, status: OnboardingStatus.OngoingInviteTeam, res: true }, { loc: AppPath.Impersonate, status: OnboardingStatus.Completed, res: false }, { loc: AppPath.Impersonate, status: OnboardingStatus.CompletedWithoutSubscription, res: false }, @@ -234,6 +263,7 @@ const testCases = [ { loc: AppPath.Authorize, status: OnboardingStatus.OngoingWorkspaceActivation, res: true }, { loc: AppPath.Authorize, status: OnboardingStatus.OngoingProfileCreation, res: true }, { loc: AppPath.Authorize, status: OnboardingStatus.OngoingSyncEmail, res: true }, + { loc: AppPath.Authorize, status: OnboardingStatus.OngoingInviteTeam, res: true }, { loc: AppPath.Authorize, status: OnboardingStatus.Completed, res: false }, { loc: AppPath.Authorize, status: OnboardingStatus.CompletedWithoutSubscription, res: false }, @@ -245,6 +275,7 @@ const testCases = [ { loc: AppPath.NotFoundWildcard, status: OnboardingStatus.OngoingWorkspaceActivation, res: true }, { loc: AppPath.NotFoundWildcard, status: OnboardingStatus.OngoingProfileCreation, res: true }, { loc: AppPath.NotFoundWildcard, status: OnboardingStatus.OngoingSyncEmail, res: true }, + { loc: AppPath.NotFoundWildcard, status: OnboardingStatus.OngoingInviteTeam, res: true }, { loc: AppPath.NotFoundWildcard, status: OnboardingStatus.Completed, res: false }, { loc: AppPath.NotFoundWildcard, status: OnboardingStatus.CompletedWithoutSubscription, res: false }, @@ -256,6 +287,7 @@ const testCases = [ { loc: AppPath.NotFound, status: OnboardingStatus.OngoingWorkspaceActivation, res: true }, { loc: AppPath.NotFound, status: OnboardingStatus.OngoingProfileCreation, res: true }, { loc: AppPath.NotFound, status: OnboardingStatus.OngoingSyncEmail, res: true }, + { loc: AppPath.NotFound, status: OnboardingStatus.OngoingInviteTeam, res: true }, { loc: AppPath.NotFound, status: OnboardingStatus.Completed, res: false }, { loc: AppPath.NotFound, status: OnboardingStatus.CompletedWithoutSubscription, res: false }, ]; diff --git a/packages/twenty-front/src/modules/ui/layout/hooks/useShowAuthModal.ts b/packages/twenty-front/src/modules/ui/layout/hooks/useShowAuthModal.ts index 21dbe8840b..47a59bc29b 100644 --- a/packages/twenty-front/src/modules/ui/layout/hooks/useShowAuthModal.ts +++ b/packages/twenty-front/src/modules/ui/layout/hooks/useShowAuthModal.ts @@ -28,7 +28,8 @@ export const useShowAuthModal = () => { OnboardingStatus.OngoingUserCreation === onboardingStatus || OnboardingStatus.OngoingProfileCreation === onboardingStatus || OnboardingStatus.OngoingWorkspaceActivation === onboardingStatus || - OnboardingStatus.OngoingSyncEmail === onboardingStatus + OnboardingStatus.OngoingSyncEmail === onboardingStatus || + OnboardingStatus.OngoingInviteTeam === onboardingStatus ) { return true; } diff --git a/packages/twenty-front/src/modules/ui/utilities/animation/components/AnimatedTranslation.tsx b/packages/twenty-front/src/modules/ui/utilities/animation/components/AnimatedTranslation.tsx new file mode 100644 index 0000000000..e22ba407e7 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/utilities/animation/components/AnimatedTranslation.tsx @@ -0,0 +1,33 @@ +import { useTheme } from '@emotion/react'; +import { motion } from 'framer-motion'; + +type AnimatedTranslationProps = { + children: React.ReactNode; +}; + +export const AnimatedTranslation = ({ children }: AnimatedTranslationProps) => { + const theme = useTheme(); + + return ( + + {children} + + ); +}; diff --git a/packages/twenty-front/src/modules/users/graphql/fragments/userQueryFragment.ts b/packages/twenty-front/src/modules/users/graphql/fragments/userQueryFragment.ts index 50df938f43..32d358c645 100644 --- a/packages/twenty-front/src/modules/users/graphql/fragments/userQueryFragment.ts +++ b/packages/twenty-front/src/modules/users/graphql/fragments/userQueryFragment.ts @@ -8,9 +8,7 @@ export const USER_QUERY_FRAGMENT = gql` email canImpersonate supportUserHash - state { - skipSyncEmailOnboardingStep - } + onboardingStep workspaceMember { id name { diff --git a/packages/twenty-front/src/modules/workspace/components/WorkspaceInviteTeam.tsx b/packages/twenty-front/src/modules/workspace/components/WorkspaceInviteTeam.tsx index e0490363fc..3bb6bbc024 100644 --- a/packages/twenty-front/src/modules/workspace/components/WorkspaceInviteTeam.tsx +++ b/packages/twenty-front/src/modules/workspace/components/WorkspaceInviteTeam.tsx @@ -1,17 +1,16 @@ import { useEffect } from 'react'; import { Controller, useForm } from 'react-hook-form'; -import { useTheme } from '@emotion/react'; import styled from '@emotion/styled'; import { zodResolver } from '@hookform/resolvers/zod'; import { Key } from 'ts-key-enum'; -import { IconCopy, IconMail, IconSend } from 'twenty-ui'; +import { IconMail, IconSend } from 'twenty-ui'; import { z } from 'zod'; import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar'; import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; import { Button } from '@/ui/input/button/components/Button'; import { TextInput } from '@/ui/input/components/TextInput'; -import { extractEmailsList } from '@/workspace/utils/extractEmailList'; +import { sanitizeEmailList } from '@/workspace/utils/sanitizeEmailList'; import { useSendInviteLinkMutation } from '~/generated/graphql'; import { isDefined } from '~/utils/isDefined'; @@ -35,7 +34,7 @@ const validationSchema = () => if (!value.length) { return; } - const emails = extractEmailsList(value); + const emails = sanitizeEmailList(value.split(',')); if (emails.length === 0) { ctx.addIssue({ code: z.ZodIssueCode.invalid_string, @@ -69,7 +68,6 @@ type FormInput = { }; export const WorkspaceInviteTeam = () => { - const theme = useTheme(); const { enqueueSnackBar } = useSnackBar(); const [sendInviteLink] = useSendInviteLinkMutation(); @@ -82,14 +80,13 @@ export const WorkspaceInviteTeam = () => { }); const submit = handleSubmit(async (data) => { - const emailsList = extractEmailsList(data.emails); + const emailsList = sanitizeEmailList(data.emails.split(',')); const result = await sendInviteLink({ variables: { emails: emailsList } }); if (isDefined(result.errors)) { throw result.errors; } enqueueSnackBar('Invite link sent to email addresses', { variant: SnackBarVariant.Success, - icon: , duration: 2000, }); }); diff --git a/packages/twenty-front/src/modules/workspace/utils/__tests__/extractEmailList.test.ts b/packages/twenty-front/src/modules/workspace/utils/__tests__/extractEmailList.test.ts deleted file mode 100644 index d9b24cab1f..0000000000 --- a/packages/twenty-front/src/modules/workspace/utils/__tests__/extractEmailList.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { extractEmailsList } from '@/workspace/utils/extractEmailList'; - -describe('extractEmailList', () => { - it('should extract email list', () => { - expect(extractEmailsList('toto@toto.com')).toEqual(['toto@toto.com']); - }); - it('should extract email list with multiple emails', () => { - expect(extractEmailsList('toto@toto.com,toto2@toto.com')).toEqual([ - 'toto@toto.com', - 'toto2@toto.com', - ]); - }); - it('should extract email list with multiple emails and wrong emails', () => { - expect(extractEmailsList('toto@toto.com,toto2@toto.com,toto')).toEqual([ - 'toto@toto.com', - 'toto2@toto.com', - 'toto', - ]); - }); - it('should remove duplicates', () => { - expect(extractEmailsList('toto@toto.com,toto@toto.com')).toEqual([ - 'toto@toto.com', - ]); - }); - it('should remove empty emails', () => { - expect(extractEmailsList('toto@toto.com,')).toEqual(['toto@toto.com']); - }); -}); diff --git a/packages/twenty-front/src/modules/workspace/utils/__tests__/sanitizeEmailList.test.ts b/packages/twenty-front/src/modules/workspace/utils/__tests__/sanitizeEmailList.test.ts new file mode 100644 index 0000000000..0a7706aca1 --- /dev/null +++ b/packages/twenty-front/src/modules/workspace/utils/__tests__/sanitizeEmailList.test.ts @@ -0,0 +1,24 @@ +import { sanitizeEmailList } from '@/workspace/utils/sanitizeEmailList'; + +describe('sanitizeEmailList', () => { + it('should do nothing if sanitized email list', () => { + expect(sanitizeEmailList(['toto@toto.com', 'toto2@toto.com'])).toEqual([ + 'toto@toto.com', + 'toto2@toto.com', + ]); + }); + it('should trim spaces', () => { + expect(sanitizeEmailList([' toto@toto.com ', ' toto2@toto.com'])).toEqual([ + 'toto@toto.com', + 'toto2@toto.com', + ]); + }); + it('should filter empty emails', () => { + expect(sanitizeEmailList(['toto@toto.com', ''])).toEqual(['toto@toto.com']); + }); + it('should remove duplicates', () => { + expect(sanitizeEmailList(['toto@toto.com', 'toto@toto.com'])).toEqual([ + 'toto@toto.com', + ]); + }); +}); diff --git a/packages/twenty-front/src/modules/workspace/utils/extractEmailList.ts b/packages/twenty-front/src/modules/workspace/utils/sanitizeEmailList.ts similarity index 60% rename from packages/twenty-front/src/modules/workspace/utils/extractEmailList.ts rename to packages/twenty-front/src/modules/workspace/utils/sanitizeEmailList.ts index caefe80691..075367a194 100644 --- a/packages/twenty-front/src/modules/workspace/utils/extractEmailList.ts +++ b/packages/twenty-front/src/modules/workspace/utils/sanitizeEmailList.ts @@ -1,8 +1,7 @@ -export const extractEmailsList = (emails: string) => { +export const sanitizeEmailList = (emailList: string[]): string[] => { return Array.from( new Set( - emails - .split(',') + emailList .map((email) => email.trim()) .filter((email) => email.length > 0), ), diff --git a/packages/twenty-front/src/pages/onboarding/ChooseYourPlan.tsx b/packages/twenty-front/src/pages/onboarding/ChooseYourPlan.tsx index 58fa0dfc64..cd65b729f9 100644 --- a/packages/twenty-front/src/pages/onboarding/ChooseYourPlan.tsx +++ b/packages/twenty-front/src/pages/onboarding/ChooseYourPlan.tsx @@ -143,7 +143,7 @@ export const ChooseYourPlan = () => { return ( prices?.getProductPrices?.productPrices && ( <> - Choose your Plan + Choose your Plan Enjoy a {billing?.billingFreeTrialDurationInDays}-day free trial diff --git a/packages/twenty-front/src/pages/onboarding/CreateProfile.tsx b/packages/twenty-front/src/pages/onboarding/CreateProfile.tsx index 3c41918ec4..fa588b34ee 100644 --- a/packages/twenty-front/src/pages/onboarding/CreateProfile.tsx +++ b/packages/twenty-front/src/pages/onboarding/CreateProfile.tsx @@ -55,9 +55,7 @@ type Form = z.infer; export const CreateProfile = () => { const onboardingStatus = useOnboardingStatus(); - const { enqueueSnackBar } = useSnackBar(); - const [currentWorkspaceMember, setCurrentWorkspaceMember] = useRecoilState( currentWorkspaceMemberState, ); @@ -145,7 +143,7 @@ export const CreateProfile = () => { return ( <> - Create profile + Create profile How you'll be identified on the app. diff --git a/packages/twenty-front/src/pages/onboarding/CreateWorkspace.tsx b/packages/twenty-front/src/pages/onboarding/CreateWorkspace.tsx index 21ed64567c..5d37f13d27 100644 --- a/packages/twenty-front/src/pages/onboarding/CreateWorkspace.tsx +++ b/packages/twenty-front/src/pages/onboarding/CreateWorkspace.tsx @@ -111,7 +111,7 @@ export const CreateWorkspace = () => { return ( <> - Create your workspace + Create your workspace A shared environment where you will be able to manage your customer relations with your team. diff --git a/packages/twenty-front/src/pages/onboarding/InviteTeam.tsx b/packages/twenty-front/src/pages/onboarding/InviteTeam.tsx new file mode 100644 index 0000000000..2395c4bb27 --- /dev/null +++ b/packages/twenty-front/src/pages/onboarding/InviteTeam.tsx @@ -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; + +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({ + 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: , + duration: 2000, + }); + } + }; + + const onSubmit: SubmitHandler = 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 ( + <> + Invite your team + + Get the most out of your workspace by inviting your team. + + + {fields.map((field, index) => ( + ( + + + + )} + /> + ))} + {isDefined(currentWorkspace?.inviteHash) && ( + <> + Or + + + + + )} + + + + + + ); +}; diff --git a/packages/twenty-front/src/pages/onboarding/SyncEmails.tsx b/packages/twenty-front/src/pages/onboarding/SyncEmails.tsx index 7cda37b4dd..a52654701f 100644 --- a/packages/twenty-front/src/pages/onboarding/SyncEmails.tsx +++ b/packages/twenty-front/src/pages/onboarding/SyncEmails.tsx @@ -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.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 ( <> - Emails and Calendar + Emails and Calendar Sync your Emails and Calendar with Twenty. Choose your privacy settings. @@ -82,7 +96,6 @@ export const SyncEmails = () => { onClick={handleButtonClick} width={200} Icon={() => } - disabled={isSubmitting} /> diff --git a/packages/twenty-front/src/pages/onboarding/__stories__/InviteTeam.stories.tsx b/packages/twenty-front/src/pages/onboarding/__stories__/InviteTeam.stories.tsx new file mode 100644 index 0000000000..799daa5073 --- /dev/null +++ b/packages/twenty-front/src/pages/onboarding/__stories__/InviteTeam.stories.tsx @@ -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 = { + 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; + +export const Default: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await canvas.findByText('Invite your team'); + }, +}; diff --git a/packages/twenty-front/src/pages/onboarding/__stories__/SyncEmails.stories.tsx b/packages/twenty-front/src/pages/onboarding/__stories__/SyncEmails.stories.tsx index dce9aeb8d0..d6f7ba36dd 100644 --- a/packages/twenty-front/src/pages/onboarding/__stories__/SyncEmails.stories.tsx +++ b/packages/twenty-front/src/pages/onboarding/__stories__/SyncEmails.stories.tsx @@ -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 = { graphql.query(getOperationName(GET_CURRENT_USER) ?? '', () => { return HttpResponse.json({ data: { - currentUser: mockedOnboardingUsersData[0], + currentUser: { + ...mockedOnboardingUsersData[0], + onboardingStep: OnboardingStep.SyncEmail, + }, }, }); }), diff --git a/packages/twenty-front/src/testing/mock-data/users.ts b/packages/twenty-front/src/testing/mock-data/users.ts index ca69010d54..525919166a 100644 --- a/packages/twenty-front/src/testing/mock-data/users.ts +++ b/packages/twenty-front/src/testing/mock-data/users.ts @@ -10,7 +10,7 @@ type MockedUser = Pick< | 'canImpersonate' | '__typename' | 'supportUserHash' - | 'state' + | 'onboardingStep' > & { workspaceMember: WorkspaceMember | null; locale: string; @@ -93,7 +93,7 @@ export const mockedUsersData: Array = [ defaultWorkspace: mockDefaultWorkspace, locale: 'en', workspaces: [{ workspace: mockDefaultWorkspace }], - state: { skipSyncEmailOnboardingStep: true }, + onboardingStep: null, }, { id: '7dfbc3f7-6e5e-4128-957e-8d86808cdf6c', @@ -116,7 +116,7 @@ export const mockedUsersData: Array = [ defaultWorkspace: mockDefaultWorkspace, locale: 'en', workspaces: [{ workspace: mockDefaultWorkspace }], - state: { skipSyncEmailOnboardingStep: true }, + onboardingStep: null, }, ]; @@ -143,7 +143,7 @@ export const mockedOnboardingUsersData: Array = [ defaultWorkspace: mockDefaultWorkspace, locale: 'en', workspaces: [{ workspace: mockDefaultWorkspace }], - state: { skipSyncEmailOnboardingStep: true }, + onboardingStep: null, }, { id: '7dfbc3f7-6e5e-4128-957e-8d86808cdf6d', @@ -159,6 +159,6 @@ export const mockedOnboardingUsersData: Array = [ }, locale: 'en', workspaces: [{ workspace: mockDefaultWorkspace }], - state: { skipSyncEmailOnboardingStep: true }, + onboardingStep: null, }, ]; diff --git a/packages/twenty-server/src/database/typeorm/core/migrations/1717688588972-addKeyValuePairTableUniqueContraints.ts b/packages/twenty-server/src/database/typeorm/core/migrations/1717688588972-addKeyValuePairTableUniqueContraints.ts new file mode 100644 index 0000000000..9f019f2d86 --- /dev/null +++ b/packages/twenty-server/src/database/typeorm/core/migrations/1717688588972-addKeyValuePairTableUniqueContraints.ts @@ -0,0 +1,25 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddKeyValuePairTableUniqueContraints1717688588972 + implements MigrationInterface +{ + name = 'AddKeyValuePairTableUniqueContraints1717688588972'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE UNIQUE INDEX "IndexOnKeyUserIdAndNullWorkspaceIdUnique" ON "core"."keyValuePair" ("key", "userId") WHERE "workspaceId" is NULL`, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IndexOnKeyWorkspaceIdAndNullUserIdUnique" ON "core"."keyValuePair" ("key", "workspaceId") WHERE "userId" is NULL`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP INDEX "core"."IndexOnKeyWorkspaceIdAndNullUserIdUnique"`, + ); + await queryRunner.query( + `DROP INDEX "core"."IndexOnKeyUserIdAndNullWorkspaceIdUnique"`, + ); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/auth/auth.module.ts b/packages/twenty-server/src/engine/core-modules/auth/auth.module.ts index b492ca1a89..999d317507 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/auth.module.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/auth.module.ts @@ -27,7 +27,7 @@ import { ObjectMetadataRepositoryModule } from 'src/engine/object-metadata-repos import { ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity'; import { CalendarChannelWorkspaceEntity } from 'src/modules/calendar/standard-objects/calendar-channel.workspace-entity'; import { MessageChannelWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-channel.workspace-entity'; -import { UserStateModule } from 'src/engine/core-modules/user-state/user-state.module'; +import { OnboardingModule } from 'src/engine/core-modules/onboarding/onboarding.module'; import { AuthResolver } from './auth.resolver'; @@ -64,7 +64,7 @@ const jwtModule = JwtModule.registerAsync({ ]), HttpModule, UserWorkspaceModule, - UserStateModule, + OnboardingModule, ], controllers: [ GoogleAuthController, diff --git a/packages/twenty-server/src/engine/core-modules/auth/controllers/google-apis-auth.controller.ts b/packages/twenty-server/src/engine/core-modules/auth/controllers/google-apis-auth.controller.ts index 87ada636b8..0a36fe5d01 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/controllers/google-apis-auth.controller.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/controllers/google-apis-auth.controller.ts @@ -15,10 +15,10 @@ import { GoogleAPIsRequest } from 'src/engine/core-modules/auth/strategies/googl import { GoogleAPIsService } from 'src/engine/core-modules/auth/services/google-apis.service'; import { TokenService } from 'src/engine/core-modules/auth/services/token.service'; import { EnvironmentService } from 'src/engine/integrations/environment/environment.service'; -import { UserStateService } from 'src/engine/core-modules/user-state/user-state.service'; -import { InjectObjectMetadataRepository } from 'src/engine/object-metadata-repository/object-metadata-repository.decorator'; -import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity'; +import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.service'; import { WorkspaceMemberRepository } from 'src/modules/workspace-member/repositories/workspace-member.repository'; +import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity'; +import { InjectObjectMetadataRepository } from 'src/engine/object-metadata-repository/object-metadata-repository.decorator'; @Controller('auth/google-apis') export class GoogleAPIsAuthController { @@ -26,7 +26,7 @@ export class GoogleAPIsAuthController { private readonly googleAPIsService: GoogleAPIsService, private readonly tokenService: TokenService, private readonly environmentService: EnvironmentService, - private readonly userStateService: UserStateService, + private readonly onboardingService: OnboardingService, @InjectObjectMetadataRepository(WorkspaceMemberWorkspaceEntity) private readonly workspaceMemberService: WorkspaceMemberRepository, ) {} @@ -86,7 +86,7 @@ export class GoogleAPIsAuthController { )?.userId; if (userId) { - await this.userStateService.skipSyncEmailOnboardingStep( + await this.onboardingService.skipSyncEmailOnboardingStep( userId, workspaceId, ); diff --git a/packages/twenty-server/src/engine/core-modules/key-value-pair/key-value-pair.entity.ts b/packages/twenty-server/src/engine/core-modules/key-value-pair/key-value-pair.entity.ts index 8ef0658440..5374c948b8 100644 --- a/packages/twenty-server/src/engine/core-modules/key-value-pair/key-value-pair.entity.ts +++ b/packages/twenty-server/src/engine/core-modules/key-value-pair/key-value-pair.entity.ts @@ -4,6 +4,7 @@ import { Column, CreateDateColumn, Entity, + Index, JoinColumn, ManyToOne, PrimaryGeneratedColumn, @@ -20,6 +21,14 @@ import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; @Entity({ name: 'keyValuePair', schema: 'core' }) @ObjectType('KeyValuePair') @Unique('IndexOnKeyUserIdWorkspaceIdUnique', ['key', 'userId', 'workspaceId']) +@Index('IndexOnKeyWorkspaceIdAndNullUserIdUnique', ['key', 'workspaceId'], { + unique: true, + where: '"userId" is NULL', +}) +@Index('IndexOnKeyUserIdAndNullWorkspaceIdUnique', ['key', 'userId'], { + unique: true, + where: '"workspaceId" is NULL', +}) export class KeyValuePair { @IDField(() => UUIDScalarType) @PrimaryGeneratedColumn('uuid') diff --git a/packages/twenty-server/src/engine/core-modules/key-value-pair/key-value-pair.service.ts b/packages/twenty-server/src/engine/core-modules/key-value-pair/key-value-pair.service.ts index 9b3d01f64b..5f74effd09 100644 --- a/packages/twenty-server/src/engine/core-modules/key-value-pair/key-value-pair.service.ts +++ b/packages/twenty-server/src/engine/core-modules/key-value-pair/key-value-pair.service.ts @@ -1,55 +1,70 @@ import { InjectRepository } from '@nestjs/typeorm'; +import { BadRequestException } from '@nestjs/common'; import { Repository } from 'typeorm'; import { KeyValuePair } from 'src/engine/core-modules/key-value-pair/key-value-pair.entity'; -import { UserStates } from 'src/engine/core-modules/user-state/enums/user-states.enum'; -import { UserStateEmailSyncValues } from 'src/engine/core-modules/user-state/enums/user-state-email-sync-values.enum'; -export enum KeyValueTypes { - USER_STATE = 'USER_STATE', -} - -type KeyValuePairs = { - [KeyValueTypes.USER_STATE]: { - [UserStates.SYNC_EMAIL_ONBOARDING_STEP]: UserStateEmailSyncValues; - }; -}; - -export class KeyValuePairService { +export class KeyValuePairService { constructor( @InjectRepository(KeyValuePair, 'core') private readonly keyValuePairRepository: Repository, ) {} - async get( - userId: string, - workspaceId: string, - key: K, - ) { - return await this.keyValuePairRepository.findOne({ - where: { - userId, - workspaceId, - key: key as string, - }, - }); + async get({ + userId, + workspaceId, + key, + }: { + userId?: string; + workspaceId?: string; + key: K; + }): Promise { + return ( + await this.keyValuePairRepository.findOne({ + where: { + userId, + workspaceId, + key: key as string, + }, + }) + )?.value as TYPE[K] | undefined; } - async set( - userId: string, - workspaceId: string, - key: K, - value: KeyValuePairs[TYPE][K], - ) { - await this.keyValuePairRepository.upsert( - { - userId, - workspaceId, - key: key as string, - value: value as string, - }, - { conflictPaths: ['userId', 'workspaceId', 'key'] }, + async set({ + userId, + workspaceId, + key, + value, + }: { + userId?: string; + workspaceId?: string; + key: K; + value: TYPE[K]; + }) { + if (!userId && !workspaceId) { + throw new BadRequestException('userId and workspaceId are undefined'); + } + const upsertData = { + userId, + workspaceId, + key: key as string, + value: value as string, + }; + + const conflictPaths = Object.keys(upsertData).filter( + (key) => key !== 'value' && upsertData[key] !== undefined, ); + + const indexPredicate = !userId + ? '"userId" is NULL' + : !workspaceId + ? '"workspaceId" is NULL' + : undefined; + + await this.keyValuePairRepository.upsert(upsertData, { + conflictPaths, + indexPredicate, + }); } } diff --git a/packages/twenty-server/src/engine/core-modules/user-state/dtos/skip-sync-email.entity-onboarding-step.ts b/packages/twenty-server/src/engine/core-modules/onboarding/dtos/onboarding-step-success.dto.ts similarity index 81% rename from packages/twenty-server/src/engine/core-modules/user-state/dtos/skip-sync-email.entity-onboarding-step.ts rename to packages/twenty-server/src/engine/core-modules/onboarding/dtos/onboarding-step-success.dto.ts index 36c43cf0ae..58d7b48963 100644 --- a/packages/twenty-server/src/engine/core-modules/user-state/dtos/skip-sync-email.entity-onboarding-step.ts +++ b/packages/twenty-server/src/engine/core-modules/onboarding/dtos/onboarding-step-success.dto.ts @@ -1,7 +1,7 @@ import { Field, ObjectType } from '@nestjs/graphql'; @ObjectType() -export class SkipSyncEmailOnboardingStep { +export class OnboardingStepSuccess { @Field(() => Boolean, { description: 'Boolean that confirms query was dispatched', }) diff --git a/packages/twenty-server/src/engine/core-modules/onboarding/enums/onboarding-step.enum.ts b/packages/twenty-server/src/engine/core-modules/onboarding/enums/onboarding-step.enum.ts new file mode 100644 index 0000000000..55087ce5aa --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/onboarding/enums/onboarding-step.enum.ts @@ -0,0 +1,4 @@ +export enum OnboardingStep { + SYNC_EMAIL = 'SYNC_EMAIL', + INVITE_TEAM = 'INVITE_TEAM', +} diff --git a/packages/twenty-server/src/engine/core-modules/onboarding/onboarding.module.ts b/packages/twenty-server/src/engine/core-modules/onboarding/onboarding.module.ts new file mode 100644 index 0000000000..65f0156d3d --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/onboarding/onboarding.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; + +import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.service'; +import { OnboardingResolver } from 'src/engine/core-modules/onboarding/onboarding.resolver'; +import { KeyValuePairModule } from 'src/engine/core-modules/key-value-pair/key-value-pair.module'; +import { UserWorkspaceModule } from 'src/engine/core-modules/user-workspace/user-workspace.module'; +import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module'; + +@Module({ + imports: [DataSourceModule, UserWorkspaceModule, KeyValuePairModule], + exports: [OnboardingService], + providers: [OnboardingService, OnboardingResolver], +}) +export class OnboardingModule {} diff --git a/packages/twenty-server/src/engine/core-modules/user-state/user-state.resolver.ts b/packages/twenty-server/src/engine/core-modules/onboarding/onboarding.resolver.ts similarity index 52% rename from packages/twenty-server/src/engine/core-modules/user-state/user-state.resolver.ts rename to packages/twenty-server/src/engine/core-modules/onboarding/onboarding.resolver.ts index cdb0a77b63..89e7925a2d 100644 --- a/packages/twenty-server/src/engine/core-modules/user-state/user-state.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/onboarding/onboarding.resolver.ts @@ -2,27 +2,28 @@ import { UseGuards } from '@nestjs/common'; import { Mutation, Resolver } from '@nestjs/graphql'; import { JwtAuthGuard } from 'src/engine/guards/jwt.auth.guard'; -import { UserState } from 'src/engine/core-modules/user-state/dtos/user-state.dto'; +import { OnboardingStepSuccess } from 'src/engine/core-modules/onboarding/dtos/onboarding-step-success.dto'; import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator'; import { User } from 'src/engine/core-modules/user/user.entity'; -import { UserStateService } from 'src/engine/core-modules/user-state/user-state.service'; import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; -import { SkipSyncEmailOnboardingStep } from 'src/engine/core-modules/user-state/dtos/skip-sync-email.entity-onboarding-step'; +import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.service'; @UseGuards(JwtAuthGuard) -@Resolver(() => UserState) -export class UserStateResolver { - constructor(private readonly userStateService: UserStateService) {} +@Resolver() +export class OnboardingResolver { + constructor(private readonly onboardingService: OnboardingService) {} - @Mutation(() => SkipSyncEmailOnboardingStep) + @Mutation(() => OnboardingStepSuccess) async skipSyncEmailOnboardingStep( @AuthUser() user: User, @AuthWorkspace() workspace: Workspace, - ): Promise { - return await this.userStateService.skipSyncEmailOnboardingStep( + ): Promise { + await this.onboardingService.skipSyncEmailOnboardingStep( user.id, workspace.id, ); + + return { success: true }; } } diff --git a/packages/twenty-server/src/engine/core-modules/onboarding/onboarding.service.ts b/packages/twenty-server/src/engine/core-modules/onboarding/onboarding.service.ts new file mode 100644 index 0000000000..1709f3d789 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/onboarding/onboarding.service.ts @@ -0,0 +1,98 @@ +import { Injectable } from '@nestjs/common'; + +import { KeyValuePairService } from 'src/engine/core-modules/key-value-pair/key-value-pair.service'; +import { OnboardingStep } from 'src/engine/core-modules/onboarding/enums/onboarding-step.enum'; +import { User } from 'src/engine/core-modules/user/user.entity'; +import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; +import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service'; +import { InjectObjectMetadataRepository } from 'src/engine/object-metadata-repository/object-metadata-repository.decorator'; +import { ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity'; +import { ConnectedAccountRepository } from 'src/modules/connected-account/repositories/connected-account.repository'; + +enum OnboardingStepValues { + SKIPPED = 'SKIPPED', +} + +enum OnboardingStepKeys { + SYNC_EMAIL_ONBOARDING_STEP = 'SYNC_EMAIL_ONBOARDING_STEP', + INVITE_TEAM_ONBOARDING_STEP = 'INVITE_TEAM_ONBOARDING_STEP', +} + +type OnboardingKeyValueType = { + [OnboardingStepKeys.SYNC_EMAIL_ONBOARDING_STEP]: OnboardingStepValues; + [OnboardingStepKeys.INVITE_TEAM_ONBOARDING_STEP]: OnboardingStepValues; +}; + +@Injectable() +export class OnboardingService { + constructor( + private readonly userWorkspaceService: UserWorkspaceService, + private readonly keyValuePairService: KeyValuePairService, + @InjectObjectMetadataRepository(ConnectedAccountWorkspaceEntity) + private readonly connectedAccountRepository: ConnectedAccountRepository, + ) {} + + private async isSyncEmailOnboardingStep(user: User, workspace: Workspace) { + const syncEmailValue = await this.keyValuePairService.get({ + userId: user.id, + workspaceId: workspace.id, + key: OnboardingStepKeys.SYNC_EMAIL_ONBOARDING_STEP, + }); + const isSyncEmailSkipped = syncEmailValue === OnboardingStepValues.SKIPPED; + const connectedAccounts = + await this.connectedAccountRepository.getAllByUserId( + user.id, + workspace.id, + ); + + return !isSyncEmailSkipped && !connectedAccounts?.length; + } + + private async isInviteTeamOnboardingStep(workspace: Workspace) { + const inviteTeamValue = await this.keyValuePairService.get({ + workspaceId: workspace.id, + key: OnboardingStepKeys.INVITE_TEAM_ONBOARDING_STEP, + }); + const isInviteTeamSkipped = + inviteTeamValue === OnboardingStepValues.SKIPPED; + const workspaceMemberCount = + await this.userWorkspaceService.getWorkspaceMemberCount(workspace.id); + + return ( + !isInviteTeamSkipped && + (!workspaceMemberCount || workspaceMemberCount <= 1) + ); + } + + async getOnboardingStep( + user: User, + workspace: Workspace, + ): Promise { + if (await this.isSyncEmailOnboardingStep(user, workspace)) { + return OnboardingStep.SYNC_EMAIL; + } + + if (await this.isInviteTeamOnboardingStep(workspace)) { + return OnboardingStep.INVITE_TEAM; + } + + return null; + } + + async skipInviteTeamOnboardingStep(workspaceId: string) { + await this.keyValuePairService.set({ + workspaceId, + key: OnboardingStepKeys.INVITE_TEAM_ONBOARDING_STEP, + value: OnboardingStepValues.SKIPPED, + }); + } + + async skipSyncEmailOnboardingStep(userId: string, workspaceId: string) { + await this.keyValuePairService.set({ + userId, + workspaceId, + key: OnboardingStepKeys.SYNC_EMAIL_ONBOARDING_STEP, + value: OnboardingStepValues.SKIPPED, + }); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/user-state/constants/default-user-state.ts b/packages/twenty-server/src/engine/core-modules/user-state/constants/default-user-state.ts deleted file mode 100644 index 6b9cdc2412..0000000000 --- a/packages/twenty-server/src/engine/core-modules/user-state/constants/default-user-state.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { UserState } from 'src/engine/core-modules/user-state/dtos/user-state.dto'; - -export const DEFAULT_USER_STATE: UserState = { - skipSyncEmailOnboardingStep: true, -}; diff --git a/packages/twenty-server/src/engine/core-modules/user-state/dtos/user-state.dto.ts b/packages/twenty-server/src/engine/core-modules/user-state/dtos/user-state.dto.ts deleted file mode 100644 index 9774b9d1f2..0000000000 --- a/packages/twenty-server/src/engine/core-modules/user-state/dtos/user-state.dto.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { Field, ObjectType } from '@nestjs/graphql'; - -@ObjectType('UserState') -export class UserState { - @Field(() => Boolean, { nullable: true }) - skipSyncEmailOnboardingStep: boolean | null; -} diff --git a/packages/twenty-server/src/engine/core-modules/user-state/enums/user-state-email-sync-values.enum.ts b/packages/twenty-server/src/engine/core-modules/user-state/enums/user-state-email-sync-values.enum.ts deleted file mode 100644 index c4eb11cf8f..0000000000 --- a/packages/twenty-server/src/engine/core-modules/user-state/enums/user-state-email-sync-values.enum.ts +++ /dev/null @@ -1,3 +0,0 @@ -export enum UserStateEmailSyncValues { - SKIPPED = 'SKIPPED', -} diff --git a/packages/twenty-server/src/engine/core-modules/user-state/enums/user-states.enum.ts b/packages/twenty-server/src/engine/core-modules/user-state/enums/user-states.enum.ts deleted file mode 100644 index c8519271d4..0000000000 --- a/packages/twenty-server/src/engine/core-modules/user-state/enums/user-states.enum.ts +++ /dev/null @@ -1,3 +0,0 @@ -export enum UserStates { - SYNC_EMAIL_ONBOARDING_STEP = 'SYNC_EMAIL_ONBOARDING_STEP', -} diff --git a/packages/twenty-server/src/engine/core-modules/user-state/user-state.module.ts b/packages/twenty-server/src/engine/core-modules/user-state/user-state.module.ts deleted file mode 100644 index 26233500fa..0000000000 --- a/packages/twenty-server/src/engine/core-modules/user-state/user-state.module.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Module } from '@nestjs/common'; - -import { UserStateService } from 'src/engine/core-modules/user-state/user-state.service'; -import { UserStateResolver } from 'src/engine/core-modules/user-state/user-state.resolver'; -import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module'; -import { KeyValuePairModule } from 'src/engine/core-modules/key-value-pair/key-value-pair.module'; - -@Module({ - imports: [DataSourceModule, KeyValuePairModule], - exports: [UserStateService], - providers: [UserStateService, UserStateResolver], -}) -export class UserStateModule {} diff --git a/packages/twenty-server/src/engine/core-modules/user-state/user-state.service.ts b/packages/twenty-server/src/engine/core-modules/user-state/user-state.service.ts deleted file mode 100644 index 66bcc2bd00..0000000000 --- a/packages/twenty-server/src/engine/core-modules/user-state/user-state.service.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { Injectable } from '@nestjs/common'; - -import { InjectObjectMetadataRepository } from 'src/engine/object-metadata-repository/object-metadata-repository.decorator'; -import { ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity'; -import { ConnectedAccountRepository } from 'src/modules/connected-account/repositories/connected-account.repository'; -import { User } from 'src/engine/core-modules/user/user.entity'; -import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; -import { UserState } from 'src/engine/core-modules/user-state/dtos/user-state.dto'; -import { - KeyValuePairService, - KeyValueTypes, -} from 'src/engine/core-modules/key-value-pair/key-value-pair.service'; -import { UserStates } from 'src/engine/core-modules/user-state/enums/user-states.enum'; -import { UserStateEmailSyncValues } from 'src/engine/core-modules/user-state/enums/user-state-email-sync-values.enum'; -import { SkipSyncEmailOnboardingStep } from 'src/engine/core-modules/user-state/dtos/skip-sync-email.entity-onboarding-step'; - -@Injectable() -export class UserStateService { - constructor( - @InjectObjectMetadataRepository(ConnectedAccountWorkspaceEntity) - private readonly connectedAccountRepository: ConnectedAccountRepository, - private readonly keyValuePairService: KeyValuePairService, - ) {} - - async getUserState(user: User, workspace: Workspace): Promise { - const connectedAccounts = - await this.connectedAccountRepository.getAllByUserId( - user.id, - workspace.id, - ); - - if (connectedAccounts?.length) { - return { - skipSyncEmailOnboardingStep: true, - }; - } - - const skipSyncEmail = await this.keyValuePairService.get( - user.id, - workspace.id, - UserStates.SYNC_EMAIL_ONBOARDING_STEP, - ); - - return { - skipSyncEmailOnboardingStep: - !!skipSyncEmail && - skipSyncEmail.value === UserStateEmailSyncValues.SKIPPED, - }; - } - - async skipSyncEmailOnboardingStep( - userId: string, - workspaceId: string, - ): Promise { - await this.keyValuePairService.set( - userId, - workspaceId, - UserStates.SYNC_EMAIL_ONBOARDING_STEP, - UserStateEmailSyncValues.SKIPPED, - ); - - return { success: true }; - } -} diff --git a/packages/twenty-server/src/engine/core-modules/user/user.entity.ts b/packages/twenty-server/src/engine/core-modules/user/user.entity.ts index 8e484cef52..4a855db103 100644 --- a/packages/twenty-server/src/engine/core-modules/user/user.entity.ts +++ b/packages/twenty-server/src/engine/core-modules/user/user.entity.ts @@ -1,4 +1,4 @@ -import { Field, ObjectType } from '@nestjs/graphql'; +import { Field, ObjectType, registerEnumType } from '@nestjs/graphql'; import { Entity, @@ -18,7 +18,12 @@ import { WorkspaceMember } from 'src/engine/core-modules/user/dtos/workspace-mem import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; import { KeyValuePair } from 'src/engine/core-modules/key-value-pair/key-value-pair.entity'; -import { UserState } from 'src/engine/core-modules/user-state/dtos/user-state.dto'; +import { OnboardingStep } from 'src/engine/core-modules/onboarding/enums/onboarding-step.enum'; + +registerEnumType(OnboardingStep, { + name: 'OnboardingStep', + description: 'Onboarding step', +}); @Entity({ name: 'user', schema: 'core' }) @ObjectType('User') @@ -114,6 +119,6 @@ export class User { @OneToMany(() => UserWorkspace, (userWorkspace) => userWorkspace.user) workspaces: Relation; - @Field(() => UserState, { nullable: false }) - state: UserState; + @Field(() => OnboardingStep, { nullable: true }) + onboardingStep: OnboardingStep; } diff --git a/packages/twenty-server/src/engine/core-modules/user/user.module.ts b/packages/twenty-server/src/engine/core-modules/user/user.module.ts index 94747ef637..944657139e 100644 --- a/packages/twenty-server/src/engine/core-modules/user/user.module.ts +++ b/packages/twenty-server/src/engine/core-modules/user/user.module.ts @@ -11,7 +11,7 @@ import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-s import { TypeORMModule } from 'src/database/typeorm/typeorm.module'; import { FileUploadModule } from 'src/engine/core-modules/file/file-upload/file-upload.module'; import { WorkspaceModule } from 'src/engine/core-modules/workspace/workspace.module'; -import { UserStateModule } from 'src/engine/core-modules/user-state/user-state.module'; +import { OnboardingModule } from 'src/engine/core-modules/onboarding/onboarding.module'; import { userAutoResolverOpts } from './user.auto-resolver-opts'; @@ -28,8 +28,8 @@ import { UserService } from './services/user.service'; }), DataSourceModule, FileUploadModule, - UserStateModule, WorkspaceModule, + OnboardingModule, ], exports: [UserService], providers: [UserService, UserResolver, TypeORMService], diff --git a/packages/twenty-server/src/engine/core-modules/user/user.resolver.ts b/packages/twenty-server/src/engine/core-modules/user/user.resolver.ts index 1f8f1b35bb..ef596d19d0 100644 --- a/packages/twenty-server/src/engine/core-modules/user/user.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/user/user.resolver.ts @@ -27,11 +27,8 @@ import { DemoEnvGuard } from 'src/engine/guards/demo.env.guard'; import { JwtAuthGuard } from 'src/engine/guards/jwt.auth.guard'; import { User } from 'src/engine/core-modules/user/user.entity'; import { WorkspaceMember } from 'src/engine/core-modules/user/dtos/workspace-member.dto'; -import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator'; -import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; -import { UserState } from 'src/engine/core-modules/user-state/dtos/user-state.dto'; -import { UserStateService } from 'src/engine/core-modules/user-state/user-state.service'; -import { DEFAULT_USER_STATE } from 'src/engine/core-modules/user-state/constants/default-user-state'; +import { OnboardingStep } from 'src/engine/core-modules/onboarding/enums/onboarding-step.enum'; +import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.service'; const getHMACKey = (email?: string, key?: string | null) => { if (!email || !key) return null; @@ -47,10 +44,10 @@ export class UserResolver { constructor( @InjectRepository(User, 'core') private readonly userRepository: Repository, - private readonly userStateService: UserStateService, private readonly userService: UserService, private readonly environmentService: EnvironmentService, private readonly fileUploadService: FileUploadService, + private readonly onboardingService: OnboardingService, ) {} @Query(() => User) @@ -119,15 +116,15 @@ export class UserResolver { return this.userService.deleteUser(userId); } - @ResolveField(() => UserState) - async state( - @Parent() user: User, - @AuthWorkspace() workspace: Workspace, - ): Promise { - if (!user || !workspace) { - return DEFAULT_USER_STATE; + @ResolveField(() => OnboardingStep) + async onboardingStep(@Parent() user: User): Promise { + if (!user) { + return null; } - return this.userStateService.getUserState(user, workspace); + return this.onboardingService.getOnboardingStep( + user, + user.defaultWorkspace, + ); } } diff --git a/packages/twenty-server/src/engine/core-modules/workspace/dtos/send-invite-link.input.ts b/packages/twenty-server/src/engine/core-modules/workspace/dtos/send-invite-link.input.ts index 2fb71f8b5f..ac80111bab 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace/dtos/send-invite-link.input.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace/dtos/send-invite-link.input.ts @@ -1,12 +1,12 @@ import { ArgsType, Field } from '@nestjs/graphql'; -import { ArrayNotEmpty, IsArray, IsEmail } from 'class-validator'; +import { ArrayUnique, IsArray, IsEmail } from 'class-validator'; @ArgsType() export class SendInviteLinkInput { @Field(() => [String]) @IsArray() - @ArrayNotEmpty() @IsEmail({}, { each: true }) + @ArrayUnique() emails: string[]; } diff --git a/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.spec.ts b/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.spec.ts index 7c403f7328..87a10f16c6 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.spec.ts @@ -10,6 +10,7 @@ import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/use import { UserService } from 'src/engine/core-modules/user/services/user.service'; import { EmailService } from 'src/engine/integrations/email/email.service'; import { EnvironmentService } from 'src/engine/integrations/environment/environment.service'; +import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.service'; import { WorkspaceService } from './workspace.service'; @@ -48,12 +49,16 @@ describe('WorkspaceService', () => { provide: BillingService, useValue: {}, }, + { + provide: EnvironmentService, + useValue: {}, + }, { provide: EmailService, useValue: {}, }, { - provide: EnvironmentService, + provide: OnboardingService, useValue: {}, }, ], diff --git a/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.ts b/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.ts index 38e7c2e04a..6cc47b0871 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.ts @@ -18,6 +18,7 @@ import { BillingService } from 'src/engine/core-modules/billing/billing.service' import { SendInviteLink } from 'src/engine/core-modules/workspace/dtos/send-invite-link.entity'; import { EmailService } from 'src/engine/integrations/email/email.service'; import { EnvironmentService } from 'src/engine/integrations/environment/environment.service'; +import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.service'; export class WorkspaceService extends TypeOrmQueryService { constructor( @@ -32,6 +33,7 @@ export class WorkspaceService extends TypeOrmQueryService { private readonly billingService: BillingService, private readonly environmentService: EnvironmentService, private readonly emailService: EmailService, + private readonly onboardingService: OnboardingService, ) { super(workspaceRepository); } @@ -96,6 +98,7 @@ export class WorkspaceService extends TypeOrmQueryService { userId, workspaceId, }); + await this.onboardingService.skipInviteTeamOnboardingStep(workspaceId); await this.reassignOrRemoveUserDefaultWorkspace(workspaceId, userId); } @@ -107,6 +110,7 @@ export class WorkspaceService extends TypeOrmQueryService { if (!workspace?.inviteHash) { return { success: false }; } + const frontBaseURL = this.environmentService.get('FRONT_BASE_URL'); const inviteLink = `${frontBaseURL}/invite/${workspace.inviteHash}`; @@ -136,6 +140,8 @@ export class WorkspaceService extends TypeOrmQueryService { }); } + await this.onboardingService.skipInviteTeamOnboardingStep(workspace.id); + return { success: true }; } diff --git a/packages/twenty-server/src/engine/core-modules/workspace/workspace.module.ts b/packages/twenty-server/src/engine/core-modules/workspace/workspace.module.ts index d151781f4c..86f416fc41 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace/workspace.module.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace/workspace.module.ts @@ -16,6 +16,7 @@ import { WorkspaceWorkspaceMemberListener } from 'src/engine/core-modules/worksp import { WorkspaceCacheVersionModule } from 'src/engine/metadata-modules/workspace-cache-version/workspace-cache-version.module'; import { User } from 'src/engine/core-modules/user/user.entity'; import { UserWorkspaceResolver } from 'src/engine/core-modules/user-workspace/user-workspace.resolver'; +import { OnboardingModule } from 'src/engine/core-modules/onboarding/onboarding.module'; import { workspaceAutoResolverOpts } from './workspace.auto-resolver-opts'; import { Workspace } from './workspace.entity'; @@ -37,6 +38,7 @@ import { WorkspaceService } from './services/workspace.service'; UserWorkspaceModule, WorkspaceManagerModule, DataSourceModule, + OnboardingModule, TypeORMModule, ], services: [WorkspaceService], diff --git a/yarn.lock b/yarn.lock index 96f3306ce9..58f27c9225 100644 --- a/yarn.lock +++ b/yarn.lock @@ -42859,6 +42859,13 @@ __metadata: languageName: node linkType: hard +"reflect-metadata@npm:^0.2.1": + version: 0.2.2 + resolution: "reflect-metadata@npm:0.2.2" + checksum: 1cd93a15ea291e420204955544637c264c216e7aac527470e393d54b4bb075f10a17e60d8168ec96600c7e0b9fcc0cb0bb6e91c3fbf5b0d8c9056f04e6ac1ec2 + languageName: node + linkType: hard + "reflect.getprototypeof@npm:^1.0.4": version: 1.0.4 resolution: "reflect.getprototypeof@npm:1.0.4" @@ -47538,7 +47545,7 @@ __metadata: tsup: "npm:^8.0.1" tsx: "npm:^4.7.2" type-fest: "npm:4.10.1" - typeorm: "npm:^0.3.17" + typeorm: "npm:^0.3.20" typescript: "npm:5.3.3" use-context-selector: "npm:^2.0.0" use-debounce: "npm:^10.0.0" @@ -47664,9 +47671,9 @@ __metadata: languageName: node linkType: hard -"typeorm@npm:^0.3.17": - version: 0.3.19 - resolution: "typeorm@npm:0.3.19" +"typeorm@npm:^0.3.20": + version: 0.3.20 + resolution: "typeorm@npm:0.3.20" dependencies: "@sqltools/formatter": "npm:^1.2.5" app-root-path: "npm:^3.1.0" @@ -47678,7 +47685,7 @@ __metadata: dotenv: "npm:^16.0.3" glob: "npm:^10.3.10" mkdirp: "npm:^2.1.3" - reflect-metadata: "npm:^0.1.13" + reflect-metadata: "npm:^0.2.1" sha.js: "npm:^2.4.11" tslib: "npm:^2.5.0" uuid: "npm:^9.0.0" @@ -47740,7 +47747,7 @@ __metadata: typeorm: cli.js typeorm-ts-node-commonjs: cli-ts-node-commonjs.js typeorm-ts-node-esm: cli-ts-node-esm.js - checksum: c03a226b6d78c65fa0e69f5d0f0c515f80d6d2f39ffdac50a84e41bcc59d3296c012e4f6033d8f7444c9f5fd277c6bb4c9fa72bac637547d8bf2ff29a38cb3c6 + checksum: 7e4be724641beef86ae36289c87b6e66bfaf19a4313f089926d36d2d6f0d67f9314d942711c9d83ab8a174b8622148c2f7e83e6c1448d638ee3ab24469257814 languageName: node linkType: hard