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
@@ -1,6 +1,7 @@
import { useCallback } from 'react';
import { mountedEngineCommandsState } from '@/command-menu-item/engine-command/states/mountedEngineCommandsState';
import { commandMenuItemProgressFamilyState } from '@/command-menu-item/states/commandMenuItemProgressFamilyState';
import { useStore } from 'jotai';
export const useUnmountEngineCommand = () => {
@@ -15,6 +16,11 @@ export const useUnmountEngineCommand = () => {
return newMap;
});
store.set(
commandMenuItemProgressFamilyState.atomFamily(engineCommandId),
undefined,
);
},
[store],
);
@@ -1,24 +1,75 @@
import { HeadlessEngineCommandWrapperEffect } from '@/command-menu-item/engine-command/components/HeadlessEngineCommandWrapperEffect';
import { useMountedEngineCommandContext } from '@/command-menu-item/engine-command/hooks/useMountedEngineCommandContext';
import { EngineCommandComponentInstanceContext } from '@/command-menu-item/engine-command/states/contexts/EngineCommandComponentInstanceContext';
import { commandMenuItemProgressFamilyState } from '@/command-menu-item/states/commandMenuItemProgressFamilyState';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { useRecordIndexExportRecords } from '@/object-record/record-index/export/hooks/useRecordIndexExportRecords';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useSetAtomFamilyState } from '@/ui/utilities/state/jotai/hooks/useSetAtomFamilyState';
import { ViewComponentInstanceContext } from '@/views/states/contexts/ViewComponentInstanceContext';
import { useEffect } from 'react';
import { isDefined } from 'twenty-shared/utils';
const ExportMultipleRecordsCommandContent = ({
objectMetadataItem,
recordIndexId,
setCommandMenuItemProgress,
}: {
objectMetadataItem: ObjectMetadataItem;
recordIndexId: string;
setCommandMenuItemProgress: (value: number | undefined) => void;
}) => {
const { download, progress } = useRecordIndexExportRecords({
delayMs: 100,
objectMetadataItem,
recordIndexId,
filename: `${objectMetadataItem.nameSingular}.csv`,
});
useEffect(() => {
if (
isDefined(progress.totalRecordCount) &&
isDefined(progress.processedRecordCount) &&
progress.totalRecordCount > 0
) {
const percentage = Math.round(
(progress.processedRecordCount / progress.totalRecordCount) * 100,
);
setCommandMenuItemProgress(percentage);
}
}, [progress, setCommandMenuItemProgress]);
return <HeadlessEngineCommandWrapperEffect execute={download} />;
};
export const ExportMultipleRecordsCommand = () => {
const { objectMetadataItem, recordIndexId } =
useMountedEngineCommandContext();
const engineCommandId = useAvailableComponentInstanceIdOrThrow(
EngineCommandComponentInstanceContext,
);
const setCommandMenuItemProgress = useSetAtomFamilyState(
commandMenuItemProgressFamilyState,
engineCommandId,
);
if (!isDefined(objectMetadataItem) || !isDefined(recordIndexId)) {
throw new Error(
'Object metadata item and record index ID are required to export multiple records',
);
}
const { download } = useRecordIndexExportRecords({
delayMs: 100,
objectMetadataItem,
recordIndexId,
filename: `${objectMetadataItem.nameSingular}.csv`,
});
return <HeadlessEngineCommandWrapperEffect execute={download} />;
return (
<ViewComponentInstanceContext.Provider
value={{ instanceId: recordIndexId }}
>
<ExportMultipleRecordsCommandContent
objectMetadataItem={objectMetadataItem}
recordIndexId={recordIndexId}
setCommandMenuItemProgress={setCommandMenuItemProgress}
/>
</ViewComponentInstanceContext.Provider>
);
};
@@ -0,0 +1,16 @@
import { mountedEngineCommandsState } from '@/command-menu-item/engine-command/states/mountedEngineCommandsState';
import { createAtomFamilySelector } from '@/ui/utilities/state/jotai/utils/createAtomFamilySelector';
export const isEngineCommandMountedFamilySelector = createAtomFamilySelector<
boolean,
string
>({
key: 'isEngineCommandMountedFamilySelector',
get:
(engineCommandId: string) =>
({ get }) => {
const mountedMap = get(mountedEngineCommandsState);
return mountedMap.has(engineCommandId);
},
});