From da3479a4f4e3e1124552b0b7541caf3febee17ec Mon Sep 17 00:00:00 2001 From: nitin <142569587+ehconitin@users.noreply.github.com> Date: Wed, 20 Aug 2025 18:46:07 +0530 Subject: [PATCH] Multi drag on tables + Refactor (#13570) https://github.com/user-attachments/assets/32d20ca0-fa50-427c-92e8-638884d86de3 - code needs refactoring - ~~missing stack behaviour (I dont think its easy to add something similar here, but will give it a try) - I suspect it will look crowded~~ - ~~the notification counter - boards have it on top right, but on tables we cant do that since the rows could be longer than the scrolling container , therefore I added it on top left corner~~ - ~~needs to test with large set of data for both boards and tables (pagination)~~ --------- Co-authored-by: Lucas Bordeau --- .../record-board/components/RecordBoard.tsx | 14 +- .../hooks/useBoardCardDragState.ts | 34 -- .../record-board/hooks/useEndBoardCardDrag.ts | 45 -- .../hooks/useRecordBoardDragOperations.ts | 106 ----- .../hooks/useStartBoardCardDrag.ts | 61 --- .../components/RecordBoardCard.tsx | 15 +- .../RecordBoardCardMultiDragPreview.tsx | 9 +- .../calculateRecordPositions.test.ts | 446 ------------------ .../utils/__tests__/processMultiDrag.test.ts | 180 ------- .../utils/__tests__/processSingleDrag.test.ts | 143 ------ .../record-board/utils/processMultiDrag.ts | 58 --- .../record-board/utils/processSingleDrag.ts | 52 -- .../hooks/useRecordBoardDragOperations.ts | 51 ++ .../states/draggedRecordIdsComponentState.ts | 0 .../states/isMultiDragActiveComponentState.ts | 0 .../states/originalSelectionComponentState.ts | 0 .../primaryDraggedRecordIdComponentState.ts | 0 .../hooks/__tests__/useEndRecordDrag.test.tsx | 204 ++++++++ .../__tests__/useMultiDragState.test.tsx | 140 ++++++ .../__tests__/useRecordDragState.test.tsx | 159 +++++++ .../__tests__/useStartRecordDrag.test.tsx | 339 +++++++++++++ .../shared/hooks/useEndRecordDrag.ts | 65 +++ .../shared}/hooks/useMultiDragState.ts | 10 +- .../shared/hooks/useRecordDragState.ts | 52 ++ .../shared/hooks/useStartRecordDrag.ts | 81 ++++ .../shared}/types/DragOperationType.ts | 0 .../shared/types/MultiDragResult.ts | 5 + .../shared/types/MultiDragState.ts | 6 + .../shared/types/RecordDragContext.ts | 1 + .../shared/types/RecordDragPositionData.ts | 4 + .../shared/types/RecordDragUpdate.ts | 6 + .../__tests__/calculateDragPositions.test.ts | 194 ++++++++ .../__tests__/extractRecordPositions.test.ts | 159 +++++++ .../__tests__/getDragOperationType.test.ts | 2 +- .../getDraggedRecordPosition.test.ts | 2 +- .../processGroupDragOperation.test.ts | 343 ++++++++++++++ .../utils/__tests__/processMultiDrag.test.ts | 211 +++++++++ .../utils/__tests__/processSingleDrag.test.ts | 156 ++++++ .../shared/utils/calculateDragPositions.ts} | 28 +- .../shared/utils/extractRecordPositions.ts | 17 + .../shared}/utils/getDragOperationType.ts | 0 .../shared}/utils/getDraggedRecordPosition.ts | 0 .../shared/utils/processGroupDragOperation.ts | 103 ++++ .../shared/utils/processMultiDrag.ts | 62 +++ .../shared/utils/processSingleDrag.ts | 56 +++ .../hooks/useRecordTableDragOperations.ts | 122 +++++ .../useRecordTableGroupDragOperations.ts | 109 +++++ .../draggedRecordIdsTableComponentState.ts | 10 + .../isMultiDragActiveTableComponentState.ts | 9 + .../originalSelectionTableComponentState.ts | 10 + ...imaryDraggedRecordIdTableComponentState.ts | 10 + ...RecordTableBodyDragDropContextProvider.tsx | 126 ++--- ...BodyRecordGroupDragDropContextProvider.tsx | 156 ++---- .../components/RecordTableDraggableTr.tsx | 14 + .../RecordTableRowMultiDragPreview.tsx | 37 ++ 55 files changed, 2844 insertions(+), 1378 deletions(-) delete mode 100644 packages/twenty-front/src/modules/object-record/record-board/hooks/useBoardCardDragState.ts delete mode 100644 packages/twenty-front/src/modules/object-record/record-board/hooks/useEndBoardCardDrag.ts delete mode 100644 packages/twenty-front/src/modules/object-record/record-board/hooks/useRecordBoardDragOperations.ts delete mode 100644 packages/twenty-front/src/modules/object-record/record-board/hooks/useStartBoardCardDrag.ts delete mode 100644 packages/twenty-front/src/modules/object-record/record-board/utils/__tests__/calculateRecordPositions.test.ts delete mode 100644 packages/twenty-front/src/modules/object-record/record-board/utils/__tests__/processMultiDrag.test.ts delete mode 100644 packages/twenty-front/src/modules/object-record/record-board/utils/__tests__/processSingleDrag.test.ts delete mode 100644 packages/twenty-front/src/modules/object-record/record-board/utils/processMultiDrag.ts delete mode 100644 packages/twenty-front/src/modules/object-record/record-board/utils/processSingleDrag.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-drag/board/hooks/useRecordBoardDragOperations.ts rename packages/twenty-front/src/modules/object-record/{record-board => record-drag/board}/states/draggedRecordIdsComponentState.ts (100%) rename packages/twenty-front/src/modules/object-record/{record-board => record-drag/board}/states/isMultiDragActiveComponentState.ts (100%) rename packages/twenty-front/src/modules/object-record/{record-board => record-drag/board}/states/originalSelectionComponentState.ts (100%) rename packages/twenty-front/src/modules/object-record/{record-board => record-drag/board}/states/primaryDraggedRecordIdComponentState.ts (100%) create mode 100644 packages/twenty-front/src/modules/object-record/record-drag/shared/hooks/__tests__/useEndRecordDrag.test.tsx create mode 100644 packages/twenty-front/src/modules/object-record/record-drag/shared/hooks/__tests__/useMultiDragState.test.tsx create mode 100644 packages/twenty-front/src/modules/object-record/record-drag/shared/hooks/__tests__/useRecordDragState.test.tsx create mode 100644 packages/twenty-front/src/modules/object-record/record-drag/shared/hooks/__tests__/useStartRecordDrag.test.tsx create mode 100644 packages/twenty-front/src/modules/object-record/record-drag/shared/hooks/useEndRecordDrag.ts rename packages/twenty-front/src/modules/object-record/{record-board => record-drag/shared}/hooks/useMultiDragState.ts (84%) create mode 100644 packages/twenty-front/src/modules/object-record/record-drag/shared/hooks/useRecordDragState.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-drag/shared/hooks/useStartRecordDrag.ts rename packages/twenty-front/src/modules/object-record/{record-board => record-drag/shared}/types/DragOperationType.ts (100%) create mode 100644 packages/twenty-front/src/modules/object-record/record-drag/shared/types/MultiDragResult.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-drag/shared/types/MultiDragState.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-drag/shared/types/RecordDragContext.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-drag/shared/types/RecordDragPositionData.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-drag/shared/types/RecordDragUpdate.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-drag/shared/utils/__tests__/calculateDragPositions.test.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-drag/shared/utils/__tests__/extractRecordPositions.test.ts rename packages/twenty-front/src/modules/object-record/{record-board => record-drag/shared}/utils/__tests__/getDragOperationType.test.ts (94%) rename packages/twenty-front/src/modules/object-record/{record-board => record-drag/shared}/utils/__tests__/getDraggedRecordPosition.test.ts (89%) create mode 100644 packages/twenty-front/src/modules/object-record/record-drag/shared/utils/__tests__/processGroupDragOperation.test.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-drag/shared/utils/__tests__/processMultiDrag.test.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-drag/shared/utils/__tests__/processSingleDrag.test.ts rename packages/twenty-front/src/modules/object-record/{record-board/utils/calculateRecordPositions.ts => record-drag/shared/utils/calculateDragPositions.ts} (70%) create mode 100644 packages/twenty-front/src/modules/object-record/record-drag/shared/utils/extractRecordPositions.ts rename packages/twenty-front/src/modules/object-record/{record-board => record-drag/shared}/utils/getDragOperationType.ts (100%) rename packages/twenty-front/src/modules/object-record/{record-board => record-drag/shared}/utils/getDraggedRecordPosition.ts (100%) create mode 100644 packages/twenty-front/src/modules/object-record/record-drag/shared/utils/processGroupDragOperation.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-drag/shared/utils/processMultiDrag.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-drag/shared/utils/processSingleDrag.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-drag/table/hooks/useRecordTableDragOperations.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-drag/table/hooks/useRecordTableGroupDragOperations.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-drag/table/states/draggedRecordIdsTableComponentState.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-drag/table/states/isMultiDragActiveTableComponentState.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-drag/table/states/originalSelectionTableComponentState.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-drag/table/states/primaryDraggedRecordIdTableComponentState.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-table/record-table-row/components/RecordTableRowMultiDragPreview.tsx 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 a1cb2350d7..b13f3e46e1 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 @@ -15,17 +15,17 @@ import { RecordBoardStickyHeaderEffect } from '@/object-record/record-board/comp import { RECORD_BOARD_CLICK_OUTSIDE_LISTENER_ID } from '@/object-record/record-board/constants/RecordBoardClickOutsideListenerId'; import { RecordBoardContext } from '@/object-record/record-board/contexts/RecordBoardContext'; import { useActiveRecordBoardCard } from '@/object-record/record-board/hooks/useActiveRecordBoardCard'; -import { useBoardCardDragState } from '@/object-record/record-board/hooks/useBoardCardDragState'; -import { useEndBoardCardDrag } from '@/object-record/record-board/hooks/useEndBoardCardDrag'; import { useFocusedRecordBoardCard } from '@/object-record/record-board/hooks/useFocusedRecordBoardCard'; -import { useRecordBoardDragOperations } from '@/object-record/record-board/hooks/useRecordBoardDragOperations'; import { useRecordBoardSelection } from '@/object-record/record-board/hooks/useRecordBoardSelection'; -import { useStartBoardCardDrag } from '@/object-record/record-board/hooks/useStartBoardCardDrag'; import { RecordBoardDeactivateBoardCardEffect } from '@/object-record/record-board/record-board-card/components/RecordBoardDeactivateBoardCardEffect'; import { RECORD_BOARD_CARD_CLICK_OUTSIDE_ID } from '@/object-record/record-board/record-board-card/constants/RecordBoardCardClickOutsideId'; import { RecordBoardColumn } from '@/object-record/record-board/record-board-column/components/RecordBoardColumn'; import { RecordBoardComponentInstanceContext } from '@/object-record/record-board/states/contexts/RecordBoardComponentInstanceContext'; import { recordBoardSelectedRecordIdsComponentSelector } from '@/object-record/record-board/states/selectors/recordBoardSelectedRecordIdsComponentSelector'; +import { useRecordBoardDragOperations } from '@/object-record/record-drag/board/hooks/useRecordBoardDragOperations'; +import { useEndRecordDrag } from '@/object-record/record-drag/shared/hooks/useEndRecordDrag'; +import { useRecordDragState } from '@/object-record/record-drag/shared/hooks/useRecordDragState'; +import { useStartRecordDrag } from '@/object-record/record-drag/shared/hooks/useStartRecordDrag'; import { visibleRecordGroupIdsComponentFamilySelector } from '@/object-record/record-group/states/selectors/visibleRecordGroupIdsComponentFamilySelector'; import { RECORD_INDEX_REMOVE_SORTING_MODAL_ID } from '@/object-record/record-index/constants/RecordIndexRemoveSortingModalId'; import { currentRecordSortsComponentState } from '@/object-record/record-sort/states/currentRecordSortsComponentState'; @@ -113,9 +113,9 @@ export const RecordBoard = () => { recordBoardId, ); - const startDrag = useStartBoardCardDrag(recordBoardId); - const endDrag = useEndBoardCardDrag(recordBoardId); - const multiDragState = useBoardCardDragState(recordBoardId); + const { startDrag } = useStartRecordDrag('board', recordBoardId); + const { endDrag } = useEndRecordDrag('board', recordBoardId); + const multiDragState = useRecordDragState('board', recordBoardId); const { processDragOperation } = useRecordBoardDragOperations(); diff --git a/packages/twenty-front/src/modules/object-record/record-board/hooks/useBoardCardDragState.ts b/packages/twenty-front/src/modules/object-record/record-board/hooks/useBoardCardDragState.ts deleted file mode 100644 index 3f9b0f4f59..0000000000 --- a/packages/twenty-front/src/modules/object-record/record-board/hooks/useBoardCardDragState.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { draggedRecordIdsComponentState } from '@/object-record/record-board/states/draggedRecordIdsComponentState'; -import { isMultiDragActiveComponentState } from '@/object-record/record-board/states/isMultiDragActiveComponentState'; -import { originalSelectionComponentState } from '@/object-record/record-board/states/originalSelectionComponentState'; -import { primaryDraggedRecordIdComponentState } from '@/object-record/record-board/states/primaryDraggedRecordIdComponentState'; -import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; - -export const useBoardCardDragState = (recordBoardId?: string) => { - const isDragging = useRecoilComponentValue( - isMultiDragActiveComponentState, - recordBoardId, - ); - - const draggedRecordIds = useRecoilComponentValue( - draggedRecordIdsComponentState, - recordBoardId, - ); - - const primaryDraggedRecordId = useRecoilComponentValue( - primaryDraggedRecordIdComponentState, - recordBoardId, - ); - - const originalSelection = useRecoilComponentValue( - originalSelectionComponentState, - recordBoardId, - ); - - return { - isDragging, - draggedRecordIds, - primaryDraggedRecordId, - originalSelection, - }; -}; diff --git a/packages/twenty-front/src/modules/object-record/record-board/hooks/useEndBoardCardDrag.ts b/packages/twenty-front/src/modules/object-record/record-board/hooks/useEndBoardCardDrag.ts deleted file mode 100644 index ef121fcbcb..0000000000 --- a/packages/twenty-front/src/modules/object-record/record-board/hooks/useEndBoardCardDrag.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { useRecoilCallback } from 'recoil'; - -import { draggedRecordIdsComponentState } from '@/object-record/record-board/states/draggedRecordIdsComponentState'; -import { isMultiDragActiveComponentState } from '@/object-record/record-board/states/isMultiDragActiveComponentState'; -import { originalSelectionComponentState } from '@/object-record/record-board/states/originalSelectionComponentState'; -import { primaryDraggedRecordIdComponentState } from '@/object-record/record-board/states/primaryDraggedRecordIdComponentState'; -import { useRecoilComponentCallbackState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackState'; - -export const useEndBoardCardDrag = (recordBoardId?: string) => { - const isMultiDragActiveState = useRecoilComponentCallbackState( - isMultiDragActiveComponentState, - recordBoardId, - ); - - const draggedRecordIdsState = useRecoilComponentCallbackState( - draggedRecordIdsComponentState, - recordBoardId, - ); - - const primaryDraggedRecordIdState = useRecoilComponentCallbackState( - primaryDraggedRecordIdComponentState, - recordBoardId, - ); - - const originalSelectionState = useRecoilComponentCallbackState( - originalSelectionComponentState, - recordBoardId, - ); - - return useRecoilCallback( - ({ set }) => - () => { - set(isMultiDragActiveState, false); - set(draggedRecordIdsState, []); - set(primaryDraggedRecordIdState, null); - set(originalSelectionState, []); - }, - [ - isMultiDragActiveState, - draggedRecordIdsState, - primaryDraggedRecordIdState, - originalSelectionState, - ], - ); -}; diff --git a/packages/twenty-front/src/modules/object-record/record-board/hooks/useRecordBoardDragOperations.ts b/packages/twenty-front/src/modules/object-record/record-board/hooks/useRecordBoardDragOperations.ts deleted file mode 100644 index 90045ecbd1..0000000000 --- a/packages/twenty-front/src/modules/object-record/record-board/hooks/useRecordBoardDragOperations.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { RecordBoardContext } from '@/object-record/record-board/contexts/RecordBoardContext'; -import { recordGroupDefinitionFamilyState } from '@/object-record/record-group/states/recordGroupDefinitionFamilyState'; -import { recordIndexRecordIdsByGroupComponentFamilyState } from '@/object-record/record-index/states/recordIndexRecordIdsByGroupComponentFamilyState'; -import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState'; -import { useRecoilComponentCallbackState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackState'; -import { getSnapshotValue } from '@/ui/utilities/state/utils/getSnapshotValue'; -import { type DropResult } from '@hello-pangea/dnd'; -import { useContext } from 'react'; -import { useRecoilCallback } from 'recoil'; -import { getDragOperationType } from '../utils/getDragOperationType'; -import { processMultiDrag } from '../utils/processMultiDrag'; -import { processSingleDrag } from '../utils/processSingleDrag'; - -export const useRecordBoardDragOperations = () => { - const { updateOneRecord, selectFieldMetadataItem } = - useContext(RecordBoardContext); - - const recordIndexRecordIdsByGroupFamilyState = - useRecoilComponentCallbackState( - recordIndexRecordIdsByGroupComponentFamilyState, - ); - - const processDragOperation = useRecoilCallback( - ({ snapshot }) => - (result: DropResult, selectedRecordIds: string[]) => { - if (!result.destination || !selectFieldMetadataItem) return; - - const draggedRecordId = result.draggableId; - const destinationGroupId = result.destination.droppableId; - - const recordGroup = getSnapshotValue( - snapshot, - recordGroupDefinitionFamilyState(destinationGroupId), - ); - - if (!recordGroup) return; - - const destinationRecordIds = getSnapshotValue( - snapshot, - recordIndexRecordIdsByGroupFamilyState(destinationGroupId), - ) as string[]; - - const recordPositionData = destinationRecordIds.map((recordId) => { - const record = getSnapshotValue( - snapshot, - recordStoreFamilyState(recordId), - ); - return { - recordId, - position: record?.position, - }; - }); - - const operationType = getDragOperationType({ - draggedRecordId, - selectedRecordIds, - }); - - if (operationType === 'single') { - const singleDragResult = processSingleDrag({ - result, - recordPositionData, - destinationRecordIds, - groupValue: recordGroup.value, - selectFieldName: selectFieldMetadataItem.name, - }); - - updateOneRecord({ - idToUpdate: singleDragResult.recordId, - updateOneRecordInput: { - [selectFieldMetadataItem.name]: singleDragResult.groupValue, - position: singleDragResult.position, - }, - }); - } else { - const multiDragResult = processMultiDrag({ - result, - selectedRecordIds, - recordPositionData, - destinationRecordIds, - groupValue: recordGroup.value, - selectFieldName: selectFieldMetadataItem.name, - }); - - for (const update of multiDragResult.recordUpdates) { - updateOneRecord({ - idToUpdate: update.recordId, - updateOneRecordInput: { - [selectFieldMetadataItem.name]: update.groupValue, - position: update.position, - }, - }); - } - } - }, - [ - updateOneRecord, - selectFieldMetadataItem, - recordIndexRecordIdsByGroupFamilyState, - ], - ); - - return { - processDragOperation, - }; -}; diff --git a/packages/twenty-front/src/modules/object-record/record-board/hooks/useStartBoardCardDrag.ts b/packages/twenty-front/src/modules/object-record/record-board/hooks/useStartBoardCardDrag.ts deleted file mode 100644 index 7fbe05d8c9..0000000000 --- a/packages/twenty-front/src/modules/object-record/record-board/hooks/useStartBoardCardDrag.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { type DragStart } from '@hello-pangea/dnd'; -import { useRecoilCallback } from 'recoil'; - -import { draggedRecordIdsComponentState } from '@/object-record/record-board/states/draggedRecordIdsComponentState'; -import { isMultiDragActiveComponentState } from '@/object-record/record-board/states/isMultiDragActiveComponentState'; -import { originalSelectionComponentState } from '@/object-record/record-board/states/originalSelectionComponentState'; -import { primaryDraggedRecordIdComponentState } from '@/object-record/record-board/states/primaryDraggedRecordIdComponentState'; -import { useRecoilComponentCallbackState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackState'; -import { getDragOperationType } from '../utils/getDragOperationType'; - -export const useStartBoardCardDrag = (recordBoardId?: string) => { - const isMultiDragActiveState = useRecoilComponentCallbackState( - isMultiDragActiveComponentState, - recordBoardId, - ); - - const draggedRecordIdsState = useRecoilComponentCallbackState( - draggedRecordIdsComponentState, - recordBoardId, - ); - - const primaryDraggedRecordIdState = useRecoilComponentCallbackState( - primaryDraggedRecordIdComponentState, - recordBoardId, - ); - - const originalSelectionState = useRecoilComponentCallbackState( - originalSelectionComponentState, - recordBoardId, - ); - - return useRecoilCallback( - ({ set }) => - (start: DragStart, selectedRecordIds: string[]) => { - const draggedRecordId = start.draggableId; - - const operationType = getDragOperationType({ - draggedRecordId, - selectedRecordIds, - }); - - if (operationType === 'multi') { - set(isMultiDragActiveState, true); - set(draggedRecordIdsState, selectedRecordIds); - set(primaryDraggedRecordIdState, draggedRecordId); - set(originalSelectionState, selectedRecordIds); - } else { - set(isMultiDragActiveState, true); - set(draggedRecordIdsState, [draggedRecordId]); - set(primaryDraggedRecordIdState, draggedRecordId); - set(originalSelectionState, [draggedRecordId]); - } - }, - [ - isMultiDragActiveState, - draggedRecordIdsState, - primaryDraggedRecordIdState, - originalSelectionState, - ], - ); -}; 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 80e5027f06..88e0445c66 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,7 +1,7 @@ import { recordIndexActionMenuDropdownPositionComponentState } from '@/action-menu/states/recordIndexActionMenuDropdownPositionComponentState'; import { getActionMenuDropdownIdFromActionMenuId } from '@/action-menu/utils/getActionMenuDropdownIdFromActionMenuId'; import { getActionMenuIdFromRecordIndexId } from '@/action-menu/utils/getActionMenuIdFromRecordIndexId'; -import { useBoardCardDragState } from '@/object-record/record-board/hooks/useBoardCardDragState'; +import { useRecordDragState } from '@/object-record/record-drag/shared/hooks/useRecordDragState'; import { RecordBoardCardContext } from '@/object-record/record-board/record-board-card/contexts/RecordBoardCardContext'; import { isRecordBoardCardActiveComponentFamilyState } from '@/object-record/record-board/states/isRecordBoardCardActiveComponentFamilyState'; import { isRecordBoardCardFocusedComponentFamilyState } from '@/object-record/record-board/states/isRecordBoardCardFocusedComponentFamilyState'; @@ -124,11 +124,14 @@ export const RecordBoardCard = () => { RecordBoardCardContext, ); - const multiDragState = useBoardCardDragState(); + const recordBoardId = useAvailableComponentInstanceIdOrThrow( + RecordBoardComponentInstanceContext, + ); + + const multiDragState = useRecordDragState('board', recordBoardId); const isPrimaryMultiDrag = - multiDragState && - multiDragState.isDragging && + multiDragState?.isDragging && recordId === multiDragState.primaryDraggedRecordId && multiDragState.originalSelection.length > 1; @@ -169,10 +172,6 @@ export const RecordBoardCard = () => { }, ); - const recordBoardId = useAvailableComponentInstanceIdOrThrow( - RecordBoardComponentInstanceContext, - ); - const actionMenuId = getActionMenuIdFromRecordIndexId(recordBoardId); const actionMenuDropdownId = diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-card/components/RecordBoardCardMultiDragPreview.tsx b/packages/twenty-front/src/modules/object-record/record-board/record-board-card/components/RecordBoardCardMultiDragPreview.tsx index c96f655e01..4559c8a5fc 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/record-board-card/components/RecordBoardCardMultiDragPreview.tsx +++ b/packages/twenty-front/src/modules/object-record/record-board/record-board-card/components/RecordBoardCardMultiDragPreview.tsx @@ -1,5 +1,7 @@ -import { useBoardCardDragState } from '@/object-record/record-board/hooks/useBoardCardDragState'; +import { useRecordDragState } from '@/object-record/record-drag/shared/hooks/useRecordDragState'; import { RecordBoardCardContext } from '@/object-record/record-board/record-board-card/contexts/RecordBoardCardContext'; +import { RecordBoardComponentInstanceContext } from '@/object-record/record-board/states/contexts/RecordBoardComponentInstanceContext'; +import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow'; import styled from '@emotion/styled'; import { useContext } from 'react'; import { NotificationCounter } from 'twenty-ui/navigation'; @@ -19,7 +21,10 @@ export const RecordBoardCardMultiDragPreview = ({ isDragging, }: RecordBoardCardMultiDragPreviewProps) => { const { recordId } = useContext(RecordBoardCardContext); - const multiDragState = useBoardCardDragState(); + const recordBoardId = useAvailableComponentInstanceIdOrThrow( + RecordBoardComponentInstanceContext, + ); + const multiDragState = useRecordDragState('board', recordBoardId); const isCurrentCardSelected = multiDragState?.originalSelection.includes(recordId) || false; diff --git a/packages/twenty-front/src/modules/object-record/record-board/utils/__tests__/calculateRecordPositions.test.ts b/packages/twenty-front/src/modules/object-record/record-board/utils/__tests__/calculateRecordPositions.test.ts deleted file mode 100644 index c712e4cbf3..0000000000 --- a/packages/twenty-front/src/modules/object-record/record-board/utils/__tests__/calculateRecordPositions.test.ts +++ /dev/null @@ -1,446 +0,0 @@ -import { calculateRecordPositions } from '../calculateRecordPositions'; - -import { getDraggedRecordPosition } from '@/object-record/record-board/utils/getDraggedRecordPosition'; -import { getIndexNeighboursElementsFromArray } from '~/utils/array/getIndexNeighboursElementsFromArray'; - -jest.mock( - '@/object-record/record-board/utils/getDraggedRecordPosition', - () => ({ - getDraggedRecordPosition: jest.fn(), - }), -); - -jest.mock('~/utils/array/getIndexNeighboursElementsFromArray', () => ({ - getIndexNeighboursElementsFromArray: jest.fn(), -})); - -const mockGetDraggedRecordPosition = getDraggedRecordPosition as jest.Mock; -const mockGetIndexNeighboursElementsFromArray = - getIndexNeighboursElementsFromArray as jest.Mock; - -describe('calculateRecordPositions', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - const mockRecordPositionData = [ - { recordId: 'record-1', position: 1 }, - { recordId: 'record-2', position: 2 }, - { recordId: 'record-3', position: 3 }, - { recordId: 'record-4', position: 4 }, - { recordId: 'record-5', position: 5 }, - ]; - - it('should calculate single record position correctly', () => { - const basePosition = 2.5; - - mockGetIndexNeighboursElementsFromArray.mockReturnValue({ - before: 'record-2', - after: 'record-3', - }); - - mockGetDraggedRecordPosition.mockReturnValue(basePosition); - - const result = calculateRecordPositions({ - destinationRecordIds: [ - 'record-1', - 'record-2', - 'record-3', - 'record-4', - 'record-5', - ], - recordsToMove: ['record-1'], - destinationIndex: 2, - recordPositionData: mockRecordPositionData, - }); - - expect(result).toEqual({ - 'record-1': basePosition, - }); - expect(mockGetDraggedRecordPosition).toHaveBeenCalledWith(2, 3); - }); - - it('should calculate multiple record positions with proportional spacing', () => { - const basePosition = 2.5; - - mockGetIndexNeighboursElementsFromArray.mockReturnValue({ - before: 'record-2', - after: 'record-5', - }); - - mockGetDraggedRecordPosition.mockReturnValue(basePosition); - - const result = calculateRecordPositions({ - destinationRecordIds: ['record-2', 'record-3', 'record-4', 'record-5'], - recordsToMove: ['record-1', 'record-6', 'record-7'], - destinationIndex: 1, - recordPositionData: mockRecordPositionData, - }); - - expect(result).toEqual({ - 'record-1': 3.125, - 'record-6': 3.75, - 'record-7': 4.375, - }); - }); - - it('should filter out records being moved from destination column', () => { - const basePosition = 2.5; - - mockGetIndexNeighboursElementsFromArray.mockReturnValue({ - before: 'record-2', - after: 'record-5', - }); - - mockGetDraggedRecordPosition.mockReturnValue(basePosition); - - calculateRecordPositions({ - destinationRecordIds: [ - 'record-1', - 'record-2', - 'record-3', - 'record-4', - 'record-5', - ], - recordsToMove: ['record-1', 'record-3'], - destinationIndex: 1, - recordPositionData: mockRecordPositionData, - }); - - expect(mockGetIndexNeighboursElementsFromArray).toHaveBeenCalledWith({ - index: 1, - array: ['record-2', 'record-3', 'record-4', 'record-5'], - }); - }); - - it('should handle same group single drag by filtering source record', () => { - const basePosition = 2.5; - - mockGetIndexNeighboursElementsFromArray.mockReturnValue({ - before: 'record-2', - after: 'record-4', - }); - - mockGetDraggedRecordPosition.mockReturnValue(basePosition); - - calculateRecordPositions({ - destinationRecordIds: [ - 'record-1', - 'record-2', - 'record-3', - 'record-4', - 'record-5', - ], - recordsToMove: ['record-3'], - destinationIndex: 1, - recordPositionData: mockRecordPositionData, - }); - - expect(mockGetIndexNeighboursElementsFromArray).toHaveBeenCalledWith({ - index: 1, - array: ['record-1', 'record-2', 'record-4', 'record-5'], - }); - }); - - it('should handle case with no before record', () => { - const basePosition = 0.5; - - mockGetIndexNeighboursElementsFromArray.mockReturnValue({ - before: null, - after: 'record-2', - }); - - mockGetDraggedRecordPosition.mockReturnValue(basePosition); - - const result = calculateRecordPositions({ - destinationRecordIds: ['record-1', 'record-2', 'record-3'], - recordsToMove: ['record-new'], - destinationIndex: 0, - recordPositionData: mockRecordPositionData, - }); - - expect(mockGetDraggedRecordPosition).toHaveBeenCalledWith(undefined, 2); - expect(result).toEqual({ - 'record-new': basePosition, - }); - }); - - it('should handle case with no after record', () => { - const basePosition = 5.5; - - mockGetIndexNeighboursElementsFromArray.mockReturnValue({ - before: 'record-5', - after: null, - }); - - mockGetDraggedRecordPosition.mockReturnValue(basePosition); - - const result = calculateRecordPositions({ - destinationRecordIds: [ - 'record-1', - 'record-2', - 'record-3', - 'record-4', - 'record-5', - ], - recordsToMove: ['record-new'], - destinationIndex: 5, - recordPositionData: mockRecordPositionData, - }); - - expect(mockGetDraggedRecordPosition).toHaveBeenCalledWith(5, undefined); - expect(result).toEqual({ - 'record-new': basePosition, - }); - }); - - it('should handle multi-drag with proportional spacing when after record exists', () => { - const basePosition = 2.5; - const afterPosition = 5.0; - - mockGetIndexNeighboursElementsFromArray.mockReturnValue({ - before: 'record-2', - after: 'record-5', - }); - - mockGetDraggedRecordPosition.mockReturnValue(basePosition); - - const recordPositionDataWithAfter = [ - ...mockRecordPositionData, - { recordId: 'record-5', position: afterPosition }, - ]; - - const result = calculateRecordPositions({ - destinationRecordIds: [ - 'record-1', - 'record-2', - 'record-3', - 'record-4', - 'record-5', - ], - recordsToMove: ['record-a', 'record-b'], - destinationIndex: 1, - recordPositionData: recordPositionDataWithAfter, - }); - - expect(result).toEqual({ - 'record-a': 3.3333333333333335, - 'record-b': 4.166666666666667, - }); - }); - - it('should handle multi-drag with default spacing when no after record exists', () => { - const basePosition = 5.5; - - mockGetIndexNeighboursElementsFromArray.mockReturnValue({ - before: 'record-5', - after: null, - }); - - mockGetDraggedRecordPosition.mockReturnValue(basePosition); - - const result = calculateRecordPositions({ - destinationRecordIds: [ - 'record-1', - 'record-2', - 'record-3', - 'record-4', - 'record-5', - ], - recordsToMove: ['record-a', 'record-b'], - destinationIndex: 5, - recordPositionData: mockRecordPositionData, - }); - - expect(result).toEqual({ - 'record-a': 5.833333333333333, - 'record-b': 6.166666666666667, - }); - }); - - describe('Single drag scenarios', () => { - it('should handle single drag within same column', () => { - mockGetIndexNeighboursElementsFromArray.mockReturnValue({ - before: 'record-2', - after: 'record-4', - }); - mockGetDraggedRecordPosition.mockReturnValue(2.5); - - const result = calculateRecordPositions({ - destinationRecordIds: ['record-1', 'record-2', 'record-3', 'record-4'], - recordsToMove: ['record-3'], - destinationIndex: 2, - recordPositionData: mockRecordPositionData, - }); - - expect(mockGetIndexNeighboursElementsFromArray).toHaveBeenCalledWith({ - index: 2, - array: ['record-1', 'record-2', 'record-4'], - }); - - expect(result).toEqual({ - 'record-3': 2.5, - }); - }); - - it('should handle single drag to empty column', () => { - mockGetIndexNeighboursElementsFromArray.mockReturnValue({ - before: undefined, - after: undefined, - }); - mockGetDraggedRecordPosition.mockReturnValue(1); - - const result = calculateRecordPositions({ - destinationRecordIds: [], - recordsToMove: ['record-1'], - destinationIndex: 0, - recordPositionData: mockRecordPositionData, - }); - - expect(mockGetIndexNeighboursElementsFromArray).toHaveBeenCalledWith({ - index: 0, - array: [], - }); - - expect(result).toEqual({ - 'record-1': 1, - }); - }); - }); - - describe('Multi-drag scenarios', () => { - it('should handle multi-drag with secondary records already in destination', () => { - mockGetIndexNeighboursElementsFromArray.mockReturnValue({ - before: 'record-1', - after: 'record-4', - }); - mockGetDraggedRecordPosition.mockReturnValue(2); - - const result = calculateRecordPositions({ - destinationRecordIds: ['record-1', 'record-2', 'record-3', 'record-4'], - recordsToMove: ['record-2', 'record-3', 'record-5'], - destinationIndex: 1, - recordPositionData: mockRecordPositionData, - }); - - expect(mockGetIndexNeighboursElementsFromArray).toHaveBeenCalledWith({ - index: 1, - array: ['record-1', 'record-3', 'record-4'], - }); - - const availableSpace = 4 - 2; - const increment = availableSpace / (3 + 1); - - expect(result).toEqual({ - 'record-2': 2 + 1 * increment, - 'record-3': 2 + 2 * increment, - 'record-5': 2 + 3 * increment, - }); - }); - - it('should handle multi-drag where all records are from different columns', () => { - mockGetIndexNeighboursElementsFromArray.mockReturnValue({ - before: 'record-1', - after: 'record-2', - }); - mockGetDraggedRecordPosition.mockReturnValue(1.5); - - const result = calculateRecordPositions({ - destinationRecordIds: ['record-1', 'record-2'], - recordsToMove: ['record-3', 'record-4'], - destinationIndex: 1, - recordPositionData: mockRecordPositionData, - }); - - expect(mockGetIndexNeighboursElementsFromArray).toHaveBeenCalledWith({ - index: 1, - array: ['record-1', 'record-2'], - }); - - const availableSpace = 2 - 1.5; - const increment = availableSpace / 3; - - expect(result).toEqual({ - 'record-3': 1.5 + increment, - 'record-4': 1.5 + 2 * increment, - }); - }); - - it('should handle multi-drag to empty column', () => { - mockGetIndexNeighboursElementsFromArray.mockReturnValue({ - before: undefined, - after: undefined, - }); - mockGetDraggedRecordPosition.mockReturnValue(1); - - const result = calculateRecordPositions({ - destinationRecordIds: [], - recordsToMove: ['record-1', 'record-2'], - destinationIndex: 0, - recordPositionData: mockRecordPositionData, - }); - - expect(mockGetIndexNeighboursElementsFromArray).toHaveBeenCalledWith({ - index: 0, - array: [], - }); - - const increment = 1 / 3; - - expect(result).toEqual({ - 'record-1': 1 + increment, - 'record-2': 1 + 2 * increment, - }); - }); - }); - - describe('Edge cases', () => { - it('should handle moving to beginning of column (index 0)', () => { - mockGetIndexNeighboursElementsFromArray.mockReturnValue({ - before: undefined, - after: 'record-1', - }); - mockGetDraggedRecordPosition.mockReturnValue(0.5); - - const result = calculateRecordPositions({ - destinationRecordIds: ['record-1', 'record-2'], - recordsToMove: ['record-3'], - destinationIndex: 0, - recordPositionData: mockRecordPositionData, - }); - - expect(mockGetIndexNeighboursElementsFromArray).toHaveBeenCalledWith({ - index: 0, - array: ['record-1', 'record-2'], - }); - - expect(result).toEqual({ - 'record-3': 0.5, - }); - }); - - it('should handle moving to end of column', () => { - mockGetIndexNeighboursElementsFromArray.mockReturnValue({ - before: 'record-2', - after: undefined, - }); - mockGetDraggedRecordPosition.mockReturnValue(2.5); - - const result = calculateRecordPositions({ - destinationRecordIds: ['record-1', 'record-2'], - recordsToMove: ['record-3'], - destinationIndex: 2, - recordPositionData: mockRecordPositionData, - }); - - expect(mockGetIndexNeighboursElementsFromArray).toHaveBeenCalledWith({ - index: 2, - array: ['record-1', 'record-2'], - }); - - expect(result).toEqual({ - 'record-3': 2.5, - }); - }); - }); -}); diff --git a/packages/twenty-front/src/modules/object-record/record-board/utils/__tests__/processMultiDrag.test.ts b/packages/twenty-front/src/modules/object-record/record-board/utils/__tests__/processMultiDrag.test.ts deleted file mode 100644 index b27f23cf0b..0000000000 --- a/packages/twenty-front/src/modules/object-record/record-board/utils/__tests__/processMultiDrag.test.ts +++ /dev/null @@ -1,180 +0,0 @@ -import { type DropResult } from '@hello-pangea/dnd'; -import { processMultiDrag } from '../processMultiDrag'; - -import { calculateRecordPositions } from '../calculateRecordPositions'; - -jest.mock('../calculateRecordPositions', () => ({ - calculateRecordPositions: jest.fn(), -})); - -const mockCalculateRecordPositions = calculateRecordPositions as jest.Mock; - -describe('processMultiDrag', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - const mockDropResult: DropResult = { - draggableId: 'record-1', - type: 'DEFAULT', - source: { - droppableId: 'group-1', - index: 0, - }, - destination: { - droppableId: 'group-2', - index: 1, - }, - reason: 'DROP', - mode: 'FLUID', - combine: null, - }; - - const mockRecordPositionData = [ - { recordId: 'record-1', position: 1 }, - { recordId: 'record-2', position: 2 }, - { recordId: 'record-3', position: 3 }, - ]; - - it('should process multi-drag operation correctly', () => { - const selectedRecordIds = ['record-1', 'record-3']; - - mockCalculateRecordPositions.mockReturnValue({ - 'record-1': 2.5, - 'record-3': 2.5001, - }); - - const result = processMultiDrag({ - result: mockDropResult, - selectedRecordIds, - recordPositionData: mockRecordPositionData, - destinationRecordIds: ['record-1', 'record-2', 'record-3'], - groupValue: 'new-group-value', - selectFieldName: 'status', - }); - - expect(result).toEqual({ - recordUpdates: [ - { - recordId: 'record-1', - position: 2.5, - groupValue: 'new-group-value', - selectFieldName: 'status', - }, - { - recordId: 'record-3', - position: 2.5001, - groupValue: 'new-group-value', - selectFieldName: 'status', - }, - ], - }); - - expect(mockCalculateRecordPositions).toHaveBeenCalledWith({ - destinationRecordIds: ['record-1', 'record-2', 'record-3'], - recordsToMove: selectedRecordIds, - destinationIndex: 1, - recordPositionData: mockRecordPositionData, - }); - }); - - it('should handle single record in multi-drag correctly', () => { - const selectedRecordIds = ['record-2']; - - mockCalculateRecordPositions.mockReturnValue({ - 'record-2': 3.5, - }); - - const result = processMultiDrag({ - result: mockDropResult, - selectedRecordIds, - recordPositionData: mockRecordPositionData, - destinationRecordIds: ['record-1', 'record-2', 'record-3'], - groupValue: 'single-record-group', - selectFieldName: 'priority', - }); - - expect(result).toEqual({ - recordUpdates: [ - { - recordId: 'record-2', - position: 3.5, - groupValue: 'single-record-group', - selectFieldName: 'priority', - }, - ], - }); - }); - - it('should handle null group value', () => { - const selectedRecordIds = ['record-1', 'record-2']; - - mockCalculateRecordPositions.mockReturnValue({ - 'record-1': 1.5, - 'record-2': 1.5001, - }); - - const result = processMultiDrag({ - result: mockDropResult, - selectedRecordIds, - recordPositionData: mockRecordPositionData, - destinationRecordIds: ['record-1', 'record-2', 'record-3'], - groupValue: null, - selectFieldName: 'category', - }); - - expect(result).toEqual({ - recordUpdates: [ - { - recordId: 'record-1', - position: 1.5, - groupValue: null, - selectFieldName: 'category', - }, - { - recordId: 'record-2', - position: 1.5001, - groupValue: null, - selectFieldName: 'category', - }, - ], - }); - }); - - it('should throw error when destination is null', () => { - const dropResultWithoutDestination = { - ...mockDropResult, - destination: null, - }; - - expect(() => { - processMultiDrag({ - result: dropResultWithoutDestination, - selectedRecordIds: ['record-1', 'record-2'], - recordPositionData: mockRecordPositionData, - destinationRecordIds: ['record-1', 'record-2', 'record-3'], - groupValue: 'new-group-value', - selectFieldName: 'status', - }); - }).toThrow('Destination is required for drag operation'); - }); - - it('should handle empty selectedRecordIds', () => { - const selectedRecordIds: string[] = []; - - mockCalculateRecordPositions.mockReturnValue({}); - - const result = processMultiDrag({ - result: mockDropResult, - selectedRecordIds, - recordPositionData: mockRecordPositionData, - destinationRecordIds: ['record-1', 'record-2', 'record-3'], - groupValue: 'empty-selection', - selectFieldName: 'status', - }); - - expect(result).toEqual({ - recordUpdates: [], - }); - }); -}); diff --git a/packages/twenty-front/src/modules/object-record/record-board/utils/__tests__/processSingleDrag.test.ts b/packages/twenty-front/src/modules/object-record/record-board/utils/__tests__/processSingleDrag.test.ts deleted file mode 100644 index 9ee78bb929..0000000000 --- a/packages/twenty-front/src/modules/object-record/record-board/utils/__tests__/processSingleDrag.test.ts +++ /dev/null @@ -1,143 +0,0 @@ -import { type DropResult } from '@hello-pangea/dnd'; -import { processSingleDrag } from '../processSingleDrag'; - -import { calculateRecordPositions } from '../calculateRecordPositions'; - -jest.mock('../calculateRecordPositions', () => ({ - calculateRecordPositions: jest.fn(), -})); - -const mockCalculateRecordPositions = calculateRecordPositions as jest.Mock; - -describe('processSingleDrag', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - const mockDropResult: DropResult = { - draggableId: 'record-1', - type: 'DEFAULT', - source: { - droppableId: 'group-1', - index: 0, - }, - destination: { - droppableId: 'group-2', - index: 1, - }, - reason: 'DROP', - mode: 'FLUID', - combine: null, - }; - - const mockRecordPositionData = [ - { recordId: 'record-1', position: 1 }, - { recordId: 'record-2', position: 2 }, - { recordId: 'record-3', position: 3 }, - ]; - - it('should process single drag operation correctly', () => { - const expectedPosition = 2.5; - - mockCalculateRecordPositions.mockReturnValue({ - 'record-1': expectedPosition, - }); - - const result = processSingleDrag({ - result: mockDropResult, - recordPositionData: mockRecordPositionData, - destinationRecordIds: ['record-1', 'record-2', 'record-3'], - groupValue: 'new-group-value', - selectFieldName: 'status', - }); - - expect(result).toEqual({ - recordId: 'record-1', - position: expectedPosition, - groupValue: 'new-group-value', - selectFieldName: 'status', - }); - - expect(mockCalculateRecordPositions).toHaveBeenCalledWith({ - destinationRecordIds: ['record-1', 'record-2', 'record-3'], - recordsToMove: ['record-1'], - destinationIndex: 1, - recordPositionData: mockRecordPositionData, - }); - }); - - it('should handle different record IDs correctly', () => { - const dropResult = { - ...mockDropResult, - draggableId: 'record-5', - }; - - const expectedPosition = 3.5; - - mockCalculateRecordPositions.mockReturnValue({ - 'record-5': expectedPosition, - }); - - const result = processSingleDrag({ - result: dropResult, - recordPositionData: mockRecordPositionData, - destinationRecordIds: ['record-1', 'record-2', 'record-3'], - groupValue: 'another-group-value', - selectFieldName: 'priority', - }); - - expect(result).toEqual({ - recordId: 'record-5', - position: expectedPosition, - groupValue: 'another-group-value', - selectFieldName: 'priority', - }); - - expect(mockCalculateRecordPositions).toHaveBeenCalledWith({ - destinationRecordIds: ['record-1', 'record-2', 'record-3'], - recordsToMove: ['record-5'], - destinationIndex: 1, - recordPositionData: mockRecordPositionData, - }); - }); - - it('should handle null group value', () => { - const expectedPosition = 1.5; - - mockCalculateRecordPositions.mockReturnValue({ - 'record-1': expectedPosition, - }); - - const result = processSingleDrag({ - result: mockDropResult, - recordPositionData: mockRecordPositionData, - destinationRecordIds: ['record-1', 'record-2', 'record-3'], - groupValue: null, - selectFieldName: 'category', - }); - - expect(result).toEqual({ - recordId: 'record-1', - position: expectedPosition, - groupValue: null, - selectFieldName: 'category', - }); - }); - - it('should throw error when destination is null', () => { - const dropResultWithoutDestination = { - ...mockDropResult, - destination: null, - }; - - expect(() => { - processSingleDrag({ - result: dropResultWithoutDestination, - recordPositionData: mockRecordPositionData, - destinationRecordIds: ['record-1', 'record-2', 'record-3'], - groupValue: 'new-group-value', - selectFieldName: 'status', - }); - }).toThrow('Destination is required for drag operation'); - }); -}); diff --git a/packages/twenty-front/src/modules/object-record/record-board/utils/processMultiDrag.ts b/packages/twenty-front/src/modules/object-record/record-board/utils/processMultiDrag.ts deleted file mode 100644 index 87103a24ed..0000000000 --- a/packages/twenty-front/src/modules/object-record/record-board/utils/processMultiDrag.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { type DropResult } from '@hello-pangea/dnd'; -import { - calculateRecordPositions, - type RecordPositionData, -} from './calculateRecordPositions'; - -type MultiDragContext = { - result: DropResult; - selectedRecordIds: string[]; - recordPositionData: RecordPositionData[]; - destinationRecordIds: string[]; - groupValue: string | null; - selectFieldName: string; -}; - -type MultiDragResult = { - recordUpdates: Array<{ - recordId: string; - position: number; - groupValue: string | null; - selectFieldName: string; - }>; -}; - -export const processMultiDrag = ({ - result, - selectedRecordIds, - recordPositionData, - destinationRecordIds, - groupValue, - selectFieldName, -}: MultiDragContext): MultiDragResult => { - if (!result.destination) { - throw new Error('Destination is required for drag operation'); - } - - const destinationIndex = result.destination.index; - - const recordsToMove = selectedRecordIds; - - const positions = calculateRecordPositions({ - destinationRecordIds, - recordsToMove, - destinationIndex, - recordPositionData, - }); - - const recordUpdates = recordsToMove.map((recordId) => ({ - recordId, - position: positions[recordId], - groupValue, - selectFieldName, - })); - - return { - recordUpdates, - }; -}; diff --git a/packages/twenty-front/src/modules/object-record/record-board/utils/processSingleDrag.ts b/packages/twenty-front/src/modules/object-record/record-board/utils/processSingleDrag.ts deleted file mode 100644 index 7ed7e8475e..0000000000 --- a/packages/twenty-front/src/modules/object-record/record-board/utils/processSingleDrag.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { type DropResult } from '@hello-pangea/dnd'; -import { - calculateRecordPositions, - type RecordPositionData, -} from './calculateRecordPositions'; - -type SingleDragContext = { - result: DropResult; - recordPositionData: RecordPositionData[]; - destinationRecordIds: string[]; - groupValue: string | null; - selectFieldName: string; -}; - -type SingleDragResult = { - recordId: string; - position: number; - groupValue: string | null; - selectFieldName: string; -}; - -export const processSingleDrag = ({ - result, - recordPositionData, - destinationRecordIds, - groupValue, - selectFieldName, -}: SingleDragContext): SingleDragResult => { - const draggedRecordId = result.draggableId; - - if (!result.destination) { - throw new Error('Destination is required for drag operation'); - } - - const destinationIndex = result.destination.index; - - const recordsToMove = [draggedRecordId]; - - const positions = calculateRecordPositions({ - destinationRecordIds, - recordsToMove, - destinationIndex, - recordPositionData, - }); - - return { - recordId: draggedRecordId, - position: positions[draggedRecordId], - groupValue, - selectFieldName, - }; -}; diff --git a/packages/twenty-front/src/modules/object-record/record-drag/board/hooks/useRecordBoardDragOperations.ts b/packages/twenty-front/src/modules/object-record/record-drag/board/hooks/useRecordBoardDragOperations.ts new file mode 100644 index 0000000000..f0c97ccd93 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-drag/board/hooks/useRecordBoardDragOperations.ts @@ -0,0 +1,51 @@ +import { type DropResult } from '@hello-pangea/dnd'; +import { useContext } from 'react'; +import { useRecoilCallback } from 'recoil'; + +import { RecordBoardContext } from '@/object-record/record-board/contexts/RecordBoardContext'; +import { processGroupDragOperation } from '@/object-record/record-drag/shared/utils/processGroupDragOperation'; +import { recordIndexRecordIdsByGroupComponentFamilyState } from '@/object-record/record-index/states/recordIndexRecordIdsByGroupComponentFamilyState'; +import { useRecoilComponentCallbackState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackState'; + +export const useRecordBoardDragOperations = () => { + const { updateOneRecord, selectFieldMetadataItem } = + useContext(RecordBoardContext); + + const recordIndexRecordIdsByGroupFamilyState = + useRecoilComponentCallbackState( + recordIndexRecordIdsByGroupComponentFamilyState, + ); + + const processDragOperation = useRecoilCallback( + ({ snapshot }) => + (result: DropResult, selectedRecordIds: string[]) => { + if (!selectFieldMetadataItem) return; + + processGroupDragOperation({ + result, + snapshot, + selectedRecordIds, + selectFieldName: selectFieldMetadataItem.name, + recordIdsByGroupFamilyState: recordIndexRecordIdsByGroupFamilyState, + onUpdateRecord: ({ recordId, position, groupValue }) => { + updateOneRecord({ + idToUpdate: recordId, + updateOneRecordInput: { + [selectFieldMetadataItem.name]: groupValue, + position, + }, + }); + }, + }); + }, + [ + updateOneRecord, + selectFieldMetadataItem, + recordIndexRecordIdsByGroupFamilyState, + ], + ); + + return { + processDragOperation, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-board/states/draggedRecordIdsComponentState.ts b/packages/twenty-front/src/modules/object-record/record-drag/board/states/draggedRecordIdsComponentState.ts similarity index 100% rename from packages/twenty-front/src/modules/object-record/record-board/states/draggedRecordIdsComponentState.ts rename to packages/twenty-front/src/modules/object-record/record-drag/board/states/draggedRecordIdsComponentState.ts diff --git a/packages/twenty-front/src/modules/object-record/record-board/states/isMultiDragActiveComponentState.ts b/packages/twenty-front/src/modules/object-record/record-drag/board/states/isMultiDragActiveComponentState.ts similarity index 100% rename from packages/twenty-front/src/modules/object-record/record-board/states/isMultiDragActiveComponentState.ts rename to packages/twenty-front/src/modules/object-record/record-drag/board/states/isMultiDragActiveComponentState.ts diff --git a/packages/twenty-front/src/modules/object-record/record-board/states/originalSelectionComponentState.ts b/packages/twenty-front/src/modules/object-record/record-drag/board/states/originalSelectionComponentState.ts similarity index 100% rename from packages/twenty-front/src/modules/object-record/record-board/states/originalSelectionComponentState.ts rename to packages/twenty-front/src/modules/object-record/record-drag/board/states/originalSelectionComponentState.ts diff --git a/packages/twenty-front/src/modules/object-record/record-board/states/primaryDraggedRecordIdComponentState.ts b/packages/twenty-front/src/modules/object-record/record-drag/board/states/primaryDraggedRecordIdComponentState.ts similarity index 100% rename from packages/twenty-front/src/modules/object-record/record-board/states/primaryDraggedRecordIdComponentState.ts rename to packages/twenty-front/src/modules/object-record/record-drag/board/states/primaryDraggedRecordIdComponentState.ts diff --git a/packages/twenty-front/src/modules/object-record/record-drag/shared/hooks/__tests__/useEndRecordDrag.test.tsx b/packages/twenty-front/src/modules/object-record/record-drag/shared/hooks/__tests__/useEndRecordDrag.test.tsx new file mode 100644 index 0000000000..30e365523d --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-drag/shared/hooks/__tests__/useEndRecordDrag.test.tsx @@ -0,0 +1,204 @@ +import { renderHook } from '@testing-library/react'; +import { act } from 'react'; +import { RecoilRoot, useSetRecoilState } from 'recoil'; + +import { draggedRecordIdsComponentState } from '@/object-record/record-drag/board/states/draggedRecordIdsComponentState'; +import { isMultiDragActiveComponentState } from '@/object-record/record-drag/board/states/isMultiDragActiveComponentState'; +import { originalSelectionComponentState } from '@/object-record/record-drag/board/states/originalSelectionComponentState'; +import { primaryDraggedRecordIdComponentState } from '@/object-record/record-drag/board/states/primaryDraggedRecordIdComponentState'; +import { useEndRecordDrag } from '@/object-record/record-drag/shared/hooks/useEndRecordDrag'; +import { draggedRecordIdsTableComponentState } from '@/object-record/record-drag/table/states/draggedRecordIdsTableComponentState'; +import { isMultiDragActiveTableComponentState } from '@/object-record/record-drag/table/states/isMultiDragActiveTableComponentState'; +import { originalSelectionTableComponentState } from '@/object-record/record-drag/table/states/originalSelectionTableComponentState'; +import { primaryDraggedRecordIdTableComponentState } from '@/object-record/record-drag/table/states/primaryDraggedRecordIdTableComponentState'; +import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; + +describe('useEndRecordDrag', () => { + const Wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + + describe('Board context', () => { + it('should clear all board drag states', () => { + const instanceId = 'test-instance'; + + const { result } = renderHook( + () => { + const isMultiDragActive = useRecoilComponentValue( + isMultiDragActiveComponentState, + instanceId, + ); + const draggedRecordIds = useRecoilComponentValue( + draggedRecordIdsComponentState, + instanceId, + ); + const primaryDraggedRecordId = useRecoilComponentValue( + primaryDraggedRecordIdComponentState, + instanceId, + ); + const originalSelection = useRecoilComponentValue( + originalSelectionComponentState, + instanceId, + ); + + const setIsMultiDragActive = useSetRecoilState( + isMultiDragActiveComponentState.atomFamily({ + instanceId, + }), + ); + const setDraggedRecordIds = useSetRecoilState( + draggedRecordIdsComponentState.atomFamily({ + instanceId, + }), + ); + const setPrimaryDraggedRecordId = useSetRecoilState( + primaryDraggedRecordIdComponentState.atomFamily({ + instanceId, + }), + ); + const setOriginalSelection = useSetRecoilState( + originalSelectionComponentState.atomFamily({ + instanceId, + }), + ); + + const { endDrag } = useEndRecordDrag('board', instanceId); + + return { + endDrag, + isMultiDragActive, + draggedRecordIds, + primaryDraggedRecordId, + originalSelection, + setIsMultiDragActive, + setDraggedRecordIds, + setPrimaryDraggedRecordId, + setOriginalSelection, + }; + }, + { wrapper: Wrapper }, + ); + + act(() => { + result.current.setIsMultiDragActive(true); + result.current.setDraggedRecordIds(['record-1', 'record-2']); + result.current.setPrimaryDraggedRecordId('record-1'); + result.current.setOriginalSelection([ + 'record-1', + 'record-2', + 'record-3', + ]); + }); + + expect(result.current.isMultiDragActive).toBe(true); + expect(result.current.draggedRecordIds).toEqual(['record-1', 'record-2']); + expect(result.current.primaryDraggedRecordId).toBe('record-1'); + expect(result.current.originalSelection).toEqual([ + 'record-1', + 'record-2', + 'record-3', + ]); + + act(() => { + result.current.endDrag(); + }); + + expect(result.current.isMultiDragActive).toBe(false); + expect(result.current.draggedRecordIds).toEqual([]); + expect(result.current.primaryDraggedRecordId).toBeNull(); + expect(result.current.originalSelection).toEqual([]); + }); + }); + + describe('Table context', () => { + it('should clear all table drag states', () => { + const instanceId = 'test-instance'; + + const { result } = renderHook( + () => { + const isMultiDragActive = useRecoilComponentValue( + isMultiDragActiveTableComponentState, + instanceId, + ); + const draggedRecordIds = useRecoilComponentValue( + draggedRecordIdsTableComponentState, + instanceId, + ); + const primaryDraggedRecordId = useRecoilComponentValue( + primaryDraggedRecordIdTableComponentState, + instanceId, + ); + const originalSelection = useRecoilComponentValue( + originalSelectionTableComponentState, + instanceId, + ); + + const setIsMultiDragActive = useSetRecoilState( + isMultiDragActiveTableComponentState.atomFamily({ + instanceId, + }), + ); + const setDraggedRecordIds = useSetRecoilState( + draggedRecordIdsTableComponentState.atomFamily({ + instanceId, + }), + ); + const setPrimaryDraggedRecordId = useSetRecoilState( + primaryDraggedRecordIdTableComponentState.atomFamily({ + instanceId, + }), + ); + const setOriginalSelection = useSetRecoilState( + originalSelectionTableComponentState.atomFamily({ + instanceId, + }), + ); + + const { endDrag } = useEndRecordDrag('table', instanceId); + + return { + endDrag, + isMultiDragActive, + draggedRecordIds, + primaryDraggedRecordId, + originalSelection, + setIsMultiDragActive, + setDraggedRecordIds, + setPrimaryDraggedRecordId, + setOriginalSelection, + }; + }, + { wrapper: Wrapper }, + ); + + act(() => { + result.current.setIsMultiDragActive(true); + result.current.setDraggedRecordIds(['record-1', 'record-2']); + result.current.setPrimaryDraggedRecordId('record-1'); + result.current.setOriginalSelection([ + 'record-1', + 'record-2', + 'record-3', + ]); + }); + + expect(result.current.isMultiDragActive).toBe(true); + expect(result.current.draggedRecordIds).toEqual(['record-1', 'record-2']); + expect(result.current.primaryDraggedRecordId).toBe('record-1'); + expect(result.current.originalSelection).toEqual([ + 'record-1', + 'record-2', + 'record-3', + ]); + + act(() => { + result.current.endDrag(); + }); + + expect(result.current.isMultiDragActive).toBe(false); + expect(result.current.draggedRecordIds).toEqual([]); + expect(result.current.primaryDraggedRecordId).toBeNull(); + expect(result.current.originalSelection).toEqual([]); + }); + }); +}); diff --git a/packages/twenty-front/src/modules/object-record/record-drag/shared/hooks/__tests__/useMultiDragState.test.tsx b/packages/twenty-front/src/modules/object-record/record-drag/shared/hooks/__tests__/useMultiDragState.test.tsx new file mode 100644 index 0000000000..8d1a10461d --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-drag/shared/hooks/__tests__/useMultiDragState.test.tsx @@ -0,0 +1,140 @@ +import { type DragStart } from '@hello-pangea/dnd'; +import { renderHook } from '@testing-library/react'; +import { act } from 'react'; + +import { useMultiDragState } from '@/object-record/record-drag/shared/hooks/useMultiDragState'; + +const createDragStart = (draggableId: string, index: number): DragStart => ({ + draggableId, + type: 'record', + source: { + droppableId: 'test-droppable', + index, + }, + mode: 'FLUID', +}); + +describe('useMultiDragState', () => { + it('should have initial state with no drag', () => { + const { result } = renderHook(() => useMultiDragState()); + + expect(result.current.multiDragState.isDragging).toBe(false); + expect(result.current.multiDragState.draggedRecordIds).toEqual([]); + expect(result.current.multiDragState.primaryDraggedRecordId).toBeNull(); + expect(result.current.multiDragState.originalSelection).toEqual([]); + }); + + it('should handle single drag when record is not in selection', () => { + const { result } = renderHook(() => useMultiDragState()); + + const dragStart = createDragStart('record-1', 0); + const selectedRecordIds = ['record-2', 'record-3']; + + act(() => { + result.current.startDrag(dragStart, selectedRecordIds); + }); + + expect(result.current.multiDragState.isDragging).toBe(true); + expect(result.current.multiDragState.draggedRecordIds).toEqual([ + 'record-1', + ]); + expect(result.current.multiDragState.primaryDraggedRecordId).toBe( + 'record-1', + ); + expect(result.current.multiDragState.originalSelection).toEqual([ + 'record-1', + ]); + }); + + it('should handle single drag when only one record is selected', () => { + const { result } = renderHook(() => useMultiDragState()); + + const dragStart = createDragStart('record-1', 0); + const selectedRecordIds = ['record-1']; + + act(() => { + result.current.startDrag(dragStart, selectedRecordIds); + }); + + expect(result.current.multiDragState.isDragging).toBe(true); + expect(result.current.multiDragState.draggedRecordIds).toEqual([ + 'record-1', + ]); + expect(result.current.multiDragState.primaryDraggedRecordId).toBe( + 'record-1', + ); + expect(result.current.multiDragState.originalSelection).toEqual([ + 'record-1', + ]); + }); + + it('should handle multi drag when multiple records are selected', () => { + const { result } = renderHook(() => useMultiDragState()); + + const dragStart = createDragStart('record-2', 1); + const selectedRecordIds = ['record-1', 'record-2', 'record-3']; + + act(() => { + result.current.startDrag(dragStart, selectedRecordIds); + }); + + expect(result.current.multiDragState.isDragging).toBe(true); + expect(result.current.multiDragState.draggedRecordIds).toEqual([ + 'record-1', + 'record-2', + 'record-3', + ]); + expect(result.current.multiDragState.primaryDraggedRecordId).toBe( + 'record-2', + ); + expect(result.current.multiDragState.originalSelection).toEqual([ + 'record-1', + 'record-2', + 'record-3', + ]); + }); + + it('should clear state when endDrag is called', () => { + const { result } = renderHook(() => useMultiDragState()); + + const dragStart = createDragStart('record-1', 0); + const selectedRecordIds = ['record-1', 'record-2']; + + act(() => { + result.current.startDrag(dragStart, selectedRecordIds); + }); + + expect(result.current.multiDragState.isDragging).toBe(true); + + act(() => { + result.current.endDrag(); + }); + + expect(result.current.multiDragState.isDragging).toBe(false); + expect(result.current.multiDragState.draggedRecordIds).toEqual([]); + expect(result.current.multiDragState.primaryDraggedRecordId).toBeNull(); + expect(result.current.multiDragState.originalSelection).toEqual([]); + }); + + it('should handle empty selection', () => { + const { result } = renderHook(() => useMultiDragState()); + + const dragStart = createDragStart('record-1', 0); + const selectedRecordIds: string[] = []; + + act(() => { + result.current.startDrag(dragStart, selectedRecordIds); + }); + + expect(result.current.multiDragState.isDragging).toBe(true); + expect(result.current.multiDragState.draggedRecordIds).toEqual([ + 'record-1', + ]); + expect(result.current.multiDragState.primaryDraggedRecordId).toBe( + 'record-1', + ); + expect(result.current.multiDragState.originalSelection).toEqual([ + 'record-1', + ]); + }); +}); diff --git a/packages/twenty-front/src/modules/object-record/record-drag/shared/hooks/__tests__/useRecordDragState.test.tsx b/packages/twenty-front/src/modules/object-record/record-drag/shared/hooks/__tests__/useRecordDragState.test.tsx new file mode 100644 index 0000000000..a193ec4eaa --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-drag/shared/hooks/__tests__/useRecordDragState.test.tsx @@ -0,0 +1,159 @@ +import { renderHook } from '@testing-library/react'; +import { act } from 'react'; +import { RecoilRoot, useSetRecoilState } from 'recoil'; + +import { draggedRecordIdsComponentState } from '@/object-record/record-drag/board/states/draggedRecordIdsComponentState'; +import { isMultiDragActiveComponentState } from '@/object-record/record-drag/board/states/isMultiDragActiveComponentState'; +import { originalSelectionComponentState } from '@/object-record/record-drag/board/states/originalSelectionComponentState'; +import { primaryDraggedRecordIdComponentState } from '@/object-record/record-drag/board/states/primaryDraggedRecordIdComponentState'; +import { useRecordDragState } from '@/object-record/record-drag/shared/hooks/useRecordDragState'; +import { draggedRecordIdsTableComponentState } from '@/object-record/record-drag/table/states/draggedRecordIdsTableComponentState'; +import { isMultiDragActiveTableComponentState } from '@/object-record/record-drag/table/states/isMultiDragActiveTableComponentState'; +import { originalSelectionTableComponentState } from '@/object-record/record-drag/table/states/originalSelectionTableComponentState'; +import { primaryDraggedRecordIdTableComponentState } from '@/object-record/record-drag/table/states/primaryDraggedRecordIdTableComponentState'; + +describe('useRecordDragState', () => { + const Wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + + describe('Board context', () => { + it('should return current board drag state', () => { + const instanceId = 'test-instance'; + + const { result } = renderHook( + () => { + const dragState = useRecordDragState('board', instanceId); + + const setIsMultiDragActive = useSetRecoilState( + isMultiDragActiveComponentState.atomFamily({ + instanceId, + }), + ); + const setDraggedRecordIds = useSetRecoilState( + draggedRecordIdsComponentState.atomFamily({ + instanceId, + }), + ); + const setPrimaryDraggedRecordId = useSetRecoilState( + primaryDraggedRecordIdComponentState.atomFamily({ + instanceId, + }), + ); + const setOriginalSelection = useSetRecoilState( + originalSelectionComponentState.atomFamily({ + instanceId, + }), + ); + + return { + dragState, + setIsMultiDragActive, + setDraggedRecordIds, + setPrimaryDraggedRecordId, + setOriginalSelection, + }; + }, + { wrapper: Wrapper }, + ); + + expect(result.current.dragState.isDragging).toBe(false); + expect(result.current.dragState.draggedRecordIds).toEqual([]); + expect(result.current.dragState.primaryDraggedRecordId).toBeNull(); + expect(result.current.dragState.originalSelection).toEqual([]); + + act(() => { + result.current.setIsMultiDragActive(true); + result.current.setDraggedRecordIds(['record-1', 'record-2']); + result.current.setPrimaryDraggedRecordId('record-1'); + result.current.setOriginalSelection([ + 'record-1', + 'record-2', + 'record-3', + ]); + }); + + expect(result.current.dragState.isDragging).toBe(true); + expect(result.current.dragState.draggedRecordIds).toEqual([ + 'record-1', + 'record-2', + ]); + expect(result.current.dragState.primaryDraggedRecordId).toBe('record-1'); + expect(result.current.dragState.originalSelection).toEqual([ + 'record-1', + 'record-2', + 'record-3', + ]); + }); + }); + + describe('Table context', () => { + it('should return current table drag state', () => { + const instanceId = 'test-instance'; + + const { result } = renderHook( + () => { + const dragState = useRecordDragState('table', instanceId); + + const setIsMultiDragActive = useSetRecoilState( + isMultiDragActiveTableComponentState.atomFamily({ + instanceId, + }), + ); + const setDraggedRecordIds = useSetRecoilState( + draggedRecordIdsTableComponentState.atomFamily({ + instanceId, + }), + ); + const setPrimaryDraggedRecordId = useSetRecoilState( + primaryDraggedRecordIdTableComponentState.atomFamily({ + instanceId, + }), + ); + const setOriginalSelection = useSetRecoilState( + originalSelectionTableComponentState.atomFamily({ + instanceId, + }), + ); + + return { + dragState, + setIsMultiDragActive, + setDraggedRecordIds, + setPrimaryDraggedRecordId, + setOriginalSelection, + }; + }, + { wrapper: Wrapper }, + ); + + expect(result.current.dragState.isDragging).toBe(false); + expect(result.current.dragState.draggedRecordIds).toEqual([]); + expect(result.current.dragState.primaryDraggedRecordId).toBeNull(); + expect(result.current.dragState.originalSelection).toEqual([]); + + act(() => { + result.current.setIsMultiDragActive(true); + result.current.setDraggedRecordIds(['record-1', 'record-2']); + result.current.setPrimaryDraggedRecordId('record-1'); + result.current.setOriginalSelection([ + 'record-1', + 'record-2', + 'record-3', + ]); + }); + + expect(result.current.dragState.isDragging).toBe(true); + expect(result.current.dragState.draggedRecordIds).toEqual([ + 'record-1', + 'record-2', + ]); + expect(result.current.dragState.primaryDraggedRecordId).toBe('record-1'); + expect(result.current.dragState.originalSelection).toEqual([ + 'record-1', + 'record-2', + 'record-3', + ]); + }); + }); +}); diff --git a/packages/twenty-front/src/modules/object-record/record-drag/shared/hooks/__tests__/useStartRecordDrag.test.tsx b/packages/twenty-front/src/modules/object-record/record-drag/shared/hooks/__tests__/useStartRecordDrag.test.tsx new file mode 100644 index 0000000000..8c9d10e36f --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-drag/shared/hooks/__tests__/useStartRecordDrag.test.tsx @@ -0,0 +1,339 @@ +import { type DragStart } from '@hello-pangea/dnd'; +import { renderHook } from '@testing-library/react'; +import { act } from 'react'; +import { RecoilRoot } from 'recoil'; + +import { draggedRecordIdsComponentState } from '@/object-record/record-drag/board/states/draggedRecordIdsComponentState'; +import { isMultiDragActiveComponentState } from '@/object-record/record-drag/board/states/isMultiDragActiveComponentState'; +import { originalSelectionComponentState } from '@/object-record/record-drag/board/states/originalSelectionComponentState'; +import { primaryDraggedRecordIdComponentState } from '@/object-record/record-drag/board/states/primaryDraggedRecordIdComponentState'; +import { useStartRecordDrag } from '@/object-record/record-drag/shared/hooks/useStartRecordDrag'; +import { draggedRecordIdsTableComponentState } from '@/object-record/record-drag/table/states/draggedRecordIdsTableComponentState'; +import { isMultiDragActiveTableComponentState } from '@/object-record/record-drag/table/states/isMultiDragActiveTableComponentState'; +import { originalSelectionTableComponentState } from '@/object-record/record-drag/table/states/originalSelectionTableComponentState'; +import { primaryDraggedRecordIdTableComponentState } from '@/object-record/record-drag/table/states/primaryDraggedRecordIdTableComponentState'; +import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; + +const createDragStart = (draggableId: string, index: number): DragStart => ({ + draggableId, + type: 'record', + source: { + droppableId: 'test-droppable', + index, + }, + mode: 'FLUID', +}); + +describe('useStartRecordDrag', () => { + const Wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + + describe('Board context', () => { + it('should set single drag state when dragged record is not in selection', () => { + const instanceId = 'test-instance'; + + const { result } = renderHook( + () => { + const isMultiDragActive = useRecoilComponentValue( + isMultiDragActiveComponentState, + instanceId, + ); + const draggedRecordIds = useRecoilComponentValue( + draggedRecordIdsComponentState, + instanceId, + ); + const primaryDraggedRecordId = useRecoilComponentValue( + primaryDraggedRecordIdComponentState, + instanceId, + ); + const originalSelection = useRecoilComponentValue( + originalSelectionComponentState, + instanceId, + ); + + const { startDrag } = useStartRecordDrag('board', instanceId); + + return { + startDrag, + isMultiDragActive, + draggedRecordIds, + primaryDraggedRecordId, + originalSelection, + }; + }, + { wrapper: Wrapper }, + ); + + const dragStart = createDragStart('record-1', 0); + const selectedRecordIds = ['record-2', 'record-3']; + + act(() => { + result.current.startDrag(dragStart, selectedRecordIds); + }); + + expect(result.current.isMultiDragActive).toBe(true); + expect(result.current.draggedRecordIds).toEqual(['record-1']); + expect(result.current.primaryDraggedRecordId).toBe('record-1'); + expect(result.current.originalSelection).toEqual(['record-1']); + }); + + it('should set single drag state when only one record is selected', () => { + const instanceId = 'test-instance'; + + const { result } = renderHook( + () => { + const isMultiDragActive = useRecoilComponentValue( + isMultiDragActiveComponentState, + instanceId, + ); + const draggedRecordIds = useRecoilComponentValue( + draggedRecordIdsComponentState, + instanceId, + ); + const primaryDraggedRecordId = useRecoilComponentValue( + primaryDraggedRecordIdComponentState, + instanceId, + ); + const originalSelection = useRecoilComponentValue( + originalSelectionComponentState, + instanceId, + ); + + const { startDrag } = useStartRecordDrag('board', instanceId); + + return { + startDrag, + isMultiDragActive, + draggedRecordIds, + primaryDraggedRecordId, + originalSelection, + }; + }, + { wrapper: Wrapper }, + ); + + const dragStart = createDragStart('record-1', 0); + const selectedRecordIds = ['record-1']; + + act(() => { + result.current.startDrag(dragStart, selectedRecordIds); + }); + + expect(result.current.isMultiDragActive).toBe(true); + expect(result.current.draggedRecordIds).toEqual(['record-1']); + expect(result.current.primaryDraggedRecordId).toBe('record-1'); + expect(result.current.originalSelection).toEqual(['record-1']); + }); + + it('should set multi drag state when multiple records are selected', () => { + const instanceId = 'test-instance'; + + const { result } = renderHook( + () => { + const isMultiDragActive = useRecoilComponentValue( + isMultiDragActiveComponentState, + instanceId, + ); + const draggedRecordIds = useRecoilComponentValue( + draggedRecordIdsComponentState, + instanceId, + ); + const primaryDraggedRecordId = useRecoilComponentValue( + primaryDraggedRecordIdComponentState, + instanceId, + ); + const originalSelection = useRecoilComponentValue( + originalSelectionComponentState, + instanceId, + ); + + const { startDrag } = useStartRecordDrag('board', instanceId); + + return { + startDrag, + isMultiDragActive, + draggedRecordIds, + primaryDraggedRecordId, + originalSelection, + }; + }, + { wrapper: Wrapper }, + ); + + const dragStart = createDragStart('record-2', 1); + const selectedRecordIds = ['record-1', 'record-2', 'record-3']; + + act(() => { + result.current.startDrag(dragStart, selectedRecordIds); + }); + + expect(result.current.isMultiDragActive).toBe(true); + expect(result.current.draggedRecordIds).toEqual([ + 'record-1', + 'record-2', + 'record-3', + ]); + expect(result.current.primaryDraggedRecordId).toBe('record-2'); + expect(result.current.originalSelection).toEqual([ + 'record-1', + 'record-2', + 'record-3', + ]); + }); + + it('should handle empty selection', () => { + const instanceId = 'test-instance'; + + const { result } = renderHook( + () => { + const isMultiDragActive = useRecoilComponentValue( + isMultiDragActiveComponentState, + instanceId, + ); + const draggedRecordIds = useRecoilComponentValue( + draggedRecordIdsComponentState, + instanceId, + ); + const primaryDraggedRecordId = useRecoilComponentValue( + primaryDraggedRecordIdComponentState, + instanceId, + ); + const originalSelection = useRecoilComponentValue( + originalSelectionComponentState, + instanceId, + ); + + const { startDrag } = useStartRecordDrag('board', instanceId); + + return { + startDrag, + isMultiDragActive, + draggedRecordIds, + primaryDraggedRecordId, + originalSelection, + }; + }, + { wrapper: Wrapper }, + ); + + const dragStart = createDragStart('record-1', 0); + const selectedRecordIds: string[] = []; + + act(() => { + result.current.startDrag(dragStart, selectedRecordIds); + }); + + expect(result.current.isMultiDragActive).toBe(true); + expect(result.current.draggedRecordIds).toEqual(['record-1']); + expect(result.current.primaryDraggedRecordId).toBe('record-1'); + expect(result.current.originalSelection).toEqual(['record-1']); + }); + }); + + describe('Table context', () => { + it('should set single drag state when dragged record is not in selection', () => { + const instanceId = 'test-instance'; + + const { result } = renderHook( + () => { + const isMultiDragActive = useRecoilComponentValue( + isMultiDragActiveTableComponentState, + instanceId, + ); + const draggedRecordIds = useRecoilComponentValue( + draggedRecordIdsTableComponentState, + instanceId, + ); + const primaryDraggedRecordId = useRecoilComponentValue( + primaryDraggedRecordIdTableComponentState, + instanceId, + ); + const originalSelection = useRecoilComponentValue( + originalSelectionTableComponentState, + instanceId, + ); + + const { startDrag } = useStartRecordDrag('table', instanceId); + + return { + startDrag, + isMultiDragActive, + draggedRecordIds, + primaryDraggedRecordId, + originalSelection, + }; + }, + { wrapper: Wrapper }, + ); + + const dragStart = createDragStart('record-1', 0); + const selectedRecordIds = ['record-2', 'record-3']; + + act(() => { + result.current.startDrag(dragStart, selectedRecordIds); + }); + + expect(result.current.isMultiDragActive).toBe(true); + expect(result.current.draggedRecordIds).toEqual(['record-1']); + expect(result.current.primaryDraggedRecordId).toBe('record-1'); + expect(result.current.originalSelection).toEqual(['record-1']); + }); + + it('should set multi drag state when multiple records are selected', () => { + const instanceId = 'test-instance'; + + const { result } = renderHook( + () => { + const isMultiDragActive = useRecoilComponentValue( + isMultiDragActiveTableComponentState, + instanceId, + ); + const draggedRecordIds = useRecoilComponentValue( + draggedRecordIdsTableComponentState, + instanceId, + ); + const primaryDraggedRecordId = useRecoilComponentValue( + primaryDraggedRecordIdTableComponentState, + instanceId, + ); + const originalSelection = useRecoilComponentValue( + originalSelectionTableComponentState, + instanceId, + ); + + const { startDrag } = useStartRecordDrag('table', instanceId); + + return { + startDrag, + isMultiDragActive, + draggedRecordIds, + primaryDraggedRecordId, + originalSelection, + }; + }, + { wrapper: Wrapper }, + ); + + const dragStart = createDragStart('record-2', 1); + const selectedRecordIds = ['record-1', 'record-2', 'record-3']; + + act(() => { + result.current.startDrag(dragStart, selectedRecordIds); + }); + + expect(result.current.isMultiDragActive).toBe(true); + expect(result.current.draggedRecordIds).toEqual([ + 'record-1', + 'record-2', + 'record-3', + ]); + expect(result.current.primaryDraggedRecordId).toBe('record-2'); + expect(result.current.originalSelection).toEqual([ + 'record-1', + 'record-2', + 'record-3', + ]); + }); + }); +}); diff --git a/packages/twenty-front/src/modules/object-record/record-drag/shared/hooks/useEndRecordDrag.ts b/packages/twenty-front/src/modules/object-record/record-drag/shared/hooks/useEndRecordDrag.ts new file mode 100644 index 0000000000..208a5a7ecc --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-drag/shared/hooks/useEndRecordDrag.ts @@ -0,0 +1,65 @@ +import { useRecoilCallback } from 'recoil'; + +import { type RecordDragContext } from '@/object-record/record-drag/shared/types/RecordDragContext'; +import { useRecoilComponentCallbackState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackState'; + +import { draggedRecordIdsComponentState } from '@/object-record/record-drag/board/states/draggedRecordIdsComponentState'; +import { isMultiDragActiveComponentState } from '@/object-record/record-drag/board/states/isMultiDragActiveComponentState'; +import { originalSelectionComponentState } from '@/object-record/record-drag/board/states/originalSelectionComponentState'; +import { primaryDraggedRecordIdComponentState } from '@/object-record/record-drag/board/states/primaryDraggedRecordIdComponentState'; + +import { draggedRecordIdsTableComponentState } from '@/object-record/record-drag/table/states/draggedRecordIdsTableComponentState'; +import { isMultiDragActiveTableComponentState } from '@/object-record/record-drag/table/states/isMultiDragActiveTableComponentState'; +import { originalSelectionTableComponentState } from '@/object-record/record-drag/table/states/originalSelectionTableComponentState'; +import { primaryDraggedRecordIdTableComponentState } from '@/object-record/record-drag/table/states/primaryDraggedRecordIdTableComponentState'; + +export const useEndRecordDrag = ( + context: RecordDragContext, + instanceId?: string, +) => { + const isMultiDragActiveCallbackState = useRecoilComponentCallbackState( + context === 'board' + ? isMultiDragActiveComponentState + : isMultiDragActiveTableComponentState, + instanceId, + ); + + const draggedRecordIdsCallbackState = useRecoilComponentCallbackState( + context === 'board' + ? draggedRecordIdsComponentState + : draggedRecordIdsTableComponentState, + instanceId, + ); + + const primaryDraggedRecordIdCallbackState = useRecoilComponentCallbackState( + context === 'board' + ? primaryDraggedRecordIdComponentState + : primaryDraggedRecordIdTableComponentState, + instanceId, + ); + + const originalSelectionCallbackState = useRecoilComponentCallbackState( + context === 'board' + ? originalSelectionComponentState + : originalSelectionTableComponentState, + instanceId, + ); + + const endDrag = useRecoilCallback( + ({ set }) => + () => { + set(isMultiDragActiveCallbackState, false); + set(draggedRecordIdsCallbackState, []); + set(primaryDraggedRecordIdCallbackState, null); + set(originalSelectionCallbackState, []); + }, + [ + isMultiDragActiveCallbackState, + draggedRecordIdsCallbackState, + primaryDraggedRecordIdCallbackState, + originalSelectionCallbackState, + ], + ); + + return { endDrag }; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-board/hooks/useMultiDragState.ts b/packages/twenty-front/src/modules/object-record/record-drag/shared/hooks/useMultiDragState.ts similarity index 84% rename from packages/twenty-front/src/modules/object-record/record-board/hooks/useMultiDragState.ts rename to packages/twenty-front/src/modules/object-record/record-drag/shared/hooks/useMultiDragState.ts index ded2969ad4..03c1cdd13c 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/hooks/useMultiDragState.ts +++ b/packages/twenty-front/src/modules/object-record/record-drag/shared/hooks/useMultiDragState.ts @@ -1,13 +1,7 @@ +import { type MultiDragState } from '@/object-record/record-drag/shared/types/MultiDragState'; +import { getDragOperationType } from '@/object-record/record-drag/shared/utils/getDragOperationType'; import { type DragStart } from '@hello-pangea/dnd'; import { useState } from 'react'; -import { getDragOperationType } from '../utils/getDragOperationType'; - -export interface MultiDragState { - isDragging: boolean; - draggedRecordIds: string[]; - primaryDraggedRecordId: string | null; - originalSelection: string[]; -} export const useMultiDragState = () => { const [multiDragState, setMultiDragState] = useState({ diff --git a/packages/twenty-front/src/modules/object-record/record-drag/shared/hooks/useRecordDragState.ts b/packages/twenty-front/src/modules/object-record/record-drag/shared/hooks/useRecordDragState.ts new file mode 100644 index 0000000000..fc10708b6e --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-drag/shared/hooks/useRecordDragState.ts @@ -0,0 +1,52 @@ +import { type RecordDragContext } from '@/object-record/record-drag/shared/types/RecordDragContext'; +import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; + +import { draggedRecordIdsComponentState } from '@/object-record/record-drag/board/states/draggedRecordIdsComponentState'; +import { isMultiDragActiveComponentState } from '@/object-record/record-drag/board/states/isMultiDragActiveComponentState'; +import { originalSelectionComponentState } from '@/object-record/record-drag/board/states/originalSelectionComponentState'; +import { primaryDraggedRecordIdComponentState } from '@/object-record/record-drag/board/states/primaryDraggedRecordIdComponentState'; + +import { draggedRecordIdsTableComponentState } from '@/object-record/record-drag/table/states/draggedRecordIdsTableComponentState'; +import { isMultiDragActiveTableComponentState } from '@/object-record/record-drag/table/states/isMultiDragActiveTableComponentState'; +import { originalSelectionTableComponentState } from '@/object-record/record-drag/table/states/originalSelectionTableComponentState'; +import { primaryDraggedRecordIdTableComponentState } from '@/object-record/record-drag/table/states/primaryDraggedRecordIdTableComponentState'; + +export const useRecordDragState = ( + context: RecordDragContext, + instanceId?: string, +) => { + const isDragging = useRecoilComponentValue( + context === 'board' + ? isMultiDragActiveComponentState + : isMultiDragActiveTableComponentState, + instanceId, + ); + + const draggedRecordIds = useRecoilComponentValue( + context === 'board' + ? draggedRecordIdsComponentState + : draggedRecordIdsTableComponentState, + instanceId, + ); + + const primaryDraggedRecordId = useRecoilComponentValue( + context === 'board' + ? primaryDraggedRecordIdComponentState + : primaryDraggedRecordIdTableComponentState, + instanceId, + ); + + const originalSelection = useRecoilComponentValue( + context === 'board' + ? originalSelectionComponentState + : originalSelectionTableComponentState, + instanceId, + ); + + return { + isDragging, + draggedRecordIds, + primaryDraggedRecordId, + originalSelection, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-drag/shared/hooks/useStartRecordDrag.ts b/packages/twenty-front/src/modules/object-record/record-drag/shared/hooks/useStartRecordDrag.ts new file mode 100644 index 0000000000..eebd3d4abc --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-drag/shared/hooks/useStartRecordDrag.ts @@ -0,0 +1,81 @@ +import { type DragStart } from '@hello-pangea/dnd'; +import { useRecoilCallback } from 'recoil'; + +import { type RecordDragContext } from '@/object-record/record-drag/shared/types/RecordDragContext'; +import { getDragOperationType } from '@/object-record/record-drag/shared/utils/getDragOperationType'; +import { useRecoilComponentCallbackState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackState'; + +import { draggedRecordIdsComponentState } from '@/object-record/record-drag/board/states/draggedRecordIdsComponentState'; +import { isMultiDragActiveComponentState } from '@/object-record/record-drag/board/states/isMultiDragActiveComponentState'; +import { originalSelectionComponentState } from '@/object-record/record-drag/board/states/originalSelectionComponentState'; +import { primaryDraggedRecordIdComponentState } from '@/object-record/record-drag/board/states/primaryDraggedRecordIdComponentState'; + +import { draggedRecordIdsTableComponentState } from '@/object-record/record-drag/table/states/draggedRecordIdsTableComponentState'; +import { isMultiDragActiveTableComponentState } from '@/object-record/record-drag/table/states/isMultiDragActiveTableComponentState'; +import { originalSelectionTableComponentState } from '@/object-record/record-drag/table/states/originalSelectionTableComponentState'; +import { primaryDraggedRecordIdTableComponentState } from '@/object-record/record-drag/table/states/primaryDraggedRecordIdTableComponentState'; + +export const useStartRecordDrag = ( + context: RecordDragContext, + instanceId?: string, +) => { + const isMultiDragActiveCallbackState = useRecoilComponentCallbackState( + context === 'board' + ? isMultiDragActiveComponentState + : isMultiDragActiveTableComponentState, + instanceId, + ); + + const draggedRecordIdsCallbackState = useRecoilComponentCallbackState( + context === 'board' + ? draggedRecordIdsComponentState + : draggedRecordIdsTableComponentState, + instanceId, + ); + + const primaryDraggedRecordIdCallbackState = useRecoilComponentCallbackState( + context === 'board' + ? primaryDraggedRecordIdComponentState + : primaryDraggedRecordIdTableComponentState, + instanceId, + ); + + const originalSelectionCallbackState = useRecoilComponentCallbackState( + context === 'board' + ? originalSelectionComponentState + : originalSelectionTableComponentState, + instanceId, + ); + + const startDrag = useRecoilCallback( + ({ set }) => + (start: DragStart, selectedRecordIds: string[]) => { + const draggedRecordId = start.draggableId; + + const dragOperationType = getDragOperationType({ + draggedRecordId, + selectedRecordIds, + }); + + if (dragOperationType === 'multi') { + set(isMultiDragActiveCallbackState, true); + set(draggedRecordIdsCallbackState, selectedRecordIds); + set(primaryDraggedRecordIdCallbackState, draggedRecordId); + set(originalSelectionCallbackState, selectedRecordIds); + } else { + set(isMultiDragActiveCallbackState, true); + set(draggedRecordIdsCallbackState, [draggedRecordId]); + set(primaryDraggedRecordIdCallbackState, draggedRecordId); + set(originalSelectionCallbackState, [draggedRecordId]); + } + }, + [ + isMultiDragActiveCallbackState, + draggedRecordIdsCallbackState, + primaryDraggedRecordIdCallbackState, + originalSelectionCallbackState, + ], + ); + + return { startDrag }; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-board/types/DragOperationType.ts b/packages/twenty-front/src/modules/object-record/record-drag/shared/types/DragOperationType.ts similarity index 100% rename from packages/twenty-front/src/modules/object-record/record-board/types/DragOperationType.ts rename to packages/twenty-front/src/modules/object-record/record-drag/shared/types/DragOperationType.ts diff --git a/packages/twenty-front/src/modules/object-record/record-drag/shared/types/MultiDragResult.ts b/packages/twenty-front/src/modules/object-record/record-drag/shared/types/MultiDragResult.ts new file mode 100644 index 0000000000..80280eb2ac --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-drag/shared/types/MultiDragResult.ts @@ -0,0 +1,5 @@ +import { type RecordDragUpdate } from '@/object-record/record-drag/shared/types/RecordDragUpdate'; + +export type MultiDragResult = { + recordUpdates: RecordDragUpdate[]; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-drag/shared/types/MultiDragState.ts b/packages/twenty-front/src/modules/object-record/record-drag/shared/types/MultiDragState.ts new file mode 100644 index 0000000000..b98a22386c --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-drag/shared/types/MultiDragState.ts @@ -0,0 +1,6 @@ +export type MultiDragState = { + isDragging: boolean; + draggedRecordIds: string[]; + primaryDraggedRecordId: string | null; + originalSelection: string[]; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-drag/shared/types/RecordDragContext.ts b/packages/twenty-front/src/modules/object-record/record-drag/shared/types/RecordDragContext.ts new file mode 100644 index 0000000000..322093698d --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-drag/shared/types/RecordDragContext.ts @@ -0,0 +1 @@ +export type RecordDragContext = 'board' | 'table'; diff --git a/packages/twenty-front/src/modules/object-record/record-drag/shared/types/RecordDragPositionData.ts b/packages/twenty-front/src/modules/object-record/record-drag/shared/types/RecordDragPositionData.ts new file mode 100644 index 0000000000..1be7b4a920 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-drag/shared/types/RecordDragPositionData.ts @@ -0,0 +1,4 @@ +export type RecordDragPositionData = { + recordId: string; + position?: number; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-drag/shared/types/RecordDragUpdate.ts b/packages/twenty-front/src/modules/object-record/record-drag/shared/types/RecordDragUpdate.ts new file mode 100644 index 0000000000..79f581a56d --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-drag/shared/types/RecordDragUpdate.ts @@ -0,0 +1,6 @@ +export type RecordDragUpdate = { + recordId: string; + position: number; + groupValue?: string | null; + selectFieldName?: string; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-drag/shared/utils/__tests__/calculateDragPositions.test.ts b/packages/twenty-front/src/modules/object-record/record-drag/shared/utils/__tests__/calculateDragPositions.test.ts new file mode 100644 index 0000000000..666e15c0cd --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-drag/shared/utils/__tests__/calculateDragPositions.test.ts @@ -0,0 +1,194 @@ +import { type RecordDragPositionData } from '@/object-record/record-drag/shared/types/RecordDragPositionData'; +import { calculateDragPositions } from '@/object-record/record-drag/shared/utils/calculateDragPositions'; + +describe('calculateDragPositions', () => { + const mockRecordPositionData: RecordDragPositionData[] = [ + { recordId: 'record-1', position: 1 }, + { recordId: 'record-2', position: 2 }, + { recordId: 'record-3', position: 3 }, + { recordId: 'record-4', position: 4 }, + { recordId: 'record-5', position: 5 }, + ]; + + describe('Single record drag', () => { + it('should calculate position when moving record forward', () => { + const result = calculateDragPositions({ + recordIds: ['record-1', 'record-2', 'record-3', 'record-4', 'record-5'], + recordsToMove: ['record-2'], + destinationIndex: 3, + recordPositionData: mockRecordPositionData, + }); + + expect(result['record-2']).toBeGreaterThan(3); + expect(result['record-2']).toBeLessThan(5); + }); + + it('should calculate position when moving record backward', () => { + const result = calculateDragPositions({ + recordIds: ['record-1', 'record-2', 'record-3', 'record-4'], + recordsToMove: ['record-4'], + destinationIndex: 1, + recordPositionData: mockRecordPositionData, + }); + + expect(result['record-4']).toBeGreaterThan(1); + expect(result['record-4']).toBeLessThan(2); + }); + + it('should handle moving to the beginning', () => { + const result = calculateDragPositions({ + recordIds: ['record-1', 'record-2', 'record-3'], + recordsToMove: ['record-3'], + destinationIndex: 0, + recordPositionData: mockRecordPositionData, + }); + + expect(result['record-3']).toBeLessThan(1); + }); + + it('should handle moving to the end', () => { + const result = calculateDragPositions({ + recordIds: ['record-1', 'record-2', 'record-3'], + recordsToMove: ['record-1'], + destinationIndex: 2, + recordPositionData: mockRecordPositionData, + }); + + expect(result['record-1']).toBeGreaterThan(3); + }); + }); + + describe('Multi record drag', () => { + it('should calculate positions for multiple records with proper spacing', () => { + const result = calculateDragPositions({ + recordIds: ['record-1', 'record-2', 'record-3', 'record-4', 'record-5'], + recordsToMove: ['record-1', 'record-3'], + destinationIndex: 3, + recordPositionData: mockRecordPositionData, + }); + + expect(result['record-1']).toBeGreaterThan(4); + expect(result['record-1']).toBeLessThan(5); + expect(result['record-3']).toBeGreaterThan(result['record-1']); + expect(result['record-3']).toBeLessThan(5); + }); + + it('should maintain order of dragged records', () => { + const result = calculateDragPositions({ + recordIds: ['record-1', 'record-2', 'record-3', 'record-4', 'record-5'], + recordsToMove: ['record-2', 'record-3', 'record-4'], + destinationIndex: 0, + recordPositionData: mockRecordPositionData, + }); + + expect(result['record-2']).toBeLessThan(result['record-3']); + expect(result['record-3']).toBeLessThan(result['record-4']); + expect(result['record-4']).toBeLessThan(1); + }); + + it('should handle moving multiple records to the end', () => { + const result = calculateDragPositions({ + recordIds: ['record-1', 'record-2', 'record-3', 'record-4'], + recordsToMove: ['record-1', 'record-2'], + destinationIndex: 2, + recordPositionData: mockRecordPositionData, + }); + + expect(typeof result['record-1']).toBe('number'); + expect(result['record-2']).toBeGreaterThan(result['record-1']); + }); + }); + + describe('Edge cases', () => { + it('should handle undefined positions', () => { + const dataWithUndefined: RecordDragPositionData[] = [ + { recordId: 'record-1', position: undefined }, + { recordId: 'record-2', position: 2 }, + { recordId: 'record-3', position: undefined }, + ]; + + const result = calculateDragPositions({ + recordIds: ['record-1', 'record-2', 'record-3'], + recordsToMove: ['record-1'], + destinationIndex: 1, + recordPositionData: dataWithUndefined, + }); + + expect(typeof result['record-1']).toBe('number'); + expect(isNaN(result['record-1'])).toBe(false); + }); + + it('should handle empty record arrays', () => { + const result = calculateDragPositions({ + recordIds: [], + recordsToMove: ['record-1'], + destinationIndex: 0, + recordPositionData: [], + }); + + expect(typeof result['record-1']).toBe('number'); + }); + + it('should handle negative positions', () => { + const negativeData: RecordDragPositionData[] = [ + { recordId: 'record-1', position: -5 }, + { recordId: 'record-2', position: -3 }, + { recordId: 'record-3', position: -1 }, + ]; + + const result = calculateDragPositions({ + recordIds: ['record-1', 'record-2', 'record-3'], + recordsToMove: ['record-1'], + destinationIndex: 2, + recordPositionData: negativeData, + }); + + expect(result['record-1']).toBeGreaterThan(-1); + }); + + it('should handle very small gaps between positions', () => { + const tightData: RecordDragPositionData[] = [ + { recordId: 'record-1', position: 1 }, + { recordId: 'record-2', position: 1.0001 }, + { recordId: 'record-3', position: 1.0002 }, + ]; + + const result = calculateDragPositions({ + recordIds: ['record-1', 'record-2', 'record-3'], + recordsToMove: ['record-3'], + destinationIndex: 0, + recordPositionData: tightData, + }); + + expect(result['record-3']).toBeLessThan(1); + }); + + it('should handle single record in list', () => { + const result = calculateDragPositions({ + recordIds: ['record-1'], + recordsToMove: ['record-1'], + destinationIndex: 0, + recordPositionData: [{ recordId: 'record-1', position: 1 }], + }); + + expect(typeof result['record-1']).toBe('number'); + }); + + it('should preserve relative order when moving multiple non-consecutive records', () => { + const result = calculateDragPositions({ + recordIds: ['record-1', 'record-2', 'record-3', 'record-4', 'record-5'], + recordsToMove: ['record-1', 'record-3', 'record-5'], + destinationIndex: 1, + recordPositionData: mockRecordPositionData, + }); + + const positions = ['record-1', 'record-3', 'record-5'].map( + (id) => result[id], + ); + + expect(positions[0]).toBeLessThan(positions[1]); + expect(positions[1]).toBeLessThan(positions[2]); + expect(typeof positions[2]).toBe('number'); + }); + }); +}); diff --git a/packages/twenty-front/src/modules/object-record/record-drag/shared/utils/__tests__/extractRecordPositions.test.ts b/packages/twenty-front/src/modules/object-record/record-drag/shared/utils/__tests__/extractRecordPositions.test.ts new file mode 100644 index 0000000000..f5e789ab64 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-drag/shared/utils/__tests__/extractRecordPositions.test.ts @@ -0,0 +1,159 @@ +import { type Snapshot } from 'recoil'; + +import { extractRecordPositions } from '@/object-record/record-drag/shared/utils/extractRecordPositions'; +import { isDefined } from 'twenty-shared/utils'; + +describe('extractRecordPositions', () => { + const createMockSnapshot = (recordData: Record): Snapshot => { + return { + getLoadable: (atom: any) => { + const match = atom.key.match(/recordStoreFamilyState__"([^"]+)"/); + const recordId = match ? match[1] : atom.key; + if (isDefined(recordData[recordId])) { + return { + getValue: () => recordData[recordId], + state: 'hasValue', + }; + } + return { + getValue: () => undefined, + state: 'hasValue', + }; + }, + } as unknown as Snapshot; + }; + + it('should extract position data from records in snapshot', () => { + const mockRecordData = { + 'record-1': { id: 'record-1', position: 1, name: 'Record 1' }, + 'record-2': { id: 'record-2', position: 2, name: 'Record 2' }, + 'record-3': { id: 'record-3', position: 3, name: 'Record 3' }, + }; + + const snapshot = createMockSnapshot(mockRecordData); + const allRecordIds = ['record-1', 'record-2', 'record-3']; + + const result = extractRecordPositions(allRecordIds, snapshot); + + expect(result).toEqual([ + { recordId: 'record-1', position: 1 }, + { recordId: 'record-2', position: 2 }, + { recordId: 'record-3', position: 3 }, + ]); + }); + + it('should handle records with undefined positions', () => { + const mockRecordData = { + 'record-1': { id: 'record-1', position: 1 }, + 'record-2': { id: 'record-2' }, + 'record-3': { id: 'record-3', position: undefined }, + }; + + const snapshot = createMockSnapshot(mockRecordData); + const allRecordIds = ['record-1', 'record-2', 'record-3']; + + const result = extractRecordPositions(allRecordIds, snapshot); + + expect(result).toEqual([ + { recordId: 'record-1', position: 1 }, + { recordId: 'record-2', position: undefined }, + { recordId: 'record-3', position: undefined }, + ]); + }); + + it('should handle non-existent records', () => { + const mockRecordData = { + 'record-1': { id: 'record-1', position: 1 }, + }; + + const snapshot = createMockSnapshot(mockRecordData); + const allRecordIds = ['record-1', 'record-2', 'non-existent']; + + const result = extractRecordPositions(allRecordIds, snapshot); + + expect(result).toEqual([ + { recordId: 'record-1', position: 1 }, + { recordId: 'record-2', position: undefined }, + { recordId: 'non-existent', position: undefined }, + ]); + }); + + it('should handle empty record list', () => { + const snapshot = createMockSnapshot({}); + const allRecordIds: string[] = []; + + const result = extractRecordPositions(allRecordIds, snapshot); + + expect(result).toEqual([]); + }); + + it('should preserve order of input record IDs', () => { + const mockRecordData = { + 'record-1': { id: 'record-1', position: 100 }, + 'record-2': { id: 'record-2', position: 50 }, + 'record-3': { id: 'record-3', position: 75 }, + }; + + const snapshot = createMockSnapshot(mockRecordData); + const allRecordIds = ['record-3', 'record-1', 'record-2']; + + const result = extractRecordPositions(allRecordIds, snapshot); + + expect(result).toEqual([ + { recordId: 'record-3', position: 75 }, + { recordId: 'record-1', position: 100 }, + { recordId: 'record-2', position: 50 }, + ]); + }); + + it('should handle null positions', () => { + const mockRecordData = { + 'record-1': { id: 'record-1', position: null }, + 'record-2': { id: 'record-2', position: 2 }, + }; + + const snapshot = createMockSnapshot(mockRecordData); + const allRecordIds = ['record-1', 'record-2']; + + const result = extractRecordPositions(allRecordIds, snapshot); + + expect(result).toEqual([ + { recordId: 'record-1', position: null }, + { recordId: 'record-2', position: 2 }, + ]); + }); + + it('should handle negative positions', () => { + const mockRecordData = { + 'record-1': { id: 'record-1', position: -5 }, + 'record-2': { id: 'record-2', position: -10 }, + }; + + const snapshot = createMockSnapshot(mockRecordData); + const allRecordIds = ['record-1', 'record-2']; + + const result = extractRecordPositions(allRecordIds, snapshot); + + expect(result).toEqual([ + { recordId: 'record-1', position: -5 }, + { recordId: 'record-2', position: -10 }, + ]); + }); + + it('should handle decimal positions', () => { + const mockRecordData = { + 'record-1': { id: 'record-1', position: 1.5 }, + 'record-2': { id: 'record-2', position: 2.75 }, + }; + + const snapshot = createMockSnapshot(mockRecordData); + const allRecordIds = ['record-1', 'record-2']; + + const result = extractRecordPositions(allRecordIds, snapshot); + + expect(result).toEqual([ + { recordId: 'record-1', position: 1.5 }, + { recordId: 'record-2', position: 2.75 }, + ]); + }); +}); diff --git a/packages/twenty-front/src/modules/object-record/record-board/utils/__tests__/getDragOperationType.test.ts b/packages/twenty-front/src/modules/object-record/record-drag/shared/utils/__tests__/getDragOperationType.test.ts similarity index 94% rename from packages/twenty-front/src/modules/object-record/record-board/utils/__tests__/getDragOperationType.test.ts rename to packages/twenty-front/src/modules/object-record/record-drag/shared/utils/__tests__/getDragOperationType.test.ts index 77024bcb61..1792d7b435 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/utils/__tests__/getDragOperationType.test.ts +++ b/packages/twenty-front/src/modules/object-record/record-drag/shared/utils/__tests__/getDragOperationType.test.ts @@ -1,4 +1,4 @@ -import { getDragOperationType } from '../getDragOperationType'; +import { getDragOperationType } from '@/object-record/record-drag/shared/utils/getDragOperationType'; describe('getDragOperationType', () => { it('should return single when dragged record is not selected', () => { diff --git a/packages/twenty-front/src/modules/object-record/record-board/utils/__tests__/getDraggedRecordPosition.test.ts b/packages/twenty-front/src/modules/object-record/record-drag/shared/utils/__tests__/getDraggedRecordPosition.test.ts similarity index 89% rename from packages/twenty-front/src/modules/object-record/record-board/utils/__tests__/getDraggedRecordPosition.test.ts rename to packages/twenty-front/src/modules/object-record/record-drag/shared/utils/__tests__/getDraggedRecordPosition.test.ts index 483c323f07..dbac58d0fb 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/utils/__tests__/getDraggedRecordPosition.test.ts +++ b/packages/twenty-front/src/modules/object-record/record-drag/shared/utils/__tests__/getDraggedRecordPosition.test.ts @@ -1,4 +1,4 @@ -import { getDraggedRecordPosition } from '../getDraggedRecordPosition'; +import { getDraggedRecordPosition } from '@/object-record/record-drag/shared/utils/getDraggedRecordPosition'; describe('getDraggedRecordPosition', () => { it('when both records defined and positive, should return the average of the two positions', () => { diff --git a/packages/twenty-front/src/modules/object-record/record-drag/shared/utils/__tests__/processGroupDragOperation.test.ts b/packages/twenty-front/src/modules/object-record/record-drag/shared/utils/__tests__/processGroupDragOperation.test.ts new file mode 100644 index 0000000000..dfa8a24f10 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-drag/shared/utils/__tests__/processGroupDragOperation.test.ts @@ -0,0 +1,343 @@ +import { type DropResult } from '@hello-pangea/dnd'; +import { type Snapshot } from 'recoil'; + +import { processGroupDragOperation } from '../processGroupDragOperation'; + +jest.mock('../getDragOperationType'); +jest.mock('../extractRecordPositions'); +jest.mock('../processSingleDrag'); +jest.mock('../processMultiDrag'); +jest.mock('@/ui/utilities/state/utils/getSnapshotValue'); + +import { getDragOperationType } from '../getDragOperationType'; +import { extractRecordPositions } from '../extractRecordPositions'; +import { processSingleDrag } from '../processSingleDrag'; +import { processMultiDrag } from '../processMultiDrag'; +import { getSnapshotValue } from '@/ui/utilities/state/utils/getSnapshotValue'; + +const mockGetDragOperationType = getDragOperationType as jest.Mock; +const mockExtractRecordPositions = extractRecordPositions as jest.Mock; +const mockProcessSingleDrag = processSingleDrag as jest.Mock; +const mockProcessMultiDrag = processMultiDrag as jest.Mock; +const mockGetSnapshotValue = getSnapshotValue as jest.Mock; + +describe('processGroupDragOperation', () => { + const mockSnapshot = {} as Snapshot; + const mockOnUpdateRecord = jest.fn(); + const mockRecordIdsByGroupFamilyState = jest.fn(); + + const createDropResult = (destination?: { + droppableId: string; + index: number; + }): DropResult => ({ + draggableId: 'record-1', + type: 'DEFAULT', + source: { + droppableId: 'group-1', + index: 0, + }, + destination: destination ?? null, + reason: 'DROP', + mode: 'FLUID', + combine: null, + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('Early returns', () => { + it('should return early when destination is not defined', () => { + const result = createDropResult(); + + processGroupDragOperation({ + result, + snapshot: mockSnapshot, + selectedRecordIds: ['record-1'], + selectFieldName: 'status', + recordIdsByGroupFamilyState: mockRecordIdsByGroupFamilyState, + onUpdateRecord: mockOnUpdateRecord, + }); + + expect(mockGetSnapshotValue).not.toHaveBeenCalled(); + expect(mockOnUpdateRecord).not.toHaveBeenCalled(); + }); + + it('should throw error when record group is not defined', () => { + const result = createDropResult({ droppableId: 'group-2', index: 1 }); + + mockGetSnapshotValue.mockReturnValueOnce(undefined); + + expect(() => { + processGroupDragOperation({ + result, + snapshot: mockSnapshot, + selectedRecordIds: ['record-1'], + selectFieldName: 'status', + recordIdsByGroupFamilyState: mockRecordIdsByGroupFamilyState, + onUpdateRecord: mockOnUpdateRecord, + }); + }).toThrow('Record group is not defined'); + }); + }); + + describe('Single drag operation', () => { + it('should process single drag operation successfully', () => { + const result = createDropResult({ droppableId: 'group-2', index: 1 }); + const mockRecordGroup = { + value: 'in-progress', + fieldMetadataId: 'field-1', + }; + const mockDestinationRecordIds = ['record-2', 'record-3']; + const mockRecordPositionData = [ + { recordId: 'record-2', position: 1 }, + { recordId: 'record-3', position: 2 }, + ]; + + mockGetSnapshotValue + .mockReturnValueOnce(mockRecordGroup) + .mockReturnValueOnce(mockDestinationRecordIds); + + mockExtractRecordPositions.mockReturnValue(mockRecordPositionData); + mockGetDragOperationType.mockReturnValue('single'); + mockProcessSingleDrag.mockReturnValue({ + recordId: 'record-1', + position: 1.5, + groupValue: 'in-progress', + }); + + processGroupDragOperation({ + result, + snapshot: mockSnapshot, + selectedRecordIds: ['record-1'], + selectFieldName: 'status', + recordIdsByGroupFamilyState: mockRecordIdsByGroupFamilyState, + onUpdateRecord: mockOnUpdateRecord, + }); + + expect(mockGetDragOperationType).toHaveBeenCalledWith({ + draggedRecordId: 'record-1', + selectedRecordIds: ['record-1'], + }); + + expect(mockProcessSingleDrag).toHaveBeenCalledWith({ + result, + recordPositionData: mockRecordPositionData, + recordIds: mockDestinationRecordIds, + groupValue: 'in-progress', + selectFieldName: 'status', + }); + + expect(mockOnUpdateRecord).toHaveBeenCalledWith({ + recordId: 'record-1', + position: 1.5, + groupValue: 'in-progress', + selectFieldName: 'status', + }); + }); + + it('should return early when single drag has no position', () => { + const result = createDropResult({ droppableId: 'group-2', index: 1 }); + const mockRecordGroup = { value: 'done', fieldMetadataId: 'field-1' }; + const mockDestinationRecordIds = ['record-2']; + + mockGetSnapshotValue + .mockReturnValueOnce(mockRecordGroup) + .mockReturnValueOnce(mockDestinationRecordIds); + + mockExtractRecordPositions.mockReturnValue([]); + mockGetDragOperationType.mockReturnValue('single'); + mockProcessSingleDrag.mockReturnValue({ + recordId: 'record-1', + position: undefined, + groupValue: 'done', + }); + + processGroupDragOperation({ + result, + snapshot: mockSnapshot, + selectedRecordIds: ['record-1'], + selectFieldName: 'status', + recordIdsByGroupFamilyState: mockRecordIdsByGroupFamilyState, + onUpdateRecord: mockOnUpdateRecord, + }); + + expect(mockOnUpdateRecord).not.toHaveBeenCalled(); + }); + }); + + describe('Multi drag operation', () => { + it('should process multi drag operation successfully', () => { + const result = createDropResult({ droppableId: 'group-2', index: 1 }); + const mockRecordGroup = { + value: 'in-review', + fieldMetadataId: 'field-1', + }; + const mockDestinationRecordIds = ['record-4', 'record-5']; + const mockRecordPositionData = [ + { recordId: 'record-4', position: 1 }, + { recordId: 'record-5', position: 2 }, + ]; + const selectedRecordIds = ['record-1', 'record-2', 'record-3']; + + mockGetSnapshotValue + .mockReturnValueOnce(mockRecordGroup) + .mockReturnValueOnce(mockDestinationRecordIds); + + mockExtractRecordPositions.mockReturnValue(mockRecordPositionData); + mockGetDragOperationType.mockReturnValue('multi'); + mockProcessMultiDrag.mockReturnValue({ + recordUpdates: [ + { recordId: 'record-1', position: 1.3 }, + { recordId: 'record-2', position: 1.6 }, + { recordId: 'record-3', position: 1.9 }, + ], + }); + + processGroupDragOperation({ + result, + snapshot: mockSnapshot, + selectedRecordIds, + selectFieldName: 'priority', + recordIdsByGroupFamilyState: mockRecordIdsByGroupFamilyState, + onUpdateRecord: mockOnUpdateRecord, + }); + + expect(mockGetDragOperationType).toHaveBeenCalledWith({ + draggedRecordId: 'record-1', + selectedRecordIds, + }); + + expect(mockProcessMultiDrag).toHaveBeenCalledWith({ + result, + selectedRecordIds, + recordPositionData: mockRecordPositionData, + recordIds: mockDestinationRecordIds, + groupValue: 'in-review', + selectFieldName: 'priority', + }); + + expect(mockOnUpdateRecord).toHaveBeenCalledTimes(3); + expect(mockOnUpdateRecord).toHaveBeenNthCalledWith(1, { + recordId: 'record-1', + position: 1.3, + groupValue: 'in-review', + selectFieldName: 'priority', + }); + expect(mockOnUpdateRecord).toHaveBeenNthCalledWith(2, { + recordId: 'record-2', + position: 1.6, + groupValue: 'in-review', + selectFieldName: 'priority', + }); + expect(mockOnUpdateRecord).toHaveBeenNthCalledWith(3, { + recordId: 'record-3', + position: 1.9, + groupValue: 'in-review', + selectFieldName: 'priority', + }); + }); + + it('should handle empty multi drag result', () => { + const result = createDropResult({ droppableId: 'group-2', index: 0 }); + const mockRecordGroup = { value: null, fieldMetadataId: 'field-1' }; + const mockDestinationRecordIds: string[] = []; + + mockGetSnapshotValue + .mockReturnValueOnce(mockRecordGroup) + .mockReturnValueOnce(mockDestinationRecordIds); + + mockExtractRecordPositions.mockReturnValue([]); + mockGetDragOperationType.mockReturnValue('multi'); + mockProcessMultiDrag.mockReturnValue({ + recordUpdates: [], + }); + + processGroupDragOperation({ + result, + snapshot: mockSnapshot, + selectedRecordIds: ['record-1', 'record-2'], + selectFieldName: 'category', + recordIdsByGroupFamilyState: mockRecordIdsByGroupFamilyState, + onUpdateRecord: mockOnUpdateRecord, + }); + + expect(mockOnUpdateRecord).not.toHaveBeenCalled(); + }); + }); + + describe('Group value handling', () => { + it('should handle null group value correctly', () => { + const result = createDropResult({ droppableId: 'no-group', index: 0 }); + const mockRecordGroup = { value: null, fieldMetadataId: 'field-1' }; + const mockDestinationRecordIds = ['record-1']; + + mockGetSnapshotValue + .mockReturnValueOnce(mockRecordGroup) + .mockReturnValueOnce(mockDestinationRecordIds); + + mockExtractRecordPositions.mockReturnValue([]); + mockGetDragOperationType.mockReturnValue('single'); + mockProcessSingleDrag.mockReturnValue({ + recordId: 'record-1', + position: 1, + groupValue: null, + }); + + processGroupDragOperation({ + result, + snapshot: mockSnapshot, + selectedRecordIds: ['record-1'], + selectFieldName: 'status', + recordIdsByGroupFamilyState: mockRecordIdsByGroupFamilyState, + onUpdateRecord: mockOnUpdateRecord, + }); + + expect(mockOnUpdateRecord).toHaveBeenCalledWith({ + recordId: 'record-1', + position: 1, + groupValue: null, + selectFieldName: 'status', + }); + }); + + it('should pass correct group value to processors', () => { + const result = createDropResult({ + droppableId: 'custom-group', + index: 2, + }); + const mockRecordGroup = { + value: 'custom-value', + fieldMetadataId: 'field-custom', + }; + const mockDestinationRecordIds = ['record-a', 'record-b', 'record-c']; + + mockGetSnapshotValue + .mockReturnValueOnce(mockRecordGroup) + .mockReturnValueOnce(mockDestinationRecordIds); + + mockExtractRecordPositions.mockReturnValue([]); + mockGetDragOperationType.mockReturnValue('single'); + mockProcessSingleDrag.mockReturnValue({ + recordId: 'record-1', + position: 2.5, + }); + + processGroupDragOperation({ + result, + snapshot: mockSnapshot, + selectedRecordIds: ['record-1'], + selectFieldName: 'customField', + recordIdsByGroupFamilyState: mockRecordIdsByGroupFamilyState, + onUpdateRecord: mockOnUpdateRecord, + }); + + expect(mockProcessSingleDrag).toHaveBeenCalledWith( + expect.objectContaining({ + groupValue: 'custom-value', + selectFieldName: 'customField', + }), + ); + }); + }); +}); diff --git a/packages/twenty-front/src/modules/object-record/record-drag/shared/utils/__tests__/processMultiDrag.test.ts b/packages/twenty-front/src/modules/object-record/record-drag/shared/utils/__tests__/processMultiDrag.test.ts new file mode 100644 index 0000000000..9b12ac5367 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-drag/shared/utils/__tests__/processMultiDrag.test.ts @@ -0,0 +1,211 @@ +import { type RecordDragPositionData } from '@/object-record/record-drag/shared/types/RecordDragPositionData'; +import { processMultiDrag } from '@/object-record/record-drag/shared/utils/processMultiDrag'; +import { type DropResult } from '@hello-pangea/dnd'; + +jest.mock( + '@/object-record/record-drag/shared/utils/calculateDragPositions', + () => ({ + calculateDragPositions: jest.fn(({ recordsToMove }) => { + const positions: Record = {}; + recordsToMove.forEach((id: string, index: number) => { + positions[id] = 1500 + index * 500; + }); + return positions; + }), + }), +); + +describe('processMultiDrag', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + const mockRecordPositionData: RecordDragPositionData[] = [ + { recordId: 'record-1', position: 1000 }, + { recordId: 'record-2', position: 2000 }, + { recordId: 'record-3', position: 3000 }, + { recordId: 'record-4', position: 4000 }, + ]; + + const createDropResult = ( + draggableId: string, + destinationIndex: number | null, + ): DropResult => ({ + draggableId, + type: 'record', + source: { + droppableId: 'source', + index: 0, + }, + destination: + destinationIndex !== null + ? { + droppableId: 'destination', + index: destinationIndex, + } + : null, + reason: 'DROP', + mode: 'FLUID', + combine: null, + }); + + describe('Table context (without group fields)', () => { + it('should process multi drag for table', () => { + const result = createDropResult('record-2', 3); + const selectedRecordIds = ['record-1', 'record-2', 'record-3']; + + const dragResult = processMultiDrag({ + result, + selectedRecordIds, + recordPositionData: mockRecordPositionData, + recordIds: ['record-1', 'record-2', 'record-3', 'record-4'], + }); + + expect(dragResult.recordUpdates).toEqual([ + { recordId: 'record-1', position: 1500 }, + { recordId: 'record-2', position: 2000 }, + { recordId: 'record-3', position: 2500 }, + ]); + + dragResult.recordUpdates.forEach((update) => { + expect(update).not.toHaveProperty('groupValue'); + expect(update).not.toHaveProperty('selectFieldName'); + }); + }); + + it('should throw error when destination is null', () => { + const result = createDropResult('record-1', null); + const selectedRecordIds = ['record-1', 'record-2']; + + expect(() => { + processMultiDrag({ + result, + selectedRecordIds, + recordPositionData: mockRecordPositionData, + recordIds: ['record-1', 'record-2', 'record-3'], + }); + }).toThrow('Destination is required for drag operation'); + }); + }); + + describe('Board context (with group fields)', () => { + it('should process multi drag for board with group fields', () => { + const result = createDropResult('record-2', 3); + const selectedRecordIds = ['record-1', 'record-2', 'record-3']; + + const dragResult = processMultiDrag({ + result, + selectedRecordIds, + recordPositionData: mockRecordPositionData, + recordIds: ['record-1', 'record-2', 'record-3', 'record-4'], + groupValue: 'in-progress', + selectFieldName: 'status', + }); + + expect(dragResult.recordUpdates).toEqual([ + { + recordId: 'record-1', + position: 1500, + groupValue: 'in-progress', + selectFieldName: 'status', + }, + { + recordId: 'record-2', + position: 2000, + groupValue: 'in-progress', + selectFieldName: 'status', + }, + { + recordId: 'record-3', + position: 2500, + groupValue: 'in-progress', + selectFieldName: 'status', + }, + ]); + }); + + it('should handle null group value', () => { + const result = createDropResult('record-1', 1); + const selectedRecordIds = ['record-1', 'record-2']; + + const dragResult = processMultiDrag({ + result, + selectedRecordIds, + recordPositionData: mockRecordPositionData, + recordIds: ['record-1', 'record-2', 'record-3'], + groupValue: null, + selectFieldName: 'status', + }); + + expect(dragResult.recordUpdates).toEqual([ + { + recordId: 'record-1', + position: 1500, + groupValue: null, + selectFieldName: 'status', + }, + { + recordId: 'record-2', + position: 2000, + groupValue: null, + selectFieldName: 'status', + }, + ]); + }); + }); + + describe('Edge cases', () => { + it('should handle single record in selection', () => { + const result = createDropResult('record-1', 2); + const selectedRecordIds = ['record-1']; + + const dragResult = processMultiDrag({ + result, + selectedRecordIds, + recordPositionData: mockRecordPositionData, + recordIds: ['record-1', 'record-2', 'record-3'], + }); + + expect(dragResult.recordUpdates).toEqual([ + { recordId: 'record-1', position: 1500 }, + ]); + }); + + it('should handle empty selection', () => { + const result = createDropResult('record-1', 2); + const selectedRecordIds: string[] = []; + + const dragResult = processMultiDrag({ + result, + selectedRecordIds, + recordPositionData: mockRecordPositionData, + recordIds: ['record-1', 'record-2', 'record-3'], + }); + + expect(dragResult.recordUpdates).toEqual([]); + }); + + it('should not include group fields when only one is provided', () => { + const result = createDropResult('record-1', 2); + const selectedRecordIds = ['record-1', 'record-2']; + + const dragResult = processMultiDrag({ + result, + selectedRecordIds, + recordPositionData: mockRecordPositionData, + recordIds: ['record-1', 'record-2', 'record-3'], + groupValue: 'group-1', + }); + + expect(dragResult.recordUpdates).toEqual([ + { recordId: 'record-1', position: 1500 }, + { recordId: 'record-2', position: 2000 }, + ]); + + dragResult.recordUpdates.forEach((update) => { + expect(update).not.toHaveProperty('groupValue'); + expect(update).not.toHaveProperty('selectFieldName'); + }); + }); + }); +}); diff --git a/packages/twenty-front/src/modules/object-record/record-drag/shared/utils/__tests__/processSingleDrag.test.ts b/packages/twenty-front/src/modules/object-record/record-drag/shared/utils/__tests__/processSingleDrag.test.ts new file mode 100644 index 0000000000..9b1b2ae2be --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-drag/shared/utils/__tests__/processSingleDrag.test.ts @@ -0,0 +1,156 @@ +import { type RecordDragPositionData } from '@/object-record/record-drag/shared/types/RecordDragPositionData'; +import { processSingleDrag } from '@/object-record/record-drag/shared/utils/processSingleDrag'; +import { type DropResult } from '@hello-pangea/dnd'; + +jest.mock( + '@/object-record/record-drag/shared/utils/calculateDragPositions', + () => ({ + calculateDragPositions: jest.fn(({ recordsToMove }) => { + const positions: Record = {}; + recordsToMove.forEach((id: string, index: number) => { + positions[id] = 1000 + index * 1000; + }); + return positions; + }), + }), +); + +describe('processSingleDrag', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + const mockRecordPositionData: RecordDragPositionData[] = [ + { recordId: 'record-1', position: 1000 }, + { recordId: 'record-2', position: 2000 }, + { recordId: 'record-3', position: 3000 }, + ]; + + const createDropResult = ( + draggableId: string, + destinationIndex: number | null, + ): DropResult => ({ + draggableId, + type: 'record', + source: { + droppableId: 'source', + index: 0, + }, + destination: + destinationIndex !== null + ? { + droppableId: 'destination', + index: destinationIndex, + } + : null, + reason: 'DROP', + mode: 'FLUID', + combine: null, + }); + + describe('Table context (without group fields)', () => { + it('should process single drag for table', () => { + const result = createDropResult('record-1', 2); + + const dragResult = processSingleDrag({ + result, + recordPositionData: mockRecordPositionData, + recordIds: ['record-1', 'record-2', 'record-3'], + }); + + expect(dragResult).toEqual({ + recordId: 'record-1', + position: 1000, + }); + expect(dragResult).not.toHaveProperty('groupValue'); + expect(dragResult).not.toHaveProperty('selectFieldName'); + }); + + it('should throw error when destination is null', () => { + const result = createDropResult('record-1', null); + + expect(() => { + processSingleDrag({ + result, + recordPositionData: mockRecordPositionData, + recordIds: ['record-1', 'record-2', 'record-3'], + }); + }).toThrow('Destination is required for drag operation'); + }); + }); + + describe('Board context (with group fields)', () => { + it('should process single drag for board with group fields', () => { + const result = createDropResult('record-1', 2); + + const dragResult = processSingleDrag({ + result, + recordPositionData: mockRecordPositionData, + recordIds: ['record-1', 'record-2', 'record-3'], + groupValue: 'group-1', + selectFieldName: 'status', + }); + + expect(dragResult).toEqual({ + recordId: 'record-1', + position: 1000, + groupValue: 'group-1', + selectFieldName: 'status', + }); + }); + + it('should handle null group value', () => { + const result = createDropResult('record-1', 2); + + const dragResult = processSingleDrag({ + result, + recordPositionData: mockRecordPositionData, + recordIds: ['record-1', 'record-2', 'record-3'], + groupValue: null, + selectFieldName: 'status', + }); + + expect(dragResult).toEqual({ + recordId: 'record-1', + position: 1000, + groupValue: null, + selectFieldName: 'status', + }); + }); + }); + + describe('Edge cases', () => { + it('should handle empty record list', () => { + const result = createDropResult('record-1', 0); + + const dragResult = processSingleDrag({ + result, + recordPositionData: [], + recordIds: [], + }); + + expect(dragResult).toEqual({ + recordId: 'record-1', + position: 1000, + }); + }); + + it('should not include group fields when only one is provided', () => { + const result = createDropResult('record-1', 2); + + const dragResult = processSingleDrag({ + result, + recordPositionData: mockRecordPositionData, + recordIds: ['record-1', 'record-2', 'record-3'], + groupValue: 'group-1', + }); + + expect(dragResult).toEqual({ + recordId: 'record-1', + position: 1000, + }); + expect(dragResult).not.toHaveProperty('groupValue'); + expect(dragResult).not.toHaveProperty('selectFieldName'); + }); + }); +}); diff --git a/packages/twenty-front/src/modules/object-record/record-board/utils/calculateRecordPositions.ts b/packages/twenty-front/src/modules/object-record/record-drag/shared/utils/calculateDragPositions.ts similarity index 70% rename from packages/twenty-front/src/modules/object-record/record-board/utils/calculateRecordPositions.ts rename to packages/twenty-front/src/modules/object-record/record-drag/shared/utils/calculateDragPositions.ts index 6da7c505bb..6e4f021efd 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/utils/calculateRecordPositions.ts +++ b/packages/twenty-front/src/modules/object-record/record-drag/shared/utils/calculateDragPositions.ts @@ -1,34 +1,28 @@ -import { getDraggedRecordPosition } from '@/object-record/record-board/utils/getDraggedRecordPosition'; +import { type RecordDragPositionData } from '@/object-record/record-drag/shared/types/RecordDragPositionData'; +import { getDraggedRecordPosition } from '@/object-record/record-drag/shared/utils/getDraggedRecordPosition'; import { getIndexNeighboursElementsFromArray } from '~/utils/array/getIndexNeighboursElementsFromArray'; -export type RecordPositionData = { - recordId: string; - position?: number; -}; - -type PositionCalculationContext = { - destinationRecordIds: string[]; +type DragPositionCalculationParams = { + recordIds: string[]; recordsToMove: string[]; destinationIndex: number; - recordPositionData: RecordPositionData[]; + recordPositionData: RecordDragPositionData[]; }; -export const calculateRecordPositions = ({ - destinationRecordIds, +export const calculateDragPositions = ({ + recordIds, recordsToMove, destinationIndex, recordPositionData, -}: PositionCalculationContext): Record => { - const otherRecordIdsInDestinationColumn = destinationRecordIds.filter( +}: DragPositionCalculationParams): Record => { + const otherRecordIds = recordIds.filter( (recordId: string) => !recordsToMove.includes(recordId), ); const filteredRecordIds = recordsToMove.length === 1 - ? otherRecordIdsInDestinationColumn - : destinationRecordIds.filter( - (recordId) => recordId !== recordsToMove[0], - ); + ? otherRecordIds + : recordIds.filter((recordId) => recordId !== recordsToMove[0]); const { before: recordBeforeId, after: recordAfterId } = getIndexNeighboursElementsFromArray({ diff --git a/packages/twenty-front/src/modules/object-record/record-drag/shared/utils/extractRecordPositions.ts b/packages/twenty-front/src/modules/object-record/record-drag/shared/utils/extractRecordPositions.ts new file mode 100644 index 0000000000..226f426615 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-drag/shared/utils/extractRecordPositions.ts @@ -0,0 +1,17 @@ +import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState'; +import { type Snapshot } from 'recoil'; + +export const extractRecordPositions = ( + recordIds: string[], + snapshot: Snapshot, +) => { + return recordIds.map((recordId) => { + const record = snapshot + .getLoadable(recordStoreFamilyState(recordId)) + .getValue(); + return { + recordId, + position: record?.position, + }; + }); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-board/utils/getDragOperationType.ts b/packages/twenty-front/src/modules/object-record/record-drag/shared/utils/getDragOperationType.ts similarity index 100% rename from packages/twenty-front/src/modules/object-record/record-board/utils/getDragOperationType.ts rename to packages/twenty-front/src/modules/object-record/record-drag/shared/utils/getDragOperationType.ts diff --git a/packages/twenty-front/src/modules/object-record/record-board/utils/getDraggedRecordPosition.ts b/packages/twenty-front/src/modules/object-record/record-drag/shared/utils/getDraggedRecordPosition.ts similarity index 100% rename from packages/twenty-front/src/modules/object-record/record-board/utils/getDraggedRecordPosition.ts rename to packages/twenty-front/src/modules/object-record/record-drag/shared/utils/getDraggedRecordPosition.ts diff --git a/packages/twenty-front/src/modules/object-record/record-drag/shared/utils/processGroupDragOperation.ts b/packages/twenty-front/src/modules/object-record/record-drag/shared/utils/processGroupDragOperation.ts new file mode 100644 index 0000000000..ada156be35 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-drag/shared/utils/processGroupDragOperation.ts @@ -0,0 +1,103 @@ +import { type DropResult } from '@hello-pangea/dnd'; +import { type Snapshot } from 'recoil'; +import { isDefined } from 'twenty-shared/utils'; + +import { recordGroupDefinitionFamilyState } from '@/object-record/record-group/states/recordGroupDefinitionFamilyState'; +import { getSnapshotValue } from '@/ui/utilities/state/utils/getSnapshotValue'; + +import { extractRecordPositions } from './extractRecordPositions'; +import { getDragOperationType } from './getDragOperationType'; +import { processMultiDrag } from './processMultiDrag'; +import { processSingleDrag } from './processSingleDrag'; + +type ProcessGroupDragOperationParams = { + result: DropResult; + snapshot: Snapshot; + selectedRecordIds: string[]; + selectFieldName: string; + recordIdsByGroupFamilyState: any; + onUpdateRecord: (update: { + recordId: string; + position?: number; + groupValue?: string | null; + selectFieldName: string; + }) => void; +}; + +export const processGroupDragOperation = ({ + result, + snapshot, + selectedRecordIds, + selectFieldName, + recordIdsByGroupFamilyState, + onUpdateRecord, +}: ProcessGroupDragOperationParams) => { + if (!result.destination) { + return; + } + const destinationGroupId = result.destination.droppableId; + + const recordGroup = getSnapshotValue( + snapshot, + recordGroupDefinitionFamilyState(destinationGroupId), + ); + + if (!isDefined(recordGroup)) { + throw new Error('Record group is not defined'); + } + + const destinationRecordIds = getSnapshotValue( + snapshot, + recordIdsByGroupFamilyState(destinationGroupId), + ) as string[]; + + const recordPositionData = extractRecordPositions( + destinationRecordIds, + snapshot, + ); + + const draggedRecordId = result.draggableId; + const dragOperationType = getDragOperationType({ + draggedRecordId, + selectedRecordIds, + }); + + if (dragOperationType === 'single') { + const singleDragResult = processSingleDrag({ + result, + recordPositionData, + recordIds: destinationRecordIds, + groupValue: recordGroup.value, + selectFieldName, + }); + + if (!isDefined(singleDragResult.position)) { + return; + } + + onUpdateRecord({ + recordId: singleDragResult.recordId, + position: singleDragResult.position, + groupValue: recordGroup.value, + selectFieldName, + }); + } else { + const multiDragResult = processMultiDrag({ + result, + selectedRecordIds, + recordPositionData, + recordIds: destinationRecordIds, + groupValue: recordGroup.value, + selectFieldName, + }); + + for (const update of multiDragResult.recordUpdates) { + onUpdateRecord({ + recordId: update.recordId, + position: update.position, + groupValue: recordGroup.value, + selectFieldName, + }); + } + } +}; diff --git a/packages/twenty-front/src/modules/object-record/record-drag/shared/utils/processMultiDrag.ts b/packages/twenty-front/src/modules/object-record/record-drag/shared/utils/processMultiDrag.ts new file mode 100644 index 0000000000..7ee1427113 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-drag/shared/utils/processMultiDrag.ts @@ -0,0 +1,62 @@ +import { type MultiDragResult } from '@/object-record/record-drag/shared/types/MultiDragResult'; +import { type RecordDragPositionData } from '@/object-record/record-drag/shared/types/RecordDragPositionData'; +import { calculateDragPositions } from '@/object-record/record-drag/shared/utils/calculateDragPositions'; +import { type DropResult } from '@hello-pangea/dnd'; +import { isNull } from '@sniptt/guards'; +import { isDefined } from 'twenty-shared/utils'; + +type MultiDragContext = { + result: DropResult; + selectedRecordIds: string[]; + recordPositionData: RecordDragPositionData[]; + recordIds: string[]; + groupValue?: string | null; + selectFieldName?: string; +}; + +export const processMultiDrag = ({ + result, + selectedRecordIds, + recordPositionData, + recordIds, + groupValue, + selectFieldName, +}: MultiDragContext): MultiDragResult => { + if (!result.destination) { + throw new Error('Destination is required for drag operation'); + } + + const destinationIndex = result.destination.index; + + const positions = calculateDragPositions({ + recordIds, + recordsToMove: selectedRecordIds, + destinationIndex, + recordPositionData, + }); + + const recordUpdates = selectedRecordIds.map((recordId) => { + const baseUpdate = { + recordId, + position: positions[recordId], + }; + + const shouldIncludeGroupFields = + isDefined(selectFieldName) && + (isDefined(groupValue) || isNull(groupValue)); + + if (shouldIncludeGroupFields) { + return { + ...baseUpdate, + groupValue, + selectFieldName, + }; + } + + return baseUpdate; + }); + + return { + recordUpdates, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-drag/shared/utils/processSingleDrag.ts b/packages/twenty-front/src/modules/object-record/record-drag/shared/utils/processSingleDrag.ts new file mode 100644 index 0000000000..bca129f559 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-drag/shared/utils/processSingleDrag.ts @@ -0,0 +1,56 @@ +import { type RecordDragPositionData } from '@/object-record/record-drag/shared/types/RecordDragPositionData'; +import { type RecordDragUpdate } from '@/object-record/record-drag/shared/types/RecordDragUpdate'; +import { calculateDragPositions } from '@/object-record/record-drag/shared/utils/calculateDragPositions'; +import { type DropResult } from '@hello-pangea/dnd'; +import { isNull } from '@sniptt/guards'; +import { isDefined } from 'twenty-shared/utils'; + +type SingleDragContext = { + result: DropResult; + recordPositionData: RecordDragPositionData[]; + recordIds: string[]; + groupValue?: string | null; + selectFieldName?: string; +}; + +export const processSingleDrag = ({ + result, + recordPositionData, + recordIds, + groupValue, + selectFieldName, +}: SingleDragContext): RecordDragUpdate => { + const draggedRecordId = result.draggableId; + + if (!result.destination) { + throw new Error('Destination is required for drag operation'); + } + + const destinationIndex = result.destination.index; + const recordsToMove = [draggedRecordId]; + + const positions = calculateDragPositions({ + recordIds, + recordsToMove, + destinationIndex, + recordPositionData, + }); + + const baseResult = { + recordId: draggedRecordId, + position: positions[draggedRecordId], + }; + + const shouldIncludeGroupFields = + isDefined(selectFieldName) && (isDefined(groupValue) || isNull(groupValue)); + + if (shouldIncludeGroupFields) { + return { + ...baseResult, + groupValue, + selectFieldName, + }; + } + + return baseResult; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-drag/table/hooks/useRecordTableDragOperations.ts b/packages/twenty-front/src/modules/object-record/record-drag/table/hooks/useRecordTableDragOperations.ts new file mode 100644 index 0000000000..0af406d357 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-drag/table/hooks/useRecordTableDragOperations.ts @@ -0,0 +1,122 @@ +import { type DropResult } from '@hello-pangea/dnd'; + +import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord'; +import { getDragOperationType } from '@/object-record/record-drag/shared/utils/getDragOperationType'; +import { RECORD_INDEX_REMOVE_SORTING_MODAL_ID } from '@/object-record/record-index/constants/RecordIndexRemoveSortingModalId'; +import { recordIndexAllRecordIdsComponentSelector } from '@/object-record/record-index/states/selectors/recordIndexAllRecordIdsComponentSelector'; +import { currentRecordSortsComponentState } from '@/object-record/record-sort/states/currentRecordSortsComponentState'; +import { useRecordTableContextOrThrow } from '@/object-record/record-table/contexts/RecordTableContext'; +import { useModal } from '@/ui/layout/modal/hooks/useModal'; +import { useRecoilComponentCallbackState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackState'; +import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; +import { getSnapshotValue } from '@/ui/utilities/state/utils/getSnapshotValue'; +import { isDefined } from 'twenty-shared/utils'; + +import { useRecordDragState } from '@/object-record/record-drag/shared/hooks/useRecordDragState'; +import { extractRecordPositions } from '@/object-record/record-drag/shared/utils/extractRecordPositions'; +import { processSingleDrag } from '@/object-record/record-drag/shared/utils/processSingleDrag'; +import { processMultiDrag } from '@/object-record/record-drag/shared/utils/processMultiDrag'; +import { selectedRowIdsComponentSelector } from '@/object-record/record-table/states/selectors/selectedRowIdsComponentSelector'; +import { useRecoilCallback } from 'recoil'; + +export const useRecordTableDragOperations = () => { + const { objectNameSingular, recordTableId } = useRecordTableContextOrThrow(); + + const { updateOneRecord: updateOneRow } = useUpdateOneRecord({ + objectNameSingular, + }); + + const recordIndexAllRecordIdsSelector = useRecoilComponentCallbackState( + recordIndexAllRecordIdsComponentSelector, + ); + + const selectedRowIdsSelector = useRecoilComponentCallbackState( + selectedRowIdsComponentSelector, + recordTableId, + ); + + const currentRecordSorts = useRecoilComponentValue( + currentRecordSortsComponentState, + ); + + const { openModal } = useModal(); + const multiDragState = useRecordDragState('table', recordTableId); + + const processDragOperation = useRecoilCallback( + ({ snapshot }) => + (result: DropResult) => { + if (!result.destination) return; + + if (currentRecordSorts.length > 0) { + openModal(RECORD_INDEX_REMOVE_SORTING_MODAL_ID); + return; + } + + const allRecordIds = getSnapshotValue( + snapshot, + recordIndexAllRecordIdsSelector, + ); + + const draggedRecordId = result.draggableId; + const selectedRecordIds = getSnapshotValue( + snapshot, + selectedRowIdsSelector, + ); + + const recordPositionData = extractRecordPositions( + allRecordIds, + snapshot, + ); + + const dragOperationType = getDragOperationType({ + draggedRecordId, + selectedRecordIds, + }); + + if (dragOperationType === 'single') { + const singleDragResult = processSingleDrag({ + result, + recordPositionData, + recordIds: allRecordIds, + }); + + if (!isDefined(singleDragResult.position)) { + return; + } + + updateOneRow({ + idToUpdate: singleDragResult.recordId, + updateOneRecordInput: { + position: singleDragResult.position, + }, + }); + } else { + const multiDragResult = processMultiDrag({ + result, + selectedRecordIds: multiDragState.originalSelection, + recordPositionData, + recordIds: allRecordIds, + }); + + for (const update of multiDragResult.recordUpdates) { + updateOneRow({ + idToUpdate: update.recordId, + updateOneRecordInput: { + position: update.position, + }, + }); + } + } + }, + [ + selectedRowIdsSelector, + updateOneRow, + openModal, + currentRecordSorts, + multiDragState.originalSelection, + recordIndexAllRecordIdsSelector, + ], + ); + + return { processDragOperation }; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-drag/table/hooks/useRecordTableGroupDragOperations.ts b/packages/twenty-front/src/modules/object-record/record-drag/table/hooks/useRecordTableGroupDragOperations.ts new file mode 100644 index 0000000000..20e8002186 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-drag/table/hooks/useRecordTableGroupDragOperations.ts @@ -0,0 +1,109 @@ +import { type DropResult } from '@hello-pangea/dnd'; +import { useRecoilCallback } from 'recoil'; +import { isDefined } from 'twenty-shared/utils'; + +import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord'; +import { useRecordDragState } from '@/object-record/record-drag/shared/hooks/useRecordDragState'; +import { processGroupDragOperation } from '@/object-record/record-drag/shared/utils/processGroupDragOperation'; +import { recordGroupDefinitionFamilyState } from '@/object-record/record-group/states/recordGroupDefinitionFamilyState'; +import { RECORD_INDEX_REMOVE_SORTING_MODAL_ID } from '@/object-record/record-index/constants/RecordIndexRemoveSortingModalId'; +import { recordIndexRecordIdsByGroupComponentFamilyState } from '@/object-record/record-index/states/recordIndexRecordIdsByGroupComponentFamilyState'; +import { currentRecordSortsComponentState } from '@/object-record/record-sort/states/currentRecordSortsComponentState'; +import { useRecordTableContextOrThrow } from '@/object-record/record-table/contexts/RecordTableContext'; +import { selectedRowIdsComponentSelector } from '@/object-record/record-table/states/selectors/selectedRowIdsComponentSelector'; +import { useModal } from '@/ui/layout/modal/hooks/useModal'; +import { useRecoilComponentCallbackState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackState'; +import { getSnapshotValue } from '@/ui/utilities/state/utils/getSnapshotValue'; + +export const useRecordTableGroupDragOperations = () => { + const { objectNameSingular, objectMetadataItem, recordTableId } = + useRecordTableContextOrThrow(); + + const { updateOneRecord: updateOneRow } = useUpdateOneRecord({ + objectNameSingular, + }); + + const { openModal } = useModal(); + + const recordIdsByGroupFamilyState = useRecoilComponentCallbackState( + recordIndexRecordIdsByGroupComponentFamilyState, + ); + + const currentRecordSortsCallbackState = useRecoilComponentCallbackState( + currentRecordSortsComponentState, + ); + + const selectedRowIdsSelector = useRecoilComponentCallbackState( + selectedRowIdsComponentSelector, + recordTableId, + ); + + const multiDragState = useRecordDragState('table', recordTableId); + + const processDragOperation = useRecoilCallback( + ({ snapshot }) => + (result: DropResult) => { + if (!result.destination) return; + + const destinationRecordGroupId = result.destination.droppableId; + const destinationRecordGroup = getSnapshotValue( + snapshot, + recordGroupDefinitionFamilyState(destinationRecordGroupId), + ); + + if (!isDefined(destinationRecordGroup)) { + throw new Error('Record group is not defined'); + } + + const fieldMetadata = objectMetadataItem.fields.find( + (field) => field.id === destinationRecordGroup.fieldMetadataId, + ); + + if (!isDefined(fieldMetadata)) { + throw new Error('Field metadata is not defined'); + } + + const selectedRecordIds = multiDragState.isDragging + ? multiDragState.originalSelection + : getSnapshotValue(snapshot, selectedRowIdsSelector); + + const currentRecordSorts = snapshot + .getLoadable(currentRecordSortsCallbackState) + .getValue(); + + if (currentRecordSorts.length > 0) { + openModal(RECORD_INDEX_REMOVE_SORTING_MODAL_ID); + return; + } + + processGroupDragOperation({ + result, + snapshot, + selectedRecordIds, + selectFieldName: fieldMetadata.name, + recordIdsByGroupFamilyState: recordIdsByGroupFamilyState, + onUpdateRecord: ({ recordId, position }) => { + updateOneRow({ + idToUpdate: recordId, + updateOneRecordInput: { + position, + [fieldMetadata.name]: destinationRecordGroup.value, + }, + }); + }, + }); + }, + [ + currentRecordSortsCallbackState, + objectMetadataItem.fields, + recordIdsByGroupFamilyState, + updateOneRow, + openModal, + selectedRowIdsSelector, + multiDragState.originalSelection, + multiDragState.isDragging, + ], + ); + + return { processDragOperation }; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-drag/table/states/draggedRecordIdsTableComponentState.ts b/packages/twenty-front/src/modules/object-record/record-drag/table/states/draggedRecordIdsTableComponentState.ts new file mode 100644 index 0000000000..e7e9f4269f --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-drag/table/states/draggedRecordIdsTableComponentState.ts @@ -0,0 +1,10 @@ +import { RecordTableComponentInstanceContext } from '@/object-record/record-table/states/context/RecordTableComponentInstanceContext'; +import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState'; + +export const draggedRecordIdsTableComponentState = createComponentState< + string[] +>({ + key: 'draggedRecordIdsTableComponentState', + defaultValue: [], + componentInstanceContext: RecordTableComponentInstanceContext, +}); diff --git a/packages/twenty-front/src/modules/object-record/record-drag/table/states/isMultiDragActiveTableComponentState.ts b/packages/twenty-front/src/modules/object-record/record-drag/table/states/isMultiDragActiveTableComponentState.ts new file mode 100644 index 0000000000..add2e68a67 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-drag/table/states/isMultiDragActiveTableComponentState.ts @@ -0,0 +1,9 @@ +import { RecordTableComponentInstanceContext } from '@/object-record/record-table/states/context/RecordTableComponentInstanceContext'; +import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState'; + +export const isMultiDragActiveTableComponentState = + createComponentState({ + key: 'isMultiDragActiveTableComponentState', + defaultValue: false, + componentInstanceContext: RecordTableComponentInstanceContext, + }); diff --git a/packages/twenty-front/src/modules/object-record/record-drag/table/states/originalSelectionTableComponentState.ts b/packages/twenty-front/src/modules/object-record/record-drag/table/states/originalSelectionTableComponentState.ts new file mode 100644 index 0000000000..c9a4aa6851 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-drag/table/states/originalSelectionTableComponentState.ts @@ -0,0 +1,10 @@ +import { RecordTableComponentInstanceContext } from '@/object-record/record-table/states/context/RecordTableComponentInstanceContext'; +import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState'; + +export const originalSelectionTableComponentState = createComponentState< + string[] +>({ + key: 'originalSelectionTableComponentState', + defaultValue: [], + componentInstanceContext: RecordTableComponentInstanceContext, +}); diff --git a/packages/twenty-front/src/modules/object-record/record-drag/table/states/primaryDraggedRecordIdTableComponentState.ts b/packages/twenty-front/src/modules/object-record/record-drag/table/states/primaryDraggedRecordIdTableComponentState.ts new file mode 100644 index 0000000000..202b0e4f96 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-drag/table/states/primaryDraggedRecordIdTableComponentState.ts @@ -0,0 +1,10 @@ +import { RecordTableComponentInstanceContext } from '@/object-record/record-table/states/context/RecordTableComponentInstanceContext'; +import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState'; + +export const primaryDraggedRecordIdTableComponentState = createComponentState< + string | null +>({ + key: 'primaryDraggedRecordIdTableComponentState', + defaultValue: null, + componentInstanceContext: RecordTableComponentInstanceContext, +}); diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableBodyDragDropContextProvider.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableBodyDragDropContextProvider.tsx index 1d3044676b..5271d93e95 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableBodyDragDropContextProvider.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableBodyDragDropContextProvider.tsx @@ -1,112 +1,60 @@ -import { DragDropContext, type DropResult } from '@hello-pangea/dnd'; +import { + DragDropContext, + type DragStart, + type DropResult, +} from '@hello-pangea/dnd'; import { type ReactNode } from 'react'; import { useRecoilCallback } from 'recoil'; -import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord'; -import { getDraggedRecordPosition } from '@/object-record/record-board/utils/getDraggedRecordPosition'; -import { RECORD_INDEX_REMOVE_SORTING_MODAL_ID } from '@/object-record/record-index/constants/RecordIndexRemoveSortingModalId'; -import { recordIndexAllRecordIdsComponentSelector } from '@/object-record/record-index/states/selectors/recordIndexAllRecordIdsComponentSelector'; -import { currentRecordSortsComponentState } from '@/object-record/record-sort/states/currentRecordSortsComponentState'; -import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState'; import { useRecordTableContextOrThrow } from '@/object-record/record-table/contexts/RecordTableContext'; -import { useModal } from '@/ui/layout/modal/hooks/useModal'; import { useRecoilComponentCallbackState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackState'; -import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; import { getSnapshotValue } from '@/ui/utilities/state/utils/getSnapshotValue'; -import { isDefined } from 'twenty-shared/utils'; + +import { useEndRecordDrag } from '@/object-record/record-drag/shared/hooks/useEndRecordDrag'; +import { useStartRecordDrag } from '@/object-record/record-drag/shared/hooks/useStartRecordDrag'; +import { useRecordTableDragOperations } from '@/object-record/record-drag/table/hooks/useRecordTableDragOperations'; +import { selectedRowIdsComponentSelector } from '../../states/selectors/selectedRowIdsComponentSelector'; export const RecordTableBodyDragDropContextProvider = ({ children, }: { children: ReactNode; }) => { - const { objectNameSingular } = useRecordTableContextOrThrow(); + const { recordTableId } = useRecordTableContextOrThrow(); - const { updateOneRecord: updateOneRow } = useUpdateOneRecord({ - objectNameSingular, - }); - - const recordIndexAllRecordIdsSelector = useRecoilComponentCallbackState( - recordIndexAllRecordIdsComponentSelector, + const selectedRowIdsSelector = useRecoilComponentCallbackState( + selectedRowIdsComponentSelector, + recordTableId, ); - const currentRecordSorts = useRecoilComponentValue( - currentRecordSortsComponentState, - ); + const { startDrag } = useStartRecordDrag('table', recordTableId); + const { endDrag } = useEndRecordDrag('table', recordTableId); + const { processDragOperation } = useRecordTableDragOperations(); - const { openModal } = useModal(); + const handleDragStart = useRecoilCallback( + ({ snapshot }) => + (start: DragStart) => { + const currentSelectedRecordIds = getSnapshotValue( + snapshot, + selectedRowIdsSelector, + ); + + startDrag(start, currentSelectedRecordIds); + }, + [selectedRowIdsSelector, startDrag], + ); const handleDragEnd = useRecoilCallback( - ({ snapshot }) => - (result: DropResult) => { - if (currentRecordSorts.length > 0) { - openModal(RECORD_INDEX_REMOVE_SORTING_MODAL_ID); - return; - } - - if (!isDefined(result.destination)) { - throw new Error('Drop Destination is not defined'); - } - - const allRecordIds = getSnapshotValue( - snapshot, - recordIndexAllRecordIdsSelector, - ); - - const isSourceIndexBeforeDestinationIndex = - result.source.index < result.destination.index; - - const recordBeforeDestinationId = - allRecordIds[ - isSourceIndexBeforeDestinationIndex - ? result.destination.index - : result.destination.index - 1 - ]; - - const recordBeforeDestination = recordBeforeDestinationId - ? snapshot - .getLoadable(recordStoreFamilyState(recordBeforeDestinationId)) - .getValue() - : null; - - const recordAfterDestinationId = - allRecordIds[ - isSourceIndexBeforeDestinationIndex - ? result.destination.index + 1 - : result.destination.index - ]; - - const recordAfterDestination = recordAfterDestinationId - ? snapshot - .getLoadable(recordStoreFamilyState(recordAfterDestinationId)) - .getValue() - : null; - - const newPosition = getDraggedRecordPosition( - recordBeforeDestination?.position, - recordAfterDestination?.position, - ); - - if (!isDefined(newPosition)) { - return; - } - - updateOneRow({ - idToUpdate: result.draggableId, - updateOneRecordInput: { - position: newPosition, - }, - }); - }, - [ - currentRecordSorts.length, - recordIndexAllRecordIdsSelector, - updateOneRow, - openModal, - ], + () => (result: DropResult) => { + processDragOperation(result); + endDrag(); + }, + [endDrag, processDragOperation], ); return ( - {children} + + {children} + ); }; diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableBodyRecordGroupDragDropContextProvider.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableBodyRecordGroupDragDropContextProvider.tsx index ef148d5e51..9c460d1ae2 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableBodyRecordGroupDragDropContextProvider.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableBodyRecordGroupDragDropContextProvider.tsx @@ -1,143 +1,59 @@ -import { DragDropContext, type DropResult } from '@hello-pangea/dnd'; +import { + DragDropContext, + type DragStart, + type DropResult, +} from '@hello-pangea/dnd'; import { type ReactNode } from 'react'; import { useRecoilCallback } from 'recoil'; -import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord'; -import { getDraggedRecordPosition } from '@/object-record/record-board/utils/getDraggedRecordPosition'; -import { recordGroupDefinitionFamilyState } from '@/object-record/record-group/states/recordGroupDefinitionFamilyState'; -import { RECORD_INDEX_REMOVE_SORTING_MODAL_ID } from '@/object-record/record-index/constants/RecordIndexRemoveSortingModalId'; -import { recordIndexRecordIdsByGroupComponentFamilyState } from '@/object-record/record-index/states/recordIndexRecordIdsByGroupComponentFamilyState'; -import { currentRecordSortsComponentState } from '@/object-record/record-sort/states/currentRecordSortsComponentState'; -import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState'; +import { useEndRecordDrag } from '@/object-record/record-drag/shared/hooks/useEndRecordDrag'; +import { useStartRecordDrag } from '@/object-record/record-drag/shared/hooks/useStartRecordDrag'; +import { useRecordTableGroupDragOperations } from '@/object-record/record-drag/table/hooks/useRecordTableGroupDragOperations'; import { useRecordTableContextOrThrow } from '@/object-record/record-table/contexts/RecordTableContext'; -import { useModal } from '@/ui/layout/modal/hooks/useModal'; +import { selectedRowIdsComponentSelector } from '@/object-record/record-table/states/selectors/selectedRowIdsComponentSelector'; import { useRecoilComponentCallbackState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackState'; import { getSnapshotValue } from '@/ui/utilities/state/utils/getSnapshotValue'; -import { isDefined } from 'twenty-shared/utils'; export const RecordTableBodyRecordGroupDragDropContextProvider = ({ children, }: { children: ReactNode; }) => { - const { objectNameSingular, objectMetadataItem } = - useRecordTableContextOrThrow(); + const { recordTableId } = useRecordTableContextOrThrow(); - const { updateOneRecord: updateOneRow } = useUpdateOneRecord({ - objectNameSingular, - }); - - const { openModal } = useModal(); - - const recordIdsByGroupFamilyState = useRecoilComponentCallbackState( - recordIndexRecordIdsByGroupComponentFamilyState, + const selectedRowIdsSelector = useRecoilComponentCallbackState( + selectedRowIdsComponentSelector, + recordTableId, ); - const currentRecordSortsCallbackState = useRecoilComponentCallbackState( - currentRecordSortsComponentState, + const { startDrag } = useStartRecordDrag('table', recordTableId); + const { endDrag } = useEndRecordDrag('table', recordTableId); + const { processDragOperation } = useRecordTableGroupDragOperations(); + + const handleDragStart = useRecoilCallback( + ({ snapshot }) => + (start: DragStart) => { + const currentSelectedRecordIds = getSnapshotValue( + snapshot, + selectedRowIdsSelector, + ); + + startDrag(start, currentSelectedRecordIds); + }, + [selectedRowIdsSelector, startDrag], ); const handleDragEnd = useRecoilCallback( - ({ snapshot }) => - (result: DropResult) => { - const destinationRecordGroupId = result.destination?.droppableId; - - if (!isDefined(result.destination)) { - throw new Error('Drop Destination is not defined'); - } - - if (!isDefined(destinationRecordGroupId)) { - throw new Error('Record group id is not defined'); - } - - const destinationRecordGroup = getSnapshotValue( - snapshot, - recordGroupDefinitionFamilyState(destinationRecordGroupId), - ); - - const currentRecordSorts = snapshot - .getLoadable(currentRecordSortsCallbackState) - .getValue(); - - if (!isDefined(destinationRecordGroup)) { - throw new Error('Record group is not defined'); - } - - const fieldMetadata = objectMetadataItem.fields.find( - (field) => field.id === destinationRecordGroup.fieldMetadataId, - ); - - if (!isDefined(fieldMetadata)) { - throw new Error('Field metadata is not defined'); - } - - if (currentRecordSorts.length > 0) { - openModal(RECORD_INDEX_REMOVE_SORTING_MODAL_ID); - return; - } - - const isSourceIndexBeforeDestinationIndexInSameGroup = - result.source.index < result.destination.index && - result.source.droppableId === result.destination.droppableId; - - const destinationGroupRecordIds = getSnapshotValue( - snapshot, - recordIdsByGroupFamilyState(destinationRecordGroupId), - ); - - const recordBeforeDestinationId = - destinationGroupRecordIds[ - isSourceIndexBeforeDestinationIndexInSameGroup - ? result.destination.index - : result.destination.index - 1 - ]; - - const recordBeforeDestination = recordBeforeDestinationId - ? snapshot - .getLoadable(recordStoreFamilyState(recordBeforeDestinationId)) - .getValue() - : null; - - const recordAfterDestinationId = - destinationGroupRecordIds[ - isSourceIndexBeforeDestinationIndexInSameGroup - ? result.destination.index + 1 - : result.destination.index - ]; - - const recordAfterDestination = recordAfterDestinationId - ? snapshot - .getLoadable(recordStoreFamilyState(recordAfterDestinationId)) - .getValue() - : null; - - const newPosition = getDraggedRecordPosition( - recordBeforeDestination?.position, - recordAfterDestination?.position, - ); - - if (!isDefined(newPosition)) { - return; - } - - updateOneRow({ - idToUpdate: result.draggableId, - updateOneRecordInput: { - position: newPosition, - [fieldMetadata.name]: destinationRecordGroup.value, - }, - }); - }, - [ - currentRecordSortsCallbackState, - objectMetadataItem.fields, - recordIdsByGroupFamilyState, - updateOneRow, - openModal, - ], + () => (result: DropResult) => { + processDragOperation(result); + endDrag(); + }, + [endDrag, processDragOperation], ); return ( - {children} + + {children} + ); }; diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-row/components/RecordTableDraggableTr.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-row/components/RecordTableDraggableTr.tsx index 044d9bb27d..00325fef89 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-row/components/RecordTableDraggableTr.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-row/components/RecordTableDraggableTr.tsx @@ -2,7 +2,10 @@ import { useTheme } from '@emotion/react'; import { Draggable } from '@hello-pangea/dnd'; import { type ReactNode } from 'react'; +import { useRecordTableContextOrThrow } from '@/object-record/record-table/contexts/RecordTableContext'; import { RecordTableRowDraggableContextProvider } from '@/object-record/record-table/contexts/RecordTableRowDraggableContext'; +import { useRecordDragState } from '@/object-record/record-drag/shared/hooks/useRecordDragState'; +import { RecordTableRowMultiDragPreview } from '@/object-record/record-table/record-table-row/components/RecordTableRowMultiDragPreview'; import { RecordTableTr } from '@/object-record/record-table/record-table-row/components/RecordTableTr'; import { RecordTableTrEffect } from '@/object-record/record-table/record-table-row/components/RecordTableTrEffect'; @@ -26,6 +29,13 @@ export const RecordTableDraggableTr = ({ children, }: RecordTableDraggableTrProps) => { const theme = useTheme(); + const { recordTableId } = useRecordTableContextOrThrow(); + const multiDragState = useRecordDragState('table', recordTableId); + + const isSecondaryDragged = + multiDragState?.isDragging && + multiDragState.originalSelection.includes(recordId) && + recordId !== multiDragState.primaryDraggedRecordId; return ( {children} + diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-row/components/RecordTableRowMultiDragPreview.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-row/components/RecordTableRowMultiDragPreview.tsx new file mode 100644 index 0000000000..6f8779df0b --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-row/components/RecordTableRowMultiDragPreview.tsx @@ -0,0 +1,37 @@ +import styled from '@emotion/styled'; +import { NotificationCounter } from 'twenty-ui/navigation'; + +import { useRecordTableContextOrThrow } from '@/object-record/record-table/contexts/RecordTableContext'; +import { useRecordTableRowContextOrThrow } from '@/object-record/record-table/contexts/RecordTableRowContext'; +import { useRecordDragState } from '@/object-record/record-drag/shared/hooks/useRecordDragState'; + +const StyledNotificationCounter = styled(NotificationCounter)` + position: absolute; + top: -7px; + left: -7px; + z-index: 1000; +`; + +type RecordTableRowMultiDragPreviewProps = { + isDragging: boolean; +}; + +export const RecordTableRowMultiDragPreview = ({ + isDragging, +}: RecordTableRowMultiDragPreviewProps) => { + const { recordId } = useRecordTableRowContextOrThrow(); + const { recordTableId } = useRecordTableContextOrThrow(); + const multiDragState = useRecordDragState('table', recordTableId); + + const isCurrentRowSelected = + multiDragState?.originalSelection.includes(recordId) || false; + const selectedCount = multiDragState?.originalSelection.length ?? 0; + + const shouldShow = isDragging && isCurrentRowSelected && selectedCount > 1; + + if (!shouldShow) { + return null; + } + + return ; +};