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,110 @@
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { objectPermissionsFamilySelector } from '@/auth/states/objectPermissionsFamilySelector';
import { CommandMenuContext } from '@/command-menu-item/contexts/CommandMenuContext';
import { PinnedCommandMenuItemButtons } from '@/command-menu-item/display/components/PinnedCommandMenuItemButtons';
import { CommandMenuItemEditButton } from '@/command-menu-item/edit/components/CommandMenuItemEditButton';
import { commandMenuItemsSelector } from '@/command-menu-item/states/commandMenuItemsSelector';
import { doesCommandMenuItemMatchObjectMetadataId } from '@/command-menu-item/utils/doesCommandMenuItemMatchObjectMetadataId';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useStore } from 'jotai';
import { useMemo } from 'react';
import {
ContextStorePageType,
type CommandMenuContextApi,
} from 'twenty-shared/types';
import { evaluateConditionalAvailabilityExpression } from 'twenty-shared/utils';
import { useIsMobile } from 'twenty-ui/utilities';
import { CommandMenuItemAvailabilityType } from '~/generated-metadata/graphql';
export const StandalonePageCommandMenu = () => {
const store = useStore();
const isMobile = useIsMobile();
const commandMenuItems = useAtomStateValue(commandMenuItemsSelector);
const currentWorkspace = useAtomStateValue(currentWorkspaceState);
const { objectMetadataItems } = useObjectMetadataItems();
const commandMenuContextApi = useMemo<CommandMenuContextApi>(() => {
const featureFlags: Record<string, boolean> = {};
for (const flag of currentWorkspace?.featureFlags ?? []) {
featureFlags[flag.key] = flag.value === true;
}
const targetObjectReadPermissions: Record<string, boolean> = {};
const targetObjectWritePermissions: Record<string, boolean> = {};
for (const metadataItem of objectMetadataItems) {
const permissions = store.get(
objectPermissionsFamilySelector.selectorFamily({
objectNameSingular: metadataItem.nameSingular,
}),
);
targetObjectReadPermissions[metadataItem.nameSingular] =
permissions.canRead;
targetObjectWritePermissions[metadataItem.nameSingular] =
permissions.canUpdate;
}
return {
pageType: ContextStorePageType.Standalone,
isInSidePanel: false,
isPageInEditMode: false,
favoriteRecordIds: [],
isSelectAll: false,
hasAnySoftDeleteFilterOnView: false,
numberOfSelectedRecords: 0,
objectPermissions: {
canReadObjectRecords: false,
canUpdateObjectRecords: false,
canSoftDeleteObjectRecords: false,
canDestroyObjectRecords: false,
restrictedFields: {},
objectMetadataId: '',
rowLevelPermissionPredicates: [],
rowLevelPermissionPredicateGroups: [],
},
selectedRecords: [],
featureFlags,
targetObjectReadPermissions,
targetObjectWritePermissions,
objectMetadataItem: {},
objectMetadataLabel: '',
};
}, [currentWorkspace?.featureFlags, objectMetadataItems, store]);
const filteredCommandMenuItems = useMemo(() => {
return commandMenuItems
.filter(doesCommandMenuItemMatchObjectMetadataId(undefined))
.filter(
(item) =>
item.availabilityType !==
CommandMenuItemAvailabilityType.RECORD_SELECTION &&
item.availabilityType !==
CommandMenuItemAvailabilityType.GLOBAL_OBJECT_CONTEXT,
)
.filter((item) =>
evaluateConditionalAvailabilityExpression(
item.conditionalAvailabilityExpression,
commandMenuContextApi,
),
)
.sort(
(firstItem, secondItem) => firstItem.position - secondItem.position,
);
}, [commandMenuItems, commandMenuContextApi]);
return (
<CommandMenuContext.Provider
value={{
displayType: 'button',
containerType: 'standalone-page-header',
commandMenuItems: filteredCommandMenuItems,
commandMenuContextApi,
}}
>
{!isMobile && <PinnedCommandMenuItemButtons />}
<CommandMenuItemEditButton />
</CommandMenuContext.Provider>
);
};