Action menu refactoring (#11454)

# Description

Closes [#696](https://github.com/twentyhq/core-team-issues/issues/696)

- `useAction` hooks have been removed for all actions
- Every action can now declare a react component
- Some standard action components have been introduced: `Action`,
`ActionLink` and `ActionModal`
- The `ActionDisplay` component uses the new `displayType` prop of the
`ActionMenuContext` to render the right component for the action
according to its container: `ActionButton`, `ActionDropdownItem` or
`ActionListItem`
- The `ActionDisplayer` wraps the action component inside a context
which gives it all the information about the action
-`actionMenuEntriesComponenState` has been removed and now all actions
are computed directly using `useRegisteredAction`
- This computation is done inside `ActionMenuContextProvider` and the
actions are passed inside a context
- `actionMenuType` gives information about the container of the action,
so the action can know wether or not to close this container upon
execution
This commit is contained in:
Raphaël Bosi
2025-04-09 15:12:49 +02:00
committed by GitHub
parent 1834b38d04
commit 9e0402e691
235 changed files with 6252 additions and 7590 deletions

View File

@ -1,10 +1,10 @@
import { ActionConfig } from '@/action-menu/actions/types/ActionConfig';
import { CommandGroup } from '@/command-menu/components/CommandGroup';
import { CommandMenuList } from '@/command-menu/components/CommandMenuList';
import { ResetContextToSelectionCommandButton } from '@/command-menu/components/ResetContextToSelectionCommandButton';
import { RESET_CONTEXT_TO_SELECTION } from '@/command-menu/constants/ResetContextToSelection';
import { useMatchingCommandMenuCommands } from '@/command-menu/hooks/useMatchingCommandMenuCommands';
import { useMatchingCommandMenuActions } from '@/command-menu/hooks/useMatchingCommandMenuActions';
import { commandMenuSearchState } from '@/command-menu/states/commandMenuSearchState';
import { Command } from '@/command-menu/types/Command';
import { contextStoreCurrentObjectMetadataItemIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataItemIdComponentState';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { SelectableItem } from '@/ui/layout/selectable-list/components/SelectableItem';
@ -13,9 +13,9 @@ import { useLingui } from '@lingui/react/macro';
import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
export type CommandGroupConfig = {
export type ActionGroupConfig = {
heading: string;
items?: Command[];
items?: ActionConfig[];
};
export const CommandMenu = () => {
@ -26,14 +26,14 @@ export const CommandMenu = () => {
const {
noResults,
matchingStandardActionRecordSelectionCommands,
matchingStandardActionObjectCommands,
matchingWorkflowRunRecordSelectionCommands,
matchingStandardActionGlobalCommands,
matchingWorkflowRunGlobalCommands,
matchingNavigateCommands,
fallbackCommands,
} = useMatchingCommandMenuCommands({
matchingStandardActionRecordSelectionActions,
matchingStandardActionObjectActions,
matchingWorkflowRunRecordSelectionActions,
matchingStandardActionGlobalActions,
matchingWorkflowRunGlobalActions,
matchingNavigateActions,
fallbackActions,
} = useMatchingCommandMenuActions({
commandMenuSearch,
});
@ -50,34 +50,32 @@ export const CommandMenu = () => {
(item) => item.id === objectMetadataItemId,
);
const commandGroups: CommandGroupConfig[] = [
const commandGroups: ActionGroupConfig[] = [
{
heading: t`Record Selection`,
items: matchingStandardActionRecordSelectionCommands.concat(
matchingWorkflowRunRecordSelectionCommands,
items: matchingStandardActionRecordSelectionActions.concat(
matchingWorkflowRunRecordSelectionActions,
),
},
{
heading: currentObjectMetadataItem?.labelPlural ?? t`Object`,
items: matchingStandardActionObjectCommands,
items: matchingStandardActionObjectActions,
},
{
heading: t`Global`,
items: matchingStandardActionGlobalCommands
.concat(matchingWorkflowRunGlobalCommands)
.concat(matchingNavigateCommands),
items: matchingStandardActionGlobalActions
.concat(matchingWorkflowRunGlobalActions)
.concat(matchingNavigateActions),
},
{
heading: t`Search ''${commandMenuSearch}'' with...`,
items: fallbackCommands,
items: fallbackActions,
},
];
const selectableItems: Command[] = commandGroups.flatMap(
(group) => group.items ?? [],
);
const selectableItems = commandGroups.flatMap((group) => group.items ?? []);
const selectableItemIds = selectableItems.map((item) => item.id);
const selectableItemIds = selectableItems.map((item) => item.key);
if (isDefined(previousContextStoreCurrentObjectMetadataItemId)) {
selectableItemIds.unshift(RESET_CONTEXT_TO_SELECTION);

View File

@ -1,17 +1,9 @@
import { RecordActionMenuEntriesSetter } from '@/action-menu/actions/record-actions/components/RecordActionMenuEntriesSetter';
import { NoSelectionRecordActionKeys } from '@/action-menu/actions/record-actions/no-selection/types/NoSelectionRecordActionsKeys';
import { RecordAgnosticActionMenuEntriesSetter } from '@/action-menu/actions/record-agnostic-actions/components/RecordAgnosticActionMenuEntriesSetter';
import { RunWorkflowRecordAgnosticActionMenuEntriesSetter } from '@/action-menu/actions/record-agnostic-actions/components/RunWorkflowRecordAgnosticActionMenuEntriesSetter';
import { RecordAgnosticActionsKeys } from '@/action-menu/actions/record-agnostic-actions/types/RecordAgnosticActionsKeys';
import { ActionMenuConfirmationModals } from '@/action-menu/components/ActionMenuConfirmationModals';
import { ActionMenuContext } from '@/action-menu/contexts/ActionMenuContext';
import { ActionMenuComponentInstanceContext } from '@/action-menu/states/contexts/ActionMenuComponentInstanceContext';
import { COMMAND_MENU_ANIMATION_VARIANTS } from '@/command-menu/constants/CommandMenuAnimationVariants';
import { COMMAND_MENU_COMPONENT_INSTANCE_ID } from '@/command-menu/constants/CommandMenuComponentInstanceId';
import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
import { useCommandMenuCloseAnimationCompleteCleanup } from '@/command-menu/hooks/useCommandMenuCloseAnimationCompleteCleanup';
import { useCommandMenuHotKeys } from '@/command-menu/hooks/useCommandMenuHotKeys';
import { commandMenuSearchState } from '@/command-menu/states/commandMenuSearchState';
import { isCommandMenuOpenedState } from '@/command-menu/states/isCommandMenuOpenedState';
import { CommandMenuAnimationVariant } from '@/command-menu/types/CommandMenuAnimationVariant';
import { CommandMenuHotkeyScope } from '@/command-menu/types/CommandMenuHotkeyScope';
@ -27,13 +19,11 @@ import { RootStackingContextZIndices } from '@/ui/layout/constants/RootStackingC
import { currentHotkeyScopeState } from '@/ui/utilities/hotkey/states/internal/currentHotkeyScopeState';
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { AnimatePresence, motion } from 'framer-motion';
import { useRef } from 'react';
import { useRecoilCallback, useRecoilValue, useSetRecoilState } from 'recoil';
import { FeatureFlagKey } from '~/generated-metadata/graphql';
import { useRecoilCallback, useRecoilValue } from 'recoil';
import { useIsMobile } from 'twenty-ui/utilities';
const StyledCommandMenu = styled(motion.div)`
@ -57,7 +47,7 @@ export const CommandMenuContainer = ({
}: {
children: React.ReactNode;
}) => {
const { toggleCommandMenu, closeCommandMenu } = useCommandMenu();
const { closeCommandMenu } = useCommandMenu();
const { commandMenuCloseAnimationCompleteCleanup } =
useCommandMenuCloseAnimationCompleteCleanup();
@ -97,12 +87,6 @@ export const CommandMenuContainer = ({
const theme = useTheme();
const isWorkflowEnabled = useIsFeatureEnabled(
FeatureFlagKey.IsWorkflowEnabled,
);
const setCommandMenuSearch = useSetRecoilState(commandMenuSearchState);
const objectMetadataItemId = useRecoilComponentValueV2(
contextStoreCurrentObjectMetadataItemIdComponentState,
COMMAND_MENU_COMPONENT_INSTANCE_ID,
@ -140,53 +124,25 @@ export const CommandMenuContainer = ({
<ActionMenuComponentInstanceContext.Provider
value={{ instanceId: COMMAND_MENU_COMPONENT_INSTANCE_ID }}
>
<ActionMenuContext.Provider
value={{
isInRightDrawer: true,
onActionExecutedCallback: ({ key }) => {
if (
key !== RecordAgnosticActionsKeys.SEARCH_RECORDS &&
key !==
RecordAgnosticActionsKeys.SEARCH_RECORDS_FALLBACK &&
key !== NoSelectionRecordActionKeys.CREATE_NEW_RECORD
) {
toggleCommandMenu();
}
if (
key !== RecordAgnosticActionsKeys.SEARCH_RECORDS_FALLBACK
) {
setCommandMenuSearch('');
}
},
}}
<AnimatePresence
mode="wait"
onExitComplete={commandMenuCloseAnimationCompleteCleanup}
>
<RecordActionMenuEntriesSetter />
<RecordAgnosticActionMenuEntriesSetter />
{isWorkflowEnabled && (
<RunWorkflowRecordAgnosticActionMenuEntriesSetter />
{isCommandMenuOpened && (
<StyledCommandMenu
data-testid="command-menu"
ref={commandMenuRef}
className="command-menu"
animate={targetVariantForAnimation}
initial="closed"
exit="closed"
variants={COMMAND_MENU_ANIMATION_VARIANTS}
transition={{ duration: theme.animation.duration.normal }}
>
{children}
</StyledCommandMenu>
)}
<ActionMenuConfirmationModals />
<AnimatePresence
mode="wait"
onExitComplete={commandMenuCloseAnimationCompleteCleanup}
>
{isCommandMenuOpened && (
<StyledCommandMenu
data-testid="command-menu"
ref={commandMenuRef}
className="command-menu"
animate={targetVariantForAnimation}
initial="closed"
exit="closed"
variants={COMMAND_MENU_ANIMATION_VARIANTS}
transition={{ duration: theme.animation.duration.normal }}
>
{children}
</StyledCommandMenu>
)}
</AnimatePresence>
</ActionMenuContext.Provider>
</AnimatePresence>
</ActionMenuComponentInstanceContext.Provider>
</ContextStoreComponentInstanceContext.Provider>
</RecordSortsComponentInstanceContext.Provider>

View File

@ -27,7 +27,6 @@ export const CommandMenuItem = ({
onClick,
Icon,
hotKeys,
shouldCloseCommandMenuOnClick,
RightComponent,
}: CommandMenuItemProps) => {
const { onItemClick } = useCommandMenuOnItemClick();
@ -47,7 +46,6 @@ export const CommandMenuItem = ({
hotKeys={hotKeys}
onClick={() =>
onItemClick({
shouldCloseCommandMenuOnClick,
onClick,
to,
})

View File

@ -1,26 +1,23 @@
import { ActionComponent } from '@/action-menu/actions/display/components/ActionComponent';
import { CommandGroup } from '@/command-menu/components/CommandGroup';
import { CommandGroupConfig } from '@/command-menu/components/CommandMenu';
import { ActionGroupConfig } from '@/command-menu/components/CommandMenu';
import { CommandMenuDefaultSelectionEffect } from '@/command-menu/components/CommandMenuDefaultSelectionEffect';
import { CommandMenuItem } from '@/command-menu/components/CommandMenuItem';
import { COMMAND_MENU_SEARCH_BAR_HEIGHT } from '@/command-menu/constants/CommandMenuSearchBarHeight';
import { COMMAND_MENU_SEARCH_BAR_PADDING } from '@/command-menu/constants/CommandMenuSearchBarPadding';
import { RESET_CONTEXT_TO_SELECTION } from '@/command-menu/constants/ResetContextToSelection';
import { useCommandMenuOnItemClick } from '@/command-menu/hooks/useCommandMenuOnItemClick';
import { useResetPreviousCommandMenuContext } from '@/command-menu/hooks/useResetPreviousCommandMenuContext';
import { hasUserSelectedCommandState } from '@/command-menu/states/hasUserSelectedCommandState';
import { SelectableItem } from '@/ui/layout/selectable-list/components/SelectableItem';
import { SelectableList } from '@/ui/layout/selectable-list/components/SelectableList';
import { AppHotkeyScope } from '@/ui/utilities/hotkey/types/AppHotkeyScope';
import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper';
import styled from '@emotion/styled';
import { useSetRecoilState } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { MOBILE_VIEWPORT } from 'twenty-ui/theme';
const MOBILE_NAVIGATION_BAR_HEIGHT = 64;
export type CommandMenuListProps = {
commandGroups: CommandGroupConfig[];
commandGroups: ActionGroupConfig[];
selectableItemIds: string[];
children?: React.ReactNode;
loading?: boolean;
@ -63,10 +60,6 @@ export const CommandMenuList = ({
loading = false,
noResults = false,
}: CommandMenuListProps) => {
const { onItemClick } = useCommandMenuOnItemClick();
const commands = commandGroups.flatMap((group) => group.items ?? []);
const { resetPreviousCommandMenuContext } =
useResetPreviousCommandMenuContext();
@ -90,19 +83,6 @@ export const CommandMenuList = ({
resetPreviousCommandMenuContext();
return;
}
const command = commands.find((item) => item.id === itemId);
if (isDefined(command)) {
const { to, onCommandClick, shouldCloseCommandMenuOnClick } =
command;
onItemClick({
shouldCloseCommandMenuOnClick,
onClick: onCommandClick,
to,
});
}
}}
onSelect={() => {
setHasUserSelectedCommand(true);
@ -112,24 +92,9 @@ export const CommandMenuList = ({
{commandGroups.map(({ heading, items }) =>
items?.length ? (
<CommandGroup heading={heading} key={heading}>
{items.map((item) => {
return (
<SelectableItem itemId={item.id} key={item.id}>
<CommandMenuItem
id={item.id}
Icon={item.Icon}
label={item.label}
description={item.description}
to={item.to}
onClick={item.onCommandClick}
hotKeys={item.hotKeys}
shouldCloseCommandMenuOnClick={
item.shouldCloseCommandMenuOnClick
}
/>
</SelectableItem>
);
})}
{items.map((item) => (
<ActionComponent action={item} key={item.key} />
))}
</CommandGroup>
) : null,
)}

View File

@ -1,3 +1,4 @@
import { ActionMenuContextProvider } from '@/action-menu/contexts/ActionMenuContextProvider';
import { CommandMenuContainer } from '@/command-menu/components/CommandMenuContainer';
import { CommandMenuContextChipRecordSetterEffect } from '@/command-menu/components/CommandMenuContextChipRecordSetterEffect';
import { CommandMenuTopBar } from '@/command-menu/components/CommandMenuTopBar';
@ -47,7 +48,13 @@ export const CommandMenuRouter = () => {
<CommandMenuTopBar />
</motion.div>
<StyledCommandMenuContent>
{commandMenuPageComponent}
<ActionMenuContextProvider
isInRightDrawer={true}
displayType="listItem"
actionMenuType="command-menu"
>
{commandMenuPageComponent}
</ActionMenuContextProvider>
</StyledCommandMenuContent>
</CommandMenuPageComponentInstanceContext.Provider>
</CommandMenuContainer>

View File

@ -26,10 +26,10 @@ import { RecordFilterGroupsComponentInstanceContext } from '@/object-record/reco
import { RecordFiltersComponentInstanceContext } from '@/object-record/record-filter/states/context/RecordFiltersComponentInstanceContext';
import { RecordSortsComponentInstanceContext } from '@/object-record/record-sort/states/context/RecordSortsComponentInstanceContext';
import { HttpResponse, graphql } from 'msw';
import { IconDotsVertical } from 'twenty-ui/display';
import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator';
import { JestContextStoreSetter } from '~/testing/jest/JestContextStoreSetter';
import { CommandMenu } from '../CommandMenu';
import { IconDotsVertical } from 'twenty-ui/display';
const openTimeout = 50;
@ -115,10 +115,10 @@ export const DefaultWithoutSearch: Story = {
const canvas = within(document.body);
expect(await canvas.findByText('Go to People')).toBeVisible();
expect(await canvas.findByText('Go to Companies')).toBeVisible();
expect(await canvas.findByText('Go to Opportunities')).toBeVisible();
expect(await canvas.findByText('Go to Settings')).toBeVisible();
expect(await canvas.findByText('Go to Tasks')).toBeVisible();
expect(await canvas.findByText('Go to Notes')).toBeVisible();
},
};

View File

@ -42,13 +42,11 @@ describe('useCommandMenuOnItemClick', () => {
act(() => {
result.current.onItemClick({
shouldCloseCommandMenuOnClick: true,
onClick: onClickMock,
to: '/test',
});
});
expect(result.current.isCommandMenuOpened).toBe(true);
expect(onClickMock).toHaveBeenCalledTimes(1);
});
});

View File

@ -7,8 +7,8 @@ import { isCommandMenuClosingState } from '@/command-menu/states/isCommandMenuCl
import { CommandMenuPages } from '@/command-menu/types/CommandMenuPages';
import { isDragSelectionStartEnabledState } from '@/ui/utilities/drag-select/states/internal/isDragSelectionStartEnabledState';
import { useCallback } from 'react';
import { isCommandMenuOpenedState } from '../states/isCommandMenuOpenedState';
import { IconDotsVertical } from 'twenty-ui/display';
import { isCommandMenuOpenedState } from '../states/isCommandMenuOpenedState';
export const useCommandMenu = () => {
const { navigateCommandMenu } = useNavigateCommandMenu();

View File

@ -0,0 +1,57 @@
import { ActionConfig } from '@/action-menu/actions/types/ActionConfig';
import { ActionScope } from '@/action-menu/actions/types/ActionScope';
import { ActionType } from '@/action-menu/actions/types/ActionType';
import { ActionMenuContext } from '@/action-menu/contexts/ActionMenuContext';
import { useContext } from 'react';
export const useCommandMenuActions = () => {
const { actions } = useContext(ActionMenuContext);
const navigateActions = actions?.filter(
(action) => action.type === ActionType.Navigation,
);
const actionRecordSelectionActions: ActionConfig[] = actions?.filter(
(action) =>
action.type === ActionType.Standard &&
action.scope === ActionScope.RecordSelection,
);
const actionObjectActions: ActionConfig[] = actions?.filter(
(action) =>
action.type === ActionType.Standard &&
action.scope === ActionScope.Object,
);
const actionGlobalActions: ActionConfig[] = actions?.filter(
(action) =>
action.type === ActionType.Standard &&
action.scope === ActionScope.Global,
);
const workflowRunRecordSelectionActions: ActionConfig[] = actions?.filter(
(action) =>
action.type === ActionType.WorkflowRun &&
action.scope === ActionScope.RecordSelection,
);
const workflowRunGlobalActions: ActionConfig[] = actions?.filter(
(action) =>
action.type === ActionType.WorkflowRun &&
action.scope === ActionScope.Global,
);
const fallbackActions: ActionConfig[] = actions?.filter(
(action) => action.type === ActionType.Fallback,
);
return {
navigateActions,
actionRecordSelectionActions,
actionGlobalActions,
actionObjectActions,
workflowRunRecordSelectionActions,
workflowRunGlobalActions,
fallbackActions,
};
};

View File

@ -1,138 +0,0 @@
import { actionMenuEntriesComponentSelector } from '@/action-menu/states/actionMenuEntriesComponentSelector';
import {
ActionMenuEntryScope,
ActionMenuEntryType,
} from '@/action-menu/types/ActionMenuEntry';
import {
Command,
CommandScope,
CommandType,
} from '@/command-menu/types/Command';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { i18n } from '@lingui/core';
export const useCommandMenuCommands = () => {
const actionMenuEntries = useRecoilComponentValueV2(
actionMenuEntriesComponentSelector,
);
const navigateCommands = actionMenuEntries
?.filter(
(actionMenuEntry) =>
actionMenuEntry.type === ActionMenuEntryType.Navigation,
)
?.map((actionMenuEntry) => ({
id: actionMenuEntry.key,
label: i18n._(actionMenuEntry.label),
Icon: actionMenuEntry.Icon,
onCommandClick: actionMenuEntry.onClick,
type: CommandType.Navigate,
scope: CommandScope.Global,
hotKeys: actionMenuEntry.hotKeys,
})) as Command[];
const actionRecordSelectionCommands: Command[] = actionMenuEntries
?.filter(
(actionMenuEntry) =>
actionMenuEntry.type === ActionMenuEntryType.Standard &&
actionMenuEntry.scope === ActionMenuEntryScope.RecordSelection,
)
?.map((actionMenuEntry) => ({
id: actionMenuEntry.key,
label: i18n._(actionMenuEntry.label),
Icon: actionMenuEntry.Icon,
onCommandClick: actionMenuEntry.onClick,
type: CommandType.StandardAction,
scope: CommandScope.RecordSelection,
hotKeys: actionMenuEntry.hotKeys,
}));
const actionObjectCommands: Command[] = actionMenuEntries
?.filter(
(actionMenuEntry) =>
actionMenuEntry.type === ActionMenuEntryType.Standard &&
actionMenuEntry.scope === ActionMenuEntryScope.Object,
)
?.map((actionMenuEntry) => ({
id: actionMenuEntry.key,
label: i18n._(actionMenuEntry.label),
Icon: actionMenuEntry.Icon,
onCommandClick: actionMenuEntry.onClick,
type: CommandType.StandardAction,
scope: CommandScope.Object,
hotKeys: actionMenuEntry.hotKeys,
}));
const actionGlobalCommands: Command[] = actionMenuEntries
?.filter(
(actionMenuEntry) =>
actionMenuEntry.type === ActionMenuEntryType.Standard &&
actionMenuEntry.scope === ActionMenuEntryScope.Global,
)
?.map((actionMenuEntry) => ({
id: actionMenuEntry.key,
label: i18n._(actionMenuEntry.label),
Icon: actionMenuEntry.Icon,
onCommandClick: actionMenuEntry.onClick,
type: CommandType.StandardAction,
scope: CommandScope.Global,
hotKeys: actionMenuEntry.hotKeys,
}));
const workflowRunRecordSelectionCommands: Command[] = actionMenuEntries
?.filter(
(actionMenuEntry) =>
actionMenuEntry.type === ActionMenuEntryType.WorkflowRun &&
actionMenuEntry.scope === ActionMenuEntryScope.RecordSelection,
)
?.map((actionMenuEntry) => ({
id: actionMenuEntry.key,
label: i18n._(actionMenuEntry.label),
Icon: actionMenuEntry.Icon,
onCommandClick: actionMenuEntry.onClick,
type: CommandType.WorkflowRun,
scope: CommandScope.RecordSelection,
hotKeys: actionMenuEntry.hotKeys,
}));
const workflowRunGlobalCommands: Command[] = actionMenuEntries
?.filter(
(actionMenuEntry) =>
actionMenuEntry.type === ActionMenuEntryType.WorkflowRun &&
actionMenuEntry.scope === ActionMenuEntryScope.Global,
)
?.map((actionMenuEntry) => ({
id: actionMenuEntry.key,
label: i18n._(actionMenuEntry.label),
Icon: actionMenuEntry.Icon,
onCommandClick: actionMenuEntry.onClick,
type: CommandType.WorkflowRun,
scope: CommandScope.Global,
hotKeys: actionMenuEntry.hotKeys,
}));
const fallbackCommands: Command[] = actionMenuEntries
?.filter(
(actionMenuEntry) =>
actionMenuEntry.type === ActionMenuEntryType.Fallback,
)
?.map((actionMenuEntry) => ({
id: actionMenuEntry.key,
label: i18n._(actionMenuEntry.label),
Icon: actionMenuEntry.Icon,
onCommandClick: actionMenuEntry.onClick,
type: CommandType.Fallback,
scope: CommandScope.Global,
hotKeys: actionMenuEntry.hotKeys,
}));
return {
navigateCommands,
actionRecordSelectionCommands,
actionGlobalCommands,
actionObjectCommands,
workflowRunRecordSelectionCommands,
workflowRunGlobalCommands,
fallbackCommands,
};
};

View File

@ -1,40 +1,21 @@
import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
import { isNonEmptyString } from '@sniptt/guards';
import { useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { isDefined } from 'twenty-shared/utils';
export const useCommandMenuOnItemClick = () => {
const { toggleCommandMenu } = useCommandMenu();
const navigate = useNavigate();
const onItemClick = useCallback(
({
shouldCloseCommandMenuOnClick,
onClick,
to,
}: {
shouldCloseCommandMenuOnClick?: boolean;
onClick?: () => void;
to?: string;
}) => {
if (
isDefined(shouldCloseCommandMenuOnClick) &&
shouldCloseCommandMenuOnClick
) {
toggleCommandMenu();
}
({ onClick, to }: { onClick?: () => void; to?: string }) => {
if (isDefined(onClick)) {
onClick();
return;
}
if (isNonEmptyString(to)) {
navigate(to);
return;
}
},
[navigate, toggleCommandMenu],
[navigate],
);
return { onItemClick };

View File

@ -1,14 +1,19 @@
import { Action } from '@/action-menu/actions/components/Action';
import { ActionLink } from '@/action-menu/actions/components/ActionLink';
import { ActionScope } from '@/action-menu/actions/types/ActionScope';
import { ActionType } from '@/action-menu/actions/types/ActionType';
import { MAX_SEARCH_RESULTS } from '@/command-menu/constants/MaxSearchResults';
import { useOpenRecordInCommandMenu } from '@/command-menu/hooks/useOpenRecordInCommandMenu';
import { commandMenuSearchState } from '@/command-menu/states/commandMenuSearchState';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { AppPath } from '@/types/AppPath';
import { t } from '@lingui/core/macro';
import { useMemo } from 'react';
import { useRecoilValue } from 'recoil';
import { capitalize } from 'twenty-shared/utils';
import { Avatar } from 'twenty-ui/display';
import { useDebounce } from 'use-debounce';
import { useSearchQuery } from '~/generated/graphql';
import { Avatar } from 'twenty-ui/display';
export const useCommandMenuSearchRecords = () => {
const commandMenuSearch = useRecoilValue(commandMenuSearchState);
@ -25,14 +30,14 @@ export const useCommandMenuSearchRecords = () => {
const { openRecordInCommandMenu } = useOpenRecordInCommandMenu();
const commands = useMemo(() => {
return (searchData?.search ?? []).map((searchRecord) => {
const command = {
id: searchRecord.recordId,
const actionItems = useMemo(() => {
return (searchData?.search ?? []).map((searchRecord, index) => {
const baseAction = {
type: ActionType.Navigation,
scope: ActionScope.Global,
key: searchRecord.recordId,
label: searchRecord.label,
description: capitalize(searchRecord.objectNameSingular),
to: `object/${searchRecord.objectNameSingular}/${searchRecord.recordId}`,
shouldCloseCommandMenuOnClick: true,
position: index,
Icon: () => (
<Avatar
type={
@ -45,39 +50,59 @@ export const useCommandMenuSearchRecords = () => {
placeholder={searchRecord.label}
/>
),
shouldBeRegistered: () => true,
description: capitalize(searchRecord.objectNameSingular),
shouldCloseCommandMenuOnClick: true,
};
if (
[CoreObjectNameSingular.Task, CoreObjectNameSingular.Note].includes(
searchRecord.objectNameSingular as CoreObjectNameSingular,
)
) {
return {
...command,
to: '',
onCommandClick: () => {
searchRecord.objectNameSingular === 'task'
? openRecordInCommandMenu({
recordId: searchRecord.recordId,
objectNameSingular: CoreObjectNameSingular.Task,
})
: openRecordInCommandMenu({
recordId: searchRecord.recordId,
objectNameSingular: CoreObjectNameSingular.Note,
});
},
...baseAction,
component: (
<Action
onClick={() => {
searchRecord.objectNameSingular === 'task'
? openRecordInCommandMenu({
recordId: searchRecord.recordId,
objectNameSingular: CoreObjectNameSingular.Task,
})
: openRecordInCommandMenu({
recordId: searchRecord.recordId,
objectNameSingular: CoreObjectNameSingular.Note,
});
}}
preventCommandMenuClosing
/>
),
};
}
return command;
return {
...baseAction,
component: (
<ActionLink
to={AppPath.RecordShowPage}
params={{
objectNameSingular: searchRecord.objectNameSingular,
objectRecordId: searchRecord.recordId,
}}
/>
),
};
});
}, [searchData, openRecordInCommandMenu]);
return {
loading,
noResults: !commands?.length,
noResults: !actionItems?.length,
commandGroups: [
{
heading: t`Results`,
items: commands,
items: actionItems,
},
],
hasMore: false,

View File

@ -1,4 +1,3 @@
import { actionMenuEntriesComponentState } from '@/action-menu/states/actionMenuEntriesComponentState';
import { contextStoreCurrentObjectMetadataItemIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataItemIdComponentState';
import { contextStoreCurrentViewIdComponentState } from '@/context-store/states/contextStoreCurrentViewIdComponentState';
import { contextStoreCurrentViewTypeComponentState } from '@/context-store/states/contextStoreCurrentViewTypeComponentState';
@ -106,21 +105,6 @@ export const useCopyContextStoreStates = () => {
}),
contextStoreCurrentViewType,
);
const actionMenuEntries = snapshot
.getLoadable(
actionMenuEntriesComponentState.atomFamily({
instanceId: instanceIdToCopyFrom,
}),
)
.getValue();
set(
actionMenuEntriesComponentState.atomFamily({
instanceId: instanceIdToCopyTo,
}),
actionMenuEntries,
);
},
[],
);

View File

@ -0,0 +1,40 @@
import { ActionConfig } from '@/action-menu/actions/types/ActionConfig';
import { getActionLabel } from '@/action-menu/utils/getActionLabel';
import { isNonEmptyString } from '@sniptt/guards';
import { useDebounce } from 'use-debounce';
export const useFilterActionsWithCommandMenuSearch = ({
commandMenuSearch,
}: {
commandMenuSearch: string;
}) => {
const [deferredCommandMenuSearch] = useDebounce(commandMenuSearch, 300); // 200ms - 500ms
const checkInShortcuts = (action: ActionConfig, search: string) => {
const concatenatedString = action.hotKeys?.join('') ?? '';
return concatenatedString
.toLowerCase()
.includes(search.toLowerCase().trim());
};
const checkInLabels = (action: ActionConfig, search: string) => {
const actionLabel = getActionLabel(action.label);
if (isNonEmptyString(actionLabel)) {
return actionLabel.toLowerCase().includes(search.toLowerCase());
}
return false;
};
const filterActionsWithCommandMenuSearch = (actions: ActionConfig[]) => {
return actions.filter((action) =>
deferredCommandMenuSearch.length > 0
? checkInShortcuts(action, deferredCommandMenuSearch) ||
checkInLabels(action, deferredCommandMenuSearch)
: true,
);
};
return {
filterActionsWithCommandMenuSearch,
};
};

View File

@ -1,38 +0,0 @@
import { Command } from '@/command-menu/types/Command';
import { isNonEmptyString } from '@sniptt/guards';
import { useDebounce } from 'use-debounce';
export const useMatchCommands = ({
commandMenuSearch,
}: {
commandMenuSearch: string;
}) => {
const [deferredCommandMenuSearch] = useDebounce(commandMenuSearch, 300); // 200ms - 500ms
const checkInShortcuts = (cmd: Command, search: string) => {
const concatenatedString = cmd.hotKeys?.join('') ?? '';
return concatenatedString
.toLowerCase()
.includes(search.toLowerCase().trim());
};
const checkInLabels = (cmd: Command, search: string) => {
if (isNonEmptyString(cmd.label)) {
return cmd.label.toLowerCase().includes(search.toLowerCase());
}
return false;
};
const matchCommands = (commands: Command[]) => {
return commands.filter((cmd) =>
deferredCommandMenuSearch.length > 0
? checkInShortcuts(cmd, deferredCommandMenuSearch) ||
checkInLabels(cmd, deferredCommandMenuSearch)
: true,
);
};
return {
matchCommands,
};
};

View File

@ -0,0 +1,61 @@
import { useCommandMenuActions } from '@/command-menu/hooks/useCommandMenuActions';
import { useFilterActionsWithCommandMenuSearch } from '@/command-menu/hooks/useFilterActionsWithCommandMenuSearch';
export const useMatchingCommandMenuActions = ({
commandMenuSearch,
}: {
commandMenuSearch: string;
}) => {
const { filterActionsWithCommandMenuSearch } =
useFilterActionsWithCommandMenuSearch({
commandMenuSearch,
});
const {
navigateActions,
actionRecordSelectionActions,
actionObjectActions,
actionGlobalActions,
workflowRunRecordSelectionActions,
workflowRunGlobalActions,
fallbackActions,
} = useCommandMenuActions();
const matchingNavigateActions =
filterActionsWithCommandMenuSearch(navigateActions);
const matchingStandardActionRecordSelectionActions =
filterActionsWithCommandMenuSearch(actionRecordSelectionActions);
const matchingStandardActionObjectActions =
filterActionsWithCommandMenuSearch(actionObjectActions);
const matchingStandardActionGlobalActions =
filterActionsWithCommandMenuSearch(actionGlobalActions);
const matchingWorkflowRunRecordSelectionActions =
filterActionsWithCommandMenuSearch(workflowRunRecordSelectionActions);
const matchingWorkflowRunGlobalActions = filterActionsWithCommandMenuSearch(
workflowRunGlobalActions,
);
const noResults =
!matchingStandardActionRecordSelectionActions.length &&
!matchingWorkflowRunRecordSelectionActions.length &&
!matchingStandardActionGlobalActions.length &&
!matchingWorkflowRunGlobalActions.length &&
!matchingStandardActionObjectActions.length &&
!matchingNavigateActions.length;
return {
noResults,
matchingStandardActionRecordSelectionActions,
matchingStandardActionObjectActions,
matchingWorkflowRunRecordSelectionActions,
matchingStandardActionGlobalActions,
matchingWorkflowRunGlobalActions,
matchingNavigateActions,
fallbackActions: noResults ? fallbackActions : [],
};
};

View File

@ -1,59 +0,0 @@
import { useCommandMenuCommands } from '@/command-menu/hooks/useCommandMenuCommands';
import { useMatchCommands } from '@/command-menu/hooks/useMatchCommands';
export const useMatchingCommandMenuCommands = ({
commandMenuSearch,
}: {
commandMenuSearch: string;
}) => {
const { matchCommands } = useMatchCommands({ commandMenuSearch });
const {
navigateCommands,
actionRecordSelectionCommands,
actionObjectCommands,
actionGlobalCommands,
workflowRunRecordSelectionCommands,
workflowRunGlobalCommands,
fallbackCommands,
} = useCommandMenuCommands();
const matchingNavigateCommands = matchCommands(navigateCommands);
const matchingStandardActionRecordSelectionCommands = matchCommands(
actionRecordSelectionCommands,
);
const matchingStandardActionObjectCommands =
matchCommands(actionObjectCommands);
const matchingStandardActionGlobalCommands =
matchCommands(actionGlobalCommands);
const matchingWorkflowRunRecordSelectionCommands = matchCommands(
workflowRunRecordSelectionCommands,
);
const matchingWorkflowRunGlobalCommands = matchCommands(
workflowRunGlobalCommands,
);
const noResults =
!matchingStandardActionRecordSelectionCommands.length &&
!matchingWorkflowRunRecordSelectionCommands.length &&
!matchingStandardActionGlobalCommands.length &&
!matchingWorkflowRunGlobalCommands.length &&
!matchingStandardActionObjectCommands.length &&
!matchingNavigateCommands.length;
return {
noResults,
matchingStandardActionRecordSelectionCommands,
matchingStandardActionObjectCommands,
matchingWorkflowRunRecordSelectionCommands,
matchingStandardActionGlobalCommands,
matchingWorkflowRunGlobalCommands,
matchingNavigateCommands,
fallbackCommands: noResults ? fallbackCommands : [],
};
};

View File

@ -15,8 +15,8 @@ import { MAIN_CONTEXT_STORE_INSTANCE_ID } from '@/context-store/constants/MainCo
import { isDragSelectionStartEnabledState } from '@/ui/utilities/drag-select/states/internal/isDragSelectionStartEnabledState';
import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope';
import { useRecoilCallback } from 'recoil';
import { v4 } from 'uuid';
import { IconComponent } from 'twenty-ui/display';
import { v4 } from 'uuid';
export type CommandMenuNavigationStackItem = {
page: CommandMenuPages;

View File

@ -1,7 +1,5 @@
import { actionMenuEntriesComponentState } from '@/action-menu/states/actionMenuEntriesComponentState';
import { contextStoreCurrentObjectMetadataItemIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataItemIdComponentState';
import { contextStoreCurrentViewIdComponentState } from '@/context-store/states/contextStoreCurrentViewIdComponentState';
import { contextStoreCurrentViewTypeComponentState } from '@/context-store/states/contextStoreCurrentViewTypeComponentState';
import { contextStoreFiltersComponentState } from '@/context-store/states/contextStoreFiltersComponentState';
import { contextStoreNumberOfSelectedRecordsComponentState } from '@/context-store/states/contextStoreNumberOfSelectedRecordsComponentState';
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
@ -47,20 +45,6 @@ export const useResetContextStoreStates = () => {
}),
undefined,
);
set(
contextStoreCurrentViewTypeComponentState.atomFamily({
instanceId,
}),
null,
);
set(
actionMenuEntriesComponentState.atomFamily({
instanceId,
}),
new Map(),
);
};
}, []);

View File

@ -6,7 +6,9 @@ export const CommandMenuSearchRecordsPage = () => {
const { commandGroups, loading, noResults } = useCommandMenuSearchRecords();
const selectableItemIds = useMemo(() => {
return commandGroups.flatMap((group) => group.items).map((item) => item.id);
return commandGroups
.flatMap((group) => group.items)
.map((item) => item.key);
}, [commandGroups]);
return (