Add progress tracking to command menu items (#18732)

Introduces a progress indicator for command menu items (both engine
commands and front components). When a command is running, the menu item
now displays a percentage alongside the loader spinner instead of just a
spinner.

- Added `commandMenuItemProgressFamilyState` to track per-item progress
and `CommandListItemLoader` to render it
- Exposed `updateProgress` in the twenty-sdk public API so front
components can report execution progress back to the host
- Wired progress reporting into `ExportMultipleRecordsCommand` as the
first consumer, showing CSV export progress
- Progress state is cleaned up on unmount for both engine commands and
headless front components
- Refactor
This commit is contained in:
Raphaël Bosi
2026-03-18 15:37:27 +01:00
committed by GitHub
parent eb51f8e6da
commit f2fa958f20
23 changed files with 348 additions and 110 deletions
@@ -5,22 +5,24 @@ import { SelectableListItem } from '@/ui/layout/selectable-list/components/Selec
import { useNavigate } from 'react-router-dom';
import { isDefined } from 'twenty-shared/utils';
import { Loader } from 'twenty-ui/feedback';
import { CommandListItemLoader } from './CommandListItemLoader';
export const CommandListItem = ({
action,
onClick,
to,
disabled = false,
progress,
showDisabledLoader = false,
}: {
action: CommandMenuItemDisplayProps;
onClick?: () => void;
to?: string;
disabled?: boolean;
progress?: number;
showDisabledLoader?: boolean;
}) => {
const navigate = useNavigate();
const handleClick = () => {
if (disabled) {
return;
@@ -32,6 +34,15 @@ export const CommandListItem = ({
}
};
const loaderComponent =
disabled && showDisabledLoader ? (
isDefined(progress) ? (
<CommandListItemLoader progress={progress} />
) : (
<Loader />
)
) : undefined;
return (
<SelectableListItem itemId={action.key} onEnter={handleClick}>
<CommandMenuItem
@@ -43,7 +54,7 @@ export const CommandListItem = ({
onClick={disabled ? undefined : onClick}
hotKeys={action.hotKeys}
disabled={disabled}
RightComponent={disabled && showDisabledLoader ? <Loader /> : undefined}
RightComponent={loaderComponent}
/>
</SelectableListItem>
);
@@ -0,0 +1,24 @@
import { styled } from '@linaria/react';
import { Loader } from 'twenty-ui/feedback';
import { themeCssVariables } from 'twenty-ui/theme-constants';
const StyledContainer = styled.div`
align-items: center;
display: flex;
gap: ${themeCssVariables.spacing[2]};
`;
const StyledProgressText = styled.span`
color: ${themeCssVariables.font.color.tertiary};
font-size: ${themeCssVariables.font.size.sm};
white-space: nowrap;
`;
export const CommandListItemLoader = ({ progress }: { progress: number }) => {
return (
<StyledContainer>
<StyledProgressText>{Math.round(progress)}%</StyledProgressText>
<Loader />
</StyledContainer>
);
};
@@ -26,11 +26,13 @@ export const CommandMenuItemDisplay = ({
onClick,
to,
disabled,
progress,
showDisabledLoader = false,
}: {
onClick?: (event?: React.MouseEvent<HTMLElement>) => void;
to?: string;
disabled?: boolean;
progress?: number;
showDisabledLoader?: boolean;
}) => {
const action = useContext(CommandConfigContext);
@@ -66,6 +68,7 @@ export const CommandMenuItemDisplay = ({
onClick={onClickWhenEnabled}
to={toWhenEnabled}
disabled={isDisabled}
progress={progress}
showDisabledLoader={showDisabledLoader}
/>
);
@@ -0,0 +1,43 @@
import { isEngineCommandMountedFamilySelector } from '@/command-menu-item/engine-command/selectors/isEngineCommandMountedFamilySelector';
import { useMountEngineCommand } from '@/command-menu-item/engine-command/hooks/useMountEngineCommand';
import { ContextStoreComponentInstanceContext } from '@/context-store/states/contexts/ContextStoreComponentInstanceContext';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useAtomFamilySelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilySelectorValue';
import { type EngineComponentKey } from '~/generated-metadata/graphql';
import { HeadlessCommandMenuItem } from './HeadlessCommandMenuItem';
export const EngineCommandMenuItem = ({
commandMenuItemId,
engineComponentKey,
}: {
commandMenuItemId: string;
engineComponentKey: EngineComponentKey;
}) => {
const mountEngineCommand = useMountEngineCommand();
const contextStoreInstanceId = useAvailableComponentInstanceIdOrThrow(
ContextStoreComponentInstanceContext,
);
const isMounted = useAtomFamilySelectorValue(
isEngineCommandMountedFamilySelector,
commandMenuItemId,
);
const handleClick = () => {
mountEngineCommand(
commandMenuItemId,
contextStoreInstanceId,
engineComponentKey,
);
};
return (
<HeadlessCommandMenuItem
isMounted={isMounted}
commandMenuItemId={commandMenuItemId}
onClick={handleClick}
/>
);
};
@@ -0,0 +1,52 @@
import { CommandConfigContext } from '@/command-menu-item/contexts/CommandConfigContext';
import { useCloseCommandMenu } from '@/command-menu-item/hooks/useCloseCommandMenu';
import { commandMenuItemProgressFamilyState } from '@/command-menu-item/states/commandMenuItemProgressFamilyState';
import { useAtomFamilyStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilyStateValue';
import { useContext } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { CommandMenuItemDisplay } from './CommandMenuItemDisplay';
export const HeadlessCommandMenuItem = ({
isMounted,
commandMenuItemId,
onClick,
}: {
isMounted: boolean;
commandMenuItemId: string;
onClick: () => void;
}) => {
const commandMenuItemConfig = useContext(CommandConfigContext);
const { closeCommandMenu } = useCloseCommandMenu({
closeSidePanelOnShowPageOptionsExecution: false,
closeSidePanelOnCommandMenuListExecution: false,
});
const commandMenuItemProgress = useAtomFamilyStateValue(
commandMenuItemProgressFamilyState,
commandMenuItemId,
);
if (!isDefined(commandMenuItemConfig)) {
return null;
}
const handleClick = () => {
if (isMounted) {
return;
}
closeCommandMenu();
onClick();
};
return (
<CommandMenuItemDisplay
onClick={handleClick}
disabled={isMounted}
progress={commandMenuItemProgress}
showDisabledLoader={isMounted}
/>
);
};
@@ -1,48 +1,40 @@
import { CommandConfigContext } from '@/command-menu-item/contexts/CommandConfigContext';
import { useCloseCommandMenu } from '@/command-menu-item/hooks/useCloseCommandMenu';
import { useMountHeadlessFrontComponent } from '@/front-components/hooks/useMountHeadlessFrontComponent';
import { isHeadlessFrontComponentMountedFamilySelector } from '@/front-components/selectors/isHeadlessFrontComponentMountedFamilySelector';
import { useAtomFamilySelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilySelectorValue';
import { useContext } from 'react';
import { CommandMenuItemDisplay } from './CommandMenuItemDisplay';
import { HeadlessCommandMenuItem } from './HeadlessCommandMenuItem';
export const HeadlessFrontComponentCommandMenuItem = ({
frontComponentId,
onClick,
commandMenuItemId,
recordId,
objectNameSingular,
}: {
frontComponentId: string;
onClick: () => void;
commandMenuItemId: string;
recordId?: string;
objectNameSingular?: string;
}) => {
const commandMenuItemConfig = useContext(CommandConfigContext);
const { closeCommandMenu } = useCloseCommandMenu({
closeSidePanelOnShowPageOptionsExecution: false,
closeSidePanelOnCommandMenuListExecution: false,
});
const mountHeadlessFrontComponent = useMountHeadlessFrontComponent();
const isMounted = useAtomFamilySelectorValue(
isHeadlessFrontComponentMountedFamilySelector,
frontComponentId,
);
if (!commandMenuItemConfig) {
return null;
}
const handleClick = () => {
if (isMounted) {
return;
}
closeCommandMenu();
onClick();
mountHeadlessFrontComponent(frontComponentId, {
commandMenuItemId,
recordId,
objectNameSingular,
});
};
return (
<CommandMenuItemDisplay
<HeadlessCommandMenuItem
isMounted={isMounted}
commandMenuItemId={commandMenuItemId}
onClick={handleClick}
disabled={isMounted}
showDisabledLoader={isMounted}
/>
);
};