[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
This commit is contained in:
Raphaël Bosi
2026-02-23 11:15:45 +01:00
committed by GitHub
parent 129d1ede86
commit 22bc3004b9
12 changed files with 117 additions and 5 deletions
@@ -23,9 +23,11 @@ export type ActionDisplayProps = {
export const ActionDisplay = ({
onClick,
to,
disabled,
}: {
onClick?: (event?: React.MouseEvent<HTMLElement>) => void;
to?: string;
disabled?: boolean;
}) => {
const action = useContext(ActionConfigContext);
const { displayType } = useContext(ActionMenuContext);
@@ -39,7 +41,14 @@ export const ActionDisplay = ({
}
if (displayType === 'listItem') {
return <ActionListItem action={action} onClick={onClick} to={to} />;
return (
<ActionListItem
action={action}
onClick={onClick}
to={to}
disabled={disabled}
/>
);
}
if (displayType === 'dropdownItem') {
@@ -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 ? <Loader /> : undefined}
/>
</SelectableListItem>
);
@@ -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 <ActionDisplay onClick={handleClick} disabled={isMounted} />;
};
@@ -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: (
<Action
component: isHeadless ? (
<HeadlessFrontComponentAction
frontComponentId={item.frontComponentId}
onClick={handleClick}
closeSidePanelOnCommandMenuListActionExecution={isHeadless}
/>
) : (
<Action onClick={handleClick} />
),
};
};
@@ -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 {
@@ -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);
},
});
@@ -95,6 +95,7 @@ const initializeHostCommunicationApi: WorkerExports['initializeHostCommunication
hostApi.openSidePanelPage;
frontComponentHostCommunicationApi.unmountFrontComponent =
hostApi.unmountFrontComponent;
frontComponentHostCommunicationApi.closeSidePanel = hostApi.closeSidePanel;
};
const updateContext: WorkerExports['updateContext'] = async (
@@ -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;
};
@@ -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();
};
@@ -21,10 +21,13 @@ export type OpenSidePanelPageFunction = (params: {
export type UnmountFrontComponentFunction = () => Promise<void>;
export type CloseSidePanelFunction = () => Promise<void>;
export type FrontComponentHostCommunicationApiStore = {
navigate?: NavigateFunction;
openSidePanelPage?: OpenSidePanelPageFunction;
unmountFrontComponent?: UnmountFrontComponentFunction;
closeSidePanel?: CloseSidePanelFunction;
};
declare global {
@@ -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';
+1
View File
@@ -74,6 +74,7 @@ export type { ActionOpenSidePanelPageProps } from './action';
// Front Component API exports
export {
closeSidePanel,
navigate,
openSidePanelPage,
unmountFrontComponent,