[POC] multi select drag and drop on record board (#13053)

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 <felix.malfait@gmail.com>
This commit is contained in:
nitin
2025-08-01 18:29:35 +05:30
committed by GitHub
parent 73f12d3504
commit c44f610872
25 changed files with 1572 additions and 98 deletions
@@ -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<HTMLDivElement>(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 = () => {
<RecordBoardHeader />
<StyledBoardContentContainer>
<StyledContainer ref={boardRef}>
<DragDropContext onDragEnd={handleDragEnd}>
<DragDropContext
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<StyledColumnContainer>
{visibleRecordGroupIds.map((recordGroupId, index) => (
<RecordBoardColumn
@@ -0,0 +1,34 @@
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 { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
export const useBoardCardDragState = (recordBoardId?: string) => {
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,
};
};
@@ -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,
],
);
};
@@ -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<MultiDragState>({
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,
};
};
@@ -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,
};
};
@@ -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,
],
);
};
@@ -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}
>
<InView>
<StyledBoardCard
ref={cardRef}
data-selected={isCurrentCardSelected}
data-focused={isCurrentCardFocused}
data-active={isCurrentCardActive}
onMouseLeave={onMouseLeaveBoard}
onClick={handleCardClick}
>
<RecordBoardCardHeader
isCardExpanded={isCardExpanded}
setIsCardExpanded={setIsCardExpanded}
/>
<AnimatedEaseInOut
isOpen={isCardExpanded || !isCompactModeActive}
initial={false}
<StyledCardContainer isPrimaryMultiDrag={isPrimaryMultiDrag}>
{isPrimaryMultiDrag &&
Array.from({
length: Math.min(5, multiDragState.originalSelection.length - 1),
}).map((_, index) => (
<StyledRecordBoardCardStackCard key={index} offset={index + 1} />
))}
<StyledBoardCard
ref={cardRef}
data-selected={isCurrentCardSelected}
data-focused={isCurrentCardFocused}
data-active={isCurrentCardActive}
onMouseLeave={onMouseLeaveBoard}
onClick={handleCardClick}
isPrimaryMultiDrag={isPrimaryMultiDrag}
isSecondaryDragged={isSecondaryDragged}
>
<RecordBoardCardBody
fieldDefinitions={visibleFieldDefinitionsFiltered}
<RecordBoardCardHeader
isCardExpanded={isCardExpanded}
setIsCardExpanded={setIsCardExpanded}
/>
</AnimatedEaseInOut>
</StyledBoardCard>
<AnimatedEaseInOut
isOpen={isCardExpanded || !isCompactModeActive}
initial={false}
>
<RecordBoardCardBody
fieldDefinitions={visibleFieldDefinitionsFiltered}
/>
</AnimatedEaseInOut>
</StyledBoardCard>
</StyledCardContainer>
</InView>
</StyledBoardCardWrapper>
);
@@ -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 }}
>
<Draggable key={recordId} draggableId={recordId} index={rowIndex}>
{(draggableProvided) => (
{(draggableProvided, snapshot) => (
<StyledDraggableContainer
id={`record-board-card-${columnIndex}-${rowIndex}`}
ref={draggableProvided?.innerRef}
@@ -59,6 +61,7 @@ export const RecordBoardCardDraggableContainer = ({
>
{isRecordBoardCardFocusActive && <RecordBoardCardHotkeysEffect />}
<RecordBoardCard />
<RecordBoardCardMultiDragPreview isDragging={snapshot.isDragging} />
</StyledDraggableContainer>
)}
</Draggable>
@@ -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 <StyledNotificationCounter count={selectedCount} />;
};
@@ -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;
@@ -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<string[]>({
key: 'draggedRecordIdsComponentState',
defaultValue: [],
componentInstanceContext: RecordBoardComponentInstanceContext,
});
@@ -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<boolean>({
key: 'isMultiDragActiveComponentState',
defaultValue: false,
componentInstanceContext: RecordBoardComponentInstanceContext,
});
@@ -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<string[]>(
{
key: 'originalSelectionComponentState',
defaultValue: [],
componentInstanceContext: RecordBoardComponentInstanceContext,
},
);
@@ -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,
});
@@ -0,0 +1 @@
export type DragOperationType = 'single' | 'multi';
@@ -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,
});
});
});
});
@@ -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');
});
});
@@ -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: [],
});
});
});
@@ -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');
});
});
@@ -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<string, number> => {
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<string, number> = {};
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;
};
@@ -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';
};
@@ -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,
};
};
@@ -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,
};
};
@@ -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';
@@ -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 (
<StyledNotificationCounter variant={variant} className={className}>
{count}
</StyledNotificationCounter>
);
};