Command menu refactoring (#9257)

Refactored the `CommandMenu` component to make it more readable and
easier to refactor.
The file was way too big so I introduced a few hooks and eliminated code
duplication.

Introduced:
- `useMatchCommands` hook to match commands with the search
- `useCommandMenuCommands` which returns all command menu commands
- `useMatchingCommandMenuCommands` to return the commands matched with
the search
- `CommandMenuContainer` to simplify the `DefaultLayout`

- Unmounted the `CommandMenu` when it wasn't opened to improve
performances

I also introduced a new behavior: Automatically select the first item
when opening the command menu:

https://github.com/user-attachments/assets/4b683d49-570e-47c9-8939-99f42ed8691c
This commit is contained in:
Raphaël Bosi
2024-12-30 15:22:49 +01:00
committed by GitHub
parent 0fa59d7718
commit 1091bc657d
24 changed files with 827 additions and 980 deletions

View File

@ -1,8 +1,8 @@
import { MultipleRecordsActionMenuEntrySetterEffect } from '@/action-menu/actions/record-actions/multiple-records/components/MultipleRecordsActionMenuEntrySetterEffect';
import { NoSelectionActionMenuEntrySetterEffect } from '@/action-menu/actions/record-actions/no-selection/components/NoSelectionActionMenuEntrySetterEffect';
import { ShowPageSingleRecordActionMenuEntrySetterEffect } from '@/action-menu/actions/record-actions/single-record/components/ShowPageSingleRecordActionMenuEntrySetterEffect';
import { SingleRecordActionMenuEntrySetterEffect } from '@/action-menu/actions/record-actions/single-record/components/SingleRecordActionMenuEntrySetterEffect';
import { WorkflowRunRecordActionMenuEntrySetterEffect } from '@/action-menu/actions/record-actions/workflow-run-record-actions/components/WorkflowRunRecordActionMenuEntrySetter';
import { ActionViewType } from '@/action-menu/actions/types/ActionViewType';
import { contextStoreCurrentObjectMetadataIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataIdComponentState';
import { contextStoreCurrentViewTypeComponentState } from '@/context-store/states/contextStoreCurrentViewTypeComponentState';
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
@ -67,14 +67,16 @@ const ActionEffects = ({
contextStoreTargetedRecordsRule.selectedRecordIds.length === 1 && (
<>
{contextStoreCurrentViewType === ContextStoreViewType.ShowPage && (
<ShowPageSingleRecordActionMenuEntrySetterEffect
<SingleRecordActionMenuEntrySetterEffect
objectMetadataItem={objectMetadataItem}
viewType={ActionViewType.SHOW_PAGE}
/>
)}
{(contextStoreCurrentViewType === ContextStoreViewType.Table ||
contextStoreCurrentViewType === ContextStoreViewType.Kanban) && (
<SingleRecordActionMenuEntrySetterEffect
objectMetadataItem={objectMetadataItem}
viewType={ActionViewType.INDEX_PAGE_SINGLE_RECORD_SELECTION}
/>
)}
{isWorkflowEnabled && (

View File

@ -1,78 +1,23 @@
import { getActionConfig } from '@/action-menu/actions/record-actions/single-record/utils/getActionConfig';
import { ActionAvailableOn } from '@/action-menu/actions/types/ActionAvailableOn';
import { wrapActionInCallbacks } from '@/action-menu/actions/utils/wrapActionInCallbacks';
import { ActionMenuContext } from '@/action-menu/contexts/ActionMenuContext';
import { ActionViewType } from '@/action-menu/actions/types/ActionViewType';
import { useActionMenuEntries } from '@/action-menu/hooks/useActionMenuEntries';
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
import { useActionMenuEntriesWithCallbacks } from '@/action-menu/hooks/useActionMenuEntriesWithCallbacks';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { useContext, useEffect } from 'react';
import { isDefined } from 'twenty-ui';
import { useEffect } from 'react';
export const SingleRecordActionMenuEntrySetterEffect = ({
objectMetadataItem,
viewType,
}: {
objectMetadataItem: ObjectMetadataItem;
viewType: ActionViewType;
}) => {
const isPageHeaderV2Enabled = useIsFeatureEnabled(
'IS_PAGE_HEADER_V2_ENABLED',
);
const actionConfig = getActionConfig(
objectMetadataItem,
isPageHeaderV2Enabled,
);
const { addActionMenuEntry, removeActionMenuEntry } = useActionMenuEntries();
const contextStoreTargetedRecordsRule = useRecoilComponentValueV2(
contextStoreTargetedRecordsRuleComponentState,
const { actionMenuEntries } = useActionMenuEntriesWithCallbacks(
objectMetadataItem,
viewType,
);
const selectedRecordId =
contextStoreTargetedRecordsRule.mode === 'selection'
? contextStoreTargetedRecordsRule.selectedRecordIds[0]
: undefined;
if (!isDefined(selectedRecordId)) {
throw new Error('Selected record ID is required');
}
const { onActionStartedCallback, onActionExecutedCallback } =
useContext(ActionMenuContext);
const actionMenuEntries = Object.values(actionConfig ?? {})
.filter((action) =>
action.availableOn?.includes(
ActionAvailableOn.INDEX_PAGE_SINGLE_RECORD_SELECTION,
),
)
.map((action) => {
const { shouldBeRegistered, onClick, ConfirmationModal } =
action.actionHook({
recordId: selectedRecordId,
objectMetadataItem,
});
if (!shouldBeRegistered) {
return undefined;
}
const wrappedAction = wrapActionInCallbacks({
action: {
...action,
onClick,
ConfirmationModal,
},
onActionStartedCallback,
onActionExecutedCallback,
});
return wrappedAction;
})
.filter(isDefined);
useEffect(() => {
for (const action of actionMenuEntries) {
addActionMenuEntry(action);

View File

@ -2,7 +2,7 @@ import { useAddToFavoritesSingleRecordAction } from '@/action-menu/actions/recor
import { useDeleteSingleRecordAction } from '@/action-menu/actions/record-actions/single-record/hooks/useDeleteSingleRecordAction';
import { useRemoveFromFavoritesSingleRecordAction } from '@/action-menu/actions/record-actions/single-record/hooks/useRemoveFromFavoritesSingleRecordAction';
import { SingleRecordActionKeys } from '@/action-menu/actions/record-actions/single-record/types/SingleRecordActionsKey';
import { ActionAvailableOn } from '@/action-menu/actions/types/ActionAvailableOn';
import { ActionViewType } from '@/action-menu/actions/types/ActionViewType';
import { SingleRecordActionHook } from '@/action-menu/actions/types/SingleRecordActionHook';
import {
ActionMenuEntry,
@ -25,8 +25,8 @@ export const DEFAULT_SINGLE_RECORD_ACTIONS_CONFIG_V1: Record<
position: 0,
Icon: IconHeart,
availableOn: [
ActionAvailableOn.SHOW_PAGE,
ActionAvailableOn.INDEX_PAGE_SINGLE_RECORD_SELECTION,
ActionViewType.SHOW_PAGE,
ActionViewType.INDEX_PAGE_SINGLE_RECORD_SELECTION,
],
actionHook: useAddToFavoritesSingleRecordAction,
},
@ -38,8 +38,8 @@ export const DEFAULT_SINGLE_RECORD_ACTIONS_CONFIG_V1: Record<
position: 1,
Icon: IconHeartOff,
availableOn: [
ActionAvailableOn.SHOW_PAGE,
ActionAvailableOn.INDEX_PAGE_SINGLE_RECORD_SELECTION,
ActionViewType.SHOW_PAGE,
ActionViewType.INDEX_PAGE_SINGLE_RECORD_SELECTION,
],
actionHook: useRemoveFromFavoritesSingleRecordAction,
},
@ -53,8 +53,8 @@ export const DEFAULT_SINGLE_RECORD_ACTIONS_CONFIG_V1: Record<
accent: 'danger',
isPinned: true,
availableOn: [
ActionAvailableOn.SHOW_PAGE,
ActionAvailableOn.INDEX_PAGE_SINGLE_RECORD_SELECTION,
ActionViewType.SHOW_PAGE,
ActionViewType.INDEX_PAGE_SINGLE_RECORD_SELECTION,
],
actionHook: useDeleteSingleRecordAction,
},

View File

@ -6,7 +6,7 @@ import { useNavigateToNextRecordSingleRecordAction } from '@/action-menu/actions
import { useNavigateToPreviousRecordSingleRecordAction } from '@/action-menu/actions/record-actions/single-record/hooks/useNavigateToPreviousRecordSingleRecordAction';
import { useRemoveFromFavoritesSingleRecordAction } from '@/action-menu/actions/record-actions/single-record/hooks/useRemoveFromFavoritesSingleRecordAction';
import { SingleRecordActionKeys } from '@/action-menu/actions/record-actions/single-record/types/SingleRecordActionsKey';
import { ActionAvailableOn } from '@/action-menu/actions/types/ActionAvailableOn';
import { ActionViewType } from '@/action-menu/actions/types/ActionViewType';
import { SingleRecordActionHook } from '@/action-menu/actions/types/SingleRecordActionHook';
import {
ActionMenuEntry,
@ -38,7 +38,7 @@ export const DEFAULT_SINGLE_RECORD_ACTIONS_CONFIG_V2: Record<
position: 0,
isPinned: false,
Icon: IconFileExport,
availableOn: [ActionAvailableOn.SHOW_PAGE],
availableOn: [ActionViewType.SHOW_PAGE],
actionHook: useExportNoteAction,
},
addToFavoritesSingleRecord: {
@ -51,8 +51,8 @@ export const DEFAULT_SINGLE_RECORD_ACTIONS_CONFIG_V2: Record<
isPinned: true,
Icon: IconHeart,
availableOn: [
ActionAvailableOn.INDEX_PAGE_SINGLE_RECORD_SELECTION,
ActionAvailableOn.SHOW_PAGE,
ActionViewType.INDEX_PAGE_SINGLE_RECORD_SELECTION,
ActionViewType.SHOW_PAGE,
],
actionHook: useAddToFavoritesSingleRecordAction,
},
@ -66,8 +66,8 @@ export const DEFAULT_SINGLE_RECORD_ACTIONS_CONFIG_V2: Record<
position: 2,
Icon: IconHeartOff,
availableOn: [
ActionAvailableOn.INDEX_PAGE_SINGLE_RECORD_SELECTION,
ActionAvailableOn.SHOW_PAGE,
ActionViewType.INDEX_PAGE_SINGLE_RECORD_SELECTION,
ActionViewType.SHOW_PAGE,
],
actionHook: useRemoveFromFavoritesSingleRecordAction,
},
@ -82,8 +82,8 @@ export const DEFAULT_SINGLE_RECORD_ACTIONS_CONFIG_V2: Record<
accent: 'danger',
isPinned: true,
availableOn: [
ActionAvailableOn.INDEX_PAGE_SINGLE_RECORD_SELECTION,
ActionAvailableOn.SHOW_PAGE,
ActionViewType.INDEX_PAGE_SINGLE_RECORD_SELECTION,
ActionViewType.SHOW_PAGE,
],
actionHook: useDeleteSingleRecordAction,
},
@ -98,8 +98,8 @@ export const DEFAULT_SINGLE_RECORD_ACTIONS_CONFIG_V2: Record<
accent: 'danger',
isPinned: true,
availableOn: [
ActionAvailableOn.INDEX_PAGE_SINGLE_RECORD_SELECTION,
ActionAvailableOn.SHOW_PAGE,
ActionViewType.INDEX_PAGE_SINGLE_RECORD_SELECTION,
ActionViewType.SHOW_PAGE,
],
actionHook: useDestroySingleRecordAction,
},
@ -112,7 +112,7 @@ export const DEFAULT_SINGLE_RECORD_ACTIONS_CONFIG_V2: Record<
position: 5,
isPinned: true,
Icon: IconChevronUp,
availableOn: [ActionAvailableOn.SHOW_PAGE],
availableOn: [ActionViewType.SHOW_PAGE],
actionHook: useNavigateToPreviousRecordSingleRecordAction,
},
navigateToNextRecord: {
@ -124,7 +124,7 @@ export const DEFAULT_SINGLE_RECORD_ACTIONS_CONFIG_V2: Record<
position: 6,
isPinned: true,
Icon: IconChevronDown,
availableOn: [ActionAvailableOn.SHOW_PAGE],
availableOn: [ActionViewType.SHOW_PAGE],
actionHook: useNavigateToNextRecordSingleRecordAction,
},
};

View File

@ -14,7 +14,7 @@ import { useSeeRunsWorkflowSingleRecordAction } from '@/action-menu/actions/reco
import { useSeeVersionsWorkflowSingleRecordAction } from '@/action-menu/actions/record-actions/single-record/workflow-actions/hooks/useSeeVersionsWorkflowSingleRecordAction';
import { useTestWorkflowSingleRecordAction } from '@/action-menu/actions/record-actions/single-record/workflow-actions/hooks/useTestWorkflowSingleRecordAction';
import { WorkflowSingleRecordActionKeys } from '@/action-menu/actions/record-actions/single-record/workflow-actions/types/WorkflowSingleRecordActionsKeys';
import { ActionAvailableOn } from '@/action-menu/actions/types/ActionAvailableOn';
import { ActionViewType } from '@/action-menu/actions/types/ActionViewType';
import { SingleRecordActionHook } from '@/action-menu/actions/types/SingleRecordActionHook';
import {
ActionMenuEntry,
@ -51,8 +51,8 @@ export const WORKFLOW_SINGLE_RECORD_ACTIONS_CONFIG: Record<
type: ActionMenuEntryType.Standard,
scope: ActionMenuEntryScope.RecordSelection,
availableOn: [
ActionAvailableOn.SHOW_PAGE,
ActionAvailableOn.INDEX_PAGE_SINGLE_RECORD_SELECTION,
ActionViewType.SHOW_PAGE,
ActionViewType.INDEX_PAGE_SINGLE_RECORD_SELECTION,
],
actionHook: useActivateDraftWorkflowSingleRecordAction,
},
@ -66,8 +66,8 @@ export const WORKFLOW_SINGLE_RECORD_ACTIONS_CONFIG: Record<
type: ActionMenuEntryType.Standard,
scope: ActionMenuEntryScope.RecordSelection,
availableOn: [
ActionAvailableOn.SHOW_PAGE,
ActionAvailableOn.INDEX_PAGE_SINGLE_RECORD_SELECTION,
ActionViewType.SHOW_PAGE,
ActionViewType.INDEX_PAGE_SINGLE_RECORD_SELECTION,
],
actionHook: useActivateLastPublishedVersionWorkflowSingleRecordAction,
},
@ -81,8 +81,8 @@ export const WORKFLOW_SINGLE_RECORD_ACTIONS_CONFIG: Record<
type: ActionMenuEntryType.Standard,
scope: ActionMenuEntryScope.RecordSelection,
availableOn: [
ActionAvailableOn.SHOW_PAGE,
ActionAvailableOn.INDEX_PAGE_SINGLE_RECORD_SELECTION,
ActionViewType.SHOW_PAGE,
ActionViewType.INDEX_PAGE_SINGLE_RECORD_SELECTION,
],
actionHook: useDeactivateWorkflowSingleRecordAction,
},
@ -96,8 +96,8 @@ export const WORKFLOW_SINGLE_RECORD_ACTIONS_CONFIG: Record<
type: ActionMenuEntryType.Standard,
scope: ActionMenuEntryScope.RecordSelection,
availableOn: [
ActionAvailableOn.SHOW_PAGE,
ActionAvailableOn.INDEX_PAGE_SINGLE_RECORD_SELECTION,
ActionViewType.SHOW_PAGE,
ActionViewType.INDEX_PAGE_SINGLE_RECORD_SELECTION,
],
actionHook: useDiscardDraftWorkflowSingleRecordAction,
},
@ -111,8 +111,8 @@ export const WORKFLOW_SINGLE_RECORD_ACTIONS_CONFIG: Record<
type: ActionMenuEntryType.Standard,
scope: ActionMenuEntryScope.RecordSelection,
availableOn: [
ActionAvailableOn.SHOW_PAGE,
ActionAvailableOn.INDEX_PAGE_SINGLE_RECORD_SELECTION,
ActionViewType.SHOW_PAGE,
ActionViewType.INDEX_PAGE_SINGLE_RECORD_SELECTION,
],
actionHook: useSeeActiveVersionWorkflowSingleRecordAction,
},
@ -126,8 +126,8 @@ export const WORKFLOW_SINGLE_RECORD_ACTIONS_CONFIG: Record<
type: ActionMenuEntryType.Standard,
scope: ActionMenuEntryScope.RecordSelection,
availableOn: [
ActionAvailableOn.SHOW_PAGE,
ActionAvailableOn.INDEX_PAGE_SINGLE_RECORD_SELECTION,
ActionViewType.SHOW_PAGE,
ActionViewType.INDEX_PAGE_SINGLE_RECORD_SELECTION,
],
actionHook: useSeeRunsWorkflowSingleRecordAction,
},
@ -141,8 +141,8 @@ export const WORKFLOW_SINGLE_RECORD_ACTIONS_CONFIG: Record<
type: ActionMenuEntryType.Standard,
scope: ActionMenuEntryScope.RecordSelection,
availableOn: [
ActionAvailableOn.SHOW_PAGE,
ActionAvailableOn.INDEX_PAGE_SINGLE_RECORD_SELECTION,
ActionViewType.SHOW_PAGE,
ActionViewType.INDEX_PAGE_SINGLE_RECORD_SELECTION,
],
actionHook: useSeeVersionsWorkflowSingleRecordAction,
},
@ -156,8 +156,8 @@ export const WORKFLOW_SINGLE_RECORD_ACTIONS_CONFIG: Record<
type: ActionMenuEntryType.Standard,
scope: ActionMenuEntryScope.RecordSelection,
availableOn: [
ActionAvailableOn.SHOW_PAGE,
ActionAvailableOn.INDEX_PAGE_SINGLE_RECORD_SELECTION,
ActionViewType.SHOW_PAGE,
ActionViewType.INDEX_PAGE_SINGLE_RECORD_SELECTION,
],
actionHook: useTestWorkflowSingleRecordAction,
},
@ -169,7 +169,7 @@ export const WORKFLOW_SINGLE_RECORD_ACTIONS_CONFIG: Record<
shortLabel: '',
position: 9,
Icon: IconChevronUp,
availableOn: [ActionAvailableOn.SHOW_PAGE],
availableOn: [ActionViewType.SHOW_PAGE],
actionHook: useNavigateToPreviousRecordSingleRecordAction,
},
navigateToNextRecord: {
@ -180,7 +180,7 @@ export const WORKFLOW_SINGLE_RECORD_ACTIONS_CONFIG: Record<
shortLabel: '',
position: 10,
Icon: IconChevronDown,
availableOn: [ActionAvailableOn.SHOW_PAGE],
availableOn: [ActionViewType.SHOW_PAGE],
actionHook: useNavigateToNextRecordSingleRecordAction,
},
addToFavoritesSingleRecord: {
@ -193,8 +193,8 @@ export const WORKFLOW_SINGLE_RECORD_ACTIONS_CONFIG: Record<
isPinned: false,
Icon: IconHeart,
availableOn: [
ActionAvailableOn.INDEX_PAGE_SINGLE_RECORD_SELECTION,
ActionAvailableOn.SHOW_PAGE,
ActionViewType.INDEX_PAGE_SINGLE_RECORD_SELECTION,
ActionViewType.SHOW_PAGE,
],
actionHook: useAddToFavoritesSingleRecordAction,
},
@ -208,8 +208,8 @@ export const WORKFLOW_SINGLE_RECORD_ACTIONS_CONFIG: Record<
position: 12,
Icon: IconHeartOff,
availableOn: [
ActionAvailableOn.INDEX_PAGE_SINGLE_RECORD_SELECTION,
ActionAvailableOn.SHOW_PAGE,
ActionViewType.INDEX_PAGE_SINGLE_RECORD_SELECTION,
ActionViewType.SHOW_PAGE,
],
actionHook: useRemoveFromFavoritesSingleRecordAction,
},
@ -224,8 +224,8 @@ export const WORKFLOW_SINGLE_RECORD_ACTIONS_CONFIG: Record<
accent: 'danger',
isPinned: false,
availableOn: [
ActionAvailableOn.INDEX_PAGE_SINGLE_RECORD_SELECTION,
ActionAvailableOn.SHOW_PAGE,
ActionViewType.INDEX_PAGE_SINGLE_RECORD_SELECTION,
ActionViewType.SHOW_PAGE,
],
actionHook: useDeleteSingleRecordAction,
},
@ -240,8 +240,8 @@ export const WORKFLOW_SINGLE_RECORD_ACTIONS_CONFIG: Record<
accent: 'danger',
isPinned: false,
availableOn: [
ActionAvailableOn.INDEX_PAGE_SINGLE_RECORD_SELECTION,
ActionAvailableOn.SHOW_PAGE,
ActionViewType.INDEX_PAGE_SINGLE_RECORD_SELECTION,
ActionViewType.SHOW_PAGE,
],
actionHook: useDestroySingleRecordAction,
},

View File

@ -3,7 +3,7 @@ import { useNavigateToNextRecordSingleRecordAction } from '@/action-menu/actions
import { useNavigateToPreviousRecordSingleRecordAction } from '@/action-menu/actions/record-actions/single-record/hooks/useNavigateToPreviousRecordSingleRecordAction';
import { useRemoveFromFavoritesSingleRecordAction } from '@/action-menu/actions/record-actions/single-record/hooks/useRemoveFromFavoritesSingleRecordAction';
import { SingleRecordActionKeys } from '@/action-menu/actions/record-actions/single-record/types/SingleRecordActionsKey';
import { ActionAvailableOn } from '@/action-menu/actions/types/ActionAvailableOn';
import { ActionViewType } from '@/action-menu/actions/types/ActionViewType';
import { SingleRecordActionHook } from '@/action-menu/actions/types/SingleRecordActionHook';
import {
ActionMenuEntry,
@ -33,8 +33,8 @@ export const WORKFLOW_RUNS_SINGLE_RECORD_ACTIONS_CONFIG: Record<
isPinned: true,
Icon: IconHeart,
availableOn: [
ActionAvailableOn.INDEX_PAGE_SINGLE_RECORD_SELECTION,
ActionAvailableOn.SHOW_PAGE,
ActionViewType.INDEX_PAGE_SINGLE_RECORD_SELECTION,
ActionViewType.SHOW_PAGE,
],
actionHook: useAddToFavoritesSingleRecordAction,
},
@ -48,8 +48,8 @@ export const WORKFLOW_RUNS_SINGLE_RECORD_ACTIONS_CONFIG: Record<
position: 1,
Icon: IconHeartOff,
availableOn: [
ActionAvailableOn.INDEX_PAGE_SINGLE_RECORD_SELECTION,
ActionAvailableOn.SHOW_PAGE,
ActionViewType.INDEX_PAGE_SINGLE_RECORD_SELECTION,
ActionViewType.SHOW_PAGE,
],
actionHook: useRemoveFromFavoritesSingleRecordAction,
},
@ -62,7 +62,7 @@ export const WORKFLOW_RUNS_SINGLE_RECORD_ACTIONS_CONFIG: Record<
position: 2,
isPinned: true,
Icon: IconChevronUp,
availableOn: [ActionAvailableOn.SHOW_PAGE],
availableOn: [ActionViewType.SHOW_PAGE],
actionHook: useNavigateToPreviousRecordSingleRecordAction,
},
navigateToNextRecord: {
@ -74,7 +74,7 @@ export const WORKFLOW_RUNS_SINGLE_RECORD_ACTIONS_CONFIG: Record<
position: 3,
isPinned: true,
Icon: IconChevronDown,
availableOn: [ActionAvailableOn.SHOW_PAGE],
availableOn: [ActionViewType.SHOW_PAGE],
actionHook: useNavigateToNextRecordSingleRecordAction,
},
};

View File

@ -7,7 +7,7 @@ import { useSeeRunsWorkflowVersionSingleRecordAction } from '@/action-menu/actio
import { useSeeVersionsWorkflowVersionSingleRecordAction } from '@/action-menu/actions/record-actions/single-record/workflow-version-actions/hooks/useSeeVersionsWorkflowVersionSingleRecordAction';
import { useUseAsDraftWorkflowVersionSingleRecordAction } from '@/action-menu/actions/record-actions/single-record/workflow-version-actions/hooks/useUseAsDraftWorkflowVersionSingleRecordAction';
import { WorkflowVersionSingleRecordActionKeys } from '@/action-menu/actions/record-actions/single-record/workflow-version-actions/types/WorkflowVersionSingleRecordActionsKeys';
import { ActionAvailableOn } from '@/action-menu/actions/types/ActionAvailableOn';
import { ActionViewType } from '@/action-menu/actions/types/ActionViewType';
import { SingleRecordActionHook } from '@/action-menu/actions/types/SingleRecordActionHook';
import {
ActionMenuEntry,
@ -40,8 +40,8 @@ export const WORKFLOW_VERSIONS_SINGLE_RECORD_ACTIONS_CONFIG: Record<
scope: ActionMenuEntryScope.RecordSelection,
Icon: IconPencil,
availableOn: [
ActionAvailableOn.SHOW_PAGE,
ActionAvailableOn.INDEX_PAGE_SINGLE_RECORD_SELECTION,
ActionViewType.SHOW_PAGE,
ActionViewType.INDEX_PAGE_SINGLE_RECORD_SELECTION,
],
actionHook: useUseAsDraftWorkflowVersionSingleRecordAction,
},
@ -54,8 +54,8 @@ export const WORKFLOW_VERSIONS_SINGLE_RECORD_ACTIONS_CONFIG: Record<
scope: ActionMenuEntryScope.RecordSelection,
Icon: IconHistoryToggle,
availableOn: [
ActionAvailableOn.SHOW_PAGE,
ActionAvailableOn.INDEX_PAGE_SINGLE_RECORD_SELECTION,
ActionViewType.SHOW_PAGE,
ActionViewType.INDEX_PAGE_SINGLE_RECORD_SELECTION,
],
actionHook: useSeeRunsWorkflowVersionSingleRecordAction,
},
@ -68,8 +68,8 @@ export const WORKFLOW_VERSIONS_SINGLE_RECORD_ACTIONS_CONFIG: Record<
scope: ActionMenuEntryScope.RecordSelection,
Icon: IconHistory,
availableOn: [
ActionAvailableOn.SHOW_PAGE,
ActionAvailableOn.INDEX_PAGE_SINGLE_RECORD_SELECTION,
ActionViewType.SHOW_PAGE,
ActionViewType.INDEX_PAGE_SINGLE_RECORD_SELECTION,
],
actionHook: useSeeVersionsWorkflowVersionSingleRecordAction,
},
@ -81,7 +81,7 @@ export const WORKFLOW_VERSIONS_SINGLE_RECORD_ACTIONS_CONFIG: Record<
shortLabel: '',
position: 4,
Icon: IconChevronUp,
availableOn: [ActionAvailableOn.SHOW_PAGE],
availableOn: [ActionViewType.SHOW_PAGE],
actionHook: useNavigateToPreviousRecordSingleRecordAction,
},
navigateToNextRecord: {
@ -92,7 +92,7 @@ export const WORKFLOW_VERSIONS_SINGLE_RECORD_ACTIONS_CONFIG: Record<
shortLabel: '',
position: 5,
Icon: IconChevronDown,
availableOn: [ActionAvailableOn.SHOW_PAGE],
availableOn: [ActionViewType.SHOW_PAGE],
actionHook: useNavigateToNextRecordSingleRecordAction,
},
addToFavoritesSingleRecord: {
@ -105,8 +105,8 @@ export const WORKFLOW_VERSIONS_SINGLE_RECORD_ACTIONS_CONFIG: Record<
isPinned: false,
Icon: IconHeart,
availableOn: [
ActionAvailableOn.INDEX_PAGE_SINGLE_RECORD_SELECTION,
ActionAvailableOn.SHOW_PAGE,
ActionViewType.INDEX_PAGE_SINGLE_RECORD_SELECTION,
ActionViewType.SHOW_PAGE,
],
actionHook: useAddToFavoritesSingleRecordAction,
},
@ -120,8 +120,8 @@ export const WORKFLOW_VERSIONS_SINGLE_RECORD_ACTIONS_CONFIG: Record<
position: 7,
Icon: IconHeartOff,
availableOn: [
ActionAvailableOn.INDEX_PAGE_SINGLE_RECORD_SELECTION,
ActionAvailableOn.SHOW_PAGE,
ActionViewType.INDEX_PAGE_SINGLE_RECORD_SELECTION,
ActionViewType.SHOW_PAGE,
],
actionHook: useRemoveFromFavoritesSingleRecordAction,
},

View File

@ -1,4 +1,4 @@
export enum ActionAvailableOn {
export enum ActionViewType {
INDEX_PAGE_BULK_SELECTION = 'INDEX_PAGE_BULK_SELECTION',
INDEX_PAGE_SINGLE_RECORD_SELECTION = 'INDEX_PAGE_SINGLE_RECORD_SELECTION',
INDEX_PAGE_NO_SELECTION = 'INDEX_PAGE_NO_SELECTION',

View File

@ -1,20 +1,18 @@
import { getActionConfig } from '@/action-menu/actions/record-actions/single-record/utils/getActionConfig';
import { ActionAvailableOn } from '@/action-menu/actions/types/ActionAvailableOn';
import { ActionViewType } from '@/action-menu/actions/types/ActionViewType';
import { wrapActionInCallbacks } from '@/action-menu/actions/utils/wrapActionInCallbacks';
import { ActionMenuContext } from '@/action-menu/contexts/ActionMenuContext';
import { useActionMenuEntries } from '@/action-menu/hooks/useActionMenuEntries';
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { useContext, useEffect } from 'react';
import { useContext } from 'react';
import { isDefined } from 'twenty-ui';
export const ShowPageSingleRecordActionMenuEntrySetterEffect = ({
objectMetadataItem,
}: {
objectMetadataItem: ObjectMetadataItem;
}) => {
export const useActionMenuEntriesWithCallbacks = (
objectMetadataItem: ObjectMetadataItem,
viewType: ActionViewType,
) => {
const isPageHeaderV2Enabled = useIsFeatureEnabled(
'IS_PAGE_HEADER_V2_ENABLED',
);
@ -24,8 +22,6 @@ export const ShowPageSingleRecordActionMenuEntrySetterEffect = ({
isPageHeaderV2Enabled,
);
const { addActionMenuEntry, removeActionMenuEntry } = useActionMenuEntries();
const contextStoreTargetedRecordsRule = useRecoilComponentValueV2(
contextStoreTargetedRecordsRuleComponentState,
);
@ -38,13 +34,12 @@ export const ShowPageSingleRecordActionMenuEntrySetterEffect = ({
if (!isDefined(selectedRecordId)) {
throw new Error('Selected record ID is required');
}
const { onActionStartedCallback, onActionExecutedCallback } =
useContext(ActionMenuContext);
const actionMenuEntries = Object.values(actionConfig ?? {})
.filter((action) =>
action.availableOn?.includes(ActionAvailableOn.SHOW_PAGE),
)
.filter((action) => action.availableOn?.includes(viewType))
.map((action) => {
const { shouldBeRegistered, onClick, ConfirmationModal } =
action.actionHook({
@ -70,17 +65,5 @@ export const ShowPageSingleRecordActionMenuEntrySetterEffect = ({
})
.filter(isDefined);
useEffect(() => {
for (const action of actionMenuEntries) {
addActionMenuEntry(action);
}
return () => {
for (const action of actionMenuEntries) {
removeActionMenuEntry(action.key);
}
};
}, [actionMenuEntries, addActionMenuEntry, removeActionMenuEntry]);
return null;
return { actionMenuEntries };
};

View File

@ -1,4 +1,4 @@
import { ActionAvailableOn } from '@/action-menu/actions/types/ActionAvailableOn';
import { ActionViewType } from '@/action-menu/actions/types/ActionViewType';
import { ConfirmationModalProps } from '@/ui/layout/modal/components/ConfirmationModal';
import { MouseEvent, ReactElement } from 'react';
import { IconComponent, MenuItemAccent } from 'twenty-ui';
@ -23,7 +23,7 @@ export type ActionMenuEntry = {
Icon: IconComponent;
isPinned?: boolean;
accent?: MenuItemAccent;
availableOn?: ActionAvailableOn[];
availableOn?: ActionViewType[];
onClick?: (event?: MouseEvent<HTMLElement>) => void;
ConfirmationModal?: ReactElement<ConfirmationModalProps>;
};

View File

@ -1,77 +1,29 @@
import { useOpenCopilotRightDrawer } from '@/activities/copilot/right-drawer/hooks/useOpenCopilotRightDrawer';
import { copilotQueryState } from '@/activities/copilot/right-drawer/states/copilotQueryState';
import { useOpenActivityRightDrawer } from '@/activities/hooks/useOpenActivityRightDrawer';
import { Note } from '@/activities/types/Note';
import { Task } from '@/activities/types/Task';
import { CommandGroup } from '@/command-menu/components/CommandGroup';
import { CommandMenuDefaultSelectionEffect } from '@/command-menu/components/CommandMenuDefaultSelectionEffect';
import { CommandMenuItem } from '@/command-menu/components/CommandMenuItem';
import { CommandMenuTopBar } from '@/command-menu/components/CommandMenuTopBar';
import { COMMAND_MENU_SEARCH_BAR_HEIGHT } from '@/command-menu/constants/CommandMenuSearchBarHeight';
import { COMMAND_MENU_SEARCH_BAR_PADDING } from '@/command-menu/constants/CommandMenuSearchBarPadding';
import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
import { commandMenuCommandsComponentSelector } from '@/command-menu/states/commandMenuCommandsSelector';
import { useCommandMenuHotKeys } from '@/command-menu/hooks/useCommandMenuHotKeys';
import { useMatchingCommandMenuCommands } from '@/command-menu/hooks/useMatchingCommandMenuCommands';
import { commandMenuSearchState } from '@/command-menu/states/commandMenuSearchState';
import { isCommandMenuOpenedState } from '@/command-menu/states/isCommandMenuOpenedState';
import {
Command,
CommandScope,
CommandType,
} from '@/command-menu/types/Command';
import { Company } from '@/companies/types/Company';
import { contextStoreNumberOfSelectedRecordsComponentState } from '@/context-store/states/contextStoreNumberOfSelectedRecordsComponentState';
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
import { useKeyboardShortcutMenu } from '@/keyboard-shortcut-menu/hooks/useKeyboardShortcutMenu';
import { CoreObjectNamePlural } from '@/object-metadata/types/CoreObjectNamePlural';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { getCompanyDomainName } from '@/object-metadata/utils/getCompanyDomainName';
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
import { useMultiObjectSearch } from '@/object-record/relation-picker/hooks/useMultiObjectSearch';
import { useMultiObjectSearchQueryResultFormattedAsObjectRecordsMap } from '@/object-record/relation-picker/hooks/useMultiObjectSearchQueryResultFormattedAsObjectRecordsMap';
import { makeOrFilterVariables } from '@/object-record/utils/makeOrFilterVariables';
import { SelectableItem } from '@/ui/layout/selectable-list/components/SelectableItem';
import { SelectableList } from '@/ui/layout/selectable-list/components/SelectableList';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { AppHotkeyScope } from '@/ui/utilities/hotkey/types/AppHotkeyScope';
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import styled from '@emotion/styled';
import { isNonEmptyString } from '@sniptt/guards';
import isEmpty from 'lodash.isempty';
import { useMemo, useRef } from 'react';
import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil';
import { Key } from 'ts-key-enum';
import {
Avatar,
IconCheckbox,
IconComponent,
IconNotes,
IconSparkles,
isDefined,
} from 'twenty-ui';
import { useDebounce } from 'use-debounce';
import { getLogoUrlFromDomainName } from '~/utils';
import { capitalize } from '~/utils/string/capitalize';
import { useRef } from 'react';
import { useRecoilState } from 'recoil';
import { isDefined } from 'twenty-ui';
const MOBILE_NAVIGATION_BAR_HEIGHT = 64;
type CommandGroupConfig = {
heading: string;
items?: any[];
renderItem: (item: any) => {
id: string;
Icon?: IconComponent;
label: string;
to?: string;
onClick?: () => void;
key?: string;
firstHotKey?: string;
secondHotKey?: string;
shouldCloseCommandMenuOnClick?: boolean;
};
};
const StyledCommandMenu = styled.div`
@ -122,304 +74,16 @@ const StyledEmpty = styled.div`
`;
export const CommandMenu = () => {
const { toggleCommandMenu, onItemClick, closeCommandMenu } = useCommandMenu();
const { onItemClick, closeCommandMenu } = useCommandMenu();
const commandMenuRef = useRef<HTMLDivElement>(null);
const openActivityRightDrawer = useOpenActivityRightDrawer({
objectNameSingular: CoreObjectNameSingular.Note,
});
const isCommandMenuOpened = useRecoilValue(isCommandMenuOpenedState);
const [commandMenuSearch, setCommandMenuSearch] = useRecoilState(
commandMenuSearchState,
);
const [deferredCommandMenuSearch] = useDebounce(commandMenuSearch, 300); // 200ms - 500ms
const { closeKeyboardShortcutMenu } = useKeyboardShortcutMenu();
const setContextStoreTargetedRecordsRule = useSetRecoilComponentStateV2(
contextStoreTargetedRecordsRuleComponentState,
);
const setContextStoreNumberOfSelectedRecords = useSetRecoilComponentStateV2(
contextStoreNumberOfSelectedRecordsComponentState,
);
const isMobile = useIsMobile();
const commandMenuCommands = useRecoilComponentValueV2(
commandMenuCommandsComponentSelector,
);
useScopedHotkeys(
'ctrl+k,meta+k',
() => {
closeKeyboardShortcutMenu();
toggleCommandMenu();
},
AppHotkeyScope.CommandMenu,
[toggleCommandMenu],
);
useScopedHotkeys(
[Key.Escape],
() => {
closeCommandMenu();
},
AppHotkeyScope.CommandMenuOpen,
[closeCommandMenu],
);
useScopedHotkeys(
[Key.Backspace, Key.Delete],
() => {
if (!isNonEmptyString(commandMenuSearch)) {
setContextStoreTargetedRecordsRule({
mode: 'selection',
selectedRecordIds: [],
});
setContextStoreNumberOfSelectedRecords(0);
}
},
AppHotkeyScope.CommandMenuOpen,
[closeCommandMenu],
{
preventDefault: false,
},
);
const {
matchesSearchFilterObjectRecordsQueryResult,
matchesSearchFilterObjectRecordsLoading: loading,
} = useMultiObjectSearch({
excludedObjects: [CoreObjectNameSingular.Task, CoreObjectNameSingular.Note],
searchFilterValue: deferredCommandMenuSearch ?? undefined,
limit: 3,
});
const { objectRecordsMap: matchesSearchFilterObjectRecords } =
useMultiObjectSearchQueryResultFormattedAsObjectRecordsMap({
multiObjectRecordsQueryResult:
matchesSearchFilterObjectRecordsQueryResult,
});
const { loading: isNotesLoading, records: notes } = useFindManyRecords<Note>({
skip: !isCommandMenuOpened,
objectNameSingular: CoreObjectNameSingular.Note,
filter: deferredCommandMenuSearch
? makeOrFilterVariables([
{ title: { ilike: `%${deferredCommandMenuSearch}%` } },
{ body: { ilike: `%${deferredCommandMenuSearch}%` } },
])
: undefined,
limit: 3,
});
const { loading: isTasksLoading, records: tasks } = useFindManyRecords<Task>({
skip: !isCommandMenuOpened,
objectNameSingular: CoreObjectNameSingular.Task,
filter: deferredCommandMenuSearch
? makeOrFilterVariables([
{ title: { ilike: `%${deferredCommandMenuSearch}%` } },
{ body: { ilike: `%${deferredCommandMenuSearch}%` } },
])
: undefined,
limit: 3,
});
const people = matchesSearchFilterObjectRecords.people?.map(
(people) => people.record,
);
const companies = matchesSearchFilterObjectRecords.companies?.map(
(companies) => companies.record,
);
const opportunities = matchesSearchFilterObjectRecords.opportunities?.map(
(opportunities) => opportunities.record,
);
const customObjectRecordsMap = useMemo(() => {
return Object.fromEntries(
Object.entries(matchesSearchFilterObjectRecords).filter(
([namePlural, records]) =>
![
CoreObjectNamePlural.Person,
CoreObjectNamePlural.Opportunity,
CoreObjectNamePlural.Company,
].includes(namePlural as CoreObjectNamePlural) && !isEmpty(records),
),
);
}, [matchesSearchFilterObjectRecords]);
const peopleCommands = useMemo(
() =>
people?.map(({ id, name: { firstName, lastName } }) => ({
id,
label: `${firstName} ${lastName}`,
to: `object/person/${id}`,
shouldCloseCommandMenuOnClick: true,
})),
[people],
);
const companyCommands = useMemo(
() =>
companies?.map(({ id, name }) => ({
id,
label: name ?? '',
to: `object/company/${id}`,
shouldCloseCommandMenuOnClick: true,
})),
[companies],
);
const opportunityCommands = useMemo(
() =>
opportunities?.map(({ id, name }) => ({
id,
label: name ?? '',
to: `object/opportunity/${id}`,
shouldCloseCommandMenuOnClick: true,
})),
[opportunities],
);
const noteCommands = useMemo(
() =>
notes?.map((note) => ({
id: note.id,
label: note.title ?? '',
to: '',
onCommandClick: () => openActivityRightDrawer(note.id),
shouldCloseCommandMenuOnClick: true,
})),
[notes, openActivityRightDrawer],
);
const tasksCommands = useMemo(
() =>
tasks?.map((task) => ({
id: task.id,
label: task.title ?? '',
to: '',
onCommandClick: () => openActivityRightDrawer(task.id),
shouldCloseCommandMenuOnClick: true,
})),
[tasks, openActivityRightDrawer],
);
const customObjectCommands = useMemo(() => {
const customObjectCommandsArray: Command[] = [];
Object.values(customObjectRecordsMap).forEach((objectRecords) => {
customObjectCommandsArray.push(
...objectRecords.map((objectRecord) => ({
id: objectRecord.record.id,
label: objectRecord.recordIdentifier.name,
to: `object/${objectRecord.objectMetadataItem.nameSingular}/${objectRecord.record.id}`,
shouldCloseCommandMenuOnClick: true,
})),
);
});
return customObjectCommandsArray;
}, [customObjectRecordsMap]);
const otherCommands = useMemo(() => {
const commandsArray: Command[] = [];
if (peopleCommands?.length > 0) {
commandsArray.push(...(peopleCommands as Command[]));
}
if (companyCommands?.length > 0) {
commandsArray.push(...(companyCommands as Command[]));
}
if (opportunityCommands?.length > 0) {
commandsArray.push(...(opportunityCommands as Command[]));
}
if (noteCommands?.length > 0) {
commandsArray.push(...(noteCommands as Command[]));
}
if (tasksCommands?.length > 0) {
commandsArray.push(...(tasksCommands as Command[]));
}
if (customObjectCommands?.length > 0) {
commandsArray.push(...(customObjectCommands as Command[]));
}
return commandsArray;
}, [
peopleCommands,
companyCommands,
opportunityCommands,
noteCommands,
customObjectCommands,
tasksCommands,
]);
const checkInShortcuts = (cmd: Command, search: string) => {
return (cmd.firstHotKey + (cmd.secondHotKey ?? ''))
.toLowerCase()
.includes(search.toLowerCase());
};
const checkInLabels = (cmd: Command, search: string) => {
if (isNonEmptyString(cmd.label)) {
return cmd.label.toLowerCase().includes(search.toLowerCase());
}
return false;
};
const matchingNavigateCommand = commandMenuCommands.filter(
(cmd) =>
(deferredCommandMenuSearch.length > 0
? checkInShortcuts(cmd, deferredCommandMenuSearch) ||
checkInLabels(cmd, deferredCommandMenuSearch)
: true) && cmd.type === CommandType.Navigate,
);
const matchingCreateCommand = commandMenuCommands.filter(
(cmd) =>
(deferredCommandMenuSearch.length > 0
? checkInShortcuts(cmd, deferredCommandMenuSearch) ||
checkInLabels(cmd, deferredCommandMenuSearch)
: true) && cmd.type === CommandType.Create,
);
const matchingStandardActionRecordSelectionCommands =
commandMenuCommands.filter(
(cmd) =>
(deferredCommandMenuSearch.length > 0
? checkInShortcuts(cmd, deferredCommandMenuSearch) ||
checkInLabels(cmd, deferredCommandMenuSearch)
: true) &&
cmd.type === CommandType.StandardAction &&
cmd.scope === CommandScope.RecordSelection,
);
const matchingStandardActionGlobalCommands = commandMenuCommands.filter(
(cmd) =>
(deferredCommandMenuSearch.length > 0
? checkInShortcuts(cmd, deferredCommandMenuSearch) ||
checkInLabels(cmd, deferredCommandMenuSearch)
: true) &&
cmd.type === CommandType.StandardAction &&
cmd.scope === CommandScope.Global,
);
const matchingWorkflowRunRecordSelectionCommands = commandMenuCommands.filter(
(cmd) =>
(deferredCommandMenuSearch.length > 0
? checkInShortcuts(cmd, deferredCommandMenuSearch) ||
checkInLabels(cmd, deferredCommandMenuSearch)
: true) &&
cmd.type === CommandType.WorkflowRun &&
cmd.scope === CommandScope.RecordSelection,
);
const matchingWorkflowRunGlobalCommands = commandMenuCommands.filter(
(cmd) =>
(deferredCommandMenuSearch.length > 0
? checkInShortcuts(cmd, deferredCommandMenuSearch) ||
checkInLabels(cmd, deferredCommandMenuSearch)
: true) &&
cmd.type === CommandType.WorkflowRun &&
cmd.scope === CommandScope.Global,
);
useCommandMenuHotKeys();
useListenClickOutside({
refs: [commandMenuRef],
@ -428,399 +92,165 @@ export const CommandMenu = () => {
hotkeyScope: AppHotkeyScope.CommandMenuOpen,
});
const isCopilotEnabled = useIsFeatureEnabled('IS_COPILOT_ENABLED');
const setCopilotQuery = useSetRecoilState(copilotQueryState);
const openCopilotRightDrawer = useOpenCopilotRightDrawer();
const {
isNoResults,
isLoading,
copilotCommands,
matchingStandardActionRecordSelectionCommands,
matchingWorkflowRunRecordSelectionCommands,
matchingStandardActionGlobalCommands,
matchingWorkflowRunGlobalCommands,
matchingNavigateCommand,
peopleCommands,
companyCommands,
opportunityCommands,
noteCommands,
tasksCommands,
customObjectCommands,
} = useMatchingCommandMenuCommands({
commandMenuSearch,
});
const copilotCommand: Command = {
id: 'copilot',
to: '', // TODO
Icon: IconSparkles,
label: 'Open Copilot',
type: CommandType.Navigate,
onCommandClick: () => {
setCopilotQuery(deferredCommandMenuSearch);
openCopilotRightDrawer();
},
};
const selectableItems = copilotCommands
.concat(matchingStandardActionRecordSelectionCommands)
.concat(matchingWorkflowRunRecordSelectionCommands)
.concat(matchingStandardActionGlobalCommands)
.concat(matchingWorkflowRunGlobalCommands)
.concat(matchingNavigateCommand)
.concat(peopleCommands)
.concat(companyCommands)
.concat(opportunityCommands)
.concat(noteCommands)
.concat(tasksCommands)
.concat(customObjectCommands)
.filter(isDefined);
const copilotCommands: Command[] = isCopilotEnabled ? [copilotCommand] : [];
const selectableItemIds = copilotCommands
.map((cmd) => cmd.id)
.concat(matchingStandardActionRecordSelectionCommands.map((cmd) => cmd.id))
.concat(matchingWorkflowRunRecordSelectionCommands.map((cmd) => cmd.id))
.concat(matchingStandardActionGlobalCommands.map((cmd) => cmd.id))
.concat(matchingWorkflowRunGlobalCommands.map((cmd) => cmd.id))
.concat(matchingCreateCommand.map((cmd) => cmd.id))
.concat(matchingNavigateCommand.map((cmd) => cmd.id))
.concat(people?.map((person) => person.id))
.concat(companies?.map((company) => company.id))
.concat(opportunities?.map((opportunity) => opportunity.id))
.concat(notes?.map((note) => note.id))
.concat(tasks?.map((task) => task.id))
.concat(
Object.values(customObjectRecordsMap)
?.map((objectRecords) =>
objectRecords.map((objectRecord) => objectRecord.record.id),
)
.flat() ?? [],
);
const isNoResults =
!matchingStandardActionRecordSelectionCommands.length &&
!matchingWorkflowRunRecordSelectionCommands.length &&
!matchingStandardActionGlobalCommands.length &&
!matchingWorkflowRunGlobalCommands.length &&
!matchingCreateCommand.length &&
!matchingNavigateCommand.length &&
!people?.length &&
!companies?.length &&
!notes?.length &&
!tasks?.length &&
!opportunities?.length &&
isEmpty(customObjectRecordsMap);
const isLoading = loading || isNotesLoading || isTasksLoading;
const selectableItemIds = selectableItems.map((item) => item.id);
const commandGroups: CommandGroupConfig[] = [
{
heading: 'Navigate',
items: matchingNavigateCommand,
renderItem: (command) => ({
id: command.id,
Icon: command.Icon,
label: command.label,
to: command.to,
onClick: command.onCommandClick,
firstHotKey: command.firstHotKey,
secondHotKey: command.secondHotKey,
shouldCloseCommandMenuOnClick: command.shouldCloseCommandMenuOnClick,
}),
heading: 'Copilot',
items: copilotCommands,
},
{
heading: 'Other',
items: matchingCreateCommand,
renderItem: (command) => ({
id: command.id,
Icon: command.Icon,
label: command.label,
to: command.to,
onClick: command.onCommandClick,
firstHotKey: command.firstHotKey,
secondHotKey: command.secondHotKey,
shouldCloseCommandMenuOnClick: command.shouldCloseCommandMenuOnClick,
}),
heading: 'Record Selection',
items: matchingStandardActionRecordSelectionCommands,
},
{
heading: 'Workflow Record Selection',
items: matchingWorkflowRunRecordSelectionCommands,
},
{
heading: 'View',
items: matchingStandardActionGlobalCommands,
},
{
heading: 'Workflows',
items: matchingWorkflowRunGlobalCommands,
},
{
heading: 'Navigate',
items: matchingNavigateCommand,
},
{
heading: 'People',
items: people,
renderItem: (person) => ({
id: person.id,
label: `${person.name.firstName} ${person.name.lastName}`,
to: `object/person/${person.id}`,
Icon: () => (
<Avatar
type="rounded"
avatarUrl={null}
placeholderColorSeed={person.id}
placeholder={`${person.name.firstName} ${person.name.lastName}`}
/>
),
firstHotKey: person.firstHotKey,
secondHotKey: person.secondHotKey,
shouldCloseCommandMenuOnClick: true,
}),
items: peopleCommands,
},
{
heading: 'Companies',
items: companies,
renderItem: (company) => ({
id: company.id,
label: company.name,
to: `object/company/${company.id}`,
Icon: () => (
<Avatar
placeholderColorSeed={company.id}
placeholder={company.name}
avatarUrl={getLogoUrlFromDomainName(
getCompanyDomainName(company as Company),
)}
/>
),
firstHotKey: company.firstHotKey,
secondHotKey: company.secondHotKey,
shouldCloseCommandMenuOnClick: true,
}),
items: companyCommands,
},
{
heading: 'Opportunities',
items: opportunities,
renderItem: (opportunity) => ({
id: opportunity.id,
label: opportunity.name ?? '',
to: `object/opportunity/${opportunity.id}`,
Icon: () => (
<Avatar
type="rounded"
avatarUrl={null}
placeholderColorSeed={opportunity.id}
placeholder={opportunity.name ?? ''}
/>
),
shouldCloseCommandMenuOnClick: true,
}),
items: opportunityCommands,
},
{
heading: 'Notes',
items: notes,
renderItem: (note) => ({
id: note.id,
Icon: IconNotes,
label: note.title ?? '',
onClick: () => openActivityRightDrawer(note.id),
shouldCloseCommandMenuOnClick: true,
}),
items: noteCommands,
},
{
heading: 'Tasks',
items: tasks,
renderItem: (task) => ({
id: task.id,
Icon: IconCheckbox,
label: task.title ?? '',
onClick: () => openActivityRightDrawer(task.id),
shouldCloseCommandMenuOnClick: true,
}),
items: tasksCommands,
},
{
heading: 'Custom Objects',
items: customObjectCommands,
},
...Object.entries(customObjectRecordsMap).map(
([customObjectNamePlural, objectRecords]): CommandGroupConfig => ({
heading: capitalize(customObjectNamePlural),
items: objectRecords,
renderItem: (objectRecord) => ({
key: objectRecord.record.id,
id: objectRecord.record.id,
label: objectRecord.recordIdentifier.name,
to: `object/${objectRecord.objectMetadataItem.nameSingular}/${objectRecord.record.id}`,
Icon: () => (
<Avatar
type="rounded"
avatarUrl={null}
placeholderColorSeed={objectRecord.id}
placeholder={objectRecord.recordIdentifier.name ?? ''}
/>
),
shouldCloseCommandMenuOnClick: true,
}),
}),
),
];
return (
<>
{isCommandMenuOpened && (
<StyledCommandMenu ref={commandMenuRef} className="command-menu">
<CommandMenuTopBar
commandMenuSearch={commandMenuSearch}
setCommandMenuSearch={setCommandMenuSearch}
/>
<StyledList>
<ScrollWrapper
contextProviderName="commandMenu"
componentInstanceId={`scroll-wrapper-command-menu`}
>
<StyledInnerList isMobile={isMobile}>
<SelectableList
selectableListId="command-menu-list"
selectableItemIdArray={selectableItemIds}
hotkeyScope={AppHotkeyScope.CommandMenu}
onEnter={(itemId) => {
const command = [
...copilotCommands,
...commandMenuCommands,
...otherCommands,
].find((cmd) => cmd.id === itemId);
<CommandMenuDefaultSelectionEffect
selectableItemIds={selectableItemIds}
/>
<StyledCommandMenu ref={commandMenuRef} className="command-menu">
<CommandMenuTopBar
commandMenuSearch={commandMenuSearch}
setCommandMenuSearch={setCommandMenuSearch}
/>
<StyledList>
<ScrollWrapper
contextProviderName="commandMenu"
componentInstanceId={`scroll-wrapper-command-menu`}
>
<StyledInnerList isMobile={isMobile}>
<SelectableList
selectableListId="command-menu-list"
selectableItemIdArray={selectableItemIds}
hotkeyScope={AppHotkeyScope.CommandMenu}
onEnter={(itemId) => {
const command = selectableItems.find(
(item) => item.id === itemId,
);
if (isDefined(command)) {
const {
to,
onCommandClick,
shouldCloseCommandMenuOnClick,
} = command;
if (isDefined(command)) {
const {
to,
onCommandClick,
shouldCloseCommandMenuOnClick,
} = command;
onItemClick({
shouldCloseCommandMenuOnClick,
onClick: onCommandClick,
to,
});
}
}}
>
{isNoResults && !isLoading && (
<StyledEmpty>No results found</StyledEmpty>
)}
{isCopilotEnabled && (
<CommandGroup heading="Copilot">
<SelectableItem itemId={copilotCommand.id}>
<CommandMenuItem
id={copilotCommand.id}
Icon={copilotCommand.Icon}
label={`${copilotCommand.label} ${
deferredCommandMenuSearch.length > 2
? `"${deferredCommandMenuSearch}"`
: ''
}`}
onClick={copilotCommand.onCommandClick}
firstHotKey={copilotCommand.firstHotKey}
secondHotKey={copilotCommand.secondHotKey}
/>
</SelectableItem>
</CommandGroup>
)}
<CommandGroup heading="Record Selection">
{matchingStandardActionRecordSelectionCommands?.map(
(standardActionrecordSelectionCommand) => (
<SelectableItem
itemId={standardActionrecordSelectionCommand.id}
key={standardActionrecordSelectionCommand.id}
>
<CommandMenuItem
id={standardActionrecordSelectionCommand.id}
label={standardActionrecordSelectionCommand.label}
Icon={standardActionrecordSelectionCommand.Icon}
onClick={
standardActionrecordSelectionCommand.onCommandClick
}
firstHotKey={
standardActionrecordSelectionCommand.firstHotKey
}
secondHotKey={
standardActionrecordSelectionCommand.secondHotKey
}
/>
</SelectableItem>
),
)}
{matchingWorkflowRunRecordSelectionCommands?.map(
(workflowRunRecordSelectionCommand) => (
<SelectableItem
itemId={workflowRunRecordSelectionCommand.id}
key={workflowRunRecordSelectionCommand.id}
>
<CommandMenuItem
id={workflowRunRecordSelectionCommand.id}
label={workflowRunRecordSelectionCommand.label}
Icon={workflowRunRecordSelectionCommand.Icon}
onClick={
workflowRunRecordSelectionCommand.onCommandClick
}
firstHotKey={
workflowRunRecordSelectionCommand.firstHotKey
}
secondHotKey={
workflowRunRecordSelectionCommand.secondHotKey
}
/>
</SelectableItem>
),
)}
</CommandGroup>
{matchingStandardActionGlobalCommands?.length > 0 && (
<CommandGroup heading="View">
{matchingStandardActionGlobalCommands?.map(
(standardActionGlobalCommand) => (
<SelectableItem
itemId={standardActionGlobalCommand.id}
key={standardActionGlobalCommand.id}
>
onItemClick({
shouldCloseCommandMenuOnClick,
onClick: onCommandClick,
to,
});
}
}}
>
{isNoResults && !isLoading && (
<StyledEmpty>No results found</StyledEmpty>
)}
{commandGroups.map(({ heading, items }) =>
items?.length ? (
<CommandGroup heading={heading} key={heading}>
{items.map((item) => {
return (
<SelectableItem itemId={item.id} key={item.id}>
<CommandMenuItem
id={standardActionGlobalCommand.id}
label={standardActionGlobalCommand.label}
Icon={standardActionGlobalCommand.Icon}
onClick={
standardActionGlobalCommand.onCommandClick
}
firstHotKey={
standardActionGlobalCommand.firstHotKey
}
secondHotKey={
standardActionGlobalCommand.secondHotKey
}
/>
</SelectableItem>
),
)}
</CommandGroup>
)}
{matchingWorkflowRunGlobalCommands?.length > 0 && (
<CommandGroup heading="Workflows">
{matchingWorkflowRunGlobalCommands?.map(
(workflowRunGlobalCommand) => (
<SelectableItem
itemId={workflowRunGlobalCommand.id}
key={workflowRunGlobalCommand.id}
>
<CommandMenuItem
id={workflowRunGlobalCommand.id}
label={workflowRunGlobalCommand.label}
Icon={workflowRunGlobalCommand.Icon}
onClick={workflowRunGlobalCommand.onCommandClick}
firstHotKey={workflowRunGlobalCommand.firstHotKey}
secondHotKey={
workflowRunGlobalCommand.secondHotKey
}
key={item.id}
id={item.id}
Icon={item.Icon}
label={item.label}
to={item.to}
onClick={item.onClick}
firstHotKey={item.firstHotKey}
secondHotKey={item.secondHotKey}
shouldCloseCommandMenuOnClick={
workflowRunGlobalCommand.shouldCloseCommandMenuOnClick
item.shouldCloseCommandMenuOnClick
}
/>
</SelectableItem>
),
)}
);
})}
</CommandGroup>
)}
{commandGroups.map(({ heading, items, renderItem }) =>
items?.length ? (
<CommandGroup heading={heading} key={heading}>
{items.map((item) => {
const {
id,
Icon,
label,
to,
onClick,
key,
firstHotKey,
secondHotKey,
shouldCloseCommandMenuOnClick,
} = renderItem(item);
return (
<SelectableItem itemId={id} key={id}>
<CommandMenuItem
key={key}
id={id}
Icon={Icon}
label={label}
to={to}
onClick={onClick}
firstHotKey={firstHotKey}
secondHotKey={secondHotKey}
shouldCloseCommandMenuOnClick={
shouldCloseCommandMenuOnClick
}
/>
</SelectableItem>
);
})}
</CommandGroup>
) : null,
)}
</SelectableList>
</StyledInnerList>
</ScrollWrapper>
</StyledList>
</StyledCommandMenu>
)}
) : null,
)}
</SelectableList>
</StyledInnerList>
</ScrollWrapper>
</StyledList>
</StyledCommandMenu>
</>
);
};

View File

@ -0,0 +1,54 @@
import { RecordActionMenuEntriesSetter } from '@/action-menu/actions/record-actions/components/RecordActionMenuEntriesSetter';
import { RecordAgnosticActionsSetterEffect } from '@/action-menu/actions/record-agnostic-actions/components/RecordAgnosticActionsSetterEffect';
import { ActionMenuConfirmationModals } from '@/action-menu/components/ActionMenuConfirmationModals';
import { ActionMenuContext } from '@/action-menu/contexts/ActionMenuContext';
import { ActionMenuComponentInstanceContext } from '@/action-menu/states/contexts/ActionMenuComponentInstanceContext';
import { CommandMenu } from '@/command-menu/components/CommandMenu';
import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
import { isCommandMenuOpenedState } from '@/command-menu/states/isCommandMenuOpenedState';
import { ContextStoreComponentInstanceContext } from '@/context-store/states/contexts/ContextStoreComponentInstanceContext';
import { useKeyboardShortcutMenu } from '@/keyboard-shortcut-menu/hooks/useKeyboardShortcutMenu';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { AppHotkeyScope } from '@/ui/utilities/hotkey/types/AppHotkeyScope';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { useRecoilValue } from 'recoil';
export const CommandMenuContainer = () => {
const { toggleCommandMenu } = useCommandMenu();
const { closeKeyboardShortcutMenu } = useKeyboardShortcutMenu();
const isWorkflowEnabled = useIsFeatureEnabled('IS_WORKFLOW_ENABLED');
const isCommandMenuOpened = useRecoilValue(isCommandMenuOpenedState);
useScopedHotkeys(
'ctrl+k,meta+k',
() => {
closeKeyboardShortcutMenu();
toggleCommandMenu();
},
AppHotkeyScope.CommandMenu,
[toggleCommandMenu],
);
return (
<ContextStoreComponentInstanceContext.Provider
value={{ instanceId: 'command-menu' }}
>
<ActionMenuComponentInstanceContext.Provider
value={{ instanceId: 'command-menu' }}
>
<ActionMenuContext.Provider
value={{
isInRightDrawer: false,
onActionExecutedCallback: toggleCommandMenu,
}}
>
<RecordActionMenuEntriesSetter />
{isWorkflowEnabled && <RecordAgnosticActionsSetterEffect />}
<ActionMenuConfirmationModals />
{isCommandMenuOpened && <CommandMenu />}
</ActionMenuContext.Provider>
</ActionMenuComponentInstanceContext.Provider>
</ContextStoreComponentInstanceContext.Provider>
);
};

View File

@ -0,0 +1,27 @@
import { useSelectableList } from '@/ui/layout/selectable-list/hooks/useSelectableList';
import { useEffect } from 'react';
import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-ui';
export const CommandMenuDefaultSelectionEffect = ({
selectableItemIds,
}: {
selectableItemIds: string[];
}) => {
const { setSelectedItemId, selectedItemIdState } =
useSelectableList('command-menu-list');
const selectedItemId = useRecoilValue(selectedItemIdState);
useEffect(() => {
if (isDefined(selectedItemId)) {
return;
}
if (selectableItemIds.length > 0) {
setSelectedItemId(selectableItemIds[0]);
}
}, [selectableItemIds, selectedItemId, setSelectedItemId]);
return null;
};

View File

@ -4,9 +4,7 @@ import { MemoryRouter } from 'react-router-dom';
import { RecoilRoot, useRecoilValue } from 'recoil';
import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
import { commandMenuCommandsComponentSelector } from '@/command-menu/states/commandMenuCommandsSelector';
import { isCommandMenuOpenedState } from '@/command-menu/states/isCommandMenuOpenedState';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
const Wrapper = ({ children }: { children: React.ReactNode }) => (
<RecoilRoot>
@ -24,15 +22,10 @@ const renderHooks = () => {
() => {
const commandMenu = useCommandMenu();
const isCommandMenuOpened = useRecoilValue(isCommandMenuOpenedState);
const commandMenuCommands = useRecoilComponentValueV2(
commandMenuCommandsComponentSelector,
'command-menu',
);
return {
commandMenu,
isCommandMenuOpened,
commandMenuCommands,
};
},
{

View File

@ -9,6 +9,7 @@ import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousH
import { AppHotkeyScope } from '@/ui/utilities/hotkey/types/AppHotkeyScope';
import { isDefined } from '~/utils/isDefined';
import { actionMenuEntriesComponentState } from '@/action-menu/states/actionMenuEntriesComponentState';
import { contextStoreCurrentObjectMetadataIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataIdComponentState';
import { contextStoreCurrentViewIdComponentState } from '@/context-store/states/contextStoreCurrentViewIdComponentState';
import { contextStoreCurrentViewTypeComponentState } from '@/context-store/states/contextStoreCurrentViewTypeComponentState';
@ -126,6 +127,21 @@ export const useCommandMenu = () => {
);
}
const actionMenuEntries = snapshot
.getLoadable(
actionMenuEntriesComponentState.atomFamily({
instanceId: mainContextStoreComponentInstanceId,
}),
)
.getValue();
set(
actionMenuEntriesComponentState.atomFamily({
instanceId: 'command-menu',
}),
actionMenuEntries,
);
setIsCommandMenuOpened(true);
setHotkeyScopeAndMemorizePreviousScope(AppHotkeyScope.CommandMenuOpen);
},
@ -188,6 +204,13 @@ export const useCommandMenu = () => {
null,
);
set(
actionMenuEntriesComponentState.atomFamily({
instanceId: 'command-menu',
}),
new Map(),
);
if (isCommandMenuOpened) {
setIsCommandMenuOpened(false);
resetSelectedItem();

View File

@ -0,0 +1,314 @@
import { actionMenuEntriesComponentSelector } from '@/action-menu/states/actionMenuEntriesComponentSelector';
import {
ActionMenuEntryScope,
ActionMenuEntryType,
} from '@/action-menu/types/ActionMenuEntry';
import { useOpenCopilotRightDrawer } from '@/activities/copilot/right-drawer/hooks/useOpenCopilotRightDrawer';
import { copilotQueryState } from '@/activities/copilot/right-drawer/states/copilotQueryState';
import { useOpenActivityRightDrawer } from '@/activities/hooks/useOpenActivityRightDrawer';
import { Note } from '@/activities/types/Note';
import { Task } from '@/activities/types/Task';
import { COMMAND_MENU_NAVIGATE_COMMANDS } from '@/command-menu/constants/CommandMenuNavigateCommands';
import { commandMenuSearchState } from '@/command-menu/states/commandMenuSearchState';
import { isCommandMenuOpenedState } from '@/command-menu/states/isCommandMenuOpenedState';
import {
Command,
CommandScope,
CommandType,
} from '@/command-menu/types/Command';
import { Company } from '@/companies/types/Company';
import { CoreObjectNamePlural } from '@/object-metadata/types/CoreObjectNamePlural';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { getCompanyDomainName } from '@/object-metadata/utils/getCompanyDomainName';
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
import { useMultiObjectSearch } from '@/object-record/relation-picker/hooks/useMultiObjectSearch';
import { useMultiObjectSearchQueryResultFormattedAsObjectRecordsMap } from '@/object-record/relation-picker/hooks/useMultiObjectSearchQueryResultFormattedAsObjectRecordsMap';
import { makeOrFilterVariables } from '@/object-record/utils/makeOrFilterVariables';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import isEmpty from 'lodash.isempty';
import { useMemo } from 'react';
import { useRecoilValue, useSetRecoilState } from 'recoil';
import { Avatar, IconCheckbox, IconNotes, IconSparkles } from 'twenty-ui';
import { useDebounce } from 'use-debounce';
import { getLogoUrlFromDomainName } from '~/utils';
export const useCommandMenuCommands = () => {
const actionMenuEntries = useRecoilComponentValueV2(
actionMenuEntriesComponentSelector,
);
const openActivityRightDrawer = useOpenActivityRightDrawer({
objectNameSingular: CoreObjectNameSingular.Note,
});
const isCommandMenuOpened = useRecoilValue(isCommandMenuOpenedState);
const commandMenuSearch = useRecoilValue(commandMenuSearchState);
const [deferredCommandMenuSearch] = useDebounce(commandMenuSearch, 300); // 200ms - 500ms
const isCopilotEnabled = useIsFeatureEnabled('IS_COPILOT_ENABLED');
const setCopilotQuery = useSetRecoilState(copilotQueryState);
const openCopilotRightDrawer = useOpenCopilotRightDrawer();
const copilotCommand: Command = {
id: 'copilot',
to: '', // TODO
Icon: IconSparkles,
label: 'Open Copilot',
type: CommandType.Navigate,
onCommandClick: () => {
setCopilotQuery(deferredCommandMenuSearch);
openCopilotRightDrawer();
},
};
const copilotCommands: Command[] = isCopilotEnabled ? [copilotCommand] : [];
const navigateCommands = Object.values(COMMAND_MENU_NAVIGATE_COMMANDS);
const actionRecordSelectionCommands: Command[] = actionMenuEntries
?.filter(
(actionMenuEntry) =>
actionMenuEntry.type === ActionMenuEntryType.Standard &&
actionMenuEntry.scope === ActionMenuEntryScope.RecordSelection,
)
?.map((actionMenuEntry) => ({
id: actionMenuEntry.key,
label: actionMenuEntry.label,
Icon: actionMenuEntry.Icon,
onCommandClick: actionMenuEntry.onClick,
type: CommandType.StandardAction,
scope: CommandScope.RecordSelection,
}));
const actionGlobalCommands: Command[] = actionMenuEntries
?.filter(
(actionMenuEntry) =>
actionMenuEntry.type === ActionMenuEntryType.Standard &&
actionMenuEntry.scope === ActionMenuEntryScope.Global,
)
?.map((actionMenuEntry) => ({
id: actionMenuEntry.key,
label: actionMenuEntry.label,
Icon: actionMenuEntry.Icon,
onCommandClick: actionMenuEntry.onClick,
type: CommandType.StandardAction,
scope: CommandScope.Global,
}));
const workflowRunRecordSelectionCommands: Command[] = actionMenuEntries
?.filter(
(actionMenuEntry) =>
actionMenuEntry.type === ActionMenuEntryType.WorkflowRun &&
actionMenuEntry.scope === ActionMenuEntryScope.RecordSelection,
)
?.map((actionMenuEntry) => ({
id: actionMenuEntry.key,
label: actionMenuEntry.label,
Icon: actionMenuEntry.Icon,
onCommandClick: actionMenuEntry.onClick,
type: CommandType.WorkflowRun,
scope: CommandScope.RecordSelection,
}));
const workflowRunGlobalCommands: Command[] = actionMenuEntries
?.filter(
(actionMenuEntry) =>
actionMenuEntry.type === ActionMenuEntryType.WorkflowRun &&
actionMenuEntry.scope === ActionMenuEntryScope.Global,
)
?.map((actionMenuEntry) => ({
id: actionMenuEntry.key,
label: actionMenuEntry.label,
Icon: actionMenuEntry.Icon,
onCommandClick: actionMenuEntry.onClick,
type: CommandType.WorkflowRun,
scope: CommandScope.Global,
}));
const {
matchesSearchFilterObjectRecordsQueryResult,
matchesSearchFilterObjectRecordsLoading: loading,
} = useMultiObjectSearch({
excludedObjects: [CoreObjectNameSingular.Task, CoreObjectNameSingular.Note],
searchFilterValue: deferredCommandMenuSearch ?? undefined,
limit: 3,
});
const { objectRecordsMap: matchesSearchFilterObjectRecords } =
useMultiObjectSearchQueryResultFormattedAsObjectRecordsMap({
multiObjectRecordsQueryResult:
matchesSearchFilterObjectRecordsQueryResult,
});
const { loading: isNotesLoading, records: notes } = useFindManyRecords<Note>({
skip: !isCommandMenuOpened,
objectNameSingular: CoreObjectNameSingular.Note,
filter: deferredCommandMenuSearch
? makeOrFilterVariables([
{ title: { ilike: `%${deferredCommandMenuSearch}%` } },
{ body: { ilike: `%${deferredCommandMenuSearch}%` } },
])
: undefined,
limit: 3,
});
const { loading: isTasksLoading, records: tasks } = useFindManyRecords<Task>({
skip: !isCommandMenuOpened,
objectNameSingular: CoreObjectNameSingular.Task,
filter: deferredCommandMenuSearch
? makeOrFilterVariables([
{ title: { ilike: `%${deferredCommandMenuSearch}%` } },
{ body: { ilike: `%${deferredCommandMenuSearch}%` } },
])
: undefined,
limit: 3,
});
const people = matchesSearchFilterObjectRecords.people?.map(
(people) => people.record,
);
const companies = matchesSearchFilterObjectRecords.companies?.map(
(companies) => companies.record,
);
const opportunities = matchesSearchFilterObjectRecords.opportunities?.map(
(opportunities) => opportunities.record,
);
const peopleCommands = useMemo(
() =>
people?.map(({ id, name: { firstName, lastName } }) => ({
id,
label: `${firstName} ${lastName}`,
to: `object/person/${id}`,
shouldCloseCommandMenuOnClick: true,
Icon: () => (
<Avatar
type="rounded"
avatarUrl={null}
placeholderColorSeed={id}
placeholder={`${firstName} ${lastName}`}
/>
),
})),
[people],
);
const companyCommands = useMemo(
() =>
companies?.map((company) => ({
id: company.id,
label: company.name ?? '',
to: `object/company/${company.id}`,
shouldCloseCommandMenuOnClick: true,
Icon: () => (
<Avatar
placeholderColorSeed={company.id}
placeholder={company.name}
avatarUrl={getLogoUrlFromDomainName(
getCompanyDomainName(company as Company),
)}
/>
),
})),
[companies],
);
const opportunityCommands = useMemo(
() =>
opportunities?.map(({ id, name }) => ({
id,
label: name ?? '',
to: `object/opportunity/${id}`,
shouldCloseCommandMenuOnClick: true,
Icon: () => (
<Avatar
type="rounded"
avatarUrl={null}
placeholderColorSeed={id}
placeholder={name ?? ''}
/>
),
})),
[opportunities],
);
const noteCommands = useMemo(
() =>
notes?.map((note) => ({
id: note.id,
label: note.title ?? '',
to: '',
onCommandClick: () => openActivityRightDrawer(note.id),
shouldCloseCommandMenuOnClick: true,
Icon: IconNotes,
})),
[notes, openActivityRightDrawer],
);
const tasksCommands = useMemo(
() =>
tasks?.map((task) => ({
id: task.id,
label: task.title ?? '',
to: '',
onCommandClick: () => openActivityRightDrawer(task.id),
shouldCloseCommandMenuOnClick: true,
Icon: IconCheckbox,
})),
[tasks, openActivityRightDrawer],
);
const customObjectRecordsMap = useMemo(() => {
return Object.fromEntries(
Object.entries(matchesSearchFilterObjectRecords).filter(
([namePlural, records]) =>
![
CoreObjectNamePlural.Person,
CoreObjectNamePlural.Opportunity,
CoreObjectNamePlural.Company,
].includes(namePlural as CoreObjectNamePlural) && !isEmpty(records),
),
);
}, [matchesSearchFilterObjectRecords]);
const customObjectCommands = useMemo(() => {
const customObjectCommandsArray: Command[] = [];
Object.values(customObjectRecordsMap).forEach((objectRecords) => {
customObjectCommandsArray.push(
...objectRecords.map((objectRecord) => ({
id: objectRecord.record.id,
label: objectRecord.recordIdentifier.name,
to: `object/${objectRecord.objectMetadataItem.nameSingular}/${objectRecord.record.id}`,
shouldCloseCommandMenuOnClick: true,
Icon: () => (
<Avatar
type="rounded"
avatarUrl={null}
placeholderColorSeed={objectRecord.record.id}
placeholder={objectRecord.recordIdentifier.name ?? ''}
/>
),
})),
);
});
return customObjectCommandsArray;
}, [customObjectRecordsMap]);
const isLoading = loading || isNotesLoading || isTasksLoading;
return {
copilotCommands,
navigateCommands,
actionRecordSelectionCommands,
actionGlobalCommands,
workflowRunRecordSelectionCommands,
workflowRunGlobalCommands,
peopleCommands,
companyCommands,
opportunityCommands,
noteCommands,
tasksCommands,
customObjectCommands,
isLoading,
};
};

View File

@ -0,0 +1,54 @@
import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
import { commandMenuSearchState } from '@/command-menu/states/commandMenuSearchState';
import { contextStoreNumberOfSelectedRecordsComponentState } from '@/context-store/states/contextStoreNumberOfSelectedRecordsComponentState';
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { AppHotkeyScope } from '@/ui/utilities/hotkey/types/AppHotkeyScope';
import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2';
import { isNonEmptyString } from '@sniptt/guards';
import { useRecoilValue } from 'recoil';
import { Key } from 'ts-key-enum';
export const useCommandMenuHotKeys = () => {
const { closeCommandMenu } = useCommandMenu();
const commandMenuSearch = useRecoilValue(commandMenuSearchState);
const setContextStoreTargetedRecordsRule = useSetRecoilComponentStateV2(
contextStoreTargetedRecordsRuleComponentState,
'command-menu',
);
const setContextStoreNumberOfSelectedRecords = useSetRecoilComponentStateV2(
contextStoreNumberOfSelectedRecordsComponentState,
'command-menu',
);
useScopedHotkeys(
[Key.Escape],
() => {
closeCommandMenu();
},
AppHotkeyScope.CommandMenuOpen,
[closeCommandMenu],
);
useScopedHotkeys(
[Key.Backspace, Key.Delete],
() => {
if (!isNonEmptyString(commandMenuSearch)) {
setContextStoreTargetedRecordsRule({
mode: 'selection',
selectedRecordIds: [],
});
setContextStoreNumberOfSelectedRecords(0);
}
},
AppHotkeyScope.CommandMenuOpen,
[closeCommandMenu],
{
preventDefault: false,
},
);
};

View File

@ -0,0 +1,37 @@
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) => {
return (cmd.firstHotKey + (cmd.secondHotKey ?? ''))
.toLowerCase()
.includes(search.toLowerCase());
};
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,73 @@
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 {
copilotCommands,
navigateCommands,
actionRecordSelectionCommands,
actionGlobalCommands,
workflowRunRecordSelectionCommands,
workflowRunGlobalCommands,
peopleCommands,
companyCommands,
opportunityCommands,
noteCommands,
tasksCommands,
customObjectCommands,
isLoading,
} = useCommandMenuCommands();
const matchingNavigateCommand = matchCommands(navigateCommands);
const matchingStandardActionRecordSelectionCommands = matchCommands(
actionRecordSelectionCommands,
);
const matchingStandardActionGlobalCommands =
matchCommands(actionGlobalCommands);
const matchingWorkflowRunRecordSelectionCommands = matchCommands(
workflowRunRecordSelectionCommands,
);
const matchingWorkflowRunGlobalCommands = matchCommands(
workflowRunGlobalCommands,
);
const isNoResults =
!matchingStandardActionRecordSelectionCommands.length &&
!matchingWorkflowRunRecordSelectionCommands.length &&
!matchingStandardActionGlobalCommands.length &&
!matchingWorkflowRunGlobalCommands.length &&
!matchingNavigateCommand.length &&
!peopleCommands?.length &&
!companyCommands?.length &&
!opportunityCommands?.length &&
!noteCommands?.length &&
!tasksCommands?.length &&
!customObjectCommands?.length;
return {
isNoResults,
isLoading,
copilotCommands,
matchingStandardActionRecordSelectionCommands,
matchingWorkflowRunRecordSelectionCommands,
matchingStandardActionGlobalCommands,
matchingWorkflowRunGlobalCommands,
matchingNavigateCommand,
peopleCommands,
companyCommands,
opportunityCommands,
noteCommands,
tasksCommands,
customObjectCommands,
};
};

View File

@ -1,23 +0,0 @@
import { actionMenuEntriesComponentSelector } from '@/action-menu/states/actionMenuEntriesComponentSelector';
import { ActionMenuComponentInstanceContext } from '@/action-menu/states/contexts/ActionMenuComponentInstanceContext';
import { Command } from '@/command-menu/types/Command';
import { computeCommandMenuCommands } from '@/command-menu/utils/computeCommandMenuCommands';
import { createComponentSelectorV2 } from '@/ui/utilities/state/component-state/utils/createComponentSelectorV2';
export const commandMenuCommandsComponentSelector = createComponentSelectorV2<
Command[]
>({
key: 'commandMenuCommandsComponentSelector',
componentInstanceContext: ActionMenuComponentInstanceContext,
get:
({ instanceId }) =>
({ get }) => {
const actionMenuEntries = get(
actionMenuEntriesComponentSelector.selectorFamily({
instanceId,
}),
);
return computeCommandMenuCommands(actionMenuEntries);
},
});

View File

@ -1,53 +0,0 @@
import {
ActionMenuEntry,
ActionMenuEntryScope,
ActionMenuEntryType,
} from '@/action-menu/types/ActionMenuEntry';
import { COMMAND_MENU_NAVIGATE_COMMANDS } from '@/command-menu/constants/CommandMenuNavigateCommands';
import {
Command,
CommandScope,
CommandType,
} from '@/command-menu/types/Command';
export const computeCommandMenuCommands = (
actionMenuEntries: ActionMenuEntry[],
): Command[] => {
const navigateCommands = Object.values(COMMAND_MENU_NAVIGATE_COMMANDS);
const actionCommands: Command[] = actionMenuEntries
?.filter(
(actionMenuEntry) =>
actionMenuEntry.type === ActionMenuEntryType.Standard,
)
?.map((actionMenuEntry) => ({
id: actionMenuEntry.key,
label: actionMenuEntry.label,
Icon: actionMenuEntry.Icon,
onCommandClick: actionMenuEntry.onClick,
type: CommandType.StandardAction,
scope:
actionMenuEntry.scope === ActionMenuEntryScope.RecordSelection
? CommandScope.RecordSelection
: CommandScope.Global,
}));
const workflowRunCommands: Command[] = actionMenuEntries
?.filter(
(actionMenuEntry) =>
actionMenuEntry.type === ActionMenuEntryType.WorkflowRun,
)
?.map((actionMenuEntry) => ({
id: actionMenuEntry.key,
label: actionMenuEntry.label,
Icon: actionMenuEntry.Icon,
onCommandClick: actionMenuEntry.onClick,
type: CommandType.WorkflowRun,
scope:
actionMenuEntry.scope === ActionMenuEntryScope.RecordSelection
? CommandScope.RecordSelection
: CommandScope.Global,
}));
return [...navigateCommands, ...actionCommands, ...workflowRunCommands];
};

View File

@ -1,12 +1,5 @@
import { RecordActionMenuEntriesSetter } from '@/action-menu/actions/record-actions/components/RecordActionMenuEntriesSetter';
import { RecordAgnosticActionsSetterEffect } from '@/action-menu/actions/record-agnostic-actions/components/RecordAgnosticActionsSetterEffect';
import { ActionMenuConfirmationModals } from '@/action-menu/components/ActionMenuConfirmationModals';
import { ActionMenuContext } from '@/action-menu/contexts/ActionMenuContext';
import { ActionMenuComponentInstanceContext } from '@/action-menu/states/contexts/ActionMenuComponentInstanceContext';
import { AuthModal } from '@/auth/components/AuthModal';
import { CommandMenu } from '@/command-menu/components/CommandMenu';
import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
import { ContextStoreComponentInstanceContext } from '@/context-store/states/contexts/ContextStoreComponentInstanceContext';
import { CommandMenuContainer } from '@/command-menu/components/CommandMenuContainer';
import { AppErrorBoundary } from '@/error-handler/components/AppErrorBoundary';
import { KeyboardShortcutMenu } from '@/keyboard-shortcut-menu/components/KeyboardShortcutMenu';
import { AppNavigationDrawer } from '@/navigation/components/AppNavigationDrawer';
@ -18,8 +11,7 @@ import { SignInBackgroundMockPage } from '@/sign-in-background-mock/components/S
import { useShowAuthModal } from '@/ui/layout/hooks/useShowAuthModal';
import { NAV_DRAWER_WIDTHS } from '@/ui/navigation/navigation-drawer/constants/NavDrawerWidths';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { css, Global, useTheme } from '@emotion/react';
import { Global, css, useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { AnimatePresence, LayoutGroup, motion } from 'framer-motion';
import { Outlet } from 'react-router-dom';
@ -77,9 +69,6 @@ export const DefaultLayout = () => {
const theme = useTheme();
const windowsWidth = useScreenSize().width;
const showAuthModal = useShowAuthModal();
const { toggleCommandMenu } = useCommandMenu();
const isWorkflowEnabled = useIsFeatureEnabled('IS_WORKFLOW_ENABLED');
return (
<>
@ -93,25 +82,7 @@ export const DefaultLayout = () => {
<StyledLayout>
{!showAuthModal && (
<>
<ContextStoreComponentInstanceContext.Provider
value={{ instanceId: 'command-menu' }}
>
<ActionMenuComponentInstanceContext.Provider
value={{ instanceId: 'command-menu' }}
>
<ActionMenuContext.Provider
value={{
isInRightDrawer: false,
onActionExecutedCallback: toggleCommandMenu,
}}
>
<RecordActionMenuEntriesSetter />
{isWorkflowEnabled && <RecordAgnosticActionsSetterEffect />}
<ActionMenuConfirmationModals />
<CommandMenu />
</ActionMenuContext.Provider>
</ActionMenuComponentInstanceContext.Provider>
</ContextStoreComponentInstanceContext.Provider>
<CommandMenuContainer />
<KeyboardShortcutMenu />
</>
)}

View File

@ -26,7 +26,7 @@ export const SelectableList = ({
}: SelectableListProps) => {
useSelectableListHotKeys(selectableListId, hotkeyScope);
const { setSelectableItemIds, setSelectableListOnEnter } =
const { setSelectableItemIds, setSelectableListOnEnter, setSelectedItemId } =
useSelectableList(selectableListId);
useEffect(() => {
@ -47,7 +47,12 @@ export const SelectableList = ({
if (isDefined(selectableItemIdArray)) {
setSelectableItemIds(arrayToChunks(selectableItemIdArray, 1));
}
}, [selectableItemIdArray, selectableItemIdMatrix, setSelectableItemIds]);
}, [
selectableItemIdArray,
selectableItemIdMatrix,
setSelectableItemIds,
setSelectedItemId,
]);
return (
<SelectableListScope selectableListScopeId={selectableListId}>

View File

@ -33,11 +33,23 @@ export const useSelectableList = (selectableListId?: string) => {
[selectedItemIdState, isSelectedItemIdSelector],
);
const setSelectedItemId = useRecoilCallback(
({ set }) =>
(itemId: string) => {
resetSelectedItem();
set(selectedItemIdState, itemId);
set(isSelectedItemIdSelector(itemId), true);
},
[resetSelectedItem, selectedItemIdState, isSelectedItemIdSelector],
);
return {
selectableListId: scopeId,
setSelectableItemIds,
isSelectedItemIdSelector,
setSelectableListOnEnter,
resetSelectedItem,
setSelectedItemId,
selectedItemIdState,
};
};