From 8f5515cab3f698a6ae0e6475928d6057545a8267 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Bosi?= <71827178+bosiraphael@users.noreply.github.com> Date: Thu, 21 Nov 2024 17:56:53 +0100 Subject: [PATCH] 8414 add records selection context inside the command menu (#8610) Closes #8414 https://github.com/user-attachments/assets/a6aeb50a-b57d-43db-a839-4627c49b4155 --- .../components/WorkflowRunActionEffect.tsx | 8 +- .../components/DeleteRecordsActionEffect.tsx | 8 +- .../components/ExportRecordsActionEffect.tsx | 11 +- .../ManageFavoritesActionEffect.tsx | 8 +- .../RecordActionMenuEntriesSetter.tsx | 31 +- .../WorkflowRunRecordActionEffect.tsx | 8 +- .../RecordIndexActionMenuEffect.tsx | 9 +- .../RightDrawerActionMenuDropdown.tsx | 4 +- .../RecordIndexActionMenuBar.stories.tsx | 10 +- .../RecordIndexActionMenuBarEntry.stories.tsx | 16 +- .../RecordIndexActionMenuDropdown.stories.tsx | 18 +- .../RecordShowActionMenuBar.stories.tsx | 18 +- .../action-menu/types/ActionMenuEntry.ts | 14 +- .../command-menu/components/CommandMenu.tsx | 282 ++++++++++-------- .../components/CommandMenuCommandsEffect.tsx | 20 ++ .../CommandMenuContextRecordChip.tsx | 117 ++++++++ .../components/CommandMenuTopBar.tsx | 89 ++++++ .../__stories__/CommandMenu.stories.tsx | 28 +- .../constants/CommandMenuSearchBarHeight.ts | 1 + .../constants/CommandMenuSearchBarPadding.ts | 1 + .../command-menu/hooks/useCommandMenu.ts | 114 ++++--- .../src/modules/command-menu/types/Command.ts | 13 +- .../utils/computeCommandMenuCommands.ts | 53 ++++ ...xtStoreComponentInstanceIdSetterEffect.tsx | 4 +- ...textStoreCurrentObjectMetadataIdOrThrow.ts | 18 ++ .../hooks/useContextStoreSelectedRecords.ts | 58 ++++ .../mainContextStoreComponentInstanceId.ts | 6 +- .../layout/page/components/DefaultLayout.tsx | 20 +- 28 files changed, 762 insertions(+), 225 deletions(-) create mode 100644 packages/twenty-front/src/modules/command-menu/components/CommandMenuCommandsEffect.tsx create mode 100644 packages/twenty-front/src/modules/command-menu/components/CommandMenuContextRecordChip.tsx create mode 100644 packages/twenty-front/src/modules/command-menu/components/CommandMenuTopBar.tsx create mode 100644 packages/twenty-front/src/modules/command-menu/constants/CommandMenuSearchBarHeight.ts create mode 100644 packages/twenty-front/src/modules/command-menu/constants/CommandMenuSearchBarPadding.ts create mode 100644 packages/twenty-front/src/modules/command-menu/utils/computeCommandMenuCommands.ts create mode 100644 packages/twenty-front/src/modules/context-store/hooks/useContextStoreCurrentObjectMetadataIdOrThrow.ts create mode 100644 packages/twenty-front/src/modules/context-store/hooks/useContextStoreSelectedRecords.ts diff --git a/packages/twenty-front/src/modules/action-menu/actions/global-actions/workflow-run-actions/components/WorkflowRunActionEffect.tsx b/packages/twenty-front/src/modules/action-menu/actions/global-actions/workflow-run-actions/components/WorkflowRunActionEffect.tsx index 0929ffc81..424b339af 100644 --- a/packages/twenty-front/src/modules/action-menu/actions/global-actions/workflow-run-actions/components/WorkflowRunActionEffect.tsx +++ b/packages/twenty-front/src/modules/action-menu/actions/global-actions/workflow-run-actions/components/WorkflowRunActionEffect.tsx @@ -1,4 +1,8 @@ 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 { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; import { useAllActiveWorkflowVersions } from '@/workflow/hooks/useAllActiveWorkflowVersions'; @@ -28,9 +32,9 @@ export const WorkflowRunActionEffect = () => { activeWorkflowVersion, ] of activeWorkflowVersions.entries()) { addActionMenuEntry({ - type: 'workflow-run', + type: ActionMenuEntryType.WorkflowRun, key: `workflow-run-${activeWorkflowVersion.id}`, - scope: 'global', + scope: ActionMenuEntryScope.Global, label: capitalize(activeWorkflowVersion.workflow.name), position: index, Icon: IconSettingsAutomation, diff --git a/packages/twenty-front/src/modules/action-menu/actions/record-actions/components/DeleteRecordsActionEffect.tsx b/packages/twenty-front/src/modules/action-menu/actions/record-actions/components/DeleteRecordsActionEffect.tsx index 4f6d4f914..629d74f27 100644 --- a/packages/twenty-front/src/modules/action-menu/actions/record-actions/components/DeleteRecordsActionEffect.tsx +++ b/packages/twenty-front/src/modules/action-menu/actions/record-actions/components/DeleteRecordsActionEffect.tsx @@ -1,5 +1,9 @@ import { ActionMenuContext } from '@/action-menu/contexts/ActionMenuContext'; import { useActionMenuEntries } from '@/action-menu/hooks/useActionMenuEntries'; +import { + ActionMenuEntryScope, + ActionMenuEntryType, +} from '@/action-menu/types/ActionMenuEntry'; import { contextStoreFiltersComponentState } from '@/context-store/states/contextStoreFiltersComponentState'; import { contextStoreNumberOfSelectedRecordsComponentState } from '@/context-store/states/contextStoreNumberOfSelectedRecordsComponentState'; import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState'; @@ -105,8 +109,8 @@ export const DeleteRecordsActionEffect = ({ useEffect(() => { if (canDelete) { addActionMenuEntry({ - type: 'standard', - scope: 'record-selection', + type: ActionMenuEntryType.Standard, + scope: ActionMenuEntryScope.RecordSelection, key: 'delete', label: 'Delete', position, diff --git a/packages/twenty-front/src/modules/action-menu/actions/record-actions/components/ExportRecordsActionEffect.tsx b/packages/twenty-front/src/modules/action-menu/actions/record-actions/components/ExportRecordsActionEffect.tsx index 602aa9623..b4636a1d3 100644 --- a/packages/twenty-front/src/modules/action-menu/actions/record-actions/components/ExportRecordsActionEffect.tsx +++ b/packages/twenty-front/src/modules/action-menu/actions/record-actions/components/ExportRecordsActionEffect.tsx @@ -4,6 +4,10 @@ import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; import { IconDatabaseExport } from 'twenty-ui'; +import { + ActionMenuEntryScope, + ActionMenuEntryType, +} from '@/action-menu/types/ActionMenuEntry'; import { displayedExportProgress, useExportRecords, @@ -31,8 +35,11 @@ export const ExportRecordsActionEffect = ({ useEffect(() => { addActionMenuEntry({ - type: 'standard', - scope: 'record-selection', + type: ActionMenuEntryType.Standard, + scope: + contextStoreNumberOfSelectedRecords > 0 + ? ActionMenuEntryScope.RecordSelection + : ActionMenuEntryScope.Global, key: 'export', position, label: displayedExportProgress(progress), diff --git a/packages/twenty-front/src/modules/action-menu/actions/record-actions/components/ManageFavoritesActionEffect.tsx b/packages/twenty-front/src/modules/action-menu/actions/record-actions/components/ManageFavoritesActionEffect.tsx index ecb31f197..f1423b922 100644 --- a/packages/twenty-front/src/modules/action-menu/actions/record-actions/components/ManageFavoritesActionEffect.tsx +++ b/packages/twenty-front/src/modules/action-menu/actions/record-actions/components/ManageFavoritesActionEffect.tsx @@ -1,4 +1,8 @@ import { useActionMenuEntries } from '@/action-menu/hooks/useActionMenuEntries'; +import { + ActionMenuEntryScope, + ActionMenuEntryType, +} from '@/action-menu/types/ActionMenuEntry'; import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState'; import { useCreateFavorite } from '@/favorites/hooks/useCreateFavorite'; import { useDeleteFavorite } from '@/favorites/hooks/useDeleteFavorite'; @@ -50,8 +54,8 @@ export const ManageFavoritesActionEffect = ({ } addActionMenuEntry({ - type: 'standard', - scope: 'record-selection', + type: ActionMenuEntryType.Standard, + scope: ActionMenuEntryScope.RecordSelection, key: 'manage-favorites', label: isFavorite ? 'Remove from favorites' : 'Add to favorites', position, diff --git a/packages/twenty-front/src/modules/action-menu/actions/record-actions/components/RecordActionMenuEntriesSetter.tsx b/packages/twenty-front/src/modules/action-menu/actions/record-actions/components/RecordActionMenuEntriesSetter.tsx index 7d64ec72c..417fa1dd1 100644 --- a/packages/twenty-front/src/modules/action-menu/actions/record-actions/components/RecordActionMenuEntriesSetter.tsx +++ b/packages/twenty-front/src/modules/action-menu/actions/record-actions/components/RecordActionMenuEntriesSetter.tsx @@ -7,6 +7,7 @@ import { contextStoreNumberOfSelectedRecordsComponentState } from '@/context-sto import { useObjectMetadataItemById } from '@/object-metadata/hooks/useObjectMetadataItemById'; import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; +import { isDefined } from 'twenty-ui'; const noSelectionRecordActionEffects = [ExportRecordsActionEffect]; @@ -21,25 +22,33 @@ const multipleRecordActionEffects = [ ]; export const RecordActionMenuEntriesSetter = () => { - const contextStoreNumberOfSelectedRecords = useRecoilComponentValueV2( - contextStoreNumberOfSelectedRecordsComponentState, - ); - const contextStoreCurrentObjectMetadataId = useRecoilComponentValueV2( contextStoreCurrentObjectMetadataIdComponentState, ); + if (!isDefined(contextStoreCurrentObjectMetadataId)) { + return null; + } + + return ( + + ); +}; + +const ActionEffects = ({ + objectMetadataItemId, +}: { + objectMetadataItemId: string; +}) => { const { objectMetadataItem } = useObjectMetadataItemById({ - objectId: contextStoreCurrentObjectMetadataId ?? '', + objectId: objectMetadataItemId, }); - const isWorkflowEnabled = useIsFeatureEnabled('IS_WORKFLOW_ENABLED'); + const contextStoreNumberOfSelectedRecords = useRecoilComponentValueV2( + contextStoreNumberOfSelectedRecordsComponentState, + ); - if (!objectMetadataItem) { - throw new Error( - `Object metadata item not found for id ${contextStoreCurrentObjectMetadataId}`, - ); - } + const isWorkflowEnabled = useIsFeatureEnabled('IS_WORKFLOW_ENABLED'); const actions = contextStoreNumberOfSelectedRecords === 0 diff --git a/packages/twenty-front/src/modules/action-menu/actions/record-actions/workflow-run-record-actions/components/WorkflowRunRecordActionEffect.tsx b/packages/twenty-front/src/modules/action-menu/actions/record-actions/workflow-run-record-actions/components/WorkflowRunRecordActionEffect.tsx index 8f7b1a85f..9535571eb 100644 --- a/packages/twenty-front/src/modules/action-menu/actions/record-actions/workflow-run-record-actions/components/WorkflowRunRecordActionEffect.tsx +++ b/packages/twenty-front/src/modules/action-menu/actions/record-actions/workflow-run-record-actions/components/WorkflowRunRecordActionEffect.tsx @@ -1,4 +1,8 @@ import { useActionMenuEntries } from '@/action-menu/hooks/useActionMenuEntries'; +import { + ActionMenuEntryScope, + ActionMenuEntryType, +} from '@/action-menu/types/ActionMenuEntry'; import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState'; import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState'; @@ -55,9 +59,9 @@ export const WorkflowRunRecordActionEffect = ({ activeWorkflowVersion, ] of activeWorkflowVersions.entries()) { addActionMenuEntry({ - type: 'workflow-run', + type: ActionMenuEntryType.WorkflowRun, key: `workflow-run-${activeWorkflowVersion.id}`, - scope: 'record-selection', + scope: ActionMenuEntryScope.RecordSelection, label: capitalize(activeWorkflowVersion.workflow.name), position: index, Icon: IconSettingsAutomation, diff --git a/packages/twenty-front/src/modules/action-menu/components/RecordIndexActionMenuEffect.tsx b/packages/twenty-front/src/modules/action-menu/components/RecordIndexActionMenuEffect.tsx index 3306d9fb0..66d49da9a 100644 --- a/packages/twenty-front/src/modules/action-menu/components/RecordIndexActionMenuEffect.tsx +++ b/packages/twenty-front/src/modules/action-menu/components/RecordIndexActionMenuEffect.tsx @@ -43,7 +43,12 @@ export const RecordIndexActionMenuEffect = () => { const isCommandMenuOpened = useRecoilValue(isCommandMenuOpenedState); useEffect(() => { - if (contextStoreNumberOfSelectedRecords > 0 && !isDropdownOpen) { + if ( + contextStoreNumberOfSelectedRecords > 0 && + !isDropdownOpen && + !isRightDrawerOpen && + !isCommandMenuOpened + ) { // We only handle opening the ActionMenuBar here, not the Dropdown. // The Dropdown is already managed by sync handlers for events like // right-click to open and click outside to close. @@ -57,6 +62,8 @@ export const RecordIndexActionMenuEffect = () => { openActionBar, closeActionBar, isDropdownOpen, + isRightDrawerOpen, + isCommandMenuOpened, ]); useEffect(() => { diff --git a/packages/twenty-front/src/modules/action-menu/components/RightDrawerActionMenuDropdown.tsx b/packages/twenty-front/src/modules/action-menu/components/RightDrawerActionMenuDropdown.tsx index 2eeba8190..86b028a73 100644 --- a/packages/twenty-front/src/modules/action-menu/components/RightDrawerActionMenuDropdown.tsx +++ b/packages/twenty-front/src/modules/action-menu/components/RightDrawerActionMenuDropdown.tsx @@ -1,5 +1,6 @@ import { actionMenuEntriesComponentSelector } from '@/action-menu/states/actionMenuEntriesComponentSelector'; import { ActionMenuComponentInstanceContext } from '@/action-menu/states/contexts/ActionMenuComponentInstanceContext'; +import { ActionMenuEntryScope } from '@/action-menu/types/ActionMenuEntry'; import { RightDrawerActionMenuDropdownHotkeyScope } from '@/action-menu/types/RightDrawerActionMenuDropdownHotkeyScope'; import { getRightDrawerActionMenuDropdownIdFromActionMenuId } from '@/action-menu/utils/getRightDrawerActionMenuDropdownIdFromActionMenuId'; import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown'; @@ -67,7 +68,8 @@ export const RightDrawerActionMenuDropdown = () => { {actionMenuEntries .filter( - (actionMenuEntry) => actionMenuEntry.scope === 'record-selection', + (actionMenuEntry) => + actionMenuEntry.scope === ActionMenuEntryScope.RecordSelection, ) .map((actionMenuEntry, index) => ( = { map.set('delete', { isPinned: true, - scope: 'record-selection', - type: 'standard', + scope: ActionMenuEntryScope.RecordSelection, + type: ActionMenuEntryType.Standard, key: 'delete', label: 'Delete', position: 0, diff --git a/packages/twenty-front/src/modules/action-menu/components/__stories__/RecordIndexActionMenuBarEntry.stories.tsx b/packages/twenty-front/src/modules/action-menu/components/__stories__/RecordIndexActionMenuBarEntry.stories.tsx index 6eda55a0c..0990e6e8b 100644 --- a/packages/twenty-front/src/modules/action-menu/components/__stories__/RecordIndexActionMenuBarEntry.stories.tsx +++ b/packages/twenty-front/src/modules/action-menu/components/__stories__/RecordIndexActionMenuBarEntry.stories.tsx @@ -1,4 +1,8 @@ import { RecordIndexActionMenuBarEntry } from '@/action-menu/components/RecordIndexActionMenuBarEntry'; +import { + ActionMenuEntryScope, + ActionMenuEntryType, +} from '@/action-menu/types/ActionMenuEntry'; import { expect, jest } from '@storybook/jest'; import { Meta, StoryObj } from '@storybook/react'; import { userEvent, within } from '@storybook/testing-library'; @@ -21,8 +25,8 @@ const markAsDoneMock = jest.fn(); export const Default: Story = { args: { entry: { - type: 'standard', - scope: 'record-selection', + type: ActionMenuEntryType.Standard, + scope: ActionMenuEntryScope.RecordSelection, key: 'delete', label: 'Delete', position: 0, @@ -35,8 +39,8 @@ export const Default: Story = { export const WithDangerAccent: Story = { args: { entry: { - type: 'standard', - scope: 'record-selection', + type: ActionMenuEntryType.Standard, + scope: ActionMenuEntryScope.RecordSelection, key: 'delete', label: 'Delete', position: 0, @@ -50,8 +54,8 @@ export const WithDangerAccent: Story = { export const WithInteraction: Story = { args: { entry: { - type: 'standard', - scope: 'record-selection', + type: ActionMenuEntryType.Standard, + scope: ActionMenuEntryScope.RecordSelection, key: 'markAsDone', label: 'Mark as done', position: 0, diff --git a/packages/twenty-front/src/modules/action-menu/components/__stories__/RecordIndexActionMenuDropdown.stories.tsx b/packages/twenty-front/src/modules/action-menu/components/__stories__/RecordIndexActionMenuDropdown.stories.tsx index ba8a22cf0..a3ef043c6 100644 --- a/packages/twenty-front/src/modules/action-menu/components/__stories__/RecordIndexActionMenuDropdown.stories.tsx +++ b/packages/twenty-front/src/modules/action-menu/components/__stories__/RecordIndexActionMenuDropdown.stories.tsx @@ -7,7 +7,11 @@ import { RecordIndexActionMenuDropdown } from '@/action-menu/components/RecordIn import { actionMenuEntriesComponentState } from '@/action-menu/states/actionMenuEntriesComponentState'; import { ActionMenuComponentInstanceContext } from '@/action-menu/states/contexts/ActionMenuComponentInstanceContext'; 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 { extractComponentState } from '@/ui/utilities/state/component-state/utils/extractComponentState'; import { IconCheckbox, IconHeart, IconTrash } from 'twenty-ui'; @@ -41,8 +45,8 @@ const meta: Meta = { ); map.set('delete', { - type: 'standard', - scope: 'record-selection', + type: ActionMenuEntryType.Standard, + scope: ActionMenuEntryScope.RecordSelection, key: 'delete', label: 'Delete', position: 0, @@ -51,8 +55,8 @@ const meta: Meta = { }); map.set('markAsDone', { - type: 'standard', - scope: 'record-selection', + type: ActionMenuEntryType.Standard, + scope: ActionMenuEntryScope.RecordSelection, key: 'markAsDone', label: 'Mark as done', position: 1, @@ -61,8 +65,8 @@ const meta: Meta = { }); map.set('addToFavorites', { - type: 'standard', - scope: 'record-selection', + type: ActionMenuEntryType.Standard, + scope: ActionMenuEntryScope.RecordSelection, key: 'addToFavorites', label: 'Add to favorites', position: 2, diff --git a/packages/twenty-front/src/modules/action-menu/components/__stories__/RecordShowActionMenuBar.stories.tsx b/packages/twenty-front/src/modules/action-menu/components/__stories__/RecordShowActionMenuBar.stories.tsx index a400f4341..413ee1683 100644 --- a/packages/twenty-front/src/modules/action-menu/components/__stories__/RecordShowActionMenuBar.stories.tsx +++ b/packages/twenty-front/src/modules/action-menu/components/__stories__/RecordShowActionMenuBar.stories.tsx @@ -5,7 +5,11 @@ import { RecoilRoot } from 'recoil'; import { RightDrawerActionMenuDropdown } from '@/action-menu/components/RightDrawerActionMenuDropdown'; import { actionMenuEntriesComponentState } from '@/action-menu/states/actionMenuEntriesComponentState'; 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 { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState'; import { userEvent, waitFor, within } from '@storybook/test'; @@ -54,8 +58,8 @@ const meta: Meta = { ); map.set('addToFavorites', { - type: 'standard', - scope: 'record-selection', + type: ActionMenuEntryType.Standard, + scope: ActionMenuEntryScope.RecordSelection, key: 'addToFavorites', label: 'Add to favorites', position: 0, @@ -64,8 +68,8 @@ const meta: Meta = { }); map.set('export', { - type: 'standard', - scope: 'record-selection', + type: ActionMenuEntryType.Standard, + scope: ActionMenuEntryScope.RecordSelection, key: 'export', label: 'Export', position: 1, @@ -74,8 +78,8 @@ const meta: Meta = { }); map.set('delete', { - type: 'standard', - scope: 'record-selection', + type: ActionMenuEntryType.Standard, + scope: ActionMenuEntryScope.RecordSelection, key: 'delete', label: 'Delete', position: 2, diff --git a/packages/twenty-front/src/modules/action-menu/types/ActionMenuEntry.ts b/packages/twenty-front/src/modules/action-menu/types/ActionMenuEntry.ts index 993cff704..d5a6cc175 100644 --- a/packages/twenty-front/src/modules/action-menu/types/ActionMenuEntry.ts +++ b/packages/twenty-front/src/modules/action-menu/types/ActionMenuEntry.ts @@ -1,9 +1,19 @@ import { MouseEvent, ReactNode } from 'react'; import { IconComponent, MenuItemAccent } from 'twenty-ui'; +export enum ActionMenuEntryType { + Standard = 'Standard', + WorkflowRun = 'WorkflowRun', +} + +export enum ActionMenuEntryScope { + Global = 'Global', + RecordSelection = 'RecordSelection', +} + export type ActionMenuEntry = { - type: 'standard' | 'workflow-run'; - scope: 'global' | 'record-selection'; + type: ActionMenuEntryType; + scope: ActionMenuEntryScope; key: string; label: string; position: number; diff --git a/packages/twenty-front/src/modules/command-menu/components/CommandMenu.tsx b/packages/twenty-front/src/modules/command-menu/components/CommandMenu.tsx index 749ea0e69..625611110 100644 --- a/packages/twenty-front/src/modules/command-menu/components/CommandMenu.tsx +++ b/packages/twenty-front/src/modules/command-menu/components/CommandMenu.tsx @@ -5,13 +5,21 @@ import { Note } from '@/activities/types/Note'; import { Task } from '@/activities/types/Task'; import { CommandGroup } from '@/command-menu/components/CommandGroup'; 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 { commandMenuCommandsState } from '@/command-menu/states/commandMenuCommandsState'; import { commandMenuSearchState } from '@/command-menu/states/commandMenuSearchState'; 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 { 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 { CoreObjectNamePlural } from '@/object-metadata/types/CoreObjectNamePlural'; 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 { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile'; 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 styled from '@emotion/styled'; import { isNonEmptyString } from '@sniptt/guards'; @@ -40,16 +49,12 @@ import { IconComponent, IconNotes, IconSparkles, - IconX, - LightIconButton, isDefined, } from 'twenty-ui'; import { useDebounce } from 'use-debounce'; import { getLogoUrlFromDomainName } from '~/utils'; import { capitalize } from '~/utils/string/capitalize'; -const SEARCH_BAR_HEIGHT = 56; -const SEARCH_BAR_PADDING = 3; const MOBILE_NAVIGATION_BAR_HEIGHT = 64; type CommandGroupConfig = { @@ -80,48 +85,6 @@ const StyledCommandMenu = styled.div` 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` background: ${({ theme }) => theme.background.secondary}; overscroll-behavior: contain; @@ -132,10 +95,12 @@ const StyledList = styled.div` const StyledInnerList = styled.div<{ isMobile: boolean }>` max-height: ${({ isMobile }) => isMobile - ? `calc(100dvh - ${SEARCH_BAR_HEIGHT}px - ${ - SEARCH_BAR_PADDING * 2 + ? `calc(100dvh - ${COMMAND_MENU_SEARCH_BAR_HEIGHT}px - ${ + COMMAND_MENU_SEARCH_BAR_PADDING * 2 }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-right: ${({ theme }) => theme.spacing(2)}; padding-top: ${({ theme }) => theme.spacing(1)}; @@ -165,9 +130,14 @@ export const CommandMenu = () => { const [deferredCommandMenuSearch] = useDebounce(commandMenuSearch, 300); // 200ms - 500ms const commandMenuCommands = useRecoilValue(commandMenuCommandsState); const { closeKeyboardShortcutMenu } = useKeyboardShortcutMenu(); - const handleSearchChange = (event: React.ChangeEvent) => { - setCommandMenuSearch(event.target.value); - }; + + const setContextStoreTargetedRecordsRule = useSetRecoilComponentStateV2( + contextStoreTargetedRecordsRuleComponentState, + ); + + const setContextStoreNumberOfSelectedRecords = useSetRecoilComponentStateV2( + contextStoreNumberOfSelectedRecordsComponentState, + ); const isMobile = useIsMobile(); @@ -190,6 +160,25 @@ export const CommandMenu = () => { [closeCommandMenu], ); + useScopedHotkeys( + [Key.Backspace, Key.Delete], + () => { + if (!isNonEmptyString(commandMenuSearch)) { + setContextStoreTargetedRecordsRule({ + mode: 'selection', + selectedRecordIds: [], + }); + + setContextStoreNumberOfSelectedRecords(0); + } + }, + AppHotkeyScope.CommandMenuOpen, + [closeCommandMenu], + { + preventDefault: false, + }, + ); + const { matchesSearchFilterObjectRecordsQueryResult, matchesSearchFilterObjectRecordsLoading: loading, @@ -378,20 +367,45 @@ export const CommandMenu = () => { : 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) => (deferredCommandMenuSearch.length > 0 ? checkInShortcuts(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) => (deferredCommandMenuSearch.length > 0 ? checkInShortcuts(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({ @@ -419,8 +433,10 @@ export const CommandMenu = () => { const selectableItemIds = copilotCommands .map((cmd) => cmd.id) - .concat(matchingStandardActionCommands.map((cmd) => cmd.id)) - .concat(matchingWorkflowRunCommands.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)) @@ -437,8 +453,10 @@ export const CommandMenu = () => { ); const isNoResults = - !matchingStandardActionCommands.length && - !matchingWorkflowRunCommands.length && + !matchingStandardActionRecordSelectionCommands.length && + !matchingWorkflowRunRecordSelectionCommands.length && + !matchingStandardActionGlobalCommands.length && + !matchingWorkflowRunGlobalCommands.length && !matchingCreateCommand.length && !matchingNavigateCommand.length && !people?.length && @@ -450,10 +468,6 @@ export const CommandMenu = () => { const isLoading = loading || isNotesLoading || isTasksLoading; - const mainContextStoreComponentInstanceId = useRecoilValue( - mainContextStoreComponentInstanceIdState, - ); - const commandGroups: CommandGroupConfig[] = [ { heading: 'Navigate', @@ -575,24 +589,10 @@ export const CommandMenu = () => { <> {isCommandMenuOpened && ( - - - {!isMobile && ( - - - - )} - + @@ -632,45 +632,83 @@ export const CommandMenu = () => { )} - {mainContextStoreComponentInstanceId && ( - <> - - {matchingStandardActionCommands?.map( - (standardActionCommand) => ( - - - - ), - )} - - - - {matchingWorkflowRunCommands?.map( - (workflowRunCommand) => ( - - - - ), - )} - - + + {matchingStandardActionRecordSelectionCommands?.map( + (standardActionrecordSelectionCommand) => ( + + + + ), + )} + {matchingWorkflowRunRecordSelectionCommands?.map( + (workflowRunRecordSelectionCommand) => ( + + + + ), + )} + + {matchingStandardActionGlobalCommands?.length > 0 && ( + + {matchingStandardActionGlobalCommands?.map( + (standardActionGlobalCommand) => ( + + + + ), + )} + )} + {matchingWorkflowRunGlobalCommands?.length > 0 && ( + + {matchingWorkflowRunGlobalCommands?.map( + (workflowRunGlobalCommand) => ( + + + + ), + )} + + )} + {commandGroups.map(({ heading, items, renderItem }) => items?.length ? ( diff --git a/packages/twenty-front/src/modules/command-menu/components/CommandMenuCommandsEffect.tsx b/packages/twenty-front/src/modules/command-menu/components/CommandMenuCommandsEffect.tsx new file mode 100644 index 000000000..aa3e89b1a --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu/components/CommandMenuCommandsEffect.tsx @@ -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; +}; diff --git a/packages/twenty-front/src/modules/command-menu/components/CommandMenuContextRecordChip.tsx b/packages/twenty-front/src/modules/command-menu/components/CommandMenuContextRecordChip.tsx new file mode 100644 index 000000000..e87c4311c --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu/components/CommandMenuContextRecordChip.tsx @@ -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 ( + + {Icon ? ( + + ) : ( + + )} + + ); +}; + +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 ( + + + {records.map((record) => ( + + ))} + + {totalCount === 1 + ? getObjectRecordIdentifier({ objectMetadataItem, record: records[0] }) + .name + : `${totalCount} ${capitalize(objectMetadataItem.namePlural)}`} + + ); +}; diff --git a/packages/twenty-front/src/modules/command-menu/components/CommandMenuTopBar.tsx b/packages/twenty-front/src/modules/command-menu/components/CommandMenuTopBar.tsx new file mode 100644 index 000000000..2385683fd --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu/components/CommandMenuTopBar.tsx @@ -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) => { + setCommandMenuSearch(event.target.value); + }; + + const isMobile = useIsMobile(); + + const { closeCommandMenu } = useCommandMenu(); + + return ( + + + + {!isMobile && ( + + + + )} + + ); +}; diff --git a/packages/twenty-front/src/modules/command-menu/components/__stories__/CommandMenu.stories.tsx b/packages/twenty-front/src/modules/command-menu/components/__stories__/CommandMenu.stories.tsx index a7e1dc95e..26cf5d75f 100644 --- a/packages/twenty-front/src/modules/command-menu/components/__stories__/CommandMenu.stories.tsx +++ b/packages/twenty-front/src/modules/command-menu/components/__stories__/CommandMenu.stories.tsx @@ -1,5 +1,5 @@ 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 { useEffect } from 'react'; import { useRecoilValue, useSetRecoilState } from 'recoil'; @@ -20,13 +20,32 @@ import { } from '~/testing/mock-data/users'; 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 { JestContextStoreSetter } from '~/testing/jest/JestContextStoreSetter'; import { CommandMenu } from '../CommandMenu'; const companiesMock = getCompaniesMock(); const openTimeout = 50; +const ContextStoreDecorator: Decorator = (Story) => { + return ( + + + + + + + + ); +}; + const meta: Meta = { title: 'Modules/CommandMenu/CommandMenu', component: CommandMenu, @@ -79,6 +98,7 @@ const meta: Meta = { return ; }, + ContextStoreDecorator, ObjectMetadataItemsDecorator, SnackBarDecorator, ComponentWithRouterDecorator, @@ -109,7 +129,7 @@ export const DefaultWithoutSearch: Story = { export const MatchingPersonCompanyActivityCreateNavigate: Story = { play: async () => { const canvas = within(document.body); - const searchInput = await canvas.findByPlaceholderText('Search'); + const searchInput = await canvas.findByPlaceholderText('Type anything'); await sleep(openTimeout); await userEvent.type(searchInput, 'n'); expect(await canvas.findByText('Linkedin')).toBeInTheDocument(); @@ -122,7 +142,7 @@ export const MatchingPersonCompanyActivityCreateNavigate: Story = { export const OnlyMatchingCreateAndNavigate: Story = { play: async () => { const canvas = within(document.body); - const searchInput = await canvas.findByPlaceholderText('Search'); + const searchInput = await canvas.findByPlaceholderText('Type anything'); await sleep(openTimeout); await userEvent.type(searchInput, 'ta'); expect(await canvas.findByText('Create Task')).toBeInTheDocument(); @@ -133,7 +153,7 @@ export const OnlyMatchingCreateAndNavigate: Story = { export const AtleastMatchingOnePerson: Story = { play: async () => { const canvas = within(document.body); - const searchInput = await canvas.findByPlaceholderText('Search'); + const searchInput = await canvas.findByPlaceholderText('Type anything'); await sleep(openTimeout); await userEvent.type(searchInput, 'alex'); expect(await canvas.findByText('Sylvie Palmer')).toBeInTheDocument(); diff --git a/packages/twenty-front/src/modules/command-menu/constants/CommandMenuSearchBarHeight.ts b/packages/twenty-front/src/modules/command-menu/constants/CommandMenuSearchBarHeight.ts new file mode 100644 index 000000000..f76e7d4d8 --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu/constants/CommandMenuSearchBarHeight.ts @@ -0,0 +1 @@ +export const COMMAND_MENU_SEARCH_BAR_HEIGHT = 56; diff --git a/packages/twenty-front/src/modules/command-menu/constants/CommandMenuSearchBarPadding.ts b/packages/twenty-front/src/modules/command-menu/constants/CommandMenuSearchBarPadding.ts new file mode 100644 index 000000000..747f0e673 --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu/constants/CommandMenuSearchBarPadding.ts @@ -0,0 +1 @@ +export const COMMAND_MENU_SEARCH_BAR_PADDING = 3; diff --git a/packages/twenty-front/src/modules/command-menu/hooks/useCommandMenu.ts b/packages/twenty-front/src/modules/command-menu/hooks/useCommandMenu.ts index 715333599..45618ba2e 100644 --- a/packages/twenty-front/src/modules/command-menu/hooks/useCommandMenu.ts +++ b/packages/twenty-front/src/modules/command-menu/hooks/useCommandMenu.ts @@ -9,8 +9,12 @@ import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousH import { AppHotkeyScope } from '@/ui/utilities/hotkey/types/AppHotkeyScope'; import { isDefined } from '~/utils/isDefined'; -import { actionMenuEntriesComponentSelector } from '@/action-menu/states/actionMenuEntriesComponentSelector'; 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 { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; import { ALL_ICONS } from '@ui/display/icon/providers/internal/AllIcons'; @@ -34,42 +38,83 @@ export const useCommandMenu = () => { ); const openCommandMenu = useRecoilCallback( - ({ snapshot }) => + ({ snapshot, set }) => () => { if (isDefined(mainContextStoreComponentInstanceId)) { - const actionMenuEntries = snapshot.getLoadable( - actionMenuEntriesComponentSelector.selectorFamily({ - instanceId: mainContextStoreComponentInstanceId, + const contextStoreCurrentObjectMetadataId = snapshot + .getLoadable( + contextStoreCurrentObjectMetadataIdComponentState.atomFamily({ + instanceId: mainContextStoreComponentInstanceId, + }), + ) + .getValue(); + + set( + contextStoreCurrentObjectMetadataIdComponentState.atomFamily({ + instanceId: 'command-menu', }), + contextStoreCurrentObjectMetadataId, ); - const commands = Object.values(COMMAND_MENU_COMMANDS); - - const actionCommands = actionMenuEntries - .getValue() - ?.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', + const contextStoreTargetedRecordsRule = snapshot + .getLoadable( + contextStoreTargetedRecordsRuleComponentState.atomFamily({ + instanceId: mainContextStoreComponentInstanceId, + }), ) - ?.map((actionMenuEntry) => ({ - id: actionMenuEntry.key, - label: actionMenuEntry.label, - Icon: actionMenuEntry.Icon, - onCommandClick: actionMenuEntry.onClick, - type: CommandType.WorkflowRun, - })); + .getValue(); - 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); @@ -77,7 +122,6 @@ export const useCommandMenu = () => { }, [ mainContextStoreComponentInstanceId, - setCommands, setHotkeyScopeAndMemorizePreviousScope, setIsCommandMenuOpened, ], @@ -92,17 +136,11 @@ export const useCommandMenu = () => { if (isCommandMenuOpened) { setIsCommandMenuOpened(false); - setCommands([]); resetSelectedItem(); goBackToPreviousHotkeyScope(); } }, - [ - goBackToPreviousHotkeyScope, - resetSelectedItem, - setCommands, - setIsCommandMenuOpened, - ], + [goBackToPreviousHotkeyScope, resetSelectedItem, setIsCommandMenuOpened], ); const toggleCommandMenu = useRecoilCallback( diff --git a/packages/twenty-front/src/modules/command-menu/types/Command.ts b/packages/twenty-front/src/modules/command-menu/types/Command.ts index 739466903..2f14cc6a1 100644 --- a/packages/twenty-front/src/modules/command-menu/types/Command.ts +++ b/packages/twenty-front/src/modules/command-menu/types/Command.ts @@ -1,5 +1,4 @@ import { IconComponent } from 'twenty-ui'; - export enum CommandType { Navigate = 'Navigate', Create = 'Create', @@ -7,15 +6,17 @@ export enum CommandType { WorkflowRun = 'WorkflowRun', } +export enum CommandScope { + Global = 'Global', + RecordSelection = 'RecordSelection', +} + export type Command = { id: string; to?: string; label: string; - type?: - | CommandType.Navigate - | CommandType.Create - | CommandType.StandardAction - | CommandType.WorkflowRun; + type?: CommandType; + scope?: CommandScope; Icon?: IconComponent; firstHotKey?: string; secondHotKey?: string; diff --git a/packages/twenty-front/src/modules/command-menu/utils/computeCommandMenuCommands.ts b/packages/twenty-front/src/modules/command-menu/utils/computeCommandMenuCommands.ts new file mode 100644 index 000000000..933ad9c39 --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu/utils/computeCommandMenuCommands.ts @@ -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]; +}; diff --git a/packages/twenty-front/src/modules/context-store/components/MainContextStoreComponentInstanceIdSetterEffect.tsx b/packages/twenty-front/src/modules/context-store/components/MainContextStoreComponentInstanceIdSetterEffect.tsx index cc838bb7e..633203892 100644 --- a/packages/twenty-front/src/modules/context-store/components/MainContextStoreComponentInstanceIdSetterEffect.tsx +++ b/packages/twenty-front/src/modules/context-store/components/MainContextStoreComponentInstanceIdSetterEffect.tsx @@ -11,10 +11,10 @@ export const MainContextStoreComponentInstanceIdSetterEffect = () => { const context = useContext(ContextStoreComponentInstanceContext); useEffect(() => { - setMainContextStoreComponentInstanceId(context?.instanceId ?? null); + setMainContextStoreComponentInstanceId(context?.instanceId ?? 'app'); return () => { - setMainContextStoreComponentInstanceId(null); + setMainContextStoreComponentInstanceId('app'); }; }, [context, setMainContextStoreComponentInstanceId]); diff --git a/packages/twenty-front/src/modules/context-store/hooks/useContextStoreCurrentObjectMetadataIdOrThrow.ts b/packages/twenty-front/src/modules/context-store/hooks/useContextStoreCurrentObjectMetadataIdOrThrow.ts new file mode 100644 index 000000000..cf147833a --- /dev/null +++ b/packages/twenty-front/src/modules/context-store/hooks/useContextStoreCurrentObjectMetadataIdOrThrow.ts @@ -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; +}; diff --git a/packages/twenty-front/src/modules/context-store/hooks/useContextStoreSelectedRecords.ts b/packages/twenty-front/src/modules/context-store/hooks/useContextStoreSelectedRecords.ts new file mode 100644 index 000000000..362e44276 --- /dev/null +++ b/packages/twenty-front/src/modules/context-store/hooks/useContextStoreSelectedRecords.ts @@ -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, + }; +}; diff --git a/packages/twenty-front/src/modules/context-store/states/mainContextStoreComponentInstanceId.ts b/packages/twenty-front/src/modules/context-store/states/mainContextStoreComponentInstanceId.ts index 2e7343672..242b85e64 100644 --- a/packages/twenty-front/src/modules/context-store/states/mainContextStoreComponentInstanceId.ts +++ b/packages/twenty-front/src/modules/context-store/states/mainContextStoreComponentInstanceId.ts @@ -1,8 +1,6 @@ import { createState } from 'twenty-ui'; -export const mainContextStoreComponentInstanceIdState = createState< - string | null ->({ +export const mainContextStoreComponentInstanceIdState = createState({ key: 'mainContextStoreComponentInstanceIdState', - defaultValue: null, + defaultValue: 'app', }); diff --git a/packages/twenty-front/src/modules/ui/layout/page/components/DefaultLayout.tsx b/packages/twenty-front/src/modules/ui/layout/page/components/DefaultLayout.tsx index 1c7d76480..c0f4ff700 100644 --- a/packages/twenty-front/src/modules/ui/layout/page/components/DefaultLayout.tsx +++ b/packages/twenty-front/src/modules/ui/layout/page/components/DefaultLayout.tsx @@ -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 { 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 { KeyboardShortcutMenu } from '@/keyboard-shortcut-menu/components/KeyboardShortcutMenu'; import { AppNavigationDrawer } from '@/navigation/components/AppNavigationDrawer'; @@ -80,7 +86,19 @@ export const DefaultLayout = () => { `} /> - + + + + + + + + +