8414 add records selection context inside the command menu (#8610)

Closes #8414



https://github.com/user-attachments/assets/a6aeb50a-b57d-43db-a839-4627c49b4155
This commit is contained in:
Raphaël Bosi
2024-11-21 17:56:53 +01:00
committed by GitHub
parent 52df5301a8
commit 8f5515cab3
28 changed files with 762 additions and 225 deletions

View File

@ -1,4 +1,8 @@
import { useActionMenuEntries } from '@/action-menu/hooks/useActionMenuEntries'; import { useActionMenuEntries } from '@/action-menu/hooks/useActionMenuEntries';
import {
ActionMenuEntryScope,
ActionMenuEntryType,
} from '@/action-menu/types/ActionMenuEntry';
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar'; import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useAllActiveWorkflowVersions } from '@/workflow/hooks/useAllActiveWorkflowVersions'; import { useAllActiveWorkflowVersions } from '@/workflow/hooks/useAllActiveWorkflowVersions';
@ -28,9 +32,9 @@ export const WorkflowRunActionEffect = () => {
activeWorkflowVersion, activeWorkflowVersion,
] of activeWorkflowVersions.entries()) { ] of activeWorkflowVersions.entries()) {
addActionMenuEntry({ addActionMenuEntry({
type: 'workflow-run', type: ActionMenuEntryType.WorkflowRun,
key: `workflow-run-${activeWorkflowVersion.id}`, key: `workflow-run-${activeWorkflowVersion.id}`,
scope: 'global', scope: ActionMenuEntryScope.Global,
label: capitalize(activeWorkflowVersion.workflow.name), label: capitalize(activeWorkflowVersion.workflow.name),
position: index, position: index,
Icon: IconSettingsAutomation, Icon: IconSettingsAutomation,

View File

@ -1,5 +1,9 @@
import { ActionMenuContext } from '@/action-menu/contexts/ActionMenuContext'; import { ActionMenuContext } from '@/action-menu/contexts/ActionMenuContext';
import { useActionMenuEntries } from '@/action-menu/hooks/useActionMenuEntries'; import { useActionMenuEntries } from '@/action-menu/hooks/useActionMenuEntries';
import {
ActionMenuEntryScope,
ActionMenuEntryType,
} from '@/action-menu/types/ActionMenuEntry';
import { contextStoreFiltersComponentState } from '@/context-store/states/contextStoreFiltersComponentState'; import { contextStoreFiltersComponentState } from '@/context-store/states/contextStoreFiltersComponentState';
import { contextStoreNumberOfSelectedRecordsComponentState } from '@/context-store/states/contextStoreNumberOfSelectedRecordsComponentState'; import { contextStoreNumberOfSelectedRecordsComponentState } from '@/context-store/states/contextStoreNumberOfSelectedRecordsComponentState';
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState'; import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
@ -105,8 +109,8 @@ export const DeleteRecordsActionEffect = ({
useEffect(() => { useEffect(() => {
if (canDelete) { if (canDelete) {
addActionMenuEntry({ addActionMenuEntry({
type: 'standard', type: ActionMenuEntryType.Standard,
scope: 'record-selection', scope: ActionMenuEntryScope.RecordSelection,
key: 'delete', key: 'delete',
label: 'Delete', label: 'Delete',
position, position,

View File

@ -4,6 +4,10 @@ import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { IconDatabaseExport } from 'twenty-ui'; import { IconDatabaseExport } from 'twenty-ui';
import {
ActionMenuEntryScope,
ActionMenuEntryType,
} from '@/action-menu/types/ActionMenuEntry';
import { import {
displayedExportProgress, displayedExportProgress,
useExportRecords, useExportRecords,
@ -31,8 +35,11 @@ export const ExportRecordsActionEffect = ({
useEffect(() => { useEffect(() => {
addActionMenuEntry({ addActionMenuEntry({
type: 'standard', type: ActionMenuEntryType.Standard,
scope: 'record-selection', scope:
contextStoreNumberOfSelectedRecords > 0
? ActionMenuEntryScope.RecordSelection
: ActionMenuEntryScope.Global,
key: 'export', key: 'export',
position, position,
label: displayedExportProgress(progress), label: displayedExportProgress(progress),

View File

@ -1,4 +1,8 @@
import { useActionMenuEntries } from '@/action-menu/hooks/useActionMenuEntries'; import { useActionMenuEntries } from '@/action-menu/hooks/useActionMenuEntries';
import {
ActionMenuEntryScope,
ActionMenuEntryType,
} from '@/action-menu/types/ActionMenuEntry';
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState'; import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
import { useCreateFavorite } from '@/favorites/hooks/useCreateFavorite'; import { useCreateFavorite } from '@/favorites/hooks/useCreateFavorite';
import { useDeleteFavorite } from '@/favorites/hooks/useDeleteFavorite'; import { useDeleteFavorite } from '@/favorites/hooks/useDeleteFavorite';
@ -50,8 +54,8 @@ export const ManageFavoritesActionEffect = ({
} }
addActionMenuEntry({ addActionMenuEntry({
type: 'standard', type: ActionMenuEntryType.Standard,
scope: 'record-selection', scope: ActionMenuEntryScope.RecordSelection,
key: 'manage-favorites', key: 'manage-favorites',
label: isFavorite ? 'Remove from favorites' : 'Add to favorites', label: isFavorite ? 'Remove from favorites' : 'Add to favorites',
position, position,

View File

@ -7,6 +7,7 @@ import { contextStoreNumberOfSelectedRecordsComponentState } from '@/context-sto
import { useObjectMetadataItemById } from '@/object-metadata/hooks/useObjectMetadataItemById'; import { useObjectMetadataItemById } from '@/object-metadata/hooks/useObjectMetadataItemById';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { isDefined } from 'twenty-ui';
const noSelectionRecordActionEffects = [ExportRecordsActionEffect]; const noSelectionRecordActionEffects = [ExportRecordsActionEffect];
@ -21,25 +22,33 @@ const multipleRecordActionEffects = [
]; ];
export const RecordActionMenuEntriesSetter = () => { export const RecordActionMenuEntriesSetter = () => {
const contextStoreNumberOfSelectedRecords = useRecoilComponentValueV2(
contextStoreNumberOfSelectedRecordsComponentState,
);
const contextStoreCurrentObjectMetadataId = useRecoilComponentValueV2( const contextStoreCurrentObjectMetadataId = useRecoilComponentValueV2(
contextStoreCurrentObjectMetadataIdComponentState, contextStoreCurrentObjectMetadataIdComponentState,
); );
if (!isDefined(contextStoreCurrentObjectMetadataId)) {
return null;
}
return (
<ActionEffects objectMetadataItemId={contextStoreCurrentObjectMetadataId} />
);
};
const ActionEffects = ({
objectMetadataItemId,
}: {
objectMetadataItemId: string;
}) => {
const { objectMetadataItem } = useObjectMetadataItemById({ const { objectMetadataItem } = useObjectMetadataItemById({
objectId: contextStoreCurrentObjectMetadataId ?? '', objectId: objectMetadataItemId,
}); });
const isWorkflowEnabled = useIsFeatureEnabled('IS_WORKFLOW_ENABLED'); const contextStoreNumberOfSelectedRecords = useRecoilComponentValueV2(
contextStoreNumberOfSelectedRecordsComponentState,
);
if (!objectMetadataItem) { const isWorkflowEnabled = useIsFeatureEnabled('IS_WORKFLOW_ENABLED');
throw new Error(
`Object metadata item not found for id ${contextStoreCurrentObjectMetadataId}`,
);
}
const actions = const actions =
contextStoreNumberOfSelectedRecords === 0 contextStoreNumberOfSelectedRecords === 0

View File

@ -1,4 +1,8 @@
import { useActionMenuEntries } from '@/action-menu/hooks/useActionMenuEntries'; import { useActionMenuEntries } from '@/action-menu/hooks/useActionMenuEntries';
import {
ActionMenuEntryScope,
ActionMenuEntryType,
} from '@/action-menu/types/ActionMenuEntry';
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState'; import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState'; import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
@ -55,9 +59,9 @@ export const WorkflowRunRecordActionEffect = ({
activeWorkflowVersion, activeWorkflowVersion,
] of activeWorkflowVersions.entries()) { ] of activeWorkflowVersions.entries()) {
addActionMenuEntry({ addActionMenuEntry({
type: 'workflow-run', type: ActionMenuEntryType.WorkflowRun,
key: `workflow-run-${activeWorkflowVersion.id}`, key: `workflow-run-${activeWorkflowVersion.id}`,
scope: 'record-selection', scope: ActionMenuEntryScope.RecordSelection,
label: capitalize(activeWorkflowVersion.workflow.name), label: capitalize(activeWorkflowVersion.workflow.name),
position: index, position: index,
Icon: IconSettingsAutomation, Icon: IconSettingsAutomation,

View File

@ -43,7 +43,12 @@ export const RecordIndexActionMenuEffect = () => {
const isCommandMenuOpened = useRecoilValue(isCommandMenuOpenedState); const isCommandMenuOpened = useRecoilValue(isCommandMenuOpenedState);
useEffect(() => { useEffect(() => {
if (contextStoreNumberOfSelectedRecords > 0 && !isDropdownOpen) { if (
contextStoreNumberOfSelectedRecords > 0 &&
!isDropdownOpen &&
!isRightDrawerOpen &&
!isCommandMenuOpened
) {
// We only handle opening the ActionMenuBar here, not the Dropdown. // We only handle opening the ActionMenuBar here, not the Dropdown.
// The Dropdown is already managed by sync handlers for events like // The Dropdown is already managed by sync handlers for events like
// right-click to open and click outside to close. // right-click to open and click outside to close.
@ -57,6 +62,8 @@ export const RecordIndexActionMenuEffect = () => {
openActionBar, openActionBar,
closeActionBar, closeActionBar,
isDropdownOpen, isDropdownOpen,
isRightDrawerOpen,
isCommandMenuOpened,
]); ]);
useEffect(() => { useEffect(() => {

View File

@ -1,5 +1,6 @@
import { actionMenuEntriesComponentSelector } from '@/action-menu/states/actionMenuEntriesComponentSelector'; import { actionMenuEntriesComponentSelector } from '@/action-menu/states/actionMenuEntriesComponentSelector';
import { ActionMenuComponentInstanceContext } from '@/action-menu/states/contexts/ActionMenuComponentInstanceContext'; import { ActionMenuComponentInstanceContext } from '@/action-menu/states/contexts/ActionMenuComponentInstanceContext';
import { ActionMenuEntryScope } from '@/action-menu/types/ActionMenuEntry';
import { RightDrawerActionMenuDropdownHotkeyScope } from '@/action-menu/types/RightDrawerActionMenuDropdownHotkeyScope'; import { RightDrawerActionMenuDropdownHotkeyScope } from '@/action-menu/types/RightDrawerActionMenuDropdownHotkeyScope';
import { getRightDrawerActionMenuDropdownIdFromActionMenuId } from '@/action-menu/utils/getRightDrawerActionMenuDropdownIdFromActionMenuId'; import { getRightDrawerActionMenuDropdownIdFromActionMenuId } from '@/action-menu/utils/getRightDrawerActionMenuDropdownIdFromActionMenuId';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown'; import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
@ -67,7 +68,8 @@ export const RightDrawerActionMenuDropdown = () => {
<DropdownMenuItemsContainer> <DropdownMenuItemsContainer>
{actionMenuEntries {actionMenuEntries
.filter( .filter(
(actionMenuEntry) => actionMenuEntry.scope === 'record-selection', (actionMenuEntry) =>
actionMenuEntry.scope === ActionMenuEntryScope.RecordSelection,
) )
.map((actionMenuEntry, index) => ( .map((actionMenuEntry, index) => (
<MenuItem <MenuItem

View File

@ -5,7 +5,11 @@ import { RecoilRoot } from 'recoil';
import { RecordIndexActionMenuBar } from '@/action-menu/components/RecordIndexActionMenuBar'; import { RecordIndexActionMenuBar } from '@/action-menu/components/RecordIndexActionMenuBar';
import { actionMenuEntriesComponentState } from '@/action-menu/states/actionMenuEntriesComponentState'; import { actionMenuEntriesComponentState } from '@/action-menu/states/actionMenuEntriesComponentState';
import { ActionMenuComponentInstanceContext } from '@/action-menu/states/contexts/ActionMenuComponentInstanceContext'; import { ActionMenuComponentInstanceContext } from '@/action-menu/states/contexts/ActionMenuComponentInstanceContext';
import { ActionMenuEntry } from '@/action-menu/types/ActionMenuEntry'; import {
ActionMenuEntry,
ActionMenuEntryScope,
ActionMenuEntryType,
} from '@/action-menu/types/ActionMenuEntry';
import { getActionBarIdFromActionMenuId } from '@/action-menu/utils/getActionBarIdFromActionMenuId'; import { getActionBarIdFromActionMenuId } from '@/action-menu/utils/getActionBarIdFromActionMenuId';
import { ContextStoreComponentInstanceContext } from '@/context-store/states/contexts/ContextStoreComponentInstanceContext'; import { ContextStoreComponentInstanceContext } from '@/context-store/states/contexts/ContextStoreComponentInstanceContext';
import { contextStoreNumberOfSelectedRecordsComponentState } from '@/context-store/states/contextStoreNumberOfSelectedRecordsComponentState'; import { contextStoreNumberOfSelectedRecordsComponentState } from '@/context-store/states/contextStoreNumberOfSelectedRecordsComponentState';
@ -48,8 +52,8 @@ const meta: Meta<typeof RecordIndexActionMenuBar> = {
map.set('delete', { map.set('delete', {
isPinned: true, isPinned: true,
scope: 'record-selection', scope: ActionMenuEntryScope.RecordSelection,
type: 'standard', type: ActionMenuEntryType.Standard,
key: 'delete', key: 'delete',
label: 'Delete', label: 'Delete',
position: 0, position: 0,

View File

@ -1,4 +1,8 @@
import { RecordIndexActionMenuBarEntry } from '@/action-menu/components/RecordIndexActionMenuBarEntry'; import { RecordIndexActionMenuBarEntry } from '@/action-menu/components/RecordIndexActionMenuBarEntry';
import {
ActionMenuEntryScope,
ActionMenuEntryType,
} from '@/action-menu/types/ActionMenuEntry';
import { expect, jest } from '@storybook/jest'; import { expect, jest } from '@storybook/jest';
import { Meta, StoryObj } from '@storybook/react'; import { Meta, StoryObj } from '@storybook/react';
import { userEvent, within } from '@storybook/testing-library'; import { userEvent, within } from '@storybook/testing-library';
@ -21,8 +25,8 @@ const markAsDoneMock = jest.fn();
export const Default: Story = { export const Default: Story = {
args: { args: {
entry: { entry: {
type: 'standard', type: ActionMenuEntryType.Standard,
scope: 'record-selection', scope: ActionMenuEntryScope.RecordSelection,
key: 'delete', key: 'delete',
label: 'Delete', label: 'Delete',
position: 0, position: 0,
@ -35,8 +39,8 @@ export const Default: Story = {
export const WithDangerAccent: Story = { export const WithDangerAccent: Story = {
args: { args: {
entry: { entry: {
type: 'standard', type: ActionMenuEntryType.Standard,
scope: 'record-selection', scope: ActionMenuEntryScope.RecordSelection,
key: 'delete', key: 'delete',
label: 'Delete', label: 'Delete',
position: 0, position: 0,
@ -50,8 +54,8 @@ export const WithDangerAccent: Story = {
export const WithInteraction: Story = { export const WithInteraction: Story = {
args: { args: {
entry: { entry: {
type: 'standard', type: ActionMenuEntryType.Standard,
scope: 'record-selection', scope: ActionMenuEntryScope.RecordSelection,
key: 'markAsDone', key: 'markAsDone',
label: 'Mark as done', label: 'Mark as done',
position: 0, position: 0,

View File

@ -7,7 +7,11 @@ import { RecordIndexActionMenuDropdown } from '@/action-menu/components/RecordIn
import { actionMenuEntriesComponentState } from '@/action-menu/states/actionMenuEntriesComponentState'; import { actionMenuEntriesComponentState } from '@/action-menu/states/actionMenuEntriesComponentState';
import { ActionMenuComponentInstanceContext } from '@/action-menu/states/contexts/ActionMenuComponentInstanceContext'; import { ActionMenuComponentInstanceContext } from '@/action-menu/states/contexts/ActionMenuComponentInstanceContext';
import { recordIndexActionMenuDropdownPositionComponentState } from '@/action-menu/states/recordIndexActionMenuDropdownPositionComponentState'; import { recordIndexActionMenuDropdownPositionComponentState } from '@/action-menu/states/recordIndexActionMenuDropdownPositionComponentState';
import { ActionMenuEntry } from '@/action-menu/types/ActionMenuEntry'; import {
ActionMenuEntry,
ActionMenuEntryScope,
ActionMenuEntryType,
} from '@/action-menu/types/ActionMenuEntry';
import { isDropdownOpenComponentState } from '@/ui/layout/dropdown/states/isDropdownOpenComponentState'; import { isDropdownOpenComponentState } from '@/ui/layout/dropdown/states/isDropdownOpenComponentState';
import { extractComponentState } from '@/ui/utilities/state/component-state/utils/extractComponentState'; import { extractComponentState } from '@/ui/utilities/state/component-state/utils/extractComponentState';
import { IconCheckbox, IconHeart, IconTrash } from 'twenty-ui'; import { IconCheckbox, IconHeart, IconTrash } from 'twenty-ui';
@ -41,8 +45,8 @@ const meta: Meta<typeof RecordIndexActionMenuDropdown> = {
); );
map.set('delete', { map.set('delete', {
type: 'standard', type: ActionMenuEntryType.Standard,
scope: 'record-selection', scope: ActionMenuEntryScope.RecordSelection,
key: 'delete', key: 'delete',
label: 'Delete', label: 'Delete',
position: 0, position: 0,
@ -51,8 +55,8 @@ const meta: Meta<typeof RecordIndexActionMenuDropdown> = {
}); });
map.set('markAsDone', { map.set('markAsDone', {
type: 'standard', type: ActionMenuEntryType.Standard,
scope: 'record-selection', scope: ActionMenuEntryScope.RecordSelection,
key: 'markAsDone', key: 'markAsDone',
label: 'Mark as done', label: 'Mark as done',
position: 1, position: 1,
@ -61,8 +65,8 @@ const meta: Meta<typeof RecordIndexActionMenuDropdown> = {
}); });
map.set('addToFavorites', { map.set('addToFavorites', {
type: 'standard', type: ActionMenuEntryType.Standard,
scope: 'record-selection', scope: ActionMenuEntryScope.RecordSelection,
key: 'addToFavorites', key: 'addToFavorites',
label: 'Add to favorites', label: 'Add to favorites',
position: 2, position: 2,

View File

@ -5,7 +5,11 @@ import { RecoilRoot } from 'recoil';
import { RightDrawerActionMenuDropdown } from '@/action-menu/components/RightDrawerActionMenuDropdown'; import { RightDrawerActionMenuDropdown } from '@/action-menu/components/RightDrawerActionMenuDropdown';
import { actionMenuEntriesComponentState } from '@/action-menu/states/actionMenuEntriesComponentState'; import { actionMenuEntriesComponentState } from '@/action-menu/states/actionMenuEntriesComponentState';
import { ActionMenuComponentInstanceContext } from '@/action-menu/states/contexts/ActionMenuComponentInstanceContext'; import { ActionMenuComponentInstanceContext } from '@/action-menu/states/contexts/ActionMenuComponentInstanceContext';
import { ActionMenuEntry } from '@/action-menu/types/ActionMenuEntry'; import {
ActionMenuEntry,
ActionMenuEntryScope,
ActionMenuEntryType,
} from '@/action-menu/types/ActionMenuEntry';
import { contextStoreNumberOfSelectedRecordsComponentState } from '@/context-store/states/contextStoreNumberOfSelectedRecordsComponentState'; import { contextStoreNumberOfSelectedRecordsComponentState } from '@/context-store/states/contextStoreNumberOfSelectedRecordsComponentState';
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState'; import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
import { userEvent, waitFor, within } from '@storybook/test'; import { userEvent, waitFor, within } from '@storybook/test';
@ -54,8 +58,8 @@ const meta: Meta<typeof RightDrawerActionMenuDropdown> = {
); );
map.set('addToFavorites', { map.set('addToFavorites', {
type: 'standard', type: ActionMenuEntryType.Standard,
scope: 'record-selection', scope: ActionMenuEntryScope.RecordSelection,
key: 'addToFavorites', key: 'addToFavorites',
label: 'Add to favorites', label: 'Add to favorites',
position: 0, position: 0,
@ -64,8 +68,8 @@ const meta: Meta<typeof RightDrawerActionMenuDropdown> = {
}); });
map.set('export', { map.set('export', {
type: 'standard', type: ActionMenuEntryType.Standard,
scope: 'record-selection', scope: ActionMenuEntryScope.RecordSelection,
key: 'export', key: 'export',
label: 'Export', label: 'Export',
position: 1, position: 1,
@ -74,8 +78,8 @@ const meta: Meta<typeof RightDrawerActionMenuDropdown> = {
}); });
map.set('delete', { map.set('delete', {
type: 'standard', type: ActionMenuEntryType.Standard,
scope: 'record-selection', scope: ActionMenuEntryScope.RecordSelection,
key: 'delete', key: 'delete',
label: 'Delete', label: 'Delete',
position: 2, position: 2,

View File

@ -1,9 +1,19 @@
import { MouseEvent, ReactNode } from 'react'; import { MouseEvent, ReactNode } from 'react';
import { IconComponent, MenuItemAccent } from 'twenty-ui'; import { IconComponent, MenuItemAccent } from 'twenty-ui';
export enum ActionMenuEntryType {
Standard = 'Standard',
WorkflowRun = 'WorkflowRun',
}
export enum ActionMenuEntryScope {
Global = 'Global',
RecordSelection = 'RecordSelection',
}
export type ActionMenuEntry = { export type ActionMenuEntry = {
type: 'standard' | 'workflow-run'; type: ActionMenuEntryType;
scope: 'global' | 'record-selection'; scope: ActionMenuEntryScope;
key: string; key: string;
label: string; label: string;
position: number; position: number;

View File

@ -5,13 +5,21 @@ import { Note } from '@/activities/types/Note';
import { Task } from '@/activities/types/Task'; import { Task } from '@/activities/types/Task';
import { CommandGroup } from '@/command-menu/components/CommandGroup'; import { CommandGroup } from '@/command-menu/components/CommandGroup';
import { CommandMenuItem } from '@/command-menu/components/CommandMenuItem'; 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 { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
import { commandMenuCommandsState } from '@/command-menu/states/commandMenuCommandsState'; import { commandMenuCommandsState } from '@/command-menu/states/commandMenuCommandsState';
import { commandMenuSearchState } from '@/command-menu/states/commandMenuSearchState'; import { commandMenuSearchState } from '@/command-menu/states/commandMenuSearchState';
import { isCommandMenuOpenedState } from '@/command-menu/states/isCommandMenuOpenedState'; import { isCommandMenuOpenedState } from '@/command-menu/states/isCommandMenuOpenedState';
import { Command, CommandType } from '@/command-menu/types/Command'; import {
Command,
CommandScope,
CommandType,
} from '@/command-menu/types/Command';
import { Company } from '@/companies/types/Company'; import { Company } from '@/companies/types/Company';
import { mainContextStoreComponentInstanceIdState } from '@/context-store/states/mainContextStoreComponentInstanceId'; import { contextStoreNumberOfSelectedRecordsComponentState } from '@/context-store/states/contextStoreNumberOfSelectedRecordsComponentState';
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
import { useKeyboardShortcutMenu } from '@/keyboard-shortcut-menu/hooks/useKeyboardShortcutMenu'; import { useKeyboardShortcutMenu } from '@/keyboard-shortcut-menu/hooks/useKeyboardShortcutMenu';
import { CoreObjectNamePlural } from '@/object-metadata/types/CoreObjectNamePlural'; import { CoreObjectNamePlural } from '@/object-metadata/types/CoreObjectNamePlural';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
@ -27,6 +35,7 @@ import { AppHotkeyScope } from '@/ui/utilities/hotkey/types/AppHotkeyScope';
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside'; import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile'; import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper'; import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper';
import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { isNonEmptyString } from '@sniptt/guards'; import { isNonEmptyString } from '@sniptt/guards';
@ -40,16 +49,12 @@ import {
IconComponent, IconComponent,
IconNotes, IconNotes,
IconSparkles, IconSparkles,
IconX,
LightIconButton,
isDefined, isDefined,
} from 'twenty-ui'; } from 'twenty-ui';
import { useDebounce } from 'use-debounce'; import { useDebounce } from 'use-debounce';
import { getLogoUrlFromDomainName } from '~/utils'; import { getLogoUrlFromDomainName } from '~/utils';
import { capitalize } from '~/utils/string/capitalize'; import { capitalize } from '~/utils/string/capitalize';
const SEARCH_BAR_HEIGHT = 56;
const SEARCH_BAR_PADDING = 3;
const MOBILE_NAVIGATION_BAR_HEIGHT = 64; const MOBILE_NAVIGATION_BAR_HEIGHT = 64;
type CommandGroupConfig = { type CommandGroupConfig = {
@ -80,48 +85,6 @@ const StyledCommandMenu = styled.div`
z-index: 1000; z-index: 1000;
`; `;
const StyledInputContainer = styled.div`
align-items: center;
background-color: ${({ theme }) => theme.background.transparent.lighter};
border: none;
border-bottom: 1px solid ${({ theme }) => theme.border.color.medium};
border-radius: 0;
display: flex;
font-size: ${({ theme }) => theme.font.size.lg};
height: ${SEARCH_BAR_HEIGHT}px;
margin: 0;
outline: none;
position: relative;
padding: 0 ${({ theme }) => theme.spacing(SEARCH_BAR_PADDING)};
`;
const StyledInput = styled.input`
border: none;
border-radius: 0;
background-color: transparent;
color: ${({ theme }) => theme.font.color.primary};
font-size: ${({ theme }) => theme.font.size.md};
margin: 0;
outline: none;
height: 24px;
padding: 0;
width: ${({ theme }) => `calc(100% - ${theme.spacing(8)})`};
&::placeholder {
color: ${({ theme }) => theme.font.color.light};
font-weight: ${({ theme }) => theme.font.weight.medium};
}
`;
const StyledCloseButtonContainer = styled.div`
align-items: center;
display: flex;
height: 32px;
justify-content: center;
`;
const StyledList = styled.div` const StyledList = styled.div`
background: ${({ theme }) => theme.background.secondary}; background: ${({ theme }) => theme.background.secondary};
overscroll-behavior: contain; overscroll-behavior: contain;
@ -132,10 +95,12 @@ const StyledList = styled.div`
const StyledInnerList = styled.div<{ isMobile: boolean }>` const StyledInnerList = styled.div<{ isMobile: boolean }>`
max-height: ${({ isMobile }) => max-height: ${({ isMobile }) =>
isMobile isMobile
? `calc(100dvh - ${SEARCH_BAR_HEIGHT}px - ${ ? `calc(100dvh - ${COMMAND_MENU_SEARCH_BAR_HEIGHT}px - ${
SEARCH_BAR_PADDING * 2 COMMAND_MENU_SEARCH_BAR_PADDING * 2
}px - ${MOBILE_NAVIGATION_BAR_HEIGHT}px)` }px - ${MOBILE_NAVIGATION_BAR_HEIGHT}px)`
: `calc(100dvh - ${SEARCH_BAR_HEIGHT}px - ${SEARCH_BAR_PADDING * 2}px)`}; : `calc(100dvh - ${COMMAND_MENU_SEARCH_BAR_HEIGHT}px - ${
COMMAND_MENU_SEARCH_BAR_PADDING * 2
}px)`};
padding-left: ${({ theme }) => theme.spacing(2)}; padding-left: ${({ theme }) => theme.spacing(2)};
padding-right: ${({ theme }) => theme.spacing(2)}; padding-right: ${({ theme }) => theme.spacing(2)};
padding-top: ${({ theme }) => theme.spacing(1)}; padding-top: ${({ theme }) => theme.spacing(1)};
@ -165,9 +130,14 @@ export const CommandMenu = () => {
const [deferredCommandMenuSearch] = useDebounce(commandMenuSearch, 300); // 200ms - 500ms const [deferredCommandMenuSearch] = useDebounce(commandMenuSearch, 300); // 200ms - 500ms
const commandMenuCommands = useRecoilValue(commandMenuCommandsState); const commandMenuCommands = useRecoilValue(commandMenuCommandsState);
const { closeKeyboardShortcutMenu } = useKeyboardShortcutMenu(); const { closeKeyboardShortcutMenu } = useKeyboardShortcutMenu();
const handleSearchChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setCommandMenuSearch(event.target.value); const setContextStoreTargetedRecordsRule = useSetRecoilComponentStateV2(
}; contextStoreTargetedRecordsRuleComponentState,
);
const setContextStoreNumberOfSelectedRecords = useSetRecoilComponentStateV2(
contextStoreNumberOfSelectedRecordsComponentState,
);
const isMobile = useIsMobile(); const isMobile = useIsMobile();
@ -190,6 +160,25 @@ export const CommandMenu = () => {
[closeCommandMenu], [closeCommandMenu],
); );
useScopedHotkeys(
[Key.Backspace, Key.Delete],
() => {
if (!isNonEmptyString(commandMenuSearch)) {
setContextStoreTargetedRecordsRule({
mode: 'selection',
selectedRecordIds: [],
});
setContextStoreNumberOfSelectedRecords(0);
}
},
AppHotkeyScope.CommandMenuOpen,
[closeCommandMenu],
{
preventDefault: false,
},
);
const { const {
matchesSearchFilterObjectRecordsQueryResult, matchesSearchFilterObjectRecordsQueryResult,
matchesSearchFilterObjectRecordsLoading: loading, matchesSearchFilterObjectRecordsLoading: loading,
@ -378,20 +367,45 @@ export const CommandMenu = () => {
: true) && cmd.type === CommandType.Create, : true) && cmd.type === CommandType.Create,
); );
const matchingStandardActionCommands = commandMenuCommands.filter( 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) => (cmd) =>
(deferredCommandMenuSearch.length > 0 (deferredCommandMenuSearch.length > 0
? checkInShortcuts(cmd, deferredCommandMenuSearch) || ? checkInShortcuts(cmd, deferredCommandMenuSearch) ||
checkInLabels(cmd, deferredCommandMenuSearch) checkInLabels(cmd, deferredCommandMenuSearch)
: true) && cmd.type === CommandType.StandardAction, : true) &&
cmd.type === CommandType.StandardAction &&
cmd.scope === CommandScope.Global,
); );
const matchingWorkflowRunCommands = commandMenuCommands.filter( const matchingWorkflowRunRecordSelectionCommands = commandMenuCommands.filter(
(cmd) => (cmd) =>
(deferredCommandMenuSearch.length > 0 (deferredCommandMenuSearch.length > 0
? checkInShortcuts(cmd, deferredCommandMenuSearch) || ? checkInShortcuts(cmd, deferredCommandMenuSearch) ||
checkInLabels(cmd, deferredCommandMenuSearch) checkInLabels(cmd, deferredCommandMenuSearch)
: true) && cmd.type === CommandType.WorkflowRun, : 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,
); );
useListenClickOutside({ useListenClickOutside({
@ -419,8 +433,10 @@ export const CommandMenu = () => {
const selectableItemIds = copilotCommands const selectableItemIds = copilotCommands
.map((cmd) => cmd.id) .map((cmd) => cmd.id)
.concat(matchingStandardActionCommands.map((cmd) => cmd.id)) .concat(matchingStandardActionRecordSelectionCommands.map((cmd) => cmd.id))
.concat(matchingWorkflowRunCommands.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(matchingCreateCommand.map((cmd) => cmd.id))
.concat(matchingNavigateCommand.map((cmd) => cmd.id)) .concat(matchingNavigateCommand.map((cmd) => cmd.id))
.concat(people?.map((person) => person.id)) .concat(people?.map((person) => person.id))
@ -437,8 +453,10 @@ export const CommandMenu = () => {
); );
const isNoResults = const isNoResults =
!matchingStandardActionCommands.length && !matchingStandardActionRecordSelectionCommands.length &&
!matchingWorkflowRunCommands.length && !matchingWorkflowRunRecordSelectionCommands.length &&
!matchingStandardActionGlobalCommands.length &&
!matchingWorkflowRunGlobalCommands.length &&
!matchingCreateCommand.length && !matchingCreateCommand.length &&
!matchingNavigateCommand.length && !matchingNavigateCommand.length &&
!people?.length && !people?.length &&
@ -450,10 +468,6 @@ export const CommandMenu = () => {
const isLoading = loading || isNotesLoading || isTasksLoading; const isLoading = loading || isNotesLoading || isTasksLoading;
const mainContextStoreComponentInstanceId = useRecoilValue(
mainContextStoreComponentInstanceIdState,
);
const commandGroups: CommandGroupConfig[] = [ const commandGroups: CommandGroupConfig[] = [
{ {
heading: 'Navigate', heading: 'Navigate',
@ -575,24 +589,10 @@ export const CommandMenu = () => {
<> <>
{isCommandMenuOpened && ( {isCommandMenuOpened && (
<StyledCommandMenu ref={commandMenuRef} className="command-menu"> <StyledCommandMenu ref={commandMenuRef} className="command-menu">
<StyledInputContainer> <CommandMenuTopBar
<StyledInput commandMenuSearch={commandMenuSearch}
autoFocus setCommandMenuSearch={setCommandMenuSearch}
value={commandMenuSearch} />
placeholder="Search"
onChange={handleSearchChange}
/>
{!isMobile && (
<StyledCloseButtonContainer>
<LightIconButton
accent={'tertiary'}
size={'medium'}
Icon={IconX}
onClick={closeCommandMenu}
/>
</StyledCloseButtonContainer>
)}
</StyledInputContainer>
<StyledList> <StyledList>
<ScrollWrapper contextProviderName="commandMenu"> <ScrollWrapper contextProviderName="commandMenu">
<StyledInnerList isMobile={isMobile}> <StyledInnerList isMobile={isMobile}>
@ -632,45 +632,83 @@ export const CommandMenu = () => {
</SelectableItem> </SelectableItem>
</CommandGroup> </CommandGroup>
)} )}
{mainContextStoreComponentInstanceId && ( <CommandGroup heading="Record Selection">
<> {matchingStandardActionRecordSelectionCommands?.map(
<CommandGroup heading="Standard Actions"> (standardActionrecordSelectionCommand) => (
{matchingStandardActionCommands?.map( <SelectableItem
(standardActionCommand) => ( itemId={standardActionrecordSelectionCommand.id}
<SelectableItem key={standardActionrecordSelectionCommand.id}
itemId={standardActionCommand.id} >
key={standardActionCommand.id} <CommandMenuItem
> id={standardActionrecordSelectionCommand.id}
<CommandMenuItem label={standardActionrecordSelectionCommand.label}
id={standardActionCommand.id} Icon={standardActionrecordSelectionCommand.Icon}
label={standardActionCommand.label} onClick={
Icon={standardActionCommand.Icon} standardActionrecordSelectionCommand.onCommandClick
onClick={standardActionCommand.onCommandClick} }
/> />
</SelectableItem> </SelectableItem>
), ),
)} )}
</CommandGroup> {matchingWorkflowRunRecordSelectionCommands?.map(
(workflowRunRecordSelectionCommand) => (
<CommandGroup heading="Workflows"> <SelectableItem
{matchingWorkflowRunCommands?.map( itemId={workflowRunRecordSelectionCommand.id}
(workflowRunCommand) => ( key={workflowRunRecordSelectionCommand.id}
<SelectableItem >
itemId={workflowRunCommand.id} <CommandMenuItem
key={workflowRunCommand.id} id={workflowRunRecordSelectionCommand.id}
> label={workflowRunRecordSelectionCommand.label}
<CommandMenuItem Icon={workflowRunRecordSelectionCommand.Icon}
id={workflowRunCommand.id} onClick={
label={workflowRunCommand.label} workflowRunRecordSelectionCommand.onCommandClick
Icon={workflowRunCommand.Icon} }
onClick={workflowRunCommand.onCommandClick} />
/> </SelectableItem>
</SelectableItem> ),
), )}
)} </CommandGroup>
</CommandGroup> {matchingStandardActionGlobalCommands?.length > 0 && (
</> <CommandGroup heading="View">
{matchingStandardActionGlobalCommands?.map(
(standardActionGlobalCommand) => (
<SelectableItem
itemId={standardActionGlobalCommand.id}
key={standardActionGlobalCommand.id}
>
<CommandMenuItem
id={standardActionGlobalCommand.id}
label={standardActionGlobalCommand.label}
Icon={standardActionGlobalCommand.Icon}
onClick={
standardActionGlobalCommand.onCommandClick
}
/>
</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}
/>
</SelectableItem>
),
)}
</CommandGroup>
)}
{commandGroups.map(({ heading, items, renderItem }) => {commandGroups.map(({ heading, items, renderItem }) =>
items?.length ? ( items?.length ? (
<CommandGroup heading={heading} key={heading}> <CommandGroup heading={heading} key={heading}>

View File

@ -0,0 +1,20 @@
import { actionMenuEntriesComponentSelector } from '@/action-menu/states/actionMenuEntriesComponentSelector';
import { commandMenuCommandsState } from '@/command-menu/states/commandMenuCommandsState';
import { computeCommandMenuCommands } from '@/command-menu/utils/computeCommandMenuCommands';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { useEffect } from 'react';
import { useSetRecoilState } from 'recoil';
export const CommandMenuCommandsEffect = () => {
const actionMenuEntries = useRecoilComponentValueV2(
actionMenuEntriesComponentSelector,
);
const setCommands = useSetRecoilState(commandMenuCommandsState);
useEffect(() => {
setCommands(computeCommandMenuCommands(actionMenuEntries));
}, [actionMenuEntries, setCommands]);
return null;
};

View File

@ -0,0 +1,117 @@
import { useContextStoreSelectedRecords } from '@/context-store/hooks/useContextStoreSelectedRecords';
import { contextStoreCurrentObjectMetadataIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataIdComponentState';
import { useGetStandardObjectIcon } from '@/object-metadata/hooks/useGetStandardObjectIcon';
import { useObjectMetadataItemById } from '@/object-metadata/hooks/useObjectMetadataItemById';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { getObjectRecordIdentifier } from '@/object-metadata/utils/getObjectRecordIdentifier';
import { useRecordChipData } from '@/object-record/hooks/useRecordChipData';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { Avatar } from 'twenty-ui';
import { capitalize } from '~/utils/string/capitalize';
const StyledChip = styled.div`
align-items: center;
background: ${({ theme }) => theme.background.transparent.light};
border: 1px solid ${({ theme }) => theme.border.color.medium};
border-radius: ${({ theme }) => theme.border.radius.md};
box-sizing: border-box;
display: flex;
gap: ${({ theme }) => theme.spacing(1)};
height: ${({ theme }) => theme.spacing(8)};
padding: 0 ${({ theme }) => theme.spacing(2)};
font-size: ${({ theme }) => theme.font.size.sm};
font-weight: ${({ theme }) => theme.font.weight.medium};
line-height: ${({ theme }) => theme.text.lineHeight.lg};
color: ${({ theme }) => theme.font.color.primary};
`;
const StyledAvatarWrapper = styled.div`
background-color: ${({ theme }) => theme.background.primary};
border-radius: ${({ theme }) => theme.border.radius.sm};
padding: ${({ theme }) => theme.spacing(0.5)};
border: 1px solid ${({ theme }) => theme.border.color.medium};
&:not(:first-of-type) {
margin-left: -${({ theme }) => theme.spacing(1)};
}
display: flex;
align-items: center;
justify-content: center;
`;
const StyledAvatarContainer = styled.div`
display: flex;
`;
const CommandMenuContextRecordChipAvatars = ({
objectMetadataItem,
record,
}: {
objectMetadataItem: ObjectMetadataItem;
record: ObjectRecord;
}) => {
const { recordChipData } = useRecordChipData({
objectNameSingular: objectMetadataItem.nameSingular,
record,
});
const { Icon, IconColor } = useGetStandardObjectIcon(
objectMetadataItem.nameSingular,
);
const theme = useTheme();
return (
<StyledAvatarWrapper>
{Icon ? (
<Icon color={IconColor} size={theme.icon.size.sm} />
) : (
<Avatar
avatarUrl={recordChipData.avatarUrl}
placeholderColorSeed={recordChipData.recordId}
placeholder={recordChipData.name}
type={recordChipData.avatarType}
size="sm"
/>
)}
</StyledAvatarWrapper>
);
};
export const CommandMenuContextRecordChip = () => {
const contextStoreCurrentObjectMetadataId = useRecoilComponentValueV2(
contextStoreCurrentObjectMetadataIdComponentState,
);
const { objectMetadataItem } = useObjectMetadataItemById({
objectId: contextStoreCurrentObjectMetadataId ?? '',
});
const { records, loading, totalCount } = useContextStoreSelectedRecords({
limit: 3,
});
if (loading || !totalCount) {
return null;
}
return (
<StyledChip>
<StyledAvatarContainer>
{records.map((record) => (
<CommandMenuContextRecordChipAvatars
objectMetadataItem={objectMetadataItem}
key={record.id}
record={record}
/>
))}
</StyledAvatarContainer>
{totalCount === 1
? getObjectRecordIdentifier({ objectMetadataItem, record: records[0] })
.name
: `${totalCount} ${capitalize(objectMetadataItem.namePlural)}`}
</StyledChip>
);
};

View File

@ -0,0 +1,89 @@
import { CommandMenuContextRecordChip } from '@/command-menu/components/CommandMenuContextRecordChip';
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 styled from '@emotion/styled';
import { IconX, LightIconButton, useIsMobile } from 'twenty-ui';
const StyledInputContainer = styled.div`
align-items: center;
background-color: ${({ theme }) => theme.background.transparent.lighter};
border: none;
border-bottom: 1px solid ${({ theme }) => theme.border.color.medium};
border-radius: 0;
display: flex;
font-size: ${({ theme }) => theme.font.size.lg};
height: ${COMMAND_MENU_SEARCH_BAR_HEIGHT}px;
margin: 0;
outline: none;
position: relative;
padding: 0 ${({ theme }) => theme.spacing(COMMAND_MENU_SEARCH_BAR_PADDING)};
gap: ${({ theme }) => theme.spacing(1)};
`;
const StyledInput = styled.input`
border: none;
border-radius: 0;
background-color: transparent;
color: ${({ theme }) => theme.font.color.primary};
font-size: ${({ theme }) => theme.font.size.md};
margin: 0;
outline: none;
height: 24px;
padding: 0;
flex: 1;
&::placeholder {
color: ${({ theme }) => theme.font.color.light};
font-weight: ${({ theme }) => theme.font.weight.medium};
}
`;
const StyledCloseButtonContainer = styled.div`
align-items: center;
display: flex;
height: 32px;
justify-content: center;
`;
type CommandMenuTopBarProps = {
commandMenuSearch: string;
setCommandMenuSearch: (search: string) => void;
};
export const CommandMenuTopBar = ({
commandMenuSearch,
setCommandMenuSearch,
}: CommandMenuTopBarProps) => {
const handleSearchChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setCommandMenuSearch(event.target.value);
};
const isMobile = useIsMobile();
const { closeCommandMenu } = useCommandMenu();
return (
<StyledInputContainer>
<CommandMenuContextRecordChip />
<StyledInput
autoFocus
value={commandMenuSearch}
placeholder="Type anything"
onChange={handleSearchChange}
/>
{!isMobile && (
<StyledCloseButtonContainer>
<LightIconButton
accent={'tertiary'}
size={'medium'}
Icon={IconX}
onClick={closeCommandMenu}
/>
</StyledCloseButtonContainer>
)}
</StyledInputContainer>
);
};

View File

@ -1,5 +1,5 @@
import { action } from '@storybook/addon-actions'; import { action } from '@storybook/addon-actions';
import { Meta, StoryObj } from '@storybook/react'; import { Decorator, Meta, StoryObj } from '@storybook/react';
import { expect, userEvent, within } from '@storybook/test'; import { expect, userEvent, within } from '@storybook/test';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { useRecoilValue, useSetRecoilState } from 'recoil'; import { useRecoilValue, useSetRecoilState } from 'recoil';
@ -20,13 +20,32 @@ import {
} from '~/testing/mock-data/users'; } from '~/testing/mock-data/users';
import { sleep } from '~/utils/sleep'; import { sleep } from '~/utils/sleep';
import { ActionMenuComponentInstanceContext } from '@/action-menu/states/contexts/ActionMenuComponentInstanceContext';
import { ContextStoreComponentInstanceContext } from '@/context-store/states/contexts/ContextStoreComponentInstanceContext';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState'; import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { JestContextStoreSetter } from '~/testing/jest/JestContextStoreSetter';
import { CommandMenu } from '../CommandMenu'; import { CommandMenu } from '../CommandMenu';
const companiesMock = getCompaniesMock(); const companiesMock = getCompaniesMock();
const openTimeout = 50; const openTimeout = 50;
const ContextStoreDecorator: Decorator = (Story) => {
return (
<ContextStoreComponentInstanceContext.Provider
value={{ instanceId: 'command-menu' }}
>
<ActionMenuComponentInstanceContext.Provider
value={{ instanceId: 'command-menu' }}
>
<JestContextStoreSetter contextStoreCurrentObjectMetadataNameSingular="company">
<Story />
</JestContextStoreSetter>
</ActionMenuComponentInstanceContext.Provider>
</ContextStoreComponentInstanceContext.Provider>
);
};
const meta: Meta<typeof CommandMenu> = { const meta: Meta<typeof CommandMenu> = {
title: 'Modules/CommandMenu/CommandMenu', title: 'Modules/CommandMenu/CommandMenu',
component: CommandMenu, component: CommandMenu,
@ -79,6 +98,7 @@ const meta: Meta<typeof CommandMenu> = {
return <Story />; return <Story />;
}, },
ContextStoreDecorator,
ObjectMetadataItemsDecorator, ObjectMetadataItemsDecorator,
SnackBarDecorator, SnackBarDecorator,
ComponentWithRouterDecorator, ComponentWithRouterDecorator,
@ -109,7 +129,7 @@ export const DefaultWithoutSearch: Story = {
export const MatchingPersonCompanyActivityCreateNavigate: Story = { export const MatchingPersonCompanyActivityCreateNavigate: Story = {
play: async () => { play: async () => {
const canvas = within(document.body); const canvas = within(document.body);
const searchInput = await canvas.findByPlaceholderText('Search'); const searchInput = await canvas.findByPlaceholderText('Type anything');
await sleep(openTimeout); await sleep(openTimeout);
await userEvent.type(searchInput, 'n'); await userEvent.type(searchInput, 'n');
expect(await canvas.findByText('Linkedin')).toBeInTheDocument(); expect(await canvas.findByText('Linkedin')).toBeInTheDocument();
@ -122,7 +142,7 @@ export const MatchingPersonCompanyActivityCreateNavigate: Story = {
export const OnlyMatchingCreateAndNavigate: Story = { export const OnlyMatchingCreateAndNavigate: Story = {
play: async () => { play: async () => {
const canvas = within(document.body); const canvas = within(document.body);
const searchInput = await canvas.findByPlaceholderText('Search'); const searchInput = await canvas.findByPlaceholderText('Type anything');
await sleep(openTimeout); await sleep(openTimeout);
await userEvent.type(searchInput, 'ta'); await userEvent.type(searchInput, 'ta');
expect(await canvas.findByText('Create Task')).toBeInTheDocument(); expect(await canvas.findByText('Create Task')).toBeInTheDocument();
@ -133,7 +153,7 @@ export const OnlyMatchingCreateAndNavigate: Story = {
export const AtleastMatchingOnePerson: Story = { export const AtleastMatchingOnePerson: Story = {
play: async () => { play: async () => {
const canvas = within(document.body); const canvas = within(document.body);
const searchInput = await canvas.findByPlaceholderText('Search'); const searchInput = await canvas.findByPlaceholderText('Type anything');
await sleep(openTimeout); await sleep(openTimeout);
await userEvent.type(searchInput, 'alex'); await userEvent.type(searchInput, 'alex');
expect(await canvas.findByText('Sylvie Palmer')).toBeInTheDocument(); expect(await canvas.findByText('Sylvie Palmer')).toBeInTheDocument();

View File

@ -0,0 +1 @@
export const COMMAND_MENU_SEARCH_BAR_HEIGHT = 56;

View File

@ -0,0 +1 @@
export const COMMAND_MENU_SEARCH_BAR_PADDING = 3;

View File

@ -9,8 +9,12 @@ import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousH
import { AppHotkeyScope } from '@/ui/utilities/hotkey/types/AppHotkeyScope'; import { AppHotkeyScope } from '@/ui/utilities/hotkey/types/AppHotkeyScope';
import { isDefined } from '~/utils/isDefined'; import { isDefined } from '~/utils/isDefined';
import { actionMenuEntriesComponentSelector } from '@/action-menu/states/actionMenuEntriesComponentSelector';
import { COMMAND_MENU_COMMANDS } from '@/command-menu/constants/CommandMenuCommands'; import { COMMAND_MENU_COMMANDS } from '@/command-menu/constants/CommandMenuCommands';
import { contextStoreCurrentObjectMetadataIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataIdComponentState';
import { contextStoreCurrentViewIdComponentState } from '@/context-store/states/contextStoreCurrentViewIdComponentState';
import { contextStoreFiltersComponentState } from '@/context-store/states/contextStoreFiltersComponentState';
import { contextStoreNumberOfSelectedRecordsComponentState } from '@/context-store/states/contextStoreNumberOfSelectedRecordsComponentState';
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
import { mainContextStoreComponentInstanceIdState } from '@/context-store/states/mainContextStoreComponentInstanceId'; import { mainContextStoreComponentInstanceIdState } from '@/context-store/states/mainContextStoreComponentInstanceId';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { ALL_ICONS } from '@ui/display/icon/providers/internal/AllIcons'; import { ALL_ICONS } from '@ui/display/icon/providers/internal/AllIcons';
@ -34,42 +38,83 @@ export const useCommandMenu = () => {
); );
const openCommandMenu = useRecoilCallback( const openCommandMenu = useRecoilCallback(
({ snapshot }) => ({ snapshot, set }) =>
() => { () => {
if (isDefined(mainContextStoreComponentInstanceId)) { if (isDefined(mainContextStoreComponentInstanceId)) {
const actionMenuEntries = snapshot.getLoadable( const contextStoreCurrentObjectMetadataId = snapshot
actionMenuEntriesComponentSelector.selectorFamily({ .getLoadable(
instanceId: mainContextStoreComponentInstanceId, contextStoreCurrentObjectMetadataIdComponentState.atomFamily({
instanceId: mainContextStoreComponentInstanceId,
}),
)
.getValue();
set(
contextStoreCurrentObjectMetadataIdComponentState.atomFamily({
instanceId: 'command-menu',
}), }),
contextStoreCurrentObjectMetadataId,
); );
const commands = Object.values(COMMAND_MENU_COMMANDS); const contextStoreTargetedRecordsRule = snapshot
.getLoadable(
const actionCommands = actionMenuEntries contextStoreTargetedRecordsRuleComponentState.atomFamily({
.getValue() instanceId: mainContextStoreComponentInstanceId,
?.filter((actionMenuEntry) => actionMenuEntry.type === 'standard') }),
?.map((actionMenuEntry) => ({
id: actionMenuEntry.key,
label: actionMenuEntry.label,
Icon: actionMenuEntry.Icon,
onCommandClick: actionMenuEntry.onClick,
type: CommandType.StandardAction,
}));
const workflowRunCommands = actionMenuEntries
.getValue()
?.filter(
(actionMenuEntry) => actionMenuEntry.type === 'workflow-run',
) )
?.map((actionMenuEntry) => ({ .getValue();
id: actionMenuEntry.key,
label: actionMenuEntry.label,
Icon: actionMenuEntry.Icon,
onCommandClick: actionMenuEntry.onClick,
type: CommandType.WorkflowRun,
}));
setCommands([...commands, ...actionCommands, ...workflowRunCommands]); set(
contextStoreTargetedRecordsRuleComponentState.atomFamily({
instanceId: 'command-menu',
}),
contextStoreTargetedRecordsRule,
);
const contextStoreNumberOfSelectedRecords = snapshot
.getLoadable(
contextStoreNumberOfSelectedRecordsComponentState.atomFamily({
instanceId: mainContextStoreComponentInstanceId,
}),
)
.getValue();
set(
contextStoreNumberOfSelectedRecordsComponentState.atomFamily({
instanceId: 'command-menu',
}),
contextStoreNumberOfSelectedRecords,
);
const contextStoreFilters = snapshot
.getLoadable(
contextStoreFiltersComponentState.atomFamily({
instanceId: mainContextStoreComponentInstanceId,
}),
)
.getValue();
set(
contextStoreFiltersComponentState.atomFamily({
instanceId: 'command-menu',
}),
contextStoreFilters,
);
const contextStoreCurrentViewId = snapshot
.getLoadable(
contextStoreCurrentViewIdComponentState.atomFamily({
instanceId: mainContextStoreComponentInstanceId,
}),
)
.getValue();
set(
contextStoreCurrentViewIdComponentState.atomFamily({
instanceId: 'command-menu',
}),
contextStoreCurrentViewId,
);
} }
setIsCommandMenuOpened(true); setIsCommandMenuOpened(true);
@ -77,7 +122,6 @@ export const useCommandMenu = () => {
}, },
[ [
mainContextStoreComponentInstanceId, mainContextStoreComponentInstanceId,
setCommands,
setHotkeyScopeAndMemorizePreviousScope, setHotkeyScopeAndMemorizePreviousScope,
setIsCommandMenuOpened, setIsCommandMenuOpened,
], ],
@ -92,17 +136,11 @@ export const useCommandMenu = () => {
if (isCommandMenuOpened) { if (isCommandMenuOpened) {
setIsCommandMenuOpened(false); setIsCommandMenuOpened(false);
setCommands([]);
resetSelectedItem(); resetSelectedItem();
goBackToPreviousHotkeyScope(); goBackToPreviousHotkeyScope();
} }
}, },
[ [goBackToPreviousHotkeyScope, resetSelectedItem, setIsCommandMenuOpened],
goBackToPreviousHotkeyScope,
resetSelectedItem,
setCommands,
setIsCommandMenuOpened,
],
); );
const toggleCommandMenu = useRecoilCallback( const toggleCommandMenu = useRecoilCallback(

View File

@ -1,5 +1,4 @@
import { IconComponent } from 'twenty-ui'; import { IconComponent } from 'twenty-ui';
export enum CommandType { export enum CommandType {
Navigate = 'Navigate', Navigate = 'Navigate',
Create = 'Create', Create = 'Create',
@ -7,15 +6,17 @@ export enum CommandType {
WorkflowRun = 'WorkflowRun', WorkflowRun = 'WorkflowRun',
} }
export enum CommandScope {
Global = 'Global',
RecordSelection = 'RecordSelection',
}
export type Command = { export type Command = {
id: string; id: string;
to?: string; to?: string;
label: string; label: string;
type?: type?: CommandType;
| CommandType.Navigate scope?: CommandScope;
| CommandType.Create
| CommandType.StandardAction
| CommandType.WorkflowRun;
Icon?: IconComponent; Icon?: IconComponent;
firstHotKey?: string; firstHotKey?: string;
secondHotKey?: string; secondHotKey?: string;

View File

@ -0,0 +1,53 @@
import {
ActionMenuEntry,
ActionMenuEntryScope,
ActionMenuEntryType,
} from '@/action-menu/types/ActionMenuEntry';
import { COMMAND_MENU_COMMANDS } from '@/command-menu/constants/CommandMenuCommands';
import {
Command,
CommandScope,
CommandType,
} from '@/command-menu/types/Command';
export const computeCommandMenuCommands = (
actionMenuEntries: ActionMenuEntry[],
): Command[] => {
const commands = Object.values(COMMAND_MENU_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 [...commands, ...actionCommands, ...workflowRunCommands];
};

View File

@ -11,10 +11,10 @@ export const MainContextStoreComponentInstanceIdSetterEffect = () => {
const context = useContext(ContextStoreComponentInstanceContext); const context = useContext(ContextStoreComponentInstanceContext);
useEffect(() => { useEffect(() => {
setMainContextStoreComponentInstanceId(context?.instanceId ?? null); setMainContextStoreComponentInstanceId(context?.instanceId ?? 'app');
return () => { return () => {
setMainContextStoreComponentInstanceId(null); setMainContextStoreComponentInstanceId('app');
}; };
}, [context, setMainContextStoreComponentInstanceId]); }, [context, setMainContextStoreComponentInstanceId]);

View File

@ -0,0 +1,18 @@
import { contextStoreCurrentObjectMetadataIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataIdComponentState';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
export const useContextStoreCurrentObjectMetadataIdOrThrow = (
instanceId?: string,
) => {
const contextStoreCurrentObjectMetadataIdComponent =
useRecoilComponentValueV2(
contextStoreCurrentObjectMetadataIdComponentState,
instanceId,
);
if (!contextStoreCurrentObjectMetadataIdComponent) {
throw new Error('contextStoreCurrentObjectMetadataIdComponent is not set');
}
return contextStoreCurrentObjectMetadataIdComponent;
};

View File

@ -0,0 +1,58 @@
import { useContextStoreCurrentObjectMetadataIdOrThrow } from '@/context-store/hooks/useContextStoreCurrentObjectMetadataIdOrThrow';
import { contextStoreFiltersComponentState } from '@/context-store/states/contextStoreFiltersComponentState';
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
import { computeContextStoreFilters } from '@/context-store/utils/computeContextStoreFilters';
import { useObjectMetadataItemById } from '@/object-metadata/hooks/useObjectMetadataItemById';
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
export const useContextStoreSelectedRecords = ({
instanceId,
limit = 3,
}: {
instanceId?: string;
limit?: number;
}) => {
const objectMetadataId =
useContextStoreCurrentObjectMetadataIdOrThrow(instanceId);
const { objectMetadataItem } = useObjectMetadataItemById({
objectId: objectMetadataId,
});
const contextStoreTargetedRecordsRule = useRecoilComponentValueV2(
contextStoreTargetedRecordsRuleComponentState,
instanceId,
);
const contextStoreFilters = useRecoilComponentValueV2(
contextStoreFiltersComponentState,
instanceId,
);
const queryFilter = computeContextStoreFilters(
contextStoreTargetedRecordsRule,
contextStoreFilters,
objectMetadataItem,
);
const { records, loading, totalCount } = useFindManyRecords({
objectNameSingular: objectMetadataItem.nameSingular,
filter: queryFilter,
orderBy: [
{
position: 'AscNullsFirst',
},
],
skip:
contextStoreTargetedRecordsRule.mode === 'selection' &&
contextStoreTargetedRecordsRule.selectedRecordIds.length === 0,
limit,
});
return {
records,
totalCount,
loading,
};
};

View File

@ -1,8 +1,6 @@
import { createState } from 'twenty-ui'; import { createState } from 'twenty-ui';
export const mainContextStoreComponentInstanceIdState = createState< export const mainContextStoreComponentInstanceIdState = createState<string>({
string | null
>({
key: 'mainContextStoreComponentInstanceIdState', key: 'mainContextStoreComponentInstanceIdState',
defaultValue: null, defaultValue: 'app',
}); });

View File

@ -1,5 +1,11 @@
import { GlobalActionMenuEntriesSetter } from '@/action-menu/actions/global-actions/components/GlobalActionMenuEntriesSetter';
import { RecordActionMenuEntriesSetter } from '@/action-menu/actions/record-actions/components/RecordActionMenuEntriesSetter';
import { ActionMenuConfirmationModals } from '@/action-menu/components/ActionMenuConfirmationModals';
import { ActionMenuComponentInstanceContext } from '@/action-menu/states/contexts/ActionMenuComponentInstanceContext';
import { AuthModal } from '@/auth/components/AuthModal'; import { AuthModal } from '@/auth/components/AuthModal';
import { CommandMenu } from '@/command-menu/components/CommandMenu'; import { CommandMenu } from '@/command-menu/components/CommandMenu';
import { CommandMenuCommandsEffect } from '@/command-menu/components/CommandMenuCommandsEffect';
import { ContextStoreComponentInstanceContext } from '@/context-store/states/contexts/ContextStoreComponentInstanceContext';
import { AppErrorBoundary } from '@/error-handler/components/AppErrorBoundary'; import { AppErrorBoundary } from '@/error-handler/components/AppErrorBoundary';
import { KeyboardShortcutMenu } from '@/keyboard-shortcut-menu/components/KeyboardShortcutMenu'; import { KeyboardShortcutMenu } from '@/keyboard-shortcut-menu/components/KeyboardShortcutMenu';
import { AppNavigationDrawer } from '@/navigation/components/AppNavigationDrawer'; import { AppNavigationDrawer } from '@/navigation/components/AppNavigationDrawer';
@ -80,7 +86,19 @@ export const DefaultLayout = () => {
`} `}
/> />
<StyledLayout> <StyledLayout>
<CommandMenu /> <ContextStoreComponentInstanceContext.Provider
value={{ instanceId: 'command-menu' }}
>
<ActionMenuComponentInstanceContext.Provider
value={{ instanceId: 'command-menu' }}
>
<RecordActionMenuEntriesSetter />
<GlobalActionMenuEntriesSetter />
<ActionMenuConfirmationModals />
<CommandMenuCommandsEffect />
<CommandMenu />
</ActionMenuComponentInstanceContext.Provider>
</ContextStoreComponentInstanceContext.Provider>
<KeyboardShortcutMenu /> <KeyboardShortcutMenu />
<StyledPageContainer <StyledPageContainer