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 ;
+};