From 22bc3004b97f7963c0149c41acf76573b5edae56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Bosi?= <71827178+bosiraphael@users.noreply.github.com> Date: Mon, 23 Feb 2026 11:15:45 +0100 Subject: [PATCH] [FRONT COMPONENTS] Add loader to command menu items (#18165) ## PR description - Add a loader to the command menu items when the action is running. - Add a new method `closeSidePanel` to the front component host api. ## Video QA https://github.com/user-attachments/assets/c20b592a-6e4c-4cab-b5da-4cb2316acc7c --- .../display/components/ActionDisplay.tsx | 11 ++++- .../display/components/ActionListItem.tsx | 11 ++++- .../HeadlessFrontComponentAction.tsx | 41 +++++++++++++++++++ ...seCommandMenuItemFrontComponentActions.tsx | 9 ++-- .../useFrontComponentExecutionContext.ts | 8 ++++ ...lessFrontComponentMountedFamilySelector.ts | 17 ++++++++ .../remote/worker/remote-worker.ts | 1 + .../FrontComponentHostCommunicationApi.ts | 2 + .../functions/closeSidePanel.ts | 17 ++++++++ .../frontComponentHostCommunicationApi.ts | 3 ++ .../src/sdk/front-component-api/index.ts | 1 + packages/twenty-sdk/src/sdk/index.ts | 1 + 12 files changed, 117 insertions(+), 5 deletions(-) create mode 100644 packages/twenty-front/src/modules/action-menu/actions/display/components/HeadlessFrontComponentAction.tsx create mode 100644 packages/twenty-front/src/modules/front-components/selectors/isHeadlessFrontComponentMountedFamilySelector.ts create mode 100644 packages/twenty-sdk/src/sdk/front-component-api/functions/closeSidePanel.ts diff --git a/packages/twenty-front/src/modules/action-menu/actions/display/components/ActionDisplay.tsx b/packages/twenty-front/src/modules/action-menu/actions/display/components/ActionDisplay.tsx index 9e56fbc6b8..90885d1dd9 100644 --- a/packages/twenty-front/src/modules/action-menu/actions/display/components/ActionDisplay.tsx +++ b/packages/twenty-front/src/modules/action-menu/actions/display/components/ActionDisplay.tsx @@ -23,9 +23,11 @@ export type ActionDisplayProps = { export const ActionDisplay = ({ onClick, to, + disabled, }: { onClick?: (event?: React.MouseEvent) => void; to?: string; + disabled?: boolean; }) => { const action = useContext(ActionConfigContext); const { displayType } = useContext(ActionMenuContext); @@ -39,7 +41,14 @@ export const ActionDisplay = ({ } if (displayType === 'listItem') { - return ; + return ( + + ); } if (displayType === 'dropdownItem') { diff --git a/packages/twenty-front/src/modules/action-menu/actions/display/components/ActionListItem.tsx b/packages/twenty-front/src/modules/action-menu/actions/display/components/ActionListItem.tsx index f8f68ed4a5..15a8daca5b 100644 --- a/packages/twenty-front/src/modules/action-menu/actions/display/components/ActionListItem.tsx +++ b/packages/twenty-front/src/modules/action-menu/actions/display/components/ActionListItem.tsx @@ -4,19 +4,26 @@ import { CommandMenuItem } from '@/command-menu/components/CommandMenuItem'; import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem'; import { useNavigate } from 'react-router-dom'; import { isDefined } from 'twenty-shared/utils'; +import { Loader } from 'twenty-ui/feedback'; export const ActionListItem = ({ action, onClick, to, + disabled, }: { action: ActionDisplayProps; onClick?: () => void; to?: string; + disabled?: boolean; }) => { const navigate = useNavigate(); const handleClick = () => { + if (disabled) { + return; + } + onClick?.(); if (isDefined(to)) { navigate(to); @@ -31,8 +38,10 @@ export const ActionListItem = ({ label={getActionLabel(action.label)} description={getActionLabel(action.description ?? '')} to={to} - onClick={onClick} + onClick={disabled ? undefined : onClick} hotKeys={action.hotKeys} + disabled={disabled} + RightComponent={disabled ? : undefined} /> ); diff --git a/packages/twenty-front/src/modules/action-menu/actions/display/components/HeadlessFrontComponentAction.tsx b/packages/twenty-front/src/modules/action-menu/actions/display/components/HeadlessFrontComponentAction.tsx new file mode 100644 index 0000000000..9260ae0a41 --- /dev/null +++ b/packages/twenty-front/src/modules/action-menu/actions/display/components/HeadlessFrontComponentAction.tsx @@ -0,0 +1,41 @@ +import { ActionConfigContext } from '@/action-menu/contexts/ActionConfigContext'; +import { useCloseActionMenu } from '@/action-menu/hooks/useCloseActionMenu'; +import { isHeadlessFrontComponentMountedFamilySelector } from '@/front-components/selectors/isHeadlessFrontComponentMountedFamilySelector'; +import { useContext } from 'react'; +import { useRecoilValue } from 'recoil'; + +import { ActionDisplay } from './ActionDisplay'; + +export const HeadlessFrontComponentAction = ({ + frontComponentId, + onClick, +}: { + frontComponentId: string; + onClick: () => void; +}) => { + const actionConfig = useContext(ActionConfigContext); + + const { closeActionMenu } = useCloseActionMenu({ + closeSidePanelOnShowPageOptionsActionExecution: false, + closeSidePanelOnCommandMenuListActionExecution: false, + }); + + const isMounted = useRecoilValue( + isHeadlessFrontComponentMountedFamilySelector(frontComponentId), + ); + + if (!actionConfig) { + return null; + } + + const handleClick = () => { + if (isMounted) { + return; + } + + closeActionMenu(); + onClick(); + }; + + return ; +}; diff --git a/packages/twenty-front/src/modules/command-menu-item/hooks/useCommandMenuItemFrontComponentActions.tsx b/packages/twenty-front/src/modules/command-menu-item/hooks/useCommandMenuItemFrontComponentActions.tsx index d411a0e803..225819440e 100644 --- a/packages/twenty-front/src/modules/command-menu-item/hooks/useCommandMenuItemFrontComponentActions.tsx +++ b/packages/twenty-front/src/modules/command-menu-item/hooks/useCommandMenuItemFrontComponentActions.tsx @@ -2,6 +2,7 @@ import { Action } from '@/action-menu/actions/components/Action'; import { ActionScope } from '@/action-menu/actions/types/ActionScope'; import { ActionType } from '@/action-menu/actions/types/ActionType'; import { ActionMenuContext } from '@/action-menu/contexts/ActionMenuContext'; +import { HeadlessFrontComponentAction } from '@/action-menu/actions/display/components/HeadlessFrontComponentAction'; import { useOpenFrontComponentInCommandMenu } from '@/command-menu/hooks/useOpenFrontComponentInCommandMenu'; import { contextStoreCurrentObjectMetadataItemIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataItemIdComponentState'; import { contextStoreIsPageInEditModeComponentState } from '@/context-store/states/contextStoreIsPageInEditModeComponentState'; @@ -76,11 +77,13 @@ const buildActionFromItem = ({ isPinned, Icon, shouldBeRegistered: () => true, - component: ( - + ) : ( + ), }; }; diff --git a/packages/twenty-front/src/modules/front-components/hooks/useFrontComponentExecutionContext.ts b/packages/twenty-front/src/modules/front-components/hooks/useFrontComponentExecutionContext.ts index 78a3cd3bdd..ec1a3b2947 100644 --- a/packages/twenty-front/src/modules/front-components/hooks/useFrontComponentExecutionContext.ts +++ b/packages/twenty-front/src/modules/front-components/hooks/useFrontComponentExecutionContext.ts @@ -6,6 +6,7 @@ import { import { type AppPath } from 'twenty-shared/types'; import { currentUserState } from '@/auth/states/currentUserState'; +import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu'; import { useNavigateCommandMenu } from '@/command-menu/hooks/useNavigateCommandMenu'; import { commandMenuSearchState } from '@/command-menu/states/commandMenuSearchState'; import { useUnmountHeadlessFrontComponent } from '@/front-components/hooks/useUnmountHeadlessFrontComponent'; @@ -26,6 +27,7 @@ export const useFrontComponentExecutionContext = ({ const setCommandMenuSearchState = useSetRecoilState(commandMenuSearchState); const { getIcon } = useIcons(); const unmountHeadlessFrontComponent = useUnmountHeadlessFrontComponent(); + const { closeCommandMenu } = useCommandMenu(); const navigate: FrontComponentHostCommunicationApi['navigate'] = async ( to, @@ -63,11 +65,17 @@ export const useFrontComponentExecutionContext = ({ unmountHeadlessFrontComponent(frontComponentId); }; + const closeSidePanel: FrontComponentHostCommunicationApi['closeSidePanel'] = + async () => { + closeCommandMenu(); + }; + const frontComponentHostCommunicationApi: FrontComponentHostCommunicationApi = { navigate, openSidePanelPage, unmountFrontComponent, + closeSidePanel, }; return { diff --git a/packages/twenty-front/src/modules/front-components/selectors/isHeadlessFrontComponentMountedFamilySelector.ts b/packages/twenty-front/src/modules/front-components/selectors/isHeadlessFrontComponentMountedFamilySelector.ts new file mode 100644 index 0000000000..b55c4f8062 --- /dev/null +++ b/packages/twenty-front/src/modules/front-components/selectors/isHeadlessFrontComponentMountedFamilySelector.ts @@ -0,0 +1,17 @@ +import { selectorFamily } from 'recoil'; + +import { mountedHeadlessFrontComponentIdsState } from '@/front-components/states/mountedHeadlessFrontComponentIdsState'; + +export const isHeadlessFrontComponentMountedFamilySelector = selectorFamily< + boolean, + string +>({ + key: 'isHeadlessFrontComponentMountedFamilySelector', + get: + (frontComponentId: string) => + ({ get }) => { + const mountedIds = get(mountedHeadlessFrontComponentIdsState); + + return mountedIds.has(frontComponentId); + }, +}); diff --git a/packages/twenty-sdk/src/front-component-renderer/remote/worker/remote-worker.ts b/packages/twenty-sdk/src/front-component-renderer/remote/worker/remote-worker.ts index c0765da841..00999486a3 100644 --- a/packages/twenty-sdk/src/front-component-renderer/remote/worker/remote-worker.ts +++ b/packages/twenty-sdk/src/front-component-renderer/remote/worker/remote-worker.ts @@ -95,6 +95,7 @@ const initializeHostCommunicationApi: WorkerExports['initializeHostCommunication hostApi.openSidePanelPage; frontComponentHostCommunicationApi.unmountFrontComponent = hostApi.unmountFrontComponent; + frontComponentHostCommunicationApi.closeSidePanel = hostApi.closeSidePanel; }; const updateContext: WorkerExports['updateContext'] = async ( diff --git a/packages/twenty-sdk/src/front-component-renderer/types/FrontComponentHostCommunicationApi.ts b/packages/twenty-sdk/src/front-component-renderer/types/FrontComponentHostCommunicationApi.ts index e40b85c0a8..20ff789d7c 100644 --- a/packages/twenty-sdk/src/front-component-renderer/types/FrontComponentHostCommunicationApi.ts +++ b/packages/twenty-sdk/src/front-component-renderer/types/FrontComponentHostCommunicationApi.ts @@ -1,4 +1,5 @@ import { + type CloseSidePanelFunction, type NavigateFunction, type OpenSidePanelPageFunction, type UnmountFrontComponentFunction, @@ -8,4 +9,5 @@ export type FrontComponentHostCommunicationApi = { navigate: NavigateFunction; openSidePanelPage: OpenSidePanelPageFunction; unmountFrontComponent: UnmountFrontComponentFunction; + closeSidePanel: CloseSidePanelFunction; }; diff --git a/packages/twenty-sdk/src/sdk/front-component-api/functions/closeSidePanel.ts b/packages/twenty-sdk/src/sdk/front-component-api/functions/closeSidePanel.ts new file mode 100644 index 0000000000..076582f351 --- /dev/null +++ b/packages/twenty-sdk/src/sdk/front-component-api/functions/closeSidePanel.ts @@ -0,0 +1,17 @@ +import { isDefined } from 'twenty-shared/utils'; + +import { + type CloseSidePanelFunction, + frontComponentHostCommunicationApi, +} from '../globals/frontComponentHostCommunicationApi'; + +export const closeSidePanel: CloseSidePanelFunction = () => { + const closeSidePanelFunction = + frontComponentHostCommunicationApi.closeSidePanel; + + if (!isDefined(closeSidePanelFunction)) { + throw new Error('closeSidePanelFunction is not set'); + } + + return closeSidePanelFunction(); +}; diff --git a/packages/twenty-sdk/src/sdk/front-component-api/globals/frontComponentHostCommunicationApi.ts b/packages/twenty-sdk/src/sdk/front-component-api/globals/frontComponentHostCommunicationApi.ts index 43167b6dab..b590ecd6d1 100644 --- a/packages/twenty-sdk/src/sdk/front-component-api/globals/frontComponentHostCommunicationApi.ts +++ b/packages/twenty-sdk/src/sdk/front-component-api/globals/frontComponentHostCommunicationApi.ts @@ -21,10 +21,13 @@ export type OpenSidePanelPageFunction = (params: { export type UnmountFrontComponentFunction = () => Promise; +export type CloseSidePanelFunction = () => Promise; + export type FrontComponentHostCommunicationApiStore = { navigate?: NavigateFunction; openSidePanelPage?: OpenSidePanelPageFunction; unmountFrontComponent?: UnmountFrontComponentFunction; + closeSidePanel?: CloseSidePanelFunction; }; declare global { diff --git a/packages/twenty-sdk/src/sdk/front-component-api/index.ts b/packages/twenty-sdk/src/sdk/front-component-api/index.ts index fe2a5ae6d7..ae82fd0a35 100644 --- a/packages/twenty-sdk/src/sdk/front-component-api/index.ts +++ b/packages/twenty-sdk/src/sdk/front-component-api/index.ts @@ -1,4 +1,5 @@ export { setFrontComponentExecutionContext } from './context/frontComponentContext'; +export { closeSidePanel } from './functions/closeSidePanel'; export { navigate } from './functions/navigate'; export { openSidePanelPage } from './functions/openSidePanelPage'; export { unmountFrontComponent } from './functions/unmountFrontComponent'; diff --git a/packages/twenty-sdk/src/sdk/index.ts b/packages/twenty-sdk/src/sdk/index.ts index 0e1c2dc3ee..8dea045545 100644 --- a/packages/twenty-sdk/src/sdk/index.ts +++ b/packages/twenty-sdk/src/sdk/index.ts @@ -74,6 +74,7 @@ export type { ActionOpenSidePanelPageProps } from './action'; // Front Component API exports export { + closeSidePanel, navigate, openSidePanelPage, unmountFrontComponent,