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
This commit is contained in:
Raphaël Bosi
2026-04-14 17:52:45 +02:00
committed by GitHub
parent 43249d80e8
commit a88d1f4442
122 changed files with 2250 additions and 836 deletions
@@ -8,6 +8,7 @@ import { pageLayoutPersistedComponentState } from '@/page-layout/states/pageLayo
import { type PageLayout } from '@/page-layout/types/PageLayout';
import { convertPageLayoutToTabLayouts } from '@/page-layout/utils/convertPageLayoutToTabLayouts';
import { isPageLayoutEmpty } from '@/page-layout/utils/isPageLayoutEmpty';
import { useLayoutRenderingContext } from '@/ui/layout/contexts/LayoutRenderingContext';
import { useAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentState';
import { useAtomComponentStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateCallbackState';
import { useFeatureFlagsMap } from '@/workspace/hooks/useFeatureFlagsMap';
@@ -21,24 +22,16 @@ type PageLayoutInitializationQueryEffectProps = {
pageLayoutId: string;
};
export const PageLayoutInitializationQueryEffect = ({
const PageLayoutInitializationEffect = ({
pageLayoutId,
}: PageLayoutInitializationQueryEffectProps) => {
pageLayout,
}: {
pageLayoutId: string;
pageLayout: PageLayout | undefined;
}) => {
const [pageLayoutIsInitialized, setPageLayoutIsInitialized] =
useAtomComponentState(pageLayoutIsInitializedComponentState);
const featureFlags = useFeatureFlagsMap();
const isRecordPageLayoutEditingEnabled =
featureFlags[FeatureFlagKey.IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED];
const basePageLayout = useBasePageLayout(pageLayoutId);
const pageLayoutWithRelationWidgets =
usePageLayoutWithRelationWidgets(basePageLayout);
const pageLayout = isRecordPageLayoutEditingEnabled
? basePageLayout
: pageLayoutWithRelationWidgets;
const { setIsPageLayoutInEditMode } =
useSetIsPageLayoutInEditMode(pageLayoutId);
@@ -55,8 +48,6 @@ export const PageLayoutInitializationQueryEffect = ({
const initializePageLayout = useCallback(
(layout: PageLayout) => {
const isRecordPageLayout = layout.type === PageLayoutType.RECORD_PAGE;
const currentPersisted = store.get(
pageLayoutPersistedComponentCallbackState,
);
@@ -78,7 +69,9 @@ export const PageLayoutInitializationQueryEffect = ({
const tabLayouts = convertPageLayoutToTabLayouts(layout);
store.set(pageLayoutCurrentLayoutsComponentCallbackState, tabLayouts);
if (!isRecordPageLayout) {
const isDashboardLayout = layout.type === PageLayoutType.DASHBOARD;
if (isDashboardLayout) {
const shouldEnterDashboardEditMode = isPageLayoutEmpty(layout);
setIsPageLayoutInEditMode(shouldEnterDashboardEditMode);
}
@@ -106,3 +99,53 @@ export const PageLayoutInitializationQueryEffect = ({
return null;
};
const PageLayoutInitializationWithRelationWidgets = ({
pageLayoutId,
basePageLayout,
}: {
pageLayoutId: string;
basePageLayout: PageLayout | undefined;
}) => {
const pageLayout = usePageLayoutWithRelationWidgets(basePageLayout);
return (
<PageLayoutInitializationEffect
pageLayoutId={pageLayoutId}
pageLayout={pageLayout}
/>
);
};
// oxlint-disable-next-line twenty/effect-components
export const PageLayoutInitializationQueryEffect = ({
pageLayoutId,
}: PageLayoutInitializationQueryEffectProps) => {
const { layoutType } = useLayoutRenderingContext();
const featureFlags = useFeatureFlagsMap();
const isRecordPageLayoutEditingEnabled =
featureFlags[FeatureFlagKey.IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED];
const basePageLayout = useBasePageLayout(pageLayoutId);
const needsRelationWidgets =
layoutType === PageLayoutType.RECORD_PAGE &&
!isRecordPageLayoutEditingEnabled;
if (needsRelationWidgets) {
return (
<PageLayoutInitializationWithRelationWidgets
pageLayoutId={pageLayoutId}
basePageLayout={basePageLayout}
/>
);
}
return (
<PageLayoutInitializationEffect
pageLayoutId={pageLayoutId}
pageLayout={basePageLayout}
/>
);
};