Files
twenty/packages/twenty-front/src/hooks/usePageChangeEffectNavigateLocation.ts
T
Raphaël Bosi a88d1f4442 Introduce standalone page (#19675)
Add support for standalone pages: a new `PageLayout` type
(`STANDALONE_PAGE`) that can be rendered independently at
`/page/:pageLayoutId`, not tied to any record or object context.

- New `STANDALONE_PAGE` page layout type
- New `PAGE_LAYOUT` navigation menu item type: adds a `pageLayoutId`
foreign key to `NavigationMenuItemEntity`, allowing sidebar items to
link directly to standalone pages
- New `GLOBAL_OBJECT_CONTEXT` command menu availability type: separates
object-context-dependent commands (Create Record, Import, Export, See
Deleted, Create View, Hide Deleted) from truly global ones, so
standalone pages only show relevant commands
- Frontend routing & rendering: adds a `/page/:pageLayoutId` route with
its own page component, header, and command menu
- Widget rendering refactor
- Instance commands: two fast 1.22 migrations: `pageLayoutId` column +
`STANDALONE_PAGE` enum, and `GLOBAL_OBJECT_CONTEXT` availability type
enum
- Workspace command: backfills existing command menu items from `GLOBAL`
to `GLOBAL_OBJECT_CONTEXT` where appropriate
- Dev seeds: adds a sample "Star History" standalone page with an iframe
widget for local development
2026-04-14 15:52:45 +00:00

193 lines
6.1 KiB
TypeScript

import { verifyEmailRedirectPathState } from '@/app/states/verifyEmailRedirectPathState';
import { ONBOARDING_PATHS } from '@/auth/constants/OnboardingPaths';
import { ONGOING_USER_CREATION_PATHS } from '@/auth/constants/OngoingUserCreationPaths';
import { useHasAccessTokenPair } from '@/auth/hooks/useHasAccessTokenPair';
import { returnToPathState } from '@/auth/states/returnToPathState';
import { calendarBookingPageIdState } from '@/client-config/states/calendarBookingPageIdState';
import { useIsCurrentLocationOnAWorkspace } from '@/domain-manager/hooks/useIsCurrentLocationOnAWorkspace';
import { useDefaultHomePagePath } from '@/navigation/hooks/useDefaultHomePagePath';
import { objectMetadataItemsSelector } from '@/object-metadata/states/objectMetadataItemsSelector';
import { useOnboardingStatus } from '@/onboarding/hooks/useOnboardingStatus';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useIsWorkspaceActivationStatusEqualsTo } from '@/workspace/hooks/useIsWorkspaceActivationStatusEqualsTo';
import { isValidReturnToPath } from '@/auth/utils/isValidReturnToPath';
import { useQuery } from '@apollo/client/react';
import { isNonEmptyString } from '@sniptt/guards';
import { useLocation, useParams } from 'react-router-dom';
import { AppPath, SettingsPath } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { WorkspaceActivationStatus } from 'twenty-shared/workspace';
import {
FindOnePageLayoutTypeDocument,
OnboardingStatus,
PageLayoutType,
} from '~/generated-metadata/graphql';
import { isMatchingLocation } from '~/utils/isMatchingLocation';
const readReturnToPathFromUrlSearchParams = (): string | null => {
const value = new URLSearchParams(window.location.search).get('returnToPath');
return value && isValidReturnToPath(value) ? value : null;
};
export const usePageChangeEffectNavigateLocation = () => {
const hasAccessTokenPair = useHasAccessTokenPair();
const { isOnAWorkspace } = useIsCurrentLocationOnAWorkspace();
const onboardingStatus = useOnboardingStatus();
const isWorkspaceSuspended = useIsWorkspaceActivationStatusEqualsTo(
WorkspaceActivationStatus.SUSPENDED,
);
const { defaultHomePagePath } = useDefaultHomePagePath();
const location = useLocation();
const calendarBookingPageId = useAtomStateValue(calendarBookingPageIdState);
const someMatchingLocationOf = (appPaths: AppPath[]): boolean =>
appPaths.some((appPath) => isMatchingLocation(location, appPath));
const params = useParams();
const objectNamePlural = params.objectNamePlural ?? '';
const objectMetadataItems = useAtomStateValue(objectMetadataItemsSelector);
const objectMetadataItem = objectMetadataItems?.find(
(objectMetadataItem) => objectMetadataItem.namePlural === objectNamePlural,
);
const pageLayoutId = params.pageLayoutId;
const isOnPageLayoutPage = isMatchingLocation(
location,
AppPath.PageLayoutPage,
);
const { data: pageLayoutData, loading: isPageLayoutLoading } = useQuery(
FindOnePageLayoutTypeDocument,
{
variables: { id: pageLayoutId ?? '' },
skip: !isOnPageLayoutPage || !isDefined(pageLayoutId),
},
);
const verifyEmailRedirectPath = useAtomStateValue(
verifyEmailRedirectPathState,
);
const returnToPath = useAtomStateValue(returnToPathState);
const resolvedReturnToPath = isNonEmptyString(returnToPath)
? returnToPath
: readReturnToPathFromUrlSearchParams();
if (
(!hasAccessTokenPair || (hasAccessTokenPair && !isOnAWorkspace)) &&
!someMatchingLocationOf([
...ONGOING_USER_CREATION_PATHS,
AppPath.ResetPassword,
])
) {
return AppPath.SignInUp;
}
if (
onboardingStatus === OnboardingStatus.PLAN_REQUIRED &&
!someMatchingLocationOf([
AppPath.PlanRequired,
AppPath.PlanRequiredSuccess,
AppPath.BookCall,
AppPath.BookCallDecision,
])
) {
if (
isMatchingLocation(location, AppPath.VerifyEmail) &&
isDefined(verifyEmailRedirectPath)
) {
return verifyEmailRedirectPath;
}
return AppPath.PlanRequired;
}
if (isWorkspaceSuspended) {
if (!isMatchingLocation(location, AppPath.SettingsCatchAll)) {
return `${AppPath.SettingsCatchAll.replace('/*', '')}/${
SettingsPath.Billing
}`;
}
return;
}
if (
onboardingStatus === OnboardingStatus.WORKSPACE_ACTIVATION &&
!someMatchingLocationOf([
AppPath.CreateWorkspace,
AppPath.BookCallDecision,
AppPath.BookCall,
])
) {
return AppPath.CreateWorkspace;
}
if (
onboardingStatus === OnboardingStatus.PROFILE_CREATION &&
!isMatchingLocation(location, AppPath.CreateProfile)
) {
return AppPath.CreateProfile;
}
if (
onboardingStatus === OnboardingStatus.SYNC_EMAIL &&
!isMatchingLocation(location, AppPath.SyncEmails)
) {
return AppPath.SyncEmails;
}
if (
onboardingStatus === OnboardingStatus.INVITE_TEAM &&
!isMatchingLocation(location, AppPath.InviteTeam)
) {
return AppPath.InviteTeam;
}
if (
onboardingStatus === OnboardingStatus.BOOK_ONBOARDING &&
!someMatchingLocationOf([AppPath.BookCallDecision, AppPath.BookCall])
) {
if (!isDefined(calendarBookingPageId)) {
return defaultHomePagePath;
}
return AppPath.BookCallDecision;
}
if (
onboardingStatus === OnboardingStatus.COMPLETED &&
someMatchingLocationOf([
...ONBOARDING_PATHS,
...ONGOING_USER_CREATION_PATHS,
]) &&
!isMatchingLocation(location, AppPath.ResetPassword) &&
hasAccessTokenPair &&
isOnAWorkspace
) {
return resolvedReturnToPath ?? defaultHomePagePath;
}
if (isMatchingLocation(location, AppPath.Index) && hasAccessTokenPair) {
return resolvedReturnToPath ?? defaultHomePagePath;
}
if (
isMatchingLocation(location, AppPath.RecordIndexPage) &&
!isDefined(objectMetadataItem)
) {
return AppPath.NotFound;
}
if (
isOnPageLayoutPage &&
isDefined(pageLayoutId) &&
!isPageLayoutLoading &&
(!isDefined(pageLayoutData?.getPageLayout) ||
pageLayoutData.getPageLayout.type !== PageLayoutType.STANDALONE_PAGE)
) {
return AppPath.NotFound;
}
return;
};