[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:
+57
-78
@@ -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
|
||||
|
||||
+34
@@ -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,
|
||||
};
|
||||
};
|
||||
+45
@@ -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,
|
||||
],
|
||||
);
|
||||
};
|
||||
+59
@@ -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,
|
||||
};
|
||||
};
|
||||
+106
@@ -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,
|
||||
};
|
||||
};
|
||||
+61
@@ -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,
|
||||
],
|
||||
);
|
||||
};
|
||||
+74
-19
@@ -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>
|
||||
);
|
||||
|
||||
+4
-1
@@ -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>
|
||||
|
||||
+35
@@ -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} />;
|
||||
};
|
||||
+1
@@ -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;
|
||||
|
||||
+8
@@ -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,
|
||||
});
|
||||
+8
@@ -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,
|
||||
});
|
||||
+10
@@ -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,
|
||||
},
|
||||
);
|
||||
+10
@@ -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,
|
||||
});
|
||||
+1
@@ -0,0 +1 @@
|
||||
export type DragOperationType = 'single' | 'multi';
|
||||
+446
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
+57
@@ -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');
|
||||
});
|
||||
});
|
||||
+180
@@ -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: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
+143
@@ -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');
|
||||
});
|
||||
});
|
||||
+68
@@ -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;
|
||||
};
|
||||
+16
@@ -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';
|
||||
};
|
||||
+58
@@ -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,
|
||||
};
|
||||
};
|
||||
+52
@@ -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';
|
||||
|
||||
+38
@@ -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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user