Create edit dashboard action (#14564)

Closes https://github.com/twentyhq/core-team-issues/issues/1435

Introduced a new pattern to force to register an action or not.
Discussed this with @charlesBochet
This commit is contained in:
Raphaël Bosi
2025-09-17 17:58:26 +02:00
committed by GitHub
parent 1c8dccda01
commit 0750bcaefc
18 changed files with 223 additions and 8 deletions
@@ -0,0 +1,74 @@
import { EditDashboardSingleRecordAction } from '@/action-menu/actions/record-actions/single-record/dashboard-actions/components/EditDashboardSingleRecordAction';
import { DashboardSingleRecordActionKeys } from '@/action-menu/actions/record-actions/single-record/dashboard-actions/types/DashboardSingleRecordActionKeys';
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 { msg } from '@lingui/core/macro';
import { isDefined } from 'twenty-shared/utils';
import { IconPencil } from 'twenty-ui/display';
export const DASHBOARD_ACTIONS_CONFIG = inheritActionsFromDefaultConfig({
config: {
[DashboardSingleRecordActionKeys.EDIT_DASHBOARD]: {
key: DashboardSingleRecordActionKeys.EDIT_DASHBOARD,
label: msg`Edit Dashboard`,
shortLabel: msg`Edit`,
isPinned: true,
position: 1,
Icon: IconPencil,
type: ActionType.Standard,
scope: ActionScope.RecordSelection,
shouldBeRegistered: ({ selectedRecord }) =>
isDefined(selectedRecord) &&
!selectedRecord?.isRemote &&
!isDefined(selectedRecord?.deletedAt) &&
isDefined(selectedRecord?.pageLayoutId),
availableOn: [ActionViewType.SHOW_PAGE],
component: <EditDashboardSingleRecordAction />,
},
},
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: 2,
},
[SingleRecordActionKeys.REMOVE_FROM_FAVORITES]: {
position: 3,
},
[SingleRecordActionKeys.DELETE]: {
position: 4,
label: msg`Delete dashboard`,
},
[SingleRecordActionKeys.EXPORT_FROM_RECORD_SHOW]: {
position: 5,
label: msg`Export dashboard`,
},
[SingleRecordActionKeys.DESTROY]: {
position: 6,
label: msg`Permanently destroy dashboard`,
},
[SingleRecordActionKeys.RESTORE]: {
position: 7,
label: msg`Restore dashboard`,
},
[SingleRecordActionKeys.NAVIGATE_TO_PREVIOUS_RECORD]: {
position: 8,
label: msg`Navigate to previous dashboard`,
},
[SingleRecordActionKeys.NAVIGATE_TO_NEXT_RECORD]: {
position: 9,
label: msg`Navigate to next dashboard`,
},
},
});
@@ -1,7 +1,7 @@
import { ActionLink } from '@/action-menu/actions/components/ActionLink';
import { MultipleRecordsActionKeys } from '@/action-menu/actions/record-actions/multiple-records/types/MultipleRecordsActionKeys';
import { NoSelectionRecordActionKeys } from '@/action-menu/actions/record-actions/no-selection/types/NoSelectionRecordActionsKeys';
import { NoSelectionWorkflowRecordActionKeys } from '@/action-menu/actions/record-actions/no-selection/workflow-actions/types/NoSelectionWorkflowRecordActionsKeys';
import { NoSelectionWorkflowRecordActionKeys } from '@/action-menu/actions/record-actions/no-selection/workflow-actions/types/NoSelectionWorkflowRecordActionKeys';
import { SingleRecordActionKeys } from '@/action-menu/actions/record-actions/single-record/types/SingleRecordActionsKey';
import { ActivateWorkflowSingleRecordAction } from '@/action-menu/actions/record-actions/single-record/workflow-actions/components/ActivateWorkflowSingleRecordAction';
import { DeactivateWorkflowSingleRecordAction } from '@/action-menu/actions/record-actions/single-record/workflow-actions/components/DeactivateWorkflowSingleRecordAction';
@@ -1,7 +1,7 @@
import { ActionLink } from '@/action-menu/actions/components/ActionLink';
import { MultipleRecordsActionKeys } from '@/action-menu/actions/record-actions/multiple-records/types/MultipleRecordsActionKeys';
import { NoSelectionRecordActionKeys } from '@/action-menu/actions/record-actions/no-selection/types/NoSelectionRecordActionsKeys';
import { NoSelectionWorkflowRecordActionKeys } from '@/action-menu/actions/record-actions/no-selection/workflow-actions/types/NoSelectionWorkflowRecordActionsKeys';
import { NoSelectionWorkflowRecordActionKeys } from '@/action-menu/actions/record-actions/no-selection/workflow-actions/types/NoSelectionWorkflowRecordActionKeys';
import { SingleRecordActionKeys } from '@/action-menu/actions/record-actions/single-record/types/SingleRecordActionsKey';
import { SeeRunsWorkflowVersionSingleRecordAction } from '@/action-menu/actions/record-actions/single-record/workflow-version-actions/components/SeeRunsWorkflowVersionSingleRecordAction';
import { SeeVersionsWorkflowVersionSingleRecordAction } from '@/action-menu/actions/record-actions/single-record/workflow-version-actions/components/SeeVersionsWorkflowVersionSingleRecordAction';
@@ -0,0 +1,22 @@
import { Action } from '@/action-menu/actions/components/Action';
import { useSelectedRecordIdOrThrow } from '@/action-menu/actions/record-actions/single-record/hooks/useSelectedRecordIdOrThrow';
import { useSetIsDashboardInEditMode } from '@/dashboards/hooks/useSetDashboardInEditMode';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { useRecoilValue } from 'recoil';
export const EditDashboardSingleRecordAction = () => {
const recordId = useSelectedRecordIdOrThrow();
const selectedRecord = useRecoilValue(recordStoreFamilyState(recordId));
const pageLayoutId = selectedRecord?.pageLayoutId;
const { setIsDashboardInEditMode } =
useSetIsDashboardInEditMode(pageLayoutId);
const handleClick = () => {
setIsDashboardInEditMode(true);
};
return <Action onClick={handleClick} />;
};
@@ -0,0 +1,3 @@
export enum DashboardSingleRecordActionKeys {
EDIT_DASHBOARD = 'edit-dashboard-single-record',
}
@@ -0,0 +1,10 @@
import { ActionMenuComponentInstanceContext } from '@/action-menu/states/contexts/ActionMenuComponentInstanceContext';
import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState';
export const forceRegisteredActionsByKeyComponentState = createComponentState<
Record<string, boolean | undefined>
>({
key: 'forceRegisteredActionsByKeyComponentState',
defaultValue: {},
componentInstanceContext: ActionMenuComponentInstanceContext,
});
@@ -25,4 +25,5 @@ export type ShouldBeRegisteredFunctionParams = {
getTargetObjectWritePermission: (
objectMetadataItemNameSingular: string,
) => boolean;
forceRegisteredActionsByKey: Record<string, boolean | undefined>;
};
@@ -1,3 +1,4 @@
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 { WORKFLOW_ACTIONS_CONFIG } from '@/action-menu/actions/record-actions/constants/WorkflowActionsConfig';
import { WORKFLOW_RUNS_ACTIONS_CONFIG } from '@/action-menu/actions/record-actions/constants/WorkflowRunsActionsConfig';
@@ -17,6 +18,9 @@ export const getActionConfig = ({
}
switch (objectMetadataItem.nameSingular) {
case CoreObjectNameSingular.Dashboard: {
return DASHBOARD_ACTIONS_CONFIG;
}
case CoreObjectNameSingular.Workflow: {
return WORKFLOW_ACTIONS_CONFIG;
}
@@ -14,7 +14,8 @@ import { useIcons } from 'twenty-ui/display';
export const useRegisteredActions = (
shouldBeRegisteredParams: ShouldBeRegisteredFunctionParams,
) => {
const { objectMetadataItem } = shouldBeRegisteredParams;
const { objectMetadataItem, forceRegisteredActionsByKey } =
shouldBeRegisteredParams;
const { getIcon } = useIcons();
@@ -67,6 +68,15 @@ export const useRegisteredActions = (
) {
return false;
}
const forcedShouldBeRegistered = forceRegisteredActionsByKey[action.key];
if (isDefined(forcedShouldBeRegistered)) {
return (
forcedShouldBeRegistered &&
action.shouldBeRegistered(shouldBeRegisteredParams)
);
}
return action.shouldBeRegistered(shouldBeRegisteredParams);
})
.sort((a, b) => a.position - b.position);
@@ -1,3 +1,4 @@
import { forceRegisteredActionsByKeyComponentState } from '@/action-menu/actions/states/forceRegisteredActionsMapComponentState';
import { type ShouldBeRegisteredFunctionParams } from '@/action-menu/actions/types/ShouldBeRegisteredFunctionParams';
import { getActionViewType } from '@/action-menu/actions/utils/getActionViewType';
import { ActionMenuContext } from '@/action-menu/contexts/ActionMenuContext';
@@ -100,6 +101,10 @@ export const useShouldActionBeRegisteredParams = ({
[],
);
const forceRegisteredActionsByKey = useRecoilComponentValue(
forceRegisteredActionsByKeyComponentState,
);
return {
objectMetadataItem,
isFavorite,
@@ -113,5 +118,6 @@ export const useShouldActionBeRegisteredParams = ({
viewType: viewType ?? undefined,
getTargetObjectReadPermission: getObjectReadPermission,
getTargetObjectWritePermission: getObjectWritePermission,
forceRegisteredActionsByKey,
};
};
@@ -0,0 +1,33 @@
import { type Dashboard } from '@/dashboards/components/types/Dashboard';
import { useSetIsDashboardInEditMode } from '@/dashboards/hooks/useSetDashboardInEditMode';
import { PageLayoutRenderer } from '@/page-layout/components/PageLayoutRenderer';
import { type PageLayoutWithData } from '@/page-layout/types/pageLayoutTypes';
import { isPageLayoutEmpty } from '@/page-layout/utils/isPageLayoutEmpty';
type DashboardContentRendererProps = {
dashboard: Dashboard;
};
export const DashboardContentRenderer = ({
dashboard,
}: DashboardContentRendererProps) => {
const pageLayoutId = dashboard.pageLayoutId;
const { setIsDashboardInEditMode } =
useSetIsDashboardInEditMode(pageLayoutId);
const onInitialized = (pageLayout: PageLayoutWithData) => {
if (isPageLayoutEmpty(pageLayout)) {
setIsDashboardInEditMode(true);
} else {
setIsDashboardInEditMode(false);
}
};
return (
<PageLayoutRenderer
pageLayoutId={pageLayoutId}
onInitialized={onInitialized}
/>
);
};
@@ -1,7 +1,7 @@
import { DashboardContentRenderer } from '@/dashboards/components/DashboardContentRenderer';
import { type Dashboard } from '@/dashboards/components/types/Dashboard';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord';
import { PageLayoutRenderer } from '@/page-layout/components/PageLayoutRenderer';
import { isDefined } from 'twenty-shared/utils';
type DashboardRendererProps = {
@@ -18,5 +18,5 @@ export const DashboardRenderer = ({ recordId }: DashboardRendererProps) => {
return null;
}
return <PageLayoutRenderer pageLayoutId={dashboard.pageLayoutId} />;
return <DashboardContentRenderer dashboard={dashboard} />;
};
@@ -0,0 +1,37 @@
import { DashboardSingleRecordActionKeys } from '@/action-menu/actions/record-actions/single-record/dashboard-actions/types/DashboardSingleRecordActionKeys';
import { forceRegisteredActionsByKeyComponentState } from '@/action-menu/actions/states/forceRegisteredActionsMapComponentState';
import { PageLayoutComponentInstanceContext } from '@/page-layout/states/contexts/PageLayoutComponentInstanceContext';
import { isPageLayoutInEditModeComponentState } from '@/page-layout/states/isPageLayoutInEditModeComponentState';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useRecoilComponentCallbackState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackState';
import { useRecoilCallback } from 'recoil';
export const useSetIsDashboardInEditMode = (pageLayoutIdFromProps: string) => {
const pageLayoutId = useAvailableComponentInstanceIdOrThrow(
PageLayoutComponentInstanceContext,
pageLayoutIdFromProps,
);
const isPageLayoutInEditModeState = useRecoilComponentCallbackState(
isPageLayoutInEditModeComponentState,
pageLayoutId,
);
const forceRegisteredActionsByKeyState = useRecoilComponentCallbackState(
forceRegisteredActionsByKeyComponentState,
);
const setIsDashboardInEditMode = useRecoilCallback(
({ set }) =>
(value: boolean) => {
set(isPageLayoutInEditModeState, value);
set(forceRegisteredActionsByKeyState, (prev) => ({
...prev,
[DashboardSingleRecordActionKeys.EDIT_DASHBOARD]: !value,
}));
},
[forceRegisteredActionsByKeyState, isPageLayoutInEditModeState],
);
return { setIsDashboardInEditMode };
};
@@ -14,10 +14,12 @@ import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
type PageLayoutInitializationQueryEffectProps = {
pageLayoutId: string;
onInitialized: (pageLayout: PageLayoutWithData) => void;
};
export const PageLayoutInitializationQueryEffect = ({
pageLayoutId,
onInitialized,
}: PageLayoutInitializationQueryEffectProps) => {
const [isInitialized, setIsInitialized] = useState(false);
@@ -88,9 +90,10 @@ export const PageLayoutInitializationQueryEffect = ({
useEffect(() => {
if (!isInitialized && isDefined(pageLayout)) {
initializePageLayout(pageLayout);
onInitialized?.(pageLayout);
setIsInitialized(true);
}
}, [initializePageLayout, isInitialized, pageLayout]);
}, [initializePageLayout, isInitialized, pageLayout, onInitialized]);
return null;
};
@@ -2,6 +2,7 @@ import { PageLayoutInitializationQueryEffect } from '@/page-layout/components/Pa
import { PageLayoutRendererContent } from '@/page-layout/components/PageLayoutRendererContent';
import { PageLayoutComponentInstanceContext } from '@/page-layout/states/contexts/PageLayoutComponentInstanceContext';
import { type PageLayoutWithData } from '@/page-layout/types/pageLayoutTypes';
import { getTabListInstanceIdFromPageLayoutId } from '@/page-layout/utils/getTabListInstanceIdFromPageLayoutId';
import { TabListComponentInstanceContext } from '@/ui/layout/tab-list/states/contexts/TabListComponentInstanceContext';
import 'react-grid-layout/css/styles.css';
@@ -9,10 +10,12 @@ import 'react-resizable/css/styles.css';
type PageLayoutRendererProps = {
pageLayoutId: string;
onInitialized: (pageLayout: PageLayoutWithData) => void;
};
export const PageLayoutRenderer = ({
pageLayoutId,
onInitialized,
}: PageLayoutRendererProps) => {
return (
<PageLayoutComponentInstanceContext.Provider
@@ -25,7 +28,10 @@ export const PageLayoutRenderer = ({
instanceId: getTabListInstanceIdFromPageLayoutId(pageLayoutId),
}}
>
<PageLayoutInitializationQueryEffect pageLayoutId={pageLayoutId} />
<PageLayoutInitializationQueryEffect
pageLayoutId={pageLayoutId}
onInitialized={onInitialized}
/>
<PageLayoutRendererContent />
</TabListComponentInstanceContext.Provider>
</PageLayoutComponentInstanceContext.Provider>
@@ -0,0 +1,7 @@
import { type PageLayoutWithData } from '~/modules/page-layout/types/pageLayoutTypes';
export const isPageLayoutEmpty = (pageLayout: PageLayoutWithData): boolean => {
return (
pageLayout.tabs.length === 1 && pageLayout.tabs[0].widgets.length === 0
);
};
@@ -26,7 +26,6 @@ export const WidgetRenderer = ({ widget }: WidgetRendererProps) => {
const isPageLayoutInEditMode = useRecoilComponentValue(
isPageLayoutInEditModeComponentState,
widget.id,
);
return (