From cb49a7a053f0e244bc284a1a5f95e90aaeeaec8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Bosi?= <71827178+bosiraphael@users.noreply.github.com> Date: Fri, 26 Jun 2026 10:46:34 +0200 Subject: [PATCH] Add v2 onboarding loading screen while creating workspace (#22152) https://github.com/user-attachments/assets/cc7b1d10-7495-4f21-9311-4c22c0f14771 Adds the full-screen loading screen shown while a new workspace is being created in the v2 sign-up flow (`SignInUpV2`), building on the v2 "Create your workspace" step. How it works: - Submitting the v2 create-workspace form marks the flow as v2 (`isOnboardingV2State`) and creates the workspace. The flag is carried across the cross-subdomain redirect with an `onboardingV2=true` URL param, so v2 users land on a new `/workspace-activation-v2` route instead of v1's `/workspace-activation`. - `WorkspaceActivationV2` runs the real `activateWorkspace` mutation on mount and renders the loader: a pulsing Twenty logomark above a stack of status messages that shift up one at a time, cycling once per second. There is no faked/minimum duration; it advances to the next onboarding step as soon as the workspace is activated. - On activation failure it shows a "Workspace creation failed" screen with a Retry button. v1 onboarding is unchanged. Storybook: `Modules/Auth/SignInUpWorkspaceActivationV2`. Note: The flashes will be fixed in later PRs Review in cubic --- ...sePageChangeEffectNavigateLocation.test.ts | 38 ++++- .../usePageChangeEffectNavigateLocation.ts | 8 +- .../effect-components/PageChangeEffect.tsx | 3 +- .../modules/app/hooks/useCreateAppRouter.tsx | 14 ++ .../app/hooks/useInitializeQueryParamState.ts | 6 + .../modules/auth/constants/OnboardingPaths.ts | 1 + .../SignInUpWorkspaceActivationV2.tsx | 98 +++++++++++++ .../SignInUpWorkspaceCreationLoader.tsx | 28 ++++ .../SignInUpWorkspaceActivationV2.stories.tsx | 38 +++++ .../SignInUpWorkspaceActivationV2Effect.tsx | 31 ++++ .../SignInUpWorkspaceCreationForm.tsx | 5 + .../SignInUpWorkspaceCreationFormV2.tsx | 33 +++-- .../SignInUpWorkspaceCreationFormV2.test.tsx | 45 +++++- .../constants/WorkspaceActivationMessages.ts | 8 ++ .../hooks/useSignUpInNewWorkspace.ts | 11 +- .../auth/states/isCreatingWorkspaceState.ts | 6 + .../auth/states/isOnboardingV2State.ts | 6 + ...useBuildSearchParamsFromUrlSyncedStates.ts | 3 + .../components/MinimalMetadataGater.tsx | 1 + .../src/pages/auth/SignInUpV2.tsx | 6 +- .../onboarding/WorkspaceActivationV2.tsx | 135 ++++++++++++++++++ .../src/testing/constants/UntestedAppPaths.ts | 6 +- .../twenty-front/src/utils/title-utils.ts | 1 + packages/twenty-shared/src/types/AppPath.ts | 1 + 24 files changed, 508 insertions(+), 24 deletions(-) create mode 100644 packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWorkspaceActivationV2.tsx create mode 100644 packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWorkspaceCreationLoader.tsx create mode 100644 packages/twenty-front/src/modules/auth/sign-in-up/components/__stories__/SignInUpWorkspaceActivationV2.stories.tsx create mode 100644 packages/twenty-front/src/modules/auth/sign-in-up/components/internal/SignInUpWorkspaceActivationV2Effect.tsx create mode 100644 packages/twenty-front/src/modules/auth/sign-in-up/constants/WorkspaceActivationMessages.ts create mode 100644 packages/twenty-front/src/modules/auth/states/isCreatingWorkspaceState.ts create mode 100644 packages/twenty-front/src/modules/auth/states/isOnboardingV2State.ts create mode 100644 packages/twenty-front/src/pages/onboarding/WorkspaceActivationV2.tsx diff --git a/packages/twenty-front/src/hooks/__tests__/usePageChangeEffectNavigateLocation.test.ts b/packages/twenty-front/src/hooks/__tests__/usePageChangeEffectNavigateLocation.test.ts index 9386ccec36..20fcbcfa50 100644 --- a/packages/twenty-front/src/hooks/__tests__/usePageChangeEffectNavigateLocation.test.ts +++ b/packages/twenty-front/src/hooks/__tests__/usePageChangeEffectNavigateLocation.test.ts @@ -85,6 +85,7 @@ const setupMockState = ( calendarBookingPageId?: string | null, returnToPath?: string, currentWorkspace: object | null = { id: 'mock-workspace-id' }, + isOnboardingV2 = false, ) => { jest .mocked(useAtomStateValue) @@ -92,7 +93,8 @@ const setupMockState = ( .mockReturnValueOnce(calendarBookingPageId ?? 'mock-calendar-id') .mockReturnValueOnce([{ namePlural: objectNamePlural ?? '' }]) .mockReturnValueOnce(verifyEmailRedirectPath) - .mockReturnValueOnce(returnToPath ?? ''); + .mockReturnValueOnce(returnToPath ?? '') + .mockReturnValueOnce(isOnboardingV2); }; // prettier-ignore @@ -454,3 +456,37 @@ describe('usePageChangeEffectNavigateLocation — authenticated with no current }, ); }); + +describe('usePageChangeEffectNavigateLocation — onboarding V2', () => { + const setupWorkspaceActivationV2Case = (loc: AppPath) => { + setupMockIsMatchingLocation(loc); + setupMockOnboardingStatus(OnboardingStatus.WORKSPACE_ACTIVATION); + setupMockIsWorkspaceActivationStatusEqualsTo(false); + setupMockHasAccessTokenPair(true); + setupMockIsOnAWorkspace(true); + setupMockUseQuery(); + setupMockUseParams(); + setupMockState( + undefined, + undefined, + undefined, + undefined, + { id: 'mock-workspace-id' }, + true, + ); + }; + + it('routes to WorkspaceActivationV2 when onboardingV2 is active and status is WORKSPACE_ACTIVATION', () => { + setupWorkspaceActivationV2Case(AppPath.SignInUpV2); + + expect(usePageChangeEffectNavigateLocation()).toEqual( + AppPath.WorkspaceActivationV2, + ); + }); + + it('does not redirect away from the WorkspaceActivationV2 page during activation', () => { + setupWorkspaceActivationV2Case(AppPath.WorkspaceActivationV2); + + expect(usePageChangeEffectNavigateLocation()).toBeUndefined(); + }); +}); diff --git a/packages/twenty-front/src/hooks/usePageChangeEffectNavigateLocation.ts b/packages/twenty-front/src/hooks/usePageChangeEffectNavigateLocation.ts index 5ab89f153c..fa569582d7 100644 --- a/packages/twenty-front/src/hooks/usePageChangeEffectNavigateLocation.ts +++ b/packages/twenty-front/src/hooks/usePageChangeEffectNavigateLocation.ts @@ -3,6 +3,7 @@ import { ONBOARDING_PATHS } from '@/auth/constants/OnboardingPaths'; import { ONGOING_USER_CREATION_PATHS } from '@/auth/constants/OngoingUserCreationPaths'; import { useHasAccessTokenPair } from '@/auth/hooks/useHasAccessTokenPair'; import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; +import { isOnboardingV2State } from '@/auth/states/isOnboardingV2State'; import { returnToPathState } from '@/auth/states/returnToPathState'; import { calendarBookingPageIdState } from '@/client-config/states/calendarBookingPageIdState'; import { useIsCurrentLocationOnAWorkspace } from '@/domain-manager/hooks/useIsCurrentLocationOnAWorkspace'; @@ -76,6 +77,8 @@ export const usePageChangeEffectNavigateLocation = () => { ? returnToPath : readReturnToPathFromUrlSearchParams(); + const isOnboardingV2 = useAtomStateValue(isOnboardingV2State); + if ( (!hasAccessTokenPair || !isOnAWorkspace || !isDefined(currentWorkspace)) && !someMatchingLocationOf([ @@ -118,11 +121,14 @@ export const usePageChangeEffectNavigateLocation = () => { onboardingStatus === OnboardingStatus.WORKSPACE_ACTIVATION && !someMatchingLocationOf([ AppPath.WorkspaceActivation, + AppPath.WorkspaceActivationV2, AppPath.BookCallDecision, AppPath.BookCall, ]) ) { - return AppPath.WorkspaceActivation; + return isOnboardingV2 + ? AppPath.WorkspaceActivationV2 + : AppPath.WorkspaceActivation; } if ( diff --git a/packages/twenty-front/src/modules/app/effect-components/PageChangeEffect.tsx b/packages/twenty-front/src/modules/app/effect-components/PageChangeEffect.tsx index 61c1ebe972..c6a2a7c1c3 100644 --- a/packages/twenty-front/src/modules/app/effect-components/PageChangeEffect.tsx +++ b/packages/twenty-front/src/modules/app/effect-components/PageChangeEffect.tsx @@ -302,7 +302,8 @@ export const PageChangeEffect = () => { }); break; } - case isMatchingLocation(location, AppPath.WorkspaceActivation): { + case isMatchingLocation(location, AppPath.WorkspaceActivation): + case isMatchingLocation(location, AppPath.WorkspaceActivationV2): { resetFocusStackToFocusItem({ focusStackItem: { focusId: PageFocusId.WorkspaceActivation, diff --git a/packages/twenty-front/src/modules/app/hooks/useCreateAppRouter.tsx b/packages/twenty-front/src/modules/app/hooks/useCreateAppRouter.tsx index 9c6af895f1..fceea53e90 100644 --- a/packages/twenty-front/src/modules/app/hooks/useCreateAppRouter.tsx +++ b/packages/twenty-front/src/modules/app/hooks/useCreateAppRouter.tsx @@ -60,6 +60,12 @@ const WorkspaceActivation = lazy(() => })), ); +const WorkspaceActivationV2 = lazy(() => + import('~/pages/onboarding/WorkspaceActivationV2').then((module) => ({ + default: module.WorkspaceActivationV2, + })), +); + const CreateProfile = lazy(() => import('~/pages/onboarding/CreateProfile').then((module) => ({ default: module.CreateProfile, @@ -271,6 +277,14 @@ export const useCreateAppRouter = ( } /> + + + + } + /> { store.set(returnToPathState.atom, value); } }, + onboardingV2: (value: string) => { + if (value === 'true') { + store.set(isOnboardingV2State.atom, true); + } + }, }; const queryParams = new URLSearchParams(window.location.search); diff --git a/packages/twenty-front/src/modules/auth/constants/OnboardingPaths.ts b/packages/twenty-front/src/modules/auth/constants/OnboardingPaths.ts index ad4cbf9d18..019d94699f 100644 --- a/packages/twenty-front/src/modules/auth/constants/OnboardingPaths.ts +++ b/packages/twenty-front/src/modules/auth/constants/OnboardingPaths.ts @@ -2,6 +2,7 @@ import { AppPath } from 'twenty-shared/types'; export const ONBOARDING_PATHS = [ AppPath.WorkspaceActivation, + AppPath.WorkspaceActivationV2, AppPath.CreateProfile, AppPath.SyncEmails, AppPath.InviteTeam, diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWorkspaceActivationV2.tsx b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWorkspaceActivationV2.tsx new file mode 100644 index 0000000000..7901de5d2f --- /dev/null +++ b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWorkspaceActivationV2.tsx @@ -0,0 +1,98 @@ +import { SubTitle } from '@/auth/components/SubTitle'; +import { WORKSPACE_ACTIVATION_MESSAGES } from '@/auth/sign-in-up/constants/WorkspaceActivationMessages'; +import { styled } from '@linaria/react'; +import { useLingui } from '@lingui/react/macro'; +import { motion, useReducedMotion } from 'framer-motion'; +import { useContext } from 'react'; +import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants'; + +const STEP_OPACITIES = [1, 0.4, 0.12]; +const VISIBLE_STEP_COUNT = STEP_OPACITIES.length; +const STEP_HEIGHT_IN_PX = 28; +const STEPS_CONTAINER_HEIGHT_IN_PX = STEP_HEIGHT_IN_PX * VISIBLE_STEP_COUNT; + +const StyledLogo = styled.img` + animation: signInUpWorkspaceActivationLogoPulse 0.8s ease-in-out infinite + alternate; + height: ${themeCssVariables.spacing[12]}; + margin-bottom: ${themeCssVariables.spacing[8]}; + width: ${themeCssVariables.spacing[12]}; + + @keyframes signInUpWorkspaceActivationLogoPulse { + from { + opacity: 1; + } + to { + opacity: 0.4; + } + } + + @media (prefers-reduced-motion: reduce) { + animation: none; + opacity: 1; + } +`; + +const StyledStepsContainer = styled.div` + height: ${STEPS_CONTAINER_HEIGHT_IN_PX}px; + position: relative; + width: 100%; +`; + +const StyledStepBase = styled.div` + left: 0; + position: absolute; + right: 0; + top: 0; +`; + +const StyledStep = motion.create(StyledStepBase); + +type SignInUpWorkspaceActivationV2Props = { + messageIndex: number; +}; + +export const SignInUpWorkspaceActivationV2 = ({ + messageIndex, +}: SignInUpWorkspaceActivationV2Props) => { + const { i18n } = useLingui(); + const { theme } = useContext(ThemeContext); + const shouldReduceMotion = useReducedMotion(); + + const messages = WORKSPACE_ACTIVATION_MESSAGES.map((message) => + i18n._(message), + ); + + return ( + <> + + + {messages.map((message, index) => { + const stepOffset = index - messageIndex; + const isVisible = stepOffset >= 0 && stepOffset < VISIBLE_STEP_COUNT; + + return ( + + {message} + + ); + })} + + + ); +}; diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWorkspaceCreationLoader.tsx b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWorkspaceCreationLoader.tsx new file mode 100644 index 0000000000..9fa4928120 --- /dev/null +++ b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWorkspaceCreationLoader.tsx @@ -0,0 +1,28 @@ +import { SignInUpWorkspaceActivationV2 } from '@/auth/sign-in-up/components/SignInUpWorkspaceActivationV2'; +import { SignInUpWorkspaceActivationV2Effect } from '@/auth/sign-in-up/components/internal/SignInUpWorkspaceActivationV2Effect'; +import { styled } from '@linaria/react'; +import { useState } from 'react'; + +const StyledContainer = styled.div` + align-items: center; + display: flex; + flex: 1; + flex-direction: column; + justify-content: center; + min-height: 100%; + width: 100%; +`; + +export const SignInUpWorkspaceCreationLoader = () => { + const [messageIndex, setMessageIndex] = useState(0); + + return ( + + + + + ); +}; diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/components/__stories__/SignInUpWorkspaceActivationV2.stories.tsx b/packages/twenty-front/src/modules/auth/sign-in-up/components/__stories__/SignInUpWorkspaceActivationV2.stories.tsx new file mode 100644 index 0000000000..e3801b9854 --- /dev/null +++ b/packages/twenty-front/src/modules/auth/sign-in-up/components/__stories__/SignInUpWorkspaceActivationV2.stories.tsx @@ -0,0 +1,38 @@ +import { type Meta, type StoryObj } from '@storybook/react-vite'; + +import { SignInUpWorkspaceActivationV2 } from '@/auth/sign-in-up/components/SignInUpWorkspaceActivationV2'; +import { SignInUpWorkspaceActivationV2Effect } from '@/auth/sign-in-up/components/internal/SignInUpWorkspaceActivationV2Effect'; +import { useState } from 'react'; +import { ModalContent } from 'twenty-ui/surfaces'; +import { ComponentDecorator } from 'twenty-ui/testing'; + +const RenderWithModalContent = () => { + const [messageIndex, setMessageIndex] = useState(0); + + return ( + + + + + ); +}; + +const meta: Meta = { + title: 'Modules/Auth/SignInUpWorkspaceActivationV2', + component: SignInUpWorkspaceActivationV2, + decorators: [ComponentDecorator], + parameters: { + codeSection: { + docs: 'This component should always be wrapped with ModalContent in the app.\n\nCorrect usage:\n```tsx\n\n \n\n```\n', + }, + }, + render: RenderWithModalContent, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/components/internal/SignInUpWorkspaceActivationV2Effect.tsx b/packages/twenty-front/src/modules/auth/sign-in-up/components/internal/SignInUpWorkspaceActivationV2Effect.tsx new file mode 100644 index 0000000000..aba5a9f0de --- /dev/null +++ b/packages/twenty-front/src/modules/auth/sign-in-up/components/internal/SignInUpWorkspaceActivationV2Effect.tsx @@ -0,0 +1,31 @@ +import { WORKSPACE_ACTIVATION_MESSAGES } from '@/auth/sign-in-up/constants/WorkspaceActivationMessages'; +import { type Dispatch, type SetStateAction, useEffect } from 'react'; + +const MESSAGE_INTERVAL_IN_MS = 1000; + +type SignInUpWorkspaceActivationV2EffectProps = { + messageIndex: number; + setMessageIndex: Dispatch>; +}; + +export const SignInUpWorkspaceActivationV2Effect = ({ + messageIndex, + setMessageIndex, +}: SignInUpWorkspaceActivationV2EffectProps) => { + useEffect(() => { + const isLastMessage = + messageIndex >= WORKSPACE_ACTIVATION_MESSAGES.length - 1; + + if (isLastMessage) { + return; + } + + const timeout = setTimeout(() => { + setMessageIndex((previousIndex) => previousIndex + 1); + }, MESSAGE_INTERVAL_IN_MS); + + return () => clearTimeout(timeout); + }, [messageIndex, setMessageIndex]); + + return <>; +}; diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/components/internal/SignInUpWorkspaceCreationForm.tsx b/packages/twenty-front/src/modules/auth/sign-in-up/components/internal/SignInUpWorkspaceCreationForm.tsx index 1133622fa4..518fdd919b 100644 --- a/packages/twenty-front/src/modules/auth/sign-in-up/components/internal/SignInUpWorkspaceCreationForm.tsx +++ b/packages/twenty-front/src/modules/auth/sign-in-up/components/internal/SignInUpWorkspaceCreationForm.tsx @@ -2,6 +2,7 @@ import { SubTitle } from '@/auth/components/SubTitle'; import { StyledOnboardingContentContainer } from '@/auth/components/StyledOnboardingContentContainer'; import { useSignUpInNewWorkspace } from '@/auth/sign-in-up/hooks/useSignUpInNewWorkspace'; import { useWorkspaceSubdomainField } from '@/auth/sign-in-up/hooks/useWorkspaceSubdomainField'; +import { isOnboardingV2State } from '@/auth/states/isOnboardingV2State'; import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState'; import { domainConfigurationState } from '@/domain-manager/states/domainConfigurationState'; import { ImageInput } from '@/ui/input/components/ImageInput'; @@ -9,6 +10,7 @@ import { InputHint } from '@/ui/input/components/InputHint'; import { InputLabel } from '@/ui/input/components/InputLabel'; import { TextInput } from '@/ui/input/components/TextInput'; import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; +import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState'; import { useLingui } from '@lingui/react/macro'; import { styled } from '@linaria/react'; import { isNonEmptyString } from '@sniptt/guards'; @@ -54,6 +56,8 @@ export const SignInUpWorkspaceCreationForm = () => { isMultiWorkspaceEnabledState, ); + const setIsOnboardingV2 = useSetAtomState(isOnboardingV2State); + const [isSubmitting, setIsSubmitting] = useState(false); const [logo, setLogo] = useState(undefined); const [logoPreviewUrl, setLogoPreviewUrl] = useState( @@ -108,6 +112,7 @@ export const SignInUpWorkspaceCreationForm = () => { } setIsSubmitting(true); + setIsOnboardingV2(false); try { await createWorkspace({ displayName: workspaceName.trim(), diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/components/internal/SignInUpWorkspaceCreationFormV2.tsx b/packages/twenty-front/src/modules/auth/sign-in-up/components/internal/SignInUpWorkspaceCreationFormV2.tsx index b6ac06e439..3c59b0acb6 100644 --- a/packages/twenty-front/src/modules/auth/sign-in-up/components/internal/SignInUpWorkspaceCreationFormV2.tsx +++ b/packages/twenty-front/src/modules/auth/sign-in-up/components/internal/SignInUpWorkspaceCreationFormV2.tsx @@ -1,18 +1,20 @@ import { StyledOnboardingContentContainer } from '@/auth/components/StyledOnboardingContentContainer'; import { useSignUpInNewWorkspace } from '@/auth/sign-in-up/hooks/useSignUpInNewWorkspace'; import { useWorkspaceSubdomainField } from '@/auth/sign-in-up/hooks/useWorkspaceSubdomainField'; +import { isCreatingWorkspaceState } from '@/auth/states/isCreatingWorkspaceState'; +import { isOnboardingV2State } from '@/auth/states/isOnboardingV2State'; import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState'; import { domainConfigurationState } from '@/domain-manager/states/domainConfigurationState'; import { TextInput } from '@/ui/input/components/TextInput'; import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; -import { useLingui } from '@lingui/react/macro'; +import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState'; import { styled } from '@linaria/react'; +import { useLingui } from '@lingui/react/macro'; import { isNonEmptyString } from '@sniptt/guards'; import { useEffect, useRef, useState } from 'react'; import { Key } from 'ts-key-enum'; import { isDefined } from 'twenty-shared/utils'; import { Avatar } from 'twenty-ui/data-display'; -import { Loader } from 'twenty-ui/feedback'; import { IconTrash, IconUpload } from 'twenty-ui/icon'; import { Button, LightIconButton, MainButton } from 'twenty-ui/input'; import { themeCssVariables } from 'twenty-ui/theme-constants'; @@ -97,7 +99,9 @@ export const SignInUpWorkspaceCreationFormV2 = () => { isMultiWorkspaceEnabledState, ); - const [isSubmitting, setIsSubmitting] = useState(false); + const isCreatingWorkspace = useAtomStateValue(isCreatingWorkspaceState); + const setIsCreatingWorkspace = useSetAtomState(isCreatingWorkspaceState); + const setIsOnboardingV2 = useSetAtomState(isOnboardingV2State); const [logo, setLogo] = useState(undefined); const [logoPreviewUrl, setLogoPreviewUrl] = useState( undefined, @@ -120,7 +124,7 @@ export const SignInUpWorkspaceCreationFormV2 = () => { const isContinueDisabled = workspaceName.trim() === '' || - isSubmitting || + isCreatingWorkspace || (isMultiWorkspaceEnabled && !isAvailable); const openFilePicker = () => { @@ -155,15 +159,17 @@ export const SignInUpWorkspaceCreationFormV2 = () => { return; } - setIsSubmitting(true); - try { - await createWorkspace({ - displayName: workspaceName.trim(), - ...(isMultiWorkspaceEnabled ? { subdomain } : {}), - logo, - }); - } finally { - setIsSubmitting(false); + setIsCreatingWorkspace(true); + setIsOnboardingV2(true); + + const isWorkspaceCreated = await createWorkspace({ + displayName: workspaceName.trim(), + ...(isMultiWorkspaceEnabled ? { subdomain } : {}), + logo, + }); + + if (!isWorkspaceCreated) { + setIsCreatingWorkspace(false); } }; @@ -283,7 +289,6 @@ export const SignInUpWorkspaceCreationFormV2 = () => { title={t`Create workspace`} onClick={handleSubmit} disabled={isContinueDisabled} - Icon={() => (isSubmitting ? : null)} fullWidth /> diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/components/internal/__tests__/SignInUpWorkspaceCreationFormV2.test.tsx b/packages/twenty-front/src/modules/auth/sign-in-up/components/internal/__tests__/SignInUpWorkspaceCreationFormV2.test.tsx index 49cf705279..8f4a4111dc 100644 --- a/packages/twenty-front/src/modules/auth/sign-in-up/components/internal/__tests__/SignInUpWorkspaceCreationFormV2.test.tsx +++ b/packages/twenty-front/src/modules/auth/sign-in-up/components/internal/__tests__/SignInUpWorkspaceCreationFormV2.test.tsx @@ -6,6 +6,7 @@ import { SOURCE_LOCALE } from 'twenty-shared/translations'; import { ThemeProvider } from 'twenty-ui/theme-constants'; import { SignInUpWorkspaceCreationFormV2 } from '@/auth/sign-in-up/components/internal/SignInUpWorkspaceCreationFormV2'; +import { isCreatingWorkspaceState } from '@/auth/states/isCreatingWorkspaceState'; import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState'; import { jotaiStore, @@ -70,7 +71,7 @@ describe('SignInUpWorkspaceCreationFormV2', () => { }); it('creates the workspace with the chosen name and subdomain', async () => { - createWorkspaceMock.mockResolvedValue(undefined); + createWorkspaceMock.mockResolvedValue(true); renderForm(); @@ -90,6 +91,46 @@ describe('SignInUpWorkspaceCreationFormV2', () => { }); }); + it('keeps the loader on through a successful creation, until the redirect', async () => { + let resolveCreateWorkspace: () => void = () => {}; + createWorkspaceMock.mockReturnValue( + new Promise((resolve) => { + resolveCreateWorkspace = () => resolve(true); + }), + ); + + renderForm(); + + await act(async () => { + fireEvent.click( + screen.getByRole('button', { name: 'Create workspace' }), + ); + }); + + expect(jotaiStore.get(isCreatingWorkspaceState.atom)).toBe(true); + expect(createWorkspaceMock).toHaveBeenCalledTimes(1); + + await act(async () => { + resolveCreateWorkspace(); + }); + + expect(jotaiStore.get(isCreatingWorkspaceState.atom)).toBe(true); + }); + + it('returns to the form when workspace creation fails', async () => { + createWorkspaceMock.mockResolvedValue(false); + + renderForm(); + + await act(async () => { + fireEvent.click( + screen.getByRole('button', { name: 'Create workspace' }), + ); + }); + + expect(jotaiStore.get(isCreatingWorkspaceState.atom)).toBe(false); + }); + it('lists available alternatives and applies the picked one when the subdomain is taken', () => { useWorkspaceSubdomainFieldMock.mockReturnValue({ workspaceName: 'Stripe', @@ -126,7 +167,7 @@ describe('SignInUpWorkspaceCreationFormV2', () => { }); it('hides the subdomain field and creates without a subdomain', async () => { - createWorkspaceMock.mockResolvedValue(undefined); + createWorkspaceMock.mockResolvedValue(true); renderForm(); diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/constants/WorkspaceActivationMessages.ts b/packages/twenty-front/src/modules/auth/sign-in-up/constants/WorkspaceActivationMessages.ts new file mode 100644 index 0000000000..c6431bd588 --- /dev/null +++ b/packages/twenty-front/src/modules/auth/sign-in-up/constants/WorkspaceActivationMessages.ts @@ -0,0 +1,8 @@ +import { msg } from '@lingui/core/macro'; + +export const WORKSPACE_ACTIVATION_MESSAGES = [ + msg`Creating your workspace...`, + msg`Setting up your database...`, + msg`Creating your data model...`, + msg`Prefilling your workspace data...`, +]; diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSignUpInNewWorkspace.ts b/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSignUpInNewWorkspace.ts index 2269b6fae9..6fedcafcde 100644 --- a/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSignUpInNewWorkspace.ts +++ b/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSignUpInNewWorkspace.ts @@ -38,7 +38,7 @@ export const useSignUpInNewWorkspace = () => { displayName?: string; subdomain?: string; logo?: File; - } = {}) => { + } = {}): Promise => { try { const { data } = await signUpInNewWorkspaceMutation({ variables: { input: { displayName, subdomain } }, @@ -69,15 +69,18 @@ export const useSignUpInNewWorkspace = () => { const loginToken = data.signUpInNewWorkspace.loginToken.token; if (!isMultiWorkspaceEnabled) { - return await getAuthTokensFromLoginToken(loginToken); + await getAuthTokensFromLoginToken(loginToken); + return true; } - return await redirectToWorkspaceDomain( + await redirectToWorkspaceDomain( getWorkspaceUrl(data.signUpInNewWorkspace.workspace.workspaceUrls), AppPath.Verify, { loginToken }, '_self', ); + + return true; } catch (error) { enqueueErrorSnackBar( CombinedGraphQLErrors.is(error) @@ -89,6 +92,8 @@ export const useSignUpInNewWorkspace = () => { : t`Workspace creation failed`, }, ); + + return false; } }; diff --git a/packages/twenty-front/src/modules/auth/states/isCreatingWorkspaceState.ts b/packages/twenty-front/src/modules/auth/states/isCreatingWorkspaceState.ts new file mode 100644 index 0000000000..e7d45884cd --- /dev/null +++ b/packages/twenty-front/src/modules/auth/states/isCreatingWorkspaceState.ts @@ -0,0 +1,6 @@ +import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState'; + +export const isCreatingWorkspaceState = createAtomState({ + key: 'isCreatingWorkspaceState', + defaultValue: false, +}); diff --git a/packages/twenty-front/src/modules/auth/states/isOnboardingV2State.ts b/packages/twenty-front/src/modules/auth/states/isOnboardingV2State.ts new file mode 100644 index 0000000000..434a4202c7 --- /dev/null +++ b/packages/twenty-front/src/modules/auth/states/isOnboardingV2State.ts @@ -0,0 +1,6 @@ +import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState'; + +export const isOnboardingV2State = createAtomState({ + key: 'isOnboardingV2State', + defaultValue: false, +}); diff --git a/packages/twenty-front/src/modules/domain-manager/hooks/useBuildSearchParamsFromUrlSyncedStates.ts b/packages/twenty-front/src/modules/domain-manager/hooks/useBuildSearchParamsFromUrlSyncedStates.ts index 44a83f4da6..9564aed9a6 100644 --- a/packages/twenty-front/src/modules/domain-manager/hooks/useBuildSearchParamsFromUrlSyncedStates.ts +++ b/packages/twenty-front/src/modules/domain-manager/hooks/useBuildSearchParamsFromUrlSyncedStates.ts @@ -1,6 +1,7 @@ import { useCallback } from 'react'; import { billingCheckoutSessionState } from '@/auth/states/billingCheckoutSessionState'; +import { isOnboardingV2State } from '@/auth/states/isOnboardingV2State'; import { returnToPathState } from '@/auth/states/returnToPathState'; import { BILLING_CHECKOUT_SESSION_DEFAULT_VALUE } from '@/settings/billing/constants/BillingCheckoutSessionDefaultValue'; import { isNonEmptyString } from '@sniptt/guards'; @@ -11,6 +12,7 @@ export const useBuildSearchParamsFromUrlSyncedStates = () => { const buildSearchParamsFromUrlSyncedStates = useCallback(async () => { const billingCheckoutSession = store.get(billingCheckoutSessionState.atom); const returnToPath = store.get(returnToPathState.atom); + const isOnboardingV2 = store.get(isOnboardingV2State.atom); const output = { ...(billingCheckoutSession !== BILLING_CHECKOUT_SESSION_DEFAULT_VALUE @@ -19,6 +21,7 @@ export const useBuildSearchParamsFromUrlSyncedStates = () => { } : {}), ...(isNonEmptyString(returnToPath) ? { returnToPath } : {}), + ...(isOnboardingV2 ? { onboardingV2: 'true' } : {}), }; return output; diff --git a/packages/twenty-front/src/modules/metadata-store/components/MinimalMetadataGater.tsx b/packages/twenty-front/src/modules/metadata-store/components/MinimalMetadataGater.tsx index 4aa2dc8ecb..bea8f30c99 100644 --- a/packages/twenty-front/src/modules/metadata-store/components/MinimalMetadataGater.tsx +++ b/packages/twenty-front/src/modules/metadata-store/components/MinimalMetadataGater.tsx @@ -23,6 +23,7 @@ export const MinimalMetadataGater = ({ children }: React.PropsWithChildren) => { isMatchingLocation(location, AppPath.Invite) || isMatchingLocation(location, AppPath.ResetPassword) || isMatchingLocation(location, AppPath.WorkspaceActivation) || + isMatchingLocation(location, AppPath.WorkspaceActivationV2) || isMatchingLocation(location, AppPath.PlanRequired) || isMatchingLocation(location, AppPath.PlanRequiredSuccess) || isMatchingLocation(location, AppPath.Authorize); diff --git a/packages/twenty-front/src/pages/auth/SignInUpV2.tsx b/packages/twenty-front/src/pages/auth/SignInUpV2.tsx index 0cf6812a60..8333c6fe1d 100644 --- a/packages/twenty-front/src/pages/auth/SignInUpV2.tsx +++ b/packages/twenty-front/src/pages/auth/SignInUpV2.tsx @@ -1,5 +1,6 @@ import { useSignInUp } from '@/auth/sign-in-up/hooks/useSignInUp'; import { useSignInUpForm } from '@/auth/sign-in-up/hooks/useSignInUpForm'; +import { isCreatingWorkspaceState } from '@/auth/states/isCreatingWorkspaceState'; import { SignInUpStep, signInUpStepState, @@ -57,6 +58,7 @@ export const SignInUpV2 = () => { const { t } = useLingui(); const setSignInUpStep = useSetAtomState(signInUpStepState); const clientConfigApiStatus = useAtomStateValue(clientConfigApiStatusState); + const isCreatingWorkspace = useAtomStateValue(isCreatingWorkspaceState); const { form } = useSignInUpForm(); const { signInUpStep } = useSignInUp(form); @@ -194,7 +196,9 @@ export const SignInUpV2 = () => { ) : signInUpStep === SignInUpStep.WorkspaceCreation ? ( <> - + {!isCreatingWorkspace ? ( + + ) : null} {signInUpForm} diff --git a/packages/twenty-front/src/pages/onboarding/WorkspaceActivationV2.tsx b/packages/twenty-front/src/pages/onboarding/WorkspaceActivationV2.tsx new file mode 100644 index 0000000000..d4c7207b90 --- /dev/null +++ b/packages/twenty-front/src/pages/onboarding/WorkspaceActivationV2.tsx @@ -0,0 +1,135 @@ +import { styled } from '@linaria/react'; +import { useCallback, useEffect, useRef, useState } from 'react'; + +import { Logo } from '@/auth/components/Logo'; +import { SubTitle } from '@/auth/components/SubTitle'; +import { Title } from '@/auth/components/Title'; +import { SignInUpWorkspaceCreationLoader } from '@/auth/sign-in-up/components/SignInUpWorkspaceCreationLoader'; +import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; +import { isCreatingWorkspaceState } from '@/auth/states/isCreatingWorkspaceState'; +import { useSetNextOnboardingStatus } from '@/onboarding/hooks/useSetNextOnboardingStatus'; +import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; +import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; +import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState'; +import { useLoadCurrentUser } from '@/users/hooks/useLoadCurrentUser'; +import { CombinedGraphQLErrors } from '@apollo/client/errors'; +import { useMutation } from '@apollo/client/react'; +import { Trans, useLingui } from '@lingui/react/macro'; +import { isNonEmptyString } from '@sniptt/guards'; +import { isDefined } from 'twenty-shared/utils'; +import { MainButton } from 'twenty-ui/input'; +import { themeCssVariables } from 'twenty-ui/theme-constants'; +import { ActivateWorkspaceDocument } from '~/generated-metadata/graphql'; + +const StyledContainer = styled.div` + align-items: center; + background: ${themeCssVariables.background.primary}; + display: flex; + flex-direction: column; + height: 100%; + justify-content: center; + width: 100%; +`; + +const StyledButtonContainer = styled.div` + margin-top: ${themeCssVariables.spacing[8]}; + width: 200px; +`; + +export const WorkspaceActivationV2 = () => { + const { t } = useLingui(); + const { enqueueErrorSnackBar } = useSnackBar(); + const setNextOnboardingStatus = useSetNextOnboardingStatus(); + const { loadCurrentUser } = useLoadCurrentUser(); + const [activateWorkspace, { loading: isActivating }] = useMutation( + ActivateWorkspaceDocument, + ); + const [hasFailed, setHasFailed] = useState(false); + const currentWorkspace = useAtomStateValue(currentWorkspaceState); + const setIsCreatingWorkspace = useSetAtomState(isCreatingWorkspaceState); + + const activate = useCallback(async () => { + setHasFailed(false); + + try { + const result = await activateWorkspace({ + variables: { + input: {}, + }, + }); + + if (isDefined(result.error)) { + throw result.error; + } + + await loadCurrentUser(); + setIsCreatingWorkspace(false); + setNextOnboardingStatus(); + } catch (error) { + setHasFailed(true); + + enqueueErrorSnackBar({ + apolloError: CombinedGraphQLErrors.is(error) ? error : undefined, + }); + } + }, [ + activateWorkspace, + enqueueErrorSnackBar, + loadCurrentUser, + setIsCreatingWorkspace, + setNextOnboardingStatus, + ]); + + // Guard the one-shot trigger with a ref, not state: a ref mutation is + // synchronous and survives StrictMode's double-invocation of effects, whereas + // a state flag stays false in the second (same-closure) invocation and fires + // a second concurrent activation that trips the server's lock. + // oxlint-disable-next-line twenty/no-state-useref + const hasTriggeredRef = useRef(false); + useEffect(() => { + if (hasTriggeredRef.current || !isDefined(currentWorkspace)) { + return; + } + + hasTriggeredRef.current = true; + void activate(); + }, [activate, currentWorkspace]); + + if (!hasFailed) { + return ( + + + + ); + } + + return ( + + + + <Trans>Workspace creation failed</Trans> + + + + Something went wrong while creating your workspace. Please try again. + + + + { + void activate(); + }} + disabled={isActivating} + fullWidth + /> + + + ); +}; diff --git a/packages/twenty-front/src/testing/constants/UntestedAppPaths.ts b/packages/twenty-front/src/testing/constants/UntestedAppPaths.ts index e9392c4f9b..b3aa851250 100644 --- a/packages/twenty-front/src/testing/constants/UntestedAppPaths.ts +++ b/packages/twenty-front/src/testing/constants/UntestedAppPaths.ts @@ -1,3 +1,7 @@ import { AppPath } from 'twenty-shared/types'; -export const UNTESTED_APP_PATHS = [AppPath.Settings, AppPath.Developers]; +export const UNTESTED_APP_PATHS = [ + AppPath.Settings, + AppPath.Developers, + AppPath.WorkspaceActivationV2, +]; diff --git a/packages/twenty-front/src/utils/title-utils.ts b/packages/twenty-front/src/utils/title-utils.ts index 7b5d6fd9a5..864741c0ab 100644 --- a/packages/twenty-front/src/utils/title-utils.ts +++ b/packages/twenty-front/src/utils/title-utils.ts @@ -34,6 +34,7 @@ export const getPageTitleFromPath = (pathname: string): string => { case AppPath.Invite: return t`Invite`; case AppPath.WorkspaceActivation: + case AppPath.WorkspaceActivationV2: return t`Create Workspace`; case AppPath.CreateProfile: return t`Create Profile`; diff --git a/packages/twenty-shared/src/types/AppPath.ts b/packages/twenty-shared/src/types/AppPath.ts index e9fbec6e66..9da7e99096 100644 --- a/packages/twenty-shared/src/types/AppPath.ts +++ b/packages/twenty-shared/src/types/AppPath.ts @@ -9,6 +9,7 @@ export enum AppPath { // Onboarding WorkspaceActivation = '/workspace-activation', + WorkspaceActivationV2 = '/workspace-activation-v2', CreateProfile = '/create/profile', SyncEmails = '/sync/emails', InviteTeam = '/invite-team',