Implement basic edition for record page layouts (#15237)

1. Add actions for record page layouts to side panel
2. Support drag-and-drop of widgets


https://github.com/user-attachments/assets/c4cef7c5-08a8-4999-b8a5-e1831cc66709
This commit is contained in:
Baptiste Devessier
2025-10-22 10:48:09 +02:00
committed by GitHub
parent c2a477a9d6
commit c6addc2bb4
13 changed files with 466 additions and 4 deletions
@@ -0,0 +1,114 @@
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 { SingleRecordActionKeys } from '@/action-menu/actions/record-actions/single-record/types/SingleRecordActionsKey';
import { inheritActionsFromDefaultConfig } from '@/action-menu/actions/record-actions/utils/inheritActionsFromDefaultConfig';
import { ActionScope } from '@/action-menu/actions/types/ActionScope';
import { ActionType } from '@/action-menu/actions/types/ActionType';
import { ActionViewType } from '@/action-menu/actions/types/ActionViewType';
import { PageLayoutSingleRecordActionKeys } from '@/page-layout/actions/PageLayoutSingleRecordActionKeys';
import { msg } from '@lingui/core/macro';
import { isDefined } from 'twenty-shared/utils';
import { IconDeviceFloppy, IconPencil, IconX } from 'twenty-ui/display';
export const RECORD_PAGE_LAYOUT_ACTIONS_CONFIG =
inheritActionsFromDefaultConfig({
config: {
[PageLayoutSingleRecordActionKeys.EDIT_LAYOUT]: {
key: PageLayoutSingleRecordActionKeys.EDIT_LAYOUT,
label: msg`Edit Layout`,
shortLabel: msg`Edit`,
isPinned: true,
position: 0,
Icon: IconPencil,
type: ActionType.Standard,
scope: ActionScope.RecordSelection,
shouldBeRegistered: ({ selectedRecord }) =>
isDefined(selectedRecord) &&
!selectedRecord?.isRemote &&
!isDefined(selectedRecord?.deletedAt),
// TODO: Once backend is ready, uncomment the line below
// isDefined(selectedRecord?.pageLayoutId),
availableOn: [ActionViewType.SHOW_PAGE],
component: <EditRecordPageLayoutSingleRecordAction />,
},
[PageLayoutSingleRecordActionKeys.SAVE_LAYOUT]: {
key: PageLayoutSingleRecordActionKeys.SAVE_LAYOUT,
label: msg`Save Layout`,
shortLabel: msg`Save`,
isPinned: true,
position: 1,
Icon: IconDeviceFloppy,
type: ActionType.Standard,
scope: ActionScope.RecordSelection,
shouldBeRegistered: ({ selectedRecord }) =>
isDefined(selectedRecord) &&
!selectedRecord?.isRemote &&
!isDefined(selectedRecord?.deletedAt),
// TODO: Once backend is ready, uncomment the line below
// isDefined(selectedRecord?.pageLayoutId),
availableOn: [ActionViewType.SHOW_PAGE],
component: <SaveRecordPageLayoutSingleRecordAction />,
},
[PageLayoutSingleRecordActionKeys.CANCEL_LAYOUT_EDITION]: {
key: PageLayoutSingleRecordActionKeys.CANCEL_LAYOUT_EDITION,
label: msg`Cancel Edition`,
shortLabel: msg`Cancel`,
isPinned: true,
position: 2,
Icon: IconX,
type: ActionType.Standard,
scope: ActionScope.RecordSelection,
shouldBeRegistered: ({ selectedRecord }) =>
isDefined(selectedRecord) &&
!selectedRecord?.isRemote &&
!isDefined(selectedRecord?.deletedAt),
// TODO: Once backend is ready, uncomment the line below
// isDefined(selectedRecord?.pageLayoutId),
availableOn: [ActionViewType.SHOW_PAGE],
component: <CancelRecordPageLayoutSingleRecordAction />,
},
},
actionKeys: [
SingleRecordActionKeys.ADD_TO_FAVORITES,
SingleRecordActionKeys.REMOVE_FROM_FAVORITES,
SingleRecordActionKeys.DELETE,
SingleRecordActionKeys.DESTROY,
SingleRecordActionKeys.RESTORE,
SingleRecordActionKeys.EXPORT_FROM_RECORD_SHOW,
SingleRecordActionKeys.NAVIGATE_TO_PREVIOUS_RECORD,
SingleRecordActionKeys.NAVIGATE_TO_NEXT_RECORD,
],
propertiesToOverwrite: {
[SingleRecordActionKeys.ADD_TO_FAVORITES]: {
position: 3,
},
[SingleRecordActionKeys.REMOVE_FROM_FAVORITES]: {
position: 4,
},
[SingleRecordActionKeys.DELETE]: {
position: 5,
label: msg`Delete record`,
},
[SingleRecordActionKeys.EXPORT_FROM_RECORD_SHOW]: {
position: 6,
label: msg`Export record`,
},
[SingleRecordActionKeys.DESTROY]: {
position: 7,
label: msg`Permanently destroy record`,
},
[SingleRecordActionKeys.RESTORE]: {
position: 8,
label: msg`Restore record`,
},
[SingleRecordActionKeys.NAVIGATE_TO_PREVIOUS_RECORD]: {
position: 9,
label: msg`Navigate to previous record`,
},
[SingleRecordActionKeys.NAVIGATE_TO_NEXT_RECORD]: {
position: 10,
label: msg`Navigate to next record`,
},
},
});
@@ -0,0 +1,37 @@
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 { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { DEFAULT_PAGE_LAYOUT_ID } from '@/page-layout/constants/DefaultPageLayoutId';
import { useResetDraftPageLayoutToPersistedPageLayout } from '@/page-layout/hooks/useResetDraftPageLayoutToPersistedPageLayout';
import { useSetIsPageLayoutInEditMode } from '@/page-layout/hooks/useSetIsPageLayoutInEditMode';
import { useRecoilValue } from 'recoil';
export const CancelRecordPageLayoutSingleRecordAction = () => {
const { objectMetadataItem } = useContextStoreObjectMetadataItemOrThrow();
const recordId = useSelectedRecordIdOrThrow();
const selectedRecord = useRecoilValue(recordStoreFamilyState(recordId));
// For COMPANY objects, use DEFAULT_PAGE_LAYOUT_ID
// For other objects, use the record's pageLayoutId (when backend is ready)
const pageLayoutId =
objectMetadataItem.nameSingular === CoreObjectNameSingular.Company
? DEFAULT_PAGE_LAYOUT_ID
: selectedRecord?.pageLayoutId;
const { setIsPageLayoutInEditMode } =
useSetIsPageLayoutInEditMode(pageLayoutId);
const { resetDraftPageLayoutToPersistedPageLayout } =
useResetDraftPageLayoutToPersistedPageLayout(pageLayoutId);
const handleClick = () => {
resetDraftPageLayoutToPersistedPageLayout();
setIsPageLayoutInEditMode(false);
};
return <Action onClick={handleClick} />;
};
@@ -0,0 +1,32 @@
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 { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { DEFAULT_PAGE_LAYOUT_ID } from '@/page-layout/constants/DefaultPageLayoutId';
import { useSetIsPageLayoutInEditMode } from '@/page-layout/hooks/useSetIsPageLayoutInEditMode';
import { useRecoilValue } from 'recoil';
export const EditRecordPageLayoutSingleRecordAction = () => {
const { objectMetadataItem } = useContextStoreObjectMetadataItemOrThrow();
const recordId = useSelectedRecordIdOrThrow();
const selectedRecord = useRecoilValue(recordStoreFamilyState(recordId));
// For COMPANY objects, use DEFAULT_PAGE_LAYOUT_ID
// For other objects, use the record's pageLayoutId (when backend is ready)
const pageLayoutId =
objectMetadataItem.nameSingular === CoreObjectNameSingular.Company
? DEFAULT_PAGE_LAYOUT_ID
: selectedRecord?.pageLayoutId;
const { setIsPageLayoutInEditMode } =
useSetIsPageLayoutInEditMode(pageLayoutId);
const handleClick = () => {
setIsPageLayoutInEditMode(true);
};
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 { useContextStoreObjectMetadataItemOrThrow } from '@/context-store/hooks/useContextStoreObjectMetadataItemOrThrow';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { DEFAULT_PAGE_LAYOUT_ID } from '@/page-layout/constants/DefaultPageLayoutId';
import { useSavePageLayout } from '@/page-layout/hooks/useSavePageLayout';
import { useSetIsPageLayoutInEditMode } from '@/page-layout/hooks/useSetIsPageLayoutInEditMode';
import { useRecoilValue } from 'recoil';
export const SaveRecordPageLayoutSingleRecordAction = () => {
const { objectMetadataItem } = useContextStoreObjectMetadataItemOrThrow();
const recordId = useSelectedRecordIdOrThrow();
const selectedRecord = useRecoilValue(recordStoreFamilyState(recordId));
// For COMPANY objects, use DEFAULT_PAGE_LAYOUT_ID
// For other objects, use the record's pageLayoutId (when backend is ready)
const pageLayoutId =
objectMetadataItem.nameSingular === CoreObjectNameSingular.Company
? DEFAULT_PAGE_LAYOUT_ID
: selectedRecord?.pageLayoutId;
const { savePageLayout } = useSavePageLayout(pageLayoutId);
const { setIsPageLayoutInEditMode } =
useSetIsPageLayoutInEditMode(pageLayoutId);
const handleClick = async () => {
await savePageLayout();
setIsPageLayoutInEditMode(false);
};
return <Action onClick={handleClick} />;
};
@@ -1,5 +1,6 @@
import { DASHBOARD_ACTIONS_CONFIG } from '@/action-menu/actions/record-actions/constants/DashboardActionsConfig';
import { DEFAULT_RECORD_ACTIONS_CONFIG } from '@/action-menu/actions/record-actions/constants/DefaultRecordActionsConfig';
import { RECORD_PAGE_LAYOUT_ACTIONS_CONFIG } from '@/action-menu/actions/record-actions/constants/RecordPageLayoutActionsConfig';
import { WORKFLOW_ACTIONS_CONFIG } from '@/action-menu/actions/record-actions/constants/WorkflowActionsConfig';
import { WORKFLOW_RUNS_ACTIONS_CONFIG } from '@/action-menu/actions/record-actions/constants/WorkflowRunsActionsConfig';
import { WORKFLOW_VERSIONS_ACTIONS_CONFIG } from '@/action-menu/actions/record-actions/constants/WorkflowVersionsActionsConfig';
@@ -10,8 +11,10 @@ import { isDefined } from 'twenty-shared/utils';
export const getActionConfig = ({
objectMetadataItem,
isRecordPageLayoutEnabled,
}: {
objectMetadataItem?: ObjectMetadataItem;
isRecordPageLayoutEnabled: boolean;
}): Record<string, ActionConfig> => {
if (!isDefined(objectMetadataItem)) {
return {};
@@ -30,6 +33,13 @@ export const getActionConfig = ({
case CoreObjectNameSingular.WorkflowRun: {
return WORKFLOW_RUNS_ACTIONS_CONFIG;
}
case CoreObjectNameSingular.Company: {
if (isRecordPageLayoutEnabled) {
return RECORD_PAGE_LAYOUT_ACTIONS_CONFIG;
}
return DEFAULT_RECORD_ACTIONS_CONFIG;
}
default: {
return DEFAULT_RECORD_ACTIONS_CONFIG;
}
@@ -8,8 +8,10 @@ import { contextStoreCurrentViewTypeComponentState } from '@/context-store/state
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
import { usePermissionFlagMap } from '@/settings/roles/hooks/usePermissionFlagMap';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { isDefined } from 'twenty-shared/utils';
import { useIcons } from 'twenty-ui/display';
import { FeatureFlagKey } from '~/generated/graphql';
export const useRegisteredActions = (
shouldBeRegisteredParams: ShouldBeRegisteredFunctionParams,
@@ -27,6 +29,10 @@ export const useRegisteredActions = (
contextStoreCurrentViewTypeComponentState,
);
const isRecordPageLayoutEnabled = useIsFeatureEnabled(
FeatureFlagKey.IS_RECORD_PAGE_LAYOUT_ENABLED,
);
const viewType = getActionViewType(
contextStoreCurrentViewType,
contextStoreTargetedRecordsRule,
@@ -34,6 +40,7 @@ export const useRegisteredActions = (
const recordActionConfig = getActionConfig({
objectMetadataItem,
isRecordPageLayoutEnabled,
});
const relatedRecordActionConfig = useRelatedRecordActions({