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
@@ -0,0 +1,140 @@
import { type PageLayoutWidget } from '@/page-layout/types/PageLayoutWidget';
import { PageLayoutWidgetForbiddenDisplay } from '@/page-layout/widgets/components/PageLayoutWidgetForbiddenDisplay';
import { PageLayoutWidgetInvalidConfigDisplay } from '@/page-layout/widgets/components/PageLayoutWidgetInvalidConfigDisplay';
import { WidgetContentRenderer } from '@/page-layout/widgets/components/WidgetContentRenderer';
import { WidgetComponentInstanceContext } from '@/page-layout/widgets/states/contexts/WidgetComponentInstanceContext';
import { type WidgetAccessDenialInfo } from '@/page-layout/widgets/types/WidgetAccessDenialInfo';
import { type WidgetAction } from '@/page-layout/widgets/types/WidgetAction';
import { type WidgetCardVariant } from '@/page-layout/widgets/types/WidgetCardVariant';
import { WidgetCard } from '@/page-layout/widgets/widget-card/components/WidgetCard';
import { WidgetCardContent } from '@/page-layout/widgets/widget-card/components/WidgetCardContent';
import { WidgetCardHeader } from '@/page-layout/widgets/widget-card/components/WidgetCardHeader';
import { styled } from '@linaria/react';
import { type MouseEvent, useContext } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
import { IconLock } from 'twenty-ui/display';
import { ThemeContext } from 'twenty-ui/theme-constants';
import { WidgetType } from '~/generated-metadata/graphql';
const StyledNoAccessContainer = styled.div`
align-items: center;
display: flex;
justify-content: center;
`;
type WidgetCardShellProps = {
widget: PageLayoutWidget;
variant: WidgetCardVariant;
isEditable: boolean;
isEditing: boolean;
isDragging: boolean;
isResizing: boolean;
isLastWidget: boolean;
showHeader: boolean;
hasAccess: boolean;
restriction: WidgetAccessDenialInfo;
actions: WidgetAction[];
isInVerticalListTab: boolean;
isMobile: boolean;
isReorderEnabled: boolean;
isDeletingWidgetEnabled: boolean;
onClick?: () => void;
onRemove: (e?: MouseEvent) => void;
onMouseEnter?: () => void;
onMouseLeave?: () => void;
};
export const WidgetCardShell = ({
widget,
variant,
isEditable,
isEditing,
isDragging,
isResizing,
isLastWidget,
showHeader,
hasAccess,
restriction,
actions,
isInVerticalListTab,
isMobile,
isReorderEnabled,
isDeletingWidgetEnabled,
onClick,
onRemove,
onMouseEnter,
onMouseLeave,
}: WidgetCardShellProps) => {
const { theme } = useContext(ThemeContext);
return (
<WidgetComponentInstanceContext.Provider value={{ instanceId: widget.id }}>
<WidgetCard
headerLess={!showHeader}
variant={variant}
isEditable={isEditable}
onClick={onClick}
isEditing={isEditing}
isDragging={isDragging}
isResizing={isResizing}
isLastWidget={isLastWidget}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
data-widget-id={widget.id}
data-testid={widget.id}
className="widget"
>
{showHeader && (
<WidgetCardHeader
widgetId={widget.id}
variant={variant}
isInEditMode={isEditable}
isResizing={isResizing}
isReorderEnabled={isReorderEnabled}
isDeletingWidgetEnabled={isDeletingWidgetEnabled}
title={widget.title}
onRemove={onRemove}
actions={actions}
forbiddenDisplay={
!hasAccess && (
<PageLayoutWidgetForbiddenDisplay
widgetId={widget.id}
restriction={restriction}
/>
)
}
/>
)}
<WidgetCardContent
variant={variant}
hasHeader={showHeader}
isEditable={isEditable}
isInVerticalListTab={isInVerticalListTab}
isMobile={isMobile}
hasInteractiveContent={widget.type === WidgetType.RECORD_TABLE}
>
{hasAccess ? (
<ErrorBoundary
FallbackComponent={PageLayoutWidgetInvalidConfigDisplay}
resetKeys={[
widget.id,
widget.configuration,
widget.objectMetadataId,
]}
>
<WidgetContentRenderer widget={widget} />
</ErrorBoundary>
) : (
<StyledNoAccessContainer>
<IconLock
color={theme.font.color.tertiary}
stroke={theme.icon.stroke.sm}
/>
</StyledNoAccessContainer>
)}
</WidgetCardContent>
</WidgetCard>
</WidgetComponentInstanceContext.Provider>
);
};