6a1b28bc12
## What & why A single, consistent **workspace-creation step** for both multi-workspace and single-workspace self-host — collecting **name + logo** (and the **subdomain** in multi-workspace) — which **removes the duplicate name/logo prompt** that previously reappeared on the workspace subdomain (reported after #21641). ## Changes **One creation form for both modes** - With 0 workspaces, both multi-workspace and single-workspace route to the shared `SignInUpWorkspaceCreationForm`; `SignInUp` renders it for the `WorkspaceCreation` step regardless of domain/scope. - The subdomain field shows only in multi-workspace; single-workspace keeps its fixed address. **Logo on the creation step** - New scoped `uploadNewWorkspaceLogo(workspaceId, file)` mutation: the creator sets a logo on their just-created `PENDING_CREATION` workspace via the workspace-agnostic token (membership enforced — only the creator is a member at that point), reusing `uploadWorkspacePicture`. Upload size is capped via `settings.storage.maxFileSize` (also applied to the existing logo / profile-picture uploads). - The picked file is held locally (object-URL preview, revoked on unmount) and uploaded right after creation (non-fatal on failure). **Onboarding step → pure activation loader** - The old "Create your workspace" form (name + logo) is removed. The onboarding step now activates the pending workspace on mount and shows the loader, with a **Retry** action on failure. ## Testing - typecheck (front + server) ✅; oxlint + oxfmt clean on changed files ✅ - Unit tests: `auth.resolver.spec`, `useWorkspaceSubdomainField`, `SignInUpWorkspaceCreationForm` (multi + single-workspace), `useAuth` ✅ - Metadata GraphQL + `twenty-client-sdk` schema regenerated. Follow-up to #21641. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01Xw37hR5seiCyWnppG9z4op --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
429 lines
15 KiB
TypeScript
429 lines
15 KiB
TypeScript
import {
|
|
setSessionId,
|
|
useEventTracker,
|
|
} from '@/analytics/hooks/useEventTracker';
|
|
import { useExecuteTasksOnAnyLocationChange } from '@/app/hooks/useExecuteTasksOnAnyLocationChange';
|
|
import { isAppEffectRedirectEnabledState } from '@/app/states/isAppEffectRedirectEnabledState';
|
|
import { ONBOARDING_PATHS } from '@/auth/constants/OnboardingPaths';
|
|
import { ONGOING_USER_CREATION_PATHS } from '@/auth/constants/OngoingUserCreationPaths';
|
|
import { useReturnToPath } from '@/auth/hooks/useReturnToPath';
|
|
import { useRequestFreshCaptchaToken } from '@/captcha/hooks/useRequestFreshCaptchaToken';
|
|
import { isCaptchaScriptLoadedState } from '@/captcha/states/isCaptchaScriptLoadedState';
|
|
import { isCaptchaRequiredForPath } from '@/captcha/utils/isCaptchaRequiredForPath';
|
|
import { useSidePanelMenu } from '@/side-panel/hooks/useSidePanelMenu';
|
|
import { isSidePanelOpenedState } from '@/side-panel/states/isSidePanelOpenedState';
|
|
import { sidePanelPageState } from '@/side-panel/states/sidePanelPageState';
|
|
import { MAIN_CONTEXT_STORE_INSTANCE_ID } from '@/context-store/constants/MainContextStoreInstanceId';
|
|
import { contextStoreCurrentViewIdComponentState } from '@/context-store/states/contextStoreCurrentViewIdComponentState';
|
|
import { contextStoreCurrentViewTypeComponentState } from '@/context-store/states/contextStoreCurrentViewTypeComponentState';
|
|
import { ContextStoreViewType } from '@/context-store/types/ContextStoreViewType';
|
|
import { CoreObjectNamePlural } from '@/object-metadata/types/CoreObjectNamePlural';
|
|
import { useActiveRecordBoardCard } from '@/object-record/record-board/hooks/useActiveRecordBoardCard';
|
|
import { useFocusedRecordBoardCard } from '@/object-record/record-board/hooks/useFocusedRecordBoardCard';
|
|
import { useResetRecordBoardSelection } from '@/object-record/record-board/hooks/useResetRecordBoardSelection';
|
|
import { useResetFocusStackToRecordIndex } from '@/object-record/record-index/hooks/useResetFocusStackToRecordIndex';
|
|
import { useResetTableRowSelection } from '@/object-record/record-table/hooks/internal/useResetTableRowSelection';
|
|
import { useActiveRecordTableRow } from '@/object-record/record-table/hooks/useActiveRecordTableRow';
|
|
import { useFocusedRecordTableRow } from '@/object-record/record-table/hooks/useFocusedRecordTableRow';
|
|
import { useOpenNewRecordTitleCell } from '@/object-record/record-title-cell/hooks/useOpenNewRecordTitleCell';
|
|
import { getRecordIndexIdFromObjectNamePluralAndViewId } from '@/object-record/utils/getRecordIndexIdFromObjectNamePluralAndViewId';
|
|
import { PageFocusId } from '@/types/PageFocusId';
|
|
import { useResetFocusStackToFocusItem } from '@/ui/utilities/focus/hooks/useResetFocusStackToFocusItem';
|
|
import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType';
|
|
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
|
|
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
|
import { currentPageLayoutIdState } from '@/page-layout/states/currentPageLayoutIdState';
|
|
import { useStore } from 'jotai';
|
|
import { useCallback, useEffect, useState } from 'react';
|
|
import {
|
|
matchPath,
|
|
useLocation,
|
|
useNavigate,
|
|
useParams,
|
|
} from 'react-router-dom';
|
|
import { AppBasePath, AppPath, SidePanelPages } from 'twenty-shared/types';
|
|
import { isDefined } from 'twenty-shared/utils';
|
|
import { AnalyticsType } from '~/generated-metadata/graphql';
|
|
import { usePageChangeEffectNavigateLocation } from '~/hooks/usePageChangeEffectNavigateLocation';
|
|
import { getPageLayoutIdForLocation } from '~/modules/app/utils/getPageLayoutIdForLocation';
|
|
import { useInitializeQueryParamState } from '~/modules/app/hooks/useInitializeQueryParamState';
|
|
import { isMatchingLocation } from '~/utils/isMatchingLocation';
|
|
import { getPageTitleFromPath } from '~/utils/title-utils';
|
|
|
|
const AUTH_AND_ONBOARDING_PATHS = [
|
|
...ONGOING_USER_CREATION_PATHS,
|
|
...ONBOARDING_PATHS,
|
|
AppPath.ResetPassword,
|
|
];
|
|
|
|
// TODO: break down into smaller functions and / or hooks
|
|
// - moved usePageChangeEffectNavigateLocation into dedicated hook
|
|
export const PageChangeEffect = () => {
|
|
const store = useStore();
|
|
const navigate = useNavigate();
|
|
|
|
const [previousLocation, setPreviousLocation] = useState('');
|
|
|
|
const location = useLocation();
|
|
|
|
const pageChangeEffectNavigateLocation =
|
|
usePageChangeEffectNavigateLocation();
|
|
|
|
const eventTracker = useEventTracker();
|
|
|
|
const { initializeQueryParamState } = useInitializeQueryParamState();
|
|
|
|
//TODO: refactor useResetTableRowSelection hook to not throw when the argument `recordTableId` is an empty string
|
|
// - replace CoreObjectNamePlural.Person
|
|
const objectNamePlural =
|
|
useParams().objectNamePlural ?? CoreObjectNamePlural.Person;
|
|
|
|
const contextStoreCurrentViewId = useAtomComponentStateValue(
|
|
contextStoreCurrentViewIdComponentState,
|
|
MAIN_CONTEXT_STORE_INSTANCE_ID,
|
|
);
|
|
|
|
const contextStoreCurrentViewType = useAtomComponentStateValue(
|
|
contextStoreCurrentViewTypeComponentState,
|
|
MAIN_CONTEXT_STORE_INSTANCE_ID,
|
|
);
|
|
|
|
const recordIndexId = getRecordIndexIdFromObjectNamePluralAndViewId(
|
|
objectNamePlural,
|
|
contextStoreCurrentViewId || '',
|
|
);
|
|
|
|
const { resetTableRowSelection } = useResetTableRowSelection(recordIndexId);
|
|
const { unfocusRecordTableRow } = useFocusedRecordTableRow(recordIndexId);
|
|
const { deactivateRecordTableRow } = useActiveRecordTableRow(recordIndexId);
|
|
|
|
const { resetRecordBoardSelection } =
|
|
useResetRecordBoardSelection(recordIndexId);
|
|
const { deactivateBoardCard } = useActiveRecordBoardCard(recordIndexId);
|
|
const { unfocusBoardCard } = useFocusedRecordBoardCard(recordIndexId);
|
|
|
|
const { executeTasksOnAnyLocationChange } =
|
|
useExecuteTasksOnAnyLocationChange();
|
|
|
|
const isAppEffectRedirectEnabled = useAtomStateValue(
|
|
isAppEffectRedirectEnabledState,
|
|
);
|
|
|
|
const { closeSidePanelMenu } = useSidePanelMenu();
|
|
|
|
const { saveReturnToPath, getReturnToPath, clearReturnToPath } =
|
|
useReturnToPath();
|
|
|
|
const isOnAuthOrOnboardingPage = AUTH_AND_ONBOARDING_PATHS.some((appPath) =>
|
|
isMatchingLocation(location, appPath),
|
|
);
|
|
|
|
const closeSidePanelUnlessNotRelevant = useCallback(() => {
|
|
const currentPage = store.get(sidePanelPageState.atom);
|
|
|
|
if (currentPage === SidePanelPages.NavigationMenuItemEdit) {
|
|
return;
|
|
}
|
|
|
|
const sidePanelIsAiChat = currentPage === SidePanelPages.AskAI;
|
|
|
|
if (sidePanelIsAiChat) {
|
|
return;
|
|
}
|
|
|
|
closeSidePanelMenu();
|
|
}, [closeSidePanelMenu, store]);
|
|
|
|
const { resetFocusStackToFocusItem } = useResetFocusStackToFocusItem();
|
|
|
|
const { resetFocusStackToRecordIndex } = useResetFocusStackToRecordIndex();
|
|
|
|
const { openNewRecordTitleCell } = useOpenNewRecordTitleCell();
|
|
|
|
useEffect(() => {
|
|
closeSidePanelUnlessNotRelevant();
|
|
}, [location.pathname, closeSidePanelUnlessNotRelevant]);
|
|
|
|
useEffect(() => {
|
|
if (!previousLocation || previousLocation !== location.pathname) {
|
|
setPreviousLocation(location.pathname);
|
|
executeTasksOnAnyLocationChange();
|
|
|
|
const newPageLayoutId = getPageLayoutIdForLocation({
|
|
location,
|
|
store,
|
|
});
|
|
|
|
store.set(currentPageLayoutIdState.atom, newPageLayoutId);
|
|
}
|
|
}, [location, previousLocation, executeTasksOnAnyLocationChange, store]);
|
|
|
|
useEffect(() => {
|
|
initializeQueryParamState();
|
|
|
|
if (
|
|
isDefined(pageChangeEffectNavigateLocation) &&
|
|
isAppEffectRedirectEnabled
|
|
) {
|
|
if (
|
|
pageChangeEffectNavigateLocation === AppPath.SignInUp &&
|
|
!isOnAuthOrOnboardingPage
|
|
) {
|
|
saveReturnToPath(
|
|
`${window.location.pathname}${window.location.search}${window.location.hash}`,
|
|
);
|
|
}
|
|
|
|
const consumedReturnToPath =
|
|
getReturnToPath() === pageChangeEffectNavigateLocation;
|
|
|
|
navigate(pageChangeEffectNavigateLocation);
|
|
|
|
if (consumedReturnToPath) {
|
|
clearReturnToPath();
|
|
}
|
|
}
|
|
}, [
|
|
navigate,
|
|
pageChangeEffectNavigateLocation,
|
|
initializeQueryParamState,
|
|
isAppEffectRedirectEnabled,
|
|
isOnAuthOrOnboardingPage,
|
|
saveReturnToPath,
|
|
getReturnToPath,
|
|
clearReturnToPath,
|
|
]);
|
|
|
|
useEffect(() => {
|
|
const isLeavingRecordIndexPage = !!matchPath(
|
|
AppPath.RecordIndexPage,
|
|
previousLocation,
|
|
);
|
|
|
|
if (isLeavingRecordIndexPage) {
|
|
if (contextStoreCurrentViewType === ContextStoreViewType.Table) {
|
|
resetTableRowSelection();
|
|
unfocusRecordTableRow();
|
|
deactivateRecordTableRow();
|
|
}
|
|
if (contextStoreCurrentViewType === ContextStoreViewType.Kanban) {
|
|
resetRecordBoardSelection();
|
|
deactivateBoardCard();
|
|
unfocusBoardCard();
|
|
}
|
|
}
|
|
|
|
if (location.pathname === previousLocation) {
|
|
return;
|
|
}
|
|
|
|
switch (true) {
|
|
case isMatchingLocation(location, AppPath.RecordIndexPage): {
|
|
resetFocusStackToRecordIndex();
|
|
break;
|
|
}
|
|
case isMatchingLocation(location, AppPath.RecordShowPage): {
|
|
const isNewRecord = location.state?.isNewRecord === true;
|
|
const isSidePanelOpen = store.get(isSidePanelOpenedState.atom);
|
|
|
|
if (!isSidePanelOpen) {
|
|
resetFocusStackToFocusItem({
|
|
focusStackItem: {
|
|
focusId: PageFocusId.RecordShowPage,
|
|
componentInstance: {
|
|
componentType: FocusComponentType.PAGE,
|
|
componentInstanceId: PageFocusId.RecordShowPage,
|
|
},
|
|
globalHotkeysConfig: {
|
|
enableGlobalHotkeysWithModifiers: true,
|
|
enableGlobalHotkeysConflictingWithKeyboard: true,
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
if (
|
|
isNewRecord &&
|
|
isDefined(location.state?.labelIdentifierFieldName)
|
|
) {
|
|
openNewRecordTitleCell({
|
|
recordId: location.state.objectRecordId,
|
|
fieldName: location.state.labelIdentifierFieldName,
|
|
});
|
|
}
|
|
break;
|
|
}
|
|
case isMatchingLocation(location, AppPath.SignInUp): {
|
|
resetFocusStackToFocusItem({
|
|
focusStackItem: {
|
|
focusId: PageFocusId.SignInUp,
|
|
componentInstance: {
|
|
componentType: FocusComponentType.PAGE,
|
|
componentInstanceId: PageFocusId.SignInUp,
|
|
},
|
|
globalHotkeysConfig: {
|
|
enableGlobalHotkeysWithModifiers: false,
|
|
enableGlobalHotkeysConflictingWithKeyboard: false,
|
|
},
|
|
},
|
|
});
|
|
break;
|
|
}
|
|
case isMatchingLocation(location, AppPath.Invite): {
|
|
resetFocusStackToFocusItem({
|
|
focusStackItem: {
|
|
focusId: PageFocusId.InviteTeam,
|
|
componentInstance: {
|
|
componentType: FocusComponentType.PAGE,
|
|
componentInstanceId: PageFocusId.InviteTeam,
|
|
},
|
|
globalHotkeysConfig: {
|
|
enableGlobalHotkeysWithModifiers: false,
|
|
enableGlobalHotkeysConflictingWithKeyboard: false,
|
|
},
|
|
},
|
|
});
|
|
break;
|
|
}
|
|
case isMatchingLocation(location, AppPath.CreateProfile): {
|
|
resetFocusStackToFocusItem({
|
|
focusStackItem: {
|
|
focusId: PageFocusId.CreateProfile,
|
|
componentInstance: {
|
|
componentType: FocusComponentType.PAGE,
|
|
componentInstanceId: PageFocusId.CreateProfile,
|
|
},
|
|
globalHotkeysConfig: {
|
|
enableGlobalHotkeysWithModifiers: false,
|
|
enableGlobalHotkeysConflictingWithKeyboard: false,
|
|
},
|
|
},
|
|
});
|
|
break;
|
|
}
|
|
case isMatchingLocation(location, AppPath.WorkspaceActivation): {
|
|
resetFocusStackToFocusItem({
|
|
focusStackItem: {
|
|
focusId: PageFocusId.WorkspaceActivation,
|
|
componentInstance: {
|
|
componentType: FocusComponentType.PAGE,
|
|
componentInstanceId: PageFocusId.WorkspaceActivation,
|
|
},
|
|
globalHotkeysConfig: {
|
|
enableGlobalHotkeysWithModifiers: false,
|
|
enableGlobalHotkeysConflictingWithKeyboard: false,
|
|
},
|
|
},
|
|
});
|
|
break;
|
|
}
|
|
case isMatchingLocation(location, AppPath.SyncEmails): {
|
|
resetFocusStackToFocusItem({
|
|
focusStackItem: {
|
|
focusId: PageFocusId.SyncEmail,
|
|
componentInstance: {
|
|
componentType: FocusComponentType.PAGE,
|
|
componentInstanceId: PageFocusId.SyncEmail,
|
|
},
|
|
globalHotkeysConfig: {
|
|
enableGlobalHotkeysWithModifiers: false,
|
|
enableGlobalHotkeysConflictingWithKeyboard: false,
|
|
},
|
|
},
|
|
});
|
|
break;
|
|
}
|
|
case isMatchingLocation(location, AppPath.InviteTeam): {
|
|
resetFocusStackToFocusItem({
|
|
focusStackItem: {
|
|
focusId: PageFocusId.InviteTeam,
|
|
componentInstance: {
|
|
componentType: FocusComponentType.PAGE,
|
|
componentInstanceId: PageFocusId.InviteTeam,
|
|
},
|
|
globalHotkeysConfig: {
|
|
enableGlobalHotkeysWithModifiers: false,
|
|
enableGlobalHotkeysConflictingWithKeyboard: false,
|
|
},
|
|
},
|
|
});
|
|
break;
|
|
}
|
|
case isMatchingLocation(location, AppPath.PlanRequired): {
|
|
resetFocusStackToFocusItem({
|
|
focusStackItem: {
|
|
focusId: PageFocusId.PlanRequired,
|
|
componentInstance: {
|
|
componentType: FocusComponentType.PAGE,
|
|
componentInstanceId: PageFocusId.PlanRequired,
|
|
},
|
|
globalHotkeysConfig: {
|
|
enableGlobalHotkeysWithModifiers: false,
|
|
enableGlobalHotkeysConflictingWithKeyboard: false,
|
|
},
|
|
},
|
|
});
|
|
break;
|
|
}
|
|
case location.pathname.startsWith(AppBasePath.Settings): {
|
|
resetFocusStackToFocusItem({
|
|
focusStackItem: {
|
|
focusId: PageFocusId.Settings,
|
|
componentInstance: {
|
|
componentType: FocusComponentType.PAGE,
|
|
componentInstanceId: PageFocusId.Settings,
|
|
},
|
|
globalHotkeysConfig: {
|
|
enableGlobalHotkeysWithModifiers: false,
|
|
enableGlobalHotkeysConflictingWithKeyboard: false,
|
|
},
|
|
},
|
|
});
|
|
break;
|
|
}
|
|
}
|
|
}, [
|
|
location,
|
|
previousLocation,
|
|
contextStoreCurrentViewType,
|
|
resetTableRowSelection,
|
|
unfocusRecordTableRow,
|
|
deactivateRecordTableRow,
|
|
resetRecordBoardSelection,
|
|
deactivateBoardCard,
|
|
unfocusBoardCard,
|
|
resetFocusStackToRecordIndex,
|
|
resetFocusStackToFocusItem,
|
|
openNewRecordTitleCell,
|
|
store,
|
|
]);
|
|
|
|
useEffect(() => {
|
|
setTimeout(() => {
|
|
setSessionId();
|
|
eventTracker(AnalyticsType['PAGEVIEW'], {
|
|
name: getPageTitleFromPath(location.pathname),
|
|
properties: {
|
|
pathname: location.pathname,
|
|
locale: navigator.language,
|
|
userAgent: window.navigator.userAgent,
|
|
href: window.location.href,
|
|
referrer: document.referrer,
|
|
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
|
},
|
|
});
|
|
}, 500);
|
|
}, [eventTracker, location.pathname]);
|
|
|
|
const { requestFreshCaptchaToken } = useRequestFreshCaptchaToken();
|
|
const isCaptchaScriptLoaded = useAtomStateValue(isCaptchaScriptLoadedState);
|
|
|
|
useEffect(() => {
|
|
if (isCaptchaScriptLoaded && isCaptchaRequiredForPath(location.pathname)) {
|
|
requestFreshCaptchaToken();
|
|
}
|
|
}, [isCaptchaScriptLoaded, location.pathname, requestFreshCaptchaToken]);
|
|
|
|
return <></>;
|
|
};
|