From 2e6077383b3d2d13e2bc06db4fa246202f6f3a85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Bosi?= <71827178+bosiraphael@users.noreply.github.com> Date: Wed, 1 Jul 2026 14:25:16 +0200 Subject: [PATCH] Add install your first apps onboarding V2 step (#22347) https://github.com/user-attachments/assets/5326d48f-1842-4db1-bc7c-94852145c035 CleanShot 2026-06-30 at 16 25 05@2x 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)". Review in cubic --- .../src/metadata/generated/schema.graphql | 2 + .../src/metadata/generated/schema.ts | 5 +- .../src/metadata/generated/types.ts | 9 + .../src/generated-metadata/graphql.ts | 15 ++ ...sePageChangeEffectNavigateLocation.test.ts | 42 ++++ .../usePageChangeEffectNavigateLocation.ts | 7 + .../modules/app/hooks/useCreateAppRouter.tsx | 14 ++ .../modules/auth/constants/OnboardingPaths.ts | 1 + .../client-config/types/OnboardingConfig.ts | 1 + .../constants/OnboardingInstallableApps.ts | 15 ++ .../InstallAppsAutoSkipEffect.tsx | 35 +++ .../triggerInstallAppsOnboardingStep.ts | 11 + .../useInstallOnboardingApps.test.ts | 162 ++++++++++++ .../useSetNextOnboardingStatus.test.ts | 9 +- .../hooks/useInstallOnboardingApps.ts | 55 ++++ .../modules/onboarding/hooks/useInviteTeam.ts | 8 +- .../hooks/useOnboardingFreeCreditsTotal.ts | 4 +- .../hooks/useSetNextOnboardingStatus.ts | 4 + .../useTriggerInstallAppsOnboardingStep.ts | 21 ++ .../states/onboardingFreeCreditsState.ts | 5 +- .../onboarding/types/OnboardingFreeCredits.ts | 1 + .../types/OnboardingInstallableApp.ts | 7 + .../src/pages/onboarding/InstallApps.tsx | 234 ++++++++++++++++++ .../src/pages/onboarding/InstallAppsV2.tsx | 56 +++++ .../onboarding/WorkspaceActivationV2.tsx | 6 +- .../__stories__/InstallAppsV2.stories.tsx | 67 +++++ .../src/testing/constants/UntestedAppPaths.ts | 1 + .../src/testing/mock-data/config.ts | 1 + .../auth/services/sign-in-up.service.ts | 9 + .../client-config.controller.spec.ts | 1 + .../client-config/client-config.entity.ts | 2 + .../services/client-config.service.spec.ts | 2 + .../services/client-config.service.ts | 5 + .../core-modules/message-queue/jobs.module.ts | 8 + ...g-installable-app-universal-identifiers.ts | 4 + .../enums/onboarding-status.enum.ts | 1 + .../install-onboarding-apps.job-constants.ts | 6 + .../jobs/install-onboarding-apps.job.spec.ts | 158 ++++++++++++ .../jobs/install-onboarding-apps.job.ts | 73 ++++++ .../onboarding/onboarding.resolver.ts | 19 +- .../onboarding/onboarding.service.spec.ts | 102 ++++++++ .../onboarding/onboarding.service.ts | 129 ++++++++++ .../twenty-config/config-variables.ts | 11 + ...er-settings-onboarding.integration-spec.ts | 23 +- packages/twenty-shared/src/types/AppPath.ts | 1 + 45 files changed, 1336 insertions(+), 16 deletions(-) create mode 100644 packages/twenty-front/src/modules/onboarding/constants/OnboardingInstallableApps.ts create mode 100644 packages/twenty-front/src/modules/onboarding/effect-components/InstallAppsAutoSkipEffect.tsx create mode 100644 packages/twenty-front/src/modules/onboarding/graphql/mutations/triggerInstallAppsOnboardingStep.ts create mode 100644 packages/twenty-front/src/modules/onboarding/hooks/__tests__/useInstallOnboardingApps.test.ts create mode 100644 packages/twenty-front/src/modules/onboarding/hooks/useInstallOnboardingApps.ts create mode 100644 packages/twenty-front/src/modules/onboarding/hooks/useTriggerInstallAppsOnboardingStep.ts create mode 100644 packages/twenty-front/src/modules/onboarding/types/OnboardingInstallableApp.ts create mode 100644 packages/twenty-front/src/pages/onboarding/InstallApps.tsx create mode 100644 packages/twenty-front/src/pages/onboarding/InstallAppsV2.tsx create mode 100644 packages/twenty-front/src/pages/onboarding/__stories__/InstallAppsV2.stories.tsx create mode 100644 packages/twenty-server/src/engine/core-modules/onboarding/constants/onboarding-installable-app-universal-identifiers.ts create mode 100644 packages/twenty-server/src/engine/core-modules/onboarding/jobs/install-onboarding-apps.job-constants.ts create mode 100644 packages/twenty-server/src/engine/core-modules/onboarding/jobs/install-onboarding-apps.job.spec.ts create mode 100644 packages/twenty-server/src/engine/core-modules/onboarding/jobs/install-onboarding-apps.job.ts diff --git a/packages/twenty-client-sdk/src/metadata/generated/schema.graphql b/packages/twenty-client-sdk/src/metadata/generated/schema.graphql index 7db61ae21f..b6b9ff17de 100644 --- a/packages/twenty-client-sdk/src/metadata/generated/schema.graphql +++ b/packages/twenty-client-sdk/src/metadata/generated/schema.graphql @@ -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! diff --git a/packages/twenty-client-sdk/src/metadata/generated/schema.ts b/packages/twenty-client-sdk/src/metadata/generated/schema.ts index 61fbf816a3..7463f3a6d5 100644 --- a/packages/twenty-client-sdk/src/metadata/generated/schema.ts +++ b/packages/twenty-client-sdk/src/metadata/generated/schema.ts @@ -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 diff --git a/packages/twenty-client-sdk/src/metadata/generated/types.ts b/packages/twenty-client-sdk/src/metadata/generated/types.ts index a3d06304bc..2fc2d07041 100644 --- a/packages/twenty-client-sdk/src/metadata/generated/types.ts +++ b/packages/twenty-client-sdk/src/metadata/generated/types.ts @@ -7416,6 +7416,15 @@ export default { "skipBookOnboardingStep": [ 156 ], + "triggerInstallAppsOnboardingStep": [ + 156, + { + "universalIdentifiers": [ + 1, + "[String!]!" + ] + } + ], "updateOneApplicationVariable": [ 6, { diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index cbff9fd2fb..a1a0e1b433 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -2634,6 +2634,7 @@ export type Mutation = { syncMarketplaceCatalog: Scalars['Boolean']['output']; trackAnalytics: Analytics; transferApplicationRegistrationOwnership: ApplicationRegistration; + triggerInstallAppsOnboardingStep: OnboardingStepSuccess; unarchiveChatThread: AgentChatThread; uninstallApplication: Scalars['Boolean']['output']; updateApiKey?: Maybe; @@ -3468,6 +3469,11 @@ export type MutationTransferApplicationRegistrationOwnershipArgs = { }; +export type MutationTriggerInstallAppsOnboardingStepArgs = { + universalIdentifiers: Array; +}; + + export type MutationUnarchiveChatThreadArgs = { id: Scalars['UUID']['input']; }; @@ -4014,6 +4020,7 @@ export type ObjectStandardOverrides = { /** Onboarding status */ export enum OnboardingStatus { + APPS_INSTALLATION = 'APPS_INSTALLATION', BOOK_ONBOARDING = 'BOOK_ONBOARDING', COMPLETED = 'COMPLETED', INVITE_TEAM = 'INVITE_TEAM', @@ -7277,6 +7284,13 @@ export type SkipSyncEmailOnboardingStepMutationVariables = Exact<{ [key: string] export type SkipSyncEmailOnboardingStepMutation = { __typename?: 'Mutation', skipSyncEmailOnboardingStep: { __typename?: 'OnboardingStepSuccess', success: boolean } }; +export type TriggerInstallAppsOnboardingStepMutationVariables = Exact<{ + universalIdentifiers: Array | Scalars['String']['input']; +}>; + + +export type TriggerInstallAppsOnboardingStepMutation = { __typename?: 'Mutation', triggerInstallAppsOnboardingStep: { __typename?: 'OnboardingStepSuccess', success: boolean } }; + export type GetInviteSuggestionsQueryVariables = Exact<{ [key: string]: never; }>; @@ -8830,6 +8844,7 @@ export const ObjectMetadataItemsDocument = {"kind":"Document","definitions":[{"k export const ObjectRecordCountsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"ObjectRecordCounts"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"objectRecordCounts"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"objectNamePlural"}},{"kind":"Field","name":{"kind":"Name","value":"totalCount"}}]}}]}}]} as unknown as DocumentNode; export const SkipBookOnboardingStepDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"SkipBookOnboardingStep"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"skipBookOnboardingStep"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"success"}}]}}]}}]} as unknown as DocumentNode; export const SkipSyncEmailOnboardingStepDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"SkipSyncEmailOnboardingStep"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"skipSyncEmailOnboardingStep"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"success"}}]}}]}}]} as unknown as DocumentNode; +export const TriggerInstallAppsOnboardingStepDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"TriggerInstallAppsOnboardingStep"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"universalIdentifiers"}},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"triggerInstallAppsOnboardingStep"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"universalIdentifiers"},"value":{"kind":"Variable","name":{"kind":"Name","value":"universalIdentifiers"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"success"}}]}}]}}]} as unknown as DocumentNode; export const GetInviteSuggestionsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetInviteSuggestions"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"getInviteSuggestions"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"email"}},{"kind":"Field","name":{"kind":"Name","value":"displayName"}}]}}]}}]} as unknown as DocumentNode; export const ResetPageLayoutTabToDefaultDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"ResetPageLayoutTabToDefault"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"resetPageLayoutTabToDefault"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"PageLayoutTabFragment"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"PageLayoutWidgetFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"PageLayoutWidget"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"applicationId"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"objectMetadataId"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"isActive"}},{"kind":"Field","name":{"kind":"Name","value":"deletedAt"}},{"kind":"Field","name":{"kind":"Name","value":"conditionalDisplay"}},{"kind":"Field","name":{"kind":"Name","value":"conditionalAvailabilityExpression"}},{"kind":"Field","name":{"kind":"Name","value":"gridPosition"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"column"}},{"kind":"Field","name":{"kind":"Name","value":"columnSpan"}},{"kind":"Field","name":{"kind":"Name","value":"row"}},{"kind":"Field","name":{"kind":"Name","value":"rowSpan"}}]}},{"kind":"Field","name":{"kind":"Name","value":"position"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"PageLayoutWidgetGridPosition"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"layoutMode"}},{"kind":"Field","name":{"kind":"Name","value":"row"}},{"kind":"Field","name":{"kind":"Name","value":"column"}},{"kind":"Field","name":{"kind":"Name","value":"rowSpan"}},{"kind":"Field","name":{"kind":"Name","value":"columnSpan"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"PageLayoutWidgetVerticalListPosition"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"layoutMode"}},{"kind":"Field","name":{"kind":"Name","value":"index"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"PageLayoutWidgetCanvasPosition"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"layoutMode"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"configuration"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"BarChartConfiguration"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"configurationType"}},{"kind":"Field","name":{"kind":"Name","value":"aggregateFieldMetadataId"}},{"kind":"Field","name":{"kind":"Name","value":"aggregateOperation"}},{"kind":"Field","name":{"kind":"Name","value":"primaryAxisGroupByFieldMetadataId"}},{"kind":"Field","name":{"kind":"Name","value":"primaryAxisGroupBySubFieldName"}},{"kind":"Field","name":{"kind":"Name","value":"primaryAxisDateGranularity"}},{"kind":"Field","name":{"kind":"Name","value":"primaryAxisOrderBy"}},{"kind":"Field","name":{"kind":"Name","value":"primaryAxisManualSortOrder"}},{"kind":"Field","name":{"kind":"Name","value":"secondaryAxisGroupByFieldMetadataId"}},{"kind":"Field","name":{"kind":"Name","value":"secondaryAxisGroupBySubFieldName"}},{"kind":"Field","name":{"kind":"Name","value":"secondaryAxisGroupByDateGranularity"}},{"kind":"Field","name":{"kind":"Name","value":"secondaryAxisOrderBy"}},{"kind":"Field","name":{"kind":"Name","value":"secondaryAxisManualSortOrder"}},{"kind":"Field","name":{"kind":"Name","value":"omitNullValues"}},{"kind":"Field","name":{"kind":"Name","value":"axisNameDisplay"}},{"kind":"Field","name":{"kind":"Name","value":"displayDataLabel"}},{"kind":"Field","name":{"kind":"Name","value":"displayLegend"}},{"kind":"Field","name":{"kind":"Name","value":"rangeMin"}},{"kind":"Field","name":{"kind":"Name","value":"rangeMax"}},{"kind":"Field","name":{"kind":"Name","value":"color"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"filter"}},{"kind":"Field","name":{"kind":"Name","value":"groupMode"}},{"kind":"Field","name":{"kind":"Name","value":"layout"}},{"kind":"Field","name":{"kind":"Name","value":"isCumulative"}},{"kind":"Field","name":{"kind":"Name","value":"splitMultiValueFields"}},{"kind":"Field","name":{"kind":"Name","value":"timezone"}},{"kind":"Field","name":{"kind":"Name","value":"firstDayOfTheWeek"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"LineChartConfiguration"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"configurationType"}},{"kind":"Field","name":{"kind":"Name","value":"aggregateFieldMetadataId"}},{"kind":"Field","name":{"kind":"Name","value":"aggregateOperation"}},{"kind":"Field","name":{"kind":"Name","value":"primaryAxisGroupByFieldMetadataId"}},{"kind":"Field","name":{"kind":"Name","value":"primaryAxisGroupBySubFieldName"}},{"kind":"Field","name":{"kind":"Name","value":"primaryAxisDateGranularity"}},{"kind":"Field","name":{"kind":"Name","value":"primaryAxisOrderBy"}},{"kind":"Field","name":{"kind":"Name","value":"primaryAxisManualSortOrder"}},{"kind":"Field","name":{"kind":"Name","value":"secondaryAxisGroupByFieldMetadataId"}},{"kind":"Field","name":{"kind":"Name","value":"secondaryAxisGroupBySubFieldName"}},{"kind":"Field","name":{"kind":"Name","value":"secondaryAxisGroupByDateGranularity"}},{"kind":"Field","name":{"kind":"Name","value":"secondaryAxisOrderBy"}},{"kind":"Field","name":{"kind":"Name","value":"secondaryAxisManualSortOrder"}},{"kind":"Field","name":{"kind":"Name","value":"omitNullValues"}},{"kind":"Field","name":{"kind":"Name","value":"axisNameDisplay"}},{"kind":"Field","name":{"kind":"Name","value":"displayDataLabel"}},{"kind":"Field","name":{"kind":"Name","value":"displayLegend"}},{"kind":"Field","name":{"kind":"Name","value":"rangeMin"}},{"kind":"Field","name":{"kind":"Name","value":"rangeMax"}},{"kind":"Field","name":{"kind":"Name","value":"color"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"filter"}},{"kind":"Field","name":{"kind":"Name","value":"isStacked"}},{"kind":"Field","name":{"kind":"Name","value":"isCumulative"}},{"kind":"Field","name":{"kind":"Name","value":"splitMultiValueFields"}},{"kind":"Field","name":{"kind":"Name","value":"timezone"}},{"kind":"Field","name":{"kind":"Name","value":"firstDayOfTheWeek"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"PieChartConfiguration"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"configurationType"}},{"kind":"Field","name":{"kind":"Name","value":"groupByFieldMetadataId"}},{"kind":"Field","name":{"kind":"Name","value":"aggregateFieldMetadataId"}},{"kind":"Field","name":{"kind":"Name","value":"aggregateOperation"}},{"kind":"Field","name":{"kind":"Name","value":"groupBySubFieldName"}},{"kind":"Field","name":{"kind":"Name","value":"dateGranularity"}},{"kind":"Field","name":{"kind":"Name","value":"orderBy"}},{"kind":"Field","name":{"kind":"Name","value":"manualSortOrder"}},{"kind":"Field","name":{"kind":"Name","value":"displayDataLabel"}},{"kind":"Field","name":{"kind":"Name","value":"showCenterMetric"}},{"kind":"Field","name":{"kind":"Name","value":"displayLegend"}},{"kind":"Field","name":{"kind":"Name","value":"hideEmptyCategory"}},{"kind":"Field","name":{"kind":"Name","value":"splitMultiValueFields"}},{"kind":"Field","name":{"kind":"Name","value":"color"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"filter"}},{"kind":"Field","name":{"kind":"Name","value":"timezone"}},{"kind":"Field","name":{"kind":"Name","value":"firstDayOfTheWeek"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"AggregateChartConfiguration"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"configurationType"}},{"kind":"Field","name":{"kind":"Name","value":"aggregateFieldMetadataId"}},{"kind":"Field","name":{"kind":"Name","value":"aggregateOperation"}},{"kind":"Field","name":{"kind":"Name","value":"label"}},{"kind":"Field","name":{"kind":"Name","value":"displayDataLabel"}},{"kind":"Field","name":{"kind":"Name","value":"numberFormat"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"filter"}},{"kind":"Field","name":{"kind":"Name","value":"prefix"}},{"kind":"Field","name":{"kind":"Name","value":"suffix"}},{"kind":"Field","name":{"kind":"Name","value":"timezone"}},{"kind":"Field","name":{"kind":"Name","value":"firstDayOfTheWeek"}},{"kind":"Field","name":{"kind":"Name","value":"ratioAggregateConfig"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"fieldMetadataId"}},{"kind":"Field","name":{"kind":"Name","value":"optionValue"}}]}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"IframeConfiguration"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"configurationType"}},{"kind":"Field","name":{"kind":"Name","value":"url"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"StandaloneRichTextConfiguration"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"configurationType"}},{"kind":"Field","name":{"kind":"Name","value":"body"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"blocknote"}},{"kind":"Field","name":{"kind":"Name","value":"markdown"}}]}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"CalendarConfiguration"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"configurationType"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"EmailsConfiguration"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"configurationType"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"EmailThreadConfiguration"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"configurationType"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"FieldConfiguration"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"configurationType"}},{"kind":"Field","name":{"kind":"Name","value":"fieldDisplayMode"}},{"kind":"Field","name":{"kind":"Name","value":"fieldMetadataId"}},{"kind":"Field","name":{"kind":"Name","value":"viewId"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"FieldRichTextConfiguration"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"configurationType"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"FieldsConfiguration"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"configurationType"}},{"kind":"Field","name":{"kind":"Name","value":"viewId"}},{"kind":"Field","name":{"kind":"Name","value":"newFieldDefaultVisibility"}},{"kind":"Field","name":{"kind":"Name","value":"shouldAllowUserToSeeHiddenFields"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"FilesConfiguration"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"configurationType"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"NotesConfiguration"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"configurationType"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"TasksConfiguration"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"configurationType"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"TimelineConfiguration"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"configurationType"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ViewConfiguration"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"configurationType"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"RecordTableConfiguration"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"configurationType"}},{"kind":"Field","name":{"kind":"Name","value":"viewId"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"WorkflowConfiguration"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"configurationType"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"WorkflowRunConfiguration"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"configurationType"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"WorkflowVersionConfiguration"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"configurationType"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"FrontComponentConfiguration"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"configurationType"}},{"kind":"Field","name":{"kind":"Name","value":"frontComponentId"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"pageLayoutTabId"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"PageLayoutTabFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"PageLayoutTab"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"applicationId"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"icon"}},{"kind":"Field","name":{"kind":"Name","value":"position"}},{"kind":"Field","name":{"kind":"Name","value":"layoutMode"}},{"kind":"Field","name":{"kind":"Name","value":"widgets"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"PageLayoutWidgetFragment"}}]}},{"kind":"Field","name":{"kind":"Name","value":"pageLayoutId"}},{"kind":"Field","name":{"kind":"Name","value":"isActive"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]} as unknown as DocumentNode; export const ResetPageLayoutToDefaultDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"ResetPageLayoutToDefault"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"resetPageLayoutToDefault"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"PageLayoutFragment"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"PageLayoutWidgetFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"PageLayoutWidget"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"applicationId"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"objectMetadataId"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"isActive"}},{"kind":"Field","name":{"kind":"Name","value":"deletedAt"}},{"kind":"Field","name":{"kind":"Name","value":"conditionalDisplay"}},{"kind":"Field","name":{"kind":"Name","value":"conditionalAvailabilityExpression"}},{"kind":"Field","name":{"kind":"Name","value":"gridPosition"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"column"}},{"kind":"Field","name":{"kind":"Name","value":"columnSpan"}},{"kind":"Field","name":{"kind":"Name","value":"row"}},{"kind":"Field","name":{"kind":"Name","value":"rowSpan"}}]}},{"kind":"Field","name":{"kind":"Name","value":"position"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"PageLayoutWidgetGridPosition"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"layoutMode"}},{"kind":"Field","name":{"kind":"Name","value":"row"}},{"kind":"Field","name":{"kind":"Name","value":"column"}},{"kind":"Field","name":{"kind":"Name","value":"rowSpan"}},{"kind":"Field","name":{"kind":"Name","value":"columnSpan"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"PageLayoutWidgetVerticalListPosition"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"layoutMode"}},{"kind":"Field","name":{"kind":"Name","value":"index"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"PageLayoutWidgetCanvasPosition"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"layoutMode"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"configuration"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"BarChartConfiguration"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"configurationType"}},{"kind":"Field","name":{"kind":"Name","value":"aggregateFieldMetadataId"}},{"kind":"Field","name":{"kind":"Name","value":"aggregateOperation"}},{"kind":"Field","name":{"kind":"Name","value":"primaryAxisGroupByFieldMetadataId"}},{"kind":"Field","name":{"kind":"Name","value":"primaryAxisGroupBySubFieldName"}},{"kind":"Field","name":{"kind":"Name","value":"primaryAxisDateGranularity"}},{"kind":"Field","name":{"kind":"Name","value":"primaryAxisOrderBy"}},{"kind":"Field","name":{"kind":"Name","value":"primaryAxisManualSortOrder"}},{"kind":"Field","name":{"kind":"Name","value":"secondaryAxisGroupByFieldMetadataId"}},{"kind":"Field","name":{"kind":"Name","value":"secondaryAxisGroupBySubFieldName"}},{"kind":"Field","name":{"kind":"Name","value":"secondaryAxisGroupByDateGranularity"}},{"kind":"Field","name":{"kind":"Name","value":"secondaryAxisOrderBy"}},{"kind":"Field","name":{"kind":"Name","value":"secondaryAxisManualSortOrder"}},{"kind":"Field","name":{"kind":"Name","value":"omitNullValues"}},{"kind":"Field","name":{"kind":"Name","value":"axisNameDisplay"}},{"kind":"Field","name":{"kind":"Name","value":"displayDataLabel"}},{"kind":"Field","name":{"kind":"Name","value":"displayLegend"}},{"kind":"Field","name":{"kind":"Name","value":"rangeMin"}},{"kind":"Field","name":{"kind":"Name","value":"rangeMax"}},{"kind":"Field","name":{"kind":"Name","value":"color"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"filter"}},{"kind":"Field","name":{"kind":"Name","value":"groupMode"}},{"kind":"Field","name":{"kind":"Name","value":"layout"}},{"kind":"Field","name":{"kind":"Name","value":"isCumulative"}},{"kind":"Field","name":{"kind":"Name","value":"splitMultiValueFields"}},{"kind":"Field","name":{"kind":"Name","value":"timezone"}},{"kind":"Field","name":{"kind":"Name","value":"firstDayOfTheWeek"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"LineChartConfiguration"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"configurationType"}},{"kind":"Field","name":{"kind":"Name","value":"aggregateFieldMetadataId"}},{"kind":"Field","name":{"kind":"Name","value":"aggregateOperation"}},{"kind":"Field","name":{"kind":"Name","value":"primaryAxisGroupByFieldMetadataId"}},{"kind":"Field","name":{"kind":"Name","value":"primaryAxisGroupBySubFieldName"}},{"kind":"Field","name":{"kind":"Name","value":"primaryAxisDateGranularity"}},{"kind":"Field","name":{"kind":"Name","value":"primaryAxisOrderBy"}},{"kind":"Field","name":{"kind":"Name","value":"primaryAxisManualSortOrder"}},{"kind":"Field","name":{"kind":"Name","value":"secondaryAxisGroupByFieldMetadataId"}},{"kind":"Field","name":{"kind":"Name","value":"secondaryAxisGroupBySubFieldName"}},{"kind":"Field","name":{"kind":"Name","value":"secondaryAxisGroupByDateGranularity"}},{"kind":"Field","name":{"kind":"Name","value":"secondaryAxisOrderBy"}},{"kind":"Field","name":{"kind":"Name","value":"secondaryAxisManualSortOrder"}},{"kind":"Field","name":{"kind":"Name","value":"omitNullValues"}},{"kind":"Field","name":{"kind":"Name","value":"axisNameDisplay"}},{"kind":"Field","name":{"kind":"Name","value":"displayDataLabel"}},{"kind":"Field","name":{"kind":"Name","value":"displayLegend"}},{"kind":"Field","name":{"kind":"Name","value":"rangeMin"}},{"kind":"Field","name":{"kind":"Name","value":"rangeMax"}},{"kind":"Field","name":{"kind":"Name","value":"color"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"filter"}},{"kind":"Field","name":{"kind":"Name","value":"isStacked"}},{"kind":"Field","name":{"kind":"Name","value":"isCumulative"}},{"kind":"Field","name":{"kind":"Name","value":"splitMultiValueFields"}},{"kind":"Field","name":{"kind":"Name","value":"timezone"}},{"kind":"Field","name":{"kind":"Name","value":"firstDayOfTheWeek"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"PieChartConfiguration"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"configurationType"}},{"kind":"Field","name":{"kind":"Name","value":"groupByFieldMetadataId"}},{"kind":"Field","name":{"kind":"Name","value":"aggregateFieldMetadataId"}},{"kind":"Field","name":{"kind":"Name","value":"aggregateOperation"}},{"kind":"Field","name":{"kind":"Name","value":"groupBySubFieldName"}},{"kind":"Field","name":{"kind":"Name","value":"dateGranularity"}},{"kind":"Field","name":{"kind":"Name","value":"orderBy"}},{"kind":"Field","name":{"kind":"Name","value":"manualSortOrder"}},{"kind":"Field","name":{"kind":"Name","value":"displayDataLabel"}},{"kind":"Field","name":{"kind":"Name","value":"showCenterMetric"}},{"kind":"Field","name":{"kind":"Name","value":"displayLegend"}},{"kind":"Field","name":{"kind":"Name","value":"hideEmptyCategory"}},{"kind":"Field","name":{"kind":"Name","value":"splitMultiValueFields"}},{"kind":"Field","name":{"kind":"Name","value":"color"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"filter"}},{"kind":"Field","name":{"kind":"Name","value":"timezone"}},{"kind":"Field","name":{"kind":"Name","value":"firstDayOfTheWeek"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"AggregateChartConfiguration"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"configurationType"}},{"kind":"Field","name":{"kind":"Name","value":"aggregateFieldMetadataId"}},{"kind":"Field","name":{"kind":"Name","value":"aggregateOperation"}},{"kind":"Field","name":{"kind":"Name","value":"label"}},{"kind":"Field","name":{"kind":"Name","value":"displayDataLabel"}},{"kind":"Field","name":{"kind":"Name","value":"numberFormat"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"filter"}},{"kind":"Field","name":{"kind":"Name","value":"prefix"}},{"kind":"Field","name":{"kind":"Name","value":"suffix"}},{"kind":"Field","name":{"kind":"Name","value":"timezone"}},{"kind":"Field","name":{"kind":"Name","value":"firstDayOfTheWeek"}},{"kind":"Field","name":{"kind":"Name","value":"ratioAggregateConfig"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"fieldMetadataId"}},{"kind":"Field","name":{"kind":"Name","value":"optionValue"}}]}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"IframeConfiguration"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"configurationType"}},{"kind":"Field","name":{"kind":"Name","value":"url"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"StandaloneRichTextConfiguration"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"configurationType"}},{"kind":"Field","name":{"kind":"Name","value":"body"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"blocknote"}},{"kind":"Field","name":{"kind":"Name","value":"markdown"}}]}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"CalendarConfiguration"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"configurationType"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"EmailsConfiguration"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"configurationType"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"EmailThreadConfiguration"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"configurationType"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"FieldConfiguration"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"configurationType"}},{"kind":"Field","name":{"kind":"Name","value":"fieldDisplayMode"}},{"kind":"Field","name":{"kind":"Name","value":"fieldMetadataId"}},{"kind":"Field","name":{"kind":"Name","value":"viewId"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"FieldRichTextConfiguration"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"configurationType"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"FieldsConfiguration"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"configurationType"}},{"kind":"Field","name":{"kind":"Name","value":"viewId"}},{"kind":"Field","name":{"kind":"Name","value":"newFieldDefaultVisibility"}},{"kind":"Field","name":{"kind":"Name","value":"shouldAllowUserToSeeHiddenFields"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"FilesConfiguration"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"configurationType"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"NotesConfiguration"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"configurationType"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"TasksConfiguration"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"configurationType"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"TimelineConfiguration"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"configurationType"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ViewConfiguration"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"configurationType"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"RecordTableConfiguration"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"configurationType"}},{"kind":"Field","name":{"kind":"Name","value":"viewId"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"WorkflowConfiguration"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"configurationType"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"WorkflowRunConfiguration"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"configurationType"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"WorkflowVersionConfiguration"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"configurationType"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"FrontComponentConfiguration"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"configurationType"}},{"kind":"Field","name":{"kind":"Name","value":"frontComponentId"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"pageLayoutTabId"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"PageLayoutTabFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"PageLayoutTab"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"applicationId"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"icon"}},{"kind":"Field","name":{"kind":"Name","value":"position"}},{"kind":"Field","name":{"kind":"Name","value":"layoutMode"}},{"kind":"Field","name":{"kind":"Name","value":"widgets"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"PageLayoutWidgetFragment"}}]}},{"kind":"Field","name":{"kind":"Name","value":"pageLayoutId"}},{"kind":"Field","name":{"kind":"Name","value":"isActive"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"PageLayoutFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"PageLayout"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"objectMetadataId"}},{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"universalIdentifier"}},{"kind":"Field","name":{"kind":"Name","value":"defaultTabToFocusOnMobileAndSidePanelId"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"tabs"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"PageLayoutTabFragment"}}]}}]}}]} as unknown as DocumentNode; diff --git a/packages/twenty-front/src/hooks/__tests__/usePageChangeEffectNavigateLocation.test.ts b/packages/twenty-front/src/hooks/__tests__/usePageChangeEffectNavigateLocation.test.ts index 0c57d8b6f1..23f2802e55 100644 --- a/packages/twenty-front/src/hooks/__tests__/usePageChangeEffectNavigateLocation.test.ts +++ b/packages/twenty-front/src/hooks/__tests__/usePageChangeEffectNavigateLocation.test.ts @@ -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, diff --git a/packages/twenty-front/src/hooks/usePageChangeEffectNavigateLocation.ts b/packages/twenty-front/src/hooks/usePageChangeEffectNavigateLocation.ts index b49493717d..8d848684aa 100644 --- a/packages/twenty-front/src/hooks/usePageChangeEffectNavigateLocation.ts +++ b/packages/twenty-front/src/hooks/usePageChangeEffectNavigateLocation.ts @@ -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]) diff --git a/packages/twenty-front/src/modules/app/hooks/useCreateAppRouter.tsx b/packages/twenty-front/src/modules/app/hooks/useCreateAppRouter.tsx index 1ed6014281..b18e0c58bd 100644 --- a/packages/twenty-front/src/modules/app/hooks/useCreateAppRouter.tsx +++ b/packages/twenty-front/src/modules/app/hooks/useCreateAppRouter.tsx @@ -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 = ( } /> + + + + } + /> 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; +}; diff --git a/packages/twenty-front/src/modules/onboarding/graphql/mutations/triggerInstallAppsOnboardingStep.ts b/packages/twenty-front/src/modules/onboarding/graphql/mutations/triggerInstallAppsOnboardingStep.ts new file mode 100644 index 0000000000..ab7b289423 --- /dev/null +++ b/packages/twenty-front/src/modules/onboarding/graphql/mutations/triggerInstallAppsOnboardingStep.ts @@ -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 + } + } +`; diff --git a/packages/twenty-front/src/modules/onboarding/hooks/__tests__/useInstallOnboardingApps.test.ts b/packages/twenty-front/src/modules/onboarding/hooks/__tests__/useInstallOnboardingApps.test.ts new file mode 100644 index 0000000000..668db402f6 --- /dev/null +++ b/packages/twenty-front/src/modules/onboarding/hooks/__tests__/useInstallOnboardingApps.test.ts @@ -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((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(); + }); + }); +}); diff --git a/packages/twenty-front/src/modules/onboarding/hooks/__tests__/useSetNextOnboardingStatus.test.ts b/packages/twenty-front/src/modules/onboarding/hooks/__tests__/useSetNextOnboardingStatus.test.ts index 604c0575ba..a8c0d50b9c 100644 --- a/packages/twenty-front/src/modules/onboarding/hooks/__tests__/useSetNextOnboardingStatus.test.ts +++ b/packages/twenty-front/src/modules/onboarding/hooks/__tests__/useSetNextOnboardingStatus.test.ts @@ -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); }); diff --git a/packages/twenty-front/src/modules/onboarding/hooks/useInstallOnboardingApps.ts b/packages/twenty-front/src/modules/onboarding/hooks/useInstallOnboardingApps.ts new file mode 100644 index 0000000000..80352d85bb --- /dev/null +++ b/packages/twenty-front/src/modules/onboarding/hooks/useInstallOnboardingApps.ts @@ -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([]); + 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([]), + }; +}; diff --git a/packages/twenty-front/src/modules/onboarding/hooks/useInviteTeam.ts b/packages/twenty-front/src/modules/onboarding/hooks/useInviteTeam.ts index f815bcf050..eb733449b9 100644 --- a/packages/twenty-front/src/modules/onboarding/hooks/useInviteTeam.ts +++ b/packages/twenty-front/src/modules/onboarding/hooks/useInviteTeam.ts @@ -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, diff --git a/packages/twenty-front/src/modules/onboarding/hooks/useOnboardingFreeCreditsTotal.ts b/packages/twenty-front/src/modules/onboarding/hooks/useOnboardingFreeCreditsTotal.ts index 51fb750147..5bb7f257e1 100644 --- a/packages/twenty-front/src/modules/onboarding/hooks/useOnboardingFreeCreditsTotal.ts +++ b/packages/twenty-front/src/modules/onboarding/hooks/useOnboardingFreeCreditsTotal.ts @@ -5,6 +5,8 @@ export const useOnboardingFreeCreditsTotal = () => { const onboardingFreeCredits = useAtomStateValue(onboardingFreeCreditsState); return ( - onboardingFreeCredits.importContacts + onboardingFreeCredits.inviteTeam + onboardingFreeCredits.importContacts + + onboardingFreeCredits.inviteTeam + + onboardingFreeCredits.installApps ); }; diff --git a/packages/twenty-front/src/modules/onboarding/hooks/useSetNextOnboardingStatus.ts b/packages/twenty-front/src/modules/onboarding/hooks/useSetNextOnboardingStatus.ts index 5da62ea45f..d9fb8cb818 100644 --- a/packages/twenty-front/src/modules/onboarding/hooks/useSetNextOnboardingStatus.ts +++ b/packages/twenty-front/src/modules/onboarding/hooks/useSetNextOnboardingStatus.ts @@ -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; } diff --git a/packages/twenty-front/src/modules/onboarding/hooks/useTriggerInstallAppsOnboardingStep.ts b/packages/twenty-front/src/modules/onboarding/hooks/useTriggerInstallAppsOnboardingStep.ts new file mode 100644 index 0000000000..26b6beb7ba --- /dev/null +++ b/packages/twenty-front/src/modules/onboarding/hooks/useTriggerInstallAppsOnboardingStep.ts @@ -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], + ); +}; diff --git a/packages/twenty-front/src/modules/onboarding/states/onboardingFreeCreditsState.ts b/packages/twenty-front/src/modules/onboarding/states/onboardingFreeCreditsState.ts index 6292d2afef..9b5eb8a4e7 100644 --- a/packages/twenty-front/src/modules/onboarding/states/onboardingFreeCreditsState.ts +++ b/packages/twenty-front/src/modules/onboarding/states/onboardingFreeCreditsState.ts @@ -4,10 +4,11 @@ import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomStat export const onboardingFreeCreditsState = createAtomState({ 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), }); diff --git a/packages/twenty-front/src/modules/onboarding/types/OnboardingFreeCredits.ts b/packages/twenty-front/src/modules/onboarding/types/OnboardingFreeCredits.ts index 05fa6a4648..ebee5c331e 100644 --- a/packages/twenty-front/src/modules/onboarding/types/OnboardingFreeCredits.ts +++ b/packages/twenty-front/src/modules/onboarding/types/OnboardingFreeCredits.ts @@ -1,4 +1,5 @@ export type OnboardingFreeCredits = { importContacts: number; inviteTeam: number; + installApps: number; }; diff --git a/packages/twenty-front/src/modules/onboarding/types/OnboardingInstallableApp.ts b/packages/twenty-front/src/modules/onboarding/types/OnboardingInstallableApp.ts new file mode 100644 index 0000000000..4b12f83087 --- /dev/null +++ b/packages/twenty-front/src/modules/onboarding/types/OnboardingInstallableApp.ts @@ -0,0 +1,7 @@ +import { type MessageDescriptor } from '@lingui/core'; + +export type OnboardingInstallableApp = { + universalIdentifier: string; + label: MessageDescriptor; + description: MessageDescriptor; +}; diff --git a/packages/twenty-front/src/pages/onboarding/InstallApps.tsx b/packages/twenty-front/src/pages/onboarding/InstallApps.tsx new file mode 100644 index 0000000000..536ad231f0 --- /dev/null +++ b/packages/twenty-front/src/pages/onboarding/InstallApps.tsx @@ -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 ( + + + + {t`Install your first apps`} + {t`Beta`} + + + {t`Get the most out of your CRM by installing some apps`} + + {isDefined(creditsRewardPerApp) && ( + + + + )} + + + + {apps.map((app) => { + const labelText = t(app.label); + const isSelected = selectedUniversalIdentifiers.includes( + app.universalIdentifier, + ); + + return ( + + + + {labelText} + + {t(app.description)} + + + onToggleApp(app.universalIdentifier)} + /> + + ); + })} + + + + + + + + {t`Skip`} + + + + ); +}; diff --git a/packages/twenty-front/src/pages/onboarding/InstallAppsV2.tsx b/packages/twenty-front/src/pages/onboarding/InstallAppsV2.tsx new file mode 100644 index 0000000000..346dc3f947 --- /dev/null +++ b/packages/twenty-front/src/pages/onboarding/InstallAppsV2.tsx @@ -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 ; + } + + const apps = ONBOARDING_INSTALLABLE_APPS.map((app) => ({ + ...app, + logo: + marketplaceApps.find( + (marketplaceApp) => marketplaceApp.id === app.universalIdentifier, + )?.logo ?? null, + })); + + return ( + + + + ); +}; diff --git a/packages/twenty-front/src/pages/onboarding/WorkspaceActivationV2.tsx b/packages/twenty-front/src/pages/onboarding/WorkspaceActivationV2.tsx index b2dc4b6b43..789cf51fc3 100644 --- a/packages/twenty-front/src/pages/onboarding/WorkspaceActivationV2.tsx +++ b/packages/twenty-front/src/pages/onboarding/WorkspaceActivationV2.tsx @@ -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]); diff --git a/packages/twenty-front/src/pages/onboarding/__stories__/InstallAppsV2.stories.tsx b/packages/twenty-front/src/pages/onboarding/__stories__/InstallAppsV2.stories.tsx new file mode 100644 index 0000000000..2cd3425553 --- /dev/null +++ b/packages/twenty-front/src/pages/onboarding/__stories__/InstallAppsV2.stories.tsx @@ -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 = { + title: 'Pages/Onboarding/InstallAppsV2', + component: InstallAppsV2, + decorators: [ + (Story) => { + jotaiStore.set(isOnboardingV2State.atom, true); + + return ; + }, + 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; + +export const Default: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement.ownerDocument.body); + await canvas.findByText('Install your first apps'); + }, +}; diff --git a/packages/twenty-front/src/testing/constants/UntestedAppPaths.ts b/packages/twenty-front/src/testing/constants/UntestedAppPaths.ts index 506ac47763..8bfe768b14 100644 --- a/packages/twenty-front/src/testing/constants/UntestedAppPaths.ts +++ b/packages/twenty-front/src/testing/constants/UntestedAppPaths.ts @@ -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 diff --git a/packages/twenty-front/src/testing/mock-data/config.ts b/packages/twenty-front/src/testing/mock-data/config.ts index f535dec1e8..3bbe5494f4 100644 --- a/packages/twenty-front/src/testing/mock-data/config.ts +++ b/packages/twenty-front/src/testing/mock-data/config.ts @@ -49,6 +49,7 @@ export const mockedClientConfig: ClientConfig = { inviteTeamMaxCreditsReward: 9, inviteTeamCreditsRewardPerUser: 3, upgradeCreditsReward: 5, + installAppsCreditsRewardPerApp: 1, }, canManageFeatureFlags: true, publicFeatureFlags: [], diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.ts b/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.ts index e804b06d9e..ec4932d725 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.ts @@ -383,6 +383,15 @@ export class SignInUpService { }, queryRunner, ); + + await this.onboardingService.setOnboardingInstallAppsPending( + { + userId: user.id, + workspaceId: workspace.id, + value: true, + }, + queryRunner, + ); } private async saveNewUser( diff --git a/packages/twenty-server/src/engine/core-modules/client-config/client-config.controller.spec.ts b/packages/twenty-server/src/engine/core-modules/client-config/client-config.controller.spec.ts index 16b761f5ce..55dcaf3236 100644 --- a/packages/twenty-server/src/engine/core-modules/client-config/client-config.controller.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/client-config/client-config.controller.spec.ts @@ -89,6 +89,7 @@ describe('ClientConfigController', () => { importContactsCreditsReward: 2, inviteTeamCreditsRewardPerUser: 3, upgradeCreditsReward: 5, + installAppsCreditsRewardPerApp: 1, }, isAttachmentPreviewEnabled: true, analyticsEnabled: false, diff --git a/packages/twenty-server/src/engine/core-modules/client-config/client-config.entity.ts b/packages/twenty-server/src/engine/core-modules/client-config/client-config.entity.ts index 35588a763a..f15c30668e 100644 --- a/packages/twenty-server/src/engine/core-modules/client-config/client-config.entity.ts +++ b/packages/twenty-server/src/engine/core-modules/client-config/client-config.entity.ts @@ -214,6 +214,8 @@ export class OnboardingConfig { inviteTeamCreditsRewardPerUser: number; upgradeCreditsReward: number; + + installAppsCreditsRewardPerApp: number; } @ObjectType() diff --git a/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.spec.ts b/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.spec.ts index b38463f728..d47c7cf63b 100644 --- a/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.spec.ts @@ -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, diff --git a/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.ts b/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.ts index e020db84b0..f6ccfd0cc4 100644 --- a/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.ts +++ b/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.ts @@ -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', diff --git a/packages/twenty-server/src/engine/core-modules/message-queue/jobs.module.ts b/packages/twenty-server/src/engine/core-modules/message-queue/jobs.module.ts index 36667c9686..e9794ce912 100644 --- a/packages/twenty-server/src/engine/core-modules/message-queue/jobs.module.ts +++ b/packages/twenty-server/src/engine/core-modules/message-queue/jobs.module.ts @@ -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 { diff --git a/packages/twenty-server/src/engine/core-modules/onboarding/constants/onboarding-installable-app-universal-identifiers.ts b/packages/twenty-server/src/engine/core-modules/onboarding/constants/onboarding-installable-app-universal-identifiers.ts new file mode 100644 index 0000000000..73ce42c6ae --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/onboarding/constants/onboarding-installable-app-universal-identifiers.ts @@ -0,0 +1,4 @@ +export const ONBOARDING_INSTALLABLE_APP_UNIVERSAL_IDENTIFIERS = [ + '8da4b8b5-5edf-4880-b51f-ab6e679ec617', + '4a1178c1-3535-4a47-b592-231d3216b36f', +]; diff --git a/packages/twenty-server/src/engine/core-modules/onboarding/enums/onboarding-status.enum.ts b/packages/twenty-server/src/engine/core-modules/onboarding/enums/onboarding-status.enum.ts index dd4370a52e..e407186103 100644 --- a/packages/twenty-server/src/engine/core-modules/onboarding/enums/onboarding-status.enum.ts +++ b/packages/twenty-server/src/engine/core-modules/onboarding/enums/onboarding-status.enum.ts @@ -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', diff --git a/packages/twenty-server/src/engine/core-modules/onboarding/jobs/install-onboarding-apps.job-constants.ts b/packages/twenty-server/src/engine/core-modules/onboarding/jobs/install-onboarding-apps.job-constants.ts new file mode 100644 index 0000000000..56ef4fe816 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/onboarding/jobs/install-onboarding-apps.job-constants.ts @@ -0,0 +1,6 @@ +export const INSTALL_ONBOARDING_APPS_JOB_NAME = 'InstallOnboardingAppsJob'; + +export type InstallOnboardingAppsJobData = { + workspaceId: string; + universalIdentifiers: string[]; +}; diff --git a/packages/twenty-server/src/engine/core-modules/onboarding/jobs/install-onboarding-apps.job.spec.ts b/packages/twenty-server/src/engine/core-modules/onboarding/jobs/install-onboarding-apps.job.spec.ts new file mode 100644 index 0000000000..6e54ebc343 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/onboarding/jobs/install-onboarding-apps.job.spec.ts @@ -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); + applicationRegistrationService = module.get( + ApplicationRegistrationService, + ); + applicationInstallService = module.get( + ApplicationInstallService, + ); + onboardingService = module.get(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, + ); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/onboarding/jobs/install-onboarding-apps.job.ts b/packages/twenty-server/src/engine/core-modules/onboarding/jobs/install-onboarding-apps.job.ts new file mode 100644 index 0000000000..d9df18ae45 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/onboarding/jobs/install-onboarding-apps.job.ts @@ -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 { + 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 { + 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, + ); + } + } +} diff --git a/packages/twenty-server/src/engine/core-modules/onboarding/onboarding.resolver.ts b/packages/twenty-server/src/engine/core-modules/onboarding/onboarding.resolver.ts index 5c9a9b08dd..895ee19b00 100644 --- a/packages/twenty-server/src/engine/core-modules/onboarding/onboarding.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/onboarding/onboarding.resolver.ts @@ -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 { + await this.onboardingService.triggerInstallAppsOnboardingStep({ + userId: user.id, + workspaceId: workspace.id, + universalIdentifiers, + }); + + return { success: true }; + } } diff --git a/packages/twenty-server/src/engine/core-modules/onboarding/onboarding.service.spec.ts b/packages/twenty-server/src/engine/core-modules/onboarding/onboarding.service.spec.ts index 9f61719a7c..7a1178d52a 100644 --- a/packages/twenty-server/src/engine/core-modules/onboarding/onboarding.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/onboarding/onboarding.service.spec.ts @@ -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); twentyConfigService = module.get(TwentyConfigService); + messageQueueService = module.get( + 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(); + }); + }); }); diff --git a/packages/twenty-server/src/engine/core-modules/onboarding/onboarding.service.ts b/packages/twenty-server/src/engine/core-modules/onboarding/onboarding.service.ts index 9fa16d1bb5..ac4d90c01d 100644 --- a/packages/twenty-server/src/engine/core-modules/onboarding/onboarding.service.ts +++ b/packages/twenty-server/src/engine/core-modules/onboarding/onboarding.service.ts @@ -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, + @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( + 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 { + 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, diff --git a/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts b/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts index 27a38b9eb1..7e95bbfaa5 100644 --- a/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts +++ b/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts @@ -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', diff --git a/packages/twenty-server/test/integration/graphql/suites/settings-permissions/update-workspace-member-settings-onboarding.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/settings-permissions/update-workspace-member-settings-onboarding.integration-spec.ts index fa30663ec0..b4954e1905 100644 --- a/packages/twenty-server/test/integration/graphql/suites/settings-permissions/update-workspace-member-settings-onboarding.integration-spec.ts +++ b/packages/twenty-server/test/integration/graphql/suites/settings-permissions/update-workspace-member-settings-onboarding.integration-spec.ts @@ -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 { diff --git a/packages/twenty-shared/src/types/AppPath.ts b/packages/twenty-shared/src/types/AppPath.ts index 28d06f54e5..41faebc143 100644 --- a/packages/twenty-shared/src/types/AppPath.ts +++ b/packages/twenty-shared/src/types/AppPath.ts @@ -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',