Add a book-a-call onboarding step for qualified leads (#23521)
https://github.com/user-attachments/assets/76d5a14e-53bd-4195-963b-bf9bb265c8c1 Large-company signups either self-serve a small plan or drop off at the paywall without sales ever seeing them. This adds an embedded Cal.com booking step to onboarding, shown only to leads worth a call. The step sits between Invite Team and the plan step: the lead has built out a workspace by then, and sales gets a chance before checkout. It is always skippable, and a successful booking advances automatically. Qualification reuses the employee count from the People Data Labs enrichment added in #23199. `ONBOARDING_BOOK_CALL_MIN_EMPLOYEE_COUNT` sets the bar; leaving it unset means the step never appears. `CALENDAR_BOOKING_PAGE_ID` must also be configured, so the step can never strand someone on an empty embed. Enrichment is no longer gated on `IS_ONBOARDING_AI_CHAT_ENABLED`, since the book-a-call step is now a second consumer of it. `PEOPLE_DATA_LABS_API_KEY` remains the instance-level switch. The existing `/book-call` page is reused: it moves into the onboarding shell and its footer switches between Skip (as a step) and the back link (when reached from the plan page). <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23521?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. -->
This commit is contained in:
@@ -969,6 +969,7 @@ enum OnboardingStatus {
|
||||
SYNC_EMAIL
|
||||
APPS_INSTALLATION
|
||||
INVITE_TEAM
|
||||
BOOK_CALL
|
||||
COMPLETED
|
||||
}
|
||||
|
||||
@@ -1994,6 +1995,8 @@ type ClientConfig {
|
||||
isEmailingDomainInDemoMode: Boolean!
|
||||
allowRequestsToTwentyIcons: Boolean!
|
||||
calendarBookingPageId: String
|
||||
isBookCallOnboardingStepEnabled: Boolean!
|
||||
isCompanyEnrichmentEnabled: Boolean!
|
||||
isCloudflareIntegrationEnabled: Boolean!
|
||||
isClickHouseConfigured: Boolean!
|
||||
isWorkspaceSchemaDDLLocked: Boolean!
|
||||
@@ -2190,6 +2193,7 @@ type MarketplaceAppDetail {
|
||||
type WorkspaceCompanyEnrichmentResult {
|
||||
outcome: WorkspaceCompanyEnrichmentOutcome!
|
||||
enrichment: JSON
|
||||
isBookCallOnboardingStepPending: Boolean!
|
||||
}
|
||||
|
||||
enum WorkspaceCompanyEnrichmentOutcome {
|
||||
@@ -3485,6 +3489,7 @@ type Mutation {
|
||||
revokeUserSession(userSessionId: UUID!): Boolean!
|
||||
revokeAllOtherUserSessions: Int!
|
||||
skipSyncEmailOnboardingStep: OnboardingStepSuccess!
|
||||
completeBookCallOnboardingStep: OnboardingStepSuccess!
|
||||
triggerInstallAppsOnboardingStep(universalIdentifiers: [String!]!): OnboardingStepSuccess!
|
||||
updateOneApplicationVariable(key: String!, value: String!, applicationId: UUID!): Boolean!
|
||||
checkoutSession(recurringInterval: SubscriptionInterval!, plan: BillingPlanKey! = PRO, requirePaymentMethod: Boolean! = true, successUrlPath: String): BillingSession!
|
||||
|
||||
@@ -687,7 +687,7 @@ export interface User {
|
||||
|
||||
|
||||
/** Onboarding status */
|
||||
export type OnboardingStatus = 'PLAN_REQUIRED' | 'WORKSPACE_ACTIVATION' | 'PROFILE_CREATION' | 'SYNC_EMAIL' | 'APPS_INSTALLATION' | 'INVITE_TEAM' | 'COMPLETED'
|
||||
export type OnboardingStatus = 'PLAN_REQUIRED' | 'WORKSPACE_ACTIVATION' | 'PROFILE_CREATION' | 'SYNC_EMAIL' | 'APPS_INSTALLATION' | 'INVITE_TEAM' | 'BOOK_CALL' | 'COMPLETED'
|
||||
|
||||
export interface ApplicationRegistration {
|
||||
id: Scalars['UUID']
|
||||
@@ -1634,6 +1634,8 @@ export interface ClientConfig {
|
||||
isEmailingDomainInDemoMode: Scalars['Boolean']
|
||||
allowRequestsToTwentyIcons: Scalars['Boolean']
|
||||
calendarBookingPageId?: Scalars['String']
|
||||
isBookCallOnboardingStepEnabled: Scalars['Boolean']
|
||||
isCompanyEnrichmentEnabled: Scalars['Boolean']
|
||||
isCloudflareIntegrationEnabled: Scalars['Boolean']
|
||||
isClickHouseConfigured: Scalars['Boolean']
|
||||
isWorkspaceSchemaDDLLocked: Scalars['Boolean']
|
||||
@@ -1854,6 +1856,7 @@ export interface MarketplaceAppDetail {
|
||||
export interface WorkspaceCompanyEnrichmentResult {
|
||||
outcome: WorkspaceCompanyEnrichmentOutcome
|
||||
enrichment?: Scalars['JSON']
|
||||
isBookCallOnboardingStepPending: Scalars['Boolean']
|
||||
__typename: 'WorkspaceCompanyEnrichmentResult'
|
||||
}
|
||||
|
||||
@@ -2999,6 +3002,7 @@ export interface Mutation {
|
||||
revokeUserSession: Scalars['Boolean']
|
||||
revokeAllOtherUserSessions: Scalars['Int']
|
||||
skipSyncEmailOnboardingStep: OnboardingStepSuccess
|
||||
completeBookCallOnboardingStep: OnboardingStepSuccess
|
||||
triggerInstallAppsOnboardingStep: OnboardingStepSuccess
|
||||
updateOneApplicationVariable: Scalars['Boolean']
|
||||
checkoutSession: BillingSession
|
||||
@@ -4882,6 +4886,8 @@ export interface ClientConfigGenqlSelection{
|
||||
isEmailingDomainInDemoMode?: boolean | number
|
||||
allowRequestsToTwentyIcons?: boolean | number
|
||||
calendarBookingPageId?: boolean | number
|
||||
isBookCallOnboardingStepEnabled?: boolean | number
|
||||
isCompanyEnrichmentEnabled?: boolean | number
|
||||
isCloudflareIntegrationEnabled?: boolean | number
|
||||
isClickHouseConfigured?: boolean | number
|
||||
isWorkspaceSchemaDDLLocked?: boolean | number
|
||||
@@ -5124,6 +5130,7 @@ export interface MarketplaceAppDetailGenqlSelection{
|
||||
export interface WorkspaceCompanyEnrichmentResultGenqlSelection{
|
||||
outcome?: boolean | number
|
||||
enrichment?: boolean | number
|
||||
isBookCallOnboardingStepPending?: boolean | number
|
||||
__typename?: boolean | number
|
||||
__scalar?: boolean | number
|
||||
}
|
||||
@@ -6370,6 +6377,7 @@ export interface MutationGenqlSelection{
|
||||
revokeUserSession?: { __args: {userSessionId: Scalars['UUID']} }
|
||||
revokeAllOtherUserSessions?: boolean | number
|
||||
skipSyncEmailOnboardingStep?: OnboardingStepSuccessGenqlSelection
|
||||
completeBookCallOnboardingStep?: OnboardingStepSuccessGenqlSelection
|
||||
triggerInstallAppsOnboardingStep?: (OnboardingStepSuccessGenqlSelection & { __args: {universalIdentifiers: Scalars['String'][]} })
|
||||
updateOneApplicationVariable?: { __args: {key: Scalars['String'], value: Scalars['String'], applicationId: Scalars['UUID']} }
|
||||
checkoutSession?: (BillingSessionGenqlSelection & { __args: {recurringInterval: SubscriptionInterval, plan: BillingPlanKey, requirePaymentMethod: Scalars['Boolean'], successUrlPath?: (Scalars['String'] | null)} })
|
||||
@@ -9432,6 +9440,7 @@ export const enumOnboardingStatus = {
|
||||
SYNC_EMAIL: 'SYNC_EMAIL' as const,
|
||||
APPS_INSTALLATION: 'APPS_INSTALLATION' as const,
|
||||
INVITE_TEAM: 'INVITE_TEAM' as const,
|
||||
BOOK_CALL: 'BOOK_CALL' as const,
|
||||
COMPLETED: 'COMPLETED' as const
|
||||
}
|
||||
|
||||
|
||||
@@ -3858,6 +3858,12 @@ export default {
|
||||
"calendarBookingPageId": [
|
||||
1
|
||||
],
|
||||
"isBookCallOnboardingStepEnabled": [
|
||||
3
|
||||
],
|
||||
"isCompanyEnrichmentEnabled": [
|
||||
3
|
||||
],
|
||||
"isCloudflareIntegrationEnabled": [
|
||||
3
|
||||
],
|
||||
@@ -4359,6 +4365,9 @@ export default {
|
||||
"enrichment": [
|
||||
5
|
||||
],
|
||||
"isBookCallOnboardingStepPending": [
|
||||
3
|
||||
],
|
||||
"__typename": [
|
||||
1
|
||||
]
|
||||
@@ -7872,6 +7881,9 @@ export default {
|
||||
"skipSyncEmailOnboardingStep": [
|
||||
170
|
||||
],
|
||||
"completeBookCallOnboardingStep": [
|
||||
170
|
||||
],
|
||||
"triggerInstallAppsOnboardingStep": [
|
||||
170,
|
||||
{
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -128,6 +128,7 @@ const testCases: {
|
||||
{ loc: AppPath.WorkspaceSetup, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails },
|
||||
{ loc: AppPath.WorkspaceSetup, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.APPS_INSTALLATION, res: AppPath.InstallApps },
|
||||
{ loc: AppPath.WorkspaceSetup, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam },
|
||||
{ loc: AppPath.WorkspaceSetup, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.BOOK_CALL, res: AppPath.BookCall },
|
||||
{ loc: AppPath.WorkspaceSetup, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: undefined },
|
||||
|
||||
{ loc: AppPath.Verify, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: AppPath.PlanRequired },
|
||||
@@ -138,6 +139,7 @@ const testCases: {
|
||||
{ loc: AppPath.Verify, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails },
|
||||
{ loc: AppPath.Verify, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.APPS_INSTALLATION, res: AppPath.InstallApps },
|
||||
{ loc: AppPath.Verify, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam },
|
||||
{ loc: AppPath.Verify, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.BOOK_CALL, res: AppPath.BookCall },
|
||||
{ loc: AppPath.Verify, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: defaultHomePagePath },
|
||||
|
||||
{ loc: AppPath.SignInUp, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: AppPath.PlanRequired },
|
||||
@@ -148,6 +150,7 @@ const testCases: {
|
||||
{ loc: AppPath.SignInUp, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails },
|
||||
{ loc: AppPath.SignInUp, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.APPS_INSTALLATION, res: AppPath.InstallApps },
|
||||
{ loc: AppPath.SignInUp, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam },
|
||||
{ loc: AppPath.SignInUp, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.BOOK_CALL, res: AppPath.BookCall },
|
||||
{ loc: AppPath.SignInUp, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: defaultHomePagePath },
|
||||
|
||||
{ loc: AppPath.Invite, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: '/plan-required' },
|
||||
@@ -158,6 +161,7 @@ const testCases: {
|
||||
{ loc: AppPath.Invite, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: '/sync/emails' },
|
||||
{ loc: AppPath.Invite, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.APPS_INSTALLATION, res: AppPath.InstallApps },
|
||||
{ loc: AppPath.Invite, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: '/invite-team' },
|
||||
{ loc: AppPath.Invite, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.BOOK_CALL, res: AppPath.BookCall },
|
||||
{ loc: AppPath.Invite, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: defaultHomePagePath },
|
||||
|
||||
{ loc: AppPath.ResetPassword, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: '/plan-required' },
|
||||
@@ -168,6 +172,7 @@ const testCases: {
|
||||
{ loc: AppPath.ResetPassword, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: '/sync/emails' },
|
||||
{ loc: AppPath.ResetPassword, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.APPS_INSTALLATION, res: AppPath.InstallApps },
|
||||
{ loc: AppPath.ResetPassword, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: '/invite-team' },
|
||||
{ loc: AppPath.ResetPassword, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.BOOK_CALL, res: AppPath.BookCall },
|
||||
{ loc: AppPath.ResetPassword, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: undefined },
|
||||
|
||||
{ loc: AppPath.VerifyEmail, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: AppPath.PlanRequired },
|
||||
@@ -180,6 +185,7 @@ const testCases: {
|
||||
{ loc: AppPath.VerifyEmail, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails },
|
||||
{ loc: AppPath.VerifyEmail, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.APPS_INSTALLATION, res: AppPath.InstallApps },
|
||||
{ loc: AppPath.VerifyEmail, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam },
|
||||
{ loc: AppPath.VerifyEmail, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.BOOK_CALL, res: AppPath.BookCall },
|
||||
{ loc: AppPath.VerifyEmail, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: defaultHomePagePath },
|
||||
|
||||
{ loc: AppPath.WorkspaceActivation, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: AppPath.PlanRequired },
|
||||
@@ -190,6 +196,7 @@ const testCases: {
|
||||
{ loc: AppPath.WorkspaceActivation, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails },
|
||||
{ loc: AppPath.WorkspaceActivation, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.APPS_INSTALLATION, res: AppPath.InstallApps },
|
||||
{ loc: AppPath.WorkspaceActivation, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam },
|
||||
{ loc: AppPath.WorkspaceActivation, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.BOOK_CALL, res: AppPath.BookCall },
|
||||
{ loc: AppPath.WorkspaceActivation, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: defaultHomePagePath },
|
||||
|
||||
{ loc: AppPath.CreateProfile, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: AppPath.PlanRequired },
|
||||
@@ -200,6 +207,7 @@ const testCases: {
|
||||
{ loc: AppPath.CreateProfile, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails },
|
||||
{ loc: AppPath.CreateProfile, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.APPS_INSTALLATION, res: AppPath.InstallApps },
|
||||
{ loc: AppPath.CreateProfile, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam },
|
||||
{ loc: AppPath.CreateProfile, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.BOOK_CALL, res: AppPath.BookCall },
|
||||
{ loc: AppPath.CreateProfile, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: defaultHomePagePath },
|
||||
|
||||
{ loc: AppPath.SyncEmails, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: AppPath.PlanRequired },
|
||||
@@ -210,6 +218,7 @@ const testCases: {
|
||||
{ loc: AppPath.SyncEmails, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: undefined },
|
||||
{ loc: AppPath.SyncEmails, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.APPS_INSTALLATION, res: AppPath.InstallApps },
|
||||
{ loc: AppPath.SyncEmails, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam },
|
||||
{ loc: AppPath.SyncEmails, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.BOOK_CALL, res: AppPath.BookCall },
|
||||
{ loc: AppPath.SyncEmails, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: defaultHomePagePath },
|
||||
|
||||
{ loc: AppPath.InstallApps, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: AppPath.PlanRequired },
|
||||
@@ -220,6 +229,7 @@ const testCases: {
|
||||
{ loc: AppPath.InstallApps, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails },
|
||||
{ loc: AppPath.InstallApps, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.APPS_INSTALLATION, res: undefined },
|
||||
{ loc: AppPath.InstallApps, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam },
|
||||
{ loc: AppPath.InstallApps, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.BOOK_CALL, res: AppPath.BookCall },
|
||||
{ loc: AppPath.InstallApps, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: defaultHomePagePath },
|
||||
|
||||
{ loc: AppPath.InviteTeam, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: AppPath.PlanRequired },
|
||||
@@ -230,6 +240,7 @@ const testCases: {
|
||||
{ loc: AppPath.InviteTeam, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails },
|
||||
{ loc: AppPath.InviteTeam, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.APPS_INSTALLATION, res: AppPath.InstallApps },
|
||||
{ loc: AppPath.InviteTeam, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: undefined },
|
||||
{ loc: AppPath.InviteTeam, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.BOOK_CALL, res: AppPath.BookCall },
|
||||
{ loc: AppPath.InviteTeam, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: AppPath.PlanRequired },
|
||||
|
||||
{ loc: AppPath.BookCall, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: undefined },
|
||||
@@ -240,6 +251,7 @@ const testCases: {
|
||||
{ loc: AppPath.BookCall, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails },
|
||||
{ loc: AppPath.BookCall, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.APPS_INSTALLATION, res: AppPath.InstallApps },
|
||||
{ loc: AppPath.BookCall, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam },
|
||||
{ loc: AppPath.BookCall, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.BOOK_CALL, res: undefined },
|
||||
{ loc: AppPath.BookCall, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: defaultHomePagePath },
|
||||
|
||||
{ loc: AppPath.PlanRequired, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: undefined },
|
||||
@@ -250,6 +262,7 @@ const testCases: {
|
||||
{ loc: AppPath.PlanRequired, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails },
|
||||
{ loc: AppPath.PlanRequired, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.APPS_INSTALLATION, res: AppPath.InstallApps },
|
||||
{ loc: AppPath.PlanRequired, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam },
|
||||
{ loc: AppPath.PlanRequired, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.BOOK_CALL, res: AppPath.BookCall },
|
||||
{ loc: AppPath.PlanRequired, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: undefined },
|
||||
|
||||
{ loc: AppPath.PlanRequiredSuccess, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: undefined },
|
||||
@@ -260,6 +273,7 @@ const testCases: {
|
||||
{ loc: AppPath.PlanRequiredSuccess, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails },
|
||||
{ loc: AppPath.PlanRequiredSuccess, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.APPS_INSTALLATION, res: AppPath.InstallApps },
|
||||
{ loc: AppPath.PlanRequiredSuccess, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam },
|
||||
{ loc: AppPath.PlanRequiredSuccess, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.BOOK_CALL, res: AppPath.BookCall },
|
||||
{ loc: AppPath.PlanRequiredSuccess, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: defaultHomePagePath },
|
||||
|
||||
{ loc: AppPath.Index, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: AppPath.PlanRequired },
|
||||
@@ -270,6 +284,7 @@ const testCases: {
|
||||
{ loc: AppPath.Index, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails },
|
||||
{ loc: AppPath.Index, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.APPS_INSTALLATION, res: AppPath.InstallApps },
|
||||
{ loc: AppPath.Index, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam },
|
||||
{ loc: AppPath.Index, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.BOOK_CALL, res: AppPath.BookCall },
|
||||
{ loc: AppPath.Index, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: defaultHomePagePath },
|
||||
|
||||
{ loc: AppPath.TasksPage, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: AppPath.PlanRequired },
|
||||
@@ -280,6 +295,7 @@ const testCases: {
|
||||
{ loc: AppPath.TasksPage, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails },
|
||||
{ loc: AppPath.TasksPage, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.APPS_INSTALLATION, res: AppPath.InstallApps },
|
||||
{ loc: AppPath.TasksPage, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam },
|
||||
{ loc: AppPath.TasksPage, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.BOOK_CALL, res: AppPath.BookCall },
|
||||
{ loc: AppPath.TasksPage, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: undefined },
|
||||
|
||||
{ loc: AppPath.OpportunitiesPage, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: AppPath.PlanRequired },
|
||||
@@ -290,6 +306,7 @@ const testCases: {
|
||||
{ loc: AppPath.OpportunitiesPage, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails },
|
||||
{ loc: AppPath.OpportunitiesPage, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.APPS_INSTALLATION, res: AppPath.InstallApps },
|
||||
{ loc: AppPath.OpportunitiesPage, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam },
|
||||
{ loc: AppPath.OpportunitiesPage, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.BOOK_CALL, res: AppPath.BookCall },
|
||||
{ loc: AppPath.OpportunitiesPage, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: undefined },
|
||||
|
||||
{ loc: AppPath.RecordIndexPage, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: AppPath.PlanRequired },
|
||||
@@ -300,6 +317,7 @@ const testCases: {
|
||||
{ loc: AppPath.RecordIndexPage, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails },
|
||||
{ loc: AppPath.RecordIndexPage, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.APPS_INSTALLATION, res: AppPath.InstallApps },
|
||||
{ loc: AppPath.RecordIndexPage, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam },
|
||||
{ loc: AppPath.RecordIndexPage, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.BOOK_CALL, res: AppPath.BookCall },
|
||||
{ loc: AppPath.RecordIndexPage, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: undefined },
|
||||
{ loc: AppPath.RecordIndexPage, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: undefined, objectNamePluralFromParams: 'existing-object', objectNamePluralFromMetadata: 'existing-object' },
|
||||
{ loc: AppPath.RecordIndexPage, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: AppPath.NotFound, objectNamePluralFromParams: 'non-existing-object', objectNamePluralFromMetadata: 'existing-object' },
|
||||
@@ -313,6 +331,7 @@ const testCases: {
|
||||
{ loc: AppPath.RecordShowPage, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails },
|
||||
{ loc: AppPath.RecordShowPage, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.APPS_INSTALLATION, res: AppPath.InstallApps },
|
||||
{ loc: AppPath.RecordShowPage, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam },
|
||||
{ loc: AppPath.RecordShowPage, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.BOOK_CALL, res: AppPath.BookCall },
|
||||
{ loc: AppPath.RecordShowPage, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: undefined },
|
||||
|
||||
{ loc: AppPath.PageLayoutPage, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: AppPath.PlanRequired },
|
||||
@@ -323,6 +342,7 @@ const testCases: {
|
||||
{ loc: AppPath.PageLayoutPage, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails },
|
||||
{ loc: AppPath.PageLayoutPage, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.APPS_INSTALLATION, res: AppPath.InstallApps },
|
||||
{ loc: AppPath.PageLayoutPage, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam },
|
||||
{ loc: AppPath.PageLayoutPage, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.BOOK_CALL, res: AppPath.BookCall },
|
||||
{ loc: AppPath.PageLayoutPage, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: undefined },
|
||||
{ loc: AppPath.PageLayoutPage, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: undefined, pageLayoutId: 'valid-id', useQueryResult: { loading: true } },
|
||||
{ loc: AppPath.PageLayoutPage, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: AppPath.NotFound, pageLayoutId: 'non-existent-id', useQueryResult: { data: { getPageLayout: null }, loading: false } },
|
||||
@@ -337,6 +357,7 @@ const testCases: {
|
||||
{ loc: AppPath.SettingsCatchAll, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails },
|
||||
{ loc: AppPath.SettingsCatchAll, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.APPS_INSTALLATION, res: AppPath.InstallApps },
|
||||
{ loc: AppPath.SettingsCatchAll, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam },
|
||||
{ loc: AppPath.SettingsCatchAll, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.BOOK_CALL, res: AppPath.BookCall },
|
||||
{ loc: AppPath.SettingsCatchAll, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: undefined },
|
||||
|
||||
{ loc: AppPath.DevelopersCatchAll, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: AppPath.PlanRequired },
|
||||
@@ -347,6 +368,7 @@ const testCases: {
|
||||
{ loc: AppPath.DevelopersCatchAll, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails },
|
||||
{ loc: AppPath.DevelopersCatchAll, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.APPS_INSTALLATION, res: AppPath.InstallApps },
|
||||
{ loc: AppPath.DevelopersCatchAll, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam },
|
||||
{ loc: AppPath.DevelopersCatchAll, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.BOOK_CALL, res: AppPath.BookCall },
|
||||
{ loc: AppPath.DevelopersCatchAll, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: undefined },
|
||||
|
||||
{ loc: AppPath.Authorize, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: AppPath.PlanRequired },
|
||||
@@ -357,6 +379,7 @@ const testCases: {
|
||||
{ loc: AppPath.Authorize, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails },
|
||||
{ loc: AppPath.Authorize, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.APPS_INSTALLATION, res: AppPath.InstallApps },
|
||||
{ loc: AppPath.Authorize, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam },
|
||||
{ loc: AppPath.Authorize, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.BOOK_CALL, res: AppPath.BookCall },
|
||||
{ loc: AppPath.Authorize, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: undefined },
|
||||
|
||||
{ loc: AppPath.NotFoundWildcard, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: AppPath.PlanRequired },
|
||||
@@ -367,6 +390,7 @@ const testCases: {
|
||||
{ loc: AppPath.NotFoundWildcard, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails },
|
||||
{ loc: AppPath.NotFoundWildcard, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.APPS_INSTALLATION, res: AppPath.InstallApps },
|
||||
{ loc: AppPath.NotFoundWildcard, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam },
|
||||
{ loc: AppPath.NotFoundWildcard, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.BOOK_CALL, res: AppPath.BookCall },
|
||||
{ loc: AppPath.NotFoundWildcard, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: undefined },
|
||||
|
||||
{ loc: AppPath.NotFound, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: AppPath.PlanRequired },
|
||||
@@ -377,6 +401,7 @@ const testCases: {
|
||||
{ loc: AppPath.NotFound, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails },
|
||||
{ loc: AppPath.NotFound, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.APPS_INSTALLATION, res: AppPath.InstallApps },
|
||||
{ loc: AppPath.NotFound, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam },
|
||||
{ loc: AppPath.NotFound, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.BOOK_CALL, res: AppPath.BookCall },
|
||||
{ loc: AppPath.NotFound, isLogged: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: undefined },
|
||||
|
||||
// isBillingEnabled:false — no post-invite-team upgrade interception on billing-disabled instances
|
||||
|
||||
@@ -164,6 +164,13 @@ export const usePageChangeEffectNavigateLocation = () => {
|
||||
return AppPath.InviteTeam;
|
||||
}
|
||||
|
||||
if (
|
||||
onboardingStatus === OnboardingStatus.BOOK_CALL &&
|
||||
!isMatchingLocation(location, AppPath.BookCall)
|
||||
) {
|
||||
return AppPath.BookCall;
|
||||
}
|
||||
|
||||
if (isBillingEnabled && onboardingStatus === OnboardingStatus.COMPLETED) {
|
||||
if (isMatchingLocation(location, AppPath.InviteTeam)) {
|
||||
return AppPath.PlanRequired;
|
||||
|
||||
@@ -99,7 +99,7 @@ const PaymentSuccess = lazy(() =>
|
||||
})),
|
||||
);
|
||||
|
||||
const BookCall = lazy(() =>
|
||||
const BookCall = lazyWithPreload(() =>
|
||||
import('~/pages/onboarding/BookCall').then((module) => ({
|
||||
default: module.BookCall,
|
||||
})),
|
||||
@@ -129,6 +129,7 @@ const preloadOnboardingPages = () => {
|
||||
SyncEmails.preload();
|
||||
InstallApps.preload();
|
||||
InviteTeam.preload();
|
||||
BookCall.preload();
|
||||
ChooseYourPlan.preload();
|
||||
WorkspaceSetup.preload();
|
||||
|
||||
@@ -231,14 +232,6 @@ const createWorkspaceAppRouter = (
|
||||
</LazyRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path={AppPath.BookCall}
|
||||
element={
|
||||
<LazyRoute fallback={<OnboardingPageLoader />}>
|
||||
<BookCall />
|
||||
</LazyRoute>
|
||||
}
|
||||
/>
|
||||
</Route>
|
||||
<Route element={<BlankLayout />}>
|
||||
<Route
|
||||
@@ -312,6 +305,14 @@ const createWorkspaceAppRouter = (
|
||||
</LazyRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path={AppPath.BookCall}
|
||||
element={
|
||||
<LazyRoute fallback={<OnboardingStepPageLoader />}>
|
||||
<BookCall />
|
||||
</LazyRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path={AppPath.PlanRequired}
|
||||
element={
|
||||
|
||||
@@ -7,6 +7,8 @@ import { billingState } from '@/client-config/states/billingState';
|
||||
import { calendarBookingPageIdState } from '@/client-config/states/calendarBookingPageIdState';
|
||||
import { canManageFeatureFlagsState } from '@/client-config/states/canManageFeatureFlagsState';
|
||||
import { captchaState } from '@/client-config/states/captchaState';
|
||||
import { isBookCallOnboardingStepEnabledState } from '@/client-config/states/isBookCallOnboardingStepEnabledState';
|
||||
import { isCompanyEnrichmentEnabledState } from '@/client-config/states/isCompanyEnrichmentEnabledState';
|
||||
import { isAnalyticsEnabledState } from '@/client-config/states/isAnalyticsEnabledState';
|
||||
import { isAttachmentPreviewEnabledState } from '@/client-config/states/isAttachmentPreviewEnabledState';
|
||||
import { isConfigVariablesInDbEnabledState } from '@/client-config/states/isConfigVariablesInDbEnabledState';
|
||||
@@ -109,6 +111,14 @@ export const useClientConfig = (): UseClientConfigResult => {
|
||||
|
||||
const setCalendarBookingPageId = useSetAtomState(calendarBookingPageIdState);
|
||||
|
||||
const setIsBookCallOnboardingStepEnabled = useSetAtomState(
|
||||
isBookCallOnboardingStepEnabledState,
|
||||
);
|
||||
|
||||
const setIsCompanyEnrichmentEnabled = useSetAtomState(
|
||||
isCompanyEnrichmentEnabledState,
|
||||
);
|
||||
|
||||
const setIsEmailingDomainInDemoMode = useSetAtomState(
|
||||
isEmailingDomainInDemoModeState,
|
||||
);
|
||||
@@ -215,6 +225,12 @@ export const useClientConfig = (): UseClientConfigResult => {
|
||||
}));
|
||||
|
||||
setCalendarBookingPageId(clientConfig?.calendarBookingPageId ?? null);
|
||||
setIsBookCallOnboardingStepEnabled(
|
||||
clientConfig?.isBookCallOnboardingStepEnabled ?? false,
|
||||
);
|
||||
setIsCompanyEnrichmentEnabled(
|
||||
clientConfig?.isCompanyEnrichmentEnabled ?? false,
|
||||
);
|
||||
setIsImapSmtpCaldavEnabled(clientConfig?.isImapSmtpCaldavEnabled);
|
||||
setIsEmailingDomainInDemoMode(
|
||||
clientConfig?.isEmailingDomainInDemoMode ?? false,
|
||||
@@ -260,6 +276,8 @@ export const useClientConfig = (): UseClientConfigResult => {
|
||||
setIsGoogleMessagingEnabled,
|
||||
setIsAnalyticsEnabled,
|
||||
setIsAttachmentPreviewEnabled,
|
||||
setIsBookCallOnboardingStepEnabled,
|
||||
setIsCompanyEnrichmentEnabled,
|
||||
setIsConfigVariablesInDbEnabled,
|
||||
setIsDeveloperDefaultSignInPrefilled,
|
||||
setIsEmailVerificationRequired,
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
|
||||
|
||||
export const isBookCallOnboardingStepEnabledState = createAtomState<boolean>({
|
||||
key: 'isBookCallOnboardingStepEnabledState',
|
||||
defaultValue: false,
|
||||
});
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
|
||||
|
||||
export const isCompanyEnrichmentEnabledState = createAtomState<boolean>({
|
||||
key: 'isCompanyEnrichmentEnabledState',
|
||||
defaultValue: false,
|
||||
});
|
||||
@@ -20,6 +20,8 @@ export type ClientConfig = {
|
||||
authProviders: AuthProviders;
|
||||
billing: Billing;
|
||||
calendarBookingPageId?: string;
|
||||
isBookCallOnboardingStepEnabled: boolean;
|
||||
isCompanyEnrichmentEnabled: boolean;
|
||||
canManageFeatureFlags: boolean;
|
||||
captcha: Captcha;
|
||||
defaultSubdomain?: string;
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import Cal from '@calcom/embed-react';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { currentUserState } from '@/auth/states/currentUserState';
|
||||
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
|
||||
import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { getAttendeeName } from '@/onboarding/utils/getAttendeeName';
|
||||
import { useThemeColorScheme } from 'twenty-ui/theme-constants';
|
||||
import { useIsMobile } from 'twenty-ui/utilities';
|
||||
|
||||
type BookCallEmbedProps = {
|
||||
calendarBookingPageId: string;
|
||||
};
|
||||
|
||||
export const BookCallEmbed = ({
|
||||
calendarBookingPageId,
|
||||
}: BookCallEmbedProps) => {
|
||||
const colorScheme = useThemeColorScheme();
|
||||
const currentUser = useAtomStateValue(currentUserState);
|
||||
const currentWorkspaceMember = useAtomStateValue(currentWorkspaceMemberState);
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const attendeeName = isDefined(currentWorkspaceMember?.name)
|
||||
? getAttendeeName(currentWorkspaceMember.name)
|
||||
: getAttendeeName(currentUser);
|
||||
|
||||
return (
|
||||
<ScrollWrapper
|
||||
componentInstanceId="scroll-wrapper-book-call"
|
||||
autoHeight={!isMobile}
|
||||
>
|
||||
<Cal
|
||||
calLink={calendarBookingPageId}
|
||||
config={{
|
||||
layout: 'month_view',
|
||||
theme: colorScheme,
|
||||
email: currentUser?.email ?? '',
|
||||
name: attendeeName,
|
||||
}}
|
||||
/>
|
||||
</ScrollWrapper>
|
||||
);
|
||||
};
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
import { CombinedGraphQLErrors } from '@apollo/client/errors';
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import { OnboardingSkipButton } from '@/onboarding/components/OnboardingSkipButton';
|
||||
import { BookCallBookingSuccessEffect } from '@/onboarding/effect-components/BookCallBookingSuccessEffect';
|
||||
import { useCompleteBookCallOnboardingStep } from '@/onboarding/hooks/useCompleteBookCallOnboardingStep';
|
||||
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||
|
||||
export const BookCallOnboardingStepActions = () => {
|
||||
const { enqueueErrorSnackBar } = useSnackBar();
|
||||
const completeBookCallOnboardingStep = useCompleteBookCallOnboardingStep();
|
||||
const [isCompleting, setIsCompleting] = useState(false);
|
||||
|
||||
// Kept stable so BookCallBookingSuccessEffect subscribes to the embed once
|
||||
// instead of cycling its listener on every render.
|
||||
const completeStep = useCallback(async () => {
|
||||
setIsCompleting(true);
|
||||
|
||||
try {
|
||||
await completeBookCallOnboardingStep();
|
||||
} catch (error) {
|
||||
setIsCompleting(false);
|
||||
|
||||
enqueueErrorSnackBar({
|
||||
apolloError: CombinedGraphQLErrors.is(error) ? error : undefined,
|
||||
});
|
||||
}
|
||||
}, [completeBookCallOnboardingStep, enqueueErrorSnackBar]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<BookCallBookingSuccessEffect onBookingSuccessful={completeStep} />
|
||||
<OnboardingSkipButton
|
||||
onClick={() => void completeStep()}
|
||||
disabled={isCompleting}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
import { onboardingConfigState } from '@/client-config/states/onboardingConfigState';
|
||||
import { OnboardingLayout } from '@/onboarding/components/OnboardingLayout';
|
||||
import { OnboardingTransitionOutlet } from '@/onboarding/components/OnboardingTransitionOutlet';
|
||||
import { PrefetchBookCallStepEffect } from '@/onboarding/effect-components/PrefetchBookCallStepEffect';
|
||||
import { PrefetchPlanRequiredStepEffect } from '@/onboarding/effect-components/PrefetchPlanRequiredStepEffect';
|
||||
import { useOnboardingFreeCreditsTotal } from '@/onboarding/hooks/useOnboardingFreeCreditsTotal';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
@@ -14,6 +15,7 @@ export const OnboardingStepLayout = () => {
|
||||
<OnboardingLayout
|
||||
freeCredits={isDefined(onboardingConfig) ? freeCreditsTotal : undefined}
|
||||
>
|
||||
<PrefetchBookCallStepEffect />
|
||||
<PrefetchPlanRequiredStepEffect />
|
||||
<OnboardingTransitionOutlet />
|
||||
</OnboardingLayout>
|
||||
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
import { render } from '@testing-library/react';
|
||||
import { Provider as JotaiProvider } from 'jotai';
|
||||
|
||||
import { currentUserState } from '@/auth/states/currentUserState';
|
||||
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
|
||||
import { BookCallEmbed } from '@/onboarding/components/BookCallEmbed';
|
||||
import {
|
||||
jotaiStore,
|
||||
resetJotaiStore,
|
||||
} from '@/ui/utilities/state/jotai/jotaiStore';
|
||||
|
||||
const mockCalConfig = jest.fn();
|
||||
|
||||
jest.mock('@calcom/embed-react', () => ({
|
||||
__esModule: true,
|
||||
default: (props: { config?: unknown }) => {
|
||||
mockCalConfig(props.config);
|
||||
|
||||
return null;
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('@/ui/utilities/scroll/components/ScrollWrapper', () => ({
|
||||
ScrollWrapper: ({ children }: { children: React.ReactNode }) => children,
|
||||
}));
|
||||
|
||||
const renderEmbed = ({
|
||||
userName,
|
||||
workspaceMemberName,
|
||||
}: {
|
||||
userName?: { firstName: string; lastName: string };
|
||||
workspaceMemberName?: { firstName: string; lastName: string };
|
||||
}) => {
|
||||
jotaiStore.set(currentUserState.atom, {
|
||||
id: 'user-id',
|
||||
email: 'raphael@acme.com',
|
||||
firstName: userName?.firstName ?? '',
|
||||
lastName: userName?.lastName ?? '',
|
||||
} as never);
|
||||
|
||||
if (workspaceMemberName) {
|
||||
jotaiStore.set(currentWorkspaceMemberState.atom, {
|
||||
id: 'workspace-member-id',
|
||||
name: workspaceMemberName,
|
||||
} as never);
|
||||
}
|
||||
|
||||
render(
|
||||
<JotaiProvider store={jotaiStore}>
|
||||
<BookCallEmbed calendarBookingPageId="team/twenty/talk-to-us" />
|
||||
</JotaiProvider>,
|
||||
);
|
||||
|
||||
return mockCalConfig.mock.calls.at(-1)?.[0] as { name: string };
|
||||
};
|
||||
|
||||
describe('BookCallEmbed', () => {
|
||||
beforeEach(() => {
|
||||
resetJotaiStore();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should prefill the name entered at the profile step', () => {
|
||||
const config = renderEmbed({
|
||||
workspaceMemberName: { firstName: 'Raphael', lastName: 'Bosi' },
|
||||
});
|
||||
|
||||
expect(config.name).toBe('Raphael Bosi');
|
||||
});
|
||||
|
||||
it('should fall back to the sign-up name when there is no workspace member yet', () => {
|
||||
const config = renderEmbed({
|
||||
userName: { firstName: 'Raphael', lastName: 'Bosi' },
|
||||
});
|
||||
|
||||
expect(config.name).toBe('Raphael Bosi');
|
||||
});
|
||||
|
||||
it('should not emit a stray separator when only one name part is set', () => {
|
||||
const config = renderEmbed({
|
||||
workspaceMemberName: { firstName: 'Raphael', lastName: '' },
|
||||
});
|
||||
|
||||
expect(config.name).toBe('Raphael');
|
||||
});
|
||||
|
||||
it('should not mix the workspace member name with the sign-up name', () => {
|
||||
const config = renderEmbed({
|
||||
userName: { firstName: 'Raphael', lastName: 'Bosi' },
|
||||
workspaceMemberName: { firstName: 'Raph', lastName: '' },
|
||||
});
|
||||
|
||||
expect(config.name).toBe('Raph');
|
||||
});
|
||||
|
||||
it('should send an empty name rather than a blank string when nothing is known', () => {
|
||||
const config = renderEmbed({});
|
||||
|
||||
expect(config.name).toBe('');
|
||||
});
|
||||
});
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
import { i18n } from '@lingui/core';
|
||||
import { I18nProvider } from '@lingui/react';
|
||||
import { act, render, screen } from '@testing-library/react';
|
||||
import { SOURCE_LOCALE } from 'twenty-shared/translations';
|
||||
import { dynamicActivate } from '~/utils/i18n/dynamicActivate';
|
||||
|
||||
import { BookCallOnboardingStepActions } from '@/onboarding/components/BookCallOnboardingStepActions';
|
||||
|
||||
const mockCompleteBookCallOnboardingStep = jest.fn();
|
||||
const mockEnqueueErrorSnackBar = jest.fn();
|
||||
const mockCalApi = jest.fn();
|
||||
|
||||
jest.mock('@calcom/embed-react', () => ({
|
||||
getCalApi: () => Promise.resolve(mockCalApi),
|
||||
}));
|
||||
|
||||
jest.mock('@/onboarding/hooks/useCompleteBookCallOnboardingStep', () => ({
|
||||
useCompleteBookCallOnboardingStep: () => mockCompleteBookCallOnboardingStep,
|
||||
}));
|
||||
|
||||
jest.mock('@/ui/feedback/snack-bar-manager/hooks/useSnackBar', () => ({
|
||||
useSnackBar: () => ({ enqueueErrorSnackBar: mockEnqueueErrorSnackBar }),
|
||||
}));
|
||||
|
||||
dynamicActivate(SOURCE_LOCALE);
|
||||
|
||||
const getSubscriptionCalls = () =>
|
||||
mockCalApi.mock.calls.filter(([action]) => action === 'on');
|
||||
|
||||
const emitBookingSuccessful = () => {
|
||||
const [, subscription] = getSubscriptionCalls().at(-1) ?? [];
|
||||
|
||||
(subscription as { callback: () => void }).callback();
|
||||
};
|
||||
|
||||
const renderActions = async () => {
|
||||
const view = render(
|
||||
<I18nProvider i18n={i18n}>
|
||||
<BookCallOnboardingStepActions />
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
return { view, skipButton: screen.getByRole('button') };
|
||||
};
|
||||
|
||||
describe('BookCallOnboardingStepActions', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockCompleteBookCallOnboardingStep.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it('should complete the step when a booking succeeds', async () => {
|
||||
await renderActions();
|
||||
|
||||
await act(async () => {
|
||||
emitBookingSuccessful();
|
||||
});
|
||||
|
||||
expect(mockCompleteBookCallOnboardingStep).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should complete the step when skipping', async () => {
|
||||
const { skipButton } = await renderActions();
|
||||
|
||||
await act(async () => {
|
||||
skipButton.click();
|
||||
});
|
||||
|
||||
expect(mockCompleteBookCallOnboardingStep).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should disable skipping while the booking completion is in flight', async () => {
|
||||
let resolveCompletion: () => void = () => {};
|
||||
|
||||
mockCompleteBookCallOnboardingStep.mockReturnValue(
|
||||
new Promise<void>((resolve) => {
|
||||
resolveCompletion = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
const { skipButton } = await renderActions();
|
||||
|
||||
expect(skipButton).not.toBeDisabled();
|
||||
|
||||
act(() => {
|
||||
emitBookingSuccessful();
|
||||
});
|
||||
|
||||
expect(skipButton).toBeDisabled();
|
||||
|
||||
await act(async () => {
|
||||
resolveCompletion();
|
||||
});
|
||||
});
|
||||
|
||||
it('should re-enable skipping and report the failure when the completion fails', async () => {
|
||||
mockCompleteBookCallOnboardingStep.mockRejectedValueOnce(
|
||||
new Error('network error'),
|
||||
);
|
||||
|
||||
const { skipButton } = await renderActions();
|
||||
|
||||
await act(async () => {
|
||||
emitBookingSuccessful();
|
||||
});
|
||||
|
||||
expect(skipButton).not.toBeDisabled();
|
||||
expect(mockEnqueueErrorSnackBar).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should complete the step once even when the embed emits repeatedly', async () => {
|
||||
mockCompleteBookCallOnboardingStep.mockRejectedValue(
|
||||
new Error('network error'),
|
||||
);
|
||||
|
||||
await renderActions();
|
||||
|
||||
await act(async () => {
|
||||
emitBookingSuccessful();
|
||||
});
|
||||
await act(async () => {
|
||||
emitBookingSuccessful();
|
||||
});
|
||||
|
||||
expect(mockCompleteBookCallOnboardingStep).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should subscribe to the embed once across re-renders', async () => {
|
||||
const { view } = await renderActions();
|
||||
|
||||
await act(async () => {
|
||||
emitBookingSuccessful();
|
||||
});
|
||||
|
||||
view.rerender(
|
||||
<I18nProvider i18n={i18n}>
|
||||
<BookCallOnboardingStepActions />
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(getSubscriptionCalls()).toHaveLength(1);
|
||||
expect(mockCalApi).not.toHaveBeenCalledWith('off', expect.anything());
|
||||
});
|
||||
});
|
||||
+1
@@ -0,0 +1 @@
|
||||
export const COMPANY_ENRICHMENT_SETTLEMENT_TIMEOUT_MS = 2500;
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
export const ONBOARDING_BOOK_CALL_PENDING_USER_VAR_KEY =
|
||||
'ONBOARDING_BOOK_CALL_PENDING';
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
import { getCalApi } from '@calcom/embed-react';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
type BookCallBookingSuccessEffectProps = {
|
||||
onBookingSuccessful: () => void;
|
||||
};
|
||||
|
||||
export const BookCallBookingSuccessEffect = ({
|
||||
onBookingSuccessful,
|
||||
}: BookCallBookingSuccessEffectProps) => {
|
||||
useEffect(() => {
|
||||
let isSubscribed = true;
|
||||
let hasHandledBookingSuccess = false;
|
||||
let calApi: Awaited<ReturnType<typeof getCalApi>> | undefined;
|
||||
|
||||
const handleBookingSuccessful = () => {
|
||||
if (hasHandledBookingSuccess) {
|
||||
return;
|
||||
}
|
||||
|
||||
hasHandledBookingSuccess = true;
|
||||
onBookingSuccessful();
|
||||
};
|
||||
|
||||
const subscribeToBookingSuccess = async () => {
|
||||
const api = await getCalApi();
|
||||
|
||||
if (!isSubscribed) {
|
||||
return;
|
||||
}
|
||||
|
||||
calApi = api;
|
||||
api('on', {
|
||||
action: 'bookingSuccessfulV2',
|
||||
callback: handleBookingSuccessful,
|
||||
});
|
||||
};
|
||||
|
||||
void subscribeToBookingSuccess();
|
||||
|
||||
return () => {
|
||||
isSubscribed = false;
|
||||
calApi?.('off', {
|
||||
action: 'bookingSuccessfulV2',
|
||||
callback: handleBookingSuccessful,
|
||||
});
|
||||
};
|
||||
}, [onBookingSuccessful]);
|
||||
|
||||
return null;
|
||||
};
|
||||
+55
-12
@@ -1,17 +1,22 @@
|
||||
import { useMutation } from '@apollo/client/react';
|
||||
import { useStore } from 'jotai';
|
||||
import { useEffect } from 'react';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { type WorkspaceCompanyEnrichment } from 'twenty-shared/workspace';
|
||||
|
||||
import { isOnboardingAiChatEnabledState } from '@/client-config/states/isOnboardingAiChatEnabledState';
|
||||
import { currentUserState } from '@/auth/states/currentUserState';
|
||||
import { isCompanyEnrichmentEnabledState } from '@/client-config/states/isCompanyEnrichmentEnabledState';
|
||||
import { useOnboardingStatus } from '@/onboarding/hooks/useOnboardingStatus';
|
||||
import { companyEnrichmentState } from '@/onboarding/states/companyEnrichmentState';
|
||||
import { hasAttemptedCompanyEnrichmentFetchState } from '@/onboarding/states/hasAttemptedCompanyEnrichmentFetchState';
|
||||
import { isCompanyEnrichmentFetchInFlightState } from '@/onboarding/states/isCompanyEnrichmentFetchInFlightState';
|
||||
import { getHasAdvancedPastBookCallStep } from '@/onboarding/utils/getHasAdvancedPastBookCallStep';
|
||||
import { setIsBookCallOnboardingStepPending } from '@/onboarding/utils/setIsBookCallOnboardingStepPending';
|
||||
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
|
||||
import {
|
||||
CompleteBookCallOnboardingStepDocument,
|
||||
EnrichWorkspaceCompanyDocument,
|
||||
OnboardingStatus,
|
||||
WorkspaceCompanyEnrichmentOutcome,
|
||||
@@ -27,12 +32,17 @@ export const CompanyEnrichmentOnboardingEffect = () => {
|
||||
setHasAttemptedCompanyEnrichmentFetch,
|
||||
] = useAtomState(hasAttemptedCompanyEnrichmentFetchState);
|
||||
const [enrichWorkspaceCompany] = useMutation(EnrichWorkspaceCompanyDocument);
|
||||
const [completeBookCallOnboardingStep] = useMutation(
|
||||
CompleteBookCallOnboardingStepDocument,
|
||||
);
|
||||
const setIsCompanyEnrichmentFetchInFlight = useSetAtomState(
|
||||
isCompanyEnrichmentFetchInFlightState,
|
||||
);
|
||||
const isOnboardingAiChatEnabled = useAtomStateValue(
|
||||
isOnboardingAiChatEnabledState,
|
||||
const setCurrentUser = useSetAtomState(currentUserState);
|
||||
const isCompanyEnrichmentEnabled = useAtomStateValue(
|
||||
isCompanyEnrichmentEnabledState,
|
||||
);
|
||||
const store = useStore();
|
||||
|
||||
const isOnboardingInProgress =
|
||||
isDefined(onboardingStatus) &&
|
||||
@@ -44,31 +54,61 @@ export const CompanyEnrichmentOnboardingEffect = () => {
|
||||
hasAttemptedCompanyEnrichmentFetch ||
|
||||
isDefined(companyEnrichment) ||
|
||||
!isOnboardingInProgress ||
|
||||
!isOnboardingAiChatEnabled
|
||||
!isCompanyEnrichmentEnabled
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
setHasAttemptedCompanyEnrichmentFetch(true);
|
||||
setIsCompanyEnrichmentFetchInFlight(true);
|
||||
setHasAttemptedCompanyEnrichmentFetch(true);
|
||||
|
||||
const fetchCompanyEnrichment = async () => {
|
||||
try {
|
||||
const { data } = await enrichWorkspaceCompany();
|
||||
const result = data?.enrichWorkspaceCompany;
|
||||
|
||||
if (result?.outcome !== WorkspaceCompanyEnrichmentOutcome.matched) {
|
||||
if (!isDefined(result)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// A response that lands after the settlement timeout must not reopen a
|
||||
// step the user already moved past, so the server is told to drop it.
|
||||
const hasAdvancedPastBookCallStep = getHasAdvancedPastBookCallStep(
|
||||
store.get(currentUserState.atom)?.onboardingStatus,
|
||||
);
|
||||
|
||||
const dropBookCallStep = async () => {
|
||||
try {
|
||||
await completeBookCallOnboardingStep();
|
||||
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// The local flag mirrors what the server ended up with: a failed drop
|
||||
// leaves the step pending on both sides instead of silently diverging.
|
||||
const hasDroppedBookCallStep =
|
||||
result.isBookCallOnboardingStepPending && hasAdvancedPastBookCallStep
|
||||
? await dropBookCallStep()
|
||||
: false;
|
||||
|
||||
setCurrentUser((current) =>
|
||||
setIsBookCallOnboardingStepPending(
|
||||
current,
|
||||
result.isBookCallOnboardingStepPending && !hasDroppedBookCallStep,
|
||||
),
|
||||
);
|
||||
|
||||
const enrichment: WorkspaceCompanyEnrichment | null =
|
||||
result.enrichment ?? null;
|
||||
result.outcome === WorkspaceCompanyEnrichmentOutcome.matched
|
||||
? (result.enrichment ?? null)
|
||||
: null;
|
||||
|
||||
if (!isDefined(enrichment)) {
|
||||
return;
|
||||
if (isDefined(enrichment)) {
|
||||
setCompanyEnrichment(enrichment);
|
||||
}
|
||||
|
||||
setCompanyEnrichment(enrichment);
|
||||
} catch {
|
||||
return;
|
||||
} finally {
|
||||
@@ -81,11 +121,14 @@ export const CompanyEnrichmentOnboardingEffect = () => {
|
||||
hasAttemptedCompanyEnrichmentFetch,
|
||||
companyEnrichment,
|
||||
isOnboardingInProgress,
|
||||
isOnboardingAiChatEnabled,
|
||||
isCompanyEnrichmentEnabled,
|
||||
setHasAttemptedCompanyEnrichmentFetch,
|
||||
setIsCompanyEnrichmentFetchInFlight,
|
||||
setCompanyEnrichment,
|
||||
setCurrentUser,
|
||||
store,
|
||||
enrichWorkspaceCompany,
|
||||
completeBookCallOnboardingStep,
|
||||
]);
|
||||
|
||||
return null;
|
||||
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
import { usePreloadCalForBookCallStep } from '@/onboarding/hooks/usePreloadCalForBookCallStep';
|
||||
|
||||
export const PrefetchBookCallStepEffect = () => {
|
||||
usePreloadCalForBookCallStep();
|
||||
|
||||
return null;
|
||||
};
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
import { act, render } from '@testing-library/react';
|
||||
|
||||
import { BookCallBookingSuccessEffect } from '@/onboarding/effect-components/BookCallBookingSuccessEffect';
|
||||
|
||||
const mockCalApi = jest.fn();
|
||||
|
||||
jest.mock('@calcom/embed-react', () => ({
|
||||
getCalApi: () => Promise.resolve(mockCalApi),
|
||||
}));
|
||||
|
||||
const renderEffect = async (onBookingSuccessful: () => void) => {
|
||||
const view = render(
|
||||
<BookCallBookingSuccessEffect onBookingSuccessful={onBookingSuccessful} />,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const subscription = mockCalApi.mock.calls.find(
|
||||
([action]) => action === 'on',
|
||||
);
|
||||
|
||||
return {
|
||||
view,
|
||||
emitBookingSuccessful: subscription?.[1].callback as () => void,
|
||||
};
|
||||
};
|
||||
|
||||
describe('BookCallBookingSuccessEffect', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should subscribe to the booking success event', async () => {
|
||||
const onBookingSuccessful = jest.fn();
|
||||
|
||||
const { emitBookingSuccessful } = await renderEffect(onBookingSuccessful);
|
||||
|
||||
expect(emitBookingSuccessful).toBeDefined();
|
||||
});
|
||||
|
||||
it('should notify once even when the embed emits repeatedly', async () => {
|
||||
const onBookingSuccessful = jest.fn();
|
||||
|
||||
const { emitBookingSuccessful } = await renderEffect(onBookingSuccessful);
|
||||
|
||||
act(() => {
|
||||
emitBookingSuccessful();
|
||||
emitBookingSuccessful();
|
||||
});
|
||||
|
||||
expect(onBookingSuccessful).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should unsubscribe on unmount so listeners cannot stack up', async () => {
|
||||
const onBookingSuccessful = jest.fn();
|
||||
|
||||
const { view } = await renderEffect(onBookingSuccessful);
|
||||
|
||||
view.unmount();
|
||||
|
||||
expect(mockCalApi).toHaveBeenCalledWith(
|
||||
'off',
|
||||
expect.objectContaining({ action: 'bookingSuccessfulV2' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
+177
-22
@@ -4,15 +4,19 @@ import { GraphQLError } from 'graphql';
|
||||
import { Provider as JotaiProvider } from 'jotai';
|
||||
import { type WorkspaceCompanyEnrichment } from 'twenty-shared/workspace';
|
||||
|
||||
import { isOnboardingAiChatEnabledState } from '@/client-config/states/isOnboardingAiChatEnabledState';
|
||||
import { currentUserState } from '@/auth/states/currentUserState';
|
||||
import { isCompanyEnrichmentEnabledState } from '@/client-config/states/isCompanyEnrichmentEnabledState';
|
||||
import { CompanyEnrichmentOnboardingEffect } from '@/onboarding/effect-components/CompanyEnrichmentOnboardingEffect';
|
||||
import { companyEnrichmentState } from '@/onboarding/states/companyEnrichmentState';
|
||||
import { hasAttemptedCompanyEnrichmentFetchState } from '@/onboarding/states/hasAttemptedCompanyEnrichmentFetchState';
|
||||
import { getIsBookCallOnboardingStepPending } from '@/onboarding/utils/getIsBookCallOnboardingStepPending';
|
||||
import { waitForCompanyEnrichmentSettlement } from '@/onboarding/utils/waitForCompanyEnrichmentSettlement';
|
||||
import {
|
||||
jotaiStore,
|
||||
resetJotaiStore,
|
||||
} from '@/ui/utilities/state/jotai/jotaiStore';
|
||||
import {
|
||||
CompleteBookCallOnboardingStepDocument,
|
||||
EnrichWorkspaceCompanyDocument,
|
||||
OnboardingStatus,
|
||||
} from '~/generated-metadata/graphql';
|
||||
@@ -44,10 +48,12 @@ const buildEnrichMock = ({
|
||||
outcome,
|
||||
enrichmentPayload,
|
||||
countCall,
|
||||
isBookCallOnboardingStepPending = false,
|
||||
}: {
|
||||
outcome: string;
|
||||
enrichmentPayload: WorkspaceCompanyEnrichment | null;
|
||||
countCall: () => void;
|
||||
isBookCallOnboardingStepPending?: boolean;
|
||||
}) => ({
|
||||
request: { query: EnrichWorkspaceCompanyDocument },
|
||||
result: () => {
|
||||
@@ -59,6 +65,7 @@ const buildEnrichMock = ({
|
||||
__typename: 'WorkspaceCompanyEnrichmentResult',
|
||||
outcome,
|
||||
enrichment: enrichmentPayload,
|
||||
isBookCallOnboardingStepPending,
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -84,8 +91,32 @@ describe('CompanyEnrichmentOnboardingEffect', () => {
|
||||
beforeEach(() => {
|
||||
resetJotaiStore();
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
mockOnboardingStatus.mockReturnValue(OnboardingStatus.PROFILE_CREATION);
|
||||
jotaiStore.set(isOnboardingAiChatEnabledState.atom, true);
|
||||
jotaiStore.set(isCompanyEnrichmentEnabledState.atom, true);
|
||||
});
|
||||
|
||||
it('does not fetch when enrichment has no consumer or api key', async () => {
|
||||
jotaiStore.set(isCompanyEnrichmentEnabledState.atom, false);
|
||||
|
||||
let callCount = 0;
|
||||
renderEffect([
|
||||
buildEnrichMock({
|
||||
outcome: 'matched',
|
||||
enrichmentPayload: enrichment,
|
||||
countCall: () => {
|
||||
callCount += 1;
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
await flushMutation();
|
||||
|
||||
expect(callCount).toBe(0);
|
||||
expect(jotaiStore.get(companyEnrichmentState.atom)).toBeNull();
|
||||
expect(jotaiStore.get(hasAttemptedCompanyEnrichmentFetchState.atom)).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -128,6 +159,38 @@ describe('CompanyEnrichmentOnboardingEffect', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('stays unsettled for a waiter while the fetch is in flight', async () => {
|
||||
let hasSettled = false;
|
||||
|
||||
void waitForCompanyEnrichmentSettlement({
|
||||
store: jotaiStore,
|
||||
timeoutMs: 10_000,
|
||||
}).then(() => {
|
||||
hasSettled = true;
|
||||
});
|
||||
|
||||
renderEffect([
|
||||
buildEnrichMock({
|
||||
outcome: 'matched',
|
||||
enrichmentPayload: enrichment,
|
||||
countCall: () => {},
|
||||
}),
|
||||
]);
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(jotaiStore.get(hasAttemptedCompanyEnrichmentFetchState.atom)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(hasSettled).toBe(false);
|
||||
|
||||
await flushMutation();
|
||||
|
||||
expect(hasSettled).toBe(true);
|
||||
});
|
||||
|
||||
it.each([OnboardingStatus.COMPLETED, OnboardingStatus.WORKSPACE_ACTIVATION])(
|
||||
'does not fetch when the onboarding status is %s',
|
||||
async (status) => {
|
||||
@@ -151,26 +214,6 @@ describe('CompanyEnrichmentOnboardingEffect', () => {
|
||||
},
|
||||
);
|
||||
|
||||
it('does not fetch when onboarding AI chat is disabled', async () => {
|
||||
jotaiStore.set(isOnboardingAiChatEnabledState.atom, false);
|
||||
|
||||
let callCount = 0;
|
||||
renderEffect([
|
||||
buildEnrichMock({
|
||||
outcome: 'matched',
|
||||
enrichmentPayload: enrichment,
|
||||
countCall: () => {
|
||||
callCount += 1;
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
await flushMutation();
|
||||
|
||||
expect(callCount).toBe(0);
|
||||
expect(jotaiStore.get(companyEnrichmentState.atom)).toBeNull();
|
||||
});
|
||||
|
||||
it('does not fetch when an enrichment is already stored', async () => {
|
||||
jotaiStore.set(companyEnrichmentState.atom, enrichment);
|
||||
|
||||
@@ -214,6 +257,118 @@ describe('CompanyEnrichmentOnboardingEffect', () => {
|
||||
},
|
||||
);
|
||||
|
||||
it('records the pending book-call step reported by the server', async () => {
|
||||
jotaiStore.set(currentUserState.atom, { id: 'user-id' } as never);
|
||||
|
||||
renderEffect([
|
||||
buildEnrichMock({
|
||||
outcome: 'matched',
|
||||
enrichmentPayload: enrichment,
|
||||
countCall: () => {},
|
||||
isBookCallOnboardingStepPending: true,
|
||||
}),
|
||||
]);
|
||||
|
||||
await flushMutation();
|
||||
|
||||
expect(
|
||||
getIsBookCallOnboardingStepPending(jotaiStore.get(currentUserState.atom)),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('records the pending book-call step even when the enrichment did not match', async () => {
|
||||
jotaiStore.set(currentUserState.atom, { id: 'user-id' } as never);
|
||||
|
||||
renderEffect([
|
||||
buildEnrichMock({
|
||||
outcome: 'transientError',
|
||||
enrichmentPayload: null,
|
||||
countCall: () => {},
|
||||
isBookCallOnboardingStepPending: true,
|
||||
}),
|
||||
]);
|
||||
|
||||
await flushMutation();
|
||||
|
||||
expect(
|
||||
getIsBookCallOnboardingStepPending(jotaiStore.get(currentUserState.atom)),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('drops a pending book-call step that lands after the user advanced past it', async () => {
|
||||
mockOnboardingStatus.mockReturnValue(OnboardingStatus.PROFILE_CREATION);
|
||||
jotaiStore.set(currentUserState.atom, {
|
||||
id: 'user-id',
|
||||
onboardingStatus: OnboardingStatus.PLAN_REQUIRED,
|
||||
} as never);
|
||||
|
||||
let hasClearedBookCallStep = false;
|
||||
|
||||
renderEffect([
|
||||
buildEnrichMock({
|
||||
outcome: 'matched',
|
||||
enrichmentPayload: enrichment,
|
||||
countCall: () => {},
|
||||
isBookCallOnboardingStepPending: true,
|
||||
}),
|
||||
{
|
||||
request: { query: CompleteBookCallOnboardingStepDocument },
|
||||
result: () => {
|
||||
hasClearedBookCallStep = true;
|
||||
|
||||
return {
|
||||
data: {
|
||||
completeBookCallOnboardingStep: {
|
||||
__typename: 'OnboardingStepSuccess',
|
||||
success: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
await flushMutation();
|
||||
|
||||
expect(
|
||||
getIsBookCallOnboardingStepPending(jotaiStore.get(currentUserState.atom)),
|
||||
).toBe(false);
|
||||
expect(hasClearedBookCallStep).toBe(true);
|
||||
expect(jotaiStore.get(companyEnrichmentState.atom)).toMatchObject({
|
||||
domain: 'acme.com',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the step pending locally when clearing it on the server fails', async () => {
|
||||
jotaiStore.set(currentUserState.atom, {
|
||||
id: 'user-id',
|
||||
onboardingStatus: OnboardingStatus.PLAN_REQUIRED,
|
||||
} as never);
|
||||
|
||||
renderEffect([
|
||||
buildEnrichMock({
|
||||
outcome: 'matched',
|
||||
enrichmentPayload: enrichment,
|
||||
countCall: () => {},
|
||||
isBookCallOnboardingStepPending: true,
|
||||
}),
|
||||
{
|
||||
request: { query: CompleteBookCallOnboardingStepDocument },
|
||||
result: { errors: [new GraphQLError('Internal server error')] },
|
||||
},
|
||||
]);
|
||||
|
||||
await flushMutation();
|
||||
|
||||
// The server still has the offer, so local state must not claim otherwise.
|
||||
expect(
|
||||
getIsBookCallOnboardingStepPending(jotaiStore.get(currentUserState.atom)),
|
||||
).toBe(true);
|
||||
expect(jotaiStore.get(companyEnrichmentState.atom)).toMatchObject({
|
||||
domain: 'acme.com',
|
||||
});
|
||||
});
|
||||
|
||||
it('stores nothing when the mutation fails', async () => {
|
||||
renderEffect([
|
||||
{
|
||||
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
import { act, render } from '@testing-library/react';
|
||||
import { Provider as JotaiProvider } from 'jotai';
|
||||
|
||||
import { currentUserState } from '@/auth/states/currentUserState';
|
||||
import { calendarBookingPageIdState } from '@/client-config/states/calendarBookingPageIdState';
|
||||
import { isBookCallOnboardingStepEnabledState } from '@/client-config/states/isBookCallOnboardingStepEnabledState';
|
||||
import { ONBOARDING_BOOK_CALL_PENDING_USER_VAR_KEY } from '@/onboarding/constants/OnboardingBookCallPendingUserVarKey';
|
||||
import { PrefetchBookCallStepEffect } from '@/onboarding/effect-components/PrefetchBookCallStepEffect';
|
||||
import {
|
||||
jotaiStore,
|
||||
resetJotaiStore,
|
||||
} from '@/ui/utilities/state/jotai/jotaiStore';
|
||||
|
||||
const mockCalApi = jest.fn();
|
||||
|
||||
jest.mock('@calcom/embed-react', () => ({
|
||||
getCalApi: () => Promise.resolve(mockCalApi),
|
||||
}));
|
||||
|
||||
const renderEffect = async ({
|
||||
isBookCallOnboardingStepEnabled,
|
||||
isBookCallOnboardingStepPending,
|
||||
calendarBookingPageId,
|
||||
}: {
|
||||
isBookCallOnboardingStepEnabled: boolean;
|
||||
isBookCallOnboardingStepPending: boolean;
|
||||
calendarBookingPageId: string | null;
|
||||
}) => {
|
||||
jotaiStore.set(
|
||||
isBookCallOnboardingStepEnabledState.atom,
|
||||
isBookCallOnboardingStepEnabled,
|
||||
);
|
||||
jotaiStore.set(calendarBookingPageIdState.atom, calendarBookingPageId);
|
||||
jotaiStore.set(currentUserState.atom, {
|
||||
id: 'user-id',
|
||||
userVars: {
|
||||
[ONBOARDING_BOOK_CALL_PENDING_USER_VAR_KEY]:
|
||||
isBookCallOnboardingStepPending,
|
||||
},
|
||||
} as never);
|
||||
|
||||
render(
|
||||
<JotaiProvider store={jotaiStore}>
|
||||
<PrefetchBookCallStepEffect />
|
||||
</JotaiProvider>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
};
|
||||
|
||||
const getPreloadCalls = () =>
|
||||
mockCalApi.mock.calls.filter(([action]) => action === 'preload');
|
||||
|
||||
describe('PrefetchBookCallStepEffect', () => {
|
||||
beforeEach(() => {
|
||||
resetJotaiStore();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should warm the booking page when the step is pending and configured', async () => {
|
||||
await renderEffect({
|
||||
isBookCallOnboardingStepEnabled: true,
|
||||
isBookCallOnboardingStepPending: true,
|
||||
calendarBookingPageId: 'team/twenty/talk-to-us',
|
||||
});
|
||||
|
||||
expect(getPreloadCalls()).toEqual([
|
||||
['preload', { calLink: 'team/twenty/talk-to-us' }],
|
||||
]);
|
||||
});
|
||||
|
||||
it('should not warm anything when the user is not pending the step', async () => {
|
||||
await renderEffect({
|
||||
isBookCallOnboardingStepEnabled: true,
|
||||
isBookCallOnboardingStepPending: false,
|
||||
calendarBookingPageId: 'team/twenty/talk-to-us',
|
||||
});
|
||||
|
||||
expect(getPreloadCalls()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should not warm anything when the step is disabled', async () => {
|
||||
await renderEffect({
|
||||
isBookCallOnboardingStepEnabled: false,
|
||||
isBookCallOnboardingStepPending: true,
|
||||
calendarBookingPageId: 'team/twenty/talk-to-us',
|
||||
});
|
||||
|
||||
expect(getPreloadCalls()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should not warm anything without a booking page', async () => {
|
||||
await renderEffect({
|
||||
isBookCallOnboardingStepEnabled: true,
|
||||
isBookCallOnboardingStepPending: true,
|
||||
calendarBookingPageId: null,
|
||||
});
|
||||
|
||||
expect(getPreloadCalls()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const COMPLETE_BOOK_CALL_ONBOARDING_STEP = gql`
|
||||
mutation CompleteBookCallOnboardingStep {
|
||||
completeBookCallOnboardingStep {
|
||||
success
|
||||
}
|
||||
}
|
||||
`;
|
||||
+1
@@ -5,6 +5,7 @@ export const ENRICH_WORKSPACE_COMPANY = gql`
|
||||
enrichWorkspaceCompany {
|
||||
outcome
|
||||
enrichment
|
||||
isBookCallOnboardingStepPending
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
import { MockedProvider } from '@apollo/client/testing/react';
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { Provider as JotaiProvider } from 'jotai';
|
||||
import { createElement, type ReactNode } from 'react';
|
||||
import { AppPath } from 'twenty-shared/types';
|
||||
|
||||
import { currentUserState } from '@/auth/states/currentUserState';
|
||||
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
|
||||
import { billingState } from '@/client-config/states/billingState';
|
||||
import { ONBOARDING_BOOK_CALL_PENDING_USER_VAR_KEY } from '@/onboarding/constants/OnboardingBookCallPendingUserVarKey';
|
||||
import { useCompleteBookCallOnboardingStep } from '@/onboarding/hooks/useCompleteBookCallOnboardingStep';
|
||||
import { getIsBookCallOnboardingStepPending } from '@/onboarding/utils/getIsBookCallOnboardingStepPending';
|
||||
import {
|
||||
jotaiStore,
|
||||
resetJotaiStore,
|
||||
} from '@/ui/utilities/state/jotai/jotaiStore';
|
||||
import { CompleteBookCallOnboardingStepDocument } from '~/generated-metadata/graphql';
|
||||
import { mockCurrentWorkspace } from '~/testing/mock-data/users';
|
||||
|
||||
const mockNavigate = jest.fn();
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
useNavigate: () => mockNavigate,
|
||||
}));
|
||||
|
||||
const mockSetNextOnboardingStatus = jest.fn();
|
||||
|
||||
jest.mock('@/onboarding/hooks/useSetNextOnboardingStatus', () => ({
|
||||
useSetNextOnboardingStatus: () => mockSetNextOnboardingStatus,
|
||||
}));
|
||||
|
||||
const mutationMock = {
|
||||
request: { query: CompleteBookCallOnboardingStepDocument },
|
||||
result: {
|
||||
data: {
|
||||
completeBookCallOnboardingStep: {
|
||||
__typename: 'OnboardingStepSuccess',
|
||||
success: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const renderCompleteHook = ({
|
||||
isBillingEnabled,
|
||||
withSubscription,
|
||||
}: {
|
||||
isBillingEnabled: boolean;
|
||||
withSubscription: boolean;
|
||||
}) => {
|
||||
jotaiStore.set(currentWorkspaceState.atom, {
|
||||
...mockCurrentWorkspace,
|
||||
billingSubscriptions: withSubscription
|
||||
? mockCurrentWorkspace.billingSubscriptions
|
||||
: [],
|
||||
});
|
||||
jotaiStore.set(billingState.atom, {
|
||||
__typename: 'Billing',
|
||||
isBillingEnabled,
|
||||
trialPeriods: [],
|
||||
} as never);
|
||||
|
||||
const wrapper = ({ children }: { children: ReactNode }) =>
|
||||
createElement(
|
||||
MockedProvider,
|
||||
{ mocks: [mutationMock] as never },
|
||||
createElement(JotaiProvider, { store: jotaiStore }, children),
|
||||
);
|
||||
|
||||
return renderHook(() => useCompleteBookCallOnboardingStep(), { wrapper });
|
||||
};
|
||||
|
||||
describe('useCompleteBookCallOnboardingStep', () => {
|
||||
beforeEach(() => {
|
||||
resetJotaiStore();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should navigate to the plan step when a plan is still required', async () => {
|
||||
const { result } = renderCompleteHook({
|
||||
isBillingEnabled: true,
|
||||
withSubscription: false,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current();
|
||||
});
|
||||
|
||||
expect(mockSetNextOnboardingStatus).toHaveBeenCalled();
|
||||
expect(mockNavigate).toHaveBeenCalledWith(AppPath.PlanRequired);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ isBillingEnabled: false, withSubscription: false },
|
||||
{ isBillingEnabled: true, withSubscription: true },
|
||||
])(
|
||||
'should leave routing to the redirect engine when no plan is required (%o)',
|
||||
async (options) => {
|
||||
const { result } = renderCompleteHook(options);
|
||||
|
||||
await act(async () => {
|
||||
await result.current();
|
||||
});
|
||||
|
||||
expect(mockSetNextOnboardingStatus).toHaveBeenCalled();
|
||||
expect(mockNavigate).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it('should clear the pending flag before advancing so the step cannot reopen', async () => {
|
||||
jotaiStore.set(currentUserState.atom, {
|
||||
id: 'user-id',
|
||||
userVars: { [ONBOARDING_BOOK_CALL_PENDING_USER_VAR_KEY]: true },
|
||||
} as never);
|
||||
|
||||
const { result } = renderCompleteHook({
|
||||
isBillingEnabled: true,
|
||||
withSubscription: false,
|
||||
});
|
||||
|
||||
mockSetNextOnboardingStatus.mockImplementation(() => {
|
||||
expect(
|
||||
getIsBookCallOnboardingStepPending(
|
||||
jotaiStore.get(currentUserState.atom),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current();
|
||||
});
|
||||
|
||||
expect(mockSetNextOnboardingStatus).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,196 @@
|
||||
import { i18n } from '@lingui/core';
|
||||
import { I18nProvider } from '@lingui/react';
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { Provider as JotaiProvider } from 'jotai';
|
||||
import { SOURCE_LOCALE } from 'twenty-shared/translations';
|
||||
import { dynamicActivate } from '~/utils/i18n/dynamicActivate';
|
||||
|
||||
import { isBookCallOnboardingStepEnabledState } from '@/client-config/states/isBookCallOnboardingStepEnabledState';
|
||||
import { isCompanyEnrichmentEnabledState } from '@/client-config/states/isCompanyEnrichmentEnabledState';
|
||||
import { useInviteTeam } from '@/onboarding/hooks/useInviteTeam';
|
||||
import {
|
||||
jotaiStore,
|
||||
resetJotaiStore,
|
||||
} from '@/ui/utilities/state/jotai/jotaiStore';
|
||||
|
||||
const mockSendInvitation = jest.fn();
|
||||
const mockSetNextOnboardingStatus = jest.fn();
|
||||
const mockWaitForCompanyEnrichmentSettlement = jest.fn();
|
||||
|
||||
jest.mock('@/workspace-invitation/hooks/useCreateWorkspaceInvitation', () => ({
|
||||
useCreateWorkspaceInvitation: () => ({
|
||||
sendInvitation: mockSendInvitation,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('@/onboarding/hooks/useSetNextOnboardingStatus', () => ({
|
||||
useSetNextOnboardingStatus: () => mockSetNextOnboardingStatus,
|
||||
}));
|
||||
|
||||
jest.mock('@/onboarding/utils/waitForCompanyEnrichmentSettlement', () => ({
|
||||
waitForCompanyEnrichmentSettlement: (...args: unknown[]) =>
|
||||
mockWaitForCompanyEnrichmentSettlement(...args),
|
||||
}));
|
||||
|
||||
jest.mock('@apollo/client/react', () => ({
|
||||
useQuery: () => ({ data: undefined, loading: false }),
|
||||
}));
|
||||
|
||||
jest.mock('@/ui/feedback/snack-bar-manager/hooks/useSnackBar', () => ({
|
||||
useSnackBar: () => ({ enqueueSuccessSnackBar: jest.fn() }),
|
||||
}));
|
||||
|
||||
jest.mock('@/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElement', () => ({
|
||||
useHotkeysOnFocusedElement: jest.fn(),
|
||||
}));
|
||||
|
||||
dynamicActivate(SOURCE_LOCALE);
|
||||
|
||||
const renderInviteTeam = () =>
|
||||
renderHook(() => useInviteTeam(), {
|
||||
wrapper: ({ children }) =>
|
||||
JotaiProvider({
|
||||
store: jotaiStore,
|
||||
children: I18nProvider({ i18n, children }),
|
||||
}),
|
||||
});
|
||||
|
||||
describe('useInviteTeam', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
resetJotaiStore();
|
||||
jest.clearAllMocks();
|
||||
mockSendInvitation.mockResolvedValue({});
|
||||
mockWaitForCompanyEnrichmentSettlement.mockResolvedValue(undefined);
|
||||
jotaiStore.set(isBookCallOnboardingStepEnabledState.atom, true);
|
||||
jotaiStore.set(isCompanyEnrichmentEnabledState.atom, true);
|
||||
});
|
||||
|
||||
it('should not wait for an enrichment that will never run', async () => {
|
||||
jotaiStore.set(isCompanyEnrichmentEnabledState.atom, false);
|
||||
|
||||
const { result } = renderInviteTeam();
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSkip();
|
||||
});
|
||||
|
||||
expect(mockWaitForCompanyEnrichmentSettlement).not.toHaveBeenCalled();
|
||||
expect(mockSetNextOnboardingStatus).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not wait for the enrichment when the book-call step is disabled', async () => {
|
||||
jotaiStore.set(isBookCallOnboardingStepEnabledState.atom, false);
|
||||
|
||||
const { result } = renderInviteTeam();
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSkip();
|
||||
});
|
||||
|
||||
expect(mockWaitForCompanyEnrichmentSettlement).not.toHaveBeenCalled();
|
||||
expect(mockSetNextOnboardingStatus).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should wait for the enrichment answer before advancing', async () => {
|
||||
let resolveCompanyEnrichmentSettlement: () => void = () => {};
|
||||
|
||||
mockWaitForCompanyEnrichmentSettlement.mockReturnValue(
|
||||
new Promise<void>((resolve) => {
|
||||
resolveCompanyEnrichmentSettlement = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
const { result } = renderInviteTeam();
|
||||
|
||||
let hasSkipResolved = false;
|
||||
|
||||
await act(async () => {
|
||||
void result.current.handleSkip().then(() => {
|
||||
hasSkipResolved = true;
|
||||
});
|
||||
});
|
||||
|
||||
expect(mockWaitForCompanyEnrichmentSettlement).toHaveBeenCalled();
|
||||
expect(hasSkipResolved).toBe(false);
|
||||
expect(mockSetNextOnboardingStatus).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
resolveCompanyEnrichmentSettlement();
|
||||
});
|
||||
|
||||
expect(hasSkipResolved).toBe(true);
|
||||
expect(mockSetNextOnboardingStatus).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should start waiting for the enrichment before the invitation resolves', async () => {
|
||||
let resolveInvitation: (value: unknown) => void = () => {};
|
||||
|
||||
mockSendInvitation.mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
resolveInvitation = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
const { result } = renderInviteTeam();
|
||||
|
||||
act(() => {
|
||||
void result.current.handleSkip();
|
||||
});
|
||||
|
||||
expect(mockWaitForCompanyEnrichmentSettlement).toHaveBeenCalled();
|
||||
expect(mockSetNextOnboardingStatus).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
resolveInvitation({});
|
||||
});
|
||||
});
|
||||
|
||||
it('should disable the form while the submission is still in flight', async () => {
|
||||
let resolveInvitation: (value: unknown) => void = () => {};
|
||||
|
||||
mockSendInvitation.mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
resolveInvitation = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
const { result } = renderInviteTeam();
|
||||
|
||||
act(() => {
|
||||
void result.current.handleSkip();
|
||||
});
|
||||
|
||||
expect(result.current.isNavigating).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
resolveInvitation({});
|
||||
});
|
||||
});
|
||||
|
||||
it('should stay disabled after advancing', async () => {
|
||||
const { result } = renderInviteTeam();
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSkip();
|
||||
});
|
||||
|
||||
expect(result.current.isNavigating).toBe(true);
|
||||
});
|
||||
|
||||
it('should re-enable submission when sending the invitations fails', async () => {
|
||||
mockSendInvitation.mockResolvedValue({ error: new Error('network error') });
|
||||
|
||||
const { result } = renderInviteTeam();
|
||||
|
||||
await act(async () => {
|
||||
await expect(result.current.handleSkip()).rejects.toThrow(
|
||||
'network error',
|
||||
);
|
||||
});
|
||||
|
||||
expect(result.current.isNavigating).toBe(false);
|
||||
expect(mockSetNextOnboardingStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+195
-1
@@ -6,7 +6,9 @@ import { currentUserState } from '@/auth/states/currentUserState';
|
||||
import { currentUserWorkspaceState } from '@/auth/states/currentUserWorkspaceState';
|
||||
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
|
||||
import { billingState } from '@/client-config/states/billingState';
|
||||
import { isBookCallOnboardingStepEnabledState } from '@/client-config/states/isBookCallOnboardingStepEnabledState';
|
||||
import { isOnboardingAiChatEnabledState } from '@/client-config/states/isOnboardingAiChatEnabledState';
|
||||
import { ONBOARDING_BOOK_CALL_PENDING_USER_VAR_KEY } from '@/onboarding/constants/OnboardingBookCallPendingUserVarKey';
|
||||
import { useSetNextOnboardingStatus } from '@/onboarding/hooks/useSetNextOnboardingStatus';
|
||||
import { isWelcomeAnimationVisibleState } from '@/onboarding/states/isWelcomeAnimationVisibleState';
|
||||
import { shouldOpenAiChatAfterOnboardingState } from '@/onboarding/states/shouldOpenAiChatAfterOnboardingState';
|
||||
@@ -32,6 +34,8 @@ type RenderHooksOptions = {
|
||||
isBillingEnabled?: boolean;
|
||||
withOneWorkspaceMember?: boolean;
|
||||
isOnboardingAiChatEnabled?: boolean;
|
||||
isBookCallOnboardingStepEnabled?: boolean;
|
||||
isBookCallOnboardingStepPending?: boolean;
|
||||
};
|
||||
|
||||
const renderHooks = (
|
||||
@@ -41,6 +45,8 @@ const renderHooks = (
|
||||
isBillingEnabled = false,
|
||||
withOneWorkspaceMember = true,
|
||||
isOnboardingAiChatEnabled = false,
|
||||
isBookCallOnboardingStepEnabled = false,
|
||||
isBookCallOnboardingStepPending = false,
|
||||
}: RenderHooksOptions = {},
|
||||
) => {
|
||||
jotaiStore.set(
|
||||
@@ -56,6 +62,9 @@ const renderHooks = (
|
||||
);
|
||||
const setCurrentWorkspace = useSetAtomState(currentWorkspaceState);
|
||||
const setBilling = useSetAtomState(billingState);
|
||||
const setIsBookCallOnboardingStepEnabled = useSetAtomState(
|
||||
isBookCallOnboardingStepEnabledState,
|
||||
);
|
||||
const setNextOnboardingStatus = useSetNextOnboardingStatus();
|
||||
const isWelcomeAnimationVisible = useAtomStateValue(
|
||||
isWelcomeAnimationVisibleState,
|
||||
@@ -69,6 +78,7 @@ const renderHooks = (
|
||||
setCurrentWorkspace,
|
||||
setCurrentUserWorkspace,
|
||||
setBilling,
|
||||
setIsBookCallOnboardingStepEnabled,
|
||||
setNextOnboardingStatus,
|
||||
isWelcomeAnimationVisible,
|
||||
shouldOpenAiChatAfterOnboarding,
|
||||
@@ -79,7 +89,15 @@ const renderHooks = (
|
||||
},
|
||||
);
|
||||
act(() => {
|
||||
result.current.setCurrentUser({ ...mockedUserData, onboardingStatus });
|
||||
result.current.setCurrentUser({
|
||||
...mockedUserData,
|
||||
onboardingStatus,
|
||||
userVars: {
|
||||
...mockedUserData.userVars,
|
||||
[ONBOARDING_BOOK_CALL_PENDING_USER_VAR_KEY]:
|
||||
isBookCallOnboardingStepPending,
|
||||
},
|
||||
});
|
||||
result.current.setCurrentUserWorkspace(mockedUserData.currentUserWorkspace);
|
||||
result.current.setCurrentWorkspace({
|
||||
...mockCurrentWorkspace,
|
||||
@@ -93,6 +111,9 @@ const renderHooks = (
|
||||
isBillingEnabled,
|
||||
trialPeriods: [],
|
||||
});
|
||||
result.current.setIsBookCallOnboardingStepEnabled(
|
||||
isBookCallOnboardingStepEnabled,
|
||||
);
|
||||
});
|
||||
act(() => {
|
||||
result.current.setNextOnboardingStatus();
|
||||
@@ -108,6 +129,7 @@ const renderHooks = (
|
||||
describe('useSetNextOnboardingStatus', () => {
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
localStorage.clear();
|
||||
resetJotaiStore();
|
||||
});
|
||||
|
||||
@@ -235,6 +257,93 @@ describe('useSetNextOnboardingStatus', () => {
|
||||
expect(shouldOpenAiChatAfterOnboarding).toBe(false);
|
||||
});
|
||||
|
||||
it('should book a call after inviting the team when the server flagged the step', () => {
|
||||
const {
|
||||
nextOnboardingStatus,
|
||||
isWelcomeAnimationVisible,
|
||||
shouldOpenAiChatAfterOnboarding,
|
||||
} = renderHooks(OnboardingStatus.INVITE_TEAM, {
|
||||
isBillingEnabled: true,
|
||||
isBookCallOnboardingStepEnabled: true,
|
||||
isBookCallOnboardingStepPending: true,
|
||||
});
|
||||
expect(nextOnboardingStatus).toEqual(OnboardingStatus.BOOK_CALL);
|
||||
expect(isWelcomeAnimationVisible).toBe(false);
|
||||
expect(shouldOpenAiChatAfterOnboarding).toBe(false);
|
||||
});
|
||||
|
||||
it('should book a call after profile creation when more than 1 workspaceMember exist', () => {
|
||||
const { nextOnboardingStatus } = renderHooks(
|
||||
OnboardingStatus.PROFILE_CREATION,
|
||||
{
|
||||
withOneWorkspaceMember: false,
|
||||
isBillingEnabled: true,
|
||||
isBookCallOnboardingStepEnabled: true,
|
||||
isBookCallOnboardingStepPending: true,
|
||||
},
|
||||
);
|
||||
expect(nextOnboardingStatus).toEqual(OnboardingStatus.BOOK_CALL);
|
||||
});
|
||||
|
||||
it('should skip the book-call step once the workspace has a subscription, matching the server', () => {
|
||||
const { nextOnboardingStatus } = renderHooks(OnboardingStatus.INVITE_TEAM, {
|
||||
isBillingEnabled: true,
|
||||
withSubscription: true,
|
||||
isBookCallOnboardingStepEnabled: true,
|
||||
isBookCallOnboardingStepPending: true,
|
||||
});
|
||||
expect(nextOnboardingStatus).toEqual(OnboardingStatus.COMPLETED);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
isBookCallOnboardingStepEnabled: true,
|
||||
isBookCallOnboardingStepPending: false,
|
||||
},
|
||||
{
|
||||
isBookCallOnboardingStepEnabled: false,
|
||||
isBookCallOnboardingStepPending: true,
|
||||
},
|
||||
{
|
||||
isBookCallOnboardingStepEnabled: false,
|
||||
isBookCallOnboardingStepPending: false,
|
||||
},
|
||||
])(
|
||||
'should skip the book-call step when enabled is $isBookCallOnboardingStepEnabled and pending is $isBookCallOnboardingStepPending',
|
||||
(options) => {
|
||||
const { nextOnboardingStatus } = renderHooks(
|
||||
OnboardingStatus.INVITE_TEAM,
|
||||
{ isBillingEnabled: true, ...options },
|
||||
);
|
||||
expect(nextOnboardingStatus).toEqual(OnboardingStatus.PLAN_REQUIRED);
|
||||
},
|
||||
);
|
||||
|
||||
it('should require a plan after booking a call when billing is enabled and the workspace has no subscription', () => {
|
||||
const { nextOnboardingStatus, isWelcomeAnimationVisible } = renderHooks(
|
||||
OnboardingStatus.BOOK_CALL,
|
||||
{
|
||||
isBillingEnabled: true,
|
||||
isBookCallOnboardingStepEnabled: true,
|
||||
isBookCallOnboardingStepPending: true,
|
||||
},
|
||||
);
|
||||
expect(nextOnboardingStatus).toEqual(OnboardingStatus.PLAN_REQUIRED);
|
||||
expect(isWelcomeAnimationVisible).toBe(false);
|
||||
});
|
||||
|
||||
it('should complete after booking a call when billing is disabled', () => {
|
||||
const { nextOnboardingStatus, isWelcomeAnimationVisible } = renderHooks(
|
||||
OnboardingStatus.BOOK_CALL,
|
||||
{
|
||||
isBookCallOnboardingStepEnabled: true,
|
||||
isBookCallOnboardingStepPending: true,
|
||||
},
|
||||
);
|
||||
expect(nextOnboardingStatus).toEqual(OnboardingStatus.COMPLETED);
|
||||
expect(isWelcomeAnimationVisible).toBe(true);
|
||||
});
|
||||
|
||||
it('should not show the welcome animation when the onboarding was already completed', () => {
|
||||
const {
|
||||
nextOnboardingStatus,
|
||||
@@ -274,4 +383,89 @@ describe('useSetNextOnboardingStatus', () => {
|
||||
expect(isWelcomeAnimationVisible).toBe(true);
|
||||
expect(shouldOpenAiChatAfterOnboarding).toBe(false);
|
||||
});
|
||||
|
||||
it('should stay on the plan step when advancing from it without a subscription', () => {
|
||||
const { nextOnboardingStatus } = renderHooks(
|
||||
OnboardingStatus.PLAN_REQUIRED,
|
||||
{ isBillingEnabled: true, withSubscription: false },
|
||||
);
|
||||
expect(nextOnboardingStatus).toEqual(OnboardingStatus.PLAN_REQUIRED);
|
||||
});
|
||||
|
||||
it('should complete when advancing from the plan step with a subscription', () => {
|
||||
const { nextOnboardingStatus } = renderHooks(
|
||||
OnboardingStatus.PLAN_REQUIRED,
|
||||
{ isBillingEnabled: true, withSubscription: true },
|
||||
);
|
||||
expect(nextOnboardingStatus).toEqual(OnboardingStatus.COMPLETED);
|
||||
});
|
||||
|
||||
it('should honour a pending book-call flag that arrives after the callback was captured', () => {
|
||||
jotaiStore.set(currentUserState.atom, {
|
||||
...mockedUserData,
|
||||
onboardingStatus: OnboardingStatus.INVITE_TEAM,
|
||||
});
|
||||
jotaiStore.set(currentWorkspaceState.atom, {
|
||||
...mockCurrentWorkspace,
|
||||
billingSubscriptions: [],
|
||||
workspaceMembersCount: 1,
|
||||
});
|
||||
jotaiStore.set(billingState.atom, {
|
||||
__typename: 'Billing',
|
||||
isBillingEnabled: true,
|
||||
trialPeriods: [],
|
||||
} as never);
|
||||
jotaiStore.set(isBookCallOnboardingStepEnabledState.atom, true);
|
||||
|
||||
const { result } = renderHook(() => useSetNextOnboardingStatus(), {
|
||||
wrapper: Wrapper,
|
||||
});
|
||||
|
||||
const advanceCapturedBeforeEnrichment = result.current;
|
||||
|
||||
act(() => {
|
||||
jotaiStore.set(currentUserState.atom, (current) => ({
|
||||
...current!,
|
||||
userVars: {
|
||||
...current?.userVars,
|
||||
[ONBOARDING_BOOK_CALL_PENDING_USER_VAR_KEY]: true,
|
||||
},
|
||||
}));
|
||||
advanceCapturedBeforeEnrichment();
|
||||
});
|
||||
|
||||
expect(jotaiStore.get(currentUserState.atom)?.onboardingStatus).toEqual(
|
||||
OnboardingStatus.BOOK_CALL,
|
||||
);
|
||||
});
|
||||
|
||||
it('should still sync emails when the server status landed before advancing', () => {
|
||||
jotaiStore.set(currentUserState.atom, {
|
||||
...mockedUserData,
|
||||
onboardingStatus: OnboardingStatus.WORKSPACE_ACTIVATION,
|
||||
});
|
||||
jotaiStore.set(currentWorkspaceState.atom, {
|
||||
...mockCurrentWorkspace,
|
||||
billingSubscriptions: [],
|
||||
workspaceMembersCount: 1,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useSetNextOnboardingStatus(), {
|
||||
wrapper: Wrapper,
|
||||
});
|
||||
|
||||
const advanceCapturedBeforeActivation = result.current;
|
||||
|
||||
act(() => {
|
||||
jotaiStore.set(currentUserState.atom, {
|
||||
...mockedUserData,
|
||||
onboardingStatus: OnboardingStatus.SYNC_EMAIL,
|
||||
});
|
||||
advanceCapturedBeforeActivation();
|
||||
});
|
||||
|
||||
expect(jotaiStore.get(currentUserState.atom)?.onboardingStatus).toEqual(
|
||||
OnboardingStatus.SYNC_EMAIL,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
import { currentUserState } from '@/auth/states/currentUserState';
|
||||
import { useIsPlanRequired } from '@/onboarding/hooks/useIsPlanRequired';
|
||||
import { useSetNextOnboardingStatus } from '@/onboarding/hooks/useSetNextOnboardingStatus';
|
||||
import { setIsBookCallOnboardingStepPending } from '@/onboarding/utils/setIsBookCallOnboardingStepPending';
|
||||
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
|
||||
import { useMutation } from '@apollo/client/react';
|
||||
import { useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { AppPath } from 'twenty-shared/types';
|
||||
import { CompleteBookCallOnboardingStepDocument } from '~/generated-metadata/graphql';
|
||||
|
||||
export const useCompleteBookCallOnboardingStep = () => {
|
||||
const navigate = useNavigate();
|
||||
const setNextOnboardingStatus = useSetNextOnboardingStatus();
|
||||
const setCurrentUser = useSetAtomState(currentUserState);
|
||||
const isPlanRequired = useIsPlanRequired();
|
||||
const [completeBookCallOnboardingStepMutation] = useMutation(
|
||||
CompleteBookCallOnboardingStepDocument,
|
||||
);
|
||||
|
||||
return useCallback(async () => {
|
||||
await completeBookCallOnboardingStepMutation();
|
||||
|
||||
setCurrentUser((current) =>
|
||||
setIsBookCallOnboardingStepPending(current, false),
|
||||
);
|
||||
setNextOnboardingStatus();
|
||||
|
||||
if (isPlanRequired) {
|
||||
navigate(AppPath.PlanRequired);
|
||||
}
|
||||
}, [
|
||||
completeBookCallOnboardingStepMutation,
|
||||
setCurrentUser,
|
||||
setNextOnboardingStatus,
|
||||
isPlanRequired,
|
||||
navigate,
|
||||
]);
|
||||
};
|
||||
@@ -1,6 +1,9 @@
|
||||
import { onboardingConfigState } from '@/client-config/states/onboardingConfigState';
|
||||
import { isBookCallOnboardingStepEnabledState } from '@/client-config/states/isBookCallOnboardingStepEnabledState';
|
||||
import { isCompanyEnrichmentEnabledState } from '@/client-config/states/isCompanyEnrichmentEnabledState';
|
||||
import { useSetNextOnboardingStatus } from '@/onboarding/hooks/useSetNextOnboardingStatus';
|
||||
import { onboardingFreeCreditsState } from '@/onboarding/states/onboardingFreeCreditsState';
|
||||
import { waitForCompanyEnrichmentSettlement } from '@/onboarding/utils/waitForCompanyEnrichmentSettlement';
|
||||
import { PageFocusId } from '@/types/PageFocusId';
|
||||
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||
import { useHotkeysOnFocusedElement } from '@/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElement';
|
||||
@@ -10,6 +13,7 @@ import { useCreateWorkspaceInvitation } from '@/workspace-invitation/hooks/useCr
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { useQuery } from '@apollo/client/react';
|
||||
import { useStore } from 'jotai';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { type SubmitHandler, useFieldArray, useForm } from 'react-hook-form';
|
||||
import { Key } from 'ts-key-enum';
|
||||
@@ -30,6 +34,13 @@ export const useInviteTeam = () => {
|
||||
const setNextOnboardingStatus = useSetNextOnboardingStatus();
|
||||
const setOnboardingFreeCredits = useSetAtomState(onboardingFreeCreditsState);
|
||||
const onboardingConfig = useAtomStateValue(onboardingConfigState);
|
||||
const isBookCallOnboardingStepEnabled = useAtomStateValue(
|
||||
isBookCallOnboardingStepEnabledState,
|
||||
);
|
||||
const isCompanyEnrichmentEnabled = useAtomStateValue(
|
||||
isCompanyEnrichmentEnabledState,
|
||||
);
|
||||
const store = useStore();
|
||||
|
||||
const [isNavigating, setIsNavigating] = useState(false);
|
||||
|
||||
@@ -130,38 +141,57 @@ export const useInviteTeam = () => {
|
||||
),
|
||||
);
|
||||
|
||||
const result = await sendInvitation({ emails });
|
||||
|
||||
if (isDefined(result.error)) {
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
const creditsRewardPerUser =
|
||||
onboardingConfig?.inviteTeamCreditsRewardPerUser ?? 0;
|
||||
|
||||
setOnboardingFreeCredits((current) => ({
|
||||
...current,
|
||||
inviteTeam: emails.length * creditsRewardPerUser,
|
||||
}));
|
||||
|
||||
if (emails.length > 0) {
|
||||
enqueueSuccessSnackBar({
|
||||
message: t`Invite link sent to email addresses`,
|
||||
options: {
|
||||
duration: 2000,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
setNextOnboardingStatus();
|
||||
setIsNavigating(true);
|
||||
|
||||
try {
|
||||
// Only wait when enrichment is actually going to run, otherwise the
|
||||
// settlement never resolves and every submit burns the full timeout.
|
||||
const companyEnrichmentSettlement =
|
||||
isBookCallOnboardingStepEnabled && isCompanyEnrichmentEnabled
|
||||
? waitForCompanyEnrichmentSettlement({ store })
|
||||
: Promise.resolve();
|
||||
|
||||
const result = await sendInvitation({ emails });
|
||||
|
||||
if (isDefined(result.error)) {
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
const creditsRewardPerUser =
|
||||
onboardingConfig?.inviteTeamCreditsRewardPerUser ?? 0;
|
||||
|
||||
setOnboardingFreeCredits((current) => ({
|
||||
...current,
|
||||
inviteTeam: emails.length * creditsRewardPerUser,
|
||||
}));
|
||||
|
||||
if (emails.length > 0) {
|
||||
enqueueSuccessSnackBar({
|
||||
message: t`Invite link sent to email addresses`,
|
||||
options: {
|
||||
duration: 2000,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await companyEnrichmentSettlement;
|
||||
|
||||
setNextOnboardingStatus();
|
||||
} catch (error) {
|
||||
setIsNavigating(false);
|
||||
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[
|
||||
enqueueSuccessSnackBar,
|
||||
isBookCallOnboardingStepEnabled,
|
||||
isCompanyEnrichmentEnabled,
|
||||
onboardingConfig?.inviteTeamCreditsRewardPerUser,
|
||||
sendInvitation,
|
||||
setNextOnboardingStatus,
|
||||
setOnboardingFreeCredits,
|
||||
store,
|
||||
t,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { getCalApi } from '@calcom/embed-react';
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { currentUserState } from '@/auth/states/currentUserState';
|
||||
import { calendarBookingPageIdState } from '@/client-config/states/calendarBookingPageIdState';
|
||||
import { isBookCallOnboardingStepEnabledState } from '@/client-config/states/isBookCallOnboardingStepEnabledState';
|
||||
import { getIsBookCallOnboardingStepPending } from '@/onboarding/utils/getIsBookCallOnboardingStepPending';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
|
||||
export const usePreloadCalForBookCallStep = () => {
|
||||
const isBookCallOnboardingStepEnabled = useAtomStateValue(
|
||||
isBookCallOnboardingStepEnabledState,
|
||||
);
|
||||
const calendarBookingPageId = useAtomStateValue(calendarBookingPageIdState);
|
||||
const currentUser = useAtomStateValue(currentUserState);
|
||||
|
||||
const isBookCallOnboardingStepPending =
|
||||
getIsBookCallOnboardingStepPending(currentUser);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!isBookCallOnboardingStepEnabled ||
|
||||
!isBookCallOnboardingStepPending ||
|
||||
!isNonEmptyString(calendarBookingPageId)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Warms the Cal.com embed script and the booking page itself while the user
|
||||
// is still on an earlier step, so the embed is not fetched from scratch.
|
||||
const preloadBookingPage = async () => {
|
||||
try {
|
||||
const calApi = await getCalApi();
|
||||
|
||||
calApi('preload', { calLink: calendarBookingPageId });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
void preloadBookingPage();
|
||||
}, [
|
||||
isBookCallOnboardingStepEnabled,
|
||||
isBookCallOnboardingStepPending,
|
||||
calendarBookingPageId,
|
||||
]);
|
||||
};
|
||||
@@ -9,33 +9,46 @@ import {
|
||||
currentWorkspaceState,
|
||||
} from '@/auth/states/currentWorkspaceState';
|
||||
import { billingState } from '@/client-config/states/billingState';
|
||||
import { isBookCallOnboardingStepEnabledState } from '@/client-config/states/isBookCallOnboardingStepEnabledState';
|
||||
import { isOnboardingAiChatEnabledState } from '@/client-config/states/isOnboardingAiChatEnabledState';
|
||||
import { isWelcomeAnimationVisibleState } from '@/onboarding/states/isWelcomeAnimationVisibleState';
|
||||
import { shouldOpenAiChatAfterOnboardingState } from '@/onboarding/states/shouldOpenAiChatAfterOnboardingState';
|
||||
import { getHasJustCompletedOnboarding } from '@/onboarding/utils/getHasJustCompletedOnboarding';
|
||||
import { getIsBookCallOnboardingStepPending } from '@/onboarding/utils/getIsBookCallOnboardingStepPending';
|
||||
import { getIsPlanRequired } from '@/onboarding/utils/getIsPlanRequired';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
|
||||
import { useStore } from 'jotai';
|
||||
import { useCallback } from 'react';
|
||||
import { OnboardingStatus } from '~/generated-metadata/graphql';
|
||||
import { useStore } from 'jotai';
|
||||
|
||||
type GetNextOnboardingStatusArgs = {
|
||||
currentUser: CurrentUser | null;
|
||||
currentWorkspace: CurrentWorkspace | null;
|
||||
isBillingEnabled: boolean;
|
||||
isBookCallRequired: boolean;
|
||||
};
|
||||
|
||||
const getNextOnboardingStatus = ({
|
||||
currentUser,
|
||||
currentWorkspace,
|
||||
isBillingEnabled,
|
||||
isBookCallRequired,
|
||||
}: GetNextOnboardingStatusArgs) => {
|
||||
const isPlanRequired = getIsPlanRequired({
|
||||
isBillingEnabled,
|
||||
currentWorkspace,
|
||||
});
|
||||
|
||||
const statusAfterBookCall = isPlanRequired
|
||||
? OnboardingStatus.PLAN_REQUIRED
|
||||
: OnboardingStatus.COMPLETED;
|
||||
|
||||
const statusAfterInviteTeam =
|
||||
isBookCallRequired && isPlanRequired
|
||||
? OnboardingStatus.BOOK_CALL
|
||||
: statusAfterBookCall;
|
||||
|
||||
if (currentUser?.onboardingStatus === OnboardingStatus.WORKSPACE_ACTIVATION) {
|
||||
return OnboardingStatus.SYNC_EMAIL;
|
||||
}
|
||||
@@ -55,14 +68,16 @@ const getNextOnboardingStatus = ({
|
||||
if (currentWorkspace?.workspaceMembersCount === 1) {
|
||||
return OnboardingStatus.INVITE_TEAM;
|
||||
}
|
||||
return isPlanRequired
|
||||
? OnboardingStatus.PLAN_REQUIRED
|
||||
: OnboardingStatus.COMPLETED;
|
||||
return statusAfterInviteTeam;
|
||||
}
|
||||
if (currentUser?.onboardingStatus === OnboardingStatus.INVITE_TEAM) {
|
||||
return isPlanRequired
|
||||
? OnboardingStatus.PLAN_REQUIRED
|
||||
: OnboardingStatus.COMPLETED;
|
||||
return statusAfterInviteTeam;
|
||||
}
|
||||
if (
|
||||
currentUser?.onboardingStatus === OnboardingStatus.BOOK_CALL ||
|
||||
currentUser?.onboardingStatus === OnboardingStatus.PLAN_REQUIRED
|
||||
) {
|
||||
return statusAfterBookCall;
|
||||
}
|
||||
return OnboardingStatus.COMPLETED;
|
||||
};
|
||||
@@ -82,7 +97,11 @@ export const useSetNextOnboardingStatus = () => {
|
||||
currentUser,
|
||||
currentWorkspace,
|
||||
isBillingEnabled,
|
||||
isBookCallRequired:
|
||||
store.get(isBookCallOnboardingStepEnabledState.atom) &&
|
||||
getIsBookCallOnboardingStepPending(store.get(currentUserState.atom)),
|
||||
});
|
||||
|
||||
store.set(currentUserState.atom, (current) => {
|
||||
if (isDefined(current)) {
|
||||
return {
|
||||
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
import { createStore } from 'jotai';
|
||||
|
||||
import { companyEnrichmentState } from '@/onboarding/states/companyEnrichmentState';
|
||||
import { hasAttemptedCompanyEnrichmentFetchState } from '@/onboarding/states/hasAttemptedCompanyEnrichmentFetchState';
|
||||
import { isCompanyEnrichmentFetchInFlightState } from '@/onboarding/states/isCompanyEnrichmentFetchInFlightState';
|
||||
import { waitForCompanyEnrichmentSettlement } from '@/onboarding/utils/waitForCompanyEnrichmentSettlement';
|
||||
|
||||
const trackResolution = (settlement: Promise<void>) => {
|
||||
const resolution = { hasResolved: false };
|
||||
|
||||
void settlement.then(() => {
|
||||
resolution.hasResolved = true;
|
||||
});
|
||||
|
||||
return resolution;
|
||||
};
|
||||
|
||||
describe('waitForCompanyEnrichmentSettlement', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
});
|
||||
|
||||
it('should resolve immediately when the fetch already settled', async () => {
|
||||
const store = createStore();
|
||||
|
||||
store.set(hasAttemptedCompanyEnrichmentFetchState.atom, true);
|
||||
|
||||
await expect(
|
||||
waitForCompanyEnrichmentSettlement({ store, timeoutMs: 10_000 }),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('should resolve immediately when the enrichment is already known', async () => {
|
||||
const store = createStore();
|
||||
|
||||
store.set(companyEnrichmentState.atom, { domain: 'acme.com' } as never);
|
||||
|
||||
await expect(
|
||||
waitForCompanyEnrichmentSettlement({ store, timeoutMs: 10_000 }),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('should wait for a fetch that has not started yet', async () => {
|
||||
const store = createStore();
|
||||
|
||||
const settlement = waitForCompanyEnrichmentSettlement({
|
||||
store,
|
||||
timeoutMs: 10_000,
|
||||
});
|
||||
const resolution = trackResolution(settlement);
|
||||
|
||||
await Promise.resolve();
|
||||
expect(resolution.hasResolved).toBe(false);
|
||||
|
||||
store.set(isCompanyEnrichmentFetchInFlightState.atom, true);
|
||||
store.set(hasAttemptedCompanyEnrichmentFetchState.atom, true);
|
||||
|
||||
await Promise.resolve();
|
||||
expect(resolution.hasResolved).toBe(false);
|
||||
|
||||
store.set(isCompanyEnrichmentFetchInFlightState.atom, false);
|
||||
|
||||
await expect(settlement).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('should resolve as soon as the fetch settles', async () => {
|
||||
const store = createStore();
|
||||
|
||||
store.set(hasAttemptedCompanyEnrichmentFetchState.atom, true);
|
||||
store.set(isCompanyEnrichmentFetchInFlightState.atom, true);
|
||||
|
||||
const settlement = waitForCompanyEnrichmentSettlement({
|
||||
store,
|
||||
timeoutMs: 10_000,
|
||||
});
|
||||
const resolution = trackResolution(settlement);
|
||||
|
||||
await Promise.resolve();
|
||||
expect(resolution.hasResolved).toBe(false);
|
||||
|
||||
store.set(isCompanyEnrichmentFetchInFlightState.atom, false);
|
||||
|
||||
await expect(settlement).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('should resolve on timeout so a slow enrichment cannot block onboarding', async () => {
|
||||
const store = createStore();
|
||||
|
||||
store.set(hasAttemptedCompanyEnrichmentFetchState.atom, true);
|
||||
store.set(isCompanyEnrichmentFetchInFlightState.atom, true);
|
||||
|
||||
await expect(
|
||||
waitForCompanyEnrichmentSettlement({ store, timeoutMs: 1 }),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(store.get(isCompanyEnrichmentFetchInFlightState.atom)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
|
||||
export const getAttendeeName = (
|
||||
nameParts:
|
||||
| { firstName?: string | null; lastName?: string | null }
|
||||
| null
|
||||
| undefined,
|
||||
) =>
|
||||
[nameParts?.firstName, nameParts?.lastName]
|
||||
.filter(isNonEmptyString)
|
||||
.join(' ');
|
||||
@@ -0,0 +1,7 @@
|
||||
import { OnboardingStatus } from '~/generated-metadata/graphql';
|
||||
|
||||
export const getHasAdvancedPastBookCallStep = (
|
||||
onboardingStatus: OnboardingStatus | null | undefined,
|
||||
) =>
|
||||
onboardingStatus === OnboardingStatus.PLAN_REQUIRED ||
|
||||
onboardingStatus === OnboardingStatus.COMPLETED;
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
import { type CurrentUser } from '@/auth/states/currentUserState';
|
||||
import { ONBOARDING_BOOK_CALL_PENDING_USER_VAR_KEY } from '@/onboarding/constants/OnboardingBookCallPendingUserVarKey';
|
||||
|
||||
export const getIsBookCallOnboardingStepPending = (
|
||||
currentUser: Pick<CurrentUser, 'userVars'> | null,
|
||||
) =>
|
||||
currentUser?.userVars?.[ONBOARDING_BOOK_CALL_PENDING_USER_VAR_KEY] === true;
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
import { type CurrentUser } from '@/auth/states/currentUserState';
|
||||
import { ONBOARDING_BOOK_CALL_PENDING_USER_VAR_KEY } from '@/onboarding/constants/OnboardingBookCallPendingUserVarKey';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
export const setIsBookCallOnboardingStepPending = (
|
||||
currentUser: CurrentUser | null,
|
||||
isBookCallOnboardingStepPending: boolean,
|
||||
): CurrentUser | null => {
|
||||
if (!isDefined(currentUser)) {
|
||||
return currentUser;
|
||||
}
|
||||
|
||||
return {
|
||||
...currentUser,
|
||||
userVars: {
|
||||
...currentUser.userVars,
|
||||
[ONBOARDING_BOOK_CALL_PENDING_USER_VAR_KEY]:
|
||||
isBookCallOnboardingStepPending,
|
||||
},
|
||||
};
|
||||
};
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
import type { Store } from 'jotai/vanilla/store';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { COMPANY_ENRICHMENT_SETTLEMENT_TIMEOUT_MS } from '@/onboarding/constants/CompanyEnrichmentSettlementTimeoutMs';
|
||||
import { companyEnrichmentState } from '@/onboarding/states/companyEnrichmentState';
|
||||
import { hasAttemptedCompanyEnrichmentFetchState } from '@/onboarding/states/hasAttemptedCompanyEnrichmentFetchState';
|
||||
import { isCompanyEnrichmentFetchInFlightState } from '@/onboarding/states/isCompanyEnrichmentFetchInFlightState';
|
||||
|
||||
const COMPANY_ENRICHMENT_SETTLEMENT_ATOMS = [
|
||||
companyEnrichmentState.atom,
|
||||
hasAttemptedCompanyEnrichmentFetchState.atom,
|
||||
isCompanyEnrichmentFetchInFlightState.atom,
|
||||
];
|
||||
|
||||
export const waitForCompanyEnrichmentSettlement = ({
|
||||
store,
|
||||
timeoutMs = COMPANY_ENRICHMENT_SETTLEMENT_TIMEOUT_MS,
|
||||
}: {
|
||||
store: Store;
|
||||
timeoutMs?: number;
|
||||
}): Promise<void> =>
|
||||
new Promise((resolve) => {
|
||||
const hasAnswer = () =>
|
||||
isDefined(store.get(companyEnrichmentState.atom)) ||
|
||||
(store.get(hasAttemptedCompanyEnrichmentFetchState.atom) &&
|
||||
!store.get(isCompanyEnrichmentFetchInFlightState.atom));
|
||||
|
||||
if (hasAnswer()) {
|
||||
resolve();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const unsubscribes: (() => void)[] = [];
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
let isSettled = false;
|
||||
|
||||
const unsubscribeAll = () => {
|
||||
for (const unsubscribe of unsubscribes) {
|
||||
unsubscribe();
|
||||
}
|
||||
};
|
||||
|
||||
const settle = () => {
|
||||
if (isSettled) {
|
||||
return;
|
||||
}
|
||||
|
||||
isSettled = true;
|
||||
clearTimeout(timeout);
|
||||
unsubscribeAll();
|
||||
resolve();
|
||||
};
|
||||
|
||||
const settleWhenAnswered = () => {
|
||||
if (hasAnswer()) {
|
||||
settle();
|
||||
}
|
||||
};
|
||||
|
||||
timeout = setTimeout(settle, timeoutMs);
|
||||
|
||||
for (const atom of COMPANY_ENRICHMENT_SETTLEMENT_ATOMS) {
|
||||
unsubscribes.push(store.sub(atom, settleWhenAnswered));
|
||||
}
|
||||
});
|
||||
@@ -1,29 +1,34 @@
|
||||
import Cal from '@calcom/embed-react';
|
||||
import { useContext } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import { Link, Navigate } from 'react-router-dom';
|
||||
|
||||
import { currentUserState } from '@/auth/states/currentUserState';
|
||||
import { calendarBookingPageIdState } from '@/client-config/states/calendarBookingPageIdState';
|
||||
import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper';
|
||||
import { BookCallEmbed } from '@/onboarding/components/BookCallEmbed';
|
||||
import { BookCallOnboardingStepActions } from '@/onboarding/components/BookCallOnboardingStepActions';
|
||||
import { OnboardingStepAnimatedItem } from '@/onboarding/components/OnboardingStepAnimatedItem';
|
||||
import { StyledOnboardingStepHeading } from '@/onboarding/components/StyledOnboardingStepHeading';
|
||||
import { StyledOnboardingStepPage } from '@/onboarding/components/StyledOnboardingStepPage';
|
||||
import { StyledOnboardingStepSubtitle } from '@/onboarding/components/StyledOnboardingStepSubtitle';
|
||||
import { StyledOnboardingStepTitle } from '@/onboarding/components/StyledOnboardingStepTitle';
|
||||
import { useOnboardingStatus } from '@/onboarding/hooks/useOnboardingStatus';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { styled } from '@linaria/react';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { AppPath } from 'twenty-shared/types';
|
||||
import { IconChevronLeft } from 'twenty-ui/icon';
|
||||
import { LightButton } from 'twenty-ui/input';
|
||||
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
import { useIsMobile } from 'twenty-ui/utilities';
|
||||
import { MOBILE_VIEWPORT, themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
import { OnboardingStatus } from '~/generated-metadata/graphql';
|
||||
|
||||
const StyledPage = styled.div`
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
const StyledPage = styled(StyledOnboardingStepPage)`
|
||||
gap: ${themeCssVariables.spacing[5]};
|
||||
padding: ${themeCssVariables.spacing[6]} ${themeCssVariables.spacing[8]};
|
||||
|
||||
@media (max-width: ${MOBILE_VIEWPORT}px) {
|
||||
padding: ${themeCssVariables.spacing[6]} ${themeCssVariables.spacing[4]};
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledContent = styled.div`
|
||||
align-items: center;
|
||||
const StyledEmbed = styled(OnboardingStepAnimatedItem)`
|
||||
display: flex;
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
@@ -41,37 +46,49 @@ const StyledFooter = styled.div`
|
||||
`;
|
||||
|
||||
export const BookCall = () => {
|
||||
const { colorScheme } = useContext(ThemeContext);
|
||||
|
||||
const { t } = useLingui();
|
||||
const onboardingStatus = useOnboardingStatus();
|
||||
const calendarBookingPageId = useAtomStateValue(calendarBookingPageIdState);
|
||||
const currentUser = useAtomStateValue(currentUserState);
|
||||
|
||||
const isMobile = useIsMobile();
|
||||
const isOnboardingStep = onboardingStatus === OnboardingStatus.BOOK_CALL;
|
||||
const hasBookingPage = isNonEmptyString(calendarBookingPageId);
|
||||
|
||||
// Never redirect out of the step itself: the page-change effect routes
|
||||
// BOOK_CALL back here, so the two would bounce off each other.
|
||||
if (!hasBookingPage && !isOnboardingStep) {
|
||||
return <Navigate to={AppPath.PlanRequired} replace />;
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledPage>
|
||||
<StyledContent>
|
||||
<ScrollWrapper
|
||||
componentInstanceId="scroll-wrapper-book-call"
|
||||
autoHeight={!isMobile}
|
||||
>
|
||||
<Cal
|
||||
calLink={calendarBookingPageId ?? ''}
|
||||
config={{
|
||||
layout: 'month_view',
|
||||
theme: colorScheme === 'light' ? 'light' : 'dark',
|
||||
email: currentUser?.email ?? '',
|
||||
name: `${currentUser?.firstName} ${currentUser?.lastName}`,
|
||||
}}
|
||||
/>
|
||||
</ScrollWrapper>
|
||||
</StyledContent>
|
||||
<StyledFooter>
|
||||
<Link to={AppPath.PlanRequired}>
|
||||
<LightButton Icon={IconChevronLeft} title={t`Back`} />
|
||||
</Link>
|
||||
</StyledFooter>
|
||||
<StyledOnboardingStepHeading>
|
||||
<OnboardingStepAnimatedItem index={0}>
|
||||
<StyledOnboardingStepTitle>{t`Talk to our team`}</StyledOnboardingStepTitle>
|
||||
</OnboardingStepAnimatedItem>
|
||||
<OnboardingStepAnimatedItem index={1}>
|
||||
<StyledOnboardingStepSubtitle>
|
||||
{t`Book a 30-minute call and we'll help you get your workspace production-ready.`}
|
||||
</StyledOnboardingStepSubtitle>
|
||||
</OnboardingStepAnimatedItem>
|
||||
</StyledOnboardingStepHeading>
|
||||
|
||||
{hasBookingPage && (
|
||||
<StyledEmbed index={2}>
|
||||
<BookCallEmbed calendarBookingPageId={calendarBookingPageId} />
|
||||
</StyledEmbed>
|
||||
)}
|
||||
|
||||
<OnboardingStepAnimatedItem index={3}>
|
||||
<StyledFooter>
|
||||
{isOnboardingStep ? (
|
||||
<BookCallOnboardingStepActions />
|
||||
) : (
|
||||
<Link to={AppPath.PlanRequired}>
|
||||
<LightButton Icon={IconChevronLeft} title={t`Back`} />
|
||||
</Link>
|
||||
)}
|
||||
</StyledFooter>
|
||||
</OnboardingStepAnimatedItem>
|
||||
</StyledPage>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -19,6 +19,7 @@ import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { Controller } from 'react-hook-form';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { IconX } from 'twenty-ui/icon';
|
||||
import { Loader } from 'twenty-ui/feedback';
|
||||
import { MainButton } from 'twenty-ui/input';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
|
||||
@@ -124,6 +125,7 @@ export const InviteTeam = () => {
|
||||
<StyledFooter>
|
||||
<MainButton
|
||||
title={t`Invite`}
|
||||
Icon={() => (isSubmitting || isNavigating ? <Loader /> : null)}
|
||||
disabled={!isValid || isSubmitting || isNavigating}
|
||||
onClick={handleSubmit(onSubmit)}
|
||||
fullWidth
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { i18n } from '@lingui/core';
|
||||
import { I18nProvider } from '@lingui/react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { Provider as JotaiProvider } from 'jotai';
|
||||
import { MemoryRouter, Route, Routes } from 'react-router-dom';
|
||||
import { SOURCE_LOCALE } from 'twenty-shared/translations';
|
||||
import { AppPath } from 'twenty-shared/types';
|
||||
import { dynamicActivate } from '~/utils/i18n/dynamicActivate';
|
||||
|
||||
import { calendarBookingPageIdState } from '@/client-config/states/calendarBookingPageIdState';
|
||||
import {
|
||||
jotaiStore,
|
||||
resetJotaiStore,
|
||||
} from '@/ui/utilities/state/jotai/jotaiStore';
|
||||
import { BookCall } from '~/pages/onboarding/BookCall';
|
||||
import { OnboardingStatus } from '~/generated-metadata/graphql';
|
||||
|
||||
const mockOnboardingStatus = jest.fn();
|
||||
|
||||
jest.mock('@/onboarding/hooks/useOnboardingStatus', () => ({
|
||||
useOnboardingStatus: () => mockOnboardingStatus(),
|
||||
}));
|
||||
|
||||
jest.mock('@/onboarding/components/BookCallEmbed', () => ({
|
||||
BookCallEmbed: ({
|
||||
calendarBookingPageId,
|
||||
}: {
|
||||
calendarBookingPageId: string;
|
||||
}) => <div data-testid="book-call-embed">{calendarBookingPageId}</div>,
|
||||
}));
|
||||
|
||||
jest.mock('@/onboarding/components/BookCallOnboardingStepActions', () => ({
|
||||
BookCallOnboardingStepActions: () => <div>Skip</div>,
|
||||
}));
|
||||
|
||||
dynamicActivate(SOURCE_LOCALE);
|
||||
|
||||
const renderPage = () =>
|
||||
render(
|
||||
<MemoryRouter initialEntries={[AppPath.BookCall]}>
|
||||
<JotaiProvider store={jotaiStore}>
|
||||
<I18nProvider i18n={i18n}>
|
||||
<Routes>
|
||||
<Route path={AppPath.BookCall} element={<BookCall />} />
|
||||
<Route
|
||||
path={AppPath.PlanRequired}
|
||||
element={<div>Choose your plan</div>}
|
||||
/>
|
||||
</Routes>
|
||||
</I18nProvider>
|
||||
</JotaiProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
describe('BookCall', () => {
|
||||
beforeEach(() => {
|
||||
resetJotaiStore();
|
||||
jest.clearAllMocks();
|
||||
mockOnboardingStatus.mockReturnValue(OnboardingStatus.BOOK_CALL);
|
||||
});
|
||||
|
||||
it('should render the step heading alongside the embed', () => {
|
||||
jotaiStore.set(calendarBookingPageIdState.atom, 'team/twenty/talk-to-us');
|
||||
|
||||
renderPage();
|
||||
|
||||
expect(screen.getByText('Talk to our team')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('book-call-embed')).toHaveTextContent(
|
||||
'team/twenty/talk-to-us',
|
||||
);
|
||||
});
|
||||
|
||||
it('should redirect to the plan step rather than render an empty embed', () => {
|
||||
mockOnboardingStatus.mockReturnValue(OnboardingStatus.PLAN_REQUIRED);
|
||||
|
||||
renderPage();
|
||||
|
||||
expect(screen.getByText('Choose your plan')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('book-call-embed')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should stay on the step without an embed rather than bounce against the redirect engine', () => {
|
||||
renderPage();
|
||||
|
||||
expect(screen.getByText('Talk to our team')).toBeInTheDocument();
|
||||
expect(screen.getByText('Skip')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Choose your plan')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('book-call-embed')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -68,4 +68,6 @@ export const mockedClientConfig: ClientConfig = {
|
||||
isClickHouseConfigured: false,
|
||||
isWorkspaceSchemaDDLLocked: false,
|
||||
isOnboardingAiChatEnabled: false,
|
||||
isBookCallOnboardingStepEnabled: false,
|
||||
isCompanyEnrichmentEnabled: false,
|
||||
};
|
||||
|
||||
@@ -89,10 +89,20 @@ FRONTEND_URL=http://localhost:3001
|
||||
# ———————— ONBOARDING AI CHAT ————————
|
||||
# Opens an AI chat at the end of onboarding to help set up the workspace.
|
||||
# IS_ONBOARDING_AI_CHAT_ENABLED=true
|
||||
# Enriches the new workspace with its own company data, used as chat context.
|
||||
|
||||
# ———————— ENRICHMENT ————————
|
||||
# Enriches the new workspace with its own company data, used as AI chat context
|
||||
# and to qualify leads for the book-a-call onboarding step.
|
||||
# Active as soon as the API key is set.
|
||||
# PEOPLE_DATA_LABS_API_KEY=
|
||||
|
||||
# ———————— BOOK A CALL ————————
|
||||
# Shows a Cal.com booking step during onboarding to enriched companies with at
|
||||
# least this many employees. Requires enrichment and CALENDAR_BOOKING_PAGE_ID.
|
||||
# Both are opt-in: leave the employee count unset to disable the step.
|
||||
# CALENDAR_BOOKING_PAGE_ID=team/twenty/talk-to-us
|
||||
# ONBOARDING_BOOK_CALL_MIN_EMPLOYEE_COUNT=20
|
||||
|
||||
# ———————— AI ————————
|
||||
# API keys for built-in providers (also editable from Admin Panel > Config Variables):
|
||||
# OPENAI_API_KEY=
|
||||
|
||||
+2
@@ -105,6 +105,8 @@ describe('ClientConfigController', () => {
|
||||
isImapSmtpCaldavEnabled: false,
|
||||
isEmailingDomainInDemoMode: false,
|
||||
calendarBookingPageId: undefined,
|
||||
isBookCallOnboardingStepEnabled: false,
|
||||
isCompanyEnrichmentEnabled: false,
|
||||
isTwoFactorAuthenticationEnabled: false,
|
||||
allowRequestsToTwentyIcons: true,
|
||||
isCloudflareIntegrationEnabled: false,
|
||||
|
||||
@@ -339,6 +339,12 @@ export class ClientConfig {
|
||||
@Field(() => String, { nullable: true })
|
||||
calendarBookingPageId?: string;
|
||||
|
||||
@Field(() => Boolean)
|
||||
isBookCallOnboardingStepEnabled: boolean;
|
||||
|
||||
@Field(() => Boolean)
|
||||
isCompanyEnrichmentEnabled: boolean;
|
||||
|
||||
@Field(() => Boolean)
|
||||
isCloudflareIntegrationEnabled: boolean;
|
||||
|
||||
|
||||
+3
@@ -106,6 +106,7 @@ describe('ClientConfigService', () => {
|
||||
IS_CONFIG_VARIABLES_IN_DB_ENABLED: false,
|
||||
IS_IMAP_SMTP_CALDAV_ENABLED: false,
|
||||
CALENDAR_BOOKING_PAGE_ID: 'team/twenty/talk-to-us',
|
||||
ONBOARDING_BOOK_CALL_MIN_EMPLOYEE_COUNT: 50,
|
||||
CLOUDFLARE_API_KEY: undefined,
|
||||
CLOUDFLARE_ZONE_ID: undefined,
|
||||
ALLOW_REQUESTS_TO_TWENTY_ICONS: false,
|
||||
@@ -190,6 +191,8 @@ describe('ClientConfigService', () => {
|
||||
isEmailingDomainInDemoMode: false,
|
||||
allowRequestsToTwentyIcons: false,
|
||||
calendarBookingPageId: 'team/twenty/talk-to-us',
|
||||
isBookCallOnboardingStepEnabled: true,
|
||||
isCompanyEnrichmentEnabled: false,
|
||||
isCloudflareIntegrationEnabled: false,
|
||||
isClickHouseConfigured: false,
|
||||
isOnboardingAiChatEnabled: false,
|
||||
|
||||
+10
@@ -3,6 +3,8 @@ import { Injectable } from '@nestjs/common';
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { readIsCompanyEnrichmentEnabled } from 'src/engine/core-modules/company-enrichment/utils/read-is-company-enrichment-enabled.util';
|
||||
import { readBookCallStepMinEmployeeCount } from 'src/engine/core-modules/onboarding/utils/read-book-call-step-min-employee-count.util';
|
||||
import { NodeEnvironment } from 'src/engine/core-modules/twenty-config/interfaces/node-environment.interface';
|
||||
import { SupportDriver } from 'src/engine/core-modules/twenty-config/interfaces/support.interface';
|
||||
|
||||
@@ -47,6 +49,12 @@ export class ClientConfigService {
|
||||
const calendarBookingPageId = this.twentyConfigService.get(
|
||||
'CALENDAR_BOOKING_PAGE_ID',
|
||||
);
|
||||
const isBookCallOnboardingStepEnabled = isDefined(
|
||||
readBookCallStepMinEmployeeCount(this.twentyConfigService),
|
||||
);
|
||||
const isCompanyEnrichmentEnabled = readIsCompanyEnrichmentEnabled(
|
||||
this.twentyConfigService,
|
||||
);
|
||||
|
||||
const isEmailingDomainInDemoMode =
|
||||
this.twentyConfigService.get('EMAILING_DOMAIN_DRIVER') ===
|
||||
@@ -284,6 +292,8 @@ export class ClientConfigService {
|
||||
calendarBookingPageId: isNonEmptyString(calendarBookingPageId)
|
||||
? calendarBookingPageId
|
||||
: undefined,
|
||||
isBookCallOnboardingStepEnabled,
|
||||
isCompanyEnrichmentEnabled,
|
||||
isCloudflareIntegrationEnabled: this.isCloudflareIntegrationEnabled(),
|
||||
isClickHouseConfigured: !!this.twentyConfigService.get('CLICKHOUSE_URL'),
|
||||
isWorkspaceSchemaDDLLocked: this.twentyConfigService.get(
|
||||
|
||||
+2
@@ -4,6 +4,7 @@ import { CompanyEnrichmentResolver } from 'src/engine/core-modules/company-enric
|
||||
import { CompanyEnrichmentService } from 'src/engine/core-modules/company-enrichment/services/company-enrichment.service';
|
||||
import { PeopleDataLabsCompanyClientService } from 'src/engine/core-modules/company-enrichment/services/people-data-labs-company-client.service';
|
||||
import { KeyValuePairModule } from 'src/engine/core-modules/key-value-pair/key-value-pair.module';
|
||||
import { OnboardingModule } from 'src/engine/core-modules/onboarding/onboarding.module';
|
||||
import { SecureHttpClientModule } from 'src/engine/core-modules/secure-http-client/secure-http-client.module';
|
||||
import { ThrottlerModule } from 'src/engine/core-modules/throttler/throttler.module';
|
||||
import { UserWorkspaceModule } from 'src/engine/core-modules/user-workspace/user-workspace.module';
|
||||
@@ -11,6 +12,7 @@ import { UserWorkspaceModule } from 'src/engine/core-modules/user-workspace/user
|
||||
@Module({
|
||||
imports: [
|
||||
KeyValuePairModule,
|
||||
OnboardingModule,
|
||||
SecureHttpClientModule,
|
||||
ThrottlerModule,
|
||||
UserWorkspaceModule,
|
||||
|
||||
+3
@@ -12,4 +12,7 @@ export class WorkspaceCompanyEnrichmentResultDTO {
|
||||
|
||||
@Field(() => GraphQLJSON, { nullable: true })
|
||||
enrichment: WorkspaceCompanyEnrichment | null;
|
||||
|
||||
@Field(() => Boolean)
|
||||
isBookCallOnboardingStepPending: boolean;
|
||||
}
|
||||
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
import { Test, type TestingModule } from '@nestjs/testing';
|
||||
|
||||
import { type AuthContextUser } from 'src/engine/core-modules/auth/types/auth-context.type';
|
||||
import { CompanyEnrichmentResolver } from 'src/engine/core-modules/company-enrichment/resolvers/company-enrichment.resolver';
|
||||
import { CompanyEnrichmentService } from 'src/engine/core-modules/company-enrichment/services/company-enrichment.service';
|
||||
import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.service';
|
||||
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
|
||||
describe('CompanyEnrichmentResolver', () => {
|
||||
let resolver: CompanyEnrichmentResolver;
|
||||
let companyEnrichmentService: { enrichCompanyForWorkspaceCreator: jest.Mock };
|
||||
let onboardingService: {
|
||||
setOnboardingBookCallPendingIfQualified: jest.Mock;
|
||||
isOnboardingBookCallPending: jest.Mock;
|
||||
};
|
||||
|
||||
const user = { id: 'user-id', email: 'foo@acme.com' } as AuthContextUser;
|
||||
const workspace = { id: 'workspace-id' } as WorkspaceEntity;
|
||||
|
||||
beforeEach(async () => {
|
||||
companyEnrichmentService = {
|
||||
enrichCompanyForWorkspaceCreator: jest.fn(),
|
||||
};
|
||||
onboardingService = {
|
||||
setOnboardingBookCallPendingIfQualified: jest.fn(),
|
||||
isOnboardingBookCallPending: jest.fn().mockResolvedValue(false),
|
||||
};
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
CompanyEnrichmentResolver,
|
||||
{
|
||||
provide: CompanyEnrichmentService,
|
||||
useValue: companyEnrichmentService,
|
||||
},
|
||||
{
|
||||
provide: OnboardingService,
|
||||
useValue: onboardingService,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
resolver = module.get<CompanyEnrichmentResolver>(CompanyEnrichmentResolver);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should hand the enriched employee count to the book-call qualification on a match', async () => {
|
||||
companyEnrichmentService.enrichCompanyForWorkspaceCreator.mockResolvedValue(
|
||||
{
|
||||
outcome: 'matched',
|
||||
enrichment: { domain: 'acme.com', employeeCount: 320 },
|
||||
},
|
||||
);
|
||||
|
||||
const result = await resolver.enrichWorkspaceCompany(user, workspace);
|
||||
|
||||
expect(result.outcome).toBe('matched');
|
||||
expect(
|
||||
onboardingService.setOnboardingBookCallPendingIfQualified,
|
||||
).toHaveBeenCalledWith({
|
||||
userId: user.id,
|
||||
workspaceId: workspace.id,
|
||||
employeeCount: 320,
|
||||
});
|
||||
});
|
||||
|
||||
it.each(['unavailable', 'transientError'])(
|
||||
'should not qualify for the book-call step on outcome %s',
|
||||
async (outcome) => {
|
||||
companyEnrichmentService.enrichCompanyForWorkspaceCreator.mockResolvedValue(
|
||||
{ outcome, enrichment: null },
|
||||
);
|
||||
|
||||
await resolver.enrichWorkspaceCompany(user, workspace);
|
||||
|
||||
expect(
|
||||
onboardingService.setOnboardingBookCallPendingIfQualified,
|
||||
).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it('should report the stored pending flag rather than whether this call flagged it', async () => {
|
||||
companyEnrichmentService.enrichCompanyForWorkspaceCreator.mockResolvedValue(
|
||||
{ outcome: 'transientError', enrichment: null },
|
||||
);
|
||||
onboardingService.isOnboardingBookCallPending.mockResolvedValue(true);
|
||||
|
||||
const result = await resolver.enrichWorkspaceCompany(user, workspace);
|
||||
|
||||
expect(result.isBookCallOnboardingStepPending).toBe(true);
|
||||
});
|
||||
|
||||
it('should report no pending step when the user has none', async () => {
|
||||
companyEnrichmentService.enrichCompanyForWorkspaceCreator.mockResolvedValue(
|
||||
{
|
||||
outcome: 'matched',
|
||||
enrichment: { domain: 'acme.com', employeeCount: 2 },
|
||||
},
|
||||
);
|
||||
|
||||
const result = await resolver.enrichWorkspaceCompany(user, workspace);
|
||||
|
||||
expect(result.isBookCallOnboardingStepPending).toBe(false);
|
||||
});
|
||||
});
|
||||
+30
-8
@@ -1,14 +1,14 @@
|
||||
import { UseFilters, UseGuards, UsePipes } from '@nestjs/common';
|
||||
import { Mutation } from '@nestjs/graphql';
|
||||
|
||||
import { type WorkspaceCompanyEnrichmentResult } from 'twenty-shared/workspace';
|
||||
|
||||
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
|
||||
import { type AuthContextUser } from 'src/engine/core-modules/auth/types/auth-context.type';
|
||||
import { WorkspaceCompanyEnrichmentResultDTO } from 'src/engine/core-modules/company-enrichment/dtos/workspace-company-enrichment-result.dto';
|
||||
import { WorkspaceCompanyEnrichmentOutcome } from 'src/engine/core-modules/company-enrichment/enums/workspace-company-enrichment-outcome.enum';
|
||||
import { CompanyEnrichmentService } from 'src/engine/core-modules/company-enrichment/services/company-enrichment.service';
|
||||
import { PreventNestToAutoLogGraphqlErrorsFilter } from 'src/engine/core-modules/graphql/filters/prevent-nest-to-auto-log-graphql-errors.filter';
|
||||
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
|
||||
import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.service';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator';
|
||||
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
|
||||
@@ -23,6 +23,7 @@ import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
|
||||
export class CompanyEnrichmentResolver {
|
||||
constructor(
|
||||
private readonly companyEnrichmentService: CompanyEnrichmentService,
|
||||
private readonly onboardingService: OnboardingService,
|
||||
) {}
|
||||
|
||||
@Mutation(() => WorkspaceCompanyEnrichmentResultDTO)
|
||||
@@ -30,11 +31,32 @@ export class CompanyEnrichmentResolver {
|
||||
async enrichWorkspaceCompany(
|
||||
@AuthUser() user: AuthContextUser,
|
||||
@AuthWorkspace() workspace: WorkspaceEntity,
|
||||
): Promise<WorkspaceCompanyEnrichmentResult> {
|
||||
return this.companyEnrichmentService.enrichCompanyForWorkspaceCreator({
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
workspaceId: workspace.id,
|
||||
});
|
||||
): Promise<WorkspaceCompanyEnrichmentResultDTO> {
|
||||
const enrichmentResult =
|
||||
await this.companyEnrichmentService.enrichCompanyForWorkspaceCreator({
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
workspaceId: workspace.id,
|
||||
});
|
||||
|
||||
if (enrichmentResult.outcome === 'matched') {
|
||||
await this.onboardingService.setOnboardingBookCallPendingIfQualified({
|
||||
userId: user.id,
|
||||
workspaceId: workspace.id,
|
||||
employeeCount: enrichmentResult.enrichment.employeeCount,
|
||||
});
|
||||
}
|
||||
|
||||
const isBookCallOnboardingStepPending =
|
||||
await this.onboardingService.isOnboardingBookCallPending({
|
||||
userId: user.id,
|
||||
workspaceId: workspace.id,
|
||||
});
|
||||
|
||||
return {
|
||||
...enrichmentResult,
|
||||
outcome: WorkspaceCompanyEnrichmentOutcome[enrichmentResult.outcome],
|
||||
isBookCallOnboardingStepPending,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+89
-25
@@ -22,7 +22,7 @@ describe('CompanyEnrichmentService', () => {
|
||||
};
|
||||
let throttlerService: { tokenBucketThrottleOrThrow: jest.Mock };
|
||||
let keyValuePairService: { set: jest.Mock };
|
||||
let twentyConfigService: { get: jest.Mock };
|
||||
let configValues: Record<string, unknown>;
|
||||
|
||||
const workspaceId = 'workspace-id';
|
||||
const creatorUserId = 'creator-user-id';
|
||||
@@ -41,8 +41,9 @@ describe('CompanyEnrichmentService', () => {
|
||||
};
|
||||
throttlerService = { tokenBucketThrottleOrThrow: jest.fn() };
|
||||
keyValuePairService = { set: jest.fn() };
|
||||
twentyConfigService = {
|
||||
get: jest.fn().mockReturnValue(true),
|
||||
configValues = {
|
||||
IS_ONBOARDING_AI_CHAT_ENABLED: true,
|
||||
PEOPLE_DATA_LABS_API_KEY: 'pdl-key',
|
||||
};
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
@@ -62,7 +63,7 @@ describe('CompanyEnrichmentService', () => {
|
||||
},
|
||||
{
|
||||
provide: TwentyConfigService,
|
||||
useValue: twentyConfigService,
|
||||
useValue: { get: (key: string) => configValues[key] },
|
||||
},
|
||||
{
|
||||
provide: KeyValuePairService,
|
||||
@@ -204,27 +205,6 @@ describe('CompanyEnrichmentService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should return unavailable without any lookup when onboarding AI chat is off', async () => {
|
||||
twentyConfigService.get.mockReturnValue(false);
|
||||
|
||||
const result = await service.enrichCompanyForWorkspaceCreator({
|
||||
userId: creatorUserId,
|
||||
email: 'foo@acme.com',
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ outcome: 'unavailable', enrichment: null });
|
||||
expect(twentyConfigService.get).toHaveBeenCalledWith(
|
||||
'IS_ONBOARDING_AI_CHAT_ENABLED',
|
||||
);
|
||||
expect(userWorkspaceService.isWorkspaceCreator).not.toHaveBeenCalled();
|
||||
expect(throttlerService.tokenBucketThrottleOrThrow).not.toHaveBeenCalled();
|
||||
expect(
|
||||
peopleDataLabsCompanyClientService.enrichCompanyByDomain,
|
||||
).not.toHaveBeenCalled();
|
||||
expect(keyValuePairService.set).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not consume throttle tokens when the feature is disabled', async () => {
|
||||
peopleDataLabsCompanyClientService.isEnabled.mockReturnValue(false);
|
||||
|
||||
@@ -336,6 +316,90 @@ describe('CompanyEnrichmentService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should not call the client when no api key is configured', async () => {
|
||||
configValues = { IS_ONBOARDING_AI_CHAT_ENABLED: true };
|
||||
|
||||
const result = await service.enrichCompanyForWorkspaceCreator({
|
||||
userId: creatorUserId,
|
||||
email: 'foo@acme.com',
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
expect(result.outcome).toBe('unavailable');
|
||||
expect(
|
||||
peopleDataLabsCompanyClientService.enrichCompanyByDomain,
|
||||
).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not call the client when no consumer of the enrichment is configured', async () => {
|
||||
configValues = {
|
||||
IS_ONBOARDING_AI_CHAT_ENABLED: false,
|
||||
PEOPLE_DATA_LABS_API_KEY: 'pdl-key',
|
||||
};
|
||||
|
||||
const result = await service.enrichCompanyForWorkspaceCreator({
|
||||
userId: creatorUserId,
|
||||
email: 'foo@acme.com',
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ outcome: 'unavailable', enrichment: null });
|
||||
expect(throttlerService.tokenBucketThrottleOrThrow).not.toHaveBeenCalled();
|
||||
expect(
|
||||
peopleDataLabsCompanyClientService.enrichCompanyByDomain,
|
||||
).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ CALENDAR_BOOKING_PAGE_ID: 'team/twenty/talk-to-us' },
|
||||
{ ONBOARDING_BOOK_CALL_MIN_EMPLOYEE_COUNT: 50 },
|
||||
{
|
||||
CALENDAR_BOOKING_PAGE_ID: 'team/twenty/talk-to-us',
|
||||
ONBOARDING_BOOK_CALL_MIN_EMPLOYEE_COUNT: 0,
|
||||
},
|
||||
])(
|
||||
'should not call the client for a half-configured book-call step (%j)',
|
||||
async (bookCallConfig) => {
|
||||
configValues = {
|
||||
IS_ONBOARDING_AI_CHAT_ENABLED: false,
|
||||
PEOPLE_DATA_LABS_API_KEY: 'pdl-key',
|
||||
...bookCallConfig,
|
||||
};
|
||||
|
||||
await service.enrichCompanyForWorkspaceCreator({
|
||||
userId: creatorUserId,
|
||||
email: 'foo@acme.com',
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
expect(
|
||||
peopleDataLabsCompanyClientService.enrichCompanyByDomain,
|
||||
).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it('should enrich for the book-call step alone when the ai chat is disabled', async () => {
|
||||
configValues = {
|
||||
IS_ONBOARDING_AI_CHAT_ENABLED: false,
|
||||
CALENDAR_BOOKING_PAGE_ID: 'team/twenty/talk-to-us',
|
||||
ONBOARDING_BOOK_CALL_MIN_EMPLOYEE_COUNT: 50,
|
||||
PEOPLE_DATA_LABS_API_KEY: 'pdl-key',
|
||||
};
|
||||
peopleDataLabsCompanyClientService.enrichCompanyByDomain.mockResolvedValue({
|
||||
outcome: 'matched',
|
||||
data: { name: 'Acme Inc', employee_count: 320 },
|
||||
});
|
||||
|
||||
const result = await service.enrichCompanyForWorkspaceCreator({
|
||||
userId: creatorUserId,
|
||||
email: 'foo@acme.com',
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
expect(result.outcome).toBe('matched');
|
||||
expect(result.enrichment).toMatchObject({ employeeCount: 320 });
|
||||
});
|
||||
|
||||
it('should rethrow non throttler errors from the throttler', async () => {
|
||||
throttlerService.tokenBucketThrottleOrThrow.mockRejectedValue(
|
||||
new Error('redis down'),
|
||||
|
||||
+7
-7
@@ -15,6 +15,7 @@ import { type PeopleDataLabsCompanyEnrichResult } from 'src/engine/core-modules/
|
||||
import { toWorkspaceCompanyEnrichment } from 'src/engine/core-modules/company-enrichment/utils/to-workspace-company-enrichment.util';
|
||||
import { KeyValuePairType } from 'src/engine/core-modules/key-value-pair/key-value-pair.entity';
|
||||
import { KeyValuePairService } from 'src/engine/core-modules/key-value-pair/key-value-pair.service';
|
||||
import { readIsCompanyEnrichmentEnabled } from 'src/engine/core-modules/company-enrichment/utils/read-is-company-enrichment-enabled.util';
|
||||
import {
|
||||
ThrottlerException,
|
||||
ThrottlerExceptionCode,
|
||||
@@ -47,8 +48,7 @@ export class CompanyEnrichmentService {
|
||||
email: string;
|
||||
workspaceId: string;
|
||||
}): Promise<WorkspaceCompanyEnrichmentResult> {
|
||||
// The enrichment only feeds the AI-chat workspace setup, so it is pointless without it.
|
||||
if (!this.twentyConfigService.get('IS_ONBOARDING_AI_CHAT_ENABLED')) {
|
||||
if (!this.hasEnrichmentConsumer()) {
|
||||
return { outcome: 'unavailable', enrichment: null };
|
||||
}
|
||||
|
||||
@@ -104,16 +104,16 @@ export class CompanyEnrichmentService {
|
||||
|
||||
// 'skipped' means the feature is disabled (no API key); don't persist the domain in that case.
|
||||
if (result.outcome !== 'skipped') {
|
||||
await this.recordEnrichmentAttempt({
|
||||
workspaceId,
|
||||
domain,
|
||||
result,
|
||||
});
|
||||
await this.recordEnrichmentAttempt({ workspaceId, domain, result });
|
||||
}
|
||||
|
||||
return enrichmentResult;
|
||||
}
|
||||
|
||||
private hasEnrichmentConsumer(): boolean {
|
||||
return readIsCompanyEnrichmentEnabled(this.twentyConfigService);
|
||||
}
|
||||
|
||||
private resolveEnrichmentResult({
|
||||
result,
|
||||
workspaceId,
|
||||
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
import { readIsCompanyEnrichmentEnabled } from 'src/engine/core-modules/company-enrichment/utils/read-is-company-enrichment-enabled.util';
|
||||
import { type TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
|
||||
const buildConfigService = (values: Record<string, unknown>) =>
|
||||
({
|
||||
get: (key: string) => values[key],
|
||||
}) as TwentyConfigService;
|
||||
|
||||
describe('readIsCompanyEnrichmentEnabled', () => {
|
||||
it('should be enabled with the AI chat consumer and an api key', () => {
|
||||
expect(
|
||||
readIsCompanyEnrichmentEnabled(
|
||||
buildConfigService({
|
||||
IS_ONBOARDING_AI_CHAT_ENABLED: true,
|
||||
PEOPLE_DATA_LABS_API_KEY: 'pdl-key',
|
||||
}),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should be enabled with the book-call consumer and an api key', () => {
|
||||
expect(
|
||||
readIsCompanyEnrichmentEnabled(
|
||||
buildConfigService({
|
||||
IS_ONBOARDING_AI_CHAT_ENABLED: false,
|
||||
CALENDAR_BOOKING_PAGE_ID: 'team/twenty/talk-to-us',
|
||||
ONBOARDING_BOOK_CALL_MIN_EMPLOYEE_COUNT: 20,
|
||||
PEOPLE_DATA_LABS_API_KEY: 'pdl-key',
|
||||
}),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should be disabled without any consumer', () => {
|
||||
expect(
|
||||
readIsCompanyEnrichmentEnabled(
|
||||
buildConfigService({
|
||||
IS_ONBOARDING_AI_CHAT_ENABLED: false,
|
||||
PEOPLE_DATA_LABS_API_KEY: 'pdl-key',
|
||||
}),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('should be disabled without an api key even when a consumer is configured', () => {
|
||||
expect(
|
||||
readIsCompanyEnrichmentEnabled(
|
||||
buildConfigService({
|
||||
IS_ONBOARDING_AI_CHAT_ENABLED: true,
|
||||
PEOPLE_DATA_LABS_API_KEY: undefined,
|
||||
}),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('should be disabled with neither a consumer nor an api key', () => {
|
||||
expect(readIsCompanyEnrichmentEnabled(buildConfigService({}))).toBe(false);
|
||||
});
|
||||
|
||||
it('should not treat an unconfigured book-call threshold as a consumer', () => {
|
||||
expect(
|
||||
readIsCompanyEnrichmentEnabled(
|
||||
buildConfigService({
|
||||
IS_ONBOARDING_AI_CHAT_ENABLED: false,
|
||||
CALENDAR_BOOKING_PAGE_ID: 'team/twenty/talk-to-us',
|
||||
PEOPLE_DATA_LABS_API_KEY: 'pdl-key',
|
||||
}),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { readBookCallStepMinEmployeeCount } from 'src/engine/core-modules/onboarding/utils/read-book-call-step-min-employee-count.util';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
|
||||
// Enrichment is only worth running when something consumes it and the provider
|
||||
// can actually answer, so callers can skip the round-trip entirely.
|
||||
export const readIsCompanyEnrichmentEnabled = (
|
||||
twentyConfigService: TwentyConfigService,
|
||||
): boolean => {
|
||||
const hasConsumer =
|
||||
twentyConfigService.get('IS_ONBOARDING_AI_CHAT_ENABLED') ||
|
||||
isDefined(readBookCallStepMinEmployeeCount(twentyConfigService));
|
||||
|
||||
return (
|
||||
hasConsumer &&
|
||||
isNonEmptyString(twentyConfigService.get('PEOPLE_DATA_LABS_API_KEY'))
|
||||
);
|
||||
};
|
||||
+43
@@ -121,6 +121,49 @@ export class KeyValuePairService<
|
||||
});
|
||||
}
|
||||
|
||||
// Returns false when a row already exists, so callers can use this as a
|
||||
// single-winner claim between concurrent requests.
|
||||
async setIfNotExists<K extends keyof KeyValueTypesMap>(
|
||||
{
|
||||
userId,
|
||||
workspaceId,
|
||||
applicationId,
|
||||
key,
|
||||
value,
|
||||
type,
|
||||
}: {
|
||||
userId?: string | null;
|
||||
workspaceId?: string | null;
|
||||
applicationId?: string | null;
|
||||
key: Extract<K, string>;
|
||||
value: KeyValueTypesMap[K];
|
||||
type: KeyValuePairType;
|
||||
},
|
||||
queryRunner?: QueryRunner,
|
||||
): Promise<boolean> {
|
||||
const keyValuePairRepository = queryRunner
|
||||
? queryRunner.manager.getRepository(KeyValuePairEntity)
|
||||
: this.keyValuePairRepository;
|
||||
|
||||
const insertResult = await keyValuePairRepository
|
||||
.createQueryBuilder()
|
||||
.insert()
|
||||
.into(KeyValuePairEntity)
|
||||
.values({
|
||||
userId: userId ?? null,
|
||||
workspaceId: workspaceId ?? null,
|
||||
applicationId: applicationId ?? null,
|
||||
key,
|
||||
value,
|
||||
type,
|
||||
})
|
||||
.orIgnore()
|
||||
.returning('id')
|
||||
.execute();
|
||||
|
||||
return insertResult.raw.length > 0;
|
||||
}
|
||||
|
||||
async delete(
|
||||
{
|
||||
userId,
|
||||
|
||||
+1
@@ -5,5 +5,6 @@ export enum OnboardingStatus {
|
||||
SYNC_EMAIL = 'SYNC_EMAIL',
|
||||
APPS_INSTALLATION = 'APPS_INSTALLATION',
|
||||
INVITE_TEAM = 'INVITE_TEAM',
|
||||
BOOK_CALL = 'BOOK_CALL',
|
||||
COMPLETED = 'COMPLETED',
|
||||
}
|
||||
|
||||
@@ -56,6 +56,21 @@ export class OnboardingResolver {
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Mutation(() => OnboardingStepSuccessDTO)
|
||||
@UseGuards(NoPermissionGuard)
|
||||
async completeBookCallOnboardingStep(
|
||||
@AuthUser() user: AuthContextUser,
|
||||
@AuthWorkspace() workspace: WorkspaceEntity,
|
||||
): Promise<OnboardingStepSuccessDTO> {
|
||||
await this.onboardingService.setOnboardingBookCallPending({
|
||||
userId: user.id,
|
||||
workspaceId: workspace.id,
|
||||
value: false,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Mutation(() => OnboardingStepSuccessDTO)
|
||||
@UseGuards(NoPermissionGuard)
|
||||
async triggerInstallAppsOnboardingStep(
|
||||
|
||||
+473
-2
@@ -1,7 +1,8 @@
|
||||
import { Test, type TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import { getDataSourceToken, getRepositoryToken } from '@nestjs/typeorm';
|
||||
|
||||
import { Repository } from 'typeorm';
|
||||
import { WorkspaceActivationStatus } from 'twenty-shared/workspace';
|
||||
import { type DataSource, type QueryRunner, Repository } from 'typeorm';
|
||||
|
||||
import { BillingCreditService } from 'src/engine/core-modules/billing/services/billing-credit.service';
|
||||
import { BillingService } from 'src/engine/core-modules/billing/services/billing.service';
|
||||
@@ -9,6 +10,7 @@ import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queu
|
||||
import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service';
|
||||
import { getQueueToken } from 'src/engine/core-modules/message-queue/utils/get-queue-token.util';
|
||||
import { ONBOARDING_INSTALLABLE_APP_UNIVERSAL_IDENTIFIERS } from 'src/engine/core-modules/onboarding/constants/onboarding-installable-app-universal-identifiers';
|
||||
import { OnboardingStatus } from 'src/engine/core-modules/onboarding/enums/onboarding-status.enum';
|
||||
import { INSTALL_ONBOARDING_APPS_JOB_NAME } from 'src/engine/core-modules/onboarding/jobs/install-onboarding-apps.job-constants';
|
||||
import {
|
||||
OnboardingService,
|
||||
@@ -16,21 +18,30 @@ import {
|
||||
} from 'src/engine/core-modules/onboarding/onboarding.service';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { UserVarsService } from 'src/engine/core-modules/user/user-vars/services/user-vars.service';
|
||||
import { UserEntity } from 'src/engine/core-modules/user/user.entity';
|
||||
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
|
||||
describe('OnboardingService', () => {
|
||||
let service: OnboardingService;
|
||||
let userVarsService: UserVarsService;
|
||||
let billingService: BillingService;
|
||||
let billingCreditService: BillingCreditService;
|
||||
let twentyConfigService: TwentyConfigService;
|
||||
let messageQueueService: MessageQueueService;
|
||||
let userWorkspaceRepository: Repository<UserWorkspaceEntity>;
|
||||
let workspaceRepository: Repository<WorkspaceEntity>;
|
||||
let dataSource: DataSource;
|
||||
|
||||
const userId = 'user-id';
|
||||
const workspaceId = 'workspace-id';
|
||||
const mockQueryRunner = {} as QueryRunner;
|
||||
|
||||
let transactionQueryRunner: QueryRunner | undefined = mockQueryRunner;
|
||||
|
||||
beforeEach(async () => {
|
||||
transactionQueryRunner = mockQueryRunner;
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
OnboardingService,
|
||||
@@ -50,7 +61,9 @@ describe('OnboardingService', () => {
|
||||
provide: UserVarsService,
|
||||
useValue: {
|
||||
get: jest.fn(),
|
||||
getAll: jest.fn(),
|
||||
set: jest.fn(),
|
||||
setIfNotExists: jest.fn().mockResolvedValue(true),
|
||||
delete: jest.fn(),
|
||||
},
|
||||
},
|
||||
@@ -76,11 +89,23 @@ describe('OnboardingService', () => {
|
||||
add: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: getDataSourceToken(),
|
||||
useValue: {
|
||||
transaction: jest.fn((runInTransaction) =>
|
||||
runInTransaction({ queryRunner: transactionQueryRunner }),
|
||||
),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<OnboardingService>(OnboardingService);
|
||||
userVarsService = module.get<UserVarsService>(UserVarsService);
|
||||
billingService = module.get<BillingService>(BillingService);
|
||||
workspaceRepository = module.get<Repository<WorkspaceEntity>>(
|
||||
getRepositoryToken(WorkspaceEntity),
|
||||
);
|
||||
billingCreditService =
|
||||
module.get<BillingCreditService>(BillingCreditService);
|
||||
twentyConfigService = module.get<TwentyConfigService>(TwentyConfigService);
|
||||
@@ -90,12 +115,107 @@ describe('OnboardingService', () => {
|
||||
userWorkspaceRepository = module.get<Repository<UserWorkspaceEntity>>(
|
||||
getRepositoryToken(UserWorkspaceEntity),
|
||||
);
|
||||
dataSource = module.get<DataSource>(getDataSourceToken());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('getOnboardingStatus', () => {
|
||||
const user = { id: userId } as UserEntity;
|
||||
|
||||
const mockOnboardingState = ({
|
||||
pendingSteps,
|
||||
isPlanRequired,
|
||||
isBookCallStepConfigured = true,
|
||||
}: {
|
||||
pendingSteps: OnboardingStepKeys[];
|
||||
isPlanRequired: boolean;
|
||||
isBookCallStepConfigured?: boolean;
|
||||
}) => {
|
||||
jest.spyOn(workspaceRepository, 'findOne').mockResolvedValue({
|
||||
id: workspaceId,
|
||||
activationStatus: WorkspaceActivationStatus.ACTIVE,
|
||||
} as WorkspaceEntity);
|
||||
jest
|
||||
.spyOn(userVarsService, 'getAll')
|
||||
.mockResolvedValue(
|
||||
new Map(pendingSteps.map((key) => [key, true])) as never,
|
||||
);
|
||||
jest
|
||||
.spyOn(billingService, 'isSubscriptionIncompleteOnboardingStatus')
|
||||
.mockResolvedValue(isPlanRequired);
|
||||
jest
|
||||
.spyOn(twentyConfigService, 'get')
|
||||
.mockImplementation((key: string) => {
|
||||
if (!isBookCallStepConfigured) {
|
||||
return undefined as never;
|
||||
}
|
||||
|
||||
return (
|
||||
key === 'CALENDAR_BOOKING_PAGE_ID' ? 'team/twenty/talk-to-us' : 50
|
||||
) as never;
|
||||
});
|
||||
};
|
||||
|
||||
it('should return BOOK_CALL when the step is pending and a plan is still required', async () => {
|
||||
mockOnboardingState({
|
||||
pendingSteps: [OnboardingStepKeys.ONBOARDING_BOOK_CALL_PENDING],
|
||||
isPlanRequired: true,
|
||||
});
|
||||
|
||||
expect(await service.getOnboardingStatus({ user, workspaceId })).toBe(
|
||||
OnboardingStatus.BOOK_CALL,
|
||||
);
|
||||
});
|
||||
|
||||
it('should ignore a pending BOOK_CALL once the workspace has a subscription', async () => {
|
||||
mockOnboardingState({
|
||||
pendingSteps: [OnboardingStepKeys.ONBOARDING_BOOK_CALL_PENDING],
|
||||
isPlanRequired: false,
|
||||
});
|
||||
|
||||
expect(await service.getOnboardingStatus({ user, workspaceId })).toBe(
|
||||
OnboardingStatus.COMPLETED,
|
||||
);
|
||||
});
|
||||
|
||||
it('should ignore a pending BOOK_CALL once the booking page is unconfigured', async () => {
|
||||
mockOnboardingState({
|
||||
pendingSteps: [OnboardingStepKeys.ONBOARDING_BOOK_CALL_PENDING],
|
||||
isPlanRequired: true,
|
||||
isBookCallStepConfigured: false,
|
||||
});
|
||||
|
||||
expect(await service.getOnboardingStatus({ user, workspaceId })).toBe(
|
||||
OnboardingStatus.PLAN_REQUIRED,
|
||||
);
|
||||
});
|
||||
|
||||
it('should keep INVITE_TEAM ahead of a pending BOOK_CALL', async () => {
|
||||
mockOnboardingState({
|
||||
pendingSteps: [
|
||||
OnboardingStepKeys.ONBOARDING_INVITE_TEAM_PENDING,
|
||||
OnboardingStepKeys.ONBOARDING_BOOK_CALL_PENDING,
|
||||
],
|
||||
isPlanRequired: true,
|
||||
});
|
||||
|
||||
expect(await service.getOnboardingStatus({ user, workspaceId })).toBe(
|
||||
OnboardingStatus.INVITE_TEAM,
|
||||
);
|
||||
});
|
||||
|
||||
it('should return PLAN_REQUIRED when no step is pending and a plan is required', async () => {
|
||||
mockOnboardingState({ pendingSteps: [], isPlanRequired: true });
|
||||
|
||||
expect(await service.getOnboardingStatus({ user, workspaceId })).toBe(
|
||||
OnboardingStatus.PLAN_REQUIRED,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('completeOnboardingConnectAccountStep', () => {
|
||||
it('should credit the import-contacts reward when the step was claimed by the first workspace user', async () => {
|
||||
jest.spyOn(userVarsService, 'delete').mockResolvedValue(1);
|
||||
@@ -289,4 +409,355 @@ describe('OnboardingService', () => {
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('setOnboardingBookCallPendingIfQualified', () => {
|
||||
const mockConfig = ({
|
||||
calendarBookingPageId,
|
||||
minEmployeeCount,
|
||||
}: {
|
||||
calendarBookingPageId?: string;
|
||||
minEmployeeCount?: number;
|
||||
}) => {
|
||||
jest
|
||||
.spyOn(twentyConfigService, 'get')
|
||||
.mockImplementation((key: string) =>
|
||||
key === 'CALENDAR_BOOKING_PAGE_ID'
|
||||
? calendarBookingPageId
|
||||
: minEmployeeCount,
|
||||
);
|
||||
};
|
||||
|
||||
it('should not offer the step twice', async () => {
|
||||
mockConfig({
|
||||
calendarBookingPageId: 'team/twenty/talk-to-us',
|
||||
minEmployeeCount: 50,
|
||||
});
|
||||
jest.spyOn(userVarsService, 'setIfNotExists').mockResolvedValue(false);
|
||||
|
||||
const isPending = await service.setOnboardingBookCallPendingIfQualified({
|
||||
userId,
|
||||
workspaceId,
|
||||
employeeCount: 320,
|
||||
});
|
||||
|
||||
expect(isPending).toBe(false);
|
||||
expect(userVarsService.set).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should record the offer so a later enrichment cannot reopen the step', async () => {
|
||||
mockConfig({
|
||||
calendarBookingPageId: 'team/twenty/talk-to-us',
|
||||
minEmployeeCount: 50,
|
||||
});
|
||||
|
||||
const isPending = await service.setOnboardingBookCallPendingIfQualified({
|
||||
userId,
|
||||
workspaceId,
|
||||
employeeCount: 320,
|
||||
});
|
||||
|
||||
expect(isPending).toBe(true);
|
||||
expect(userVarsService.setIfNotExists).toHaveBeenCalledWith(
|
||||
{
|
||||
userId,
|
||||
workspaceId,
|
||||
key: OnboardingStepKeys.ONBOARDING_BOOK_CALL_OFFERED,
|
||||
value: true,
|
||||
},
|
||||
mockQueryRunner,
|
||||
);
|
||||
});
|
||||
|
||||
it('should let only one of two concurrent qualifications flag the step', async () => {
|
||||
mockConfig({
|
||||
calendarBookingPageId: 'team/twenty/talk-to-us',
|
||||
minEmployeeCount: 50,
|
||||
});
|
||||
jest
|
||||
.spyOn(userVarsService, 'setIfNotExists')
|
||||
.mockResolvedValueOnce(true)
|
||||
.mockResolvedValueOnce(false);
|
||||
|
||||
await Promise.all([
|
||||
service.setOnboardingBookCallPendingIfQualified({
|
||||
userId,
|
||||
workspaceId,
|
||||
employeeCount: 320,
|
||||
}),
|
||||
service.setOnboardingBookCallPendingIfQualified({
|
||||
userId,
|
||||
workspaceId,
|
||||
employeeCount: 320,
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(userVarsService.set).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should write the offer and the pending step in a single transaction', async () => {
|
||||
mockConfig({
|
||||
calendarBookingPageId: 'team/twenty/talk-to-us',
|
||||
minEmployeeCount: 50,
|
||||
});
|
||||
|
||||
await service.setOnboardingBookCallPendingIfQualified({
|
||||
userId,
|
||||
workspaceId,
|
||||
employeeCount: 320,
|
||||
});
|
||||
|
||||
const [[, offeredQueryRunner]] = jest.mocked(
|
||||
userVarsService.setIfNotExists,
|
||||
).mock.calls;
|
||||
const [[, pendingQueryRunner]] = jest.mocked(userVarsService.set).mock
|
||||
.calls;
|
||||
|
||||
expect(offeredQueryRunner).toBe(mockQueryRunner);
|
||||
expect(pendingQueryRunner).toBe(mockQueryRunner);
|
||||
});
|
||||
|
||||
it('should not record the offer when flagging the step fails', async () => {
|
||||
mockConfig({
|
||||
calendarBookingPageId: 'team/twenty/talk-to-us',
|
||||
minEmployeeCount: 50,
|
||||
});
|
||||
jest
|
||||
.spyOn(dataSource, 'transaction')
|
||||
.mockRejectedValue(new Error('user vars down'));
|
||||
|
||||
await expect(
|
||||
service.setOnboardingBookCallPendingIfQualified({
|
||||
userId,
|
||||
workspaceId,
|
||||
employeeCount: 320,
|
||||
}),
|
||||
).resolves.not.toThrow();
|
||||
|
||||
expect(userVarsService.set).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should offer the step again after a failed attempt left nothing behind', async () => {
|
||||
mockConfig({
|
||||
calendarBookingPageId: 'team/twenty/talk-to-us',
|
||||
minEmployeeCount: 50,
|
||||
});
|
||||
jest
|
||||
.spyOn(dataSource, 'transaction')
|
||||
.mockRejectedValueOnce(new Error('user vars down'));
|
||||
|
||||
await service.setOnboardingBookCallPendingIfQualified({
|
||||
userId,
|
||||
workspaceId,
|
||||
employeeCount: 320,
|
||||
});
|
||||
await service.setOnboardingBookCallPendingIfQualified({
|
||||
userId,
|
||||
workspaceId,
|
||||
employeeCount: 320,
|
||||
});
|
||||
|
||||
expect(userVarsService.setIfNotExists).toHaveBeenCalledWith(
|
||||
{
|
||||
userId,
|
||||
workspaceId,
|
||||
key: OnboardingStepKeys.ONBOARDING_BOOK_CALL_OFFERED,
|
||||
value: true,
|
||||
},
|
||||
mockQueryRunner,
|
||||
);
|
||||
});
|
||||
|
||||
it('should not throw when the user vars are unavailable', async () => {
|
||||
mockConfig({
|
||||
calendarBookingPageId: 'team/twenty/talk-to-us',
|
||||
minEmployeeCount: 50,
|
||||
});
|
||||
jest
|
||||
.spyOn(userVarsService, 'setIfNotExists')
|
||||
.mockRejectedValue(new Error('user vars down'));
|
||||
|
||||
await expect(
|
||||
service.setOnboardingBookCallPendingIfQualified({
|
||||
userId,
|
||||
workspaceId,
|
||||
employeeCount: 320,
|
||||
}),
|
||||
).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('should not flag the step when the transaction exposes no query runner', async () => {
|
||||
mockConfig({
|
||||
calendarBookingPageId: 'team/twenty/talk-to-us',
|
||||
minEmployeeCount: 50,
|
||||
});
|
||||
transactionQueryRunner = undefined;
|
||||
|
||||
await expect(
|
||||
service.setOnboardingBookCallPendingIfQualified({
|
||||
userId,
|
||||
workspaceId,
|
||||
employeeCount: 320,
|
||||
}),
|
||||
).resolves.toBe(false);
|
||||
|
||||
expect(userVarsService.setIfNotExists).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should clear the pending var when the step is completed', async () => {
|
||||
await service.setOnboardingBookCallPending({
|
||||
userId,
|
||||
workspaceId,
|
||||
value: false,
|
||||
});
|
||||
|
||||
expect(userVarsService.delete).toHaveBeenCalledWith(
|
||||
{
|
||||
userId,
|
||||
workspaceId,
|
||||
key: OnboardingStepKeys.ONBOARDING_BOOK_CALL_PENDING,
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
expect(userVarsService.set).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([50, 51])(
|
||||
'should flag the step when the employee count is %s',
|
||||
async (employeeCount) => {
|
||||
mockConfig({
|
||||
calendarBookingPageId: 'team/twenty/talk-to-us',
|
||||
minEmployeeCount: 50,
|
||||
});
|
||||
|
||||
await service.setOnboardingBookCallPendingIfQualified({
|
||||
userId,
|
||||
workspaceId,
|
||||
employeeCount,
|
||||
});
|
||||
|
||||
expect(userVarsService.set).toHaveBeenCalledWith(
|
||||
{
|
||||
userId,
|
||||
workspaceId,
|
||||
key: OnboardingStepKeys.ONBOARDING_BOOK_CALL_PENDING,
|
||||
value: true,
|
||||
},
|
||||
mockQueryRunner,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it('should not flag the step below the threshold', async () => {
|
||||
mockConfig({
|
||||
calendarBookingPageId: 'team/twenty/talk-to-us',
|
||||
minEmployeeCount: 50,
|
||||
});
|
||||
|
||||
await service.setOnboardingBookCallPendingIfQualified({
|
||||
userId,
|
||||
workspaceId,
|
||||
employeeCount: 49,
|
||||
});
|
||||
|
||||
expect(userVarsService.set).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not flag the step without an employee count', async () => {
|
||||
mockConfig({
|
||||
calendarBookingPageId: 'team/twenty/talk-to-us',
|
||||
minEmployeeCount: 50,
|
||||
});
|
||||
|
||||
await service.setOnboardingBookCallPendingIfQualified({
|
||||
userId,
|
||||
workspaceId,
|
||||
employeeCount: null,
|
||||
});
|
||||
|
||||
expect(userVarsService.set).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not flag the step when no threshold is configured', async () => {
|
||||
mockConfig({ calendarBookingPageId: 'team/twenty/talk-to-us' });
|
||||
|
||||
await service.setOnboardingBookCallPendingIfQualified({
|
||||
userId,
|
||||
workspaceId,
|
||||
employeeCount: 5000,
|
||||
});
|
||||
|
||||
expect(userVarsService.set).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should treat a zero threshold as unconfigured', async () => {
|
||||
mockConfig({
|
||||
calendarBookingPageId: 'team/twenty/talk-to-us',
|
||||
minEmployeeCount: 0,
|
||||
});
|
||||
|
||||
await service.setOnboardingBookCallPendingIfQualified({
|
||||
userId,
|
||||
workspaceId,
|
||||
employeeCount: 5000,
|
||||
});
|
||||
|
||||
expect(userVarsService.set).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not flag the step when no booking page is configured', async () => {
|
||||
mockConfig({ minEmployeeCount: 50 });
|
||||
|
||||
await service.setOnboardingBookCallPendingIfQualified({
|
||||
userId,
|
||||
workspaceId,
|
||||
employeeCount: 5000,
|
||||
});
|
||||
|
||||
expect(userVarsService.set).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isOnboardingBookCallPending', () => {
|
||||
const mockConfig = ({
|
||||
calendarBookingPageId,
|
||||
minEmployeeCount,
|
||||
}: {
|
||||
calendarBookingPageId?: string;
|
||||
minEmployeeCount?: number;
|
||||
}) => {
|
||||
jest
|
||||
.spyOn(twentyConfigService, 'get')
|
||||
.mockImplementation((key: string) =>
|
||||
key === 'CALENDAR_BOOKING_PAGE_ID'
|
||||
? calendarBookingPageId
|
||||
: minEmployeeCount,
|
||||
);
|
||||
};
|
||||
|
||||
it('should report the stored pending var', async () => {
|
||||
mockConfig({
|
||||
calendarBookingPageId: 'team/twenty/talk-to-us',
|
||||
minEmployeeCount: 50,
|
||||
});
|
||||
jest.spyOn(userVarsService, 'get').mockResolvedValue(true);
|
||||
|
||||
expect(
|
||||
await service.isOnboardingBookCallPending({ userId, workspaceId }),
|
||||
).toBe(true);
|
||||
expect(userVarsService.get).toHaveBeenCalledWith({
|
||||
userId,
|
||||
workspaceId,
|
||||
key: OnboardingStepKeys.ONBOARDING_BOOK_CALL_PENDING,
|
||||
});
|
||||
});
|
||||
|
||||
it('should report false without reading the var when the step is unconfigured', async () => {
|
||||
mockConfig({ minEmployeeCount: 50 });
|
||||
|
||||
expect(
|
||||
await service.isOnboardingBookCallPending({ userId, workspaceId }),
|
||||
).toBe(false);
|
||||
expect(userVarsService.get).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { isNumber } from '@sniptt/guards';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { WorkspaceActivationStatus } from 'twenty-shared/workspace';
|
||||
import { type QueryRunner, Repository } from 'typeorm';
|
||||
import { type DataSource, type QueryRunner, Repository } from 'typeorm';
|
||||
|
||||
import { BillingCreditService } from 'src/engine/core-modules/billing/services/billing-credit.service';
|
||||
import { BillingService } from 'src/engine/core-modules/billing/services/billing.service';
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
INSTALL_ONBOARDING_APPS_JOB_NAME,
|
||||
type InstallOnboardingAppsJobData,
|
||||
} from 'src/engine/core-modules/onboarding/jobs/install-onboarding-apps.job-constants';
|
||||
import { readBookCallStepMinEmployeeCount } from 'src/engine/core-modules/onboarding/utils/read-book-call-step-min-employee-count.util';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { UserVarsService } from 'src/engine/core-modules/user/user-vars/services/user-vars.service';
|
||||
import { UserEntity } from 'src/engine/core-modules/user/user.entity';
|
||||
@@ -27,6 +29,8 @@ export enum OnboardingStepKeys {
|
||||
ONBOARDING_INVITE_TEAM_PENDING = 'ONBOARDING_INVITE_TEAM_PENDING',
|
||||
ONBOARDING_CREATE_PROFILE_PENDING = 'ONBOARDING_CREATE_PROFILE_PENDING',
|
||||
ONBOARDING_INSTALL_APPS_PENDING = 'ONBOARDING_INSTALL_APPS_PENDING',
|
||||
ONBOARDING_BOOK_CALL_PENDING = 'ONBOARDING_BOOK_CALL_PENDING',
|
||||
ONBOARDING_BOOK_CALL_OFFERED = 'ONBOARDING_BOOK_CALL_OFFERED',
|
||||
}
|
||||
|
||||
export type OnboardingKeyValueTypeMap = {
|
||||
@@ -34,6 +38,8 @@ export type OnboardingKeyValueTypeMap = {
|
||||
[OnboardingStepKeys.ONBOARDING_INVITE_TEAM_PENDING]: boolean;
|
||||
[OnboardingStepKeys.ONBOARDING_CREATE_PROFILE_PENDING]: boolean;
|
||||
[OnboardingStepKeys.ONBOARDING_INSTALL_APPS_PENDING]: boolean;
|
||||
[OnboardingStepKeys.ONBOARDING_BOOK_CALL_PENDING]: boolean;
|
||||
[OnboardingStepKeys.ONBOARDING_BOOK_CALL_OFFERED]: boolean;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
@@ -51,6 +57,8 @@ export class OnboardingService {
|
||||
private readonly userWorkspaceRepository: Repository<UserWorkspaceEntity>,
|
||||
@InjectMessageQueue(MessageQueue.workspaceQueue)
|
||||
private readonly messageQueueService: MessageQueueService,
|
||||
@InjectDataSource()
|
||||
private readonly dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
private isWorkspaceActivationPending(workspace: WorkspaceEntity) {
|
||||
@@ -103,6 +111,9 @@ export class OnboardingService {
|
||||
const isInviteTeamPending =
|
||||
userVars.get(OnboardingStepKeys.ONBOARDING_INVITE_TEAM_PENDING) === true;
|
||||
|
||||
const isBookCallPending =
|
||||
userVars.get(OnboardingStepKeys.ONBOARDING_BOOK_CALL_PENDING) === true;
|
||||
|
||||
if (isConnectAccountPending) {
|
||||
return OnboardingStatus.SYNC_EMAIL;
|
||||
}
|
||||
@@ -119,11 +130,20 @@ export class OnboardingService {
|
||||
return OnboardingStatus.INVITE_TEAM;
|
||||
}
|
||||
|
||||
if (
|
||||
const isPlanRequired =
|
||||
await this.billingService.isSubscriptionIncompleteOnboardingStatus(
|
||||
workspace.id,
|
||||
)
|
||||
);
|
||||
|
||||
if (
|
||||
isBookCallPending &&
|
||||
isPlanRequired &&
|
||||
isDefined(readBookCallStepMinEmployeeCount(this.twentyConfigService))
|
||||
) {
|
||||
return OnboardingStatus.BOOK_CALL;
|
||||
}
|
||||
|
||||
if (isPlanRequired) {
|
||||
return OnboardingStatus.PLAN_REQUIRED;
|
||||
}
|
||||
|
||||
@@ -394,6 +414,131 @@ export class OnboardingService {
|
||||
);
|
||||
}
|
||||
|
||||
async isOnboardingBookCallPending({
|
||||
userId,
|
||||
workspaceId,
|
||||
}: {
|
||||
userId: string;
|
||||
workspaceId: string;
|
||||
}): Promise<boolean> {
|
||||
if (
|
||||
!isDefined(readBookCallStepMinEmployeeCount(this.twentyConfigService))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
(await this.userVarsService.get({
|
||||
userId,
|
||||
workspaceId,
|
||||
key: OnboardingStepKeys.ONBOARDING_BOOK_CALL_PENDING,
|
||||
})) === true
|
||||
);
|
||||
}
|
||||
|
||||
async setOnboardingBookCallPending(
|
||||
{
|
||||
userId,
|
||||
workspaceId,
|
||||
value,
|
||||
}: {
|
||||
userId: string;
|
||||
workspaceId: string;
|
||||
value: boolean;
|
||||
},
|
||||
queryRunner?: QueryRunner,
|
||||
) {
|
||||
if (!value) {
|
||||
await this.userVarsService.delete(
|
||||
{
|
||||
userId,
|
||||
workspaceId,
|
||||
key: OnboardingStepKeys.ONBOARDING_BOOK_CALL_PENDING,
|
||||
},
|
||||
queryRunner,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await this.userVarsService.set(
|
||||
{
|
||||
userId,
|
||||
workspaceId,
|
||||
key: OnboardingStepKeys.ONBOARDING_BOOK_CALL_PENDING,
|
||||
value: true,
|
||||
},
|
||||
queryRunner,
|
||||
);
|
||||
}
|
||||
|
||||
async setOnboardingBookCallPendingIfQualified({
|
||||
userId,
|
||||
workspaceId,
|
||||
employeeCount,
|
||||
}: {
|
||||
userId: string;
|
||||
workspaceId: string;
|
||||
employeeCount: number | null;
|
||||
}): Promise<boolean> {
|
||||
const minEmployeeCount = readBookCallStepMinEmployeeCount(
|
||||
this.twentyConfigService,
|
||||
);
|
||||
|
||||
if (
|
||||
!isDefined(minEmployeeCount) ||
|
||||
!isNumber(employeeCount) ||
|
||||
employeeCount < minEmployeeCount
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
return await this.dataSource.transaction(async (entityManager) => {
|
||||
const { queryRunner } = entityManager;
|
||||
|
||||
if (!isDefined(queryRunner)) {
|
||||
throw new Error('Transaction entity manager has no query runner');
|
||||
}
|
||||
|
||||
// Claiming the offer is the single-winner gate: a concurrent enrichment
|
||||
// loses the insert and must not resurrect a step the user already skipped.
|
||||
const hasClaimedBookCallOffer =
|
||||
await this.userVarsService.setIfNotExists(
|
||||
{
|
||||
userId,
|
||||
workspaceId,
|
||||
key: OnboardingStepKeys.ONBOARDING_BOOK_CALL_OFFERED,
|
||||
value: true,
|
||||
},
|
||||
queryRunner,
|
||||
);
|
||||
|
||||
if (!hasClaimedBookCallOffer) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await this.setOnboardingBookCallPending(
|
||||
{
|
||||
userId,
|
||||
workspaceId,
|
||||
value: true,
|
||||
},
|
||||
queryRunner,
|
||||
);
|
||||
|
||||
return true;
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to flag the book-call onboarding step for user ${userId} in workspace ${workspaceId}`,
|
||||
error,
|
||||
);
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async setOnboardingCreateProfilePending(
|
||||
{
|
||||
userId,
|
||||
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
import { getBookCallStepMinEmployeeCount } from 'src/engine/core-modules/onboarding/utils/get-book-call-step-min-employee-count.util';
|
||||
|
||||
describe('getBookCallStepMinEmployeeCount', () => {
|
||||
const calendarBookingPageId = 'team/twenty/talk-to-us';
|
||||
|
||||
it('should return the threshold when both settings are configured', () => {
|
||||
expect(
|
||||
getBookCallStepMinEmployeeCount({
|
||||
calendarBookingPageId,
|
||||
minEmployeeCount: 50,
|
||||
}),
|
||||
).toBe(50);
|
||||
});
|
||||
|
||||
it('should treat a zero threshold as disabled', () => {
|
||||
expect(
|
||||
getBookCallStepMinEmployeeCount({
|
||||
calendarBookingPageId,
|
||||
minEmployeeCount: 0,
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('should treat a negative threshold as disabled', () => {
|
||||
expect(
|
||||
getBookCallStepMinEmployeeCount({
|
||||
calendarBookingPageId,
|
||||
minEmployeeCount: -1,
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('should accept the smallest enabling threshold', () => {
|
||||
expect(
|
||||
getBookCallStepMinEmployeeCount({
|
||||
calendarBookingPageId,
|
||||
minEmployeeCount: 1,
|
||||
}),
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
it('should be disabled without a threshold', () => {
|
||||
expect(
|
||||
getBookCallStepMinEmployeeCount({
|
||||
calendarBookingPageId,
|
||||
minEmployeeCount: undefined,
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it.each([undefined, ''])(
|
||||
'should be disabled when the booking page id is %p',
|
||||
(bookingPageId) => {
|
||||
expect(
|
||||
getBookCallStepMinEmployeeCount({
|
||||
calendarBookingPageId: bookingPageId,
|
||||
minEmployeeCount: 50,
|
||||
}),
|
||||
).toBeNull();
|
||||
},
|
||||
);
|
||||
});
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
import { isNonEmptyString, isNumber } from '@sniptt/guards';
|
||||
|
||||
export const getBookCallStepMinEmployeeCount = ({
|
||||
calendarBookingPageId,
|
||||
minEmployeeCount,
|
||||
}: {
|
||||
calendarBookingPageId: string | undefined;
|
||||
minEmployeeCount: number | undefined;
|
||||
}): number | null => {
|
||||
if (
|
||||
!isNonEmptyString(calendarBookingPageId) ||
|
||||
!isNumber(minEmployeeCount) ||
|
||||
minEmployeeCount <= 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return minEmployeeCount;
|
||||
};
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
import { getBookCallStepMinEmployeeCount } from 'src/engine/core-modules/onboarding/utils/get-book-call-step-min-employee-count.util';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
|
||||
export const readBookCallStepMinEmployeeCount = (
|
||||
twentyConfigService: TwentyConfigService,
|
||||
): number | null =>
|
||||
getBookCallStepMinEmployeeCount({
|
||||
calendarBookingPageId: twentyConfigService.get('CALENDAR_BOOKING_PAGE_ID'),
|
||||
minEmployeeCount: twentyConfigService.get(
|
||||
'ONBOARDING_BOOK_CALL_MIN_EMPLOYEE_COUNT',
|
||||
),
|
||||
});
|
||||
@@ -1088,6 +1088,18 @@ export class ConfigVariables {
|
||||
@IsOptional()
|
||||
CALENDAR_BOOKING_PAGE_ID?: string;
|
||||
|
||||
@ConfigVariablesMetadata({
|
||||
group: ConfigVariablesGroup.ADVANCED_SETTINGS,
|
||||
description:
|
||||
'Minimum enriched company employee count required to show the book-a-call onboarding step. Leave unset or set to 0 to disable the step. The step also requires CALENDAR_BOOKING_PAGE_ID.',
|
||||
isHiddenInAdminPanel: true,
|
||||
type: ConfigVariableType.NUMBER,
|
||||
})
|
||||
@CastToPositiveNumber()
|
||||
@IsInt()
|
||||
@IsOptional()
|
||||
ONBOARDING_BOOK_CALL_MIN_EMPLOYEE_COUNT?: number;
|
||||
|
||||
@ConfigVariablesMetadata({
|
||||
group: ConfigVariablesGroup.LOGGING,
|
||||
description: 'Enable or disable buffering for logs before sending',
|
||||
|
||||
+26
@@ -154,6 +154,32 @@ export class UserVarsService<
|
||||
);
|
||||
}
|
||||
|
||||
setIfNotExists<K extends keyof KeyValueTypesMap>(
|
||||
{
|
||||
userId,
|
||||
workspaceId,
|
||||
key,
|
||||
value,
|
||||
}: {
|
||||
userId?: string;
|
||||
workspaceId?: string;
|
||||
key: Extract<K, string>;
|
||||
value: KeyValueTypesMap[K];
|
||||
},
|
||||
queryRunner?: QueryRunner,
|
||||
): Promise<boolean> {
|
||||
return this.keyValuePairService.setIfNotExists(
|
||||
{
|
||||
userId,
|
||||
workspaceId,
|
||||
key,
|
||||
value,
|
||||
type: KeyValuePairType.USER_VARIABLE,
|
||||
},
|
||||
queryRunner,
|
||||
);
|
||||
}
|
||||
|
||||
async delete(
|
||||
{
|
||||
userId,
|
||||
|
||||
@@ -212,6 +212,7 @@ export class UserResolver {
|
||||
|
||||
const userVarAllowList: string[] = [
|
||||
OnboardingStepKeys.ONBOARDING_CONNECT_ACCOUNT_PENDING,
|
||||
OnboardingStepKeys.ONBOARDING_BOOK_CALL_PENDING,
|
||||
AccountsToReconnectKeys.ACCOUNTS_TO_RECONNECT_INSUFFICIENT_PERMISSIONS,
|
||||
AccountsToReconnectKeys.ACCOUNTS_TO_RECONNECT_EMAIL_ALIASES,
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user