Multi drag on tables + Refactor (#13570)

https://github.com/user-attachments/assets/32d20ca0-fa50-427c-92e8-638884d86de3


- code needs refactoring
- ~~missing stack behaviour (I dont think its easy to add something
similar here, but will give it a try) - I suspect it will look crowded~~
- ~~the notification counter - boards have it on top right, but on
tables we cant do that since the rows could be longer than the scrolling
container , therefore I added it on top left corner~~
- ~~needs to test with large set of data for both boards and tables
(pagination)~~

---------

Co-authored-by: Lucas Bordeau <bordeau.lucas@gmail.com>
This commit is contained in:
nitin
2025-08-20 18:46:07 +05:30
committed by GitHub
parent 85dfcfbb18
commit da3479a4f4
55 changed files with 2844 additions and 1378 deletions
@@ -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();
@@ -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,
};
};
@@ -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,
],
);
};
@@ -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,
};
};
@@ -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,
],
);
};
@@ -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 =
@@ -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;
@@ -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,
});
});
});
});
@@ -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: [],
});
});
});
@@ -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');
});
});
@@ -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,
};
};
@@ -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,
};
};
@@ -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,
};
};
@@ -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 }) => (
<RecoilRoot>{children}</RecoilRoot>
);
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([]);
});
});
});
@@ -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',
]);
});
});
@@ -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 }) => (
<RecoilRoot>{children}</RecoilRoot>
);
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',
]);
});
});
});
@@ -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 }) => (
<RecoilRoot>{children}</RecoilRoot>
);
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',
]);
});
});
});
@@ -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 };
};
@@ -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<MultiDragState>({
@@ -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,
};
};
@@ -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 };
};
@@ -0,0 +1,5 @@
import { type RecordDragUpdate } from '@/object-record/record-drag/shared/types/RecordDragUpdate';
export type MultiDragResult = {
recordUpdates: RecordDragUpdate[];
};
@@ -0,0 +1,6 @@
export type MultiDragState = {
isDragging: boolean;
draggedRecordIds: string[];
primaryDraggedRecordId: string | null;
originalSelection: string[];
};
@@ -0,0 +1 @@
export type RecordDragContext = 'board' | 'table';
@@ -0,0 +1,4 @@
export type RecordDragPositionData = {
recordId: string;
position?: number;
};
@@ -0,0 +1,6 @@
export type RecordDragUpdate = {
recordId: string;
position: number;
groupValue?: string | null;
selectFieldName?: string;
};
@@ -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');
});
});
});
@@ -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<string, any>): 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 },
]);
});
});
@@ -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', () => {
@@ -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', () => {
@@ -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',
}),
);
});
});
});
@@ -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<string, number> = {};
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');
});
});
});
});
@@ -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<string, number> = {};
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');
});
});
});
@@ -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<string, number> => {
const otherRecordIdsInDestinationColumn = destinationRecordIds.filter(
}: DragPositionCalculationParams): Record<string, number> => {
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({
@@ -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,
};
});
};
@@ -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,
});
}
}
};
@@ -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,
};
};
@@ -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;
};
@@ -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 };
};
@@ -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 };
};
@@ -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,
});
@@ -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<boolean>({
key: 'isMultiDragActiveTableComponentState',
defaultValue: false,
componentInstanceContext: RecordTableComponentInstanceContext,
});
@@ -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,
});
@@ -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,
});
@@ -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 (
<DragDropContext onDragEnd={handleDragEnd}>{children}</DragDropContext>
<DragDropContext onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
{children}
</DragDropContext>
);
};
@@ -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 (
<DragDropContext onDragEnd={handleDragEnd}>{children}</DragDropContext>
<DragDropContext onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
{children}
</DragDropContext>
);
};
@@ -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 (
<Draggable
@@ -51,6 +61,7 @@ export const RecordTableDraggableTr = ({
borderColor: draggableSnapshot.isDragging
? `${theme.border.color.medium}`
: 'transparent',
opacity: isSecondaryDragged ? 0.3 : 1,
}}
isDragging={draggableSnapshot.isDragging}
data-testid={`row-id-${recordId}`}
@@ -65,6 +76,9 @@ export const RecordTableDraggableTr = ({
}}
>
{children}
<RecordTableRowMultiDragPreview
isDragging={draggableSnapshot.isDragging}
/>
</RecordTableRowDraggableContextProvider>
</RecordTableTr>
</>
@@ -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 <StyledNotificationCounter count={selectedCount} />;
};