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 new file mode 100644 index 0000000000..89243ead97 --- /dev/null +++ b/packages/twenty-front/src/modules/action-menu/actions/record-actions/components/DeleteRecordsActionEffect.tsx @@ -0,0 +1,94 @@ +import { useActionMenuEntries } from '@/action-menu/hooks/useActionMenuEntries'; +import { contextStoreCurrentObjectMetadataIdState } from '@/context-store/states/contextStoreCurrentObjectMetadataIdState'; +import { contextStoreTargetedRecordIdsState } from '@/context-store/states/contextStoreTargetedRecordIdsState'; +import { useObjectMetadataItemById } from '@/object-metadata/hooks/useObjectMetadataItemById'; +import { DELETE_MAX_COUNT } from '@/object-record/constants/DeleteMaxCount'; +import { useDeleteTableData } from '@/object-record/record-index/options/hooks/useDeleteTableData'; +import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal'; +import { useCallback, useEffect, useState } from 'react'; +import { useRecoilValue } from 'recoil'; +import { IconTrash } from 'twenty-ui'; + +export const DeleteRecordsActionEffect = ({ + position, +}: { + position: number; +}) => { + const { addActionMenuEntry, removeActionMenuEntry } = useActionMenuEntries(); + + const contextStoreTargetedRecordIds = useRecoilValue( + contextStoreTargetedRecordIdsState, + ); + + const contextStoreCurrentObjectMetadataId = useRecoilValue( + contextStoreCurrentObjectMetadataIdState, + ); + + const { objectMetadataItem } = useObjectMetadataItemById({ + objectId: contextStoreCurrentObjectMetadataId, + }); + + const [isDeleteRecordsModalOpen, setIsDeleteRecordsModalOpen] = + useState(false); + + const { deleteTableData } = useDeleteTableData({ + objectNameSingular: objectMetadataItem?.nameSingular ?? '', + recordIndexId: objectMetadataItem?.namePlural ?? '', + }); + + const handleDeleteClick = useCallback(() => { + deleteTableData(contextStoreTargetedRecordIds); + }, [deleteTableData, contextStoreTargetedRecordIds]); + + const isRemoteObject = objectMetadataItem?.isRemote ?? false; + + const numberOfSelectedRecords = contextStoreTargetedRecordIds.length; + + const canDelete = + !isRemoteObject && numberOfSelectedRecords < DELETE_MAX_COUNT; + + useEffect(() => { + if (canDelete) { + addActionMenuEntry({ + key: 'delete', + label: 'Delete', + position, + Icon: IconTrash, + accent: 'danger', + onClick: () => { + setIsDeleteRecordsModalOpen(true); + }, + ConfirmationModal: ( + handleDeleteClick()} + deleteButtonText={`Delete ${ + numberOfSelectedRecords > 1 ? 'Records' : 'Record' + }`} + /> + ), + }); + } else { + removeActionMenuEntry('delete'); + } + }, [ + canDelete, + addActionMenuEntry, + removeActionMenuEntry, + isDeleteRecordsModalOpen, + numberOfSelectedRecords, + handleDeleteClick, + position, + ]); + + return null; +}; 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 new file mode 100644 index 0000000000..d7b50ddaf0 --- /dev/null +++ b/packages/twenty-front/src/modules/action-menu/actions/record-actions/components/ExportRecordsActionEffect.tsx @@ -0,0 +1,53 @@ +import { useActionMenuEntries } from '@/action-menu/hooks/useActionMenuEntries'; +import { contextStoreCurrentObjectMetadataIdState } from '@/context-store/states/contextStoreCurrentObjectMetadataIdState'; +import { useObjectMetadataItemById } from '@/object-metadata/hooks/useObjectMetadataItemById'; +import { + displayedExportProgress, + useExportTableData, +} from '@/object-record/record-index/options/hooks/useExportTableData'; +import { useEffect } from 'react'; +import { useRecoilValue } from 'recoil'; +import { IconFileExport } from 'twenty-ui'; + +export const ExportRecordsActionEffect = ({ + position, +}: { + position: number; +}) => { + const { addActionMenuEntry, removeActionMenuEntry } = useActionMenuEntries(); + + const contextStoreCurrentObjectMetadataId = useRecoilValue( + contextStoreCurrentObjectMetadataIdState, + ); + + const { objectMetadataItem } = useObjectMetadataItemById({ + objectId: contextStoreCurrentObjectMetadataId, + }); + + const baseTableDataParams = { + delayMs: 100, + objectNameSingular: objectMetadataItem?.nameSingular ?? '', + recordIndexId: objectMetadataItem?.namePlural ?? '', + }; + + const { progress, download } = useExportTableData({ + ...baseTableDataParams, + filename: `${objectMetadataItem?.nameSingular}.csv`, + }); + + useEffect(() => { + addActionMenuEntry({ + key: 'export', + position, + label: displayedExportProgress(progress), + Icon: IconFileExport, + accent: 'default', + onClick: () => download(), + }); + + return () => { + removeActionMenuEntry('export'); + }; + }, [download, progress, addActionMenuEntry, removeActionMenuEntry, position]); + return null; +}; 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 new file mode 100644 index 0000000000..e9767b0342 --- /dev/null +++ b/packages/twenty-front/src/modules/action-menu/actions/record-actions/components/ManageFavoritesActionEffect.tsx @@ -0,0 +1,78 @@ +import { useActionMenuEntries } from '@/action-menu/hooks/useActionMenuEntries'; +import { contextStoreCurrentObjectMetadataIdState } from '@/context-store/states/contextStoreCurrentObjectMetadataIdState'; +import { contextStoreTargetedRecordIdsState } from '@/context-store/states/contextStoreTargetedRecordIdsState'; +import { useFavorites } from '@/favorites/hooks/useFavorites'; +import { useObjectMetadataItemById } from '@/object-metadata/hooks/useObjectMetadataItemById'; +import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState'; +import { useEffect } from 'react'; +import { useRecoilValue } from 'recoil'; +import { IconHeart, IconHeartOff, isDefined } from 'twenty-ui'; + +export const ManageFavoritesActionEffect = ({ + position, +}: { + position: number; +}) => { + const { addActionMenuEntry, removeActionMenuEntry } = useActionMenuEntries(); + + const contextStoreTargetedRecordIds = useRecoilValue( + contextStoreTargetedRecordIdsState, + ); + const contextStoreCurrentObjectMetadataId = useRecoilValue( + contextStoreCurrentObjectMetadataIdState, + ); + + const { favorites, createFavorite, deleteFavorite } = useFavorites(); + + const selectedRecordId = contextStoreTargetedRecordIds[0]; + + const selectedRecord = useRecoilValue( + recordStoreFamilyState(selectedRecordId), + ); + + const { objectMetadataItem } = useObjectMetadataItemById({ + objectId: contextStoreCurrentObjectMetadataId, + }); + + const foundFavorite = favorites?.find( + (favorite) => favorite.recordId === selectedRecordId, + ); + + const isFavorite = !!selectedRecordId && !!foundFavorite; + + useEffect(() => { + if (!isDefined(objectMetadataItem) || objectMetadataItem.isRemote) { + return; + } + + addActionMenuEntry({ + key: 'manage-favorites', + label: isFavorite ? 'Remove from favorites' : 'Add to favorites', + position, + Icon: isFavorite ? IconHeartOff : IconHeart, + onClick: () => { + if (isFavorite && isDefined(foundFavorite?.id)) { + deleteFavorite(foundFavorite.id); + } else if (isDefined(selectedRecord)) { + createFavorite(selectedRecord, objectMetadataItem.nameSingular); + } + }, + }); + + return () => { + removeActionMenuEntry('manage-favorites'); + }; + }, [ + addActionMenuEntry, + createFavorite, + deleteFavorite, + foundFavorite?.id, + isFavorite, + objectMetadataItem, + position, + removeActionMenuEntry, + selectedRecord, + ]); + + return null; +}; diff --git a/packages/twenty-front/src/modules/action-menu/actions/record-actions/components/MultipleRecordsActionMenuEntriesSetter.tsx b/packages/twenty-front/src/modules/action-menu/actions/record-actions/components/MultipleRecordsActionMenuEntriesSetter.tsx new file mode 100644 index 0000000000..69bfd33050 --- /dev/null +++ b/packages/twenty-front/src/modules/action-menu/actions/record-actions/components/MultipleRecordsActionMenuEntriesSetter.tsx @@ -0,0 +1,14 @@ +import { DeleteRecordsActionEffect } from '@/action-menu/actions/record-actions/components/DeleteRecordsActionEffect'; +import { ExportRecordsActionEffect } from '@/action-menu/actions/record-actions/components/ExportRecordsActionEffect'; + +const actionEffects = [ExportRecordsActionEffect, DeleteRecordsActionEffect]; + +export const MultipleRecordsActionMenuEntriesSetter = () => { + return ( + <> + {actionEffects.map((ActionEffect, index) => ( + + ))} + + ); +}; 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 new file mode 100644 index 0000000000..75267e445d --- /dev/null +++ b/packages/twenty-front/src/modules/action-menu/actions/record-actions/components/RecordActionMenuEntriesSetter.tsx @@ -0,0 +1,20 @@ +import { MultipleRecordsActionMenuEntriesSetter } from '@/action-menu/actions/record-actions/components/MultipleRecordsActionMenuEntriesSetter'; +import { SingleRecordActionMenuEntriesSetter } from '@/action-menu/actions/record-actions/components/SingleRecordActionMenuEntriesSetter'; +import { contextStoreTargetedRecordIdsState } from '@/context-store/states/contextStoreTargetedRecordIdsState'; +import { useRecoilValue } from 'recoil'; + +export const RecordActionMenuEntriesSetter = () => { + const contextStoreTargetedRecordIds = useRecoilValue( + contextStoreTargetedRecordIdsState, + ); + + if (contextStoreTargetedRecordIds.length === 0) { + return null; + } + + if (contextStoreTargetedRecordIds.length === 1) { + return ; + } + + return ; +}; diff --git a/packages/twenty-front/src/modules/action-menu/actions/record-actions/components/SingleRecordActionMenuEntriesSetter.tsx b/packages/twenty-front/src/modules/action-menu/actions/record-actions/components/SingleRecordActionMenuEntriesSetter.tsx new file mode 100644 index 0000000000..feeba5aabc --- /dev/null +++ b/packages/twenty-front/src/modules/action-menu/actions/record-actions/components/SingleRecordActionMenuEntriesSetter.tsx @@ -0,0 +1,18 @@ +import { DeleteRecordsActionEffect } from '@/action-menu/actions/record-actions/components/DeleteRecordsActionEffect'; +import { ExportRecordsActionEffect } from '@/action-menu/actions/record-actions/components/ExportRecordsActionEffect'; +import { ManageFavoritesActionEffect } from '@/action-menu/actions/record-actions/components/ManageFavoritesActionEffect'; + +export const SingleRecordActionMenuEntriesSetter = () => { + const actionEffects = [ + ExportRecordsActionEffect, + DeleteRecordsActionEffect, + ManageFavoritesActionEffect, + ]; + return ( + <> + {actionEffects.map((ActionEffect, index) => ( + + ))} + + ); +}; diff --git a/packages/twenty-front/src/modules/action-menu/components/ActionMenuBar.tsx b/packages/twenty-front/src/modules/action-menu/components/ActionMenuBar.tsx index d34b6c83fc..2586833479 100644 --- a/packages/twenty-front/src/modules/action-menu/components/ActionMenuBar.tsx +++ b/packages/twenty-front/src/modules/action-menu/components/ActionMenuBar.tsx @@ -1,7 +1,7 @@ import styled from '@emotion/styled'; import { ActionMenuBarEntry } from '@/action-menu/components/ActionMenuBarEntry'; -import { actionMenuEntriesComponentState } from '@/action-menu/states/actionMenuEntriesComponentState'; +import { actionMenuEntriesComponentSelector } from '@/action-menu/states/actionMenuEntriesComponentSelector'; import { ActionMenuComponentInstanceContext } from '@/action-menu/states/contexts/ActionMenuComponentInstanceContext'; import { ActionBarHotkeyScope } from '@/action-menu/types/ActionBarHotKeyScope'; import { contextStoreTargetedRecordIdsState } from '@/context-store/states/contextStoreTargetedRecordIdsState'; @@ -28,9 +28,13 @@ export const ActionMenuBar = () => { ); const actionMenuEntries = useRecoilComponentValueV2( - actionMenuEntriesComponentState, + actionMenuEntriesComponentSelector, ); + if (actionMenuEntries.length === 0) { + return null; + } + return ( { const actionMenuEntries = useRecoilComponentValueV2( - actionMenuEntriesComponentState, + actionMenuEntriesComponentSelector, ); return ( diff --git a/packages/twenty-front/src/modules/action-menu/components/ActionMenuDropdown.tsx b/packages/twenty-front/src/modules/action-menu/components/ActionMenuDropdown.tsx index b443c4307c..18ebdac766 100644 --- a/packages/twenty-front/src/modules/action-menu/components/ActionMenuDropdown.tsx +++ b/packages/twenty-front/src/modules/action-menu/components/ActionMenuDropdown.tsx @@ -4,7 +4,7 @@ import { useRecoilValue } from 'recoil'; import { PositionType } from '../types/PositionType'; import { actionMenuDropdownPositionComponentState } from '@/action-menu/states/actionMenuDropdownPositionComponentState'; -import { actionMenuEntriesComponentState } from '@/action-menu/states/actionMenuEntriesComponentState'; +import { actionMenuEntriesComponentSelector } from '@/action-menu/states/actionMenuEntriesComponentSelector'; import { ActionMenuComponentInstanceContext } from '@/action-menu/states/contexts/ActionMenuComponentInstanceContext'; import { ActionMenuDropdownHotkeyScope } from '@/action-menu/types/ActionMenuDropdownHotKeyScope'; import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown'; @@ -36,7 +36,7 @@ const StyledContainerActionMenuDropdown = styled.div` export const ActionMenuDropdown = () => { const actionMenuEntries = useRecoilComponentValueV2( - actionMenuEntriesComponentState, + actionMenuEntriesComponentSelector, ); const actionMenuId = useAvailableComponentInstanceIdOrThrow( @@ -50,6 +50,10 @@ export const ActionMenuDropdown = () => { ), ); + if (actionMenuEntries.length === 0) { + return null; + } + //TODO: remove this const width = actionMenuEntries.some( (actionMenuEntry) => actionMenuEntry.label === 'Remove from favorites', diff --git a/packages/twenty-front/src/modules/action-menu/components/ActionMenuEntriesProvider.tsx b/packages/twenty-front/src/modules/action-menu/components/ActionMenuEntriesProvider.tsx deleted file mode 100644 index 3a340feb85..0000000000 --- a/packages/twenty-front/src/modules/action-menu/components/ActionMenuEntriesProvider.tsx +++ /dev/null @@ -1,25 +0,0 @@ -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 deleted file mode 100644 index aca1e5fdae..0000000000 --- a/packages/twenty-front/src/modules/action-menu/components/EmptyActionMenuEntriesEffect.tsx +++ /dev/null @@ -1,14 +0,0 @@ -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 deleted file mode 100644 index 210db62500..0000000000 --- a/packages/twenty-front/src/modules/action-menu/components/NonEmptyActionMenuEntriesEffect.tsx +++ /dev/null @@ -1,28 +0,0 @@ -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 index 907d6caf3f..34d709d168 100644 --- 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 @@ -25,18 +25,28 @@ const meta: Meta = { actionMenuEntriesComponentState.atomFamily({ instanceId: 'story-action-menu', }), - [ - { - label: 'Delete', - Icon: IconTrash, - onClick: deleteMock, - }, - { - label: 'Mark as done', - Icon: IconCheckbox, - onClick: markAsDoneMock, - }, - ], + new Map([ + [ + 'delete', + { + key: 'delete', + label: 'Delete', + position: 0, + Icon: IconTrash, + onClick: deleteMock, + }, + ], + [ + 'markAsDone', + { + key: 'markAsDone', + label: 'Mark as done', + position: 1, + Icon: IconCheckbox, + onClick: markAsDoneMock, + }, + ], + ]), ); set( isBottomBarOpenedComponentState.atomFamily({ 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 index a84e033312..a9c7b26b84 100644 --- 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 @@ -21,7 +21,9 @@ const markAsDoneMock = jest.fn(); export const Default: Story = { args: { entry: { + key: 'delete', label: 'Delete', + position: 0, Icon: IconTrash, onClick: deleteMock, }, @@ -31,7 +33,9 @@ export const Default: Story = { export const WithDangerAccent: Story = { args: { entry: { + key: 'delete', label: 'Delete', + position: 0, Icon: IconTrash, onClick: deleteMock, accent: 'danger', @@ -42,7 +46,9 @@ export const WithDangerAccent: Story = { export const WithInteraction: Story = { args: { entry: { + key: 'markAsDone', label: 'Mark as done', + position: 0, Icon: IconCheckbox, onClick: markAsDoneMock, }, 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 index 4848f44eee..53a0714cee 100644 --- 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 @@ -33,23 +33,38 @@ const meta: Meta = { 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, - }, - ], + new Map([ + [ + 'delete', + { + key: 'delete', + label: 'Delete', + position: 0, + Icon: IconTrash, + onClick: deleteMock, + }, + ], + [ + 'markAsDone', + { + key: 'markAsDone', + label: 'Mark as done', + position: 1, + Icon: IconCheckbox, + onClick: markAsDoneMock, + }, + ], + [ + 'addToFavorites', + { + key: 'addToFavorites', + label: 'Add to favorites', + position: 2, + Icon: IconHeart, + onClick: addToFavoritesMock, + }, + ], + ]), ); set( extractComponentState( diff --git a/packages/twenty-front/src/modules/action-menu/hooks/useActionMenuEntries.ts b/packages/twenty-front/src/modules/action-menu/hooks/useActionMenuEntries.ts new file mode 100644 index 0000000000..5f375eca17 --- /dev/null +++ b/packages/twenty-front/src/modules/action-menu/hooks/useActionMenuEntries.ts @@ -0,0 +1,28 @@ +import { actionMenuEntriesComponentState } from '@/action-menu/states/actionMenuEntriesComponentState'; +import { ActionMenuEntry } from '@/action-menu/types/ActionMenuEntry'; +import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2'; + +export const useActionMenuEntries = () => { + const setActionMenuEntries = useSetRecoilComponentStateV2( + actionMenuEntriesComponentState, + ); + + const addActionMenuEntry = (entry: ActionMenuEntry) => { + setActionMenuEntries( + (prevEntries) => new Map([...prevEntries, [entry.key, entry]]), + ); + }; + + const removeActionMenuEntry = (key: string) => { + setActionMenuEntries((prevEntries) => { + const newMap = new Map(prevEntries); + newMap.delete(key); + return newMap; + }); + }; + + return { + addActionMenuEntry, + removeActionMenuEntry, + }; +}; diff --git a/packages/twenty-front/src/modules/action-menu/hooks/useComputeActionsBasedOnContextStore.tsx b/packages/twenty-front/src/modules/action-menu/hooks/useComputeActionsBasedOnContextStore.tsx deleted file mode 100644 index 899908fb2a..0000000000 --- a/packages/twenty-front/src/modules/action-menu/hooks/useComputeActionsBasedOnContextStore.tsx +++ /dev/null @@ -1,150 +0,0 @@ -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 { isObjectMetadataReadOnly } from '@/object-metadata/utils/isObjectMetadataReadOnly'; -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 isRemote = objectMetadataItem.isRemote; - - const numberOfSelectedRecords = contextStoreTargetedRecordIds.length; - - const canDelete = - !isObjectMetadataReadOnly(objectMetadataItem) && - 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, - ...(!isRemote && isFavorite && hasOnlyOneRecordSelected - ? [ - { - label: 'Remove from favorites', - Icon: IconHeartOff, - onClick: handleFavoriteButtonClick, - }, - ] - : []), - ...(!isRemote && !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 deleted file mode 100644 index b965fe61f2..0000000000 --- a/packages/twenty-front/src/modules/action-menu/hooks/useHandleFavoriteButton.ts +++ /dev/null @@ -1,49 +0,0 @@ -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/actionMenuEntriesComponentSelector.ts b/packages/twenty-front/src/modules/action-menu/states/actionMenuEntriesComponentSelector.ts new file mode 100644 index 0000000000..921f97b38f --- /dev/null +++ b/packages/twenty-front/src/modules/action-menu/states/actionMenuEntriesComponentSelector.ts @@ -0,0 +1,22 @@ +import { actionMenuEntriesComponentState } from '@/action-menu/states/actionMenuEntriesComponentState'; +import { ActionMenuComponentInstanceContext } from '@/action-menu/states/contexts/ActionMenuComponentInstanceContext'; +import { ActionMenuEntry } from '@/action-menu/types/ActionMenuEntry'; +import { createComponentSelectorV2 } from '@/ui/utilities/state/component-state/utils/createComponentSelectorV2'; +import { isDefined } from 'twenty-ui'; + +export const actionMenuEntriesComponentSelector = createComponentSelectorV2< + ActionMenuEntry[] +>({ + key: 'actionMenuEntriesComponentSelector', + instanceContext: ActionMenuComponentInstanceContext, + get: + ({ instanceId }) => + ({ get }) => + Array.from( + get( + actionMenuEntriesComponentState.atomFamily({ instanceId }), + ).values(), + ) + .filter(isDefined) + .sort((a, b) => a.position - b.position), +}); diff --git a/packages/twenty-front/src/modules/action-menu/states/actionMenuEntriesComponentState.ts b/packages/twenty-front/src/modules/action-menu/states/actionMenuEntriesComponentState.ts index 8ba3a259da..ebf9c2cede 100644 --- a/packages/twenty-front/src/modules/action-menu/states/actionMenuEntriesComponentState.ts +++ b/packages/twenty-front/src/modules/action-menu/states/actionMenuEntriesComponentState.ts @@ -3,9 +3,9 @@ import { createComponentStateV2 } from '@/ui/utilities/state/component-state/uti import { ActionMenuEntry } from '../types/ActionMenuEntry'; export const actionMenuEntriesComponentState = createComponentStateV2< - ActionMenuEntry[] + Map >({ key: 'actionMenuEntriesComponentState', - defaultValue: [], + defaultValue: new Map(), componentInstanceContext: ActionMenuComponentInstanceContext, }); 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 d363a8fcd2..4fe1809552 100644 --- a/packages/twenty-front/src/modules/action-menu/types/ActionMenuEntry.ts +++ b/packages/twenty-front/src/modules/action-menu/types/ActionMenuEntry.ts @@ -4,7 +4,9 @@ import { IconComponent } from 'twenty-ui'; import { MenuItemAccent } from '@/ui/navigation/menu-item/types/MenuItemAccent'; export type ActionMenuEntry = { + key: string; label: string; + position: number; Icon: IconComponent; accent?: MenuItemAccent; onClick?: (event?: MouseEvent) => void; 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 index c2fe8825aa..ceff2e4554 100644 --- 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 @@ -1,6 +1,5 @@ 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'; @@ -31,14 +30,19 @@ describe('useObjectMetadataItemById', () => { const { objectMetadataItem } = result.current; - expect(objectMetadataItem.id).toBe(opportunityObjectMetadata.id); + expect(objectMetadataItem?.id).toBe(opportunityObjectMetadata.id); }); - it('should throw an error when invalid ID is provided', async () => { - expect(() => - renderHook(() => useObjectMetadataItemById({ objectId: 'invalid-id' }), { + it('should return null when invalid ID is provided', async () => { + const { result } = renderHook( + () => useObjectMetadataItemById({ objectId: 'invalid-id' }), + { wrapper: Wrapper, - }), - ).toThrow(ObjectMetadataItemNotFoundError); + }, + ); + + const { objectMetadataItem } = result.current; + + expect(objectMetadataItem).toBeNull(); }); }); diff --git a/packages/twenty-front/src/modules/object-metadata/hooks/useObjectMetadataItemById.ts b/packages/twenty-front/src/modules/object-metadata/hooks/useObjectMetadataItemById.ts index 6d9730f2d6..72c5593642 100644 --- a/packages/twenty-front/src/modules/object-metadata/hooks/useObjectMetadataItemById.ts +++ b/packages/twenty-front/src/modules/object-metadata/hooks/useObjectMetadataItemById.ts @@ -1,13 +1,12 @@ 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; + objectId: string | null; }) => { const objectMetadataItems = useRecoilValue(objectMetadataItemsState); @@ -16,7 +15,9 @@ export const useObjectMetadataItemById = ({ ); if (!isDefined(objectMetadataItem)) { - throw new ObjectMetadataItemNotFoundError(objectId, objectMetadataItems); + return { + objectMetadataItem: null, + }; } return { 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 cb0d934656..50ce4ed693 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 @@ -24,11 +24,11 @@ import { RecordFieldValueSelectorContextProvider } from '@/object-record/record- import { useRecordTable } from '@/object-record/record-table/hooks/useRecordTable'; import { SpreadsheetImportProvider } from '@/spreadsheet-import/provider/components/SpreadsheetImportProvider'; +import { RecordActionMenuEntriesSetter } from '@/action-menu/actions/record-actions/components/RecordActionMenuEntriesSetter'; 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'; @@ -202,7 +202,7 @@ export const RecordIndexContainer = () => { value={{ instanceId: recordIndexId }} > - + diff --git a/packages/twenty-front/src/modules/object-record/record-index/options/hooks/useDeleteTableData.ts b/packages/twenty-front/src/modules/object-record/record-index/options/hooks/useDeleteTableData.ts index 2c8e8f6d52..8ce580dad6 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/options/hooks/useDeleteTableData.ts +++ b/packages/twenty-front/src/modules/object-record/record-index/options/hooks/useDeleteTableData.ts @@ -7,7 +7,10 @@ import { tableRowIdsComponentState } from '@/object-record/record-table/states/t import { getScopeIdFromComponentId } from '@/ui/utilities/recoil-scope/utils/getScopeIdFromComponentId'; import { useRecoilValue } from 'recoil'; -type UseDeleteTableDataOptions = Omit; +type UseDeleteTableDataOptions = Pick< + UseTableDataOptions, + 'objectNameSingular' | 'recordIndexId' +>; export const useDeleteTableData = ({ objectNameSingular, diff --git a/packages/twenty-front/src/modules/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2.ts b/packages/twenty-front/src/modules/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2.ts index a76b91c2eb..588cca873c 100644 --- a/packages/twenty-front/src/modules/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2.ts +++ b/packages/twenty-front/src/modules/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2.ts @@ -1,10 +1,15 @@ import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow'; +import { ComponentReadOnlySelectorV2 } from '@/ui/utilities/state/component-state/types/ComponentReadOnlySelectorV2'; +import { ComponentSelectorV2 } from '@/ui/utilities/state/component-state/types/ComponentSelectorV2'; import { ComponentStateV2 } from '@/ui/utilities/state/component-state/types/ComponentStateV2'; import { globalComponentInstanceContextMap } from '@/ui/utilities/state/component-state/utils/globalComponentInstanceContextMap'; -import { useRecoilValue } from 'recoil'; +import { RecoilState, RecoilValueReadOnly, useRecoilValue } from 'recoil'; export const useRecoilComponentValueV2 = ( - componentStateV2: ComponentStateV2, + componentStateV2: + | ComponentStateV2 + | ComponentSelectorV2 + | ComponentReadOnlySelectorV2, instanceIdFromProps?: string, ) => { const instanceContext = globalComponentInstanceContextMap.get( @@ -22,5 +27,18 @@ export const useRecoilComponentValueV2 = ( instanceIdFromProps, ); - return useRecoilValue(componentStateV2.atomFamily({ instanceId })); + let state: RecoilState | RecoilValueReadOnly; + + if (componentStateV2.type === 'ComponentState') { + state = componentStateV2.atomFamily({ instanceId }); + } else if ( + componentStateV2.type === 'ComponentSelector' || + componentStateV2.type === 'ComponentReadOnlySelector' + ) { + state = componentStateV2.selectorFamily({ instanceId }); + } else { + throw new Error('Invalid component state type'); + } + + return useRecoilValue(state); };