From c44f610872f9fcab7da4faae2d51889f15fbdeef Mon Sep 17 00:00:00 2001 From: nitin <142569587+ehconitin@users.noreply.github.com> Date: Fri, 1 Aug 2025 18:29:35 +0530 Subject: [PATCH] [POC] multi select drag and drop on record board (#13053) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit closes https://github.com/twentyhq/twenty/issues/13036 Inspiration: https://github.com/hello-pangea/dnd/blob/main/docs/patterns/multi-drag.md Question: Should we have it for tables too? :) https://github.com/user-attachments/assets/64a3c4c5-40bc-4ca4-b296-685027d08861 --------- Co-authored-by: Félix Malfait --- .../record-board/components/RecordBoard.tsx | 135 +++--- .../hooks/useBoardCardDragState.ts | 34 ++ .../record-board/hooks/useEndBoardCardDrag.ts | 45 ++ .../record-board/hooks/useMultiDragState.ts | 59 +++ .../hooks/useRecordBoardDragOperations.ts | 106 +++++ .../hooks/useStartBoardCardDrag.ts | 61 +++ .../components/RecordBoardCard.tsx | 93 +++- .../RecordBoardCardDraggableContainer.tsx | 5 +- .../RecordBoardCardMultiDragPreview.tsx | 35 ++ .../RecordBoardColumnCardsContainer.tsx | 1 + .../states/draggedRecordIdsComponentState.ts | 8 + .../states/isMultiDragActiveComponentState.ts | 8 + .../states/originalSelectionComponentState.ts | 10 + .../primaryDraggedRecordIdComponentState.ts | 10 + .../record-board/types/DragOperationType.ts | 1 + .../calculateRecordPositions.test.ts | 446 ++++++++++++++++++ .../__tests__/getDragOperationType.test.ts | 57 +++ .../utils/__tests__/processMultiDrag.test.ts | 180 +++++++ .../utils/__tests__/processSingleDrag.test.ts | 143 ++++++ .../utils/calculateRecordPositions.ts | 68 +++ .../utils/getDragOperationType.ts | 16 + .../record-board/utils/processMultiDrag.ts | 58 +++ .../record-board/utils/processSingleDrag.ts | 52 ++ packages/twenty-ui/src/navigation/index.ts | 1 + .../components/NotificationCounter.tsx | 38 ++ 25 files changed, 1572 insertions(+), 98 deletions(-) create mode 100644 packages/twenty-front/src/modules/object-record/record-board/hooks/useBoardCardDragState.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-board/hooks/useEndBoardCardDrag.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-board/hooks/useMultiDragState.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-board/hooks/useRecordBoardDragOperations.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-board/hooks/useStartBoardCardDrag.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-board/record-board-card/components/RecordBoardCardMultiDragPreview.tsx create mode 100644 packages/twenty-front/src/modules/object-record/record-board/states/draggedRecordIdsComponentState.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-board/states/isMultiDragActiveComponentState.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-board/states/originalSelectionComponentState.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-board/states/primaryDraggedRecordIdComponentState.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-board/types/DragOperationType.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-board/utils/__tests__/calculateRecordPositions.test.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-board/utils/__tests__/getDragOperationType.test.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-board/utils/__tests__/processMultiDrag.test.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-board/utils/__tests__/processSingleDrag.test.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-board/utils/calculateRecordPositions.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-board/utils/getDragOperationType.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-board/utils/processMultiDrag.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-board/utils/processSingleDrag.ts create mode 100644 packages/twenty-ui/src/navigation/notification-counter/components/NotificationCounter.tsx diff --git a/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoard.tsx b/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoard.tsx index 04231f96a8..ac73d9fdbb 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoard.tsx +++ b/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoard.tsx @@ -1,5 +1,9 @@ import styled from '@emotion/styled'; -import { DragDropContext, OnDragEndResponder } from '@hello-pangea/dnd'; // Atlassian dnd does not support StrictMode from RN 18, so we use a fork @hello-pangea/dnd https://github.com/atlassian/react-beautiful-dnd/issues/2350 +import { + DragDropContext, + DragStart, + OnDragEndResponder, +} from '@hello-pangea/dnd'; // Atlassian dnd does not support StrictMode from RN 18, so we use a fork @hello-pangea/dnd https://github.com/atlassian/react-beautiful-dnd/issues/2350 import { useContext, useRef } from 'react'; import { useRecoilCallback } from 'recoil'; @@ -11,19 +15,20 @@ 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 { getDraggedRecordPosition } from '@/object-record/record-board/utils/getDraggedRecordPosition'; -import { recordGroupDefinitionFamilyState } from '@/object-record/record-group/states/recordGroupDefinitionFamilyState'; +import { recordBoardSelectedRecordIdsComponentSelector } from '@/object-record/record-board/states/selectors/recordBoardSelectedRecordIdsComponentSelector'; 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 { 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 { useCloseAnyOpenDropdown } from '@/ui/layout/dropdown/hooks/useCloseAnyOpenDropdown'; import { MODAL_BACKDROP_CLICK_OUTSIDE_ID } from '@/ui/layout/modal/constants/ModalBackdropClickOutsideId'; import { useModal } from '@/ui/layout/modal/hooks/useModal'; @@ -39,7 +44,6 @@ import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/ import { getSnapshotValue } from '@/ui/utilities/state/utils/getSnapshotValue'; import { ViewType } from '@/views/types/ViewType'; import { LINK_CHIP_CLICK_OUTSIDE_ID } from 'twenty-ui/components'; -import { getIndexNeighboursElementsFromArray } from '~/utils/array/getIndexNeighboursElementsFromArray'; const StyledContainer = styled.div` display: flex; @@ -71,8 +75,7 @@ const StyledBoardContentContainer = styled.div` `; export const RecordBoard = () => { - const { updateOneRecord, selectFieldMetadataItem, recordBoardId } = - useContext(RecordBoardContext); + const { recordBoardId } = useContext(RecordBoardContext); const boardRef = useRef(null); const { toggleClickOutside } = useClickOutsideListener( @@ -86,7 +89,6 @@ export const RecordBoard = () => { const handleDragSelectionStart = () => { closeAnyOpenDropdown(); - toggleClickOutside(false); }; @@ -99,11 +101,6 @@ export const RecordBoard = () => { ViewType.Kanban, ); - const recordIndexRecordIdsByGroupFamilyState = - useRecoilComponentCallbackStateV2( - recordIndexRecordIdsByGroupComponentFamilyState, - ); - const { resetRecordSelection, setRecordAsSelected } = useRecordBoardSelection(recordBoardId); @@ -111,6 +108,18 @@ export const RecordBoard = () => { currentRecordSortsComponentState, ); + const recordBoardSelectedRecordIdsSelector = + useRecoilComponentCallbackStateV2( + recordBoardSelectedRecordIdsComponentSelector, + recordBoardId, + ); + + const startDrag = useStartBoardCardDrag(recordBoardId); + const endDrag = useEndBoardCardDrag(recordBoardId); + const multiDragState = useBoardCardDragState(recordBoardId); + + const { processDragOperation } = useRecordBoardDragOperations(); + useListenClickOutside({ excludedClickOutsideIds: [ ACTION_MENU_DROPDOWN_CLICK_OUTSIDE_ID, @@ -123,81 +132,48 @@ export const RecordBoard = () => { listenerId: RECORD_BOARD_CLICK_OUTSIDE_LISTENER_ID, refs: [], callback: () => { - resetRecordSelection(); - deactivateBoardCard(); - unfocusBoardCard(); + if (!multiDragState.isDragging) { + resetRecordSelection(); + deactivateBoardCard(); + unfocusBoardCard(); + } }, }); const { openModal } = useModal(); - const handleDragEnd: OnDragEndResponder = useRecoilCallback( + const handleDragStart = useRecoilCallback( ({ snapshot }) => - (result) => { - if (!result.destination) return; - - if (currentRecordSorts.length > 0) { - openModal(RECORD_INDEX_REMOVE_SORTING_MODAL_ID); - return; - } - - const draggedRecordId = result.draggableId; - const sourceRecordGroupId = result.source.droppableId; - const destinationRecordGroupId = result.destination.droppableId; - const destinationIndexInColumn = result.destination.index; - - if (!destinationRecordGroupId || !selectFieldMetadataItem) return; - - const recordGroup = getSnapshotValue( + (start: DragStart) => { + const currentSelectedRecordIds = getSnapshotValue( snapshot, - recordGroupDefinitionFamilyState(destinationRecordGroupId), + recordBoardSelectedRecordIdsSelector, ); - if (!recordGroup) return; - - const destinationRecordByGroupIds = getSnapshotValue( - snapshot, - recordIndexRecordIdsByGroupFamilyState(destinationRecordGroupId), - ); - const otherRecordIdsInDestinationColumn = - sourceRecordGroupId === destinationRecordGroupId - ? destinationRecordByGroupIds.filter( - (recordId) => recordId !== draggedRecordId, - ) - : destinationRecordByGroupIds; - - const { before: recordBeforeId, after: recordAfterId } = - getIndexNeighboursElementsFromArray({ - index: destinationIndexInColumn, - array: otherRecordIdsInDestinationColumn, - }); - const recordBefore = recordBeforeId - ? getSnapshotValue(snapshot, recordStoreFamilyState(recordBeforeId)) - : null; - - const recordAfter = recordAfterId - ? getSnapshotValue(snapshot, recordStoreFamilyState(recordAfterId)) - : null; - - const draggedRecordPosition = getDraggedRecordPosition( - recordBefore?.position, - recordAfter?.position, - ); - - updateOneRecord({ - idToUpdate: draggedRecordId, - updateOneRecordInput: { - [selectFieldMetadataItem.name]: recordGroup.value, - position: draggedRecordPosition, - }, - }); + startDrag(start, currentSelectedRecordIds); }, + [recordBoardSelectedRecordIdsSelector, startDrag], + ); + + const handleDragEnd: OnDragEndResponder = useRecoilCallback( + () => (result) => { + endDrag(); + + if (!result.destination) return; + + if (currentRecordSorts.length > 0) { + openModal(RECORD_INDEX_REMOVE_SORTING_MODAL_ID); + return; + } + + processDragOperation(result, multiDragState.originalSelection); + }, [ - recordIndexRecordIdsByGroupFamilyState, - selectFieldMetadataItem, - updateOneRecord, - openModal, + processDragOperation, + multiDragState.originalSelection, + endDrag, currentRecordSorts, + openModal, ], ); @@ -221,7 +197,10 @@ export const RecordBoard = () => { - + {visibleRecordGroupIds.map((recordGroupId, index) => ( { + const isDragging = useRecoilComponentValueV2( + isMultiDragActiveComponentState, + recordBoardId, + ); + + const draggedRecordIds = useRecoilComponentValueV2( + draggedRecordIdsComponentState, + recordBoardId, + ); + + const primaryDraggedRecordId = useRecoilComponentValueV2( + primaryDraggedRecordIdComponentState, + recordBoardId, + ); + + const originalSelection = useRecoilComponentValueV2( + originalSelectionComponentState, + recordBoardId, + ); + + return { + isDragging, + draggedRecordIds, + primaryDraggedRecordId, + originalSelection, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-board/hooks/useEndBoardCardDrag.ts b/packages/twenty-front/src/modules/object-record/record-board/hooks/useEndBoardCardDrag.ts new file mode 100644 index 0000000000..7cd2b59c8b --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-board/hooks/useEndBoardCardDrag.ts @@ -0,0 +1,45 @@ +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 { useRecoilComponentCallbackStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackStateV2'; + +export const useEndBoardCardDrag = (recordBoardId?: string) => { + const isMultiDragActiveState = useRecoilComponentCallbackStateV2( + isMultiDragActiveComponentState, + recordBoardId, + ); + + const draggedRecordIdsState = useRecoilComponentCallbackStateV2( + draggedRecordIdsComponentState, + recordBoardId, + ); + + const primaryDraggedRecordIdState = useRecoilComponentCallbackStateV2( + primaryDraggedRecordIdComponentState, + recordBoardId, + ); + + const originalSelectionState = useRecoilComponentCallbackStateV2( + originalSelectionComponentState, + recordBoardId, + ); + + return useRecoilCallback( + ({ set }) => + () => { + set(isMultiDragActiveState, false); + set(draggedRecordIdsState, []); + set(primaryDraggedRecordIdState, null); + set(originalSelectionState, []); + }, + [ + isMultiDragActiveState, + draggedRecordIdsState, + primaryDraggedRecordIdState, + originalSelectionState, + ], + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-board/hooks/useMultiDragState.ts b/packages/twenty-front/src/modules/object-record/record-board/hooks/useMultiDragState.ts new file mode 100644 index 0000000000..d869cf9246 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-board/hooks/useMultiDragState.ts @@ -0,0 +1,59 @@ +import { 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({ + isDragging: false, + draggedRecordIds: [], + primaryDraggedRecordId: null, + originalSelection: [], + }); + + const startDrag = (start: DragStart, selectedRecordIds: string[]) => { + const draggedRecordId = start.draggableId; + + const operationType = getDragOperationType({ + draggedRecordId, + selectedRecordIds, + }); + + if (operationType === 'multi') { + setMultiDragState({ + isDragging: true, + draggedRecordIds: selectedRecordIds, + primaryDraggedRecordId: draggedRecordId, + originalSelection: selectedRecordIds, + }); + } else { + setMultiDragState({ + isDragging: true, + draggedRecordIds: [draggedRecordId], + primaryDraggedRecordId: draggedRecordId, + originalSelection: [draggedRecordId], + }); + } + }; + + const endDrag = () => { + setMultiDragState({ + isDragging: false, + draggedRecordIds: [], + primaryDraggedRecordId: null, + originalSelection: [], + }); + }; + + return { + multiDragState, + startDrag, + endDrag, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-board/hooks/useRecordBoardDragOperations.ts b/packages/twenty-front/src/modules/object-record/record-board/hooks/useRecordBoardDragOperations.ts new file mode 100644 index 0000000000..8431513ba9 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-board/hooks/useRecordBoardDragOperations.ts @@ -0,0 +1,106 @@ +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 { useRecoilComponentCallbackStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackStateV2'; +import { getSnapshotValue } from '@/ui/utilities/state/utils/getSnapshotValue'; +import { 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 = + useRecoilComponentCallbackStateV2( + recordIndexRecordIdsByGroupComponentFamilyState, + ); + + const processDragOperation = useRecoilCallback( + ({ snapshot }) => + (result: DropResult, selectedRecordIds: string[]) => { + if (!result.destination || !selectFieldMetadataItem) return; + + const draggedRecordId = result.draggableId; + const destinationGroupId = result.destination.droppableId; + + const recordGroup = getSnapshotValue( + snapshot, + recordGroupDefinitionFamilyState(destinationGroupId), + ); + + if (!recordGroup) return; + + const destinationRecordIds = getSnapshotValue( + snapshot, + recordIndexRecordIdsByGroupFamilyState(destinationGroupId), + ) as string[]; + + const recordPositionData = destinationRecordIds.map((recordId) => { + const record = getSnapshotValue( + snapshot, + recordStoreFamilyState(recordId), + ); + return { + recordId, + position: record?.position, + }; + }); + + const operationType = getDragOperationType({ + draggedRecordId, + selectedRecordIds, + }); + + if (operationType === 'single') { + const singleDragResult = processSingleDrag({ + result, + recordPositionData, + destinationRecordIds, + groupValue: recordGroup.value, + selectFieldName: selectFieldMetadataItem.name, + }); + + updateOneRecord({ + idToUpdate: singleDragResult.recordId, + updateOneRecordInput: { + [selectFieldMetadataItem.name]: singleDragResult.groupValue, + position: singleDragResult.position, + }, + }); + } else { + const multiDragResult = processMultiDrag({ + result, + selectedRecordIds, + recordPositionData, + destinationRecordIds, + groupValue: recordGroup.value, + selectFieldName: selectFieldMetadataItem.name, + }); + + for (const update of multiDragResult.recordUpdates) { + updateOneRecord({ + idToUpdate: update.recordId, + updateOneRecordInput: { + [selectFieldMetadataItem.name]: update.groupValue, + position: update.position, + }, + }); + } + } + }, + [ + updateOneRecord, + selectFieldMetadataItem, + recordIndexRecordIdsByGroupFamilyState, + ], + ); + + return { + processDragOperation, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-board/hooks/useStartBoardCardDrag.ts b/packages/twenty-front/src/modules/object-record/record-board/hooks/useStartBoardCardDrag.ts new file mode 100644 index 0000000000..21d73f5af1 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-board/hooks/useStartBoardCardDrag.ts @@ -0,0 +1,61 @@ +import { 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 { useRecoilComponentCallbackStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackStateV2'; +import { getDragOperationType } from '../utils/getDragOperationType'; + +export const useStartBoardCardDrag = (recordBoardId?: string) => { + const isMultiDragActiveState = useRecoilComponentCallbackStateV2( + isMultiDragActiveComponentState, + recordBoardId, + ); + + const draggedRecordIdsState = useRecoilComponentCallbackStateV2( + draggedRecordIdsComponentState, + recordBoardId, + ); + + const primaryDraggedRecordIdState = useRecoilComponentCallbackStateV2( + primaryDraggedRecordIdComponentState, + recordBoardId, + ); + + const originalSelectionState = useRecoilComponentCallbackStateV2( + originalSelectionComponentState, + recordBoardId, + ); + + return useRecoilCallback( + ({ set }) => + (start: DragStart, selectedRecordIds: string[]) => { + const draggedRecordId = start.draggableId; + + const operationType = getDragOperationType({ + draggedRecordId, + selectedRecordIds, + }); + + if (operationType === 'multi') { + set(isMultiDragActiveState, true); + set(draggedRecordIdsState, selectedRecordIds); + set(primaryDraggedRecordIdState, draggedRecordId); + set(originalSelectionState, selectedRecordIds); + } else { + set(isMultiDragActiveState, true); + set(draggedRecordIdsState, [draggedRecordId]); + set(primaryDraggedRecordIdState, draggedRecordId); + set(originalSelectionState, [draggedRecordId]); + } + }, + [ + isMultiDragActiveState, + draggedRecordIdsState, + primaryDraggedRecordIdState, + originalSelectionState, + ], + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-card/components/RecordBoardCard.tsx b/packages/twenty-front/src/modules/object-record/record-board/record-board-card/components/RecordBoardCard.tsx index 922b167501..25e43041d6 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/record-board-card/components/RecordBoardCard.tsx +++ b/packages/twenty-front/src/modules/object-record/record-board/record-board-card/components/RecordBoardCard.tsx @@ -1,6 +1,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 { 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'; @@ -30,6 +31,8 @@ import { useDebouncedCallback } from 'use-debounce'; const StyledBoardCard = styled.div<{ isDragging?: boolean; + isSecondaryDragged?: boolean; + isPrimaryMultiDrag?: boolean; }>` background-color: ${({ theme }) => theme.background.secondary}; border: 1px solid ${({ theme }) => theme.border.color.medium}; @@ -37,6 +40,12 @@ const StyledBoardCard = styled.div<{ color: ${({ theme }) => theme.font.color.primary}; cursor: pointer; + ${({ isSecondaryDragged }) => + isSecondaryDragged && + ` + opacity: 0.3; + `} + &[data-selected='true'] { background-color: ${({ theme }) => theme.accent.quaternary}; } @@ -80,6 +89,28 @@ const StyledBoardCard = styled.div<{ } `; +const StyledCardContainer = styled.div<{ isPrimaryMultiDrag?: boolean }>` + position: relative; + ${({ isPrimaryMultiDrag }) => + isPrimaryMultiDrag && + ` + transform: scale(1.02); + z-index: 10; + `} +`; + +const StyledRecordBoardCardStackCard = styled.div<{ offset: number }>` + position: absolute; + top: ${({ offset }) => (offset === 1 ? 2 : (offset - 1) * 4 + 2)}px; + left: 0; + right: 0; + height: 100%; + background-color: ${({ theme }) => theme.accent.tertiary}; + border: 1px solid ${({ theme }) => theme.border.color.medium}; + border-radius: ${({ theme }) => theme.border.radius.sm}; + z-index: ${({ offset }) => -offset}; +`; + const StyledBoardCardWrapper = styled.div` padding-bottom: ${({ theme }) => theme.spacing(2)}; width: 100%; @@ -90,6 +121,19 @@ export const RecordBoardCard = () => { RecordBoardCardContext, ); + const multiDragState = useBoardCardDragState(); + + const isPrimaryMultiDrag = + multiDragState && + multiDragState.isDragging && + recordId === multiDragState.primaryDraggedRecordId && + multiDragState.originalSelection.length > 1; + + const isSecondaryDragged = + multiDragState?.isDragging && + multiDragState.originalSelection.includes(recordId) && + recordId !== multiDragState.primaryDraggedRecordId; + const visibleFieldDefinitions = useRecoilComponentValueV2( recordBoardVisibleFieldDefinitionsComponentSelector, ); @@ -187,27 +231,38 @@ export const RecordBoardCard = () => { onContextMenu={handleContextMenuOpen} > - - - + {isPrimaryMultiDrag && + Array.from({ + length: Math.min(5, multiDragState.originalSelection.length - 1), + }).map((_, index) => ( + + ))} + + - - - + + + + + ); diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-card/components/RecordBoardCardDraggableContainer.tsx b/packages/twenty-front/src/modules/object-record/record-board/record-board-card/components/RecordBoardCardDraggableContainer.tsx index 460f309974..15a6bab375 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/record-board-card/components/RecordBoardCardDraggableContainer.tsx +++ b/packages/twenty-front/src/modules/object-record/record-board/record-board-card/components/RecordBoardCardDraggableContainer.tsx @@ -5,6 +5,7 @@ import { useContext } from 'react'; import { RecordBoardContext } from '@/object-record/record-board/contexts/RecordBoardContext'; import { RecordBoardCard } from '@/object-record/record-board/record-board-card/components/RecordBoardCard'; import { RecordBoardCardHotkeysEffect } from '@/object-record/record-board/record-board-card/components/RecordBoardCardHotkeysEffect'; +import { RecordBoardCardMultiDragPreview } from '@/object-record/record-board/record-board-card/components/RecordBoardCardMultiDragPreview'; import { RecordBoardCardContext } from '@/object-record/record-board/record-board-card/contexts/RecordBoardCardContext'; import { RecordBoardColumnContext } from '@/object-record/record-board/record-board-column/contexts/RecordBoardColumnContext'; import { isRecordBoardCardFocusedComponentFamilyState } from '@/object-record/record-board/states/isRecordBoardCardFocusedComponentFamilyState'; @@ -12,6 +13,7 @@ import { useIsRecordReadOnly } from '@/object-record/record-field/hooks/useIsRec import { useRecoilComponentFamilyValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentFamilyValueV2'; const StyledDraggableContainer = styled.div` + position: relative; scroll-margin-left: 8px; scroll-margin-right: 8px; scroll-margin-top: 40px; @@ -46,7 +48,7 @@ export const RecordBoardCardDraggableContainer = ({ value={{ recordId, isRecordReadOnly, rowIndex, columnIndex }} > - {(draggableProvided) => ( + {(draggableProvided, snapshot) => ( {isRecordBoardCardFocusActive && } + )} diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-card/components/RecordBoardCardMultiDragPreview.tsx b/packages/twenty-front/src/modules/object-record/record-board/record-board-card/components/RecordBoardCardMultiDragPreview.tsx new file mode 100644 index 0000000000..c96f655e01 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-board/record-board-card/components/RecordBoardCardMultiDragPreview.tsx @@ -0,0 +1,35 @@ +import { useBoardCardDragState } from '@/object-record/record-board/hooks/useBoardCardDragState'; +import { RecordBoardCardContext } from '@/object-record/record-board/record-board-card/contexts/RecordBoardCardContext'; +import styled from '@emotion/styled'; +import { useContext } from 'react'; +import { NotificationCounter } from 'twenty-ui/navigation'; + +const StyledNotificationCounter = styled(NotificationCounter)` + position: absolute; + top: -7px; + right: -7px; + z-index: 1000; +`; + +type RecordBoardCardMultiDragPreviewProps = { + isDragging: boolean; +}; + +export const RecordBoardCardMultiDragPreview = ({ + isDragging, +}: RecordBoardCardMultiDragPreviewProps) => { + const { recordId } = useContext(RecordBoardCardContext); + const multiDragState = useBoardCardDragState(); + + const isCurrentCardSelected = + multiDragState?.originalSelection.includes(recordId) || false; + const selectedCount = multiDragState?.originalSelection.length || 0; + + const shouldShow = isDragging && isCurrentCardSelected && selectedCount > 1; + + if (!shouldShow) { + return null; + } + + return ; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnCardsContainer.tsx b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnCardsContainer.tsx index 7e60cef4b0..964e16742d 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnCardsContainer.tsx +++ b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnCardsContainer.tsx @@ -13,6 +13,7 @@ import { isRecordBoardCompactModeActiveComponentState } from '@/object-record/re import { recordBoardVisibleFieldDefinitionsComponentSelector } from '@/object-record/record-board/states/selectors/recordBoardVisibleFieldDefinitionsComponentSelector'; import { isRecordIndexBoardColumnLoadingFamilyState } from '@/object-record/states/isRecordBoardColumnLoadingFamilyState'; import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; + const StyledColumnCardsContainer = styled.div` display: flex; flex: 1; diff --git a/packages/twenty-front/src/modules/object-record/record-board/states/draggedRecordIdsComponentState.ts b/packages/twenty-front/src/modules/object-record/record-board/states/draggedRecordIdsComponentState.ts new file mode 100644 index 0000000000..bf5d219d08 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-board/states/draggedRecordIdsComponentState.ts @@ -0,0 +1,8 @@ +import { RecordBoardComponentInstanceContext } from '@/object-record/record-board/states/contexts/RecordBoardComponentInstanceContext'; +import { createComponentStateV2 } from '@/ui/utilities/state/component-state/utils/createComponentStateV2'; + +export const draggedRecordIdsComponentState = createComponentStateV2({ + key: 'draggedRecordIdsComponentState', + defaultValue: [], + componentInstanceContext: RecordBoardComponentInstanceContext, +}); diff --git a/packages/twenty-front/src/modules/object-record/record-board/states/isMultiDragActiveComponentState.ts b/packages/twenty-front/src/modules/object-record/record-board/states/isMultiDragActiveComponentState.ts new file mode 100644 index 0000000000..23f384bd71 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-board/states/isMultiDragActiveComponentState.ts @@ -0,0 +1,8 @@ +import { RecordBoardComponentInstanceContext } from '@/object-record/record-board/states/contexts/RecordBoardComponentInstanceContext'; +import { createComponentStateV2 } from '@/ui/utilities/state/component-state/utils/createComponentStateV2'; + +export const isMultiDragActiveComponentState = createComponentStateV2({ + key: 'isMultiDragActiveComponentState', + defaultValue: false, + componentInstanceContext: RecordBoardComponentInstanceContext, +}); diff --git a/packages/twenty-front/src/modules/object-record/record-board/states/originalSelectionComponentState.ts b/packages/twenty-front/src/modules/object-record/record-board/states/originalSelectionComponentState.ts new file mode 100644 index 0000000000..eda6fae48a --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-board/states/originalSelectionComponentState.ts @@ -0,0 +1,10 @@ +import { RecordBoardComponentInstanceContext } from '@/object-record/record-board/states/contexts/RecordBoardComponentInstanceContext'; +import { createComponentStateV2 } from '@/ui/utilities/state/component-state/utils/createComponentStateV2'; + +export const originalSelectionComponentState = createComponentStateV2( + { + key: 'originalSelectionComponentState', + defaultValue: [], + componentInstanceContext: RecordBoardComponentInstanceContext, + }, +); diff --git a/packages/twenty-front/src/modules/object-record/record-board/states/primaryDraggedRecordIdComponentState.ts b/packages/twenty-front/src/modules/object-record/record-board/states/primaryDraggedRecordIdComponentState.ts new file mode 100644 index 0000000000..9a21e06ac5 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-board/states/primaryDraggedRecordIdComponentState.ts @@ -0,0 +1,10 @@ +import { RecordBoardComponentInstanceContext } from '@/object-record/record-board/states/contexts/RecordBoardComponentInstanceContext'; +import { createComponentStateV2 } from '@/ui/utilities/state/component-state/utils/createComponentStateV2'; + +export const primaryDraggedRecordIdComponentState = createComponentStateV2< + string | null +>({ + key: 'primaryDraggedRecordIdComponentState', + defaultValue: null, + componentInstanceContext: RecordBoardComponentInstanceContext, +}); diff --git a/packages/twenty-front/src/modules/object-record/record-board/types/DragOperationType.ts b/packages/twenty-front/src/modules/object-record/record-board/types/DragOperationType.ts new file mode 100644 index 0000000000..c4ef561b11 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-board/types/DragOperationType.ts @@ -0,0 +1 @@ +export type DragOperationType = 'single' | 'multi'; diff --git a/packages/twenty-front/src/modules/object-record/record-board/utils/__tests__/calculateRecordPositions.test.ts b/packages/twenty-front/src/modules/object-record/record-board/utils/__tests__/calculateRecordPositions.test.ts new file mode 100644 index 0000000000..c712e4cbf3 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-board/utils/__tests__/calculateRecordPositions.test.ts @@ -0,0 +1,446 @@ +import { calculateRecordPositions } from '../calculateRecordPositions'; + +import { getDraggedRecordPosition } from '@/object-record/record-board/utils/getDraggedRecordPosition'; +import { getIndexNeighboursElementsFromArray } from '~/utils/array/getIndexNeighboursElementsFromArray'; + +jest.mock( + '@/object-record/record-board/utils/getDraggedRecordPosition', + () => ({ + getDraggedRecordPosition: jest.fn(), + }), +); + +jest.mock('~/utils/array/getIndexNeighboursElementsFromArray', () => ({ + getIndexNeighboursElementsFromArray: jest.fn(), +})); + +const mockGetDraggedRecordPosition = getDraggedRecordPosition as jest.Mock; +const mockGetIndexNeighboursElementsFromArray = + getIndexNeighboursElementsFromArray as jest.Mock; + +describe('calculateRecordPositions', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + const mockRecordPositionData = [ + { recordId: 'record-1', position: 1 }, + { recordId: 'record-2', position: 2 }, + { recordId: 'record-3', position: 3 }, + { recordId: 'record-4', position: 4 }, + { recordId: 'record-5', position: 5 }, + ]; + + it('should calculate single record position correctly', () => { + const basePosition = 2.5; + + mockGetIndexNeighboursElementsFromArray.mockReturnValue({ + before: 'record-2', + after: 'record-3', + }); + + mockGetDraggedRecordPosition.mockReturnValue(basePosition); + + const result = calculateRecordPositions({ + destinationRecordIds: [ + 'record-1', + 'record-2', + 'record-3', + 'record-4', + 'record-5', + ], + recordsToMove: ['record-1'], + destinationIndex: 2, + recordPositionData: mockRecordPositionData, + }); + + expect(result).toEqual({ + 'record-1': basePosition, + }); + expect(mockGetDraggedRecordPosition).toHaveBeenCalledWith(2, 3); + }); + + it('should calculate multiple record positions with proportional spacing', () => { + const basePosition = 2.5; + + mockGetIndexNeighboursElementsFromArray.mockReturnValue({ + before: 'record-2', + after: 'record-5', + }); + + mockGetDraggedRecordPosition.mockReturnValue(basePosition); + + const result = calculateRecordPositions({ + destinationRecordIds: ['record-2', 'record-3', 'record-4', 'record-5'], + recordsToMove: ['record-1', 'record-6', 'record-7'], + destinationIndex: 1, + recordPositionData: mockRecordPositionData, + }); + + expect(result).toEqual({ + 'record-1': 3.125, + 'record-6': 3.75, + 'record-7': 4.375, + }); + }); + + it('should filter out records being moved from destination column', () => { + const basePosition = 2.5; + + mockGetIndexNeighboursElementsFromArray.mockReturnValue({ + before: 'record-2', + after: 'record-5', + }); + + mockGetDraggedRecordPosition.mockReturnValue(basePosition); + + calculateRecordPositions({ + destinationRecordIds: [ + 'record-1', + 'record-2', + 'record-3', + 'record-4', + 'record-5', + ], + recordsToMove: ['record-1', 'record-3'], + destinationIndex: 1, + recordPositionData: mockRecordPositionData, + }); + + expect(mockGetIndexNeighboursElementsFromArray).toHaveBeenCalledWith({ + index: 1, + array: ['record-2', 'record-3', 'record-4', 'record-5'], + }); + }); + + it('should handle same group single drag by filtering source record', () => { + const basePosition = 2.5; + + mockGetIndexNeighboursElementsFromArray.mockReturnValue({ + before: 'record-2', + after: 'record-4', + }); + + mockGetDraggedRecordPosition.mockReturnValue(basePosition); + + calculateRecordPositions({ + destinationRecordIds: [ + 'record-1', + 'record-2', + 'record-3', + 'record-4', + 'record-5', + ], + recordsToMove: ['record-3'], + destinationIndex: 1, + recordPositionData: mockRecordPositionData, + }); + + expect(mockGetIndexNeighboursElementsFromArray).toHaveBeenCalledWith({ + index: 1, + array: ['record-1', 'record-2', 'record-4', 'record-5'], + }); + }); + + it('should handle case with no before record', () => { + const basePosition = 0.5; + + mockGetIndexNeighboursElementsFromArray.mockReturnValue({ + before: null, + after: 'record-2', + }); + + mockGetDraggedRecordPosition.mockReturnValue(basePosition); + + const result = calculateRecordPositions({ + destinationRecordIds: ['record-1', 'record-2', 'record-3'], + recordsToMove: ['record-new'], + destinationIndex: 0, + recordPositionData: mockRecordPositionData, + }); + + expect(mockGetDraggedRecordPosition).toHaveBeenCalledWith(undefined, 2); + expect(result).toEqual({ + 'record-new': basePosition, + }); + }); + + it('should handle case with no after record', () => { + const basePosition = 5.5; + + mockGetIndexNeighboursElementsFromArray.mockReturnValue({ + before: 'record-5', + after: null, + }); + + mockGetDraggedRecordPosition.mockReturnValue(basePosition); + + const result = calculateRecordPositions({ + destinationRecordIds: [ + 'record-1', + 'record-2', + 'record-3', + 'record-4', + 'record-5', + ], + recordsToMove: ['record-new'], + destinationIndex: 5, + recordPositionData: mockRecordPositionData, + }); + + expect(mockGetDraggedRecordPosition).toHaveBeenCalledWith(5, undefined); + expect(result).toEqual({ + 'record-new': basePosition, + }); + }); + + it('should handle multi-drag with proportional spacing when after record exists', () => { + const basePosition = 2.5; + const afterPosition = 5.0; + + mockGetIndexNeighboursElementsFromArray.mockReturnValue({ + before: 'record-2', + after: 'record-5', + }); + + mockGetDraggedRecordPosition.mockReturnValue(basePosition); + + const recordPositionDataWithAfter = [ + ...mockRecordPositionData, + { recordId: 'record-5', position: afterPosition }, + ]; + + const result = calculateRecordPositions({ + destinationRecordIds: [ + 'record-1', + 'record-2', + 'record-3', + 'record-4', + 'record-5', + ], + recordsToMove: ['record-a', 'record-b'], + destinationIndex: 1, + recordPositionData: recordPositionDataWithAfter, + }); + + expect(result).toEqual({ + 'record-a': 3.3333333333333335, + 'record-b': 4.166666666666667, + }); + }); + + it('should handle multi-drag with default spacing when no after record exists', () => { + const basePosition = 5.5; + + mockGetIndexNeighboursElementsFromArray.mockReturnValue({ + before: 'record-5', + after: null, + }); + + mockGetDraggedRecordPosition.mockReturnValue(basePosition); + + const result = calculateRecordPositions({ + destinationRecordIds: [ + 'record-1', + 'record-2', + 'record-3', + 'record-4', + 'record-5', + ], + recordsToMove: ['record-a', 'record-b'], + destinationIndex: 5, + recordPositionData: mockRecordPositionData, + }); + + expect(result).toEqual({ + 'record-a': 5.833333333333333, + 'record-b': 6.166666666666667, + }); + }); + + describe('Single drag scenarios', () => { + it('should handle single drag within same column', () => { + mockGetIndexNeighboursElementsFromArray.mockReturnValue({ + before: 'record-2', + after: 'record-4', + }); + mockGetDraggedRecordPosition.mockReturnValue(2.5); + + const result = calculateRecordPositions({ + destinationRecordIds: ['record-1', 'record-2', 'record-3', 'record-4'], + recordsToMove: ['record-3'], + destinationIndex: 2, + recordPositionData: mockRecordPositionData, + }); + + expect(mockGetIndexNeighboursElementsFromArray).toHaveBeenCalledWith({ + index: 2, + array: ['record-1', 'record-2', 'record-4'], + }); + + expect(result).toEqual({ + 'record-3': 2.5, + }); + }); + + it('should handle single drag to empty column', () => { + mockGetIndexNeighboursElementsFromArray.mockReturnValue({ + before: undefined, + after: undefined, + }); + mockGetDraggedRecordPosition.mockReturnValue(1); + + const result = calculateRecordPositions({ + destinationRecordIds: [], + recordsToMove: ['record-1'], + destinationIndex: 0, + recordPositionData: mockRecordPositionData, + }); + + expect(mockGetIndexNeighboursElementsFromArray).toHaveBeenCalledWith({ + index: 0, + array: [], + }); + + expect(result).toEqual({ + 'record-1': 1, + }); + }); + }); + + describe('Multi-drag scenarios', () => { + it('should handle multi-drag with secondary records already in destination', () => { + mockGetIndexNeighboursElementsFromArray.mockReturnValue({ + before: 'record-1', + after: 'record-4', + }); + mockGetDraggedRecordPosition.mockReturnValue(2); + + const result = calculateRecordPositions({ + destinationRecordIds: ['record-1', 'record-2', 'record-3', 'record-4'], + recordsToMove: ['record-2', 'record-3', 'record-5'], + destinationIndex: 1, + recordPositionData: mockRecordPositionData, + }); + + expect(mockGetIndexNeighboursElementsFromArray).toHaveBeenCalledWith({ + index: 1, + array: ['record-1', 'record-3', 'record-4'], + }); + + const availableSpace = 4 - 2; + const increment = availableSpace / (3 + 1); + + expect(result).toEqual({ + 'record-2': 2 + 1 * increment, + 'record-3': 2 + 2 * increment, + 'record-5': 2 + 3 * increment, + }); + }); + + it('should handle multi-drag where all records are from different columns', () => { + mockGetIndexNeighboursElementsFromArray.mockReturnValue({ + before: 'record-1', + after: 'record-2', + }); + mockGetDraggedRecordPosition.mockReturnValue(1.5); + + const result = calculateRecordPositions({ + destinationRecordIds: ['record-1', 'record-2'], + recordsToMove: ['record-3', 'record-4'], + destinationIndex: 1, + recordPositionData: mockRecordPositionData, + }); + + expect(mockGetIndexNeighboursElementsFromArray).toHaveBeenCalledWith({ + index: 1, + array: ['record-1', 'record-2'], + }); + + const availableSpace = 2 - 1.5; + const increment = availableSpace / 3; + + expect(result).toEqual({ + 'record-3': 1.5 + increment, + 'record-4': 1.5 + 2 * increment, + }); + }); + + it('should handle multi-drag to empty column', () => { + mockGetIndexNeighboursElementsFromArray.mockReturnValue({ + before: undefined, + after: undefined, + }); + mockGetDraggedRecordPosition.mockReturnValue(1); + + const result = calculateRecordPositions({ + destinationRecordIds: [], + recordsToMove: ['record-1', 'record-2'], + destinationIndex: 0, + recordPositionData: mockRecordPositionData, + }); + + expect(mockGetIndexNeighboursElementsFromArray).toHaveBeenCalledWith({ + index: 0, + array: [], + }); + + const increment = 1 / 3; + + expect(result).toEqual({ + 'record-1': 1 + increment, + 'record-2': 1 + 2 * increment, + }); + }); + }); + + describe('Edge cases', () => { + it('should handle moving to beginning of column (index 0)', () => { + mockGetIndexNeighboursElementsFromArray.mockReturnValue({ + before: undefined, + after: 'record-1', + }); + mockGetDraggedRecordPosition.mockReturnValue(0.5); + + const result = calculateRecordPositions({ + destinationRecordIds: ['record-1', 'record-2'], + recordsToMove: ['record-3'], + destinationIndex: 0, + recordPositionData: mockRecordPositionData, + }); + + expect(mockGetIndexNeighboursElementsFromArray).toHaveBeenCalledWith({ + index: 0, + array: ['record-1', 'record-2'], + }); + + expect(result).toEqual({ + 'record-3': 0.5, + }); + }); + + it('should handle moving to end of column', () => { + mockGetIndexNeighboursElementsFromArray.mockReturnValue({ + before: 'record-2', + after: undefined, + }); + mockGetDraggedRecordPosition.mockReturnValue(2.5); + + const result = calculateRecordPositions({ + destinationRecordIds: ['record-1', 'record-2'], + recordsToMove: ['record-3'], + destinationIndex: 2, + recordPositionData: mockRecordPositionData, + }); + + expect(mockGetIndexNeighboursElementsFromArray).toHaveBeenCalledWith({ + index: 2, + array: ['record-1', 'record-2'], + }); + + expect(result).toEqual({ + 'record-3': 2.5, + }); + }); + }); +}); diff --git a/packages/twenty-front/src/modules/object-record/record-board/utils/__tests__/getDragOperationType.test.ts b/packages/twenty-front/src/modules/object-record/record-board/utils/__tests__/getDragOperationType.test.ts new file mode 100644 index 0000000000..77024bcb61 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-board/utils/__tests__/getDragOperationType.test.ts @@ -0,0 +1,57 @@ +import { getDragOperationType } from '../getDragOperationType'; + +describe('getDragOperationType', () => { + it('should return single when dragged record is not selected', () => { + const result = getDragOperationType({ + draggedRecordId: 'record-1', + selectedRecordIds: ['record-2', 'record-3'], + }); + + expect(result).toBe('single'); + }); + + it('should return single when dragged record is selected but only one record is selected', () => { + const result = getDragOperationType({ + draggedRecordId: 'record-1', + selectedRecordIds: ['record-1'], + }); + + expect(result).toBe('single'); + }); + + it('should return multi when dragged record is selected and multiple records are selected', () => { + const result = getDragOperationType({ + draggedRecordId: 'record-1', + selectedRecordIds: ['record-1', 'record-2', 'record-3'], + }); + + expect(result).toBe('multi'); + }); + + it('should return single when no records are selected', () => { + const result = getDragOperationType({ + draggedRecordId: 'record-1', + selectedRecordIds: [], + }); + + expect(result).toBe('single'); + }); + + it('should return multi when dragged record is in middle of selection', () => { + const result = getDragOperationType({ + draggedRecordId: 'record-2', + selectedRecordIds: ['record-1', 'record-2', 'record-3'], + }); + + expect(result).toBe('multi'); + }); + + it('should return multi when dragged record is last in selection', () => { + const result = getDragOperationType({ + draggedRecordId: 'record-3', + selectedRecordIds: ['record-1', 'record-2', 'record-3'], + }); + + expect(result).toBe('multi'); + }); +}); diff --git a/packages/twenty-front/src/modules/object-record/record-board/utils/__tests__/processMultiDrag.test.ts b/packages/twenty-front/src/modules/object-record/record-board/utils/__tests__/processMultiDrag.test.ts new file mode 100644 index 0000000000..7f2429b736 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-board/utils/__tests__/processMultiDrag.test.ts @@ -0,0 +1,180 @@ +import { DropResult } from '@hello-pangea/dnd'; +import { processMultiDrag } from '../processMultiDrag'; + +import { calculateRecordPositions } from '../calculateRecordPositions'; + +jest.mock('../calculateRecordPositions', () => ({ + calculateRecordPositions: jest.fn(), +})); + +const mockCalculateRecordPositions = calculateRecordPositions as jest.Mock; + +describe('processMultiDrag', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + const mockDropResult: DropResult = { + draggableId: 'record-1', + type: 'DEFAULT', + source: { + droppableId: 'group-1', + index: 0, + }, + destination: { + droppableId: 'group-2', + index: 1, + }, + reason: 'DROP', + mode: 'FLUID', + combine: null, + }; + + const mockRecordPositionData = [ + { recordId: 'record-1', position: 1 }, + { recordId: 'record-2', position: 2 }, + { recordId: 'record-3', position: 3 }, + ]; + + it('should process multi-drag operation correctly', () => { + const selectedRecordIds = ['record-1', 'record-3']; + + mockCalculateRecordPositions.mockReturnValue({ + 'record-1': 2.5, + 'record-3': 2.5001, + }); + + const result = processMultiDrag({ + result: mockDropResult, + selectedRecordIds, + recordPositionData: mockRecordPositionData, + destinationRecordIds: ['record-1', 'record-2', 'record-3'], + groupValue: 'new-group-value', + selectFieldName: 'status', + }); + + expect(result).toEqual({ + recordUpdates: [ + { + recordId: 'record-1', + position: 2.5, + groupValue: 'new-group-value', + selectFieldName: 'status', + }, + { + recordId: 'record-3', + position: 2.5001, + groupValue: 'new-group-value', + selectFieldName: 'status', + }, + ], + }); + + expect(mockCalculateRecordPositions).toHaveBeenCalledWith({ + destinationRecordIds: ['record-1', 'record-2', 'record-3'], + recordsToMove: selectedRecordIds, + destinationIndex: 1, + recordPositionData: mockRecordPositionData, + }); + }); + + it('should handle single record in multi-drag correctly', () => { + const selectedRecordIds = ['record-2']; + + mockCalculateRecordPositions.mockReturnValue({ + 'record-2': 3.5, + }); + + const result = processMultiDrag({ + result: mockDropResult, + selectedRecordIds, + recordPositionData: mockRecordPositionData, + destinationRecordIds: ['record-1', 'record-2', 'record-3'], + groupValue: 'single-record-group', + selectFieldName: 'priority', + }); + + expect(result).toEqual({ + recordUpdates: [ + { + recordId: 'record-2', + position: 3.5, + groupValue: 'single-record-group', + selectFieldName: 'priority', + }, + ], + }); + }); + + it('should handle null group value', () => { + const selectedRecordIds = ['record-1', 'record-2']; + + mockCalculateRecordPositions.mockReturnValue({ + 'record-1': 1.5, + 'record-2': 1.5001, + }); + + const result = processMultiDrag({ + result: mockDropResult, + selectedRecordIds, + recordPositionData: mockRecordPositionData, + destinationRecordIds: ['record-1', 'record-2', 'record-3'], + groupValue: null, + selectFieldName: 'category', + }); + + expect(result).toEqual({ + recordUpdates: [ + { + recordId: 'record-1', + position: 1.5, + groupValue: null, + selectFieldName: 'category', + }, + { + recordId: 'record-2', + position: 1.5001, + groupValue: null, + selectFieldName: 'category', + }, + ], + }); + }); + + it('should throw error when destination is null', () => { + const dropResultWithoutDestination = { + ...mockDropResult, + destination: null, + }; + + expect(() => { + processMultiDrag({ + result: dropResultWithoutDestination, + selectedRecordIds: ['record-1', 'record-2'], + recordPositionData: mockRecordPositionData, + destinationRecordIds: ['record-1', 'record-2', 'record-3'], + groupValue: 'new-group-value', + selectFieldName: 'status', + }); + }).toThrow('Destination is required for drag operation'); + }); + + it('should handle empty selectedRecordIds', () => { + const selectedRecordIds: string[] = []; + + mockCalculateRecordPositions.mockReturnValue({}); + + const result = processMultiDrag({ + result: mockDropResult, + selectedRecordIds, + recordPositionData: mockRecordPositionData, + destinationRecordIds: ['record-1', 'record-2', 'record-3'], + groupValue: 'empty-selection', + selectFieldName: 'status', + }); + + expect(result).toEqual({ + recordUpdates: [], + }); + }); +}); diff --git a/packages/twenty-front/src/modules/object-record/record-board/utils/__tests__/processSingleDrag.test.ts b/packages/twenty-front/src/modules/object-record/record-board/utils/__tests__/processSingleDrag.test.ts new file mode 100644 index 0000000000..3435d94c93 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-board/utils/__tests__/processSingleDrag.test.ts @@ -0,0 +1,143 @@ +import { DropResult } from '@hello-pangea/dnd'; +import { processSingleDrag } from '../processSingleDrag'; + +import { calculateRecordPositions } from '../calculateRecordPositions'; + +jest.mock('../calculateRecordPositions', () => ({ + calculateRecordPositions: jest.fn(), +})); + +const mockCalculateRecordPositions = calculateRecordPositions as jest.Mock; + +describe('processSingleDrag', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + const mockDropResult: DropResult = { + draggableId: 'record-1', + type: 'DEFAULT', + source: { + droppableId: 'group-1', + index: 0, + }, + destination: { + droppableId: 'group-2', + index: 1, + }, + reason: 'DROP', + mode: 'FLUID', + combine: null, + }; + + const mockRecordPositionData = [ + { recordId: 'record-1', position: 1 }, + { recordId: 'record-2', position: 2 }, + { recordId: 'record-3', position: 3 }, + ]; + + it('should process single drag operation correctly', () => { + const expectedPosition = 2.5; + + mockCalculateRecordPositions.mockReturnValue({ + 'record-1': expectedPosition, + }); + + const result = processSingleDrag({ + result: mockDropResult, + recordPositionData: mockRecordPositionData, + destinationRecordIds: ['record-1', 'record-2', 'record-3'], + groupValue: 'new-group-value', + selectFieldName: 'status', + }); + + expect(result).toEqual({ + recordId: 'record-1', + position: expectedPosition, + groupValue: 'new-group-value', + selectFieldName: 'status', + }); + + expect(mockCalculateRecordPositions).toHaveBeenCalledWith({ + destinationRecordIds: ['record-1', 'record-2', 'record-3'], + recordsToMove: ['record-1'], + destinationIndex: 1, + recordPositionData: mockRecordPositionData, + }); + }); + + it('should handle different record IDs correctly', () => { + const dropResult = { + ...mockDropResult, + draggableId: 'record-5', + }; + + const expectedPosition = 3.5; + + mockCalculateRecordPositions.mockReturnValue({ + 'record-5': expectedPosition, + }); + + const result = processSingleDrag({ + result: dropResult, + recordPositionData: mockRecordPositionData, + destinationRecordIds: ['record-1', 'record-2', 'record-3'], + groupValue: 'another-group-value', + selectFieldName: 'priority', + }); + + expect(result).toEqual({ + recordId: 'record-5', + position: expectedPosition, + groupValue: 'another-group-value', + selectFieldName: 'priority', + }); + + expect(mockCalculateRecordPositions).toHaveBeenCalledWith({ + destinationRecordIds: ['record-1', 'record-2', 'record-3'], + recordsToMove: ['record-5'], + destinationIndex: 1, + recordPositionData: mockRecordPositionData, + }); + }); + + it('should handle null group value', () => { + const expectedPosition = 1.5; + + mockCalculateRecordPositions.mockReturnValue({ + 'record-1': expectedPosition, + }); + + const result = processSingleDrag({ + result: mockDropResult, + recordPositionData: mockRecordPositionData, + destinationRecordIds: ['record-1', 'record-2', 'record-3'], + groupValue: null, + selectFieldName: 'category', + }); + + expect(result).toEqual({ + recordId: 'record-1', + position: expectedPosition, + groupValue: null, + selectFieldName: 'category', + }); + }); + + it('should throw error when destination is null', () => { + const dropResultWithoutDestination = { + ...mockDropResult, + destination: null, + }; + + expect(() => { + processSingleDrag({ + result: dropResultWithoutDestination, + recordPositionData: mockRecordPositionData, + destinationRecordIds: ['record-1', 'record-2', 'record-3'], + groupValue: 'new-group-value', + selectFieldName: 'status', + }); + }).toThrow('Destination is required for drag operation'); + }); +}); diff --git a/packages/twenty-front/src/modules/object-record/record-board/utils/calculateRecordPositions.ts b/packages/twenty-front/src/modules/object-record/record-board/utils/calculateRecordPositions.ts new file mode 100644 index 0000000000..6da7c505bb --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-board/utils/calculateRecordPositions.ts @@ -0,0 +1,68 @@ +import { getDraggedRecordPosition } from '@/object-record/record-board/utils/getDraggedRecordPosition'; +import { getIndexNeighboursElementsFromArray } from '~/utils/array/getIndexNeighboursElementsFromArray'; + +export type RecordPositionData = { + recordId: string; + position?: number; +}; + +type PositionCalculationContext = { + destinationRecordIds: string[]; + recordsToMove: string[]; + destinationIndex: number; + recordPositionData: RecordPositionData[]; +}; + +export const calculateRecordPositions = ({ + destinationRecordIds, + recordsToMove, + destinationIndex, + recordPositionData, +}: PositionCalculationContext): Record => { + const otherRecordIdsInDestinationColumn = destinationRecordIds.filter( + (recordId: string) => !recordsToMove.includes(recordId), + ); + + const filteredRecordIds = + recordsToMove.length === 1 + ? otherRecordIdsInDestinationColumn + : destinationRecordIds.filter( + (recordId) => recordId !== recordsToMove[0], + ); + + const { before: recordBeforeId, after: recordAfterId } = + getIndexNeighboursElementsFromArray({ + index: destinationIndex, + array: filteredRecordIds, + }); + + const recordBefore = recordBeforeId + ? recordPositionData.find((r) => r.recordId === recordBeforeId) + : null; + + const recordAfter = recordAfterId + ? recordPositionData.find((r) => r.recordId === recordAfterId) + : null; + + const basePosition = getDraggedRecordPosition( + recordBefore?.position, + recordAfter?.position, + ); + + const positions: Record = {}; + + for (const [index, recordId] of recordsToMove.entries()) { + if (recordsToMove.length > 1) { + const availableSpace = recordAfter?.position + ? recordAfter.position - basePosition + : 1; + + const increment = availableSpace / (recordsToMove.length + 1); + positions[recordId] = basePosition + (index + 1) * increment; + } else { + positions[recordId] = basePosition; + } + } + + return positions; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-board/utils/getDragOperationType.ts b/packages/twenty-front/src/modules/object-record/record-board/utils/getDragOperationType.ts new file mode 100644 index 0000000000..5b4e0b2502 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-board/utils/getDragOperationType.ts @@ -0,0 +1,16 @@ +import { DragOperationType } from '../types/DragOperationType'; + +type DragOperationContext = { + draggedRecordId: string; + selectedRecordIds: string[]; +}; + +export const getDragOperationType = ({ + draggedRecordId, + selectedRecordIds, +}: DragOperationContext): DragOperationType => { + const isDraggedItemSelected = selectedRecordIds.includes(draggedRecordId); + const hasMultipleSelected = selectedRecordIds.length > 1; + + return isDraggedItemSelected && hasMultipleSelected ? 'multi' : 'single'; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-board/utils/processMultiDrag.ts b/packages/twenty-front/src/modules/object-record/record-board/utils/processMultiDrag.ts new file mode 100644 index 0000000000..706b927605 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-board/utils/processMultiDrag.ts @@ -0,0 +1,58 @@ +import { DropResult } from '@hello-pangea/dnd'; +import { + calculateRecordPositions, + RecordPositionData, +} from './calculateRecordPositions'; + +type MultiDragContext = { + result: DropResult; + selectedRecordIds: string[]; + recordPositionData: RecordPositionData[]; + destinationRecordIds: string[]; + groupValue: string | null; + selectFieldName: string; +}; + +type MultiDragResult = { + recordUpdates: Array<{ + recordId: string; + position: number; + groupValue: string | null; + selectFieldName: string; + }>; +}; + +export const processMultiDrag = ({ + result, + selectedRecordIds, + recordPositionData, + destinationRecordIds, + groupValue, + selectFieldName, +}: MultiDragContext): MultiDragResult => { + if (!result.destination) { + throw new Error('Destination is required for drag operation'); + } + + const destinationIndex = result.destination.index; + + const recordsToMove = selectedRecordIds; + + const positions = calculateRecordPositions({ + destinationRecordIds, + recordsToMove, + destinationIndex, + recordPositionData, + }); + + const recordUpdates = recordsToMove.map((recordId) => ({ + recordId, + position: positions[recordId], + groupValue, + selectFieldName, + })); + + return { + recordUpdates, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-board/utils/processSingleDrag.ts b/packages/twenty-front/src/modules/object-record/record-board/utils/processSingleDrag.ts new file mode 100644 index 0000000000..8b2ec564cc --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-board/utils/processSingleDrag.ts @@ -0,0 +1,52 @@ +import { DropResult } from '@hello-pangea/dnd'; +import { + calculateRecordPositions, + RecordPositionData, +} from './calculateRecordPositions'; + +type SingleDragContext = { + result: DropResult; + recordPositionData: RecordPositionData[]; + destinationRecordIds: string[]; + groupValue: string | null; + selectFieldName: string; +}; + +type SingleDragResult = { + recordId: string; + position: number; + groupValue: string | null; + selectFieldName: string; +}; + +export const processSingleDrag = ({ + result, + recordPositionData, + destinationRecordIds, + groupValue, + selectFieldName, +}: SingleDragContext): SingleDragResult => { + const draggedRecordId = result.draggableId; + + if (!result.destination) { + throw new Error('Destination is required for drag operation'); + } + + const destinationIndex = result.destination.index; + + const recordsToMove = [draggedRecordId]; + + const positions = calculateRecordPositions({ + destinationRecordIds, + recordsToMove, + destinationIndex, + recordPositionData, + }); + + return { + recordId: draggedRecordId, + position: positions[draggedRecordId], + groupValue, + selectFieldName, + }; +}; diff --git a/packages/twenty-ui/src/navigation/index.ts b/packages/twenty-ui/src/navigation/index.ts index d35387101d..031a1c8430 100644 --- a/packages/twenty-ui/src/navigation/index.ts +++ b/packages/twenty-ui/src/navigation/index.ts @@ -64,3 +64,4 @@ export { export type { MenuItemAccent } from './menu-item/types/MenuItemAccent'; export { NavigationBar } from './navigation-bar/components/NavigationBar'; export { NavigationBarItem } from './navigation-bar/components/NavigationBarItem'; +export { NotificationCounter } from './notification-counter/components/NotificationCounter'; diff --git a/packages/twenty-ui/src/navigation/notification-counter/components/NotificationCounter.tsx b/packages/twenty-ui/src/navigation/notification-counter/components/NotificationCounter.tsx new file mode 100644 index 0000000000..d193adb72a --- /dev/null +++ b/packages/twenty-ui/src/navigation/notification-counter/components/NotificationCounter.tsx @@ -0,0 +1,38 @@ +import styled from '@emotion/styled'; + +const StyledNotificationCounter = styled.div<{ + variant: 'primary' | 'secondary'; +}>` + width: 14px; + height: 14px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: ${({ theme }) => theme.font.size.xxs}; + font-weight: ${({ theme }) => theme.font.weight.semiBold}; + background: ${({ theme, variant }) => + variant === 'primary' + ? theme.color.blue + : theme.background.transparent.light}; + color: ${({ theme, variant }) => + variant === 'primary' ? 'white' : theme.font.color.secondary}; +`; + +type NotificationCounterProps = { + count: number; + variant?: 'primary' | 'secondary'; + className?: string; +}; + +export const NotificationCounter = ({ + count, + variant = 'primary', + className, +}: NotificationCounterProps) => { + return ( + + {count} + + ); +};