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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
+6
@@ -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],
|
||||
);
|
||||
|
||||
+59
-8
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
+16
@@ -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);
|
||||
},
|
||||
});
|
||||
+32
-59
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
import { createAtomFamilyState } from '@/ui/utilities/state/jotai/utils/createAtomFamilyState';
|
||||
|
||||
export const commandMenuItemProgressFamilyState = createAtomFamilyState<
|
||||
number | undefined,
|
||||
string
|
||||
>({
|
||||
key: 'commandMenuItemProgressFamilyState',
|
||||
defaultValue: undefined,
|
||||
});
|
||||
+3
-1
@@ -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) => {
|
||||
|
||||
+7
-3
@@ -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>
|
||||
|
||||
+19
-1
@@ -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 {
|
||||
|
||||
+2
-5
@@ -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;
|
||||
});
|
||||
},
|
||||
|
||||
+13
@@ -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],
|
||||
);
|
||||
|
||||
+5
-6
@@ -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'] =
|
||||
|
||||
+2
@@ -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);
|
||||
};
|
||||
+3
@@ -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';
|
||||
|
||||
@@ -128,6 +128,7 @@ export {
|
||||
openCommandConfirmationModal,
|
||||
openSidePanelPage,
|
||||
unmountFrontComponent,
|
||||
updateProgress,
|
||||
useFrontComponentExecutionContext,
|
||||
useFrontComponentId,
|
||||
useRecordId,
|
||||
|
||||
Reference in New Issue
Block a user