[REQUIRED FOR 2.18 RELEASE] Show the upgrade-plan step at the end of V1 onboarding (#22368)

## What

On V1 onboarding the upgrade-plan step (`ChooseYourPlan`) only appeared
later, once the user happened to create a record, instead of right after
Invite team.

## Why

The frontend advances the onboarding status optimistically in
`getNextOnboardingStatus()` without refetching, and it never emitted
`PLAN_REQUIRED`. So after Invite team the user was locally marked
`COMPLETED` and dropped into the app; the backend's real `PLAN_REQUIRED`
only surfaced on a later `GetCurrentUser` refetch.

## Fix

Make `getNextOnboardingStatus()` billing-aware so it mirrors the
backend: return `PLAN_REQUIRED` in the terminal branches when
`isBillingEnabled && billingSubscriptions.length === 0` (using
`billingSubscriptions` to match the backend's any-subscription check).
The navigate hook already routes `PLAN_REQUIRED` to `/plan-required`, so
no routing change is needed. Self-hosted and existing-subscription flows
are unchanged.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22368?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. -->

---------

Co-authored-by: prastoin <paul@twenty.com>
This commit is contained in:
Raphaël Bosi
2026-06-30 17:59:09 +02:00
committed by GitHub
parent e82a47c9a2
commit 52d3735147
2 changed files with 100 additions and 37 deletions
@@ -1,11 +1,11 @@
import { act, renderHook } from '@testing-library/react';
import { createElement } from 'react';
import { Provider as JotaiProvider } from 'jotai';
import { v4 } from 'uuid';
import { currentUserState } from '@/auth/states/currentUserState';
import { currentUserWorkspaceState } from '@/auth/states/currentUserWorkspaceState';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { billingState } from '@/client-config/states/billingState';
import { useSetNextOnboardingStatus } from '@/onboarding/hooks/useSetNextOnboardingStatus';
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
@@ -14,10 +14,7 @@ import {
resetJotaiStore,
} from '@/ui/utilities/state/jotai/jotaiStore';
import {
OnboardingStatus,
SubscriptionStatus,
} from '~/generated-metadata/graphql';
import { OnboardingStatus } from '~/generated-metadata/graphql';
import {
mockCurrentWorkspace,
mockedUserData,
@@ -26,10 +23,19 @@ import {
const Wrapper = ({ children }: { children: React.ReactNode }) =>
createElement(JotaiProvider, { store: jotaiStore }, children);
type RenderHooksOptions = {
withSubscription?: boolean;
isBillingEnabled?: boolean;
withOneWorkspaceMember?: boolean;
};
const renderHooks = (
onboardingStatus: OnboardingStatus,
withCurrentBillingSubscription: boolean,
withOneWorkspaceMember = true,
{
withSubscription = false,
isBillingEnabled = false,
withOneWorkspaceMember = true,
}: RenderHooksOptions = {},
) => {
const { result } = renderHook(
() => {
@@ -38,12 +44,14 @@ const renderHooks = (
currentUserWorkspaceState,
);
const setCurrentWorkspace = useSetAtomState(currentWorkspaceState);
const setBilling = useSetAtomState(billingState);
const setNextOnboardingStatus = useSetNextOnboardingStatus();
return {
currentUser,
setCurrentUser,
setCurrentWorkspace,
setCurrentUserWorkspace,
setBilling,
setNextOnboardingStatus,
};
},
@@ -56,16 +64,16 @@ const renderHooks = (
result.current.setCurrentUserWorkspace(mockedUserData.currentUserWorkspace);
result.current.setCurrentWorkspace({
...mockCurrentWorkspace,
currentBillingSubscription: withCurrentBillingSubscription
? {
id: v4(),
status: SubscriptionStatus.Active,
metadata: {},
phases: [],
}
: undefined,
billingSubscriptions: withSubscription
? mockCurrentWorkspace.billingSubscriptions
: [],
workspaceMembersCount: withOneWorkspaceMember ? 1 : 2,
});
result.current.setBilling({
__typename: 'Billing',
isBillingEnabled,
trialPeriods: [],
});
});
act(() => {
result.current.setNextOnboardingStatus();
@@ -81,45 +89,76 @@ describe('useSetNextOnboardingStatus', () => {
it('should sync emails right after workspace activation', () => {
const nextOnboardingStatus = renderHooks(
OnboardingStatus.WORKSPACE_ACTIVATION,
false,
true,
);
expect(nextOnboardingStatus).toEqual(OnboardingStatus.SYNC_EMAIL);
});
it('should create profile after syncing emails', () => {
const nextOnboardingStatus = renderHooks(
OnboardingStatus.SYNC_EMAIL,
false,
true,
);
const nextOnboardingStatus = renderHooks(OnboardingStatus.SYNC_EMAIL);
expect(nextOnboardingStatus).toEqual(OnboardingStatus.PROFILE_CREATION);
});
it('should invite the team right after profile creation', () => {
const nextOnboardingStatus = renderHooks(
OnboardingStatus.PROFILE_CREATION,
false,
true,
);
const nextOnboardingStatus = renderHooks(OnboardingStatus.PROFILE_CREATION);
expect(nextOnboardingStatus).toEqual(OnboardingStatus.INVITE_TEAM);
});
it('should complete after profile creation when more than 1 workspaceMember exist', () => {
const nextOnboardingStatus = renderHooks(
OnboardingStatus.PROFILE_CREATION,
false,
false,
{
withOneWorkspaceMember: false,
},
);
expect(nextOnboardingStatus).toEqual(OnboardingStatus.COMPLETED);
});
it('should set next onboarding status for Completed', () => {
it('should require a plan after profile creation when billing is enabled and the workspace has no subscription', () => {
const nextOnboardingStatus = renderHooks(
OnboardingStatus.INVITE_TEAM,
true,
true,
OnboardingStatus.PROFILE_CREATION,
{
withOneWorkspaceMember: false,
isBillingEnabled: true,
withSubscription: false,
},
);
expect(nextOnboardingStatus).toEqual(OnboardingStatus.PLAN_REQUIRED);
});
it('should complete after inviting the team when billing is disabled', () => {
const nextOnboardingStatus = renderHooks(OnboardingStatus.INVITE_TEAM);
expect(nextOnboardingStatus).toEqual(OnboardingStatus.COMPLETED);
});
it('should complete after inviting the team when the workspace already has a subscription', () => {
const nextOnboardingStatus = renderHooks(OnboardingStatus.INVITE_TEAM, {
isBillingEnabled: true,
withSubscription: true,
});
expect(nextOnboardingStatus).toEqual(OnboardingStatus.COMPLETED);
});
it('should require a plan after inviting the team when billing is enabled and the workspace has no subscription', () => {
const nextOnboardingStatus = renderHooks(OnboardingStatus.INVITE_TEAM, {
isBillingEnabled: true,
withSubscription: false,
});
expect(nextOnboardingStatus).toEqual(OnboardingStatus.PLAN_REQUIRED);
});
it('should complete after booking onboarding when the workspace already has a subscription', () => {
const nextOnboardingStatus = renderHooks(OnboardingStatus.BOOK_ONBOARDING, {
isBillingEnabled: true,
withSubscription: true,
});
expect(nextOnboardingStatus).toEqual(OnboardingStatus.COMPLETED);
});
it('should require a plan after booking onboarding when billing is enabled and the workspace has no subscription', () => {
const nextOnboardingStatus = renderHooks(OnboardingStatus.BOOK_ONBOARDING, {
isBillingEnabled: true,
withSubscription: false,
});
expect(nextOnboardingStatus).toEqual(OnboardingStatus.PLAN_REQUIRED);
});
});
@@ -8,6 +8,7 @@ import {
type CurrentWorkspace,
currentWorkspaceState,
} from '@/auth/states/currentWorkspaceState';
import { billingState } from '@/client-config/states/billingState';
import { calendarBookingPageIdState } from '@/client-config/states/calendarBookingPageIdState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
@@ -19,13 +20,19 @@ type GetNextOnboardingStatusArgs = {
currentUser: CurrentUser | null;
currentWorkspace: CurrentWorkspace | null;
calendarBookingPageId: string | null;
isBillingEnabled: boolean;
};
const getNextOnboardingStatus = ({
currentUser,
currentWorkspace,
calendarBookingPageId,
isBillingEnabled,
}: GetNextOnboardingStatusArgs) => {
const isPlanRequired =
isBillingEnabled &&
(currentWorkspace?.billingSubscriptions?.length ?? 0) === 0;
if (currentUser?.onboardingStatus === OnboardingStatus.WORKSPACE_ACTIVATION) {
return OnboardingStatus.SYNC_EMAIL;
}
@@ -35,17 +42,25 @@ const getNextOnboardingStatus = ({
}
if (currentUser?.onboardingStatus === OnboardingStatus.PROFILE_CREATION) {
return currentWorkspace?.workspaceMembersCount === 1
? OnboardingStatus.INVITE_TEAM
if (currentWorkspace?.workspaceMembersCount === 1) {
return OnboardingStatus.INVITE_TEAM;
}
return isPlanRequired
? OnboardingStatus.PLAN_REQUIRED
: OnboardingStatus.COMPLETED;
}
if (currentUser?.onboardingStatus === OnboardingStatus.INVITE_TEAM) {
if (isPlanRequired) {
return OnboardingStatus.PLAN_REQUIRED;
}
return isDefined(calendarBookingPageId)
? OnboardingStatus.BOOK_ONBOARDING
: OnboardingStatus.COMPLETED;
}
if (currentUser?.onboardingStatus === OnboardingStatus.BOOK_ONBOARDING) {
return OnboardingStatus.COMPLETED;
return isPlanRequired
? OnboardingStatus.PLAN_REQUIRED
: OnboardingStatus.COMPLETED;
}
return OnboardingStatus.COMPLETED;
};
@@ -55,12 +70,15 @@ export const useSetNextOnboardingStatus = () => {
const currentUser = useAtomStateValue(currentUserState);
const currentWorkspace = useAtomStateValue(currentWorkspaceState);
const calendarBookingPageId = useAtomStateValue(calendarBookingPageIdState);
const billing = useAtomStateValue(billingState);
const isBillingEnabled = billing?.isBillingEnabled ?? false;
return useCallback(() => {
const nextOnboardingStatus = getNextOnboardingStatus({
currentUser,
currentWorkspace,
calendarBookingPageId,
isBillingEnabled,
});
store.set(currentUserState.atom, (current) => {
if (isDefined(current)) {
@@ -71,5 +89,11 @@ export const useSetNextOnboardingStatus = () => {
}
return current;
});
}, [currentUser, currentWorkspace, calendarBookingPageId, store]);
}, [
currentUser,
currentWorkspace,
calendarBookingPageId,
isBillingEnabled,
store,
]);
};