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:
+13
-2
@@ -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>
|
||||
);
|
||||
|
||||
+24
@@ -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>
|
||||
);
|
||||
};
|
||||
+3
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
+43
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+52
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+17
-25
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user