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:
Raphaël Bosi
2026-07-01 14:25:16 +02:00
committed by GitHub
parent 93b8f66794
commit 2e6077383b
45 changed files with 1336 additions and 16 deletions
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`,
},
];
@@ -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;
};
@@ -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
}
}
`;
@@ -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();
});
});
});
@@ -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;
}
@@ -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: [],