diff --git a/packages/twenty-front/src/modules/action-menu/components/ActionMenuBar.tsx b/packages/twenty-front/src/modules/action-menu/components/ActionMenuBar.tsx new file mode 100644 index 000000000000..d34b6c83fc54 --- /dev/null +++ b/packages/twenty-front/src/modules/action-menu/components/ActionMenuBar.tsx @@ -0,0 +1,49 @@ +import styled from '@emotion/styled'; + +import { ActionMenuBarEntry } from '@/action-menu/components/ActionMenuBarEntry'; +import { actionMenuEntriesComponentState } from '@/action-menu/states/actionMenuEntriesComponentState'; +import { ActionMenuComponentInstanceContext } from '@/action-menu/states/contexts/ActionMenuComponentInstanceContext'; +import { ActionBarHotkeyScope } from '@/action-menu/types/ActionBarHotKeyScope'; +import { contextStoreTargetedRecordIdsState } from '@/context-store/states/contextStoreTargetedRecordIdsState'; +import { BottomBar } from '@/ui/layout/bottom-bar/components/BottomBar'; +import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow'; +import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; +import { useRecoilValue } from 'recoil'; + +const StyledLabel = styled.div` + color: ${({ theme }) => theme.font.color.tertiary}; + font-size: ${({ theme }) => theme.font.size.md}; + font-weight: ${({ theme }) => theme.font.weight.medium}; + padding-left: ${({ theme }) => theme.spacing(2)}; + padding-right: ${({ theme }) => theme.spacing(2)}; +`; + +export const ActionMenuBar = () => { + const contextStoreTargetedRecordIds = useRecoilValue( + contextStoreTargetedRecordIdsState, + ); + + const actionMenuId = useAvailableComponentInstanceIdOrThrow( + ActionMenuComponentInstanceContext, + ); + + const actionMenuEntries = useRecoilComponentValueV2( + actionMenuEntriesComponentState, + ); + + return ( + + + {contextStoreTargetedRecordIds.length} selected: + + {actionMenuEntries.map((entry, index) => ( + + ))} + + ); +}; diff --git a/packages/twenty-front/src/modules/action-menu/components/ActionMenuBarEntry.tsx b/packages/twenty-front/src/modules/action-menu/components/ActionMenuBarEntry.tsx new file mode 100644 index 000000000000..02802ec4a616 --- /dev/null +++ b/packages/twenty-front/src/modules/action-menu/components/ActionMenuBarEntry.tsx @@ -0,0 +1,49 @@ +import { useTheme } from '@emotion/react'; +import styled from '@emotion/styled'; + +import { ActionMenuEntry } from '@/action-menu/types/ActionMenuEntry'; +import { MenuItemAccent } from '@/ui/navigation/menu-item/types/MenuItemAccent'; + +type ActionMenuBarEntryProps = { + entry: ActionMenuEntry; +}; + +const StyledButton = styled.div<{ accent: MenuItemAccent }>` + border-radius: ${({ theme }) => theme.border.radius.sm}; + color: ${(props) => + props.accent === 'danger' + ? props.theme.color.red + : props.theme.font.color.secondary}; + cursor: pointer; + display: flex; + justify-content: center; + + padding: ${({ theme }) => theme.spacing(2)}; + transition: background 0.1s ease; + user-select: none; + + &:hover { + background: ${({ theme, accent }) => + accent === 'danger' + ? theme.background.danger + : theme.background.tertiary}; + } +`; + +const StyledButtonLabel = styled.div` + font-weight: ${({ theme }) => theme.font.weight.medium}; + margin-left: ${({ theme }) => theme.spacing(1)}; +`; + +export const ActionMenuBarEntry = ({ entry }: ActionMenuBarEntryProps) => { + const theme = useTheme(); + return ( + entry.onClick?.()} + > + {entry.Icon && } + {entry.label} + + ); +}; diff --git a/packages/twenty-front/src/modules/action-menu/components/ActionMenuConfirmationModals.tsx b/packages/twenty-front/src/modules/action-menu/components/ActionMenuConfirmationModals.tsx new file mode 100644 index 000000000000..fc4af90b199b --- /dev/null +++ b/packages/twenty-front/src/modules/action-menu/components/ActionMenuConfirmationModals.tsx @@ -0,0 +1,18 @@ +import { actionMenuEntriesComponentState } from '@/action-menu/states/actionMenuEntriesComponentState'; +import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; + +export const ActionMenuConfirmationModals = () => { + const actionMenuEntries = useRecoilComponentValueV2( + actionMenuEntriesComponentState, + ); + + return ( +
+ {actionMenuEntries.map((actionMenuEntry, index) => + actionMenuEntry.ConfirmationModal ? ( +
{actionMenuEntry.ConfirmationModal}
+ ) : null, + )} +
+ ); +}; diff --git a/packages/twenty-front/src/modules/action-menu/components/ActionMenuDropdown.tsx b/packages/twenty-front/src/modules/action-menu/components/ActionMenuDropdown.tsx new file mode 100644 index 000000000000..b443c4307c66 --- /dev/null +++ b/packages/twenty-front/src/modules/action-menu/components/ActionMenuDropdown.tsx @@ -0,0 +1,84 @@ +import styled from '@emotion/styled'; +import { useRecoilValue } from 'recoil'; + +import { PositionType } from '../types/PositionType'; + +import { actionMenuDropdownPositionComponentState } from '@/action-menu/states/actionMenuDropdownPositionComponentState'; +import { actionMenuEntriesComponentState } from '@/action-menu/states/actionMenuEntriesComponentState'; +import { ActionMenuComponentInstanceContext } from '@/action-menu/states/contexts/ActionMenuComponentInstanceContext'; +import { ActionMenuDropdownHotkeyScope } from '@/action-menu/types/ActionMenuDropdownHotKeyScope'; +import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown'; +import { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem'; +import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow'; +import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; +import { extractComponentState } from '@/ui/utilities/state/component-state/utils/extractComponentState'; + +type StyledContainerProps = { + position: PositionType; +}; + +const StyledContainerActionMenuDropdown = styled.div` + align-items: flex-start; + background: ${({ theme }) => theme.background.secondary}; + border: 1px solid ${({ theme }) => theme.border.color.light}; + border-radius: ${({ theme }) => theme.border.radius.md}; + box-shadow: ${({ theme }) => theme.boxShadow.strong}; + display: flex; + flex-direction: column; + + left: ${(props) => `${props.position.x}px`}; + position: fixed; + top: ${(props) => `${props.position.y}px`}; + + transform: translateX(-50%); + width: auto; +`; + +export const ActionMenuDropdown = () => { + const actionMenuEntries = useRecoilComponentValueV2( + actionMenuEntriesComponentState, + ); + + const actionMenuId = useAvailableComponentInstanceIdOrThrow( + ActionMenuComponentInstanceContext, + ); + + const actionMenuDropdownPosition = useRecoilValue( + extractComponentState( + actionMenuDropdownPositionComponentState, + `action-menu-dropdown-${actionMenuId}`, + ), + ); + + //TODO: remove this + const width = actionMenuEntries.some( + (actionMenuEntry) => actionMenuEntry.label === 'Remove from favorites', + ) + ? 200 + : undefined; + + return ( + + ( + + ))} + /> + + ); +}; diff --git a/packages/twenty-front/src/modules/action-menu/components/ActionMenuEffect.tsx b/packages/twenty-front/src/modules/action-menu/components/ActionMenuEffect.tsx new file mode 100644 index 000000000000..60355cc9256f --- /dev/null +++ b/packages/twenty-front/src/modules/action-menu/components/ActionMenuEffect.tsx @@ -0,0 +1,46 @@ +import { useActionMenu } from '@/action-menu/hooks/useActionMenu'; +import { ActionMenuComponentInstanceContext } from '@/action-menu/states/contexts/ActionMenuComponentInstanceContext'; +import { contextStoreTargetedRecordIdsState } from '@/context-store/states/contextStoreTargetedRecordIdsState'; +import { isDropdownOpenComponentState } from '@/ui/layout/dropdown/states/isDropdownOpenComponentState'; +import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow'; +import { extractComponentState } from '@/ui/utilities/state/component-state/utils/extractComponentState'; +import { useEffect } from 'react'; +import { useRecoilValue } from 'recoil'; + +export const ActionMenuEffect = () => { + const contextStoreTargetedRecordIds = useRecoilValue( + contextStoreTargetedRecordIdsState, + ); + + const actionMenuId = useAvailableComponentInstanceIdOrThrow( + ActionMenuComponentInstanceContext, + ); + + const { openActionBar, closeActionBar } = useActionMenu(actionMenuId); + + const isDropdownOpen = useRecoilValue( + extractComponentState( + isDropdownOpenComponentState, + `action-menu-dropdown-${actionMenuId}`, + ), + ); + + useEffect(() => { + if (contextStoreTargetedRecordIds.length > 0 && !isDropdownOpen) { + // 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. + openActionBar(); + } + if (contextStoreTargetedRecordIds.length === 0) { + closeActionBar(); + } + }, [ + contextStoreTargetedRecordIds, + openActionBar, + closeActionBar, + isDropdownOpen, + ]); + + return null; +}; diff --git a/packages/twenty-front/src/modules/action-menu/components/ActionMenuEntriesProvider.tsx b/packages/twenty-front/src/modules/action-menu/components/ActionMenuEntriesProvider.tsx new file mode 100644 index 000000000000..3a340feb85ef --- /dev/null +++ b/packages/twenty-front/src/modules/action-menu/components/ActionMenuEntriesProvider.tsx @@ -0,0 +1,25 @@ +import { EmptyActionMenuEntriesEffect } from '@/action-menu/components/EmptyActionMenuEntriesEffect'; +import { NonEmptyActionMenuEntriesEffect } from '@/action-menu/components/NonEmptyActionMenuEntriesEffect'; +import { contextStoreCurrentObjectMetadataIdState } from '@/context-store/states/contextStoreCurrentObjectMetadataIdState'; +import { useRecoilValue } from 'recoil'; + +export const ActionMenuEntriesProvider = () => { + //TODO: Refactor this + const contextStoreCurrentObjectMetadataId = useRecoilValue( + contextStoreCurrentObjectMetadataIdState, + ); + + return ( + <> + {contextStoreCurrentObjectMetadataId ? ( + + ) : ( + + )} + + ); +}; diff --git a/packages/twenty-front/src/modules/action-menu/components/EmptyActionMenuEntriesEffect.tsx b/packages/twenty-front/src/modules/action-menu/components/EmptyActionMenuEntriesEffect.tsx new file mode 100644 index 000000000000..aca1e5fdae22 --- /dev/null +++ b/packages/twenty-front/src/modules/action-menu/components/EmptyActionMenuEntriesEffect.tsx @@ -0,0 +1,14 @@ +import { actionMenuEntriesComponentState } from '@/action-menu/states/actionMenuEntriesComponentState'; +import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2'; +import { useEffect } from 'react'; + +export const EmptyActionMenuEntriesEffect = () => { + const setActionMenuEntries = useSetRecoilComponentStateV2( + actionMenuEntriesComponentState, + ); + useEffect(() => { + setActionMenuEntries([]); + }, [setActionMenuEntries]); + + return null; +}; diff --git a/packages/twenty-front/src/modules/action-menu/components/NonEmptyActionMenuEntriesEffect.tsx b/packages/twenty-front/src/modules/action-menu/components/NonEmptyActionMenuEntriesEffect.tsx new file mode 100644 index 000000000000..210db62500cd --- /dev/null +++ b/packages/twenty-front/src/modules/action-menu/components/NonEmptyActionMenuEntriesEffect.tsx @@ -0,0 +1,28 @@ +import { useComputeActionsBasedOnContextStore } from '@/action-menu/hooks/useComputeActionsBasedOnContextStore'; +import { actionMenuEntriesComponentState } from '@/action-menu/states/actionMenuEntriesComponentState'; +import { useObjectMetadataItemById } from '@/object-metadata/hooks/useObjectMetadataItemById'; +import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2'; +import { useEffect } from 'react'; + +export const NonEmptyActionMenuEntriesEffect = ({ + contextStoreCurrentObjectMetadataId, +}: { + contextStoreCurrentObjectMetadataId: string; +}) => { + const { objectMetadataItem } = useObjectMetadataItemById({ + objectId: contextStoreCurrentObjectMetadataId, + }); + const { availableActionsInContext } = useComputeActionsBasedOnContextStore({ + objectMetadataItem, + }); + + const setActionMenuEntries = useSetRecoilComponentStateV2( + actionMenuEntriesComponentState, + ); + + useEffect(() => { + setActionMenuEntries(availableActionsInContext); + }, [availableActionsInContext, setActionMenuEntries]); + + return null; +}; diff --git a/packages/twenty-front/src/modules/action-menu/components/__stories__/ActionMenuBar.stories.tsx b/packages/twenty-front/src/modules/action-menu/components/__stories__/ActionMenuBar.stories.tsx new file mode 100644 index 000000000000..907d6caf3f4f --- /dev/null +++ b/packages/twenty-front/src/modules/action-menu/components/__stories__/ActionMenuBar.stories.tsx @@ -0,0 +1,101 @@ +import { expect, jest } from '@storybook/jest'; +import { Meta, StoryObj } from '@storybook/react'; +import { RecoilRoot } from 'recoil'; + +import { ActionMenuBar } from '@/action-menu/components/ActionMenuBar'; +import { actionMenuEntriesComponentState } from '@/action-menu/states/actionMenuEntriesComponentState'; +import { ActionMenuComponentInstanceContext } from '@/action-menu/states/contexts/ActionMenuComponentInstanceContext'; +import { contextStoreTargetedRecordIdsState } from '@/context-store/states/contextStoreTargetedRecordIdsState'; +import { isBottomBarOpenedComponentState } from '@/ui/layout/bottom-bar/states/isBottomBarOpenedComponentState'; +import { userEvent, waitFor, within } from '@storybook/test'; +import { IconCheckbox, IconTrash } from 'twenty-ui'; + +const deleteMock = jest.fn(); +const markAsDoneMock = jest.fn(); + +const meta: Meta = { + title: 'Modules/ActionMenu/ActionMenuBar', + component: ActionMenuBar, + decorators: [ + (Story) => ( + { + set(contextStoreTargetedRecordIdsState, ['1', '2', '3']); + set( + actionMenuEntriesComponentState.atomFamily({ + instanceId: 'story-action-menu', + }), + [ + { + label: 'Delete', + Icon: IconTrash, + onClick: deleteMock, + }, + { + label: 'Mark as done', + Icon: IconCheckbox, + onClick: markAsDoneMock, + }, + ], + ); + set( + isBottomBarOpenedComponentState.atomFamily({ + instanceId: 'action-bar-story-action-menu', + }), + true, + ); + }} + > + + + + + ), + ], + args: { + actionMenuId: 'story-action-menu', + }, +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + actionMenuId: 'story-action-menu', + }, +}; + +export const WithCustomSelection: Story = { + args: { + actionMenuId: 'story-action-menu', + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const selectionText = await canvas.findByText('3 selected:'); + expect(selectionText).toBeInTheDocument(); + }, +}; + +export const WithButtonClicks: Story = { + args: { + actionMenuId: 'story-action-menu', + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + const deleteButton = await canvas.findByText('Delete'); + await userEvent.click(deleteButton); + + const markAsDoneButton = await canvas.findByText('Mark as done'); + await userEvent.click(markAsDoneButton); + + await waitFor(() => { + expect(deleteMock).toHaveBeenCalled(); + expect(markAsDoneMock).toHaveBeenCalled(); + }); + }, +}; diff --git a/packages/twenty-front/src/modules/action-menu/components/__stories__/ActionMenuBarEntry.stories.tsx b/packages/twenty-front/src/modules/action-menu/components/__stories__/ActionMenuBarEntry.stories.tsx new file mode 100644 index 000000000000..a84e033312db --- /dev/null +++ b/packages/twenty-front/src/modules/action-menu/components/__stories__/ActionMenuBarEntry.stories.tsx @@ -0,0 +1,56 @@ +import { expect, jest } from '@storybook/jest'; +import { Meta, StoryObj } from '@storybook/react'; +import { userEvent, within } from '@storybook/testing-library'; + +import { ComponentDecorator, IconCheckbox, IconTrash } from 'twenty-ui'; +import { ActionMenuBarEntry } from '../ActionMenuBarEntry'; + +const meta: Meta = { + title: 'Modules/ActionMenu/ActionMenuBarEntry', + component: ActionMenuBarEntry, + decorators: [ComponentDecorator], +}; + +export default meta; + +type Story = StoryObj; + +const deleteMock = jest.fn(); +const markAsDoneMock = jest.fn(); + +export const Default: Story = { + args: { + entry: { + label: 'Delete', + Icon: IconTrash, + onClick: deleteMock, + }, + }, +}; + +export const WithDangerAccent: Story = { + args: { + entry: { + label: 'Delete', + Icon: IconTrash, + onClick: deleteMock, + accent: 'danger', + }, + }, +}; + +export const WithInteraction: Story = { + args: { + entry: { + label: 'Mark as done', + Icon: IconCheckbox, + onClick: markAsDoneMock, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const button = await canvas.findByText('Mark as done'); + await userEvent.click(button); + expect(markAsDoneMock).toHaveBeenCalled(); + }, +}; diff --git a/packages/twenty-front/src/modules/action-menu/components/__stories__/ActionMenuDropdown.stories.tsx b/packages/twenty-front/src/modules/action-menu/components/__stories__/ActionMenuDropdown.stories.tsx new file mode 100644 index 000000000000..4848f44eeec5 --- /dev/null +++ b/packages/twenty-front/src/modules/action-menu/components/__stories__/ActionMenuDropdown.stories.tsx @@ -0,0 +1,102 @@ +import { expect, jest } from '@storybook/jest'; +import { Meta, StoryObj } from '@storybook/react'; +import { userEvent, within } from '@storybook/testing-library'; +import { RecoilRoot } from 'recoil'; + +import { ActionMenuDropdown } from '@/action-menu/components/ActionMenuDropdown'; +import { actionMenuDropdownPositionComponentState } from '@/action-menu/states/actionMenuDropdownPositionComponentState'; +import { actionMenuEntriesComponentState } from '@/action-menu/states/actionMenuEntriesComponentState'; +import { ActionMenuComponentInstanceContext } from '@/action-menu/states/contexts/ActionMenuComponentInstanceContext'; +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'; + +const deleteMock = jest.fn(); +const markAsDoneMock = jest.fn(); +const addToFavoritesMock = jest.fn(); + +const meta: Meta = { + title: 'Modules/ActionMenu/ActionMenuDropdown', + component: ActionMenuDropdown, + decorators: [ + (Story) => ( + { + set( + extractComponentState( + actionMenuDropdownPositionComponentState, + 'action-menu-dropdown-story', + ), + { x: 10, y: 10 }, + ); + set( + actionMenuEntriesComponentState.atomFamily({ + instanceId: 'story-action-menu', + }), + [ + { + label: 'Delete', + Icon: IconTrash, + onClick: deleteMock, + }, + { + label: 'Mark as done', + Icon: IconCheckbox, + onClick: markAsDoneMock, + }, + { + label: 'Add to favorites', + Icon: IconHeart, + onClick: addToFavoritesMock, + }, + ], + ); + set( + extractComponentState( + isDropdownOpenComponentState, + 'action-menu-dropdown-story-action-menu', + ), + true, + ); + }} + > + + + + + ), + ], +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + actionMenuId: 'story', + }, +}; + +export const WithInteractions: Story = { + args: { + actionMenuId: 'story', + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + const deleteButton = await canvas.findByText('Delete'); + await userEvent.click(deleteButton); + expect(deleteMock).toHaveBeenCalled(); + + const markAsDoneButton = await canvas.findByText('Mark as done'); + await userEvent.click(markAsDoneButton); + expect(markAsDoneMock).toHaveBeenCalled(); + + const addToFavoritesButton = await canvas.findByText('Add to favorites'); + await userEvent.click(addToFavoritesButton); + expect(addToFavoritesMock).toHaveBeenCalled(); + }, +}; diff --git a/packages/twenty-front/src/modules/action-menu/hooks/__tests__/useActionMenu.test.ts b/packages/twenty-front/src/modules/action-menu/hooks/__tests__/useActionMenu.test.ts new file mode 100644 index 000000000000..0f37475adedc --- /dev/null +++ b/packages/twenty-front/src/modules/action-menu/hooks/__tests__/useActionMenu.test.ts @@ -0,0 +1,83 @@ +import { renderHook } from '@testing-library/react'; +import { act } from 'react'; +import { useActionMenu } from '../useActionMenu'; + +const openBottomBar = jest.fn(); +const closeBottomBar = jest.fn(); +const openDropdown = jest.fn(); +const closeDropdown = jest.fn(); + +jest.mock('@/ui/layout/bottom-bar/hooks/useBottomBar', () => ({ + useBottomBar: jest.fn(() => ({ + openBottomBar: openBottomBar, + closeBottomBar: closeBottomBar, + })), +})); + +jest.mock('@/ui/layout/dropdown/hooks/useDropdownV2', () => ({ + useDropdownV2: jest.fn(() => ({ + openDropdown: openDropdown, + closeDropdown: closeDropdown, + })), +})); + +describe('useActionMenu', () => { + const actionMenuId = 'test-action-menu'; + + it('should return the correct functions', () => { + const { result } = renderHook(() => useActionMenu(actionMenuId)); + + expect(result.current).toHaveProperty('openActionMenuDropdown'); + expect(result.current).toHaveProperty('openActionBar'); + expect(result.current).toHaveProperty('closeActionBar'); + expect(result.current).toHaveProperty('closeActionMenuDropdown'); + }); + + it('should call the correct functions when opening action menu dropdown', () => { + const { result } = renderHook(() => useActionMenu(actionMenuId)); + + act(() => { + result.current.openActionMenuDropdown(); + }); + + expect(closeBottomBar).toHaveBeenCalledWith(`action-bar-${actionMenuId}`); + expect(openDropdown).toHaveBeenCalledWith( + `action-menu-dropdown-${actionMenuId}`, + ); + }); + + it('should call the correct functions when opening action bar', () => { + const { result } = renderHook(() => useActionMenu(actionMenuId)); + + act(() => { + result.current.openActionBar(); + }); + + expect(closeDropdown).toHaveBeenCalledWith( + `action-menu-dropdown-${actionMenuId}`, + ); + expect(openBottomBar).toHaveBeenCalledWith(`action-bar-${actionMenuId}`); + }); + + it('should call the correct function when closing action menu dropdown', () => { + const { result } = renderHook(() => useActionMenu(actionMenuId)); + + act(() => { + result.current.closeActionMenuDropdown(); + }); + + expect(closeDropdown).toHaveBeenCalledWith( + `action-menu-dropdown-${actionMenuId}`, + ); + }); + + it('should call the correct function when closing action bar', () => { + const { result } = renderHook(() => useActionMenu(actionMenuId)); + + act(() => { + result.current.closeActionBar(); + }); + + expect(closeBottomBar).toHaveBeenCalledWith(`action-bar-${actionMenuId}`); + }); +}); diff --git a/packages/twenty-front/src/modules/action-menu/hooks/useActionMenu.ts b/packages/twenty-front/src/modules/action-menu/hooks/useActionMenu.ts new file mode 100644 index 000000000000..881cadd694e1 --- /dev/null +++ b/packages/twenty-front/src/modules/action-menu/hooks/useActionMenu.ts @@ -0,0 +1,32 @@ +import { useBottomBar } from '@/ui/layout/bottom-bar/hooks/useBottomBar'; +import { useDropdownV2 } from '@/ui/layout/dropdown/hooks/useDropdownV2'; + +export const useActionMenu = (actionMenuId: string) => { + const { openDropdown, closeDropdown } = useDropdownV2(); + const { openBottomBar, closeBottomBar } = useBottomBar(); + + const openActionMenuDropdown = () => { + closeBottomBar(`action-bar-${actionMenuId}`); + openDropdown(`action-menu-dropdown-${actionMenuId}`); + }; + + const openActionBar = () => { + closeDropdown(`action-menu-dropdown-${actionMenuId}`); + openBottomBar(`action-bar-${actionMenuId}`); + }; + + const closeActionMenuDropdown = () => { + closeDropdown(`action-menu-dropdown-${actionMenuId}`); + }; + + const closeActionBar = () => { + closeBottomBar(`action-bar-${actionMenuId}`); + }; + + return { + openActionMenuDropdown, + openActionBar, + closeActionBar, + closeActionMenuDropdown, + }; +}; diff --git a/packages/twenty-front/src/modules/action-menu/hooks/useComputeActionsBasedOnContextStore.tsx b/packages/twenty-front/src/modules/action-menu/hooks/useComputeActionsBasedOnContextStore.tsx new file mode 100644 index 000000000000..0145042d2847 --- /dev/null +++ b/packages/twenty-front/src/modules/action-menu/hooks/useComputeActionsBasedOnContextStore.tsx @@ -0,0 +1,148 @@ +import { useHandleFavoriteButton } from '@/action-menu/hooks/useHandleFavoriteButton'; +import { ActionMenuEntry } from '@/action-menu/types/ActionMenuEntry'; +import { contextStoreTargetedRecordIdsState } from '@/context-store/states/contextStoreTargetedRecordIdsState'; +import { useFavorites } from '@/favorites/hooks/useFavorites'; +import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { DELETE_MAX_COUNT } from '@/object-record/constants/DeleteMaxCount'; +import { useDeleteTableData } from '@/object-record/record-index/options/hooks/useDeleteTableData'; +import { + displayedExportProgress, + useExportTableData, +} from '@/object-record/record-index/options/hooks/useExportTableData'; +import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal'; +import { isNonEmptyString } from '@sniptt/guards'; +import { useCallback, useMemo, useState } from 'react'; +import { useRecoilValue } from 'recoil'; +import { + IconFileExport, + IconHeart, + IconHeartOff, + IconTrash, + isDefined, +} from 'twenty-ui'; + +export const useComputeActionsBasedOnContextStore = ({ + objectMetadataItem, +}: { + objectMetadataItem: ObjectMetadataItem; +}) => { + const contextStoreTargetedRecordIds = useRecoilValue( + contextStoreTargetedRecordIdsState, + ); + + const [isDeleteRecordsModalOpen, setIsDeleteRecordsModalOpen] = + useState(false); + + const { handleFavoriteButtonClick } = useHandleFavoriteButton( + contextStoreTargetedRecordIds, + objectMetadataItem, + ); + + const baseTableDataParams = { + delayMs: 100, + objectNameSingular: objectMetadataItem.nameSingular, + recordIndexId: objectMetadataItem.namePlural, + }; + + const { deleteTableData } = useDeleteTableData(baseTableDataParams); + + const handleDeleteClick = useCallback(() => { + deleteTableData(contextStoreTargetedRecordIds); + }, [deleteTableData, contextStoreTargetedRecordIds]); + + const { progress, download } = useExportTableData({ + ...baseTableDataParams, + filename: `${objectMetadataItem.nameSingular}.csv`, + }); + + const isRemoteObject = objectMetadataItem.isRemote; + + const numberOfSelectedRecords = contextStoreTargetedRecordIds.length; + + const canDelete = + !isRemoteObject && numberOfSelectedRecords < DELETE_MAX_COUNT; + + const menuActions: ActionMenuEntry[] = useMemo( + () => + [ + { + label: displayedExportProgress(progress), + Icon: IconFileExport, + accent: 'default', + onClick: () => download(), + } satisfies ActionMenuEntry, + canDelete + ? ({ + label: 'Delete', + Icon: IconTrash, + accent: 'danger', + onClick: () => { + setIsDeleteRecordsModalOpen(true); + }, + ConfirmationModal: ( + handleDeleteClick()} + deleteButtonText={`Delete ${ + numberOfSelectedRecords > 1 ? 'Records' : 'Record' + }`} + /> + ), + } satisfies ActionMenuEntry) + : undefined, + ].filter(isDefined), + [ + download, + progress, + canDelete, + handleDeleteClick, + isDeleteRecordsModalOpen, + numberOfSelectedRecords, + ], + ); + + const hasOnlyOneRecordSelected = contextStoreTargetedRecordIds.length === 1; + + const { favorites } = useFavorites(); + + const isFavorite = + isNonEmptyString(contextStoreTargetedRecordIds[0]) && + !!favorites?.find( + (favorite) => favorite.recordId === contextStoreTargetedRecordIds[0], + ); + + return { + availableActionsInContext: [ + ...menuActions, + ...(!isRemoteObject && isFavorite && hasOnlyOneRecordSelected + ? [ + { + label: 'Remove from favorites', + Icon: IconHeartOff, + onClick: handleFavoriteButtonClick, + }, + ] + : []), + ...(!isRemoteObject && !isFavorite && hasOnlyOneRecordSelected + ? [ + { + label: 'Add to favorites', + Icon: IconHeart, + onClick: handleFavoriteButtonClick, + }, + ] + : []), + ], + }; +}; diff --git a/packages/twenty-front/src/modules/action-menu/hooks/useHandleFavoriteButton.ts b/packages/twenty-front/src/modules/action-menu/hooks/useHandleFavoriteButton.ts new file mode 100644 index 000000000000..b965fe61f2d1 --- /dev/null +++ b/packages/twenty-front/src/modules/action-menu/hooks/useHandleFavoriteButton.ts @@ -0,0 +1,49 @@ +import { useFavorites } from '@/favorites/hooks/useFavorites'; +import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState'; +import { useRecoilCallback } from 'recoil'; +import { isDefined } from 'twenty-ui'; + +export const useHandleFavoriteButton = ( + selectedRecordIds: string[], + objectMetadataItem: ObjectMetadataItem, + callback?: () => void, +) => { + const { createFavorite, favorites, deleteFavorite } = useFavorites(); + + const handleFavoriteButtonClick = useRecoilCallback( + ({ snapshot }) => + () => { + if (selectedRecordIds.length > 1) { + return; + } + + const selectedRecordId = selectedRecordIds[0]; + const selectedRecord = snapshot + .getLoadable(recordStoreFamilyState(selectedRecordId)) + .getValue(); + + const foundFavorite = favorites?.find( + (favorite) => favorite.recordId === selectedRecordId, + ); + + const isFavorite = !!selectedRecordId && !!foundFavorite; + + if (isFavorite) { + deleteFavorite(foundFavorite.id); + } else if (isDefined(selectedRecord)) { + createFavorite(selectedRecord, objectMetadataItem.nameSingular); + } + callback?.(); + }, + [ + callback, + createFavorite, + deleteFavorite, + favorites, + objectMetadataItem.nameSingular, + selectedRecordIds, + ], + ); + return { handleFavoriteButtonClick }; +}; diff --git a/packages/twenty-front/src/modules/action-menu/states/actionMenuDropdownPositionComponentState.ts b/packages/twenty-front/src/modules/action-menu/states/actionMenuDropdownPositionComponentState.ts new file mode 100644 index 000000000000..f2f8f06b1372 --- /dev/null +++ b/packages/twenty-front/src/modules/action-menu/states/actionMenuDropdownPositionComponentState.ts @@ -0,0 +1,11 @@ +import { PositionType } from '@/action-menu/types/PositionType'; +import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState'; + +export const actionMenuDropdownPositionComponentState = + createComponentState({ + key: 'actionMenuDropdownPositionComponentState', + defaultValue: { + x: null, + y: null, + }, + }); diff --git a/packages/twenty-front/src/modules/action-menu/states/actionMenuEntriesComponentState.ts b/packages/twenty-front/src/modules/action-menu/states/actionMenuEntriesComponentState.ts new file mode 100644 index 000000000000..8ba3a259da68 --- /dev/null +++ b/packages/twenty-front/src/modules/action-menu/states/actionMenuEntriesComponentState.ts @@ -0,0 +1,11 @@ +import { ActionMenuComponentInstanceContext } from '@/action-menu/states/contexts/ActionMenuComponentInstanceContext'; +import { createComponentStateV2 } from '@/ui/utilities/state/component-state/utils/createComponentStateV2'; +import { ActionMenuEntry } from '../types/ActionMenuEntry'; + +export const actionMenuEntriesComponentState = createComponentStateV2< + ActionMenuEntry[] +>({ + key: 'actionMenuEntriesComponentState', + defaultValue: [], + componentInstanceContext: ActionMenuComponentInstanceContext, +}); diff --git a/packages/twenty-front/src/modules/action-menu/states/contexts/ActionMenuComponentInstanceContext.ts b/packages/twenty-front/src/modules/action-menu/states/contexts/ActionMenuComponentInstanceContext.ts new file mode 100644 index 000000000000..1e64690c744d --- /dev/null +++ b/packages/twenty-front/src/modules/action-menu/states/contexts/ActionMenuComponentInstanceContext.ts @@ -0,0 +1,4 @@ +import { createComponentInstanceContext } from '@/ui/utilities/state/component-state/utils/createComponentInstanceContext'; + +export const ActionMenuComponentInstanceContext = + createComponentInstanceContext(); diff --git a/packages/twenty-front/src/modules/action-menu/types/ActionBarHotKeyScope.ts b/packages/twenty-front/src/modules/action-menu/types/ActionBarHotKeyScope.ts new file mode 100644 index 000000000000..fcadbee366f0 --- /dev/null +++ b/packages/twenty-front/src/modules/action-menu/types/ActionBarHotKeyScope.ts @@ -0,0 +1,3 @@ +export enum ActionBarHotkeyScope { + ActionBar = 'action-bar', +} diff --git a/packages/twenty-front/src/modules/ui/navigation/action-bar/types/ActionBarItemAccent.ts b/packages/twenty-front/src/modules/action-menu/types/ActionBarItemAccent.ts similarity index 100% rename from packages/twenty-front/src/modules/ui/navigation/action-bar/types/ActionBarItemAccent.ts rename to packages/twenty-front/src/modules/action-menu/types/ActionBarItemAccent.ts diff --git a/packages/twenty-front/src/modules/action-menu/types/ActionMenuDropdownHotKeyScope.ts b/packages/twenty-front/src/modules/action-menu/types/ActionMenuDropdownHotKeyScope.ts new file mode 100644 index 000000000000..9c0e2df42edf --- /dev/null +++ b/packages/twenty-front/src/modules/action-menu/types/ActionMenuDropdownHotKeyScope.ts @@ -0,0 +1,3 @@ +export enum ActionMenuDropdownHotkeyScope { + ActionMenuDropdown = 'action-menu-dropdown', +} diff --git a/packages/twenty-front/src/modules/ui/navigation/context-menu/types/ContextMenuEntry.ts b/packages/twenty-front/src/modules/action-menu/types/ActionMenuEntry.ts similarity index 90% rename from packages/twenty-front/src/modules/ui/navigation/context-menu/types/ContextMenuEntry.ts rename to packages/twenty-front/src/modules/action-menu/types/ActionMenuEntry.ts index 416a41419f62..d363a8fcd252 100644 --- a/packages/twenty-front/src/modules/ui/navigation/context-menu/types/ContextMenuEntry.ts +++ b/packages/twenty-front/src/modules/action-menu/types/ActionMenuEntry.ts @@ -3,7 +3,7 @@ import { IconComponent } from 'twenty-ui'; import { MenuItemAccent } from '@/ui/navigation/menu-item/types/MenuItemAccent'; -export type ContextMenuEntry = { +export type ActionMenuEntry = { label: string; Icon: IconComponent; accent?: MenuItemAccent; diff --git a/packages/twenty-front/src/modules/ui/navigation/context-menu/types/PositionType.ts b/packages/twenty-front/src/modules/action-menu/types/PositionType.ts similarity index 100% rename from packages/twenty-front/src/modules/ui/navigation/context-menu/types/PositionType.ts rename to packages/twenty-front/src/modules/action-menu/types/PositionType.ts diff --git a/packages/twenty-front/src/modules/activities/emails/right-drawer/hooks/__tests__/useOpenEmailThreadRightDrawer.test.ts b/packages/twenty-front/src/modules/activities/emails/right-drawer/hooks/__tests__/useOpenEmailThreadRightDrawer.test.ts index 6e77fc50907d..8660d7617de1 100644 --- a/packages/twenty-front/src/modules/activities/emails/right-drawer/hooks/__tests__/useOpenEmailThreadRightDrawer.test.ts +++ b/packages/twenty-front/src/modules/activities/emails/right-drawer/hooks/__tests__/useOpenEmailThreadRightDrawer.test.ts @@ -1,5 +1,5 @@ -import { act } from 'react-dom/test-utils'; import { renderHook } from '@testing-library/react'; +import { act } from 'react-dom/test-utils'; import { useOpenEmailThreadRightDrawer } from '@/activities/emails/right-drawer/hooks/useOpenEmailThreadRightDrawer'; import { RightDrawerHotkeyScope } from '@/ui/layout/right-drawer/types/RightDrawerHotkeyScope'; diff --git a/packages/twenty-front/src/modules/information-banner/components/reconnect-account/InformationBannerReconnectAccountEmailAliases.tsx b/packages/twenty-front/src/modules/information-banner/components/reconnect-account/InformationBannerReconnectAccountEmailAliases.tsx index 03e7e04802f9..c3f381c1bcd5 100644 --- a/packages/twenty-front/src/modules/information-banner/components/reconnect-account/InformationBannerReconnectAccountEmailAliases.tsx +++ b/packages/twenty-front/src/modules/information-banner/components/reconnect-account/InformationBannerReconnectAccountEmailAliases.tsx @@ -1,6 +1,6 @@ import { InformationBanner } from '@/information-banner/components/InformationBanner'; -import { InformationBannerKeys } from '@/information-banner/enums/InformationBannerKeys.enum'; import { useAccountToReconnect } from '@/information-banner/hooks/useAccountToReconnect'; +import { InformationBannerKeys } from '@/information-banner/types/InformationBannerKeys'; import { useTriggerGoogleApisOAuth } from '@/settings/accounts/hooks/useTriggerGoogleApisOAuth'; import { IconRefresh } from 'twenty-ui'; diff --git a/packages/twenty-front/src/modules/information-banner/components/reconnect-account/InformationBannerReconnectAccountInsufficientPermissions.tsx b/packages/twenty-front/src/modules/information-banner/components/reconnect-account/InformationBannerReconnectAccountInsufficientPermissions.tsx index 45a4ca00b20b..7f74a129b652 100644 --- a/packages/twenty-front/src/modules/information-banner/components/reconnect-account/InformationBannerReconnectAccountInsufficientPermissions.tsx +++ b/packages/twenty-front/src/modules/information-banner/components/reconnect-account/InformationBannerReconnectAccountInsufficientPermissions.tsx @@ -1,6 +1,6 @@ import { InformationBanner } from '@/information-banner/components/InformationBanner'; -import { InformationBannerKeys } from '@/information-banner/enums/InformationBannerKeys.enum'; import { useAccountToReconnect } from '@/information-banner/hooks/useAccountToReconnect'; +import { InformationBannerKeys } from '@/information-banner/types/InformationBannerKeys'; import { useTriggerGoogleApisOAuth } from '@/settings/accounts/hooks/useTriggerGoogleApisOAuth'; import { IconRefresh } from 'twenty-ui'; diff --git a/packages/twenty-front/src/modules/information-banner/hooks/useAccountToReconnect.ts b/packages/twenty-front/src/modules/information-banner/hooks/useAccountToReconnect.ts index e73031015a44..ea74778ff536 100644 --- a/packages/twenty-front/src/modules/information-banner/hooks/useAccountToReconnect.ts +++ b/packages/twenty-front/src/modules/information-banner/hooks/useAccountToReconnect.ts @@ -1,6 +1,6 @@ import { ConnectedAccount } from '@/accounts/types/ConnectedAccount'; import { currentUserState } from '@/auth/states/currentUserState'; -import { InformationBannerKeys } from '@/information-banner/enums/InformationBannerKeys.enum'; +import { InformationBannerKeys } from '@/information-banner/types/InformationBannerKeys'; import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord'; import { useRecoilValue } from 'recoil'; diff --git a/packages/twenty-front/src/modules/information-banner/enums/InformationBannerKeys.enum.ts b/packages/twenty-front/src/modules/information-banner/types/InformationBannerKeys.ts similarity index 100% rename from packages/twenty-front/src/modules/information-banner/enums/InformationBannerKeys.enum.ts rename to packages/twenty-front/src/modules/information-banner/types/InformationBannerKeys.ts diff --git a/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useObjectMetadataItem.test.tsx b/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useObjectMetadataItem.test.tsx index 876846f2bd4b..f3d33774b119 100644 --- a/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useObjectMetadataItem.test.tsx +++ b/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useObjectMetadataItem.test.tsx @@ -1,5 +1,6 @@ import { renderHook } from '@testing-library/react'; +import { ObjectMetadataItemNotFoundError } from '@/object-metadata/errors/ObjectMetadataNotFoundError'; import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; import { getJestMetadataAndApolloMocksWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksWrapper'; import { generatedMockObjectMetadataItems } from '~/testing/mock-data/generatedMockObjectMetadataItems'; @@ -25,4 +26,15 @@ describe('useObjectMetadataItem', () => { expect(objectMetadataItem.id).toBe(opportunityObjectMetadata?.id); }); + + it('should throw an error when invalid object name singular is provided', async () => { + expect(() => + renderHook( + () => useObjectMetadataItem({ objectNameSingular: 'invalid-object' }), + { + wrapper: Wrapper, + }, + ), + ).toThrow(ObjectMetadataItemNotFoundError); + }); }); diff --git a/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useObjectMetadataItemById.test.ts b/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useObjectMetadataItemById.test.ts new file mode 100644 index 000000000000..c2fe8825aa68 --- /dev/null +++ b/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useObjectMetadataItemById.test.ts @@ -0,0 +1,44 @@ +import { renderHook } from '@testing-library/react'; + +import { ObjectMetadataItemNotFoundError } from '@/object-metadata/errors/ObjectMetadataNotFoundError'; +import { useObjectMetadataItemById } from '@/object-metadata/hooks/useObjectMetadataItemById'; +import { getJestMetadataAndApolloMocksWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksWrapper'; +import { generatedMockObjectMetadataItems } from '~/testing/mock-data/generatedMockObjectMetadataItems'; + +const Wrapper = getJestMetadataAndApolloMocksWrapper({ + apolloMocks: [], +}); + +describe('useObjectMetadataItemById', () => { + const opportunityObjectMetadata = generatedMockObjectMetadataItems.find( + (item) => item.nameSingular === 'opportunity', + ); + + if (!opportunityObjectMetadata) { + throw new Error('Opportunity object metadata not found'); + } + + it('should return correct properties', async () => { + const { result } = renderHook( + () => + useObjectMetadataItemById({ + objectId: opportunityObjectMetadata.id, + }), + { + wrapper: Wrapper, + }, + ); + + const { objectMetadataItem } = result.current; + + expect(objectMetadataItem.id).toBe(opportunityObjectMetadata.id); + }); + + it('should throw an error when invalid ID is provided', async () => { + expect(() => + renderHook(() => useObjectMetadataItemById({ objectId: 'invalid-id' }), { + wrapper: Wrapper, + }), + ).toThrow(ObjectMetadataItemNotFoundError); + }); +}); diff --git a/packages/twenty-front/src/modules/object-metadata/hooks/useObjectMetadataItemById.ts b/packages/twenty-front/src/modules/object-metadata/hooks/useObjectMetadataItemById.ts new file mode 100644 index 000000000000..6d9730f2d6db --- /dev/null +++ b/packages/twenty-front/src/modules/object-metadata/hooks/useObjectMetadataItemById.ts @@ -0,0 +1,25 @@ +import { useRecoilValue } from 'recoil'; + +import { ObjectMetadataItemNotFoundError } from '@/object-metadata/errors/ObjectMetadataNotFoundError'; +import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState'; +import { isDefined } from '~/utils/isDefined'; + +export const useObjectMetadataItemById = ({ + objectId, +}: { + objectId: string; +}) => { + const objectMetadataItems = useRecoilValue(objectMetadataItemsState); + + const objectMetadataItem = objectMetadataItems.find( + (objectMetadataItem) => objectMetadataItem.id === objectId, + ); + + if (!isDefined(objectMetadataItem)) { + throw new ObjectMetadataItemNotFoundError(objectId, objectMetadataItems); + } + + return { + objectMetadataItem, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-action-bar/hooks/useRecordActionBar.tsx b/packages/twenty-front/src/modules/object-record/record-action-bar/hooks/useRecordActionBar.tsx deleted file mode 100644 index b7cce8057efb..000000000000 --- a/packages/twenty-front/src/modules/object-record/record-action-bar/hooks/useRecordActionBar.tsx +++ /dev/null @@ -1,212 +0,0 @@ -import { isNonEmptyString } from '@sniptt/guards'; -import { useCallback, useMemo, useState } from 'react'; -import { useRecoilCallback, useSetRecoilState } from 'recoil'; -import { IconFileExport, IconHeart, IconHeartOff, IconTrash } from 'twenty-ui'; - -import { useFavorites } from '@/favorites/hooks/useFavorites'; -import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; -import { DELETE_MAX_COUNT } from '@/object-record/constants/DeleteMaxCount'; -import { useDeleteTableData } from '@/object-record/record-index/options/hooks/useDeleteTableData'; -import { - displayedExportProgress, - useExportTableData, -} from '@/object-record/record-index/options/hooks/useExportTableData'; -import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState'; -import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal'; -import { actionBarEntriesState } from '@/ui/navigation/action-bar/states/actionBarEntriesState'; -import { contextMenuEntriesState } from '@/ui/navigation/context-menu/states/contextMenuEntriesState'; -import { ContextMenuEntry } from '@/ui/navigation/context-menu/types/ContextMenuEntry'; -import { isDefined } from '~/utils/isDefined'; - -type useRecordActionBarProps = { - objectMetadataItem: ObjectMetadataItem; - selectedRecordIds: string[]; - callback?: () => void; - totalNumberOfRecordsSelected?: number; -}; - -export const useRecordActionBar = ({ - objectMetadataItem, - selectedRecordIds, - callback, - totalNumberOfRecordsSelected, -}: useRecordActionBarProps) => { - const setContextMenuEntries = useSetRecoilState(contextMenuEntriesState); - const setActionBarEntriesState = useSetRecoilState(actionBarEntriesState); - const [isDeleteRecordsModalOpen, setIsDeleteRecordsModalOpen] = - useState(false); - - const { createFavorite, favorites, deleteFavorite } = useFavorites(); - - const handleFavoriteButtonClick = useRecoilCallback( - ({ snapshot }) => - () => { - if (selectedRecordIds.length > 1) { - return; - } - - const selectedRecordId = selectedRecordIds[0]; - const selectedRecord = snapshot - .getLoadable(recordStoreFamilyState(selectedRecordId)) - .getValue(); - - const foundFavorite = favorites?.find( - (favorite) => favorite.recordId === selectedRecordId, - ); - - const isFavorite = !!selectedRecordId && !!foundFavorite; - - if (isFavorite) { - deleteFavorite(foundFavorite.id); - } else if (isDefined(selectedRecord)) { - createFavorite(selectedRecord, objectMetadataItem.nameSingular); - } - callback?.(); - }, - [ - callback, - createFavorite, - deleteFavorite, - favorites, - objectMetadataItem.nameSingular, - selectedRecordIds, - ], - ); - - const baseTableDataParams = { - delayMs: 100, - objectNameSingular: objectMetadataItem.nameSingular, - recordIndexId: objectMetadataItem.namePlural, - }; - - const { deleteTableData } = useDeleteTableData(baseTableDataParams); - - const handleDeleteClick = useCallback(() => { - deleteTableData(selectedRecordIds); - }, [deleteTableData, selectedRecordIds]); - - const { progress, download } = useExportTableData({ - ...baseTableDataParams, - filename: `${objectMetadataItem.nameSingular}.csv`, - }); - - const isRemoteObject = objectMetadataItem.isRemote; - - const numberOfSelectedRecords = - totalNumberOfRecordsSelected ?? selectedRecordIds.length; - const canDelete = - !isRemoteObject && numberOfSelectedRecords < DELETE_MAX_COUNT; - - const menuActions: ContextMenuEntry[] = useMemo( - () => - [ - { - label: displayedExportProgress(progress), - Icon: IconFileExport, - accent: 'default', - onClick: () => download(), - } satisfies ContextMenuEntry, - canDelete - ? ({ - label: 'Delete', - Icon: IconTrash, - accent: 'danger', - onClick: () => { - setIsDeleteRecordsModalOpen(true); - }, - ConfirmationModal: ( - handleDeleteClick()} - deleteButtonText={`Delete ${ - numberOfSelectedRecords > 1 ? 'Records' : 'Record' - }`} - /> - ), - } satisfies ContextMenuEntry) - : undefined, - ].filter(isDefined), - [ - download, - progress, - canDelete, - handleDeleteClick, - isDeleteRecordsModalOpen, - numberOfSelectedRecords, - ], - ); - - const hasOnlyOneRecordSelected = selectedRecordIds.length === 1; - - const isFavorite = - isNonEmptyString(selectedRecordIds[0]) && - !!favorites?.find((favorite) => favorite.recordId === selectedRecordIds[0]); - - return { - setContextMenuEntries: useCallback(() => { - setContextMenuEntries([ - ...menuActions, - ...(!isRemoteObject && isFavorite && hasOnlyOneRecordSelected - ? [ - { - label: 'Remove from favorites', - Icon: IconHeartOff, - onClick: handleFavoriteButtonClick, - }, - ] - : []), - ...(!isRemoteObject && !isFavorite && hasOnlyOneRecordSelected - ? [ - { - label: 'Add to favorites', - Icon: IconHeart, - onClick: handleFavoriteButtonClick, - }, - ] - : []), - ]); - }, [ - menuActions, - handleFavoriteButtonClick, - hasOnlyOneRecordSelected, - isFavorite, - isRemoteObject, - setContextMenuEntries, - ]), - - setActionBarEntries: useCallback(() => { - setActionBarEntriesState([ - /* - { - label: 'Actions', - Icon: IconClick, - subActions: - - /* [ - { - label: 'Enrich', - Icon: IconPuzzle, - onClick: handleExecuteQuickActionOnClick, - }, - { - label: 'Send to mailjet', - Icon: IconMail, - }, - ], - */ - ...menuActions, - ]); - }, [menuActions, setActionBarEntriesState]), - }; -}; diff --git a/packages/twenty-front/src/modules/object-record/record-board/action-bar/components/RecordBoardActionBar.tsx b/packages/twenty-front/src/modules/object-record/record-board/action-bar/components/RecordBoardActionBar.tsx deleted file mode 100644 index 584cbeab3b5f..000000000000 --- a/packages/twenty-front/src/modules/object-record/record-board/action-bar/components/RecordBoardActionBar.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { useRecoilValue } from 'recoil'; - -import { useRecordBoardStates } from '@/object-record/record-board/hooks/internal/useRecordBoardStates'; -import { ActionBar } from '@/ui/navigation/action-bar/components/ActionBar'; - -type RecordBoardActionBarProps = { - recordBoardId: string; -}; - -export const RecordBoardActionBar = ({ - recordBoardId, -}: RecordBoardActionBarProps) => { - const { selectedRecordIdsSelector } = useRecordBoardStates(recordBoardId); - - const selectedRecordIds = useRecoilValue(selectedRecordIdsSelector()); - - if (!selectedRecordIds.length) { - return null; - } - - return ; -}; diff --git a/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoard.tsx b/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoard.tsx index 2c3b0daf75cc..ff408eb407de 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoard.tsx +++ b/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoard.tsx @@ -69,7 +69,7 @@ export const RecordBoard = ({ recordBoardId }: RecordBoardProps) => { useListenClickOutsideByClassName({ classNames: ['record-board-card'], - excludeClassNames: ['action-bar', 'context-menu'], + excludeClassNames: ['bottom-bar', 'context-menu'], callback: resetRecordSelection, }); diff --git a/packages/twenty-front/src/modules/object-record/record-board/context-menu/components/RecordBoardContextMenu.tsx b/packages/twenty-front/src/modules/object-record/record-board/context-menu/components/RecordBoardContextMenu.tsx deleted file mode 100644 index fed35a6506d3..000000000000 --- a/packages/twenty-front/src/modules/object-record/record-board/context-menu/components/RecordBoardContextMenu.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { useRecoilValue } from 'recoil'; - -import { useRecordBoardStates } from '@/object-record/record-board/hooks/internal/useRecordBoardStates'; -import { ContextMenu } from '@/ui/navigation/context-menu/components/ContextMenu'; - -type RecordBoardContextMenuProps = { - recordBoardId: string; -}; - -export const RecordBoardContextMenu = ({ - recordBoardId, -}: RecordBoardContextMenuProps) => { - const { selectedRecordIdsSelector } = useRecordBoardStates(recordBoardId); - - const selectedRecordIds = useRecoilValue(selectedRecordIdsSelector()); - - if (!selectedRecordIds.length) { - return null; - } - - return ; -}; diff --git a/packages/twenty-front/src/modules/object-record/record-board/hooks/useRecordBoardSelection.ts b/packages/twenty-front/src/modules/object-record/record-board/hooks/useRecordBoardSelection.ts index a14c85119d51..8b2d11837ae1 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/hooks/useRecordBoardSelection.ts +++ b/packages/twenty-front/src/modules/object-record/record-board/hooks/useRecordBoardSelection.ts @@ -1,17 +1,23 @@ -import { useRecoilCallback, useSetRecoilState } from 'recoil'; +import { useRecoilCallback } from 'recoil'; import { useRecordBoardStates } from '@/object-record/record-board/hooks/internal/useRecordBoardStates'; -import { contextMenuIsOpenState } from '@/ui/navigation/context-menu/states/contextMenuIsOpenState'; +import { isDropdownOpenComponentState } from '@/ui/layout/dropdown/states/isDropdownOpenComponentState'; +import { extractComponentState } from '@/ui/utilities/state/component-state/utils/extractComponentState'; -export const useRecordBoardSelection = (recordBoardId?: string) => { - const setContextMenuOpenState = useSetRecoilState(contextMenuIsOpenState); +export const useRecordBoardSelection = (recordBoardId: string) => { const { selectedRecordIdsSelector, isRecordBoardCardSelectedFamilyState } = useRecordBoardStates(recordBoardId); const resetRecordSelection = useRecoilCallback( ({ snapshot, set }) => () => { - setContextMenuOpenState(false); + const isActionMenuDropdownOpenState = extractComponentState( + isDropdownOpenComponentState, + `action-menu-dropdown-${recordBoardId}`, + ); + + set(isActionMenuDropdownOpenState, false); + const recordIds = snapshot .getLoadable(selectedRecordIdsSelector()) .getValue(); @@ -21,9 +27,9 @@ export const useRecordBoardSelection = (recordBoardId?: string) => { } }, [ + recordBoardId, selectedRecordIdsSelector, isRecordBoardCardSelectedFamilyState, - setContextMenuOpenState, ], ); diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-card/components/RecordBoardCard.tsx b/packages/twenty-front/src/modules/object-record/record-board/record-board-card/components/RecordBoardCard.tsx index d336467e8db3..a2ea5b03def3 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/record-board-card/components/RecordBoardCard.tsx +++ b/packages/twenty-front/src/modules/object-record/record-board/record-board-card/components/RecordBoardCard.tsx @@ -1,6 +1,9 @@ +import { useActionMenu } from '@/action-menu/hooks/useActionMenu'; +import { actionMenuDropdownPositionComponentState } from '@/action-menu/states/actionMenuDropdownPositionComponentState'; import { RecordBoardContext } from '@/object-record/record-board/contexts/RecordBoardContext'; import { useRecordBoardStates } from '@/object-record/record-board/hooks/internal/useRecordBoardStates'; import { RecordBoardCardContext } from '@/object-record/record-board/record-board-card/contexts/RecordBoardCardContext'; +import { RecordBoardScopeInternalContext } from '@/object-record/record-board/scopes/scope-internal-context/RecordBoardScopeInternalContext'; import { FieldContext, RecordUpdateHook, @@ -17,10 +20,10 @@ import { ObjectRecord } from '@/object-record/types/ObjectRecord'; import { LightIconButton } from '@/ui/input/button/components/LightIconButton'; import { Checkbox, CheckboxVariant } from '@/ui/input/components/Checkbox'; import { TextInput } from '@/ui/input/components/TextInput'; -import { contextMenuIsOpenState } from '@/ui/navigation/context-menu/states/contextMenuIsOpenState'; -import { contextMenuPositionState } from '@/ui/navigation/context-menu/states/contextMenuPositionState'; import { AnimatedEaseInOut } from '@/ui/utilities/animation/components/AnimatedEaseInOut'; +import { useAvailableScopeIdOrThrow } from '@/ui/utilities/recoil-scope/scopes-internal/hooks/useAvailableScopeId'; import { RecordBoardScrollWrapperContext } from '@/ui/utilities/scroll/contexts/ScrollWrapperContexts'; +import { extractComponentState } from '@/ui/utilities/state/component-state/utils/extractComponentState'; import styled from '@emotion/styled'; import { ReactNode, useContext, useState } from 'react'; import { useInView } from 'react-intersection-observer'; @@ -175,17 +178,27 @@ export const RecordBoardCard = ({ const record = useRecoilValue(recordStoreFamilyState(recordId)); - const setContextMenuPosition = useSetRecoilState(contextMenuPositionState); - const setContextMenuOpenState = useSetRecoilState(contextMenuIsOpenState); + const recordBoardId = useAvailableScopeIdOrThrow( + RecordBoardScopeInternalContext, + ); + + const setActionMenuDropdownPosition = useSetRecoilState( + extractComponentState( + actionMenuDropdownPositionComponentState, + `action-menu-dropdown-${recordBoardId}`, + ), + ); + + const { openActionMenuDropdown } = useActionMenu(recordBoardId); - const handleContextMenu = (event: React.MouseEvent) => { + const handleActionMenuDropdown = (event: React.MouseEvent) => { event.preventDefault(); setIsCurrentCardSelected(true); - setContextMenuPosition({ + setActionMenuDropdownPosition({ x: event.clientX, y: event.clientY, }); - setContextMenuOpenState(true); + openActionMenuDropdown(); }; const PreventSelectOnClickContainer = ({ @@ -235,7 +248,7 @@ export const RecordBoardCard = ({ ); return ( - + {!isCreating && } - - ); }; diff --git a/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexBoardDataLoaderEffect.tsx b/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexBoardDataLoaderEffect.tsx index c4ad79ed412f..354abcf09dab 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexBoardDataLoaderEffect.tsx +++ b/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexBoardDataLoaderEffect.tsx @@ -6,9 +6,7 @@ import { contextStoreCurrentObjectMetadataIdState } from '@/context-store/states import { contextStoreTargetedRecordIdsState } from '@/context-store/states/contextStoreTargetedRecordIdsState'; import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; import { getObjectSlug } from '@/object-metadata/utils/getObjectSlug'; -import { useRecordActionBar } from '@/object-record/record-action-bar/hooks/useRecordActionBar'; import { useRecordBoard } from '@/object-record/record-board/hooks/useRecordBoard'; -import { useRecordBoardSelection } from '@/object-record/record-board/hooks/useRecordBoardSelection'; import { recordIndexFieldDefinitionsState } from '@/object-record/record-index/states/recordIndexFieldDefinitionsState'; import { recordIndexIsCompactModeActiveState } from '@/object-record/record-index/states/recordIndexIsCompactModeActiveState'; import { recordIndexKanbanFieldMetadataIdState } from '@/object-record/record-index/states/recordIndexKanbanFieldMetadataIdState'; @@ -79,8 +77,6 @@ export const RecordIndexBoardDataLoaderEffect = ({ setNavigationMemorizedUrl, ]); - const { resetRecordSelection } = useRecordBoardSelection(recordBoardId); - useEffect(() => { setObjectSingularName(objectNameSingular); }, [objectNameSingular, setObjectSingularName]); @@ -125,12 +121,6 @@ export const RecordIndexBoardDataLoaderEffect = ({ const selectedRecordIds = useRecoilValue(selectedRecordIdsSelector()); - const { setActionBarEntries, setContextMenuEntries } = useRecordActionBar({ - objectMetadataItem, - selectedRecordIds, - callback: resetRecordSelection, - }); - const setContextStoreTargetedRecordIds = useSetRecoilState( contextStoreTargetedRecordIdsState, ); @@ -140,9 +130,8 @@ export const RecordIndexBoardDataLoaderEffect = ({ ); useEffect(() => { - setActionBarEntries?.(); - setContextMenuEntries?.(); - }, [setActionBarEntries, setContextMenuEntries]); + setContextStoreTargetedRecordIds(selectedRecordIds); + }, [selectedRecordIds, setContextStoreTargetedRecordIds]); useEffect(() => { setContextStoreTargetedRecordIds(selectedRecordIds); diff --git a/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexContainer.tsx b/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexContainer.tsx index 3af3237929f4..cb0d934656d0 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexContainer.tsx +++ b/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexContainer.tsx @@ -23,6 +23,13 @@ import { RecordIndexRootPropsContext } from '@/object-record/record-index/contex import { RecordFieldValueSelectorContextProvider } from '@/object-record/record-store/contexts/RecordFieldValueSelectorContext'; import { useRecordTable } from '@/object-record/record-table/hooks/useRecordTable'; import { SpreadsheetImportProvider } from '@/spreadsheet-import/provider/components/SpreadsheetImportProvider'; + +import { ActionMenuBar } from '@/action-menu/components/ActionMenuBar'; +import { ActionMenuConfirmationModals } from '@/action-menu/components/ActionMenuConfirmationModals'; +import { ActionMenuDropdown } from '@/action-menu/components/ActionMenuDropdown'; +import { ActionMenuEffect } from '@/action-menu/components/ActionMenuEffect'; +import { ActionMenuEntriesProvider } from '@/action-menu/components/ActionMenuEntriesProvider'; +import { ActionMenuComponentInstanceContext } from '@/action-menu/states/contexts/ActionMenuComponentInstanceContext'; import { ViewBar } from '@/views/components/ViewBar'; import { ViewComponentInstanceContext } from '@/views/states/contexts/ViewComponentInstanceContext'; import { ViewField } from '@/views/types/ViewField'; @@ -191,6 +198,15 @@ export const RecordIndexContainer = () => { /> )} + + + + + + + diff --git a/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexTableContainer.tsx b/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexTableContainer.tsx index 50bb639f5237..f1dec5e3e7e6 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexTableContainer.tsx +++ b/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexTableContainer.tsx @@ -2,9 +2,7 @@ import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord'; import { RecordUpdateHookParams } from '@/object-record/record-field/contexts/FieldContext'; import { RecordIndexRemoveSortingModal } from '@/object-record/record-index/components/RecordIndexRemoveSortingModal'; import { RecordIndexRootPropsContext } from '@/object-record/record-index/contexts/RecordIndexRootPropsContext'; -import { RecordTableActionBar } from '@/object-record/record-table/action-bar/components/RecordTableActionBar'; import { RecordTableWithWrappers } from '@/object-record/record-table/components/RecordTableWithWrappers'; -import { RecordTableContextMenu } from '@/object-record/record-table/context-menu/components/RecordTableContextMenu'; import { useContext } from 'react'; type RecordIndexTableContainerProps = { @@ -37,9 +35,7 @@ export const RecordIndexTableContainer = ({ viewBarId={viewBarId} updateRecordMutation={updateEntity} /> - - ); }; diff --git a/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexTableContainerEffect.tsx b/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexTableContainerEffect.tsx index ce8bb6572d8c..ce5dc7279ded 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexTableContainerEffect.tsx +++ b/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexTableContainerEffect.tsx @@ -5,14 +5,10 @@ import { contextStoreCurrentObjectMetadataIdState } from '@/context-store/states import { contextStoreTargetedRecordIdsState } from '@/context-store/states/contextStoreTargetedRecordIdsState'; import { useColumnDefinitionsFromFieldMetadata } from '@/object-metadata/hooks/useColumnDefinitionsFromFieldMetadata'; import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; -import { useRecordActionBar } from '@/object-record/record-action-bar/hooks/useRecordActionBar'; import { useHandleToggleColumnFilter } from '@/object-record/record-index/hooks/useHandleToggleColumnFilter'; import { useHandleToggleColumnSort } from '@/object-record/record-index/hooks/useHandleToggleColumnSort'; -import { useRecordTableStates } from '@/object-record/record-table/hooks/internal/useRecordTableStates'; import { useRecordTable } from '@/object-record/record-table/hooks/useRecordTable'; -import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; import { useSetRecordCountInCurrentView } from '@/views/hooks/useSetRecordCountInCurrentView'; -import { entityCountInCurrentViewComponentState } from '@/views/states/entityCountInCurrentViewComponentState'; type RecordIndexTableContainerEffectProps = { objectNameSingular: string; @@ -28,7 +24,6 @@ export const RecordIndexTableContainerEffect = ({ const { setAvailableTableColumns, setOnEntityCountChange, - resetTableRowSelection, selectedRowIdsSelector, setOnToggleColumnFilter, setOnToggleColumnSort, @@ -58,34 +53,8 @@ export const RecordIndexTableContainerEffect = ({ setAvailableTableColumns(columnDefinitions); }, [columnDefinitions, setAvailableTableColumns]); - const { tableRowIdsState, hasUserSelectedAllRowsState } = - useRecordTableStates(recordTableId); - - // TODO: verify this instance id works - const entityCountInCurrentView = useRecoilComponentValueV2( - entityCountInCurrentViewComponentState, - recordTableId, - ); - const hasUserSelectedAllRows = useRecoilValue(hasUserSelectedAllRowsState); - const tableRowIds = useRecoilValue(tableRowIdsState); - const selectedRowIds = useRecoilValue(selectedRowIdsSelector()); - const numSelected = - hasUserSelectedAllRows && entityCountInCurrentView - ? selectedRowIds.length === tableRowIds.length - ? entityCountInCurrentView - : entityCountInCurrentView - - (tableRowIds.length - selectedRowIds.length) // unselected row Ids - : selectedRowIds.length; - - const { setActionBarEntries, setContextMenuEntries } = useRecordActionBar({ - objectMetadataItem, - selectedRecordIds: selectedRowIds, - callback: resetTableRowSelection, - totalNumberOfRecordsSelected: numSelected, - }); - const handleToggleColumnFilter = useHandleToggleColumnFilter({ objectNameSingular, viewBarId, @@ -110,11 +79,6 @@ export const RecordIndexTableContainerEffect = ({ ); }, [setOnToggleColumnSort, handleToggleColumnSort]); - useEffect(() => { - setActionBarEntries?.(); - setContextMenuEntries?.(); - }, [setActionBarEntries, setContextMenuEntries]); - useEffect(() => { setOnEntityCountChange( () => (entityCount: number) => setRecordCountInCurrentView(entityCount), diff --git a/packages/twenty-front/src/modules/object-record/record-table/action-bar/components/RecordTableActionBar.tsx b/packages/twenty-front/src/modules/object-record/record-table/action-bar/components/RecordTableActionBar.tsx deleted file mode 100644 index 0b2c810bc15c..000000000000 --- a/packages/twenty-front/src/modules/object-record/record-table/action-bar/components/RecordTableActionBar.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import { useRecoilValue } from 'recoil'; - -import { useRecordTableStates } from '@/object-record/record-table/hooks/internal/useRecordTableStates'; -import { ActionBar } from '@/ui/navigation/action-bar/components/ActionBar'; -import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; -import { entityCountInCurrentViewComponentState } from '@/views/states/entityCountInCurrentViewComponentState'; - -export const RecordTableActionBar = ({ - recordTableId, -}: { - recordTableId: string; -}) => { - const { - selectedRowIdsSelector, - tableRowIdsState, - hasUserSelectedAllRowsState, - } = useRecordTableStates(recordTableId); - - // TODO: verify this instance id works - const entityCountInCurrentView = useRecoilComponentValueV2( - entityCountInCurrentViewComponentState, - recordTableId, - ); - - const hasUserSelectedAllRows = useRecoilValue(hasUserSelectedAllRowsState); - const tableRowIds = useRecoilValue(tableRowIdsState); - const selectedRowIds = useRecoilValue(selectedRowIdsSelector()); - - const totalNumberOfSelectedRecords = - hasUserSelectedAllRows && entityCountInCurrentView - ? selectedRowIds.length === tableRowIds.length - ? entityCountInCurrentView - : entityCountInCurrentView - - (tableRowIds.length - selectedRowIds.length) // unselected row Ids - : selectedRowIds.length; - - if (!selectedRowIds.length) { - return null; - } - - return ( - - ); -}; diff --git a/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableContextProvider.tsx b/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableContextProvider.tsx index 6d5c5b7e0e75..8e827ae3e4e6 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableContextProvider.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableContextProvider.tsx @@ -12,7 +12,7 @@ import { OpenTableCellArgs, useOpenRecordTableCellV2, } from '@/object-record/record-table/record-table-cell/hooks/useOpenRecordTableCellV2'; -import { useTriggerContextMenu } from '@/object-record/record-table/record-table-cell/hooks/useTriggerContextMenu'; +import { useTriggerActionMenuDropdown } from '@/object-record/record-table/record-table-cell/hooks/useTriggerActionMenuDropdown'; import { useUpsertRecord } from '@/object-record/record-table/record-table-cell/hooks/useUpsertRecord'; import { MoveFocusDirection } from '@/object-record/record-table/types/MoveFocusDirection'; import { TableCellPosition } from '@/object-record/record-table/types/TableCellPosition'; @@ -75,12 +75,15 @@ export const RecordTableContextProvider = ({ moveSoftFocusToCell(cellPosition); }; - const { triggerContextMenu } = useTriggerContextMenu({ + const { triggerActionMenuDropdown } = useTriggerActionMenuDropdown({ recordTableId, }); - const handleContextMenu = (event: React.MouseEvent, recordId: string) => { - triggerContextMenu(event, recordId); + const handleActionMenuDropdown = ( + event: React.MouseEvent, + recordId: string, + ) => { + triggerActionMenuDropdown(event, recordId); }; const { handleContainerMouseEnter } = useHandleContainerMouseEnter({ @@ -99,7 +102,7 @@ export const RecordTableContextProvider = ({ onMoveFocus: handleMoveFocus, onCloseTableCell: handleCloseTableCell, onMoveSoftFocusToCell: handleMoveSoftFocusToCell, - onContextMenu: handleContextMenu, + onActionMenuDropdownOpened: handleActionMenuDropdown, onCellMouseEnter: handleContainerMouseEnter, visibleTableColumns, recordTableId, diff --git a/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableInternalEffect.tsx b/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableInternalEffect.tsx index 8b94a325f36a..0dff4b429dca 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableInternalEffect.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableInternalEffect.tsx @@ -46,7 +46,7 @@ export const RecordTableInternalEffect = ({ useListenClickOutsideByClassName({ classNames: ['entity-table-cell'], - excludeClassNames: ['action-bar', 'context-menu'], + excludeClassNames: ['bottom-bar', 'context-menu'], callback: () => { resetTableRowSelection(); }, diff --git a/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableWithWrappers.tsx b/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableWithWrappers.tsx index 268608e183cc..b7a46e64829d 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableWithWrappers.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableWithWrappers.tsx @@ -81,7 +81,9 @@ export const RecordTableWithWrappers = ({ /> { + resetTableRowSelection(); + }} onDragSelectionChange={setRowSelected} /> diff --git a/packages/twenty-front/src/modules/object-record/record-table/components/__stories__/perf/RecordTableCell.perf.stories.tsx b/packages/twenty-front/src/modules/object-record/record-table/components/__stories__/perf/RecordTableCell.perf.stories.tsx index b866e344842f..b03187ceae68 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/components/__stories__/perf/RecordTableCell.perf.stories.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/components/__stories__/perf/RecordTableCell.perf.stories.tsx @@ -70,7 +70,7 @@ const meta: Meta = { onMoveFocus: () => {}, onCloseTableCell: () => {}, onMoveSoftFocusToCell: () => {}, - onContextMenu: () => {}, + onActionMenuDropdownOpened: () => {}, onCellMouseEnter: () => {}, visibleTableColumns: mockPerformance.visibleTableColumns as any, objectNameSingular: diff --git a/packages/twenty-front/src/modules/object-record/record-table/context-menu/components/RecordTableContextMenu.tsx b/packages/twenty-front/src/modules/object-record/record-table/context-menu/components/RecordTableContextMenu.tsx deleted file mode 100644 index d9f712ef82e4..000000000000 --- a/packages/twenty-front/src/modules/object-record/record-table/context-menu/components/RecordTableContextMenu.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { useRecoilValue } from 'recoil'; - -import { useRecordTableStates } from '@/object-record/record-table/hooks/internal/useRecordTableStates'; -import { ContextMenu } from '@/ui/navigation/context-menu/components/ContextMenu'; - -export const RecordTableContextMenu = ({ - recordTableId, -}: { - recordTableId: string; -}) => { - const { selectedRowIdsSelector } = useRecordTableStates(recordTableId); - - const selectedRowIds = useRecoilValue(selectedRowIdsSelector()); - - if (!selectedRowIds.length) { - return null; - } - - return ; -}; diff --git a/packages/twenty-front/src/modules/object-record/record-table/contexts/RecordTableContext.ts b/packages/twenty-front/src/modules/object-record/record-table/contexts/RecordTableContext.ts index cf8a8afed861..ba2ea9f7a92d 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/contexts/RecordTableContext.ts +++ b/packages/twenty-front/src/modules/object-record/record-table/contexts/RecordTableContext.ts @@ -24,7 +24,10 @@ export type RecordTableContextProps = { onMoveFocus: (direction: MoveFocusDirection) => void; onCloseTableCell: () => void; onMoveSoftFocusToCell: (cellPosition: TableCellPosition) => void; - onContextMenu: (event: React.MouseEvent, recordId: string) => void; + onActionMenuDropdownOpened: ( + event: React.MouseEvent, + recordId: string, + ) => void; onCellMouseEnter: (args: HandleContainerMouseEnterArgs) => void; visibleTableColumns: ColumnDefinition[]; recordTableId: string; diff --git a/packages/twenty-front/src/modules/object-record/record-table/hooks/internal/useResetTableRowSelection.ts b/packages/twenty-front/src/modules/object-record/record-table/hooks/internal/useResetTableRowSelection.ts index 1b6263739111..58779a443da2 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/hooks/internal/useResetTableRowSelection.ts +++ b/packages/twenty-front/src/modules/object-record/record-table/hooks/internal/useResetTableRowSelection.ts @@ -1,7 +1,9 @@ import { useRecoilCallback } from 'recoil'; import { useRecordTableStates } from '@/object-record/record-table/hooks/internal/useRecordTableStates'; +import { isDropdownOpenComponentState } from '@/ui/layout/dropdown/states/isDropdownOpenComponentState'; import { getSnapshotValue } from '@/ui/utilities/recoil-scope/utils/getSnapshotValue'; +import { extractComponentState } from '@/ui/utilities/state/component-state/utils/extractComponentState'; export const useResetTableRowSelection = (recordTableId?: string) => { const { @@ -20,7 +22,19 @@ export const useResetTableRowSelection = (recordTableId?: string) => { } set(hasUserSelectedAllRowsState, false); + + const isActionMenuDropdownOpenState = extractComponentState( + isDropdownOpenComponentState, + `action-menu-dropdown-${recordTableId}`, + ); + + set(isActionMenuDropdownOpenState, false); }, - [tableRowIdsState, isRowSelectedFamilyState, hasUserSelectedAllRowsState], + [ + tableRowIdsState, + hasUserSelectedAllRowsState, + recordTableId, + isRowSelectedFamilyState, + ], ); }; diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellBaseContainer.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellBaseContainer.tsx index 1e977ecaec4e..52ae0772d769 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellBaseContainer.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellBaseContainer.tsx @@ -71,10 +71,10 @@ export const RecordTableCellBaseContainer = ({ } }; - const { onContextMenu } = useContext(RecordTableContext); + const { onActionMenuDropdownOpened } = useContext(RecordTableContext); - const handleContextMenu = (event: React.MouseEvent) => { - onContextMenu(event, recordId); + const handleActionMenuDropdown = (event: React.MouseEvent) => { + onActionMenuDropdownOpened(event, recordId); }; const { hotkeyScope } = useContext(FieldContext); @@ -87,7 +87,7 @@ export const RecordTableCellBaseContainer = ({ onMouseLeave={handleContainerMouseLeave} onMouseMove={handleContainerMouseMove} onClick={handleContainerClick} - onContextMenu={handleContextMenu} + onContextMenu={handleActionMenuDropdown} backgroundColorTransparentSecondary={ theme.background.transparent.secondary } diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellCheckbox.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellCheckbox.tsx index 645e2bc4ab6e..459211537244 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellCheckbox.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellCheckbox.tsx @@ -1,13 +1,12 @@ import styled from '@emotion/styled'; import { useCallback, useContext } from 'react'; -import { useRecoilValue, useSetRecoilState } from 'recoil'; +import { useRecoilValue } from 'recoil'; import { RecordTableRowContext } from '@/object-record/record-table/contexts/RecordTableRowContext'; import { useRecordTableStates } from '@/object-record/record-table/hooks/internal/useRecordTableStates'; import { RecordTableTd } from '@/object-record/record-table/record-table-cell/components/RecordTableTd'; import { useSetCurrentRowSelected } from '@/object-record/record-table/record-table-row/hooks/useSetCurrentRowSelected'; import { Checkbox } from '@/ui/input/components/Checkbox'; -import { actionBarOpenState } from '@/ui/navigation/action-bar/states/actionBarIsOpenState'; const StyledContainer = styled.div` align-items: center; @@ -24,14 +23,12 @@ export const RecordTableCellCheckbox = () => { const { recordId } = useContext(RecordTableRowContext); const { isRowSelectedFamilyState } = useRecordTableStates(); - const setActionBarOpenState = useSetRecoilState(actionBarOpenState); const { setCurrentRowSelected } = useSetCurrentRowSelected(); const currentRowSelected = useRecoilValue(isRowSelectedFamilyState(recordId)); const handleClick = useCallback(() => { setCurrentRowSelected(!currentRowSelected); - setActionBarOpenState(true); - }, [currentRowSelected, setActionBarOpenState, setCurrentRowSelected]); + }, [currentRowSelected, setCurrentRowSelected]); return ( diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/hooks/useTriggerActionMenuDropdown.ts b/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/hooks/useTriggerActionMenuDropdown.ts new file mode 100644 index 000000000000..0870b7bb4d5a --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/hooks/useTriggerActionMenuDropdown.ts @@ -0,0 +1,67 @@ +import { useRecoilCallback } from 'recoil'; + +import { actionMenuDropdownPositionComponentState } from '@/action-menu/states/actionMenuDropdownPositionComponentState'; +import { isRowSelectedComponentFamilyState } from '@/object-record/record-table/record-table-row/states/isRowSelectedComponentFamilyState'; +import { isBottomBarOpenedComponentState } from '@/ui/layout/bottom-bar/states/isBottomBarOpenedComponentState'; +import { isDropdownOpenComponentState } from '@/ui/layout/dropdown/states/isDropdownOpenComponentState'; +import { getScopeIdFromComponentId } from '@/ui/utilities/recoil-scope/utils/getScopeIdFromComponentId'; +import { getSnapshotValue } from '@/ui/utilities/recoil-scope/utils/getSnapshotValue'; +import { extractComponentFamilyState } from '@/ui/utilities/state/component-state/utils/extractComponentFamilyState'; +import { extractComponentState } from '@/ui/utilities/state/component-state/utils/extractComponentState'; + +export const useTriggerActionMenuDropdown = ({ + recordTableId, +}: { + recordTableId: string; +}) => { + const triggerActionMenuDropdown = useRecoilCallback( + ({ set, snapshot }) => + (event: React.MouseEvent, recordId: string) => { + event.preventDefault(); + + const tableScopeId = getScopeIdFromComponentId(recordTableId); + + set( + extractComponentState( + actionMenuDropdownPositionComponentState, + `action-menu-dropdown-${recordTableId}`, + ), + { + x: event.clientX, + y: event.clientY, + }, + ); + + const isRowSelectedFamilyState = extractComponentFamilyState( + isRowSelectedComponentFamilyState, + tableScopeId, + ); + + const isRowSelected = getSnapshotValue( + snapshot, + isRowSelectedFamilyState(recordId), + ); + + if (isRowSelected !== true) { + set(isRowSelectedFamilyState(recordId), true); + } + + const isActionMenuDropdownOpenState = extractComponentState( + isDropdownOpenComponentState, + `action-menu-dropdown-${recordTableId}`, + ); + + const isActionBarOpenState = isBottomBarOpenedComponentState.atomFamily( + { + instanceId: `action-bar-${recordTableId}`, + }, + ); + + set(isActionBarOpenState, false); + set(isActionMenuDropdownOpenState, true); + }, + [recordTableId], + ); + + return { triggerActionMenuDropdown }; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/hooks/useTriggerContextMenu.ts b/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/hooks/useTriggerContextMenu.ts deleted file mode 100644 index b9c04a8b7769..000000000000 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/hooks/useTriggerContextMenu.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { useRecoilCallback } from 'recoil'; - -import { isRowSelectedComponentFamilyState } from '@/object-record/record-table/record-table-row/states/isRowSelectedComponentFamilyState'; -import { contextMenuIsOpenState } from '@/ui/navigation/context-menu/states/contextMenuIsOpenState'; -import { contextMenuPositionState } from '@/ui/navigation/context-menu/states/contextMenuPositionState'; -import { getScopeIdFromComponentId } from '@/ui/utilities/recoil-scope/utils/getScopeIdFromComponentId'; -import { getSnapshotValue } from '@/ui/utilities/recoil-scope/utils/getSnapshotValue'; -import { extractComponentFamilyState } from '@/ui/utilities/state/component-state/utils/extractComponentFamilyState'; - -export const useTriggerContextMenu = ({ - recordTableId, -}: { - recordTableId: string; -}) => { - const triggerContextMenu = useRecoilCallback( - ({ set, snapshot }) => - (event: React.MouseEvent, recordId: string) => { - event.preventDefault(); - - const tableScopeId = getScopeIdFromComponentId(recordTableId); - - set(contextMenuPositionState, { - x: event.clientX, - y: event.clientY, - }); - set(contextMenuIsOpenState, true); - - const isRowSelectedFamilyState = extractComponentFamilyState( - isRowSelectedComponentFamilyState, - tableScopeId, - ); - - const isRowSelected = getSnapshotValue( - snapshot, - isRowSelectedFamilyState(recordId), - ); - - if (isRowSelected !== true) { - set(isRowSelectedFamilyState(recordId), true); - } - }, - [recordTableId], - ); - - return { triggerContextMenu }; -}; diff --git a/packages/twenty-front/src/modules/sign-in-background-mock/components/SignInBackgroundMockContainerEffect.tsx b/packages/twenty-front/src/modules/sign-in-background-mock/components/SignInBackgroundMockContainerEffect.tsx index f7fdf0218239..2bad3fa95ffc 100644 --- a/packages/twenty-front/src/modules/sign-in-background-mock/components/SignInBackgroundMockContainerEffect.tsx +++ b/packages/twenty-front/src/modules/sign-in-background-mock/components/SignInBackgroundMockContainerEffect.tsx @@ -2,7 +2,6 @@ import { useEffect } from 'react'; import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; import { useObjectNameSingularFromPlural } from '@/object-metadata/hooks/useObjectNameSingularFromPlural'; -import { useRecordActionBar } from '@/object-record/record-action-bar/hooks/useRecordActionBar'; import { useRecordTable } from '@/object-record/record-table/hooks/useRecordTable'; import { SIGN_IN_BACKGROUND_MOCK_COLUMN_DEFINITIONS } from '@/sign-in-background-mock/constants/SignInBackgroundMockColumnDefinitions'; import { SIGN_IN_BACKGROUND_MOCK_FILTER_DEFINITIONS } from '@/sign-in-background-mock/constants/SignInBackgroundMockFilterDefinitions'; @@ -23,14 +22,10 @@ export const SignInBackgroundMockContainerEffect = ({ recordTableId, viewId, }: SignInBackgroundMockContainerEffectProps) => { - const { - setAvailableTableColumns, - setOnEntityCountChange, - setTableColumns, - resetTableRowSelection, - } = useRecordTable({ - recordTableId, - }); + const { setAvailableTableColumns, setOnEntityCountChange, setTableColumns } = + useRecordTable({ + recordTableId, + }); const { objectNameSingular } = useObjectNameSingularFromPlural({ objectNamePlural, @@ -75,17 +70,6 @@ export const SignInBackgroundMockContainerEffect = ({ setTableColumns, ]); - const { setActionBarEntries, setContextMenuEntries } = useRecordActionBar({ - objectMetadataItem, - selectedRecordIds: [], - callback: resetTableRowSelection, - }); - - useEffect(() => { - setActionBarEntries?.(); - setContextMenuEntries?.(); - }, [setActionBarEntries, setContextMenuEntries]); - useEffect(() => { setOnEntityCountChange( () => (entityCount: number) => setRecordCountInCurrentView(entityCount), diff --git a/packages/twenty-front/src/modules/sign-in-background-mock/components/SignInBackgroundMockPage.tsx b/packages/twenty-front/src/modules/sign-in-background-mock/components/SignInBackgroundMockPage.tsx index 6ce3a80a5d4a..2b17a655549d 100644 --- a/packages/twenty-front/src/modules/sign-in-background-mock/components/SignInBackgroundMockPage.tsx +++ b/packages/twenty-front/src/modules/sign-in-background-mock/components/SignInBackgroundMockPage.tsx @@ -2,8 +2,6 @@ import styled from '@emotion/styled'; import { IconBuildingSkyscraper } from 'twenty-ui'; import { RecordFieldValueSelectorContextProvider } from '@/object-record/record-store/contexts/RecordFieldValueSelectorContext'; -import { RecordTableActionBar } from '@/object-record/record-table/action-bar/components/RecordTableActionBar'; -import { RecordTableContextMenu } from '@/object-record/record-table/context-menu/components/RecordTableContextMenu'; import { SignInBackgroundMockContainer } from '@/sign-in-background-mock/components/SignInBackgroundMockContainer'; import { PageAddButton } from '@/ui/layout/page/PageAddButton'; import { PageBody } from '@/ui/layout/page/PageBody'; @@ -29,8 +27,6 @@ export const SignInBackgroundMockPage = () => { - - diff --git a/packages/twenty-front/src/modules/ui/layout/bottom-bar/components/BottomBar.tsx b/packages/twenty-front/src/modules/ui/layout/bottom-bar/components/BottomBar.tsx new file mode 100644 index 000000000000..46ccff887a7a --- /dev/null +++ b/packages/twenty-front/src/modules/ui/layout/bottom-bar/components/BottomBar.tsx @@ -0,0 +1,61 @@ +import styled from '@emotion/styled'; + +import { useBottomBarInternalHotkeyScopeManagement } from '@/ui/layout/bottom-bar/hooks/useBottomBarInternalHotkeyScopeManagement'; +import { BottomBarInstanceContext } from '@/ui/layout/bottom-bar/states/contexts/BottomBarInstanceContext'; +import { isBottomBarOpenedComponentState } from '@/ui/layout/bottom-bar/states/isBottomBarOpenedComponentState'; +import { HotkeyScope } from '@/ui/utilities/hotkey/types/HotkeyScope'; +import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; + +const StyledContainerActionBar = styled.div` + align-items: center; + background: ${({ theme }) => theme.background.secondary}; + border: 1px solid ${({ theme }) => theme.border.color.medium}; + border-radius: ${({ theme }) => theme.border.radius.md}; + bottom: 38px; + box-shadow: ${({ theme }) => theme.boxShadow.strong}; + display: flex; + height: 48px; + width: max-content; + left: 50%; + padding-left: ${({ theme }) => theme.spacing(2)}; + padding-right: ${({ theme }) => theme.spacing(2)}; + position: absolute; + top: auto; + + transform: translateX(-50%); + z-index: 1; +`; + +type BottomBarProps = { + bottomBarId: string; + bottomBarHotkeyScopeFromParent: HotkeyScope; + children: React.ReactNode; +}; + +export const BottomBar = ({ + bottomBarId, + bottomBarHotkeyScopeFromParent, + children, +}: BottomBarProps) => { + const isBottomBarOpen = useRecoilComponentValueV2( + isBottomBarOpenedComponentState, + bottomBarId, + ); + + useBottomBarInternalHotkeyScopeManagement({ + bottomBarId, + bottomBarHotkeyScopeFromParent, + }); + + if (!isBottomBarOpen) { + return null; + } + + return ( + + + {children} + + + ); +}; diff --git a/packages/twenty-front/src/modules/ui/layout/bottom-bar/components/__stories__/BottomBar.stories.tsx b/packages/twenty-front/src/modules/ui/layout/bottom-bar/components/__stories__/BottomBar.stories.tsx new file mode 100644 index 000000000000..8562f67cdce3 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/layout/bottom-bar/components/__stories__/BottomBar.stories.tsx @@ -0,0 +1,65 @@ +import { Meta, StoryObj } from '@storybook/react'; +import { IconPlus } from 'twenty-ui'; + +import { Button } from '@/ui/input/button/components/Button'; +import { BottomBar } from '@/ui/layout/bottom-bar/components/BottomBar'; +import { isBottomBarOpenedComponentState } from '@/ui/layout/bottom-bar/states/isBottomBarOpenedComponentState'; +import styled from '@emotion/styled'; +import { RecoilRoot } from 'recoil'; + +const StyledContainer = styled.div` + display: flex; + gap: 10px; +`; + +const meta: Meta = { + title: 'UI/Layout/BottomBar/BottomBar', + component: BottomBar, + args: { + bottomBarId: 'test', + bottomBarHotkeyScopeFromParent: { scope: 'test' }, + children: ( + +