Files
twenty/packages/twenty-front/src/pages/onboarding/CreateWorkspace.tsx
T
Charles Bochet 660536d6bb Fix onboarding flow: workspace creation modal and invite team skip (#18577)
## Summary

- **Fix create-profile modal not showing after workspace creation**:
After activating a workspace, `CreateWorkspace.onSubmit` called
`refreshObjectMetadataItems()` which only updated the
`objectMetadataItemsState` atom but never marked the metadata store as
ready (`metadataStoreState` stayed at `'empty'`). Since `MetadataGater`
excludes `CreateWorkspace` but not `CreateProfile` from its loading
check, navigating to `/create/profile` triggered the skeleton loader
instead of the modal. The fix adds the full metadata pipeline after
refresh — `updateDraft('objectMetadataItems')` + `applyChanges()` for
objects, and `fetchAndLoadIndexViews()` for views — so
`isAppMetadataReady` is `true` before navigation.

- **Fix invite-team "Skip" not persisting to server**: Clicking "Skip"
on the invite-team page called `setNextOnboardingStatus()` which only
updated the local Jotai atom. The early return for empty emails bypassed
`sendInvitation`, so the server never cleared the
`ONBOARDING_INVITE_TEAM_PENDING` user var. On page refresh,
`GetCurrentUser` returned `INVITE_TEAM` and the user was stuck. The fix
removes the early return so `sendInvitation({ emails: [] })` always runs
— the server handles empty arrays fine and clears the pending flag.
2026-03-12 13:15:05 +01:00

263 lines
8.4 KiB
TypeScript

import { styled } from '@linaria/react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useCallback, useState } from 'react';
import { Controller, type SubmitHandler, useForm } from 'react-hook-form';
import { Key } from 'ts-key-enum';
import { z } from 'zod';
import { Logo } from '@/auth/components/Logo';
import { SubTitle } from '@/auth/components/SubTitle';
import { Title } from '@/auth/components/Title';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { useFetchAndLoadIndexViews } from '@/metadata-store/hooks/useFetchAndLoadIndexViews';
import { useMetadataStore } from '@/metadata-store/hooks/useMetadataStore';
import { useRefreshObjectMetadataItems } from '@/object-metadata/hooks/useRefreshObjectMetadataItems';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { useSetNextOnboardingStatus } from '@/onboarding/hooks/useSetNextOnboardingStatus';
import { WorkspaceLogoUploader } from '@/settings/workspace/components/WorkspaceLogoUploader';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { TextInput } from '@/ui/input/components/TextInput';
import { ModalContent } from 'twenty-ui/layout';
import { useLoadCurrentUser } from '@/users/hooks/useLoadCurrentUser';
import { ApolloError } from '@apollo/client';
import { Trans, useLingui } from '@lingui/react/macro';
import { isNonEmptyString } from '@sniptt/guards';
import { useStore } from 'jotai';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { isDefined } from 'twenty-shared/utils';
import { H2Title } from 'twenty-ui/display';
import { Loader } from 'twenty-ui/feedback';
import { MainButton } from 'twenty-ui/input';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { useActivateWorkspaceMutation } from '~/generated-metadata/graphql';
const StyledContentContainer = styled.div`
width: 100%;
`;
const StyledSectionContainer = styled.div`
margin-top: ${themeCssVariables.spacing[8]};
`;
const StyledButtonContainer = styled.div`
margin-top: ${themeCssVariables.spacing[8]};
width: 200px;
`;
const StyledLoaderContainer = styled.div`
align-items: center;
display: flex;
justify-content: center;
margin-bottom: ${themeCssVariables.spacing[8]};
margin-top: ${themeCssVariables.spacing[8]};
width: 100%;
`;
enum PendingCreationLoaderStep {
None = 'none',
Step1 = 'step-1',
Step2 = 'step-2',
Step3 = 'step-3',
}
const StyledPendingCreationLoader = styled.div`
align-items: center;
display: flex;
justify-content: center;
width: 100%;
`;
export const CreateWorkspace = () => {
const { t } = useLingui();
const { enqueueErrorSnackBar } = useSnackBar();
const setNextOnboardingStatus = useSetNextOnboardingStatus();
const { refreshObjectMetadataItems } = useRefreshObjectMetadataItems();
const { updateDraft, applyChanges } = useMetadataStore();
const { fetchAndLoadIndexViews } = useFetchAndLoadIndexViews();
const store = useStore();
const { loadCurrentUser } = useLoadCurrentUser();
const [activateWorkspace] = useActivateWorkspaceMutation();
const [pendingCreationLoaderStep, setPendingCreationLoaderStep] = useState(
PendingCreationLoaderStep.None,
);
const currentWorkspace = useAtomStateValue(currentWorkspaceState);
const validationSchema = z
.object({
name: z.string().min(1, { message: t`Name can not be empty` }),
})
.required();
type Form = z.infer<typeof validationSchema>;
// Form
const {
control,
handleSubmit,
formState: { isValid, isSubmitting },
} = useForm<Form>({
mode: 'onChange',
defaultValues: {
name: '',
},
resolver: zodResolver(validationSchema),
});
const onSubmit: SubmitHandler<Form> = useCallback(
async (data) => {
try {
setTimeout(() => {
setPendingCreationLoaderStep(PendingCreationLoaderStep.Step1);
}, 500);
setTimeout(() => {
setPendingCreationLoaderStep(PendingCreationLoaderStep.Step2);
}, 2000);
setTimeout(() => {
setPendingCreationLoaderStep(PendingCreationLoaderStep.Step3);
}, 5000);
const result = await activateWorkspace({
variables: {
input: {
displayName: data.name,
},
},
});
if (isDefined(result.errors)) {
throw result.errors ?? new Error(t`Unknown error`);
}
await refreshObjectMetadataItems();
const loadedObjects = store.get(objectMetadataItemsState.atom);
updateDraft('objectMetadataItems', loadedObjects);
applyChanges();
await fetchAndLoadIndexViews();
await loadCurrentUser();
setNextOnboardingStatus();
} catch (error: any) {
setPendingCreationLoaderStep(PendingCreationLoaderStep.None);
enqueueErrorSnackBar({
apolloError: error instanceof ApolloError ? error : undefined,
});
}
},
[
activateWorkspace,
enqueueErrorSnackBar,
loadCurrentUser,
refreshObjectMetadataItems,
updateDraft,
applyChanges,
store,
fetchAndLoadIndexViews,
setNextOnboardingStatus,
t,
],
);
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === Key.Enter) {
event.preventDefault();
handleSubmit(onSubmit)();
}
};
return (
<ModalContent isVerticallyCentered isHorizontallyCentered>
{pendingCreationLoaderStep !== PendingCreationLoaderStep.None && (
<>
<Logo
primaryLogo={
isNonEmptyString(currentWorkspace?.logo)
? currentWorkspace?.logo
: undefined
}
/>
<Title>
<Trans>Creating your workspace</Trans>
</Title>
<StyledPendingCreationLoader>
{pendingCreationLoaderStep === PendingCreationLoaderStep.Step1 && (
<SubTitle>
<Trans>Setting up your database...</Trans>
</SubTitle>
)}
{pendingCreationLoaderStep === PendingCreationLoaderStep.Step2 && (
<SubTitle>
<Trans>Creating your data model...</Trans>
</SubTitle>
)}
{pendingCreationLoaderStep === PendingCreationLoaderStep.Step3 && (
<SubTitle>
<Trans>Prefilling your workspace data...</Trans>
</SubTitle>
)}
</StyledPendingCreationLoader>
<StyledLoaderContainer>
<Loader color="gray" />
</StyledLoaderContainer>
</>
)}
{pendingCreationLoaderStep === PendingCreationLoaderStep.None && (
<>
<Title noMarginTop>
<Trans>Create your workspace</Trans>
</Title>
<SubTitle>
<Trans>
A shared environment where you will be able to manage your
customer relations with your team.
</Trans>
</SubTitle>
<StyledContentContainer>
<StyledSectionContainer>
<H2Title title={t`Workspace logo`} />
<WorkspaceLogoUploader />
</StyledSectionContainer>
<StyledSectionContainer>
<H2Title
title={t`Workspace name`}
description={t`The name of your organization`}
/>
<Controller
name="name"
control={control}
render={({
field: { onChange, onBlur, value },
fieldState: { error },
}) => (
<TextInput
autoFocus
value={value}
placeholder={t`Apple`}
onBlur={onBlur}
onChange={onChange}
error={error?.message}
onKeyDown={handleKeyDown}
fullWidth
/>
)}
/>
</StyledSectionContainer>
</StyledContentContainer>
<StyledButtonContainer>
<MainButton
title={t`Continue`}
onClick={handleSubmit(onSubmit)}
disabled={!isValid || isSubmitting}
Icon={() => isSubmitting && <Loader />}
fullWidth
/>
</StyledButtonContainer>
</>
)}
</ModalContent>
);
};