Add install your first apps onboarding V2 step (#22347)
https://github.com/user-attachments/assets/5326d48f-1842-4db1-bc7c-94852145c035 <img width="838" height="754" alt="CleanShot 2026-06-30 at 16 25 05@2x" src="https://github.com/user-attachments/assets/5c7d53d7-4d65-4e35-aed1-edf0c104e140" /> Adds an "Install your first apps" step to the V2 onboarding, shown right after import-contacts. It lets users opt into installing marketplace apps (Call recorder and People Data Labs for now) during onboarding. - New backend `OnboardingStatus.APPS_INSTALLATION` (between SYNC_EMAIL and PROFILE_CREATION); V1 auto-skips it. - The primary button sends the selected app ids to the server via `triggerInstallAppsOnboardingStep`, which enqueues a dedicated job that installs them asynchronously so onboarding isn't blocked. Skip continues without installing. - The workspace is credited per app on successful installation. Credits are env-driven via `ONBOARDING_INSTALL_APPS_CREDITS_REWARD_PER_APP`, shown as "Earn +N free credits (1 per tool)". <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22347?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:
@@ -1022,6 +1022,7 @@ enum OnboardingStatus {
|
||||
WORKSPACE_ACTIVATION
|
||||
PROFILE_CREATION
|
||||
SYNC_EMAIL
|
||||
APPS_INSTALLATION
|
||||
INVITE_TEAM
|
||||
BOOK_ONBOARDING
|
||||
COMPLETED
|
||||
@@ -3314,6 +3315,7 @@ type Mutation {
|
||||
assignRoleToApiKey(apiKeyId: UUID!, roleId: UUID!): Boolean!
|
||||
skipSyncEmailOnboardingStep: OnboardingStepSuccess!
|
||||
skipBookOnboardingStep: 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!
|
||||
createSubscriptionPaymentIntent(recurringInterval: SubscriptionInterval!, plan: BillingPlanKey! = PRO, requirePaymentMethod: Boolean! = true, successUrlPath: String, idempotencyKey: String!): BillingPaymentIntent!
|
||||
|
||||
@@ -731,7 +731,7 @@ export interface User {
|
||||
|
||||
|
||||
/** Onboarding status */
|
||||
export type OnboardingStatus = 'PLAN_REQUIRED' | 'WORKSPACE_ACTIVATION' | 'PROFILE_CREATION' | 'SYNC_EMAIL' | 'INVITE_TEAM' | 'BOOK_ONBOARDING' | 'COMPLETED'
|
||||
export type OnboardingStatus = 'PLAN_REQUIRED' | 'WORKSPACE_ACTIVATION' | 'PROFILE_CREATION' | 'SYNC_EMAIL' | 'APPS_INSTALLATION' | 'INVITE_TEAM' | 'BOOK_ONBOARDING' | 'COMPLETED'
|
||||
|
||||
export interface RatioAggregateConfig {
|
||||
fieldMetadataId: Scalars['UUID']
|
||||
@@ -2835,6 +2835,7 @@ export interface Mutation {
|
||||
assignRoleToApiKey: Scalars['Boolean']
|
||||
skipSyncEmailOnboardingStep: OnboardingStepSuccess
|
||||
skipBookOnboardingStep: OnboardingStepSuccess
|
||||
triggerInstallAppsOnboardingStep: OnboardingStepSuccess
|
||||
updateOneApplicationVariable: Scalars['Boolean']
|
||||
checkoutSession: BillingSession
|
||||
createSubscriptionPaymentIntent: BillingPaymentIntent
|
||||
@@ -6047,6 +6048,7 @@ export interface MutationGenqlSelection{
|
||||
assignRoleToApiKey?: { __args: {apiKeyId: Scalars['UUID'], roleId: Scalars['UUID']} }
|
||||
skipSyncEmailOnboardingStep?: OnboardingStepSuccessGenqlSelection
|
||||
skipBookOnboardingStep?: 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)} })
|
||||
createSubscriptionPaymentIntent?: (BillingPaymentIntentGenqlSelection & { __args: {recurringInterval: SubscriptionInterval, plan: BillingPlanKey, requirePaymentMethod: Scalars['Boolean'], successUrlPath?: (Scalars['String'] | null), idempotencyKey: Scalars['String']} })
|
||||
@@ -8966,6 +8968,7 @@ export const enumOnboardingStatus = {
|
||||
WORKSPACE_ACTIVATION: 'WORKSPACE_ACTIVATION' as const,
|
||||
PROFILE_CREATION: 'PROFILE_CREATION' as const,
|
||||
SYNC_EMAIL: 'SYNC_EMAIL' as const,
|
||||
APPS_INSTALLATION: 'APPS_INSTALLATION' as const,
|
||||
INVITE_TEAM: 'INVITE_TEAM' as const,
|
||||
BOOK_ONBOARDING: 'BOOK_ONBOARDING' as const,
|
||||
COMPLETED: 'COMPLETED' as const
|
||||
|
||||
@@ -7416,6 +7416,15 @@ export default {
|
||||
"skipBookOnboardingStep": [
|
||||
156
|
||||
],
|
||||
"triggerInstallAppsOnboardingStep": [
|
||||
156,
|
||||
{
|
||||
"universalIdentifiers": [
|
||||
1,
|
||||
"[String!]!"
|
||||
]
|
||||
}
|
||||
],
|
||||
"updateOneApplicationVariable": [
|
||||
6,
|
||||
{
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -118,6 +118,7 @@ const testCases: {
|
||||
{ loc: AppPath.Verify, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.WORKSPACE_ACTIVATION, res: AppPath.WorkspaceActivation },
|
||||
{ loc: AppPath.Verify, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PROFILE_CREATION, res: AppPath.CreateProfile },
|
||||
{ loc: AppPath.Verify, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails },
|
||||
{ loc: AppPath.Verify, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.APPS_INSTALLATION, res: AppPath.InstallAppsV2 },
|
||||
{ loc: AppPath.Verify, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam },
|
||||
{ loc: AppPath.Verify, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.BOOK_ONBOARDING, res: AppPath.BookCallDecision },
|
||||
{ loc: AppPath.Verify, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: defaultHomePagePath },
|
||||
@@ -128,6 +129,7 @@ const testCases: {
|
||||
{ loc: AppPath.SignInUp, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.WORKSPACE_ACTIVATION, res: AppPath.WorkspaceActivation },
|
||||
{ loc: AppPath.SignInUp, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PROFILE_CREATION, res: AppPath.CreateProfile },
|
||||
{ loc: AppPath.SignInUp, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails },
|
||||
{ loc: AppPath.SignInUp, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.APPS_INSTALLATION, res: AppPath.InstallAppsV2 },
|
||||
{ loc: AppPath.SignInUp, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam },
|
||||
{ loc: AppPath.SignInUp, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.BOOK_ONBOARDING, res: AppPath.BookCallDecision },
|
||||
{ loc: AppPath.SignInUp, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: defaultHomePagePath },
|
||||
@@ -138,6 +140,7 @@ const testCases: {
|
||||
{ loc: AppPath.SignInUpV2, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.WORKSPACE_ACTIVATION, res: AppPath.WorkspaceActivation },
|
||||
{ loc: AppPath.SignInUpV2, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PROFILE_CREATION, res: AppPath.CreateProfile },
|
||||
{ loc: AppPath.SignInUpV2, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails },
|
||||
{ loc: AppPath.SignInUpV2, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.APPS_INSTALLATION, res: AppPath.InstallAppsV2 },
|
||||
{ loc: AppPath.SignInUpV2, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam },
|
||||
{ loc: AppPath.SignInUpV2, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.BOOK_ONBOARDING, res: AppPath.BookCallDecision },
|
||||
{ loc: AppPath.SignInUpV2, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: defaultHomePagePath },
|
||||
@@ -148,6 +151,7 @@ const testCases: {
|
||||
{ loc: AppPath.Invite, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.WORKSPACE_ACTIVATION, res: '/workspace-activation' },
|
||||
{ loc: AppPath.Invite, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PROFILE_CREATION, res: '/create/profile' },
|
||||
{ loc: AppPath.Invite, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: '/sync/emails' },
|
||||
{ loc: AppPath.Invite, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.APPS_INSTALLATION, res: AppPath.InstallAppsV2 },
|
||||
{ loc: AppPath.Invite, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: '/invite-team' },
|
||||
{ loc: AppPath.Invite, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.BOOK_ONBOARDING, res: AppPath.BookCallDecision },
|
||||
{ loc: AppPath.Invite, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: defaultHomePagePath },
|
||||
@@ -158,6 +162,7 @@ const testCases: {
|
||||
{ loc: AppPath.ResetPassword, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.WORKSPACE_ACTIVATION, res: '/workspace-activation' },
|
||||
{ loc: AppPath.ResetPassword, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PROFILE_CREATION, res: '/create/profile' },
|
||||
{ loc: AppPath.ResetPassword, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: '/sync/emails' },
|
||||
{ loc: AppPath.ResetPassword, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.APPS_INSTALLATION, res: AppPath.InstallAppsV2 },
|
||||
{ loc: AppPath.ResetPassword, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: '/invite-team' },
|
||||
{ loc: AppPath.ResetPassword, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.BOOK_ONBOARDING, res: AppPath.BookCallDecision },
|
||||
{ loc: AppPath.ResetPassword, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: undefined },
|
||||
@@ -170,6 +175,7 @@ const testCases: {
|
||||
{ loc: AppPath.VerifyEmail, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.WORKSPACE_ACTIVATION, res: AppPath.WorkspaceActivation },
|
||||
{ loc: AppPath.VerifyEmail, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PROFILE_CREATION, res: AppPath.CreateProfile },
|
||||
{ loc: AppPath.VerifyEmail, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails },
|
||||
{ loc: AppPath.VerifyEmail, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.APPS_INSTALLATION, res: AppPath.InstallAppsV2 },
|
||||
{ loc: AppPath.VerifyEmail, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam },
|
||||
{ loc: AppPath.VerifyEmail, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.BOOK_ONBOARDING, res: AppPath.BookCallDecision },
|
||||
{ loc: AppPath.VerifyEmail, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: defaultHomePagePath },
|
||||
@@ -180,6 +186,7 @@ const testCases: {
|
||||
{ loc: AppPath.WorkspaceActivation, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.WORKSPACE_ACTIVATION, res: undefined },
|
||||
{ loc: AppPath.WorkspaceActivation, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PROFILE_CREATION, res: AppPath.CreateProfile },
|
||||
{ loc: AppPath.WorkspaceActivation, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails },
|
||||
{ loc: AppPath.WorkspaceActivation, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.APPS_INSTALLATION, res: AppPath.InstallAppsV2 },
|
||||
{ loc: AppPath.WorkspaceActivation, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam },
|
||||
{ loc: AppPath.WorkspaceActivation, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.BOOK_ONBOARDING, res: AppPath.BookCallDecision },
|
||||
{ loc: AppPath.WorkspaceActivation, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: defaultHomePagePath },
|
||||
@@ -190,6 +197,7 @@ const testCases: {
|
||||
{ loc: AppPath.CreateProfile, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.WORKSPACE_ACTIVATION, res: AppPath.WorkspaceActivation },
|
||||
{ loc: AppPath.CreateProfile, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PROFILE_CREATION, res: undefined },
|
||||
{ loc: AppPath.CreateProfile, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails },
|
||||
{ loc: AppPath.CreateProfile, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.APPS_INSTALLATION, res: AppPath.InstallAppsV2 },
|
||||
{ loc: AppPath.CreateProfile, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam },
|
||||
{ loc: AppPath.CreateProfile, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.BOOK_ONBOARDING, res: AppPath.BookCallDecision },
|
||||
{ loc: AppPath.CreateProfile, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: defaultHomePagePath },
|
||||
@@ -200,6 +208,7 @@ const testCases: {
|
||||
{ loc: AppPath.SyncEmails, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.WORKSPACE_ACTIVATION, res: AppPath.WorkspaceActivation },
|
||||
{ loc: AppPath.SyncEmails, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PROFILE_CREATION, res: AppPath.CreateProfile },
|
||||
{ loc: AppPath.SyncEmails, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: undefined },
|
||||
{ loc: AppPath.SyncEmails, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.APPS_INSTALLATION, res: AppPath.InstallAppsV2 },
|
||||
{ loc: AppPath.SyncEmails, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam },
|
||||
{ loc: AppPath.SyncEmails, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.BOOK_ONBOARDING, res: AppPath.BookCallDecision },
|
||||
{ loc: AppPath.SyncEmails, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: defaultHomePagePath },
|
||||
@@ -210,6 +219,7 @@ const testCases: {
|
||||
{ loc: AppPath.InviteTeam, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.WORKSPACE_ACTIVATION, res: AppPath.WorkspaceActivation },
|
||||
{ loc: AppPath.InviteTeam, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PROFILE_CREATION, res: AppPath.CreateProfile },
|
||||
{ loc: AppPath.InviteTeam, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails },
|
||||
{ loc: AppPath.InviteTeam, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.APPS_INSTALLATION, res: AppPath.InstallAppsV2 },
|
||||
{ loc: AppPath.InviteTeam, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: undefined },
|
||||
{ loc: AppPath.InviteTeam, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.BOOK_ONBOARDING, res: AppPath.BookCallDecision },
|
||||
{ loc: AppPath.InviteTeam, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: defaultHomePagePath },
|
||||
@@ -220,6 +230,7 @@ const testCases: {
|
||||
{ loc: AppPath.BookCallDecision, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.WORKSPACE_ACTIVATION, res: undefined },
|
||||
{ loc: AppPath.BookCallDecision, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PROFILE_CREATION, res: AppPath.CreateProfile },
|
||||
{ loc: AppPath.BookCallDecision, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails },
|
||||
{ loc: AppPath.BookCallDecision, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.APPS_INSTALLATION, res: AppPath.InstallAppsV2 },
|
||||
{ loc: AppPath.BookCallDecision, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam },
|
||||
{ loc: AppPath.BookCallDecision, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.BOOK_ONBOARDING, res: undefined },
|
||||
{ loc: AppPath.BookCallDecision, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: defaultHomePagePath },
|
||||
@@ -230,6 +241,7 @@ const testCases: {
|
||||
{ loc: AppPath.BookCall, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.WORKSPACE_ACTIVATION, res: undefined },
|
||||
{ loc: AppPath.BookCall, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PROFILE_CREATION, res: AppPath.CreateProfile },
|
||||
{ loc: AppPath.BookCall, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails },
|
||||
{ loc: AppPath.BookCall, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.APPS_INSTALLATION, res: AppPath.InstallAppsV2 },
|
||||
{ loc: AppPath.BookCall, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam },
|
||||
{ loc: AppPath.BookCall, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.BOOK_ONBOARDING, res: undefined },
|
||||
{ loc: AppPath.BookCall, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: defaultHomePagePath },
|
||||
@@ -240,6 +252,7 @@ const testCases: {
|
||||
{ loc: AppPath.PlanRequired, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.WORKSPACE_ACTIVATION, res: AppPath.WorkspaceActivation },
|
||||
{ loc: AppPath.PlanRequired, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PROFILE_CREATION, res: AppPath.CreateProfile },
|
||||
{ loc: AppPath.PlanRequired, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails },
|
||||
{ loc: AppPath.PlanRequired, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.APPS_INSTALLATION, res: AppPath.InstallAppsV2 },
|
||||
{ loc: AppPath.PlanRequired, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam },
|
||||
{ loc: AppPath.PlanRequired, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.BOOK_ONBOARDING, res: AppPath.BookCallDecision },
|
||||
{ loc: AppPath.PlanRequired, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: defaultHomePagePath },
|
||||
@@ -250,6 +263,7 @@ const testCases: {
|
||||
{ loc: AppPath.PlanRequiredSuccess, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.WORKSPACE_ACTIVATION, res: AppPath.WorkspaceActivation },
|
||||
{ loc: AppPath.PlanRequiredSuccess, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PROFILE_CREATION, res: AppPath.CreateProfile },
|
||||
{ loc: AppPath.PlanRequiredSuccess, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails },
|
||||
{ loc: AppPath.PlanRequiredSuccess, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.APPS_INSTALLATION, res: AppPath.InstallAppsV2 },
|
||||
{ loc: AppPath.PlanRequiredSuccess, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam },
|
||||
{ loc: AppPath.PlanRequiredSuccess, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.BOOK_ONBOARDING, res: AppPath.BookCallDecision },
|
||||
{ loc: AppPath.PlanRequiredSuccess, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: defaultHomePagePath },
|
||||
@@ -260,6 +274,7 @@ const testCases: {
|
||||
{ loc: AppPath.Index, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.WORKSPACE_ACTIVATION, res: AppPath.WorkspaceActivation },
|
||||
{ loc: AppPath.Index, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PROFILE_CREATION, res: AppPath.CreateProfile },
|
||||
{ loc: AppPath.Index, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails },
|
||||
{ loc: AppPath.Index, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.APPS_INSTALLATION, res: AppPath.InstallAppsV2 },
|
||||
{ loc: AppPath.Index, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam },
|
||||
{ loc: AppPath.Index, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.BOOK_ONBOARDING, res: AppPath.BookCallDecision },
|
||||
{ loc: AppPath.Index, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: defaultHomePagePath },
|
||||
@@ -270,6 +285,7 @@ const testCases: {
|
||||
{ loc: AppPath.TasksPage, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.WORKSPACE_ACTIVATION, res: AppPath.WorkspaceActivation },
|
||||
{ loc: AppPath.TasksPage, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PROFILE_CREATION, res: AppPath.CreateProfile },
|
||||
{ loc: AppPath.TasksPage, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails },
|
||||
{ loc: AppPath.TasksPage, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.APPS_INSTALLATION, res: AppPath.InstallAppsV2 },
|
||||
{ loc: AppPath.TasksPage, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam },
|
||||
{ loc: AppPath.TasksPage, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.BOOK_ONBOARDING, res: AppPath.BookCallDecision },
|
||||
{ loc: AppPath.TasksPage, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: undefined },
|
||||
@@ -280,6 +296,7 @@ const testCases: {
|
||||
{ loc: AppPath.OpportunitiesPage, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.WORKSPACE_ACTIVATION, res: AppPath.WorkspaceActivation },
|
||||
{ loc: AppPath.OpportunitiesPage, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PROFILE_CREATION, res: AppPath.CreateProfile },
|
||||
{ loc: AppPath.OpportunitiesPage, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails },
|
||||
{ loc: AppPath.OpportunitiesPage, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.APPS_INSTALLATION, res: AppPath.InstallAppsV2 },
|
||||
{ loc: AppPath.OpportunitiesPage, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam },
|
||||
{ loc: AppPath.OpportunitiesPage, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.BOOK_ONBOARDING, res: AppPath.BookCallDecision },
|
||||
{ loc: AppPath.OpportunitiesPage, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: undefined },
|
||||
@@ -290,6 +307,7 @@ const testCases: {
|
||||
{ loc: AppPath.RecordIndexPage, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.WORKSPACE_ACTIVATION, res: AppPath.WorkspaceActivation },
|
||||
{ loc: AppPath.RecordIndexPage, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PROFILE_CREATION, res: AppPath.CreateProfile },
|
||||
{ loc: AppPath.RecordIndexPage, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails },
|
||||
{ loc: AppPath.RecordIndexPage, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.APPS_INSTALLATION, res: AppPath.InstallAppsV2 },
|
||||
{ loc: AppPath.RecordIndexPage, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam },
|
||||
{ loc: AppPath.RecordIndexPage, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.BOOK_ONBOARDING, res: AppPath.BookCallDecision },
|
||||
{ loc: AppPath.RecordIndexPage, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: undefined },
|
||||
@@ -302,6 +320,7 @@ const testCases: {
|
||||
{ loc: AppPath.RecordShowPage, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.WORKSPACE_ACTIVATION, res: AppPath.WorkspaceActivation },
|
||||
{ loc: AppPath.RecordShowPage, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PROFILE_CREATION, res: AppPath.CreateProfile },
|
||||
{ loc: AppPath.RecordShowPage, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails },
|
||||
{ loc: AppPath.RecordShowPage, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.APPS_INSTALLATION, res: AppPath.InstallAppsV2 },
|
||||
{ loc: AppPath.RecordShowPage, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam },
|
||||
{ loc: AppPath.RecordShowPage, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.BOOK_ONBOARDING, res: AppPath.BookCallDecision },
|
||||
{ loc: AppPath.RecordShowPage, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: undefined },
|
||||
@@ -312,6 +331,7 @@ const testCases: {
|
||||
{ loc: AppPath.PageLayoutPage, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.WORKSPACE_ACTIVATION, res: AppPath.WorkspaceActivation },
|
||||
{ loc: AppPath.PageLayoutPage, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PROFILE_CREATION, res: AppPath.CreateProfile },
|
||||
{ loc: AppPath.PageLayoutPage, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails },
|
||||
{ loc: AppPath.PageLayoutPage, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.APPS_INSTALLATION, res: AppPath.InstallAppsV2 },
|
||||
{ loc: AppPath.PageLayoutPage, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam },
|
||||
{ loc: AppPath.PageLayoutPage, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.BOOK_ONBOARDING, res: AppPath.BookCallDecision },
|
||||
{ loc: AppPath.PageLayoutPage, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: undefined },
|
||||
@@ -326,6 +346,7 @@ const testCases: {
|
||||
{ loc: AppPath.SettingsCatchAll, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.WORKSPACE_ACTIVATION, res: AppPath.WorkspaceActivation },
|
||||
{ loc: AppPath.SettingsCatchAll, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PROFILE_CREATION, res: AppPath.CreateProfile },
|
||||
{ loc: AppPath.SettingsCatchAll, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails },
|
||||
{ loc: AppPath.SettingsCatchAll, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.APPS_INSTALLATION, res: AppPath.InstallAppsV2 },
|
||||
{ loc: AppPath.SettingsCatchAll, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam },
|
||||
{ loc: AppPath.SettingsCatchAll, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.BOOK_ONBOARDING, res: AppPath.BookCallDecision },
|
||||
{ loc: AppPath.SettingsCatchAll, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: undefined },
|
||||
@@ -336,6 +357,7 @@ const testCases: {
|
||||
{ loc: AppPath.DevelopersCatchAll, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.WORKSPACE_ACTIVATION, res: AppPath.WorkspaceActivation },
|
||||
{ loc: AppPath.DevelopersCatchAll, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PROFILE_CREATION, res: AppPath.CreateProfile },
|
||||
{ loc: AppPath.DevelopersCatchAll, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails },
|
||||
{ loc: AppPath.DevelopersCatchAll, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.APPS_INSTALLATION, res: AppPath.InstallAppsV2 },
|
||||
{ loc: AppPath.DevelopersCatchAll, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam },
|
||||
{ loc: AppPath.DevelopersCatchAll, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.BOOK_ONBOARDING, res: AppPath.BookCallDecision },
|
||||
{ loc: AppPath.DevelopersCatchAll, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: undefined },
|
||||
@@ -346,6 +368,7 @@ const testCases: {
|
||||
{ loc: AppPath.Authorize, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.WORKSPACE_ACTIVATION, res: AppPath.WorkspaceActivation },
|
||||
{ loc: AppPath.Authorize, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PROFILE_CREATION, res: AppPath.CreateProfile },
|
||||
{ loc: AppPath.Authorize, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails },
|
||||
{ loc: AppPath.Authorize, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.APPS_INSTALLATION, res: AppPath.InstallAppsV2 },
|
||||
{ loc: AppPath.Authorize, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam },
|
||||
{ loc: AppPath.Authorize, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.BOOK_ONBOARDING, res: AppPath.BookCallDecision },
|
||||
{ loc: AppPath.Authorize, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: undefined },
|
||||
@@ -356,6 +379,7 @@ const testCases: {
|
||||
{ loc: AppPath.NotFoundWildcard, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.WORKSPACE_ACTIVATION, res: AppPath.WorkspaceActivation },
|
||||
{ loc: AppPath.NotFoundWildcard, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PROFILE_CREATION, res: AppPath.CreateProfile },
|
||||
{ loc: AppPath.NotFoundWildcard, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails },
|
||||
{ loc: AppPath.NotFoundWildcard, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.APPS_INSTALLATION, res: AppPath.InstallAppsV2 },
|
||||
{ loc: AppPath.NotFoundWildcard, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam },
|
||||
{ loc: AppPath.NotFoundWildcard, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.BOOK_ONBOARDING, res: AppPath.BookCallDecision },
|
||||
{ loc: AppPath.NotFoundWildcard, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: undefined },
|
||||
@@ -366,6 +390,7 @@ const testCases: {
|
||||
{ loc: AppPath.NotFound, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.WORKSPACE_ACTIVATION, res: AppPath.WorkspaceActivation },
|
||||
{ loc: AppPath.NotFound, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PROFILE_CREATION, res: AppPath.CreateProfile },
|
||||
{ loc: AppPath.NotFound, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails },
|
||||
{ loc: AppPath.NotFound, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.APPS_INSTALLATION, res: AppPath.InstallAppsV2 },
|
||||
{ loc: AppPath.NotFound, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam },
|
||||
{ loc: AppPath.NotFound, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.BOOK_ONBOARDING, res: AppPath.BookCallDecision },
|
||||
{ loc: AppPath.NotFound, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: undefined },
|
||||
@@ -519,6 +544,23 @@ describe('usePageChangeEffectNavigateLocation — onboarding V2', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('routes to InstallAppsV2 when status is APPS_INSTALLATION', () => {
|
||||
setupOnboardingV2Case(AppPath.Index, OnboardingStatus.APPS_INSTALLATION);
|
||||
|
||||
expect(usePageChangeEffectNavigateLocation()).toEqual(
|
||||
AppPath.InstallAppsV2,
|
||||
);
|
||||
});
|
||||
|
||||
it('does not redirect away from the InstallAppsV2 page', () => {
|
||||
setupOnboardingV2Case(
|
||||
AppPath.InstallAppsV2,
|
||||
OnboardingStatus.APPS_INSTALLATION,
|
||||
);
|
||||
|
||||
expect(usePageChangeEffectNavigateLocation()).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not redirect away from the CreateProfileV2 page', () => {
|
||||
setupOnboardingV2Case(
|
||||
AppPath.CreateProfileV2,
|
||||
|
||||
@@ -146,6 +146,13 @@ export const usePageChangeEffectNavigateLocation = () => {
|
||||
return isOnboardingV2 ? AppPath.SyncEmailsV2 : AppPath.SyncEmails;
|
||||
}
|
||||
|
||||
if (
|
||||
onboardingStatus === OnboardingStatus.APPS_INSTALLATION &&
|
||||
!isMatchingLocation(location, AppPath.InstallAppsV2)
|
||||
) {
|
||||
return AppPath.InstallAppsV2;
|
||||
}
|
||||
|
||||
if (
|
||||
onboardingStatus === OnboardingStatus.INVITE_TEAM &&
|
||||
!someMatchingLocationOf([AppPath.InviteTeam, AppPath.InviteTeamV2])
|
||||
|
||||
@@ -93,6 +93,12 @@ const SyncEmailsV2 = lazy(() =>
|
||||
})),
|
||||
);
|
||||
|
||||
const InstallAppsV2 = lazy(() =>
|
||||
import('~/pages/onboarding/InstallAppsV2').then((module) => ({
|
||||
default: module.InstallAppsV2,
|
||||
})),
|
||||
);
|
||||
|
||||
const InviteTeam = lazy(() =>
|
||||
import('~/pages/onboarding/InviteTeam').then((module) => ({
|
||||
default: module.InviteTeam,
|
||||
@@ -338,6 +344,14 @@ export const useCreateAppRouter = (
|
||||
</LazyRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path={AppPath.InstallAppsV2}
|
||||
element={
|
||||
<LazyRoute fallback={null}>
|
||||
<InstallAppsV2 />
|
||||
</LazyRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path={AppPath.InviteTeamV2}
|
||||
element={
|
||||
|
||||
@@ -7,6 +7,7 @@ export const ONBOARDING_PATHS = [
|
||||
AppPath.CreateProfileV2,
|
||||
AppPath.SyncEmails,
|
||||
AppPath.SyncEmailsV2,
|
||||
AppPath.InstallAppsV2,
|
||||
AppPath.InviteTeam,
|
||||
AppPath.InviteTeamV2,
|
||||
AppPath.PlanRequired,
|
||||
|
||||
@@ -3,4 +3,5 @@ export type OnboardingConfig = {
|
||||
inviteTeamMaxCreditsReward: number;
|
||||
inviteTeamCreditsRewardPerUser: number;
|
||||
upgradeCreditsReward: number;
|
||||
installAppsCreditsRewardPerApp: number;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { type OnboardingInstallableApp } from '@/onboarding/types/OnboardingInstallableApp';
|
||||
|
||||
export const ONBOARDING_INSTALLABLE_APPS: OnboardingInstallableApp[] = [
|
||||
{
|
||||
universalIdentifier: '8da4b8b5-5edf-4880-b51f-ab6e679ec617',
|
||||
label: msg`Call recorder`,
|
||||
description: msg`Record your calls and get transcripts`,
|
||||
},
|
||||
{
|
||||
universalIdentifier: '4a1178c1-3535-4a47-b592-231d3216b36f',
|
||||
label: msg`Enrichment`,
|
||||
description: msg`Enrich your leads with People Data Labs`,
|
||||
},
|
||||
];
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
import { useTriggerInstallAppsOnboardingStep } from '@/onboarding/hooks/useTriggerInstallAppsOnboardingStep';
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
type InstallAppsAutoSkipEffectProps = {
|
||||
onError: () => void;
|
||||
};
|
||||
|
||||
export const InstallAppsAutoSkipEffect = ({
|
||||
onError,
|
||||
}: InstallAppsAutoSkipEffectProps) => {
|
||||
const triggerInstallAppsOnboardingStep =
|
||||
useTriggerInstallAppsOnboardingStep();
|
||||
|
||||
// oxlint-disable-next-line twenty/no-state-useref
|
||||
const hasSkippedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasSkippedRef.current) {
|
||||
return;
|
||||
}
|
||||
hasSkippedRef.current = true;
|
||||
|
||||
const skip = async () => {
|
||||
try {
|
||||
await triggerInstallAppsOnboardingStep([]);
|
||||
} catch {
|
||||
onError();
|
||||
}
|
||||
};
|
||||
|
||||
void skip();
|
||||
}, [triggerInstallAppsOnboardingStep, onError]);
|
||||
|
||||
return null;
|
||||
};
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const TRIGGER_INSTALL_APPS_ONBOARDING_STEP = gql`
|
||||
mutation TriggerInstallAppsOnboardingStep($universalIdentifiers: [String!]!) {
|
||||
triggerInstallAppsOnboardingStep(
|
||||
universalIdentifiers: $universalIdentifiers
|
||||
) {
|
||||
success
|
||||
}
|
||||
}
|
||||
`;
|
||||
+162
@@ -0,0 +1,162 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { Provider as JotaiProvider } from 'jotai';
|
||||
import { createElement } from 'react';
|
||||
|
||||
import { onboardingConfigState } from '@/client-config/states/onboardingConfigState';
|
||||
import { type OnboardingConfig } from '@/client-config/types/OnboardingConfig';
|
||||
import { useInstallOnboardingApps } from '@/onboarding/hooks/useInstallOnboardingApps';
|
||||
import { onboardingFreeCreditsState } from '@/onboarding/states/onboardingFreeCreditsState';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
|
||||
import {
|
||||
jotaiStore,
|
||||
resetJotaiStore,
|
||||
} from '@/ui/utilities/state/jotai/jotaiStore';
|
||||
|
||||
const mockTriggerInstallAppsOnboardingStep = jest.fn();
|
||||
|
||||
jest.mock('@/onboarding/hooks/useTriggerInstallAppsOnboardingStep', () => ({
|
||||
useTriggerInstallAppsOnboardingStep: () =>
|
||||
mockTriggerInstallAppsOnboardingStep,
|
||||
}));
|
||||
|
||||
const onboardingConfig: OnboardingConfig = {
|
||||
importContactsCreditsReward: 2,
|
||||
inviteTeamMaxCreditsReward: 9,
|
||||
inviteTeamCreditsRewardPerUser: 3,
|
||||
upgradeCreditsReward: 5,
|
||||
installAppsCreditsRewardPerApp: 1,
|
||||
};
|
||||
|
||||
const Wrapper = ({ children }: { children: React.ReactNode }) =>
|
||||
createElement(JotaiProvider, { store: jotaiStore }, children);
|
||||
|
||||
const renderInstallHook = () => {
|
||||
const { result } = renderHook(
|
||||
() => {
|
||||
const setOnboardingConfig = useSetAtomState(onboardingConfigState);
|
||||
const onboardingFreeCredits = useAtomStateValue(
|
||||
onboardingFreeCreditsState,
|
||||
);
|
||||
const installOnboardingApps = useInstallOnboardingApps();
|
||||
|
||||
return {
|
||||
setOnboardingConfig,
|
||||
onboardingFreeCredits,
|
||||
installOnboardingApps,
|
||||
};
|
||||
},
|
||||
{ wrapper: Wrapper },
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.setOnboardingConfig(onboardingConfig);
|
||||
});
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
describe('useInstallOnboardingApps', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
resetJotaiStore();
|
||||
mockTriggerInstallAppsOnboardingStep.mockReset();
|
||||
});
|
||||
|
||||
it('should credit the selected apps once the step succeeds', async () => {
|
||||
mockTriggerInstallAppsOnboardingStep.mockResolvedValue(undefined);
|
||||
|
||||
const result = renderInstallHook();
|
||||
|
||||
act(() => {
|
||||
result.current.installOnboardingApps.toggleApp('app-1');
|
||||
});
|
||||
act(() => {
|
||||
result.current.installOnboardingApps.toggleApp('app-2');
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.installOnboardingApps.installSelectedAppsAndContinue();
|
||||
});
|
||||
|
||||
expect(mockTriggerInstallAppsOnboardingStep).toHaveBeenCalledWith([
|
||||
'app-1',
|
||||
'app-2',
|
||||
]);
|
||||
expect(result.current.onboardingFreeCredits.installApps).toBe(2);
|
||||
});
|
||||
|
||||
it('should reset the completing state and not credit when the step fails', async () => {
|
||||
mockTriggerInstallAppsOnboardingStep.mockRejectedValue(
|
||||
new Error('network error'),
|
||||
);
|
||||
|
||||
const result = renderInstallHook();
|
||||
|
||||
act(() => {
|
||||
result.current.installOnboardingApps.toggleApp('app-1');
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.installOnboardingApps.installSelectedAppsAndContinue();
|
||||
});
|
||||
|
||||
expect(result.current.installOnboardingApps.isCompleting).toBe(false);
|
||||
expect(result.current.onboardingFreeCredits.installApps).toBe(0);
|
||||
});
|
||||
|
||||
it('should allow retrying after a failed attempt', async () => {
|
||||
mockTriggerInstallAppsOnboardingStep
|
||||
.mockRejectedValueOnce(new Error('network error'))
|
||||
.mockResolvedValueOnce(undefined);
|
||||
|
||||
const result = renderInstallHook();
|
||||
|
||||
act(() => {
|
||||
result.current.installOnboardingApps.toggleApp('app-1');
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.installOnboardingApps.installSelectedAppsAndContinue();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.installOnboardingApps.installSelectedAppsAndContinue();
|
||||
});
|
||||
|
||||
expect(mockTriggerInstallAppsOnboardingStep).toHaveBeenCalledTimes(2);
|
||||
expect(result.current.onboardingFreeCredits.installApps).toBe(1);
|
||||
});
|
||||
|
||||
it('should ignore a second submission while one is already in flight', async () => {
|
||||
let resolveTrigger: () => void = () => {};
|
||||
|
||||
mockTriggerInstallAppsOnboardingStep.mockReturnValue(
|
||||
new Promise<void>((resolve) => {
|
||||
resolveTrigger = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
const result = renderInstallHook();
|
||||
|
||||
act(() => {
|
||||
result.current.installOnboardingApps.toggleApp('app-1');
|
||||
});
|
||||
|
||||
act(() => {
|
||||
void result.current.installOnboardingApps.installSelectedAppsAndContinue();
|
||||
});
|
||||
|
||||
expect(result.current.installOnboardingApps.isCompleting).toBe(true);
|
||||
|
||||
act(() => {
|
||||
void result.current.installOnboardingApps.skip();
|
||||
});
|
||||
|
||||
expect(mockTriggerInstallAppsOnboardingStep).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => {
|
||||
resolveTrigger();
|
||||
});
|
||||
});
|
||||
});
|
||||
+8
-1
@@ -93,8 +93,15 @@ describe('useSetNextOnboardingStatus', () => {
|
||||
expect(nextOnboardingStatus).toEqual(OnboardingStatus.SYNC_EMAIL);
|
||||
});
|
||||
|
||||
it('should create profile after syncing emails', () => {
|
||||
it('should install apps after syncing emails', () => {
|
||||
const nextOnboardingStatus = renderHooks(OnboardingStatus.SYNC_EMAIL);
|
||||
expect(nextOnboardingStatus).toEqual(OnboardingStatus.APPS_INSTALLATION);
|
||||
});
|
||||
|
||||
it('should create profile after installing apps', () => {
|
||||
const nextOnboardingStatus = renderHooks(
|
||||
OnboardingStatus.APPS_INSTALLATION,
|
||||
);
|
||||
expect(nextOnboardingStatus).toEqual(OnboardingStatus.PROFILE_CREATION);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { onboardingConfigState } from '@/client-config/states/onboardingConfigState';
|
||||
import { useTriggerInstallAppsOnboardingStep } from '@/onboarding/hooks/useTriggerInstallAppsOnboardingStep';
|
||||
import { onboardingFreeCreditsState } from '@/onboarding/states/onboardingFreeCreditsState';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
|
||||
import { useState } from 'react';
|
||||
|
||||
export const useInstallOnboardingApps = () => {
|
||||
const onboardingConfig = useAtomStateValue(onboardingConfigState);
|
||||
const setOnboardingFreeCredits = useSetAtomState(onboardingFreeCreditsState);
|
||||
const triggerInstallAppsOnboardingStep =
|
||||
useTriggerInstallAppsOnboardingStep();
|
||||
|
||||
const [selectedUniversalIdentifiers, setSelectedUniversalIdentifiers] =
|
||||
useState<string[]>([]);
|
||||
const [isCompleting, setIsCompleting] = useState(false);
|
||||
|
||||
const toggleApp = (universalIdentifier: string) => {
|
||||
setSelectedUniversalIdentifiers((current) =>
|
||||
current.includes(universalIdentifier)
|
||||
? current.filter((identifier) => identifier !== universalIdentifier)
|
||||
: [...current, universalIdentifier],
|
||||
);
|
||||
};
|
||||
|
||||
const triggerStep = async (universalIdentifiers: string[]) => {
|
||||
if (isCompleting) {
|
||||
return;
|
||||
}
|
||||
setIsCompleting(true);
|
||||
|
||||
try {
|
||||
await triggerInstallAppsOnboardingStep(universalIdentifiers);
|
||||
|
||||
const creditsRewardPerApp =
|
||||
onboardingConfig?.installAppsCreditsRewardPerApp ?? 0;
|
||||
|
||||
setOnboardingFreeCredits((current) => ({
|
||||
...current,
|
||||
installApps: creditsRewardPerApp * universalIdentifiers.length,
|
||||
}));
|
||||
} catch {
|
||||
setIsCompleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
selectedUniversalIdentifiers,
|
||||
isCompleting,
|
||||
toggleApp,
|
||||
installSelectedAppsAndContinue: () =>
|
||||
triggerStep(selectedUniversalIdentifiers),
|
||||
skip: () => triggerStep([]),
|
||||
};
|
||||
};
|
||||
@@ -150,15 +150,10 @@ export const useInviteTeam = () => {
|
||||
|
||||
const creditsRewardPerUser =
|
||||
onboardingConfig?.inviteTeamCreditsRewardPerUser ?? 0;
|
||||
const maxCreditsReward =
|
||||
onboardingConfig?.inviteTeamMaxCreditsReward ?? 0;
|
||||
|
||||
setOnboardingFreeCredits((current) => ({
|
||||
...current,
|
||||
inviteTeam: Math.min(
|
||||
emails.length * creditsRewardPerUser,
|
||||
maxCreditsReward,
|
||||
),
|
||||
inviteTeam: emails.length * creditsRewardPerUser,
|
||||
}));
|
||||
|
||||
if (emails.length > 0) {
|
||||
@@ -175,7 +170,6 @@ export const useInviteTeam = () => {
|
||||
[
|
||||
enqueueSuccessSnackBar,
|
||||
onboardingConfig?.inviteTeamCreditsRewardPerUser,
|
||||
onboardingConfig?.inviteTeamMaxCreditsReward,
|
||||
sendInvitation,
|
||||
setNextOnboardingStatus,
|
||||
setOnboardingFreeCredits,
|
||||
|
||||
@@ -5,6 +5,8 @@ export const useOnboardingFreeCreditsTotal = () => {
|
||||
const onboardingFreeCredits = useAtomStateValue(onboardingFreeCreditsState);
|
||||
|
||||
return (
|
||||
onboardingFreeCredits.importContacts + onboardingFreeCredits.inviteTeam
|
||||
onboardingFreeCredits.importContacts +
|
||||
onboardingFreeCredits.inviteTeam +
|
||||
onboardingFreeCredits.installApps
|
||||
);
|
||||
};
|
||||
|
||||
@@ -38,6 +38,10 @@ const getNextOnboardingStatus = ({
|
||||
}
|
||||
|
||||
if (currentUser?.onboardingStatus === OnboardingStatus.SYNC_EMAIL) {
|
||||
return OnboardingStatus.APPS_INSTALLATION;
|
||||
}
|
||||
|
||||
if (currentUser?.onboardingStatus === OnboardingStatus.APPS_INSTALLATION) {
|
||||
return OnboardingStatus.PROFILE_CREATION;
|
||||
}
|
||||
|
||||
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
import { useSetNextOnboardingStatus } from '@/onboarding/hooks/useSetNextOnboardingStatus';
|
||||
import { useMutation } from '@apollo/client/react';
|
||||
import { useCallback } from 'react';
|
||||
import { TriggerInstallAppsOnboardingStepDocument } from '~/generated-metadata/graphql';
|
||||
|
||||
export const useTriggerInstallAppsOnboardingStep = () => {
|
||||
const setNextOnboardingStatus = useSetNextOnboardingStatus();
|
||||
const [triggerInstallAppsOnboardingStep] = useMutation(
|
||||
TriggerInstallAppsOnboardingStepDocument,
|
||||
);
|
||||
|
||||
return useCallback(
|
||||
async (universalIdentifiers: string[]) => {
|
||||
await triggerInstallAppsOnboardingStep({
|
||||
variables: { universalIdentifiers },
|
||||
});
|
||||
setNextOnboardingStatus();
|
||||
},
|
||||
[triggerInstallAppsOnboardingStep, setNextOnboardingStatus],
|
||||
);
|
||||
};
|
||||
@@ -4,10 +4,11 @@ import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomStat
|
||||
export const onboardingFreeCreditsState =
|
||||
createAtomState<OnboardingFreeCredits>({
|
||||
key: 'onboardingFreeCreditsState',
|
||||
defaultValue: { importContacts: 0, inviteTeam: 0 },
|
||||
defaultValue: { importContacts: 0, inviteTeam: 0, installApps: 0 },
|
||||
useLocalStorage: true,
|
||||
localStorageOptions: { getOnInit: true },
|
||||
validateInitFn: (payload) =>
|
||||
Number.isFinite(payload.importContacts) &&
|
||||
Number.isFinite(payload.inviteTeam),
|
||||
Number.isFinite(payload.inviteTeam) &&
|
||||
Number.isFinite(payload.installApps),
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export type OnboardingFreeCredits = {
|
||||
importContacts: number;
|
||||
inviteTeam: number;
|
||||
installApps: number;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { type MessageDescriptor } from '@lingui/core';
|
||||
|
||||
export type OnboardingInstallableApp = {
|
||||
universalIdentifier: string;
|
||||
label: MessageDescriptor;
|
||||
description: MessageDescriptor;
|
||||
};
|
||||
@@ -0,0 +1,234 @@
|
||||
import { OnboardingCreditsRewardTag } from '@/onboarding/components/import-contacts/OnboardingCreditsRewardTag';
|
||||
import { type OnboardingInstallableApp } from '@/onboarding/types/OnboardingInstallableApp';
|
||||
import { styled } from '@linaria/react';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { Avatar } from 'twenty-ui/data-display';
|
||||
import { IconCheck, IconPlus } from 'twenty-ui/icon';
|
||||
import { IconButton, MainButton } from 'twenty-ui/input';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
import { getAbsoluteImageUrl } from '~/utils/image/getAbsoluteImageUrl';
|
||||
|
||||
const CONTENT_BLOCK_WIDTH = 340;
|
||||
|
||||
const StyledContent = styled.div`
|
||||
align-items: center;
|
||||
background-color: ${themeCssVariables.background.secondary};
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex: 1 1 0;
|
||||
flex-direction: column;
|
||||
gap: ${themeCssVariables.spacing[14]};
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: ${themeCssVariables.spacing[16]} ${themeCssVariables.spacing[8]};
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const StyledHeading = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${themeCssVariables.spacing[4]};
|
||||
width: ${CONTENT_BLOCK_WIDTH}px;
|
||||
`;
|
||||
|
||||
const StyledTitleRow = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: ${themeCssVariables.spacing[2]};
|
||||
`;
|
||||
|
||||
const StyledTitle = styled.h1`
|
||||
color: ${themeCssVariables.font.color.primary};
|
||||
font-size: ${themeCssVariables.font.size.xl};
|
||||
font-weight: ${themeCssVariables.font.weight.semiBold};
|
||||
margin: 0;
|
||||
`;
|
||||
|
||||
const StyledBetaTag = styled.span`
|
||||
align-items: center;
|
||||
background-color: ${themeCssVariables.background.tertiary};
|
||||
border: 1px solid ${themeCssVariables.border.color.light};
|
||||
border-radius: ${themeCssVariables.border.radius.pill};
|
||||
box-sizing: border-box;
|
||||
color: ${themeCssVariables.font.color.tertiary};
|
||||
display: flex;
|
||||
font-size: ${themeCssVariables.font.size.sm};
|
||||
font-weight: ${themeCssVariables.font.weight.medium};
|
||||
height: ${themeCssVariables.spacing[6]};
|
||||
padding: 0 ${themeCssVariables.spacing[2]};
|
||||
`;
|
||||
|
||||
const StyledSubtitle = styled.p`
|
||||
color: ${themeCssVariables.font.color.secondary};
|
||||
font-size: ${themeCssVariables.font.size.md};
|
||||
margin: 0;
|
||||
`;
|
||||
|
||||
const StyledCreditsRow = styled.div`
|
||||
display: flex;
|
||||
padding-top: ${themeCssVariables.spacing[1]};
|
||||
`;
|
||||
|
||||
const StyledCard = styled.div`
|
||||
background-color: ${themeCssVariables.background.primary};
|
||||
border: 1px solid ${themeCssVariables.border.color.medium};
|
||||
border-radius: ${themeCssVariables.border.radius.md};
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: ${CONTENT_BLOCK_WIDTH}px;
|
||||
`;
|
||||
|
||||
const StyledAppRow = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: ${themeCssVariables.spacing[3]};
|
||||
padding: ${themeCssVariables.spacing[4]};
|
||||
|
||||
& + & {
|
||||
border-top: 1px solid ${themeCssVariables.border.color.light};
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledAppText = styled.div`
|
||||
display: flex;
|
||||
flex: 1 1 0;
|
||||
flex-direction: column;
|
||||
gap: ${themeCssVariables.spacing[1]};
|
||||
min-width: 0;
|
||||
`;
|
||||
|
||||
const StyledAppLabel = styled.span`
|
||||
color: ${themeCssVariables.font.color.primary};
|
||||
font-size: ${themeCssVariables.font.size.md};
|
||||
font-weight: ${themeCssVariables.font.weight.medium};
|
||||
`;
|
||||
|
||||
const StyledAppDescription = styled.span`
|
||||
color: ${themeCssVariables.font.color.tertiary};
|
||||
font-size: ${themeCssVariables.font.size.sm};
|
||||
`;
|
||||
|
||||
const StyledFooter = styled.div`
|
||||
align-items: flex-end;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${themeCssVariables.spacing[4]};
|
||||
width: ${CONTENT_BLOCK_WIDTH}px;
|
||||
`;
|
||||
|
||||
const StyledInstallButton = styled.div`
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const StyledSkipButton = styled.button`
|
||||
background-color: transparent;
|
||||
border: 1px solid ${themeCssVariables.border.color.light};
|
||||
border-radius: ${themeCssVariables.border.radius.md};
|
||||
color: ${themeCssVariables.font.color.tertiary};
|
||||
cursor: pointer;
|
||||
font-family: ${themeCssVariables.font.family};
|
||||
font-size: ${themeCssVariables.font.size.md};
|
||||
font-weight: ${themeCssVariables.font.weight.semiBold};
|
||||
height: ${themeCssVariables.spacing[8]};
|
||||
padding: 0 ${themeCssVariables.spacing[5]};
|
||||
`;
|
||||
|
||||
type InstallAppsProps = {
|
||||
apps: (OnboardingInstallableApp & { logo: string | null })[];
|
||||
selectedUniversalIdentifiers: string[];
|
||||
creditsRewardPerApp?: number;
|
||||
isCompleting: boolean;
|
||||
onToggleApp: (universalIdentifier: string) => void;
|
||||
onInstall: () => void;
|
||||
onSkip: () => void;
|
||||
};
|
||||
|
||||
export const InstallApps = ({
|
||||
apps,
|
||||
selectedUniversalIdentifiers,
|
||||
creditsRewardPerApp,
|
||||
isCompleting,
|
||||
onToggleApp,
|
||||
onInstall,
|
||||
onSkip,
|
||||
}: InstallAppsProps) => {
|
||||
const { t } = useLingui();
|
||||
|
||||
return (
|
||||
<StyledContent>
|
||||
<StyledHeading>
|
||||
<StyledTitleRow>
|
||||
<StyledTitle>{t`Install your first apps`}</StyledTitle>
|
||||
<StyledBetaTag>{t`Beta`}</StyledBetaTag>
|
||||
</StyledTitleRow>
|
||||
<StyledSubtitle>
|
||||
{t`Get the most out of your CRM by installing some apps`}
|
||||
</StyledSubtitle>
|
||||
{isDefined(creditsRewardPerApp) && (
|
||||
<StyledCreditsRow>
|
||||
<OnboardingCreditsRewardTag
|
||||
amount={creditsRewardPerApp * apps.length}
|
||||
suffix={t`free credits (${creditsRewardPerApp} per tool)`}
|
||||
/>
|
||||
</StyledCreditsRow>
|
||||
)}
|
||||
</StyledHeading>
|
||||
|
||||
<StyledCard>
|
||||
{apps.map((app) => {
|
||||
const labelText = t(app.label);
|
||||
const isSelected = selectedUniversalIdentifiers.includes(
|
||||
app.universalIdentifier,
|
||||
);
|
||||
|
||||
return (
|
||||
<StyledAppRow key={app.universalIdentifier}>
|
||||
<Avatar
|
||||
avatarUrl={getAbsoluteImageUrl(app.logo)}
|
||||
placeholder={labelText}
|
||||
placeholderColorSeed={app.universalIdentifier}
|
||||
size="md"
|
||||
type="squared"
|
||||
/>
|
||||
<StyledAppText>
|
||||
<StyledAppLabel>{labelText}</StyledAppLabel>
|
||||
<StyledAppDescription>
|
||||
{t(app.description)}
|
||||
</StyledAppDescription>
|
||||
</StyledAppText>
|
||||
<IconButton
|
||||
Icon={isSelected ? IconCheck : IconPlus}
|
||||
variant="secondary"
|
||||
accent={isSelected ? 'blue' : 'default'}
|
||||
ariaLabel={
|
||||
isSelected ? t`Deselect ${labelText}` : t`Select ${labelText}`
|
||||
}
|
||||
onClick={() => onToggleApp(app.universalIdentifier)}
|
||||
/>
|
||||
</StyledAppRow>
|
||||
);
|
||||
})}
|
||||
</StyledCard>
|
||||
|
||||
<StyledFooter>
|
||||
<StyledInstallButton>
|
||||
<MainButton
|
||||
title={t`Install`}
|
||||
onClick={onInstall}
|
||||
disabled={isCompleting}
|
||||
fullWidth
|
||||
/>
|
||||
</StyledInstallButton>
|
||||
<StyledSkipButton
|
||||
type="button"
|
||||
onClick={onSkip}
|
||||
disabled={isCompleting}
|
||||
>
|
||||
{t`Skip`}
|
||||
</StyledSkipButton>
|
||||
</StyledFooter>
|
||||
</StyledContent>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
import { isOnboardingV2State } from '@/auth/states/isOnboardingV2State';
|
||||
import { onboardingConfigState } from '@/client-config/states/onboardingConfigState';
|
||||
import { useMarketplaceApps } from '@/marketplace/hooks/useMarketplaceApps';
|
||||
import { OnboardingV2Layout } from '@/onboarding/components/OnboardingV2Layout';
|
||||
import { ONBOARDING_INSTALLABLE_APPS } from '@/onboarding/constants/OnboardingInstallableApps';
|
||||
import { InstallAppsAutoSkipEffect } from '@/onboarding/effect-components/InstallAppsAutoSkipEffect';
|
||||
import { useInstallOnboardingApps } from '@/onboarding/hooks/useInstallOnboardingApps';
|
||||
import { useOnboardingFreeCreditsTotal } from '@/onboarding/hooks/useOnboardingFreeCreditsTotal';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { InstallApps } from '~/pages/onboarding/InstallApps';
|
||||
|
||||
export const InstallAppsV2 = () => {
|
||||
const isOnboardingV2 = useAtomStateValue(isOnboardingV2State);
|
||||
const { data: marketplaceApps } = useMarketplaceApps();
|
||||
const onboardingConfig = useAtomStateValue(onboardingConfigState);
|
||||
const freeCreditsTotal = useOnboardingFreeCreditsTotal();
|
||||
const [hasAutoSkipFailed, setHasAutoSkipFailed] = useState(false);
|
||||
const {
|
||||
selectedUniversalIdentifiers,
|
||||
isCompleting,
|
||||
toggleApp,
|
||||
installSelectedAppsAndContinue,
|
||||
skip,
|
||||
} = useInstallOnboardingApps();
|
||||
|
||||
const handleAutoSkipError = useCallback(() => {
|
||||
setHasAutoSkipFailed(true);
|
||||
}, []);
|
||||
|
||||
if (!isOnboardingV2 && !hasAutoSkipFailed) {
|
||||
return <InstallAppsAutoSkipEffect onError={handleAutoSkipError} />;
|
||||
}
|
||||
|
||||
const apps = ONBOARDING_INSTALLABLE_APPS.map((app) => ({
|
||||
...app,
|
||||
logo:
|
||||
marketplaceApps.find(
|
||||
(marketplaceApp) => marketplaceApp.id === app.universalIdentifier,
|
||||
)?.logo ?? null,
|
||||
}));
|
||||
|
||||
return (
|
||||
<OnboardingV2Layout freeCredits={freeCreditsTotal}>
|
||||
<InstallApps
|
||||
apps={apps}
|
||||
selectedUniversalIdentifiers={selectedUniversalIdentifiers}
|
||||
creditsRewardPerApp={onboardingConfig?.installAppsCreditsRewardPerApp}
|
||||
isCompleting={isCompleting}
|
||||
onToggleApp={toggleApp}
|
||||
onInstall={installSelectedAppsAndContinue}
|
||||
onSkip={skip}
|
||||
/>
|
||||
</OnboardingV2Layout>
|
||||
);
|
||||
};
|
||||
@@ -94,7 +94,11 @@ export const WorkspaceActivationV2 = () => {
|
||||
}
|
||||
|
||||
hasTriggeredRef.current = true;
|
||||
setOnboardingFreeCredits({ importContacts: 0, inviteTeam: 0 });
|
||||
setOnboardingFreeCredits({
|
||||
importContacts: 0,
|
||||
inviteTeam: 0,
|
||||
installApps: 0,
|
||||
});
|
||||
void activate();
|
||||
}, [activate, currentWorkspace, setOnboardingFreeCredits]);
|
||||
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { getOperationName } from '~/utils/getOperationName';
|
||||
import { type Meta, type StoryObj } from '@storybook/react-vite';
|
||||
import { HttpResponse, graphql } from 'msw';
|
||||
import { within } from 'storybook/test';
|
||||
import { AppPath } from 'twenty-shared/types';
|
||||
|
||||
import { isOnboardingV2State } from '@/auth/states/isOnboardingV2State';
|
||||
import { FIND_MANY_MARKETPLACE_APPS } from '@/marketplace/graphql/queries/findManyMarketplaceApps';
|
||||
import { jotaiStore } from '@/ui/utilities/state/jotai/jotaiStore';
|
||||
import { OnboardingStatus } from '~/generated-metadata/graphql';
|
||||
import { GET_CURRENT_USER } from '~/modules/users/graphql/queries/getCurrentUser';
|
||||
import { InstallAppsV2 } from '~/pages/onboarding/InstallAppsV2';
|
||||
import {
|
||||
PageDecorator,
|
||||
type PageDecoratorArgs,
|
||||
} from '~/testing/decorators/PageDecorator';
|
||||
import { graphqlMocks } from '~/testing/graphqlMocks';
|
||||
import { mockedOnboardingUserData } from '~/testing/mock-data/users';
|
||||
|
||||
const meta: Meta<PageDecoratorArgs> = {
|
||||
title: 'Pages/Onboarding/InstallAppsV2',
|
||||
component: InstallAppsV2,
|
||||
decorators: [
|
||||
(Story) => {
|
||||
jotaiStore.set(isOnboardingV2State.atom, true);
|
||||
|
||||
return <Story />;
|
||||
},
|
||||
PageDecorator,
|
||||
],
|
||||
args: { routePath: AppPath.InstallAppsV2 },
|
||||
parameters: {
|
||||
msw: {
|
||||
handlers: [
|
||||
graphql.query(getOperationName(GET_CURRENT_USER) ?? '', () => {
|
||||
return HttpResponse.json({
|
||||
data: {
|
||||
currentUser: mockedOnboardingUserData(
|
||||
OnboardingStatus.APPS_INSTALLATION,
|
||||
),
|
||||
},
|
||||
});
|
||||
}),
|
||||
graphql.query(
|
||||
getOperationName(FIND_MANY_MARKETPLACE_APPS) ?? '',
|
||||
() => {
|
||||
return HttpResponse.json({
|
||||
data: { findManyMarketplaceApps: [] },
|
||||
});
|
||||
},
|
||||
),
|
||||
graphqlMocks.handlers,
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
export type Story = StoryObj<typeof InstallAppsV2>;
|
||||
|
||||
export const Default: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement.ownerDocument.body);
|
||||
await canvas.findByText('Install your first apps');
|
||||
},
|
||||
};
|
||||
@@ -7,6 +7,7 @@ export const UNTESTED_APP_PATHS = [
|
||||
AppPath.WorkspaceActivationV2,
|
||||
AppPath.CreateProfileV2,
|
||||
AppPath.SyncEmailsV2,
|
||||
AppPath.InstallAppsV2,
|
||||
AppPath.InviteTeamV2,
|
||||
AppPath.PlanRequiredV2,
|
||||
// Public, unauthenticated redirect route handled in useCreateAppRouter — not
|
||||
|
||||
@@ -49,6 +49,7 @@ export const mockedClientConfig: ClientConfig = {
|
||||
inviteTeamMaxCreditsReward: 9,
|
||||
inviteTeamCreditsRewardPerUser: 3,
|
||||
upgradeCreditsReward: 5,
|
||||
installAppsCreditsRewardPerApp: 1,
|
||||
},
|
||||
canManageFeatureFlags: true,
|
||||
publicFeatureFlags: [],
|
||||
|
||||
@@ -383,6 +383,15 @@ export class SignInUpService {
|
||||
},
|
||||
queryRunner,
|
||||
);
|
||||
|
||||
await this.onboardingService.setOnboardingInstallAppsPending(
|
||||
{
|
||||
userId: user.id,
|
||||
workspaceId: workspace.id,
|
||||
value: true,
|
||||
},
|
||||
queryRunner,
|
||||
);
|
||||
}
|
||||
|
||||
private async saveNewUser(
|
||||
|
||||
+1
@@ -89,6 +89,7 @@ describe('ClientConfigController', () => {
|
||||
importContactsCreditsReward: 2,
|
||||
inviteTeamCreditsRewardPerUser: 3,
|
||||
upgradeCreditsReward: 5,
|
||||
installAppsCreditsRewardPerApp: 1,
|
||||
},
|
||||
isAttachmentPreviewEnabled: true,
|
||||
analyticsEnabled: false,
|
||||
|
||||
@@ -214,6 +214,8 @@ export class OnboardingConfig {
|
||||
inviteTeamCreditsRewardPerUser: number;
|
||||
|
||||
upgradeCreditsReward: number;
|
||||
|
||||
installAppsCreditsRewardPerApp: number;
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
|
||||
+2
@@ -95,6 +95,7 @@ describe('ClientConfigService', () => {
|
||||
ONBOARDING_IMPORT_CONTACTS_CREDITS_REWARD: 2_000_000,
|
||||
ONBOARDING_INVITE_TEAM_CREDITS_REWARD_PER_USER: 3_000_000,
|
||||
BILLING_FREE_WORKFLOW_CREDITS_FOR_TRIAL_PERIOD_WITH_CREDIT_CARD: 5_000_000,
|
||||
ONBOARDING_INSTALL_APPS_CREDITS_REWARD_PER_APP: 1_000_000,
|
||||
IS_ATTACHMENT_PREVIEW_ENABLED: true,
|
||||
ANALYTICS_ENABLED: true,
|
||||
MESSAGING_PROVIDER_MICROSOFT_ENABLED: false,
|
||||
@@ -172,6 +173,7 @@ describe('ClientConfigService', () => {
|
||||
importContactsCreditsReward: 2,
|
||||
inviteTeamCreditsRewardPerUser: 3,
|
||||
upgradeCreditsReward: 5,
|
||||
installAppsCreditsRewardPerApp: 1,
|
||||
},
|
||||
isAttachmentPreviewEnabled: true,
|
||||
analyticsEnabled: true,
|
||||
|
||||
+5
@@ -235,6 +235,11 @@ export class ClientConfigService {
|
||||
'BILLING_FREE_WORKFLOW_CREDITS_FOR_TRIAL_PERIOD_WITH_CREDIT_CARD',
|
||||
),
|
||||
),
|
||||
installAppsCreditsRewardPerApp: toDisplayCredits(
|
||||
this.twentyConfigService.get(
|
||||
'ONBOARDING_INSTALL_APPS_CREDITS_REWARD_PER_APP',
|
||||
),
|
||||
),
|
||||
},
|
||||
isAttachmentPreviewEnabled: this.twentyConfigService.get(
|
||||
'IS_ATTACHMENT_PREVIEW_ENABLED',
|
||||
|
||||
@@ -12,8 +12,12 @@ import { UpdateSubscriptionQuantityJob } from 'src/engine/core-modules/billing/j
|
||||
import { BillingReminderModule } from 'src/engine/core-modules/billing/reminders/billing-reminder.module';
|
||||
import { BillingReminderCronJob } from 'src/engine/core-modules/billing/reminders/crons/billing-reminder.cron.job';
|
||||
import { StripeModule } from 'src/engine/core-modules/billing/stripe/stripe.module';
|
||||
import { ApplicationInstallModule } from 'src/engine/core-modules/application/application-install/application-install.module';
|
||||
import { ApplicationRegistrationModule } from 'src/engine/core-modules/application/application-registration/application-registration.module';
|
||||
import { BackfillApplicationInstallationJob } from 'src/engine/core-modules/application/jobs/backfill-application-installation.job';
|
||||
import { PreInstalledAppsModule } from 'src/engine/core-modules/application/pre-installed-apps/pre-installed-apps.module';
|
||||
import { InstallOnboardingAppsJob } from 'src/engine/core-modules/onboarding/jobs/install-onboarding-apps.job';
|
||||
import { OnboardingModule } from 'src/engine/core-modules/onboarding/onboarding.module';
|
||||
import { EmailSenderJob } from 'src/engine/core-modules/email/email-sender.job';
|
||||
import { EmailModule } from 'src/engine/core-modules/email/email.module';
|
||||
import { EmailingModule } from 'src/modules/emailing/emailing.module';
|
||||
@@ -88,6 +92,9 @@ import { WorkflowModule } from 'src/modules/workflow/workflow.module';
|
||||
EnterpriseModule,
|
||||
EmailingModule,
|
||||
PreInstalledAppsModule,
|
||||
ApplicationInstallModule,
|
||||
ApplicationRegistrationModule,
|
||||
OnboardingModule,
|
||||
BillingReminderModule,
|
||||
],
|
||||
providers: [
|
||||
@@ -103,6 +110,7 @@ import { WorkflowModule } from 'src/modules/workflow/workflow.module';
|
||||
UpdateWorkspaceMemberEmailJob,
|
||||
GenerateSdkClientJob,
|
||||
BackfillApplicationInstallationJob,
|
||||
InstallOnboardingAppsJob,
|
||||
],
|
||||
})
|
||||
export class JobsModule {
|
||||
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
export const ONBOARDING_INSTALLABLE_APP_UNIVERSAL_IDENTIFIERS = [
|
||||
'8da4b8b5-5edf-4880-b51f-ab6e679ec617',
|
||||
'4a1178c1-3535-4a47-b592-231d3216b36f',
|
||||
];
|
||||
+1
@@ -3,6 +3,7 @@ export enum OnboardingStatus {
|
||||
WORKSPACE_ACTIVATION = 'WORKSPACE_ACTIVATION',
|
||||
PROFILE_CREATION = 'PROFILE_CREATION',
|
||||
SYNC_EMAIL = 'SYNC_EMAIL',
|
||||
APPS_INSTALLATION = 'APPS_INSTALLATION',
|
||||
INVITE_TEAM = 'INVITE_TEAM',
|
||||
BOOK_ONBOARDING = 'BOOK_ONBOARDING',
|
||||
COMPLETED = 'COMPLETED',
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
export const INSTALL_ONBOARDING_APPS_JOB_NAME = 'InstallOnboardingAppsJob';
|
||||
|
||||
export type InstallOnboardingAppsJobData = {
|
||||
workspaceId: string;
|
||||
universalIdentifiers: string[];
|
||||
};
|
||||
+158
@@ -0,0 +1,158 @@
|
||||
import { Test, type TestingModule } from '@nestjs/testing';
|
||||
|
||||
import { ApplicationInstallService } from 'src/engine/core-modules/application/application-install/application-install.service';
|
||||
import { type ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity';
|
||||
import { ApplicationRegistrationService } from 'src/engine/core-modules/application/application-registration/application-registration.service';
|
||||
import { InstallOnboardingAppsJob } from 'src/engine/core-modules/onboarding/jobs/install-onboarding-apps.job';
|
||||
import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.service';
|
||||
|
||||
describe('InstallOnboardingAppsJob', () => {
|
||||
let job: InstallOnboardingAppsJob;
|
||||
let applicationRegistrationService: ApplicationRegistrationService;
|
||||
let applicationInstallService: ApplicationInstallService;
|
||||
let onboardingService: OnboardingService;
|
||||
|
||||
const workspaceId = 'workspace-id';
|
||||
const callRecorderId = 'call-recorder-uid';
|
||||
const peopleDataLabsId = 'people-data-labs-uid';
|
||||
|
||||
const buildRegistration = (id: string) =>
|
||||
({ id }) as ApplicationRegistrationEntity;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
InstallOnboardingAppsJob,
|
||||
{
|
||||
provide: ApplicationRegistrationService,
|
||||
useValue: {
|
||||
findOneByUniversalIdentifier: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: ApplicationInstallService,
|
||||
useValue: {
|
||||
installApplication: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: OnboardingService,
|
||||
useValue: {
|
||||
creditInstallAppsReward: jest.fn(),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
job = module.get<InstallOnboardingAppsJob>(InstallOnboardingAppsJob);
|
||||
applicationRegistrationService = module.get<ApplicationRegistrationService>(
|
||||
ApplicationRegistrationService,
|
||||
);
|
||||
applicationInstallService = module.get<ApplicationInstallService>(
|
||||
ApplicationInstallService,
|
||||
);
|
||||
onboardingService = module.get<OnboardingService>(OnboardingService);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should credit the reward for every requested app and install them', async () => {
|
||||
jest
|
||||
.spyOn(applicationRegistrationService, 'findOneByUniversalIdentifier')
|
||||
.mockImplementation(async (universalIdentifier) =>
|
||||
buildRegistration(`registration-${universalIdentifier}`),
|
||||
);
|
||||
jest
|
||||
.spyOn(applicationInstallService, 'installApplication')
|
||||
.mockResolvedValue(true);
|
||||
|
||||
await job.handle({
|
||||
workspaceId,
|
||||
universalIdentifiers: [callRecorderId, peopleDataLabsId],
|
||||
});
|
||||
|
||||
expect(onboardingService.creditInstallAppsReward).toHaveBeenCalledWith({
|
||||
workspaceId,
|
||||
rewardAppsCount: 2,
|
||||
});
|
||||
expect(applicationInstallService.installApplication).toHaveBeenCalledTimes(
|
||||
2,
|
||||
);
|
||||
});
|
||||
|
||||
it('should credit before attempting the installs', async () => {
|
||||
jest
|
||||
.spyOn(applicationRegistrationService, 'findOneByUniversalIdentifier')
|
||||
.mockImplementation(async (universalIdentifier) =>
|
||||
buildRegistration(`registration-${universalIdentifier}`),
|
||||
);
|
||||
jest
|
||||
.spyOn(applicationInstallService, 'installApplication')
|
||||
.mockResolvedValue(true);
|
||||
|
||||
await job.handle({
|
||||
workspaceId,
|
||||
universalIdentifiers: [callRecorderId],
|
||||
});
|
||||
|
||||
const creditOrder = (onboardingService.creditInstallAppsReward as jest.Mock)
|
||||
.mock.invocationCallOrder[0];
|
||||
const installOrder = (
|
||||
applicationInstallService.installApplication as jest.Mock
|
||||
).mock.invocationCallOrder[0];
|
||||
|
||||
expect(creditOrder).toBeLessThan(installOrder);
|
||||
});
|
||||
|
||||
it('should credit even when an install fails', async () => {
|
||||
jest
|
||||
.spyOn(applicationRegistrationService, 'findOneByUniversalIdentifier')
|
||||
.mockImplementation(async (universalIdentifier) =>
|
||||
buildRegistration(`registration-${universalIdentifier}`),
|
||||
);
|
||||
jest
|
||||
.spyOn(applicationInstallService, 'installApplication')
|
||||
.mockRejectedValue(new Error('install failure'));
|
||||
|
||||
await job.handle({
|
||||
workspaceId,
|
||||
universalIdentifiers: [callRecorderId, peopleDataLabsId],
|
||||
});
|
||||
|
||||
expect(onboardingService.creditInstallAppsReward).toHaveBeenCalledWith({
|
||||
workspaceId,
|
||||
rewardAppsCount: 2,
|
||||
});
|
||||
expect(applicationInstallService.installApplication).toHaveBeenCalledTimes(
|
||||
2,
|
||||
);
|
||||
});
|
||||
|
||||
it('should skip installing apps whose registration cannot be found but still credit the full count', async () => {
|
||||
jest
|
||||
.spyOn(applicationRegistrationService, 'findOneByUniversalIdentifier')
|
||||
.mockImplementation(async (universalIdentifier) =>
|
||||
universalIdentifier === callRecorderId
|
||||
? null
|
||||
: buildRegistration(`registration-${universalIdentifier}`),
|
||||
);
|
||||
jest
|
||||
.spyOn(applicationInstallService, 'installApplication')
|
||||
.mockResolvedValue(true);
|
||||
|
||||
await job.handle({
|
||||
workspaceId,
|
||||
universalIdentifiers: [callRecorderId, peopleDataLabsId],
|
||||
});
|
||||
|
||||
expect(onboardingService.creditInstallAppsReward).toHaveBeenCalledWith({
|
||||
workspaceId,
|
||||
rewardAppsCount: 2,
|
||||
});
|
||||
expect(applicationInstallService.installApplication).toHaveBeenCalledTimes(
|
||||
1,
|
||||
);
|
||||
});
|
||||
});
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { ApplicationInstallService } from 'src/engine/core-modules/application/application-install/application-install.service';
|
||||
import { ApplicationRegistrationService } from 'src/engine/core-modules/application/application-registration/application-registration.service';
|
||||
import { Process } from 'src/engine/core-modules/message-queue/decorators/process.decorator';
|
||||
import { Processor } from 'src/engine/core-modules/message-queue/decorators/processor.decorator';
|
||||
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
|
||||
import {
|
||||
INSTALL_ONBOARDING_APPS_JOB_NAME,
|
||||
type InstallOnboardingAppsJobData,
|
||||
} from 'src/engine/core-modules/onboarding/jobs/install-onboarding-apps.job-constants';
|
||||
import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.service';
|
||||
|
||||
@Processor(MessageQueue.workspaceQueue)
|
||||
export class InstallOnboardingAppsJob {
|
||||
private readonly logger = new Logger(InstallOnboardingAppsJob.name);
|
||||
|
||||
constructor(
|
||||
private readonly applicationRegistrationService: ApplicationRegistrationService,
|
||||
private readonly applicationInstallService: ApplicationInstallService,
|
||||
private readonly onboardingService: OnboardingService,
|
||||
) {}
|
||||
|
||||
@Process(INSTALL_ONBOARDING_APPS_JOB_NAME)
|
||||
async handle({
|
||||
workspaceId,
|
||||
universalIdentifiers,
|
||||
}: InstallOnboardingAppsJobData): Promise<void> {
|
||||
await this.onboardingService.creditInstallAppsReward({
|
||||
workspaceId,
|
||||
rewardAppsCount: universalIdentifiers.length,
|
||||
});
|
||||
|
||||
for (const universalIdentifier of universalIdentifiers) {
|
||||
await this.installApp({ universalIdentifier, workspaceId });
|
||||
}
|
||||
}
|
||||
|
||||
private async installApp({
|
||||
universalIdentifier,
|
||||
workspaceId,
|
||||
}: {
|
||||
universalIdentifier: string;
|
||||
workspaceId: string;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
const registration =
|
||||
await this.applicationRegistrationService.findOneByUniversalIdentifier(
|
||||
universalIdentifier,
|
||||
);
|
||||
|
||||
if (!isDefined(registration)) {
|
||||
this.logger.error(
|
||||
`Onboarding app ${universalIdentifier} not found while installing for workspace ${workspaceId}`,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await this.applicationInstallService.installApplication({
|
||||
appRegistrationId: registration.id,
|
||||
workspaceId,
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to install onboarding app ${universalIdentifier} for workspace ${workspaceId}`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { UseFilters, UseGuards, UsePipes } from '@nestjs/common';
|
||||
import { Mutation, Query } from '@nestjs/graphql';
|
||||
import { Args, Mutation, Query } from '@nestjs/graphql';
|
||||
|
||||
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
|
||||
import { PreventNestToAutoLogGraphqlErrorsFilter } from 'src/engine/core-modules/graphql/filters/prevent-nest-to-auto-log-graphql-errors.filter';
|
||||
@@ -68,4 +68,21 @@ export class OnboardingResolver {
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Mutation(() => OnboardingStepSuccessDTO)
|
||||
@UseGuards(NoPermissionGuard)
|
||||
async triggerInstallAppsOnboardingStep(
|
||||
@AuthUser() user: AuthContextUser,
|
||||
@AuthWorkspace() workspace: WorkspaceEntity,
|
||||
@Args({ name: 'universalIdentifiers', type: () => [String] })
|
||||
universalIdentifiers: string[],
|
||||
): Promise<OnboardingStepSuccessDTO> {
|
||||
await this.onboardingService.triggerInstallAppsOnboardingStep({
|
||||
userId: user.id,
|
||||
workspaceId: workspace.id,
|
||||
universalIdentifiers,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,11 @@ import { 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';
|
||||
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
|
||||
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 { INSTALL_ONBOARDING_APPS_JOB_NAME } from 'src/engine/core-modules/onboarding/jobs/install-onboarding-apps.job-constants';
|
||||
import {
|
||||
OnboardingService,
|
||||
OnboardingStepKeys,
|
||||
@@ -18,6 +23,7 @@ describe('OnboardingService', () => {
|
||||
let userVarsService: UserVarsService;
|
||||
let billingCreditService: BillingCreditService;
|
||||
let twentyConfigService: TwentyConfigService;
|
||||
let messageQueueService: MessageQueueService;
|
||||
|
||||
const userId = 'user-id';
|
||||
const workspaceId = 'workspace-id';
|
||||
@@ -56,6 +62,12 @@ describe('OnboardingService', () => {
|
||||
provide: getRepositoryToken(WorkspaceEntity),
|
||||
useClass: Repository,
|
||||
},
|
||||
{
|
||||
provide: getQueueToken(MessageQueue.workspaceQueue),
|
||||
useValue: {
|
||||
add: jest.fn(),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
@@ -64,6 +76,9 @@ describe('OnboardingService', () => {
|
||||
billingCreditService =
|
||||
module.get<BillingCreditService>(BillingCreditService);
|
||||
twentyConfigService = module.get<TwentyConfigService>(TwentyConfigService);
|
||||
messageQueueService = module.get<MessageQueueService>(
|
||||
getQueueToken(MessageQueue.workspaceQueue),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -136,4 +151,91 @@ describe('OnboardingService', () => {
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('triggerInstallAppsOnboardingStep', () => {
|
||||
const [callRecorderId, peopleDataLabsId] =
|
||||
ONBOARDING_INSTALLABLE_APP_UNIVERSAL_IDENTIFIERS;
|
||||
|
||||
it('should claim the step and enqueue the install job for the installable apps without crediting', async () => {
|
||||
jest.spyOn(userVarsService, 'delete').mockResolvedValue(1);
|
||||
|
||||
await service.triggerInstallAppsOnboardingStep({
|
||||
userId,
|
||||
workspaceId,
|
||||
universalIdentifiers: [callRecorderId, peopleDataLabsId],
|
||||
});
|
||||
|
||||
expect(userVarsService.delete).toHaveBeenCalledWith({
|
||||
userId,
|
||||
workspaceId,
|
||||
key: OnboardingStepKeys.ONBOARDING_INSTALL_APPS_PENDING,
|
||||
});
|
||||
expect(messageQueueService.add).toHaveBeenCalledWith(
|
||||
INSTALL_ONBOARDING_APPS_JOB_NAME,
|
||||
{
|
||||
workspaceId,
|
||||
universalIdentifiers: [callRecorderId, peopleDataLabsId],
|
||||
},
|
||||
{ id: `${INSTALL_ONBOARDING_APPS_JOB_NAME}-${workspaceId}` },
|
||||
);
|
||||
expect(
|
||||
billingCreditService.creditWorkspaceBalance,
|
||||
).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not enqueue anything when the step was already consumed', async () => {
|
||||
jest.spyOn(userVarsService, 'delete').mockResolvedValue(0);
|
||||
|
||||
await service.triggerInstallAppsOnboardingStep({
|
||||
userId,
|
||||
workspaceId,
|
||||
universalIdentifiers: [callRecorderId],
|
||||
});
|
||||
|
||||
expect(messageQueueService.add).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should claim the step but not enqueue when no installable app was selected', async () => {
|
||||
jest.spyOn(userVarsService, 'delete').mockResolvedValue(1);
|
||||
|
||||
await service.triggerInstallAppsOnboardingStep({
|
||||
userId,
|
||||
workspaceId,
|
||||
universalIdentifiers: ['00000000-0000-0000-0000-000000000000'],
|
||||
});
|
||||
|
||||
expect(userVarsService.delete).toHaveBeenCalled();
|
||||
expect(messageQueueService.add).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('creditInstallAppsReward', () => {
|
||||
it('should credit the reward per installed app', async () => {
|
||||
jest.spyOn(twentyConfigService, 'get').mockReturnValue(1_000_000);
|
||||
|
||||
await service.creditInstallAppsReward({
|
||||
workspaceId,
|
||||
rewardAppsCount: 2,
|
||||
});
|
||||
|
||||
expect(billingCreditService.creditWorkspaceBalance).toHaveBeenCalledWith({
|
||||
workspaceId,
|
||||
amountMicro: 2_000_000,
|
||||
});
|
||||
});
|
||||
|
||||
it('should not throw when crediting fails', async () => {
|
||||
jest.spyOn(twentyConfigService, 'get').mockReturnValue(1_000_000);
|
||||
jest
|
||||
.spyOn(billingCreditService, 'creditWorkspaceBalance')
|
||||
.mockRejectedValue(new Error('billing failure'));
|
||||
|
||||
await expect(
|
||||
service.creditInstallAppsReward({
|
||||
workspaceId,
|
||||
rewardAppsCount: 1,
|
||||
}),
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,7 +8,15 @@ import { 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';
|
||||
import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator';
|
||||
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
|
||||
import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service';
|
||||
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,
|
||||
type InstallOnboardingAppsJobData,
|
||||
} from 'src/engine/core-modules/onboarding/jobs/install-onboarding-apps.job-constants';
|
||||
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';
|
||||
@@ -19,6 +27,7 @@ export enum OnboardingStepKeys {
|
||||
ONBOARDING_INVITE_TEAM_PENDING = 'ONBOARDING_INVITE_TEAM_PENDING',
|
||||
ONBOARDING_CREATE_PROFILE_PENDING = 'ONBOARDING_CREATE_PROFILE_PENDING',
|
||||
ONBOARDING_BOOK_ONBOARDING_PENDING = 'ONBOARDING_BOOK_ONBOARDING_PENDING',
|
||||
ONBOARDING_INSTALL_APPS_PENDING = 'ONBOARDING_INSTALL_APPS_PENDING',
|
||||
}
|
||||
|
||||
export type OnboardingKeyValueTypeMap = {
|
||||
@@ -26,6 +35,7 @@ export type OnboardingKeyValueTypeMap = {
|
||||
[OnboardingStepKeys.ONBOARDING_INVITE_TEAM_PENDING]: boolean;
|
||||
[OnboardingStepKeys.ONBOARDING_CREATE_PROFILE_PENDING]: boolean;
|
||||
[OnboardingStepKeys.ONBOARDING_BOOK_ONBOARDING_PENDING]: boolean;
|
||||
[OnboardingStepKeys.ONBOARDING_INSTALL_APPS_PENDING]: boolean;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
@@ -39,6 +49,8 @@ export class OnboardingService {
|
||||
private readonly twentyConfigService: TwentyConfigService,
|
||||
@InjectRepository(WorkspaceEntity)
|
||||
private readonly workspaceRepository: Repository<WorkspaceEntity>,
|
||||
@InjectMessageQueue(MessageQueue.workspaceQueue)
|
||||
private readonly messageQueueService: MessageQueueService,
|
||||
) {}
|
||||
|
||||
private isWorkspaceActivationPending(workspace: WorkspaceEntity) {
|
||||
@@ -85,6 +97,9 @@ export class OnboardingService {
|
||||
userVars.get(OnboardingStepKeys.ONBOARDING_CONNECT_ACCOUNT_PENDING) ===
|
||||
true;
|
||||
|
||||
const isInstallAppsPending =
|
||||
userVars.get(OnboardingStepKeys.ONBOARDING_INSTALL_APPS_PENDING) === true;
|
||||
|
||||
const isInviteTeamPending =
|
||||
userVars.get(OnboardingStepKeys.ONBOARDING_INVITE_TEAM_PENDING) === true;
|
||||
|
||||
@@ -96,6 +111,10 @@ export class OnboardingService {
|
||||
return OnboardingStatus.SYNC_EMAIL;
|
||||
}
|
||||
|
||||
if (isInstallAppsPending) {
|
||||
return OnboardingStatus.APPS_INSTALLATION;
|
||||
}
|
||||
|
||||
if (isProfileCreationPending) {
|
||||
return OnboardingStatus.PROFILE_CREATION;
|
||||
}
|
||||
@@ -237,6 +256,116 @@ export class OnboardingService {
|
||||
}
|
||||
}
|
||||
|
||||
async setOnboardingInstallAppsPending(
|
||||
{
|
||||
userId,
|
||||
workspaceId,
|
||||
value,
|
||||
}: {
|
||||
userId: string;
|
||||
workspaceId: string;
|
||||
value: boolean;
|
||||
},
|
||||
queryRunner?: QueryRunner,
|
||||
) {
|
||||
if (!value) {
|
||||
await this.userVarsService.delete(
|
||||
{
|
||||
userId,
|
||||
workspaceId,
|
||||
key: OnboardingStepKeys.ONBOARDING_INSTALL_APPS_PENDING,
|
||||
},
|
||||
queryRunner,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await this.userVarsService.set(
|
||||
{
|
||||
userId,
|
||||
workspaceId,
|
||||
key: OnboardingStepKeys.ONBOARDING_INSTALL_APPS_PENDING,
|
||||
value: true,
|
||||
},
|
||||
queryRunner,
|
||||
);
|
||||
}
|
||||
|
||||
async triggerInstallAppsOnboardingStep({
|
||||
userId,
|
||||
workspaceId,
|
||||
universalIdentifiers,
|
||||
}: {
|
||||
userId: string;
|
||||
workspaceId: string;
|
||||
universalIdentifiers: string[];
|
||||
}) {
|
||||
const hasClaimedInstallAppsStep = await this.claimInstallAppsOnboardingStep(
|
||||
{ userId, workspaceId },
|
||||
);
|
||||
|
||||
if (!hasClaimedInstallAppsStep) {
|
||||
return;
|
||||
}
|
||||
|
||||
const installableUniversalIdentifiers = universalIdentifiers.filter(
|
||||
(universalIdentifier) =>
|
||||
ONBOARDING_INSTALLABLE_APP_UNIVERSAL_IDENTIFIERS.includes(
|
||||
universalIdentifier,
|
||||
),
|
||||
);
|
||||
|
||||
if (installableUniversalIdentifiers.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.messageQueueService.add<InstallOnboardingAppsJobData>(
|
||||
INSTALL_ONBOARDING_APPS_JOB_NAME,
|
||||
{ workspaceId, universalIdentifiers: installableUniversalIdentifiers },
|
||||
{ id: `${INSTALL_ONBOARDING_APPS_JOB_NAME}-${workspaceId}` },
|
||||
);
|
||||
}
|
||||
|
||||
private async claimInstallAppsOnboardingStep({
|
||||
userId,
|
||||
workspaceId,
|
||||
}: {
|
||||
userId: string;
|
||||
workspaceId: string;
|
||||
}): Promise<boolean> {
|
||||
const affectedRows = await this.userVarsService.delete({
|
||||
userId,
|
||||
workspaceId,
|
||||
key: OnboardingStepKeys.ONBOARDING_INSTALL_APPS_PENDING,
|
||||
});
|
||||
|
||||
return isDefined(affectedRows) && affectedRows > 0;
|
||||
}
|
||||
|
||||
async creditInstallAppsReward({
|
||||
workspaceId,
|
||||
rewardAppsCount,
|
||||
}: {
|
||||
workspaceId: string;
|
||||
rewardAppsCount: number;
|
||||
}) {
|
||||
try {
|
||||
await this.billingCreditService.creditWorkspaceBalance({
|
||||
workspaceId,
|
||||
amountMicro:
|
||||
this.twentyConfigService.get(
|
||||
'ONBOARDING_INSTALL_APPS_CREDITS_REWARD_PER_APP',
|
||||
) * rewardAppsCount,
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to credit onboarding install-apps reward for workspace ${workspaceId}`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async setOnboardingInviteTeamPending(
|
||||
{
|
||||
workspaceId,
|
||||
|
||||
@@ -966,6 +966,17 @@ export class ConfigVariables {
|
||||
@IsOptional()
|
||||
ONBOARDING_INVITE_TEAM_CREDITS_REWARD_PER_USER = 500_000;
|
||||
|
||||
@ConfigVariablesMetadata({
|
||||
group: ConfigVariablesGroup.BILLING_CONFIG,
|
||||
description:
|
||||
'Free credits granted per app installed during the install-apps onboarding step (in microCredits)',
|
||||
type: ConfigVariableType.NUMBER,
|
||||
})
|
||||
@CastToPositiveNumber()
|
||||
@IsInt()
|
||||
@IsOptional()
|
||||
ONBOARDING_INSTALL_APPS_CREDITS_REWARD_PER_APP = 1_000_000;
|
||||
|
||||
@ConfigVariablesMetadata({
|
||||
group: ConfigVariablesGroup.SERVER_CONFIG,
|
||||
description: 'Url for the frontend application',
|
||||
|
||||
+21
-2
@@ -74,8 +74,27 @@ describe('updateWorkspaceMemberSettings and profile onboarding', () => {
|
||||
it('should clear PROFILE_CREATION onboarding after saving first and last name via updateWorkspaceMemberSettings', async () => {
|
||||
const uniqueEmail = `profile-onboarding-${randomUUID()}@example.com`;
|
||||
|
||||
newUserAccessToken =
|
||||
await signUpInWorkspaceAndGetAccessToken(uniqueEmail);
|
||||
newUserAccessToken = await signUpInWorkspaceAndGetAccessToken(uniqueEmail);
|
||||
|
||||
const triggerInstallAppsOnboardingStepMutation = gql`
|
||||
mutation TriggerInstallAppsOnboardingStep(
|
||||
$universalIdentifiers: [String!]!
|
||||
) {
|
||||
triggerInstallAppsOnboardingStep(
|
||||
universalIdentifiers: $universalIdentifiers
|
||||
) {
|
||||
success
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
await makeMetadataAPIRequest(
|
||||
{
|
||||
query: triggerInstallAppsOnboardingStepMutation,
|
||||
variables: { universalIdentifiers: [] },
|
||||
},
|
||||
newUserAccessToken,
|
||||
);
|
||||
|
||||
const currentUserWithOnboardingQuery = gql`
|
||||
query CurrentUserWithOnboarding {
|
||||
|
||||
@@ -15,6 +15,7 @@ export enum AppPath {
|
||||
CreateProfileV2 = '/create/profile-v2',
|
||||
SyncEmails = '/sync/emails',
|
||||
SyncEmailsV2 = '/sync/emails-v2',
|
||||
InstallAppsV2 = '/install-apps-v2',
|
||||
InviteTeam = '/invite-team',
|
||||
InviteTeamV2 = '/invite-team-v2',
|
||||
PlanRequired = '/plan-required',
|
||||
|
||||
Reference in New Issue
Block a user