Add install your first apps onboarding V2 step (#22347)
https://github.com/user-attachments/assets/5326d48f-1842-4db1-bc7c-94852145c035 <img width="838" height="754" alt="CleanShot 2026-06-30 at 16 25 05@2x" src="https://github.com/user-attachments/assets/5c7d53d7-4d65-4e35-aed1-edf0c104e140" /> Adds an "Install your first apps" step to the V2 onboarding, shown right after import-contacts. It lets users opt into installing marketplace apps (Call recorder and People Data Labs for now) during onboarding. - New backend `OnboardingStatus.APPS_INSTALLATION` (between SYNC_EMAIL and PROFILE_CREATION); V1 auto-skips it. - The primary button sends the selected app ids to the server via `triggerInstallAppsOnboardingStep`, which enqueues a dedicated job that installs them asynchronously so onboarding isn't blocked. Skip continues without installing. - The workspace is credited per app on successful installation. Credits are env-driven via `ONBOARDING_INSTALL_APPS_CREDITS_REWARD_PER_APP`, shown as "Earn +N free credits (1 per tool)". <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22347?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. -->
This commit is contained in:
@@ -93,6 +93,12 @@ const SyncEmailsV2 = lazy(() =>
|
||||
})),
|
||||
);
|
||||
|
||||
const InstallAppsV2 = lazy(() =>
|
||||
import('~/pages/onboarding/InstallAppsV2').then((module) => ({
|
||||
default: module.InstallAppsV2,
|
||||
})),
|
||||
);
|
||||
|
||||
const InviteTeam = lazy(() =>
|
||||
import('~/pages/onboarding/InviteTeam').then((module) => ({
|
||||
default: module.InviteTeam,
|
||||
@@ -338,6 +344,14 @@ export const useCreateAppRouter = (
|
||||
</LazyRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path={AppPath.InstallAppsV2}
|
||||
element={
|
||||
<LazyRoute fallback={null}>
|
||||
<InstallAppsV2 />
|
||||
</LazyRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path={AppPath.InviteTeamV2}
|
||||
element={
|
||||
|
||||
@@ -7,6 +7,7 @@ export const ONBOARDING_PATHS = [
|
||||
AppPath.CreateProfileV2,
|
||||
AppPath.SyncEmails,
|
||||
AppPath.SyncEmailsV2,
|
||||
AppPath.InstallAppsV2,
|
||||
AppPath.InviteTeam,
|
||||
AppPath.InviteTeamV2,
|
||||
AppPath.PlanRequired,
|
||||
|
||||
@@ -3,4 +3,5 @@ export type OnboardingConfig = {
|
||||
inviteTeamMaxCreditsReward: number;
|
||||
inviteTeamCreditsRewardPerUser: number;
|
||||
upgradeCreditsReward: number;
|
||||
installAppsCreditsRewardPerApp: number;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { type OnboardingInstallableApp } from '@/onboarding/types/OnboardingInstallableApp';
|
||||
|
||||
export const ONBOARDING_INSTALLABLE_APPS: OnboardingInstallableApp[] = [
|
||||
{
|
||||
universalIdentifier: '8da4b8b5-5edf-4880-b51f-ab6e679ec617',
|
||||
label: msg`Call recorder`,
|
||||
description: msg`Record your calls and get transcripts`,
|
||||
},
|
||||
{
|
||||
universalIdentifier: '4a1178c1-3535-4a47-b592-231d3216b36f',
|
||||
label: msg`Enrichment`,
|
||||
description: msg`Enrich your leads with People Data Labs`,
|
||||
},
|
||||
];
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
import { useTriggerInstallAppsOnboardingStep } from '@/onboarding/hooks/useTriggerInstallAppsOnboardingStep';
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
type InstallAppsAutoSkipEffectProps = {
|
||||
onError: () => void;
|
||||
};
|
||||
|
||||
export const InstallAppsAutoSkipEffect = ({
|
||||
onError,
|
||||
}: InstallAppsAutoSkipEffectProps) => {
|
||||
const triggerInstallAppsOnboardingStep =
|
||||
useTriggerInstallAppsOnboardingStep();
|
||||
|
||||
// oxlint-disable-next-line twenty/no-state-useref
|
||||
const hasSkippedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasSkippedRef.current) {
|
||||
return;
|
||||
}
|
||||
hasSkippedRef.current = true;
|
||||
|
||||
const skip = async () => {
|
||||
try {
|
||||
await triggerInstallAppsOnboardingStep([]);
|
||||
} catch {
|
||||
onError();
|
||||
}
|
||||
};
|
||||
|
||||
void skip();
|
||||
}, [triggerInstallAppsOnboardingStep, onError]);
|
||||
|
||||
return null;
|
||||
};
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const TRIGGER_INSTALL_APPS_ONBOARDING_STEP = gql`
|
||||
mutation TriggerInstallAppsOnboardingStep($universalIdentifiers: [String!]!) {
|
||||
triggerInstallAppsOnboardingStep(
|
||||
universalIdentifiers: $universalIdentifiers
|
||||
) {
|
||||
success
|
||||
}
|
||||
}
|
||||
`;
|
||||
+162
@@ -0,0 +1,162 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { Provider as JotaiProvider } from 'jotai';
|
||||
import { createElement } from 'react';
|
||||
|
||||
import { onboardingConfigState } from '@/client-config/states/onboardingConfigState';
|
||||
import { type OnboardingConfig } from '@/client-config/types/OnboardingConfig';
|
||||
import { useInstallOnboardingApps } from '@/onboarding/hooks/useInstallOnboardingApps';
|
||||
import { onboardingFreeCreditsState } from '@/onboarding/states/onboardingFreeCreditsState';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
|
||||
import {
|
||||
jotaiStore,
|
||||
resetJotaiStore,
|
||||
} from '@/ui/utilities/state/jotai/jotaiStore';
|
||||
|
||||
const mockTriggerInstallAppsOnboardingStep = jest.fn();
|
||||
|
||||
jest.mock('@/onboarding/hooks/useTriggerInstallAppsOnboardingStep', () => ({
|
||||
useTriggerInstallAppsOnboardingStep: () =>
|
||||
mockTriggerInstallAppsOnboardingStep,
|
||||
}));
|
||||
|
||||
const onboardingConfig: OnboardingConfig = {
|
||||
importContactsCreditsReward: 2,
|
||||
inviteTeamMaxCreditsReward: 9,
|
||||
inviteTeamCreditsRewardPerUser: 3,
|
||||
upgradeCreditsReward: 5,
|
||||
installAppsCreditsRewardPerApp: 1,
|
||||
};
|
||||
|
||||
const Wrapper = ({ children }: { children: React.ReactNode }) =>
|
||||
createElement(JotaiProvider, { store: jotaiStore }, children);
|
||||
|
||||
const renderInstallHook = () => {
|
||||
const { result } = renderHook(
|
||||
() => {
|
||||
const setOnboardingConfig = useSetAtomState(onboardingConfigState);
|
||||
const onboardingFreeCredits = useAtomStateValue(
|
||||
onboardingFreeCreditsState,
|
||||
);
|
||||
const installOnboardingApps = useInstallOnboardingApps();
|
||||
|
||||
return {
|
||||
setOnboardingConfig,
|
||||
onboardingFreeCredits,
|
||||
installOnboardingApps,
|
||||
};
|
||||
},
|
||||
{ wrapper: Wrapper },
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.setOnboardingConfig(onboardingConfig);
|
||||
});
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
describe('useInstallOnboardingApps', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
resetJotaiStore();
|
||||
mockTriggerInstallAppsOnboardingStep.mockReset();
|
||||
});
|
||||
|
||||
it('should credit the selected apps once the step succeeds', async () => {
|
||||
mockTriggerInstallAppsOnboardingStep.mockResolvedValue(undefined);
|
||||
|
||||
const result = renderInstallHook();
|
||||
|
||||
act(() => {
|
||||
result.current.installOnboardingApps.toggleApp('app-1');
|
||||
});
|
||||
act(() => {
|
||||
result.current.installOnboardingApps.toggleApp('app-2');
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.installOnboardingApps.installSelectedAppsAndContinue();
|
||||
});
|
||||
|
||||
expect(mockTriggerInstallAppsOnboardingStep).toHaveBeenCalledWith([
|
||||
'app-1',
|
||||
'app-2',
|
||||
]);
|
||||
expect(result.current.onboardingFreeCredits.installApps).toBe(2);
|
||||
});
|
||||
|
||||
it('should reset the completing state and not credit when the step fails', async () => {
|
||||
mockTriggerInstallAppsOnboardingStep.mockRejectedValue(
|
||||
new Error('network error'),
|
||||
);
|
||||
|
||||
const result = renderInstallHook();
|
||||
|
||||
act(() => {
|
||||
result.current.installOnboardingApps.toggleApp('app-1');
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.installOnboardingApps.installSelectedAppsAndContinue();
|
||||
});
|
||||
|
||||
expect(result.current.installOnboardingApps.isCompleting).toBe(false);
|
||||
expect(result.current.onboardingFreeCredits.installApps).toBe(0);
|
||||
});
|
||||
|
||||
it('should allow retrying after a failed attempt', async () => {
|
||||
mockTriggerInstallAppsOnboardingStep
|
||||
.mockRejectedValueOnce(new Error('network error'))
|
||||
.mockResolvedValueOnce(undefined);
|
||||
|
||||
const result = renderInstallHook();
|
||||
|
||||
act(() => {
|
||||
result.current.installOnboardingApps.toggleApp('app-1');
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.installOnboardingApps.installSelectedAppsAndContinue();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.installOnboardingApps.installSelectedAppsAndContinue();
|
||||
});
|
||||
|
||||
expect(mockTriggerInstallAppsOnboardingStep).toHaveBeenCalledTimes(2);
|
||||
expect(result.current.onboardingFreeCredits.installApps).toBe(1);
|
||||
});
|
||||
|
||||
it('should ignore a second submission while one is already in flight', async () => {
|
||||
let resolveTrigger: () => void = () => {};
|
||||
|
||||
mockTriggerInstallAppsOnboardingStep.mockReturnValue(
|
||||
new Promise<void>((resolve) => {
|
||||
resolveTrigger = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
const result = renderInstallHook();
|
||||
|
||||
act(() => {
|
||||
result.current.installOnboardingApps.toggleApp('app-1');
|
||||
});
|
||||
|
||||
act(() => {
|
||||
void result.current.installOnboardingApps.installSelectedAppsAndContinue();
|
||||
});
|
||||
|
||||
expect(result.current.installOnboardingApps.isCompleting).toBe(true);
|
||||
|
||||
act(() => {
|
||||
void result.current.installOnboardingApps.skip();
|
||||
});
|
||||
|
||||
expect(mockTriggerInstallAppsOnboardingStep).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => {
|
||||
resolveTrigger();
|
||||
});
|
||||
});
|
||||
});
|
||||
+8
-1
@@ -93,8 +93,15 @@ describe('useSetNextOnboardingStatus', () => {
|
||||
expect(nextOnboardingStatus).toEqual(OnboardingStatus.SYNC_EMAIL);
|
||||
});
|
||||
|
||||
it('should create profile after syncing emails', () => {
|
||||
it('should install apps after syncing emails', () => {
|
||||
const nextOnboardingStatus = renderHooks(OnboardingStatus.SYNC_EMAIL);
|
||||
expect(nextOnboardingStatus).toEqual(OnboardingStatus.APPS_INSTALLATION);
|
||||
});
|
||||
|
||||
it('should create profile after installing apps', () => {
|
||||
const nextOnboardingStatus = renderHooks(
|
||||
OnboardingStatus.APPS_INSTALLATION,
|
||||
);
|
||||
expect(nextOnboardingStatus).toEqual(OnboardingStatus.PROFILE_CREATION);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { onboardingConfigState } from '@/client-config/states/onboardingConfigState';
|
||||
import { useTriggerInstallAppsOnboardingStep } from '@/onboarding/hooks/useTriggerInstallAppsOnboardingStep';
|
||||
import { onboardingFreeCreditsState } from '@/onboarding/states/onboardingFreeCreditsState';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
|
||||
import { useState } from 'react';
|
||||
|
||||
export const useInstallOnboardingApps = () => {
|
||||
const onboardingConfig = useAtomStateValue(onboardingConfigState);
|
||||
const setOnboardingFreeCredits = useSetAtomState(onboardingFreeCreditsState);
|
||||
const triggerInstallAppsOnboardingStep =
|
||||
useTriggerInstallAppsOnboardingStep();
|
||||
|
||||
const [selectedUniversalIdentifiers, setSelectedUniversalIdentifiers] =
|
||||
useState<string[]>([]);
|
||||
const [isCompleting, setIsCompleting] = useState(false);
|
||||
|
||||
const toggleApp = (universalIdentifier: string) => {
|
||||
setSelectedUniversalIdentifiers((current) =>
|
||||
current.includes(universalIdentifier)
|
||||
? current.filter((identifier) => identifier !== universalIdentifier)
|
||||
: [...current, universalIdentifier],
|
||||
);
|
||||
};
|
||||
|
||||
const triggerStep = async (universalIdentifiers: string[]) => {
|
||||
if (isCompleting) {
|
||||
return;
|
||||
}
|
||||
setIsCompleting(true);
|
||||
|
||||
try {
|
||||
await triggerInstallAppsOnboardingStep(universalIdentifiers);
|
||||
|
||||
const creditsRewardPerApp =
|
||||
onboardingConfig?.installAppsCreditsRewardPerApp ?? 0;
|
||||
|
||||
setOnboardingFreeCredits((current) => ({
|
||||
...current,
|
||||
installApps: creditsRewardPerApp * universalIdentifiers.length,
|
||||
}));
|
||||
} catch {
|
||||
setIsCompleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
selectedUniversalIdentifiers,
|
||||
isCompleting,
|
||||
toggleApp,
|
||||
installSelectedAppsAndContinue: () =>
|
||||
triggerStep(selectedUniversalIdentifiers),
|
||||
skip: () => triggerStep([]),
|
||||
};
|
||||
};
|
||||
@@ -150,15 +150,10 @@ export const useInviteTeam = () => {
|
||||
|
||||
const creditsRewardPerUser =
|
||||
onboardingConfig?.inviteTeamCreditsRewardPerUser ?? 0;
|
||||
const maxCreditsReward =
|
||||
onboardingConfig?.inviteTeamMaxCreditsReward ?? 0;
|
||||
|
||||
setOnboardingFreeCredits((current) => ({
|
||||
...current,
|
||||
inviteTeam: Math.min(
|
||||
emails.length * creditsRewardPerUser,
|
||||
maxCreditsReward,
|
||||
),
|
||||
inviteTeam: emails.length * creditsRewardPerUser,
|
||||
}));
|
||||
|
||||
if (emails.length > 0) {
|
||||
@@ -175,7 +170,6 @@ export const useInviteTeam = () => {
|
||||
[
|
||||
enqueueSuccessSnackBar,
|
||||
onboardingConfig?.inviteTeamCreditsRewardPerUser,
|
||||
onboardingConfig?.inviteTeamMaxCreditsReward,
|
||||
sendInvitation,
|
||||
setNextOnboardingStatus,
|
||||
setOnboardingFreeCredits,
|
||||
|
||||
@@ -5,6 +5,8 @@ export const useOnboardingFreeCreditsTotal = () => {
|
||||
const onboardingFreeCredits = useAtomStateValue(onboardingFreeCreditsState);
|
||||
|
||||
return (
|
||||
onboardingFreeCredits.importContacts + onboardingFreeCredits.inviteTeam
|
||||
onboardingFreeCredits.importContacts +
|
||||
onboardingFreeCredits.inviteTeam +
|
||||
onboardingFreeCredits.installApps
|
||||
);
|
||||
};
|
||||
|
||||
@@ -38,6 +38,10 @@ const getNextOnboardingStatus = ({
|
||||
}
|
||||
|
||||
if (currentUser?.onboardingStatus === OnboardingStatus.SYNC_EMAIL) {
|
||||
return OnboardingStatus.APPS_INSTALLATION;
|
||||
}
|
||||
|
||||
if (currentUser?.onboardingStatus === OnboardingStatus.APPS_INSTALLATION) {
|
||||
return OnboardingStatus.PROFILE_CREATION;
|
||||
}
|
||||
|
||||
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
import { useSetNextOnboardingStatus } from '@/onboarding/hooks/useSetNextOnboardingStatus';
|
||||
import { useMutation } from '@apollo/client/react';
|
||||
import { useCallback } from 'react';
|
||||
import { TriggerInstallAppsOnboardingStepDocument } from '~/generated-metadata/graphql';
|
||||
|
||||
export const useTriggerInstallAppsOnboardingStep = () => {
|
||||
const setNextOnboardingStatus = useSetNextOnboardingStatus();
|
||||
const [triggerInstallAppsOnboardingStep] = useMutation(
|
||||
TriggerInstallAppsOnboardingStepDocument,
|
||||
);
|
||||
|
||||
return useCallback(
|
||||
async (universalIdentifiers: string[]) => {
|
||||
await triggerInstallAppsOnboardingStep({
|
||||
variables: { universalIdentifiers },
|
||||
});
|
||||
setNextOnboardingStatus();
|
||||
},
|
||||
[triggerInstallAppsOnboardingStep, setNextOnboardingStatus],
|
||||
);
|
||||
};
|
||||
@@ -4,10 +4,11 @@ import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomStat
|
||||
export const onboardingFreeCreditsState =
|
||||
createAtomState<OnboardingFreeCredits>({
|
||||
key: 'onboardingFreeCreditsState',
|
||||
defaultValue: { importContacts: 0, inviteTeam: 0 },
|
||||
defaultValue: { importContacts: 0, inviteTeam: 0, installApps: 0 },
|
||||
useLocalStorage: true,
|
||||
localStorageOptions: { getOnInit: true },
|
||||
validateInitFn: (payload) =>
|
||||
Number.isFinite(payload.importContacts) &&
|
||||
Number.isFinite(payload.inviteTeam),
|
||||
Number.isFinite(payload.inviteTeam) &&
|
||||
Number.isFinite(payload.installApps),
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export type OnboardingFreeCredits = {
|
||||
importContacts: number;
|
||||
inviteTeam: number;
|
||||
installApps: number;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { type MessageDescriptor } from '@lingui/core';
|
||||
|
||||
export type OnboardingInstallableApp = {
|
||||
universalIdentifier: string;
|
||||
label: MessageDescriptor;
|
||||
description: MessageDescriptor;
|
||||
};
|
||||
Reference in New Issue
Block a user