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}
/>
);
};
@@ -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);
},
});
@@ -1,16 +1,15 @@
import { Command } from '@/command-menu-item/display/components/Command';
import { EngineCommandMenuItem } from '@/command-menu-item/display/components/EngineCommandMenuItem';
import { HeadlessFrontComponentCommandMenuItem } from '@/command-menu-item/display/components/HeadlessFrontComponentCommandMenuItem';
import { useMountEngineCommand } from '@/command-menu-item/engine-command/hooks/useMountEngineCommand';
import { CommandMenuItemScope } from '@/command-menu-item/types/CommandMenuItemScope';
import { CommandMenuItemType } from '@/command-menu-item/types/CommandMenuItemType';
import { contextStoreCurrentObjectMetadataItemIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataItemIdComponentState';
import { contextStoreIsPageInEditModeComponentState } from '@/context-store/states/contextStoreIsPageInEditModeComponentState';
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
import { ContextStoreComponentInstanceContext } from '@/context-store/states/contexts/ContextStoreComponentInstanceContext';
import { useMountHeadlessFrontComponent } from '@/front-components/hooks/useMountHeadlessFrontComponent';
import { objectMetadataItemsSelector } from '@/object-metadata/states/objectMetadataItemsSelector';
import { useOpenFrontComponentInSidePanel } from '@/side-panel/hooks/useOpenFrontComponentInSidePanel';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { type CommandMenuContextApi } from 'twenty-shared/types';
@@ -21,7 +20,6 @@ import {
} from 'twenty-shared/utils';
import { type IconComponent, useIcons } from 'twenty-ui/display';
import { type HeadlessFrontComponentMountContext } from '@/front-components/states/mountedHeadlessFrontComponentMapsState';
import { COMMAND_MENU_DEFAULT_ICON } from '@/workflow/workflow-trigger/constants/CommandMenuDefaultIcon';
import { useQuery } from '@apollo/client/react';
import {
@@ -55,11 +53,8 @@ type BuildCommandMenuItemFromFrontComponentParams = {
objectNameSingular: string;
};
}) => void;
mountHeadlessFrontComponent: (
frontComponentId: string,
context?: HeadlessFrontComponentMountContext,
) => void;
mountContext?: HeadlessFrontComponentMountContext;
recordId?: string;
objectNameSingular?: string;
commandMenuContextApi: CommandMenuContextApi;
};
@@ -70,8 +65,8 @@ const buildCommandMenuItemFromFrontComponent = ({
isPinned,
getIcon,
openFrontComponentInSidePanel,
mountHeadlessFrontComponent,
mountContext,
recordId,
objectNameSingular,
commandMenuContextApi,
}: BuildCommandMenuItemFromFrontComponentParams) => {
const displayLabel = interpolateCommandMenuItemLabel({
@@ -88,22 +83,16 @@ const buildCommandMenuItemFromFrontComponent = ({
const isHeadless = item.frontComponent?.isHeadless === true;
const handleClick = () => {
if (isHeadless) {
mountHeadlessFrontComponent(item.frontComponentId, mountContext);
} else {
openFrontComponentInSidePanel({
frontComponentId: item.frontComponentId,
pageTitle: displayLabel ?? '',
pageIcon: Icon,
recordContext: isDefined(mountContext)
? {
recordId: mountContext.recordId,
objectNameSingular: mountContext.objectNameSingular,
}
const handleNonHeadlessClick = () => {
openFrontComponentInSidePanel({
frontComponentId: item.frontComponentId,
pageTitle: displayLabel ?? '',
pageIcon: Icon,
recordContext:
isDefined(recordId) && isDefined(objectNameSingular)
? { recordId, objectNameSingular }
: undefined,
});
}
});
};
return {
@@ -124,10 +113,12 @@ const buildCommandMenuItemFromFrontComponent = ({
component: isHeadless ? (
<HeadlessFrontComponentCommandMenuItem
frontComponentId={item.frontComponentId}
onClick={handleClick}
commandMenuItemId={item.id}
recordId={recordId}
objectNameSingular={objectNameSingular}
/>
) : (
<Command onClick={handleClick} />
<Command onClick={handleNonHeadlessClick} />
),
};
};
@@ -140,12 +131,6 @@ type BuildCommandMenuItemFromStandardKeyParams = {
isPinned: boolean;
getIcon: ReturnType<typeof useIcons>['getIcon'];
commandMenuContextApi: CommandMenuContextApi;
mountEngineCommand: (
engineCommandId: string,
contextStoreInstanceId: string,
engineComponentKey: EngineComponentKey,
) => void;
contextStoreInstanceId: string;
};
const buildCommandItemFromEngineKey = ({
@@ -156,15 +141,9 @@ const buildCommandItemFromEngineKey = ({
isPinned,
getIcon,
commandMenuContextApi,
mountEngineCommand,
contextStoreInstanceId,
}: BuildCommandMenuItemFromStandardKeyParams) => {
const Icon = getIcon(item.icon, COMMAND_MENU_DEFAULT_ICON);
const handleClick = () => {
mountEngineCommand(item.id, contextStoreInstanceId, engineComponentKey);
};
return {
type,
key: `command-menu-item-engine-${item.id}`,
@@ -186,7 +165,12 @@ const buildCommandItemFromEngineKey = ({
item.conditionalAvailabilityExpression,
commandMenuContextApi,
),
component: <Command onClick={handleClick} />,
component: (
<EngineCommandMenuItem
commandMenuItemId={item.id}
engineComponentKey={engineComponentKey}
/>
),
};
};
@@ -195,12 +179,6 @@ export const useCommandMenuItemsFromBackend = (
) => {
const { getIcon } = useIcons();
const { openFrontComponentInSidePanel } = useOpenFrontComponentInSidePanel();
const mountHeadlessFrontComponent = useMountHeadlessFrontComponent();
const mountEngineCommand = useMountEngineCommand();
const contextStoreInstanceId = useAvailableComponentInstanceIdOrThrow(
ContextStoreComponentInstanceContext,
);
const contextStoreIsPageInEditMode = useAtomComponentStateValue(
contextStoreIsPageInEditModeComponentState,
@@ -229,13 +207,10 @@ export const useCommandMenuItemsFromBackend = (
selectedRecordIds.length >= 1 ||
contextStoreTargetedRecordsRule.mode === 'exclusion';
const mountContext: HeadlessFrontComponentMountContext | undefined =
selectedRecordIds.length === 1 && isDefined(currentObjectMetadataItem)
? {
recordId: selectedRecordIds[0],
objectNameSingular: currentObjectMetadataItem.nameSingular,
}
: undefined;
const recordId =
selectedRecordIds.length === 1 ? selectedRecordIds[0] : undefined;
const objectNameSingular = currentObjectMetadataItem?.nameSingular;
const { data } = useQuery(FindManyCommandMenuItemsDocument);
@@ -268,8 +243,6 @@ export const useCommandMenuItemsFromBackend = (
isPinned,
getIcon,
commandMenuContextApi,
mountEngineCommand,
contextStoreInstanceId,
});
}
@@ -281,9 +254,9 @@ export const useCommandMenuItemsFromBackend = (
isPinned,
getIcon,
openFrontComponentInSidePanel,
mountHeadlessFrontComponent,
commandMenuContextApi,
mountContext,
recordId,
objectNameSingular,
});
}
@@ -0,0 +1,9 @@
import { createAtomFamilyState } from '@/ui/utilities/state/jotai/utils/createAtomFamilyState';
export const commandMenuItemProgressFamilyState = createAtomFamilyState<
number | undefined,
string
>({
key: 'commandMenuItemProgressFamilyState',
defaultValue: undefined,
});
@@ -16,10 +16,12 @@ import { FindOneFrontComponentDocument } from '~/generated-metadata/graphql';
type FrontComponentRendererProps = {
frontComponentId: string;
commandMenuItemId?: string;
};
export const FrontComponentRenderer = ({
frontComponentId,
commandMenuItemId,
}: FrontComponentRendererProps) => {
const { colorScheme } = useContext(ThemeContext);
const { enqueueErrorSnackBar } = useSnackBar();
@@ -30,7 +32,7 @@ export const FrontComponentRenderer = ({
);
const { executionContext, frontComponentHostCommunicationApi } =
useFrontComponentExecutionContext({ frontComponentId });
useFrontComponentExecutionContext({ frontComponentId, commandMenuItemId });
const handleError = useCallback(
(error?: Error) => {
@@ -18,6 +18,7 @@ export const HeadlessFrontComponentMountRoot = () => {
const mountedHeadlessFrontComponentMaps = useAtomStateValue(
mountedHeadlessFrontComponentMapsState,
);
const unmountHeadlessFrontComponent = useUnmountHeadlessFrontComponent();
return (
@@ -33,8 +34,8 @@ export const HeadlessFrontComponentMountRoot = () => {
<LayoutRenderingProvider
value={{
targetRecordIdentifier:
isDefined(mountContext) &&
isDefined(mountContext.objectNameSingular)
isDefined(mountContext.objectNameSingular) &&
isDefined(mountContext.recordId)
? {
id: mountContext.recordId,
targetObjectNameSingular:
@@ -45,7 +46,10 @@ export const HeadlessFrontComponentMountRoot = () => {
isInSidePanel: false,
}}
>
<FrontComponentRenderer frontComponentId={frontComponentId} />
<FrontComponentRenderer
frontComponentId={frontComponentId}
commandMenuItemId={mountContext.commandMenuItemId}
/>
</LayoutRenderingProvider>
</Suspense>
</CommandMenuItemErrorBoundary>
@@ -7,6 +7,7 @@ import { type AppPath, type EnqueueSnackbarParams } from 'twenty-shared/types';
import { currentUserState } from '@/auth/states/currentUserState';
import { useCommandMenuConfirmationModal } from '@/command-menu-item/confirmation-modal/hooks/useCommandMenuConfirmationModal';
import { commandMenuItemProgressFamilyState } from '@/command-menu-item/states/commandMenuItemProgressFamilyState';
import { useRequestApplicationTokenRefresh } from '@/front-components/hooks/useRequestApplicationTokenRefresh';
import { useUnmountHeadlessFrontComponent } from '@/front-components/hooks/useUnmountHeadlessFrontComponent';
import { useNavigateSidePanel } from '@/side-panel/hooks/useNavigateSidePanel';
@@ -15,14 +16,17 @@ import { sidePanelSearchState } from '@/side-panel/states/sidePanelSearchState';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useLayoutRenderingContext } from '@/ui/layout/contexts/LayoutRenderingContext';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { assertUnreachable } from 'twenty-shared/utils';
import { useSetAtomFamilyState } from '@/ui/utilities/state/jotai/hooks/useSetAtomFamilyState';
import { assertUnreachable, isDefined } from 'twenty-shared/utils';
import { useIcons } from 'twenty-ui/display';
import { useNavigateApp } from '~/hooks/useNavigateApp';
export const useFrontComponentExecutionContext = ({
frontComponentId,
commandMenuItemId,
}: {
frontComponentId: string;
commandMenuItemId?: string;
}): {
executionContext: FrontComponentExecutionContext;
frontComponentHostCommunicationApi: FrontComponentHostCommunicationApi;
@@ -44,6 +48,10 @@ export const useFrontComponentExecutionContext = ({
enqueueWarningSnackBar,
} = useSnackBar();
const { closeSidePanelMenu } = useSidePanelMenu();
const setCommandMenuItemProgress = useSetAtomFamilyState(
commandMenuItemProgressFamilyState,
commandMenuItemId ?? '',
);
const navigate: FrontComponentHostCommunicationApi['navigate'] = async (
to,
@@ -133,6 +141,15 @@ export const useFrontComponentExecutionContext = ({
closeSidePanelMenu();
};
const updateProgress: FrontComponentHostCommunicationApi['updateProgress'] =
async (progress) => {
if (!isDefined(commandMenuItemId)) {
return;
}
setCommandMenuItemProgress(Math.max(0, Math.min(100, progress)));
};
const frontComponentHostCommunicationApi: FrontComponentHostCommunicationApi =
{
navigate,
@@ -142,6 +159,7 @@ export const useFrontComponentExecutionContext = ({
enqueueSnackbar,
unmountFrontComponent,
closeSidePanel,
updateProgress,
};
return {
@@ -9,13 +9,10 @@ import { useStore } from 'jotai';
export const useMountHeadlessFrontComponent = () => {
const store = useStore();
const mountHeadlessFrontComponent = useCallback(
(
frontComponentId: string,
context?: HeadlessFrontComponentMountContext,
) => {
(frontComponentId: string, context: HeadlessFrontComponentMountContext) => {
store.set(mountedHeadlessFrontComponentMapsState.atom, (previousMap) => {
const next = new Map(previousMap);
next.set(frontComponentId, context ?? undefined);
next.set(frontComponentId, context);
return next;
});
},
@@ -1,5 +1,6 @@
import { useCallback } from 'react';
import { commandMenuItemProgressFamilyState } from '@/command-menu-item/states/commandMenuItemProgressFamilyState';
import { mountedHeadlessFrontComponentMapsState } from '@/front-components/states/mountedHeadlessFrontComponentMapsState';
import { useStore } from 'jotai';
@@ -7,11 +8,23 @@ export const useUnmountHeadlessFrontComponent = () => {
const store = useStore();
const unmountHeadlessFrontComponent = useCallback(
(frontComponentId: string) => {
const currentMap = store.get(mountedHeadlessFrontComponentMapsState.atom);
const mountContext = currentMap.get(frontComponentId);
store.set(mountedHeadlessFrontComponentMapsState.atom, (previousMap) => {
const next = new Map(previousMap);
next.delete(frontComponentId);
return next;
});
if (mountContext) {
store.set(
commandMenuItemProgressFamilyState.atomFamily(
mountContext.commandMenuItemId,
),
undefined,
);
}
},
[store],
);
@@ -1,11 +1,10 @@
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
export type HeadlessFrontComponentMountContext =
| {
recordId: string;
objectNameSingular: string;
}
| undefined;
export type HeadlessFrontComponentMountContext = {
commandMenuItemId: string;
recordId?: string;
objectNameSingular?: string;
};
export const mountedHeadlessFrontComponentMapsState = createAtomState<
Map<string, HeadlessFrontComponentMountContext>
@@ -108,6 +108,7 @@ const initializeHostCommunicationApi: WorkerExports['initializeHostCommunication
frontComponentHostCommunicationApi.enqueueSnackbar =
hostApi.enqueueSnackbar;
frontComponentHostCommunicationApi.closeSidePanel = hostApi.closeSidePanel;
frontComponentHostCommunicationApi.updateProgress = hostApi.updateProgress;
};
const onConfirmationModalResult: WorkerExports['onConfirmationModalResult'] =
@@ -6,6 +6,7 @@ import {
type OpenSidePanelPageFunction,
type RequestAccessTokenRefreshFunction,
type UnmountFrontComponentFunction,
type UpdateProgressFunction,
} from '../../sdk/front-component-api/globals/frontComponentHostCommunicationApi';
export type FrontComponentHostCommunicationApi = {
@@ -16,4 +17,5 @@ export type FrontComponentHostCommunicationApi = {
unmountFrontComponent: UnmountFrontComponentFunction;
enqueueSnackbar: EnqueueSnackbarFunction;
closeSidePanel: CloseSidePanelFunction;
updateProgress: UpdateProgressFunction;
};
@@ -0,0 +1,17 @@
import { isDefined } from 'twenty-shared/utils';
import {
frontComponentHostCommunicationApi,
type UpdateProgressFunction,
} from '../globals/frontComponentHostCommunicationApi';
export const updateProgress: UpdateProgressFunction = (progress) => {
const updateProgressFunction =
frontComponentHostCommunicationApi.updateProgress;
if (!isDefined(updateProgressFunction)) {
throw new Error('updateProgressFunction is not set');
}
return updateProgressFunction(progress);
};
@@ -39,6 +39,8 @@ export type EnqueueSnackbarFunction = (
export type CloseSidePanelFunction = () => Promise<void>;
export type UpdateProgressFunction = (progress: number) => Promise<void>;
export type RequestAccessTokenRefreshFunction = () => Promise<string>;
export type OpenCommandConfirmationModalHostFunction = (
@@ -53,6 +55,7 @@ export type FrontComponentHostCommunicationApiStore = {
unmountFrontComponent?: UnmountFrontComponentFunction;
enqueueSnackbar?: EnqueueSnackbarFunction;
closeSidePanel?: CloseSidePanelFunction;
updateProgress?: UpdateProgressFunction;
};
declare global {
@@ -33,6 +33,7 @@ export { navigate } from './functions/navigate';
export { openCommandConfirmationModal } from './functions/openCommandConfirmationModal';
export { openSidePanelPage } from './functions/openSidePanelPage';
export { unmountFrontComponent } from './functions/unmountFrontComponent';
export { updateProgress } from './functions/updateProgress';
export { useFrontComponentExecutionContext } from './hooks/useFrontComponentExecutionContext';
export { useFrontComponentId } from './hooks/useFrontComponentId';
export { useRecordId } from './hooks/useRecordId';
+1
View File
@@ -128,6 +128,7 @@ export {
openCommandConfirmationModal,
openSidePanelPage,
unmountFrontComponent,
updateProgress,
useFrontComponentExecutionContext,
useFrontComponentId,
useRecordId,