Record page layout edition frontend (#17519)

Hidden behind a new feature flag:
`IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED`


https://github.com/user-attachments/assets/112f5a8d-0dd0-4b9f-8825-9980db35fcbd
This commit is contained in:
Baptiste Devessier
2026-01-29 12:13:09 +01:00
committed by GitHub
parent 97a37604bd
commit b15d092abd
22 changed files with 369 additions and 79 deletions
@@ -21,6 +21,10 @@ import { NavigateToNextRecordSingleRecordAction } from '@/action-menu/actions/re
import { NavigateToPreviousRecordSingleRecordAction } from '@/action-menu/actions/record-actions/single-record/components/NavigateToPreviousRecordSingleRecordAction';
import { RemoveFromFavoritesSingleRecordAction } from '@/action-menu/actions/record-actions/single-record/components/RemoveFromFavoritesSingleRecordAction';
import { RestoreSingleRecordAction } from '@/action-menu/actions/record-actions/single-record/components/RestoreSingleRecordAction';
import { CancelRecordPageLayoutSingleRecordAction } from '@/action-menu/actions/record-actions/single-record/record-page-layout-actions/components/CancelRecordPageLayoutSingleRecordAction';
import { EditRecordPageLayoutSingleRecordAction } from '@/action-menu/actions/record-actions/single-record/record-page-layout-actions/components/EditRecordPageLayoutSingleRecordAction';
import { SaveRecordPageLayoutSingleRecordAction } from '@/action-menu/actions/record-actions/single-record/record-page-layout-actions/components/SaveRecordPageLayoutSingleRecordAction';
import { RecordPageLayoutSingleRecordActionKeys } from '@/action-menu/actions/record-actions/single-record/record-page-layout-actions/types/RecordPageLayoutSingleRecordActionKeys';
import { SingleRecordActionKeys } from '@/action-menu/actions/record-actions/single-record/types/SingleRecordActionsKey';
import { type ActionConfig } from '@/action-menu/actions/types/ActionConfig';
import { ActionScope } from '@/action-menu/actions/types/ActionScope';
@@ -36,9 +40,11 @@ import { AppPath, SettingsPath } from 'twenty-shared/types';
import {
IconArrowMerge,
IconBuildingSkyscraper,
IconCancel,
IconCheckbox,
IconChevronDown,
IconChevronUp,
IconDeviceFloppy,
IconEdit,
IconEyeOff,
IconFileExport,
@@ -47,6 +53,7 @@ import {
IconHeartOff,
IconLayout,
IconLayoutDashboard,
IconPencil,
IconPlus,
IconRefresh,
IconRotate2,
@@ -60,11 +67,13 @@ import {
import { isDefined } from 'twenty-shared/utils';
import { PermissionFlagType } from '~/generated-metadata/graphql';
import { FeatureFlagKey } from '~/generated/graphql';
export const DEFAULT_RECORD_ACTIONS_CONFIG: Record<
| NoSelectionRecordActionKeys
| SingleRecordActionKeys
| MultipleRecordsActionKeys,
| MultipleRecordsActionKeys
| RecordPageLayoutSingleRecordActionKeys,
ActionConfig
> = {
[SingleRecordActionKeys.NAVIGATE_TO_NEXT_RECORD]: {
@@ -741,4 +750,87 @@ export const DEFAULT_RECORD_ACTIONS_CONFIG: Record<
),
hotKeys: ['G', 'N'],
},
[RecordPageLayoutSingleRecordActionKeys.EDIT_RECORD_PAGE_LAYOUT]: {
key: RecordPageLayoutSingleRecordActionKeys.EDIT_RECORD_PAGE_LAYOUT,
label: msg`Edit Page Layout`,
shortLabel: msg`Edit Layout`,
isPinned: true,
position: 30,
Icon: IconPencil,
type: ActionType.Standard,
scope: ActionScope.RecordSelection,
requiredPermissionFlag: PermissionFlagType.LAYOUTS,
shouldBeRegistered: ({
selectedRecord,
objectPermissions,
objectMetadataItem,
isFeatureFlagEnabled,
}) =>
isFeatureFlagEnabled(
FeatureFlagKey.IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED,
) &&
isDefined(selectedRecord) &&
!selectedRecord?.isRemote &&
!isDefined(selectedRecord?.deletedAt) &&
objectPermissions.canUpdateObjectRecords &&
objectMetadataItem?.nameSingular !== CoreObjectNameSingular.Dashboard,
availableOn: [ActionViewType.SHOW_PAGE],
component: <EditRecordPageLayoutSingleRecordAction />,
},
[RecordPageLayoutSingleRecordActionKeys.SAVE_RECORD_PAGE_LAYOUT]: {
key: RecordPageLayoutSingleRecordActionKeys.SAVE_RECORD_PAGE_LAYOUT,
label: msg`Save Page Layout`,
shortLabel: msg`Save`,
isPinned: true,
isPrimaryCTA: true,
position: 31,
Icon: IconDeviceFloppy,
type: ActionType.Standard,
scope: ActionScope.RecordSelection,
requiredPermissionFlag: PermissionFlagType.LAYOUTS,
shouldBeRegistered: ({
selectedRecord,
objectPermissions,
objectMetadataItem,
isFeatureFlagEnabled,
}) =>
isFeatureFlagEnabled(
FeatureFlagKey.IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED,
) &&
isDefined(selectedRecord) &&
!selectedRecord?.isRemote &&
!isDefined(selectedRecord?.deletedAt) &&
objectPermissions.canUpdateObjectRecords &&
objectMetadataItem?.nameSingular !== CoreObjectNameSingular.Dashboard,
availableOn: [ActionViewType.PAGE_EDIT_MODE],
component: <SaveRecordPageLayoutSingleRecordAction />,
},
[RecordPageLayoutSingleRecordActionKeys.CANCEL_RECORD_PAGE_LAYOUT_EDITION]: {
key: RecordPageLayoutSingleRecordActionKeys.CANCEL_RECORD_PAGE_LAYOUT_EDITION,
label: msg`Cancel Edition`,
shortLabel: msg`Cancel`,
isPinned: true,
position: 32,
Icon: IconCancel,
type: ActionType.Standard,
scope: ActionScope.RecordSelection,
requiredPermissionFlag: PermissionFlagType.LAYOUTS,
shouldBeRegistered: ({
selectedRecord,
objectPermissions,
objectMetadataItem,
isFeatureFlagEnabled,
}) =>
isFeatureFlagEnabled(
FeatureFlagKey.IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED,
) &&
isDefined(selectedRecord) &&
!selectedRecord?.isRemote &&
!isDefined(selectedRecord?.deletedAt) &&
objectPermissions.canUpdateObjectRecords &&
objectMetadataItem?.nameSingular !== CoreObjectNameSingular.Dashboard,
availableOn: [ActionViewType.PAGE_EDIT_MODE],
component: <CancelRecordPageLayoutSingleRecordAction />,
},
};
@@ -0,0 +1,35 @@
import { Action } from '@/action-menu/actions/components/Action';
import { useSelectedRecordIdOrThrow } from '@/action-menu/actions/record-actions/single-record/hooks/useSelectedRecordIdOrThrow';
import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
import { useContextStoreObjectMetadataItemOrThrow } from '@/context-store/hooks/useContextStoreObjectMetadataItemOrThrow';
import { useRecordPageLayoutIdFromRecordStoreOrThrow } from '@/page-layout/hooks/useRecordPageLayoutIdFromRecordStoreOrThrow';
import { useResetDraftPageLayoutToPersistedPageLayout } from '@/page-layout/hooks/useResetDraftPageLayoutToPersistedPageLayout';
import { useSetIsPageLayoutInEditMode } from '@/page-layout/hooks/useSetIsPageLayoutInEditMode';
export const CancelRecordPageLayoutSingleRecordAction = () => {
const recordId = useSelectedRecordIdOrThrow();
const { objectMetadataItem } = useContextStoreObjectMetadataItemOrThrow();
const { pageLayoutId } = useRecordPageLayoutIdFromRecordStoreOrThrow({
id: recordId,
targetObjectNameSingular: objectMetadataItem.nameSingular,
});
const { closeCommandMenu } = useCommandMenu();
const { setIsPageLayoutInEditMode } =
useSetIsPageLayoutInEditMode(pageLayoutId);
const { resetDraftPageLayoutToPersistedPageLayout } =
useResetDraftPageLayoutToPersistedPageLayout(pageLayoutId);
const handleClick = () => {
closeCommandMenu();
resetDraftPageLayoutToPersistedPageLayout();
setIsPageLayoutInEditMode(false);
};
return <Action onClick={handleClick} />;
};
@@ -0,0 +1,29 @@
import { Action } from '@/action-menu/actions/components/Action';
import { useSelectedRecordIdOrThrow } from '@/action-menu/actions/record-actions/single-record/hooks/useSelectedRecordIdOrThrow';
import { useContextStoreObjectMetadataItemOrThrow } from '@/context-store/hooks/useContextStoreObjectMetadataItemOrThrow';
import { useRecordPageLayoutIdFromRecordStoreOrThrow } from '@/page-layout/hooks/useRecordPageLayoutIdFromRecordStoreOrThrow';
import { useSetIsPageLayoutInEditMode } from '@/page-layout/hooks/useSetIsPageLayoutInEditMode';
import { useResetLocationHash } from 'twenty-ui/utilities';
export const EditRecordPageLayoutSingleRecordAction = () => {
const recordId = useSelectedRecordIdOrThrow();
const { objectMetadataItem } = useContextStoreObjectMetadataItemOrThrow();
const { pageLayoutId } = useRecordPageLayoutIdFromRecordStoreOrThrow({
id: recordId,
targetObjectNameSingular: objectMetadataItem.nameSingular,
});
const { setIsPageLayoutInEditMode } =
useSetIsPageLayoutInEditMode(pageLayoutId);
const { resetLocationHash } = useResetLocationHash();
const handleClick = () => {
setIsPageLayoutInEditMode(true);
resetLocationHash();
};
return <Action onClick={handleClick} />;
};
@@ -0,0 +1,36 @@
import { Action } from '@/action-menu/actions/components/Action';
import { useSelectedRecordIdOrThrow } from '@/action-menu/actions/record-actions/single-record/hooks/useSelectedRecordIdOrThrow';
import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
import { useContextStoreObjectMetadataItemOrThrow } from '@/context-store/hooks/useContextStoreObjectMetadataItemOrThrow';
import { useRecordPageLayoutIdFromRecordStoreOrThrow } from '@/page-layout/hooks/useRecordPageLayoutIdFromRecordStoreOrThrow';
import { useSavePageLayout } from '@/page-layout/hooks/useSavePageLayout';
import { useSetIsPageLayoutInEditMode } from '@/page-layout/hooks/useSetIsPageLayoutInEditMode';
export const SaveRecordPageLayoutSingleRecordAction = () => {
const recordId = useSelectedRecordIdOrThrow();
const { objectMetadataItem } = useContextStoreObjectMetadataItemOrThrow();
const { pageLayoutId } = useRecordPageLayoutIdFromRecordStoreOrThrow({
id: recordId,
targetObjectNameSingular: objectMetadataItem.nameSingular,
});
const { savePageLayout } = useSavePageLayout(pageLayoutId);
const { setIsPageLayoutInEditMode } =
useSetIsPageLayoutInEditMode(pageLayoutId);
const { closeCommandMenu } = useCommandMenu();
const handleClick = async () => {
const result = await savePageLayout();
if (result.status === 'successful') {
closeCommandMenu();
setIsPageLayoutInEditMode(false);
}
};
return <Action onClick={handleClick} />;
};
@@ -0,0 +1,5 @@
export enum RecordPageLayoutSingleRecordActionKeys {
EDIT_RECORD_PAGE_LAYOUT = 'edit-record-page-layout-single-record',
SAVE_RECORD_PAGE_LAYOUT = 'save-record-page-layout-single-record',
CANCEL_RECORD_PAGE_LAYOUT_EDITION = 'cancel-record-page-layout-edition-single-record',
}
@@ -4,6 +4,7 @@ import { type RecordFilter } from '@/object-record/record-filter/types/RecordFil
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { type WorkflowWithCurrentVersion } from '@/workflow/types/Workflow';
import { type ObjectPermissions } from 'twenty-shared/types';
import { type FeatureFlagKey } from '~/generated/graphql';
export type ShouldBeRegisteredFunctionParams = {
objectMetadataItem?: ObjectMetadataItem;
@@ -28,4 +29,5 @@ export type ShouldBeRegisteredFunctionParams = {
objectMetadataItemNameSingular: string,
) => boolean;
forceRegisteredActionsByKey: Record<string, boolean | undefined>;
isFeatureFlagEnabled: (featureFlagKey: FeatureFlagKey) => boolean;
};
@@ -2,6 +2,7 @@ import { forceRegisteredActionsByKeyState } from '@/action-menu/actions/states/f
import { type ShouldBeRegisteredFunctionParams } from '@/action-menu/actions/types/ShouldBeRegisteredFunctionParams';
import { getActionViewType } from '@/action-menu/actions/utils/getActionViewType';
import { ActionMenuContext } from '@/action-menu/contexts/ActionMenuContext';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { objectPermissionsFamilySelector } from '@/auth/states/objectPermissionsFamilySelector';
import { contextStoreCurrentViewTypeComponentState } from '@/context-store/states/contextStoreCurrentViewTypeComponentState';
import { contextStoreNumberOfSelectedRecordsComponentState } from '@/context-store/states/contextStoreNumberOfSelectedRecordsComponentState';
@@ -135,6 +136,16 @@ export const useShouldActionBeRegisteredParams = ({
forceRegisteredActionsByKeyState,
);
const currentWorkspace = useRecoilValue(currentWorkspaceState);
const isFeatureFlagEnabled = (featureFlagKey: FeatureFlagKey) => {
const featureFlag = currentWorkspace?.featureFlags?.find(
(flag) => flag.key === featureFlagKey,
);
return featureFlag?.value === true;
};
return {
objectMetadataItem,
isFavorite,
@@ -150,5 +161,6 @@ export const useShouldActionBeRegisteredParams = ({
getTargetObjectReadPermission: getObjectReadPermission,
getTargetObjectWritePermission: getObjectWritePermission,
forceRegisteredActionsByKey,
isFeatureFlagEnabled,
};
};