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 <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22152?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:
+37
-1
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = (
|
||||
</LazyRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path={AppPath.WorkspaceActivationV2}
|
||||
element={
|
||||
<LazyRoute fallback={null}>
|
||||
<WorkspaceActivationV2 />
|
||||
</LazyRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path={AppPath.Authorize}
|
||||
element={
|
||||
|
||||
@@ -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 { type BillingCheckoutSession } from '@/auth/types/billingCheckoutSession.type';
|
||||
import { isValidReturnToPath } from '@/auth/utils/isValidReturnToPath';
|
||||
@@ -50,6 +51,11 @@ export const useInitializeQueryParamState = () => {
|
||||
store.set(returnToPathState.atom, value);
|
||||
}
|
||||
},
|
||||
onboardingV2: (value: string) => {
|
||||
if (value === 'true') {
|
||||
store.set(isOnboardingV2State.atom, true);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const queryParams = new URLSearchParams(window.location.search);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { AppPath } from 'twenty-shared/types';
|
||||
|
||||
export const ONBOARDING_PATHS = [
|
||||
AppPath.WorkspaceActivation,
|
||||
AppPath.WorkspaceActivationV2,
|
||||
AppPath.CreateProfile,
|
||||
AppPath.SyncEmails,
|
||||
AppPath.InviteTeam,
|
||||
|
||||
+98
@@ -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 (
|
||||
<>
|
||||
<StyledLogo src="/images/integrations/twenty-logo.svg" alt="" />
|
||||
<StyledStepsContainer>
|
||||
{messages.map((message, index) => {
|
||||
const stepOffset = index - messageIndex;
|
||||
const isVisible = stepOffset >= 0 && stepOffset < VISIBLE_STEP_COUNT;
|
||||
|
||||
return (
|
||||
<StyledStep
|
||||
key={message}
|
||||
initial={false}
|
||||
animate={{
|
||||
opacity: isVisible ? STEP_OPACITIES[stepOffset] : 0,
|
||||
y: stepOffset * STEP_HEIGHT_IN_PX,
|
||||
}}
|
||||
transition={
|
||||
shouldReduceMotion
|
||||
? { duration: 0 }
|
||||
: {
|
||||
duration: theme.animation.duration.normal,
|
||||
ease: 'easeInOut',
|
||||
}
|
||||
}
|
||||
>
|
||||
<SubTitle>{message}</SubTitle>
|
||||
</StyledStep>
|
||||
);
|
||||
})}
|
||||
</StyledStepsContainer>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+28
@@ -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 (
|
||||
<StyledContainer>
|
||||
<SignInUpWorkspaceActivationV2Effect
|
||||
messageIndex={messageIndex}
|
||||
setMessageIndex={setMessageIndex}
|
||||
/>
|
||||
<SignInUpWorkspaceActivationV2 messageIndex={messageIndex} />
|
||||
</StyledContainer>
|
||||
);
|
||||
};
|
||||
+38
@@ -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 (
|
||||
<ModalContent isVerticallyCentered isHorizontallyCentered>
|
||||
<SignInUpWorkspaceActivationV2Effect
|
||||
messageIndex={messageIndex}
|
||||
setMessageIndex={setMessageIndex}
|
||||
/>
|
||||
<SignInUpWorkspaceActivationV2 messageIndex={messageIndex} />
|
||||
</ModalContent>
|
||||
);
|
||||
};
|
||||
|
||||
const meta: Meta<typeof SignInUpWorkspaceActivationV2> = {
|
||||
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<ModalContent isVerticallyCentered isHorizontallyCentered>\n <SignInUpWorkspaceActivationV2 />\n</ModalContent>\n```\n',
|
||||
},
|
||||
},
|
||||
render: RenderWithModalContent,
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof SignInUpWorkspaceActivationV2>;
|
||||
|
||||
export const Default: Story = {};
|
||||
+31
@@ -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<SetStateAction<number>>;
|
||||
};
|
||||
|
||||
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 <></>;
|
||||
};
|
||||
+5
@@ -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<File | undefined>(undefined);
|
||||
const [logoPreviewUrl, setLogoPreviewUrl] = useState<string | undefined>(
|
||||
@@ -108,6 +112,7 @@ export const SignInUpWorkspaceCreationForm = () => {
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
setIsOnboardingV2(false);
|
||||
try {
|
||||
await createWorkspace({
|
||||
displayName: workspaceName.trim(),
|
||||
|
||||
+19
-14
@@ -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<File | undefined>(undefined);
|
||||
const [logoPreviewUrl, setLogoPreviewUrl] = useState<string | undefined>(
|
||||
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 ? <Loader /> : null)}
|
||||
fullWidth
|
||||
/>
|
||||
</StyledButtonContainer>
|
||||
|
||||
+43
-2
@@ -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<boolean>((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();
|
||||
|
||||
|
||||
+8
@@ -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...`,
|
||||
];
|
||||
@@ -38,7 +38,7 @@ export const useSignUpInNewWorkspace = () => {
|
||||
displayName?: string;
|
||||
subdomain?: string;
|
||||
logo?: File;
|
||||
} = {}) => {
|
||||
} = {}): Promise<boolean> => {
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
|
||||
|
||||
export const isCreatingWorkspaceState = createAtomState<boolean>({
|
||||
key: 'isCreatingWorkspaceState',
|
||||
defaultValue: false,
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
|
||||
|
||||
export const isOnboardingV2State = createAtomState<boolean>({
|
||||
key: 'isOnboardingV2State',
|
||||
defaultValue: false,
|
||||
});
|
||||
+3
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 = () => {
|
||||
</ModalContent>
|
||||
) : signInUpStep === SignInUpStep.WorkspaceCreation ? (
|
||||
<>
|
||||
<SignInUpV2Header onBack={onClickOnLogo} />
|
||||
{!isCreatingWorkspace ? (
|
||||
<SignInUpV2Header onBack={onClickOnLogo} />
|
||||
) : null}
|
||||
<ModalContent isVerticallyCentered isHorizontallyCentered>
|
||||
{signInUpForm}
|
||||
</ModalContent>
|
||||
|
||||
@@ -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 (
|
||||
<StyledContainer>
|
||||
<SignInUpWorkspaceCreationLoader />
|
||||
</StyledContainer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledContainer>
|
||||
<Logo
|
||||
primaryLogo={
|
||||
isNonEmptyString(currentWorkspace?.logo)
|
||||
? currentWorkspace?.logo
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<Title>
|
||||
<Trans>Workspace creation failed</Trans>
|
||||
</Title>
|
||||
<SubTitle>
|
||||
<Trans>
|
||||
Something went wrong while creating your workspace. Please try again.
|
||||
</Trans>
|
||||
</SubTitle>
|
||||
<StyledButtonContainer>
|
||||
<MainButton
|
||||
title={t`Retry`}
|
||||
onClick={() => {
|
||||
void activate();
|
||||
}}
|
||||
disabled={isActivating}
|
||||
fullWidth
|
||||
/>
|
||||
</StyledButtonContainer>
|
||||
</StyledContainer>
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
@@ -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`;
|
||||
|
||||
@@ -9,6 +9,7 @@ export enum AppPath {
|
||||
|
||||
// Onboarding
|
||||
WorkspaceActivation = '/workspace-activation',
|
||||
WorkspaceActivationV2 = '/workspace-activation-v2',
|
||||
CreateProfile = '/create/profile',
|
||||
SyncEmails = '/sync/emails',
|
||||
InviteTeam = '/invite-team',
|
||||
|
||||
Reference in New Issue
Block a user