diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnDnd.tsx b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnDnd.tsx index 1565c42a01..a14ffde11d 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnDnd.tsx +++ b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnDnd.tsx @@ -7,7 +7,7 @@ import { RecordBoardColumnDndKitProvider } from '@/object-record/record-board/re import { visibleRecordGroupIdsComponentFamilySelector } from '@/object-record/record-group/states/selectors/visibleRecordGroupIdsComponentFamilySelector'; import { RecordGroupContext } from '@/object-record/record-group/states/context/RecordGroupContext'; import { DragDropItemDropTarget } from '@/ui/utilities/drag-and-drop/components/DragDropItemDropTarget'; -import { DragDropItemDroppableSlot } from '@/ui/utilities/drag-and-drop/components/DragDropItemDroppableSlot'; +import { DragDropItemDropTargetSlot } from '@/ui/utilities/drag-and-drop/components/DragDropItemDropTargetSlot'; import { DragDropItemSortableCell } from '@/ui/utilities/drag-and-drop/components/DragDropItemSortableCell'; import { useAtomComponentFamilySelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentFamilySelectorValue'; import { ViewType } from '@/views/types/ViewType'; @@ -20,12 +20,9 @@ export const RecordBoardColumnDnd = () => { return ( - + - + {visibleRecordGroupIds.map((recordGroupId, index) => ( { /> - + - + ))} diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/dnd/hooks/useRecordBoardColumnDndKit.ts b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/dnd/hooks/useRecordBoardColumnDndKit.ts index 453ae06c4e..9a74ef0827 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/dnd/hooks/useRecordBoardColumnDndKit.ts +++ b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/dnd/hooks/useRecordBoardColumnDndKit.ts @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useRef, useState } from 'react'; import { isDefined } from 'twenty-shared/utils'; import { RECORD_GROUP_REORDER_CONFIRMATION_MODAL_ID } from '@/object-record/record-group/constants/RecordGroupReorderConfirmationModalId'; @@ -6,18 +6,15 @@ import { useReorderRecordGroups } from '@/object-record/record-group/hooks/useRe import { visibleRecordGroupIdsComponentFamilySelector } from '@/object-record/record-group/states/selectors/visibleRecordGroupIdsComponentFamilySelector'; import { RecordGroupSort } from '@/object-record/record-group/types/RecordGroupSort'; import { useRecordIndexIdFromCurrentContextStore } from '@/object-record/record-index/hooks/useRecordIndexIdFromCurrentContextStore'; -import { recordIndexKanbanColumnWidthComponentState } from '@/object-record/record-index/states/recordIndexKanbanColumnWidthComponentState'; import { recordIndexRecordGroupIsDraggableSortComponentSelector } from '@/object-record/record-index/states/selectors/recordIndexRecordGroupIsDraggableSortComponentSelector'; import { recordIndexRecordGroupSortComponentState } from '@/object-record/record-index/states/recordIndexRecordGroupSortComponentState'; import { useModal } from '@/ui/layout/modal/hooks/useModal'; import { type DragDropItemData } from '@/ui/utilities/drag-and-drop/types/DragDropItemData'; -import { resolveDragDropItemDrop } from '@/ui/utilities/drag-and-drop/utils/resolveDragDropItemDrop'; +import { resolveDropFromPointer } from '@/ui/utilities/drag-and-drop/utils/resolveDropFromPointer'; import { useDragSelect } from '@/ui/utilities/drag-select/hooks/useDragSelect'; -import { useScrollWrapperHTMLElement } from '@/ui/utilities/scroll/hooks/useScrollWrapperHTMLElement'; import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue'; import { useAtomComponentFamilySelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentFamilySelectorValue'; import { useAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentState'; -import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue'; import { ViewType } from '@/views/types/ViewType'; import { type DragDropProviderDragEndEvent } from '@/ui/utilities/drag-and-drop/types/DragDropProviderDragEndEvent'; import { type DragDropProviderDragMoveEvent } from '@/ui/utilities/drag-and-drop/types/DragDropProviderDragMoveEvent'; @@ -52,7 +49,6 @@ export const useRecordBoardColumnDndKit = (): { viewType: ViewType.KANBAN, }); const { setDragSelectionStartEnabled } = useDragSelect(); - const { getScrollWrapperElement } = useScrollWrapperHTMLElement(); const isRecordGroupDraggableSort = useAtomComponentSelectorValue( recordIndexRecordGroupIsDraggableSortComponentSelector, @@ -63,9 +59,6 @@ export const useRecordBoardColumnDndKit = (): { visibleRecordGroupIdsComponentFamilySelector, ViewType.KANBAN, ); - const recordIndexKanbanColumnWidth = useAtomComponentStateValue( - recordIndexKanbanColumnWidthComponentState, - ); const [, setRecordIndexRecordGroupSort] = useAtomComponentState( recordIndexRecordGroupSortComponentState, @@ -79,62 +72,50 @@ export const useRecordBoardColumnDndKit = (): { null, ); - const resolveDropFromPointerX = ({ - pointerX, - sourceIndex, - }: { - pointerX: number; - sourceIndex: number; - }) => { - const { scrollWrapperElement } = getScrollWrapperElement(); - if (!isDefined(scrollWrapperElement)) return null; + // The pointer can leave every sortable (column bodies, trailing empty + // space); the last resolved boundary is kept so the drop always lands where + // the insertion indicator was last shown. A ref because it is + // gesture-scoped bookkeeping read back inside drag callbacks. + // oxlint-disable-next-line twenty/no-state-useref + const lastDropTargetIndexRef = useRef(null); - const columnWidths = visibleRecordGroupIds.map( - () => recordIndexKanbanColumnWidth, - ); - - if (columnWidths.length === 0) { - return null; - } - - return resolveDragDropItemDrop({ - pointerX, - sourceIndex, - scrollWrapperElement, - columnWidths, - }); - }; + const lastIndex = visibleRecordGroupIds.length; const handleDragStart = (_event: DragStartPayload) => { + lastDropTargetIndexRef.current = null; setActiveDropTargetIndex(null); }; const handleDragMove = (event: DragMovePayload) => { - const { operation } = event; - const sourceIndex = operation.source?.data.index; + const { target, position } = event.operation; - if (!isDefined(sourceIndex)) { - setActiveDropTargetIndex(null); - return; + const resolvedDropTargetIndex = + resolveDropFromPointer({ + target, + pointer: position.current, + defaultOrientation: 'vertical', + getDroppableItemCount: () => lastIndex, + })?.dropTargetIndex ?? null; + + if (isDefined(resolvedDropTargetIndex)) { + lastDropTargetIndexRef.current = resolvedDropTargetIndex; } - const resolvedDrop = resolveDropFromPointerX({ - pointerX: operation.position.current.x, - sourceIndex, - }); + const dropTargetIndex = + resolvedDropTargetIndex ?? lastDropTargetIndexRef.current; - setActiveDropTargetIndex((currentActiveDropTargetIndex) => { - const nextActiveDropTargetIndex = resolvedDrop?.dropTargetIndex ?? null; - - return currentActiveDropTargetIndex === nextActiveDropTargetIndex + setActiveDropTargetIndex((currentActiveDropTargetIndex) => + currentActiveDropTargetIndex === dropTargetIndex ? currentActiveDropTargetIndex - : nextActiveDropTargetIndex; - }); + : dropTargetIndex, + ); }; const handleDragEnd = (event: DragEndPayload) => { - const { operation } = event; - const source = operation.source; + const { source, target, position } = event.operation; + + const lastDropTargetIndex = lastDropTargetIndexRef.current; + lastDropTargetIndexRef.current = null; setActiveDropTargetIndex(null); setDragSelectionStartEnabled(true); @@ -144,26 +125,34 @@ export const useRecordBoardColumnDndKit = (): { } const sourceIndex = source.data.index; - const resolvedDrop = resolveDropFromPointerX({ - pointerX: operation.position.current.x, - sourceIndex, - }); - if (!isDefined(resolvedDrop)) return; - if (resolvedDrop.sourceIndex === resolvedDrop.destinationIndex) return; + const dropTargetIndex = + resolveDropFromPointer({ + target, + pointer: position.current, + defaultOrientation: 'vertical', + getDroppableItemCount: () => lastIndex, + })?.dropTargetIndex ?? lastDropTargetIndex; + + if (!isDefined(dropTargetIndex)) { + return; + } + + const destinationIndex = + dropTargetIndex > sourceIndex ? dropTargetIndex - 1 : dropTargetIndex; if (!isRecordGroupDraggableSort) { setPendingReorder({ - fromIndex: resolvedDrop.sourceIndex, - toIndex: resolvedDrop.destinationIndex, + fromIndex: sourceIndex, + toIndex: destinationIndex, }); openModal(RECORD_GROUP_REORDER_CONFIRMATION_MODAL_ID); return; } reorderRecordGroups({ - fromIndex: resolvedDrop.sourceIndex, - toIndex: resolvedDrop.destinationIndex, + fromIndex: sourceIndex, + toIndex: destinationIndex, }); }; diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-dnd/hooks/useRecordBoardDndKit.ts b/packages/twenty-front/src/modules/object-record/record-board/record-board-dnd/hooks/useRecordBoardDndKit.ts index 9005cce55a..981d7c1005 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/record-board-dnd/hooks/useRecordBoardDndKit.ts +++ b/packages/twenty-front/src/modules/object-record/record-board/record-board-dnd/hooks/useRecordBoardDndKit.ts @@ -8,7 +8,7 @@ import { isRecordBoardDropProcessingComponentState } from '@/object-record/recor import { recordBoardSelectedRecordIdsComponentSelector } from '@/object-record/record-board/states/selectors/recordBoardSelectedRecordIdsComponentSelector'; import { getBoardCardDropBehavior } from '@/object-record/record-board/utils/getBoardCardDropBehavior'; import { getDestinationIndex } from '@/ui/utilities/drag-and-drop/utils/getDestinationIndex'; -import { resolveDropFromPointerY } from '@/ui/utilities/drag-and-drop/utils/resolveDropFromPointerY'; +import { resolveDropFromPointer } from '@/ui/utilities/drag-and-drop/utils/resolveDropFromPointer'; import { recordIndexRecordIdsByGroupComponentFamilyState } from '@/object-record/record-index/states/recordIndexRecordIdsByGroupComponentFamilyState'; import { useAtomComponentFamilyStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentFamilyStateCallbackState'; import { useEndRecordDrag } from '@/object-record/record-drag/hooks/useEndRecordDrag'; @@ -114,9 +114,10 @@ export const useRecordBoardDndKit = (): { const handleDragMove = (event: DragMovePayload) => { const { target, position } = event.operation; - const resolvedDrop = resolveDropFromPointerY({ + const resolvedDrop = resolveDropFromPointer({ target, - pointerY: position.current.y, + pointer: position.current, + defaultOrientation: 'horizontal', getDroppableItemCount, }); @@ -142,9 +143,10 @@ export const useRecordBoardDndKit = (): { const sourceDroppableId = (source.data as DragDropItemData).droppableId; const sourceIndex = (source.data as DragDropItemData).index; - const resolvedDrop = resolveDropFromPointerY({ + const resolvedDrop = resolveDropFromPointer({ target, - pointerY: position.current.y, + pointer: position.current, + defaultOrientation: 'horizontal', getDroppableItemCount, }); if (!isDefined(resolvedDrop)) { diff --git a/packages/twenty-front/src/modules/object-record/record-calendar/month/hooks/useRecordCalendarMonthDndKit.ts b/packages/twenty-front/src/modules/object-record/record-calendar/month/hooks/useRecordCalendarMonthDndKit.ts index 649957b596..7425c2eba8 100644 --- a/packages/twenty-front/src/modules/object-record/record-calendar/month/hooks/useRecordCalendarMonthDndKit.ts +++ b/packages/twenty-front/src/modules/object-record/record-calendar/month/hooks/useRecordCalendarMonthDndKit.ts @@ -11,7 +11,7 @@ import { useStartRecordDrag } from '@/object-record/record-drag/hooks/useStartRe import { useUserTimezone } from '@/ui/input/components/internal/date/hooks/useUserTimezone'; import { type DragDropItemData } from '@/ui/utilities/drag-and-drop/types/DragDropItemData'; import { getDestinationIndex } from '@/ui/utilities/drag-and-drop/utils/getDestinationIndex'; -import { resolveDropFromPointerY } from '@/ui/utilities/drag-and-drop/utils/resolveDropFromPointerY'; +import { resolveDropFromPointer } from '@/ui/utilities/drag-and-drop/utils/resolveDropFromPointer'; import { useAtomComponentFamilySelectorCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentFamilySelectorCallbackState'; import { type DragDropProviderDragEndEvent } from '@/ui/utilities/drag-and-drop/types/DragDropProviderDragEndEvent'; import { type DragDropProviderDragMoveEvent } from '@/ui/utilities/drag-and-drop/types/DragDropProviderDragMoveEvent'; @@ -86,9 +86,10 @@ export const useRecordCalendarMonthDndKit = (): { const handleDragMove = (event: DragMovePayload) => { const { target, position } = event.operation; - const resolvedDrop = resolveDropFromPointerY({ + const resolvedDrop = resolveDropFromPointer({ target, - pointerY: position.current.y, + pointer: position.current, + defaultOrientation: 'horizontal', getDroppableItemCount, }); @@ -116,9 +117,10 @@ export const useRecordCalendarMonthDndKit = (): { const sourceDroppableId = (source.data as DragDropItemData).droppableId; const sourceIndex = (source.data as DragDropItemData).index; - const resolvedDrop = resolveDropFromPointerY({ + const resolvedDrop = resolveDropFromPointer({ target, - pointerY: position.current.y, + pointer: position.current, + defaultOrientation: 'horizontal', getDroppableItemCount, }); if (!isDefined(resolvedDrop)) { diff --git a/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableNoRecordGroupRows.tsx b/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableNoRecordGroupRows.tsx index 95f3e9f59c..19bfc66b4e 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableNoRecordGroupRows.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableNoRecordGroupRows.tsx @@ -1,3 +1,10 @@ +import { pointerIntersection } from '@dnd-kit/collision'; +import { useDroppable } from '@dnd-kit/react'; +import { styled } from '@linaria/react'; +import { getContiguousIncrementalValues } from 'twenty-shared/utils'; + +import { isDraggingRecordComponentState } from '@/object-record/record-drag/states/isDraggingRecordComponentState'; +import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext'; import { RecordTableNoRecordGroupAddNew } from '@/object-record/record-table/components/RecordTableNoRecordGroupAddNew'; import { RECORD_TABLE_NO_RECORD_GROUP_DROPPABLE_ID } from '@/object-record/record-table/constants/RecordTableNoRecordGroupDroppableId'; import { RECORD_TABLE_ROW_DND_TYPE } from '@/object-record/record-table/constants/RecordTableRowDndType'; @@ -6,10 +13,9 @@ import { RecordTableVirtualizedBodyPlaceholder } from '@/object-record/record-ta import { RecordTableVirtualizedDebugHelper } from '@/object-record/record-table/virtualization/components/RecordTableVirtualizedDebugHelper'; import { NUMBER_OF_VIRTUALIZED_ROWS } from '@/object-record/record-table/virtualization/constants/NumberOfVirtualizedRows'; import { totalNumberOfRecordsToVirtualizeComponentState } from '@/object-record/record-table/virtualization/states/totalNumberOfRecordsToVirtualizeComponentState'; -import { DragDropItemEndDropZone } from '@/ui/utilities/drag-and-drop/components/DragDropItemEndDropZone'; +import { DragDropItemDropTarget } from '@/ui/utilities/drag-and-drop/components/DragDropItemDropTarget'; +import { DND_KIT_COLLISION_PRIORITY } from '@/ui/utilities/drag-and-drop/constants/DndKitCollisionPriority'; import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue'; -import { styled } from '@linaria/react'; -import { getContiguousIncrementalValues } from 'twenty-shared/utils'; const StyledNoRecordGroupContainer = styled.div` display: flex; @@ -17,12 +23,24 @@ const StyledNoRecordGroupContainer = styled.div` width: 100%; `; +const StyledEndDropZone = styled.div` + position: relative; + width: 100%; +`; + export const RecordTableNoRecordGroupRows = () => { + const { recordIndexId } = useRecordIndexContextOrThrow(); + const totalNumberOfRecordsToVirtualize = useAtomComponentStateValue( totalNumberOfRecordsToVirtualizeComponentState, ) ?? 0; + const isDraggingRecord = useAtomComponentStateValue( + isDraggingRecordComponentState, + recordIndexId, + ); + const numberOfRows = Math.min( totalNumberOfRecordsToVirtualize, NUMBER_OF_VIRTUALIZED_ROWS, @@ -30,6 +48,16 @@ export const RecordTableNoRecordGroupRows = () => { const virtualRowIndices = getContiguousIncrementalValues(numberOfRows); + // Catches drops past the last row, where no row sortable is under the + // pointer; the drop target inside only renders the insertion indicator. + const { ref: endDropZoneRef } = useDroppable({ + id: RECORD_TABLE_NO_RECORD_GROUP_DROPPABLE_ID, + accept: RECORD_TABLE_ROW_DND_TYPE, + collisionPriority: DND_KIT_COLLISION_PRIORITY, + collisionDetector: pointerIntersection, + data: { droppableId: RECORD_TABLE_NO_RECORD_GROUP_DROPPABLE_ID }, + }); + return ( @@ -41,16 +69,18 @@ export const RecordTableNoRecordGroupRows = () => { /> ); })} - + + {/* Zero footprint at rest; expands during a row drag so the zone + stays droppable even when the add-new row is hidden. */} + - + ); diff --git a/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableRecordGroupBodyContextProvider.tsx b/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableRecordGroupBodyContextProvider.tsx index 57ceb05163..13584ad75a 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableRecordGroupBodyContextProvider.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableRecordGroupBodyContextProvider.tsx @@ -13,7 +13,6 @@ import { type TableCellPosition } from '@/object-record/record-table/types/Table import { type ReactNode } from 'react'; type RecordTableRecordGroupBodyContextProviderProps = { - recordGroupId: string; children?: ReactNode; }; diff --git a/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableRecordGroupRows.tsx b/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableRecordGroupRows.tsx index 49106982ce..82cd02d134 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableRecordGroupRows.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableRecordGroupRows.tsx @@ -1,5 +1,10 @@ +import { pointerIntersection } from '@dnd-kit/collision'; +import { useDroppable } from '@dnd-kit/react'; + +import { isDraggingRecordComponentState } from '@/object-record/record-drag/states/isDraggingRecordComponentState'; import { useCurrentRecordGroupId } from '@/object-record/record-group/hooks/useCurrentRecordGroupId'; import { useShouldHideRecordGroup } from '@/object-record/record-group/hooks/useShouldHideRecordGroup'; +import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext'; import { recordIndexRecordIdsByGroupComponentFamilyState } from '@/object-record/record-index/states/recordIndexRecordIdsByGroupComponentFamilyState'; import { recordIndexAllRecordIdsComponentSelector } from '@/object-record/record-index/states/selectors/recordIndexAllRecordIdsComponentSelector'; import { RECORD_TABLE_ROW_DND_TYPE } from '@/object-record/record-table/constants/RecordTableRowDndType'; @@ -8,22 +13,32 @@ import { RecordTableRow } from '@/object-record/record-table/record-table-row/co import { RecordTableRecordGroupSectionAddNew } from '@/object-record/record-table/record-table-section/components/RecordTableRecordGroupSectionAddNew'; import { RecordTableRecordGroupSectionLoadMore } from '@/object-record/record-table/record-table-section/components/RecordTableRecordGroupSectionLoadMore'; import { isRecordGroupTableSectionToggledComponentState } from '@/object-record/record-table/record-table-section/states/isRecordGroupTableSectionToggledComponentState'; -import { DragDropItemEndDropZone } from '@/ui/utilities/drag-and-drop/components/DragDropItemEndDropZone'; +import { DragDropItemDropTarget } from '@/ui/utilities/drag-and-drop/components/DragDropItemDropTarget'; +import { DND_KIT_COLLISION_PRIORITY } from '@/ui/utilities/drag-and-drop/constants/DndKitCollisionPriority'; import { useAtomComponentFamilyStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentFamilyStateValue'; import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue'; +import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue'; import { styled } from '@linaria/react'; import { useMemo } from 'react'; import { isDefined } from 'twenty-shared/utils'; -const StyledRecordGroupEndDropZone = styled(DragDropItemEndDropZone)` +const StyledRecordGroupDropTarget = styled.div` + position: relative; width: 100%; `; export const RecordTableRecordGroupRows = () => { + const { recordIndexId } = useRecordIndexContextOrThrow(); + const currentRecordGroupId = useCurrentRecordGroupId(); const shouldHide = useShouldHideRecordGroup(currentRecordGroupId); + const isDraggingRecord = useAtomComponentStateValue( + isDraggingRecordComponentState, + recordIndexId, + ); + const allRecordIds = useAtomComponentSelectorValue( recordIndexAllRecordIdsComponentSelector, ); @@ -43,6 +58,14 @@ export const RecordTableRecordGroupRows = () => { [allRecordIds], ); + const { ref: endDropZoneRef } = useDroppable({ + id: currentRecordGroupId, + accept: RECORD_TABLE_ROW_DND_TYPE, + collisionPriority: DND_KIT_COLLISION_PRIORITY, + collisionDetector: pointerIntersection, + data: { droppableId: currentRecordGroupId }, + }); + if (shouldHide) { return null; } @@ -69,17 +92,19 @@ export const RecordTableRecordGroupRows = () => { /> ); })} - + + {/* Zero footprint at rest; expands during a row drag so the zone + stays droppable even when neither action row below renders. */} + - + ) => { - const source = event.operation.source; - const sourceData = source?.data as RecordTableRowDragData | undefined; - - if (!isDefined(source) || !isDefined(sourceData)) { - return; - } - - const currentSelectedRecordIds = store.get(selectedRowIds) as string[]; - - startRecordDrag(sourceData.recordId, currentSelectedRecordIds); - }, - [selectedRowIds, startRecordDrag, store], + const [activeDropTargetIndex, setActiveDropTargetIndex] = useState< + number | null + >(null); + const [activeDroppableId, setActiveDroppableId] = useState( + null, ); - const handleDragEnd = useCallback( - (event: DragDropProviderDragEndEvent) => { - const source = event.operation.source; - const sourceData = source?.data as RecordTableRowDragData | undefined; - const targetData = event.operation.target?.data as - | DragDropItemData - | undefined; + const clearDragState = () => { + endRecordDrag(); + setActiveDropTargetIndex(null); + setActiveDroppableId(null); + }; - if ( - event.canceled || - !isDefined(source) || - !isDefined(sourceData) || - !isDefined(targetData) - ) { - endRecordDrag(); - return; - } + const handleDragStart = ( + event: DragDropProviderDragStartEvent, + ) => { + const source = event.operation.source; + const sourceData = source?.data as RecordTableRowDragData | undefined; - // Row targets and end drop zones mark the gap before them; convert that - // gap into the index the dragged row will occupy after the move. - const destinationIndex = getDestinationIndex({ - dropTargetIndex: targetData.index, - sourceIndex: sourceData.index, - sourceDroppableId: sourceData.droppableId, - destinationDroppableId: targetData.droppableId, + if (!isDefined(source) || !isDefined(sourceData)) { + return; + } + + const currentSelectedRecordIds = store.get(selectedRowIds) as string[]; + + startRecordDrag(sourceData.recordId, currentSelectedRecordIds); + }; + + const handleDragMove = ( + event: DragDropProviderDragMoveEvent, + ) => { + const { target, position } = event.operation; + + const resolvedDrop = resolveDropFromPointer({ + target, + pointer: position.current, + defaultOrientation: 'horizontal', + getDroppableItemCount: () => + store.get(totalNumberOfRecordsToVirtualize) ?? 0, + }); + + setActiveDropTargetIndex(resolvedDrop?.dropTargetIndex ?? null); + setActiveDroppableId(resolvedDrop?.droppableId ?? null); + }; + + const handleDragEnd = ( + event: DragDropProviderDragEndEvent, + ) => { + const { source, target, position } = event.operation; + const sourceData = source?.data as RecordTableRowDragData | undefined; + + if (event.canceled || !isDefined(source) || !isDefined(sourceData)) { + clearDragState(); + return; + } + + const resolvedDrop = resolveDropFromPointer({ + target, + pointer: position.current, + defaultOrientation: 'horizontal', + getDroppableItemCount: () => + store.get(totalNumberOfRecordsToVirtualize) ?? 0, + }); + + if (!isDefined(resolvedDrop)) { + clearDragState(); + return; + } + + // Row targets and end drop zones mark the gap before them; convert that + // gap into the index the dragged row will occupy after the move. + const destinationIndex = getDestinationIndex({ + dropTargetIndex: resolvedDrop.dropTargetIndex, + sourceIndex: sourceData.index, + sourceDroppableId: sourceData.droppableId, + destinationDroppableId: resolvedDrop.droppableId, + }); + + if (destinationIndex === sourceData.index) { + clearDragState(); + return; + } + + try { + processTableWithoutGroupRecordDrop({ + draggableId: sourceData.recordId, + source: { + droppableId: sourceData.droppableId, + index: sourceData.index, + }, + destination: { + droppableId: resolvedDrop.droppableId, + index: destinationIndex, + }, }); - - if (destinationIndex === sourceData.index) { - endRecordDrag(); - return; - } - - try { - processTableWithoutGroupRecordDrop({ - draggableId: sourceData.recordId, - source: { - droppableId: sourceData.droppableId, - index: sourceData.index, - }, - destination: { - droppableId: targetData.droppableId, - index: destinationIndex, - }, - }); - } finally { - endRecordDrag(); - } - }, - [endRecordDrag, processTableWithoutGroupRecordDrop], - ); + } finally { + clearDragState(); + } + }; return ( - - sensors={DND_KIT_SENSORS} - plugins={DND_KIT_PROVIDER_PLUGINS_WITHOUT_DROP_ANIMATION} - onDragStart={handleDragStart} - onDragEnd={handleDragEnd} + - {children} - - {(source) => } - - + + sensors={DND_KIT_SENSORS} + plugins={DND_KIT_PROVIDER_PLUGINS_WITHOUT_DROP_ANIMATION} + onDragStart={handleDragStart} + onDragMove={handleDragMove} + onDragEnd={handleDragEnd} + > + {children} + + {(source) => } + + + ); }; diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableBodyRecordGroupDragDropContextProvider.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableBodyRecordGroupDragDropContextProvider.tsx index 1f7aa44582..9e48189f75 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableBodyRecordGroupDragDropContextProvider.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableBodyRecordGroupDragDropContextProvider.tsx @@ -1,22 +1,28 @@ import { DragDropProvider, DragOverlay } from '@dnd-kit/react'; import { useStore } from 'jotai'; -import { type ReactNode, useCallback } from 'react'; +import { type ReactNode, useState } from 'react'; import { isDefined } from 'twenty-shared/utils'; import { useEndRecordDrag } from '@/object-record/record-drag/hooks/useEndRecordDrag'; import { useProcessTableWithGroupRecordDrop } from '@/object-record/record-drag/hooks/useProcessTableWithGroupRecordDrop'; import { useStartRecordDrag } from '@/object-record/record-drag/hooks/useStartRecordDrag'; +import { recordIndexRecordIdsByGroupComponentFamilyState } from '@/object-record/record-index/states/recordIndexRecordIdsByGroupComponentFamilyState'; import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext'; +import { RecordTableRecordGroupBodyContextProvider } from '@/object-record/record-table/components/RecordTableRecordGroupBodyContextProvider'; import { useRecordTableContextOrThrow } from '@/object-record/record-table/contexts/RecordTableContext'; import { RecordTableRowDragOverlayContent } from '@/object-record/record-table/record-table-row/components/RecordTableRowDragOverlayContent'; import { selectedRowIdsComponentSelector } from '@/object-record/record-table/states/selectors/selectedRowIdsComponentSelector'; import { type RecordTableRowDragData } from '@/object-record/record-table/types/RecordTableRowDragData'; import { DND_KIT_PROVIDER_PLUGINS_WITHOUT_DROP_ANIMATION } from '@/ui/utilities/drag-and-drop/constants/DndKitProviderPluginsWithoutDropAnimation'; import { DND_KIT_SENSORS } from '@/ui/utilities/drag-and-drop/constants/DndKitSensors'; +import { DragDropItemDndContext } from '@/ui/utilities/drag-and-drop/context/DragDropItemDndContext'; import { type DragDropItemData } from '@/ui/utilities/drag-and-drop/types/DragDropItemData'; import { type DragDropProviderDragEndEvent } from '@/ui/utilities/drag-and-drop/types/DragDropProviderDragEndEvent'; +import { type DragDropProviderDragMoveEvent } from '@/ui/utilities/drag-and-drop/types/DragDropProviderDragMoveEvent'; import { type DragDropProviderDragStartEvent } from '@/ui/utilities/drag-and-drop/types/DragDropProviderDragStartEvent'; import { getDestinationIndex } from '@/ui/utilities/drag-and-drop/utils/getDestinationIndex'; +import { resolveDropFromPointer } from '@/ui/utilities/drag-and-drop/utils/resolveDropFromPointer'; +import { useAtomComponentFamilyStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentFamilyStateCallbackState'; import { useAtomComponentSelectorCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorCallbackState'; export const RecordTableBodyRecordGroupDragDropContextProvider = ({ @@ -32,6 +38,11 @@ export const RecordTableBodyRecordGroupDragDropContextProvider = ({ recordTableId, ); + const recordIdsByGroupCallbackState = + useAtomComponentFamilyStateCallbackState( + recordIndexRecordIdsByGroupComponentFamilyState, + ); + const store = useStore(); const { startRecordDrag } = useStartRecordDrag(recordIndexId); @@ -40,87 +51,129 @@ export const RecordTableBodyRecordGroupDragDropContextProvider = ({ const { processTableWithGroupRecordDrop } = useProcessTableWithGroupRecordDrop(); - const handleDragStart = useCallback( - (event: DragDropProviderDragStartEvent) => { - const source = event.operation.source; - const sourceData = source?.data as RecordTableRowDragData | undefined; - - if (!isDefined(source) || !isDefined(sourceData)) { - return; - } - - const currentSelectedRecordIds = store.get(selectedRowIds) as string[]; - - startRecordDrag(sourceData.recordId, currentSelectedRecordIds); - }, - [selectedRowIds, startRecordDrag, store], + const [activeDropTargetIndex, setActiveDropTargetIndex] = useState< + number | null + >(null); + const [activeDroppableId, setActiveDroppableId] = useState( + null, ); - const handleDragEnd = useCallback( - (event: DragDropProviderDragEndEvent) => { - const source = event.operation.source; - const sourceData = source?.data as RecordTableRowDragData | undefined; - const targetData = event.operation.target?.data as - | DragDropItemData - | undefined; + const clearDragState = () => { + endRecordDrag(); + setActiveDropTargetIndex(null); + setActiveDroppableId(null); + }; - if ( - event.canceled || - !isDefined(source) || - !isDefined(sourceData) || - !isDefined(targetData) - ) { - endRecordDrag(); - return; - } + const handleDragStart = ( + event: DragDropProviderDragStartEvent, + ) => { + const source = event.operation.source; + const sourceData = source?.data as RecordTableRowDragData | undefined; - // Row targets and end drop zones mark the gap before them; convert that - // gap into the index the dragged row will occupy after the move. - const destinationIndex = getDestinationIndex({ - dropTargetIndex: targetData.index, - sourceIndex: sourceData.index, - sourceDroppableId: sourceData.droppableId, - destinationDroppableId: targetData.droppableId, + if (!isDefined(source) || !isDefined(sourceData)) { + return; + } + + const currentSelectedRecordIds = store.get(selectedRowIds) as string[]; + + startRecordDrag(sourceData.recordId, currentSelectedRecordIds); + }; + + const handleDragMove = ( + event: DragDropProviderDragMoveEvent, + ) => { + const { target, position } = event.operation; + + const resolvedDrop = resolveDropFromPointer({ + target, + pointer: position.current, + defaultOrientation: 'horizontal', + getDroppableItemCount: (droppableId) => + store.get(recordIdsByGroupCallbackState(droppableId)).length, + }); + + setActiveDropTargetIndex(resolvedDrop?.dropTargetIndex ?? null); + setActiveDroppableId(resolvedDrop?.droppableId ?? null); + }; + + const handleDragEnd = ( + event: DragDropProviderDragEndEvent, + ) => { + const { source, target, position } = event.operation; + const sourceData = source?.data as RecordTableRowDragData | undefined; + + if (event.canceled || !isDefined(source) || !isDefined(sourceData)) { + clearDragState(); + return; + } + + const resolvedDrop = resolveDropFromPointer({ + target, + pointer: position.current, + defaultOrientation: 'horizontal', + getDroppableItemCount: (droppableId) => + store.get(recordIdsByGroupCallbackState(droppableId)).length, + }); + + if (!isDefined(resolvedDrop)) { + clearDragState(); + return; + } + + // Row targets and end drop zones mark the gap before them; convert that + // gap into the index the dragged row will occupy after the move. + const destinationIndex = getDestinationIndex({ + dropTargetIndex: resolvedDrop.dropTargetIndex, + sourceIndex: sourceData.index, + sourceDroppableId: sourceData.droppableId, + destinationDroppableId: resolvedDrop.droppableId, + }); + + const isSameRecordGroup = + sourceData.droppableId === resolvedDrop.droppableId; + + if (isSameRecordGroup && destinationIndex === sourceData.index) { + clearDragState(); + return; + } + + try { + processTableWithGroupRecordDrop({ + draggableId: sourceData.recordId, + source: { + droppableId: sourceData.droppableId, + index: sourceData.index, + }, + destination: { + droppableId: resolvedDrop.droppableId, + index: destinationIndex, + }, }); - - const isSameRecordGroup = - sourceData.droppableId === targetData.droppableId; - - if (isSameRecordGroup && destinationIndex === sourceData.index) { - endRecordDrag(); - return; - } - - try { - processTableWithGroupRecordDrop({ - draggableId: sourceData.recordId, - source: { - droppableId: sourceData.droppableId, - index: sourceData.index, - }, - destination: { - droppableId: targetData.droppableId, - index: destinationIndex, - }, - }); - } finally { - endRecordDrag(); - } - }, - [endRecordDrag, processTableWithGroupRecordDrop], - ); + } finally { + clearDragState(); + } + }; return ( - - sensors={DND_KIT_SENSORS} - plugins={DND_KIT_PROVIDER_PLUGINS_WITHOUT_DROP_ANIMATION} - onDragStart={handleDragStart} - onDragEnd={handleDragEnd} + - {children} - - {(source) => } - - + + sensors={DND_KIT_SENSORS} + plugins={DND_KIT_PROVIDER_PLUGINS_WITHOUT_DROP_ANIMATION} + onDragStart={handleDragStart} + onDragMove={handleDragMove} + onDragEnd={handleDragEnd} + > + {children} + + {(source) => ( + + + + )} + + + ); }; diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableRecordGroupsBody.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableRecordGroupsBody.tsx index 4247ab3228..c4d4abcb3d 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableRecordGroupsBody.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableRecordGroupsBody.tsx @@ -38,10 +38,7 @@ export const RecordTableRecordGroupsBody = () => { <> {visibleRecordGroupIds.map((recordGroupId, index) => ( - + diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-header/components/RecordTableHeaderDnd.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-header/components/RecordTableHeaderDnd.tsx index 9039927434..12afab399f 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-header/components/RecordTableHeaderDnd.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-header/components/RecordTableHeaderDnd.tsx @@ -9,8 +9,8 @@ import { RecordTableHeaderLastEmptyColumn } from '@/object-record/record-table/r import { RECORD_TABLE_HEADER_DROPPABLE_ID } from '@/object-record/record-table/record-table-header/dnd/constants/RecordTableHeaderDroppableId'; import { RecordTableHeaderDndKitProvider } from '@/object-record/record-table/record-table-header/dnd/providers/RecordTableHeaderDndKitProvider'; import { isRecordTableColumnHeadersReadOnlyComponentState } from '@/object-record/record-table/states/isRecordTableColumnHeadersReadOnlyComponentState'; -import { DragDropItemDroppableSlot } from '@/ui/utilities/drag-and-drop/components/DragDropItemDroppableSlot'; import { DragDropItemDropTarget } from '@/ui/utilities/drag-and-drop/components/DragDropItemDropTarget'; +import { DragDropItemDropTargetSlot } from '@/ui/utilities/drag-and-drop/components/DragDropItemDropTargetSlot'; import { DragDropItemSortableCell } from '@/ui/utilities/drag-and-drop/components/DragDropItemSortableCell'; import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue'; import { isDefined } from 'twenty-shared/utils'; @@ -28,13 +28,9 @@ export const RecordTableHeaderDnd = () => { return ( - + - + {isDefined(firstScrollableRecordField) && ( { {recordFieldsWithoutFirstTwo.map((recordField, index) => ( - + - + { ))} - + - + {isRecordTableColumnHeadersReadOnly ? ( ) : ( diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-header/dnd/hooks/useRecordTableHeaderDndKit.ts b/packages/twenty-front/src/modules/object-record/record-table/record-table-header/dnd/hooks/useRecordTableHeaderDndKit.ts index 7e76f7a1a2..0179e811c2 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-header/dnd/hooks/useRecordTableHeaderDndKit.ts +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-header/dnd/hooks/useRecordTableHeaderDndKit.ts @@ -1,20 +1,13 @@ -import { useState } from 'react'; -import { filterOutByProperty, isDefined } from 'twenty-shared/utils'; +import { useRef, useState } from 'react'; +import { isDefined } from 'twenty-shared/utils'; -import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext'; -import { RECORD_TABLE_COLUMN_CHECKBOX_WIDTH } from '@/object-record/record-table/constants/RecordTableColumnCheckboxWidth'; -import { RECORD_TABLE_COLUMN_DRAG_AND_DROP_WIDTH } from '@/object-record/record-table/constants/RecordTableColumnDragAndDropWidth'; import { useReorderVisibleRecordFields } from '@/object-record/record-field/hooks/useReorderVisibleRecordFields'; import { useSaveCurrentViewFields } from '@/views/hooks/useSaveCurrentViewFields'; import { mapRecordFieldToViewField } from '@/views/utils/mapRecordFieldToViewField'; import { useDragSelect } from '@/ui/utilities/drag-select/hooks/useDragSelect'; import { useRecordTableContextOrThrow } from '@/object-record/record-table/contexts/RecordTableContext'; -import { useScrollWrapperHTMLElement } from '@/ui/utilities/scroll/hooks/useScrollWrapperHTMLElement'; -import { isRecordTableCheckboxColumnHiddenComponentState } from '@/object-record/record-table/states/isRecordTableCheckboxColumnHiddenComponentState'; -import { isRecordTableDragColumnHiddenComponentState } from '@/object-record/record-table/states/isRecordTableDragColumnHiddenComponentState'; import { type DragDropItemData } from '@/ui/utilities/drag-and-drop/types/DragDropItemData'; -import { resolveDragDropItemDrop } from '@/ui/utilities/drag-and-drop/utils/resolveDragDropItemDrop'; -import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue'; +import { resolveDropFromPointer } from '@/ui/utilities/drag-and-drop/utils/resolveDropFromPointer'; import { type DragDropProviderDragEndEvent } from '@/ui/utilities/drag-and-drop/types/DragDropProviderDragEndEvent'; import { type DragDropProviderDragMoveEvent } from '@/ui/utilities/drag-and-drop/types/DragDropProviderDragMoveEvent'; import { type DragDropProviderDragStartEvent } from '@/ui/utilities/drag-and-drop/types/DragDropProviderDragStartEvent'; @@ -36,117 +29,89 @@ export const useRecordTableHeaderDndKit = (): { }; } => { const { recordTableId, visibleRecordFields } = useRecordTableContextOrThrow(); - const { labelIdentifierFieldMetadataItem } = useRecordIndexContextOrThrow(); const { reorderVisibleRecordFields } = useReorderVisibleRecordFields(recordTableId); const { saveViewFields } = useSaveCurrentViewFields(); const { setDragSelectionStartEnabled } = useDragSelect(); - const { getScrollWrapperElement } = useScrollWrapperHTMLElement(); - - const isRecordTableDragColumnHidden = useAtomComponentStateValue( - isRecordTableDragColumnHiddenComponentState, - ); - const isRecordTableCheckboxColumnHidden = useAtomComponentStateValue( - isRecordTableCheckboxColumnHiddenComponentState, - ); const [activeDropTargetIndex, setActiveDropTargetIndex] = useState< number | null >(null); - const recordFieldsWithoutLabelIdentifier = visibleRecordFields.filter( - filterOutByProperty( - 'fieldMetadataItemId', - labelIdentifierFieldMetadataItem?.id, - ), - ); + // The pointer can leave every sortable (sticky pinned column, table body, + // trailing empty space); the last resolved boundary is kept so the drop + // always lands where the insertion indicator was last shown. A ref because + // it is gesture-scoped bookkeeping read back inside drag callbacks. + // oxlint-disable-next-line twenty/no-state-useref + const lastDropTargetIndexRef = useRef(null); - const labelIdentifierRecordField = visibleRecordFields.find( - (recordField) => - recordField.fieldMetadataItemId === labelIdentifierFieldMetadataItem?.id, - ); - - const nonSortableColumnsWidth = - (isRecordTableDragColumnHidden - ? 0 - : RECORD_TABLE_COLUMN_DRAG_AND_DROP_WIDTH) + - (isRecordTableCheckboxColumnHidden - ? 0 - : RECORD_TABLE_COLUMN_CHECKBOX_WIDTH) + - (labelIdentifierRecordField?.size ?? 0); - - const resolveDropFromPointerX = ({ - pointerX, - sourceIndex, - }: { - pointerX: number; - sourceIndex: number; - }) => { - const { scrollWrapperElement } = getScrollWrapperElement(); - if (!isDefined(scrollWrapperElement)) return null; - - return resolveDragDropItemDrop({ - pointerX, - sourceIndex, - scrollWrapperElement, - columnWidths: recordFieldsWithoutLabelIdentifier.map( - (recordField) => recordField.size, - ), - leadingOffset: nonSortableColumnsWidth, - }); - }; + const lastIndex = visibleRecordFields.length - 1; const handleDragStart = (_event: DragStartPayload) => { + lastDropTargetIndexRef.current = null; setActiveDropTargetIndex(null); }; const handleDragMove = (event: DragMovePayload) => { - const { operation } = event; - const sourceIndex = operation.source?.data.index; + const { target, position } = event.operation; - if (!isDefined(sourceIndex)) { - setActiveDropTargetIndex(null); - return; + const resolvedDropTargetIndex = + resolveDropFromPointer({ + target, + pointer: position.current, + defaultOrientation: 'vertical', + getDroppableItemCount: () => lastIndex, + })?.dropTargetIndex ?? null; + + if (isDefined(resolvedDropTargetIndex)) { + lastDropTargetIndexRef.current = resolvedDropTargetIndex; } - const resolvedDrop = resolveDropFromPointerX({ - pointerX: operation.position.current.x, - sourceIndex, - }); + const dropTargetIndex = + resolvedDropTargetIndex ?? lastDropTargetIndexRef.current; - setActiveDropTargetIndex((currentActiveDropTargetIndex) => { - const nextActiveDropTargetIndex = resolvedDrop?.dropTargetIndex ?? null; - - return currentActiveDropTargetIndex === nextActiveDropTargetIndex + setActiveDropTargetIndex((currentActiveDropTargetIndex) => + currentActiveDropTargetIndex === dropTargetIndex ? currentActiveDropTargetIndex - : nextActiveDropTargetIndex; - }); + : dropTargetIndex, + ); }; const handleDragEnd = (event: DragEndPayload) => { - const { operation } = event; - const source = operation.source; + const { source, target, position } = event.operation; + + const lastDropTargetIndex = lastDropTargetIndexRef.current; + lastDropTargetIndexRef.current = null; setActiveDropTargetIndex(null); setDragSelectionStartEnabled(true); - if (event.canceled) return; - if (!isDefined(source)) return; + if (event.canceled || !isDefined(source)) { + return; + } const sourceIndex = source.data.index; - const resolvedDrop = resolveDropFromPointerX({ - pointerX: operation.position.current.x, - sourceIndex, - }); - if (!isDefined(resolvedDrop)) return; - if (resolvedDrop.sourceIndex === resolvedDrop.destinationIndex) return; + const dropTargetIndex = + resolveDropFromPointer({ + target, + pointer: position.current, + defaultOrientation: 'vertical', + getDroppableItemCount: () => lastIndex, + })?.dropTargetIndex ?? lastDropTargetIndex; + + if (!isDefined(dropTargetIndex)) { + return; + } + + const destinationIndex = + dropTargetIndex <= sourceIndex ? dropTargetIndex + 1 : dropTargetIndex; // Sortable indices exclude the pinned label-identifier column at visibleRecordFields[0], // so shift by one to address the full visible field list. const updatedRecordField = reorderVisibleRecordFields({ - fromIndex: resolvedDrop.sourceIndex + 1, - toIndex: resolvedDrop.destinationIndex + 1, + fromIndex: sourceIndex + 1, + toIndex: destinationIndex, }); saveViewFields([mapRecordFieldToViewField(updatedRecordField)]); diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-row/components/RecordTableDraggableTr.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-row/components/RecordTableDraggableTr.tsx index 1010b311d6..3f96bf5a06 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-row/components/RecordTableDraggableTr.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-row/components/RecordTableDraggableTr.tsx @@ -9,19 +9,23 @@ import { RECORD_TABLE_NO_RECORD_GROUP_DROPPABLE_ID } from '@/object-record/recor import { RECORD_TABLE_ROW_DND_TYPE } from '@/object-record/record-table/constants/RecordTableRowDndType'; import { TABLE_Z_INDEX } from '@/object-record/record-table/constants/TableZIndex'; import { RecordTableRowDraggableContextProvider } from '@/object-record/record-table/contexts/RecordTableRowDraggableContext'; -import { RecordTableRowMultiDragPreview } from '@/object-record/record-table/record-table-row/components/RecordTableRowMultiDragPreview'; import { RecordTableTr } from '@/object-record/record-table/record-table-row/components/RecordTableTr'; import { useIsTableRowSecondaryDragged } from '@/object-record/record-table/record-table-row/hooks/useIsRecordSecondaryDragged'; import { type RecordTableRowDragData } from '@/object-record/record-table/types/RecordTableRowDragData'; -import { DragDropItemDropLine } from '@/ui/utilities/drag-and-drop/components/DragDropItemDropLine'; +import { DragDropItemDropTarget } from '@/ui/utilities/drag-and-drop/components/DragDropItemDropTarget'; import { DND_KIT_PLUGINS_WITHOUT_OPTIMISTIC } from '@/ui/utilities/drag-and-drop/constants/DndKitPluginsWithoutOptimistic'; import { DRAG_SOURCE_OPACITY } from '@/ui/utilities/drag-and-drop/constants/DragSourceOpacity'; import { DragDropItemSortableHandleRefContext } from '@/ui/utilities/drag-and-drop/context/DragDropItemSortableHandleRefContext'; -// The grip, checkbox and first field cells are sticky at -// TABLE_Z_INDEX.cell.sticky; without a higher z-index they would paint over -// the insertion line and truncate it to the scrollable columns. -const StyledRowDropLine = styled(DragDropItemDropLine)` +// Overlays the row's leading edge without reflowing it. The grip, checkbox and +// first field cells are sticky at TABLE_Z_INDEX.cell.sticky; without a higher +// z-index they would paint over the insertion line and truncate it to the +// scrollable columns. +const StyledRowDropTargetSlot = styled.div` + left: 0; + position: absolute; + right: 0; + top: -1px; z-index: ${TABLE_Z_INDEX.rowDropLine}; `; @@ -66,7 +70,7 @@ export const RecordTableDraggableTr = ({ // per-instance id avoids the collision; recordId travels in the drag data. const [sortableId] = useState(() => v4()); - const { handleRef, ref, isDragSource, isDropTarget } = useSortable({ + const { handleRef, ref, isDragSource } = useSortable({ id: sortableId, index: draggableIndex, group: droppableId, @@ -97,10 +101,19 @@ export const RecordTableDraggableTr = ({ {children} - - {isDropTarget && !isDragSource && } + {!isDragSource && ( + + + + )} ); }; diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-row/components/RecordTableRowDragOverlayContent.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-row/components/RecordTableRowDragOverlayContent.tsx index 3ba9df24f0..973c69bec1 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-row/components/RecordTableRowDragOverlayContent.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-row/components/RecordTableRowDragOverlayContent.tsx @@ -25,7 +25,7 @@ import { RecordTableCellDragAndDrop } from '@/object-record/record-table/record- import { RecordTableLastEmptyCell } from '@/object-record/record-table/record-table-cell/components/RecordTableLastEmptyCell'; import { RecordTablePlusButtonCellPlaceholder } from '@/object-record/record-table/record-table-cell/components/RecordTablePlusButtonCellPlaceholder'; import { RecordTableFieldsCells } from '@/object-record/record-table/record-table-row/components/RecordTableFieldsCells'; -import { RecordTableRowMultiDragCounterChip } from '@/object-record/record-table/record-table-row/components/RecordTableRowMultiDragCounterChip'; +import { RecordTableRowMultiDragPreview } from '@/object-record/record-table/record-table-row/components/RecordTableRowMultiDragPreview'; import { RecordTableTr } from '@/object-record/record-table/record-table-row/components/RecordTableTr'; import { type RecordTableRowDragData } from '@/object-record/record-table/types/RecordTableRowDragData'; import { getRecordTableColumnFieldWidthClassName } from '@/object-record/record-table/utils/getRecordTableColumnFieldWidthClassName'; @@ -194,7 +194,7 @@ export const RecordTableRowDragOverlayContent = ({ - + ); }; diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-row/components/RecordTableRowMultiDragCounterChip.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-row/components/RecordTableRowMultiDragCounterChip.tsx index 655ede214d..e0ea8edc34 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-row/components/RecordTableRowMultiDragCounterChip.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-row/components/RecordTableRowMultiDragCounterChip.tsx @@ -1,4 +1,5 @@ import { originalDragSelectionComponentState } from '@/object-record/record-drag/states/originalDragSelectionComponentState'; +import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext'; import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue'; import { styled } from '@linaria/react'; import { NotificationCounter } from 'twenty-ui/data-display'; @@ -11,8 +12,11 @@ const StyledNotificationCounterContainer = styled.div` `; export const RecordTableRowMultiDragCounterChip = () => { + const { recordIndexId } = useRecordIndexContextOrThrow(); + const originalDragSelection = useAtomComponentStateValue( originalDragSelectionComponentState, + recordIndexId, ); const selectedCount = originalDragSelection.length ?? 0; diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-row/components/RecordTableRowMultiDragPreview.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-row/components/RecordTableRowMultiDragPreview.tsx index 1c6c780987..7bc05b97a8 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-row/components/RecordTableRowMultiDragPreview.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-row/components/RecordTableRowMultiDragPreview.tsx @@ -1,14 +1,21 @@ import { isRecordIdPrimaryDragMultipleComponentFamilyState } from '@/object-record/record-drag/states/isRecordIdPrimaryDragMultipleComponentFamilyState'; -import { useRecordTableRowContextOrThrow } from '@/object-record/record-table/contexts/RecordTableRowContext'; +import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext'; import { RecordTableRowMultiDragCounterChip } from '@/object-record/record-table/record-table-row/components/RecordTableRowMultiDragCounterChip'; import { useAtomComponentFamilyStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentFamilyStateValue'; -export const RecordTableRowMultiDragPreview = () => { - const { recordId } = useRecordTableRowContextOrThrow(); +type RecordTableRowMultiDragPreviewProps = { + recordId: string; +}; + +export const RecordTableRowMultiDragPreview = ({ + recordId, +}: RecordTableRowMultiDragPreviewProps) => { + const { recordIndexId } = useRecordIndexContextOrThrow(); const isRecordIdPrimaryDragMultiple = useAtomComponentFamilyStateValue( isRecordIdPrimaryDragMultipleComponentFamilyState, { recordId }, + recordIndexId, ); if (!isRecordIdPrimaryDragMultiple) { diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-row/hooks/useIsRecordSecondaryDragged.ts b/packages/twenty-front/src/modules/object-record/record-table/record-table-row/hooks/useIsRecordSecondaryDragged.ts index b056697688..fe8a5eda2b 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-row/hooks/useIsRecordSecondaryDragged.ts +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-row/hooks/useIsRecordSecondaryDragged.ts @@ -1,11 +1,15 @@ import { isRecordIdSecondaryDragMultipleComponentFamilyState } from '@/object-record/record-drag/states/isRecordIdSecondaryDragMultipleComponentFamilyState'; +import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext'; import { useAtomComponentFamilyStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentFamilyStateValue'; import { type Nullable } from 'twenty-shared/types'; export const useIsTableRowSecondaryDragged = (recordId: Nullable) => { + const { recordIndexId } = useRecordIndexContextOrThrow(); + const isRecordIdSecondaryDragMultiple = useAtomComponentFamilyStateValue( isRecordIdSecondaryDragMultipleComponentFamilyState, { recordId: recordId ?? '' }, + recordIndexId, ); return { diff --git a/packages/twenty-front/src/modules/page-layout/components/PageLayoutTabListReorderableOverflowDropdown.tsx b/packages/twenty-front/src/modules/page-layout/components/PageLayoutTabListReorderableOverflowDropdown.tsx index f1860162aa..92332e3671 100644 --- a/packages/twenty-front/src/modules/page-layout/components/PageLayoutTabListReorderableOverflowDropdown.tsx +++ b/packages/twenty-front/src/modules/page-layout/components/PageLayoutTabListReorderableOverflowDropdown.tsx @@ -9,7 +9,6 @@ import { PageLayoutComponentInstanceContext } from '@/page-layout/states/context import { isPageLayoutTabDraggingComponentState } from '@/page-layout/states/isPageLayoutTabDraggingComponentState'; import { pageLayoutTabSettingsOpenTabIdComponentState } from '@/page-layout/states/pageLayoutTabSettingsOpenTabIdComponentState'; import { type PageLayoutTabDragData } from '@/page-layout/types/PageLayoutTabDragData'; -import { type PageLayoutTabListEndDropData } from '@/page-layout/types/PageLayoutTabListEndDropData'; import { shouldEnableTabEditingFeatures } from '@/page-layout/utils/shouldEnableTabEditingFeatures'; import { useNavigatePageLayoutSidePanel } from '@/side-panel/pages/page-layout/hooks/useNavigatePageLayoutSidePanel'; import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown'; @@ -18,14 +17,13 @@ import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/Drop import { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/GenericDropdownContentWidth'; import { TabListComponentInstanceContext } from '@/ui/layout/tab-list/states/contexts/TabListComponentInstanceContext'; import { type SingleTabProps } from '@/ui/layout/tab-list/types/SingleTabProps'; -import { DragDropItemEndDropZone } from '@/ui/utilities/drag-and-drop/components/DragDropItemEndDropZone'; +import { DragDropItemDropTarget } from '@/ui/utilities/drag-and-drop/components/DragDropItemDropTarget'; import { DragDropItemSortableCell } from '@/ui/utilities/drag-and-drop/components/DragDropItemSortableCell'; import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow'; import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue'; import { useSetAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useSetAtomComponentState'; -import { useContext } from 'react'; +import { Fragment, useContext } from 'react'; import { SidePanelPages } from 'twenty-shared/types'; -import { themeCssVariables } from 'twenty-ui/theme-constants'; import { type PageLayoutType } from '~/generated-metadata/graphql'; const StyledOverflowMenuItemWrapper = styled.div` @@ -38,17 +36,6 @@ const StyledOverflowMenuItemWrapper = styled.div` } `; -// Kept tall enough that appending after the last overflow tab stays an easy -// target. -const StyledOverflowEndDropZone = styled(DragDropItemEndDropZone)` - min-height: ${themeCssVariables.spacing[4]}; -`; - -const OVERFLOW_END_DROP_DATA: PageLayoutTabListEndDropData = { - type: 'tab-list-end', - beforeTabId: null, -}; - type PageLayoutTabListReorderableOverflowDropdownProps = { dropdownId: string; hiddenTabs: SingleTabProps[]; @@ -144,38 +131,49 @@ export const PageLayoutTabListReorderableOverflowDropdown = ({ const tabDragData: PageLayoutTabDragData = { type: 'tab', tabId: tab.id, + nextTabId: hiddenTabs[index + 1]?.id ?? null, }; return ( - - - handleTabSelect(tab.id)} - disabled={disabled} - showEditButton={shouldShowEditButton} - onEditClick={handleEditClick} - /> - - + + + + + handleTabSelect(tab.id)} + disabled={disabled} + showEditButton={shouldShowEditButton} + onEditClick={handleEditClick} + /> + + + ); })} - diff --git a/packages/twenty-front/src/modules/page-layout/components/PageLayoutTabListReorderableTab.tsx b/packages/twenty-front/src/modules/page-layout/components/PageLayoutTabListReorderableTab.tsx index 1bd7de6f7c..00e0b37f46 100644 --- a/packages/twenty-front/src/modules/page-layout/components/PageLayoutTabListReorderableTab.tsx +++ b/packages/twenty-front/src/modules/page-layout/components/PageLayoutTabListReorderableTab.tsx @@ -13,10 +13,10 @@ type PageLayoutTabListReorderableTabProps = { tab: SingleTabProps; index: number; group: string; + nextTabId: string | null; isActive: boolean; disabled?: boolean; isWidgetDropTarget?: boolean; - dropLineOrientation?: 'horizontal' | 'vertical'; onSelect: () => void; }; @@ -31,10 +31,10 @@ export const PageLayoutTabListReorderableTab = ({ tab, index, group, + nextTabId, isActive, disabled, isWidgetDropTarget = false, - dropLineOrientation = 'vertical', onSelect, }: PageLayoutTabListReorderableTabProps) => { const pageLayoutTabSettingsOpenTabId = useAtomComponentStateValue( @@ -46,6 +46,7 @@ export const PageLayoutTabListReorderableTab = ({ const tabDragData: PageLayoutTabDragData = { type: 'tab', tabId: tab.id, + nextTabId, }; const draggableTab = ( @@ -59,7 +60,7 @@ export const PageLayoutTabListReorderableTab = ({ disabled={disabled} fill hasTransition={false} - dropLine={dropLineOrientation} + orientation="vertical" > + + + + + + ); })} - + + {trailingElement} - + ); }; diff --git a/packages/twenty-front/src/modules/page-layout/components/dnd/PageLayoutTabWidgetDropTarget.tsx b/packages/twenty-front/src/modules/page-layout/components/dnd/PageLayoutTabWidgetDropTarget.tsx index f2538e40b3..5738a9ebca 100644 --- a/packages/twenty-front/src/modules/page-layout/components/dnd/PageLayoutTabWidgetDropTarget.tsx +++ b/packages/twenty-front/src/modules/page-layout/components/dnd/PageLayoutTabWidgetDropTarget.tsx @@ -16,6 +16,10 @@ const StyledDropTarget = styled.div<{ isActive: boolean }>` outline: ${({ isActive }) => isActive ? `1px solid ${themeCssVariables.color.blue}` : 'none'}; outline-offset: -1px; + z-index: ${({ isActive }) => (isActive ? 1 : 'auto')}; + &[data-widget-hover] [data-active]::after { + background-color: transparent; + } `; type PageLayoutTabWidgetDropTargetProps = { @@ -45,10 +49,13 @@ export const PageLayoutTabWidgetDropTarget = ({ pageLayoutGridDragHoveredTabIdComponentState, ); + const isActive = isDropTarget || pageLayoutGridDragHoveredTabId === tabId; + return ( {children} diff --git a/packages/twenty-front/src/modules/page-layout/components/dnd/PageLayoutWidgetDndProvider.tsx b/packages/twenty-front/src/modules/page-layout/components/dnd/PageLayoutWidgetDndProvider.tsx index bcc146a7c9..e479d3a8c0 100644 --- a/packages/twenty-front/src/modules/page-layout/components/dnd/PageLayoutWidgetDndProvider.tsx +++ b/packages/twenty-front/src/modules/page-layout/components/dnd/PageLayoutWidgetDndProvider.tsx @@ -5,6 +5,7 @@ import { usePageLayoutWidgetDragAndDrop } from '@/page-layout/hooks/usePageLayou import { type PageLayoutWidgetDndData } from '@/page-layout/types/PageLayoutWidgetDndData'; import { DND_KIT_PROVIDER_PLUGINS_WITHOUT_DROP_ANIMATION } from '@/ui/utilities/drag-and-drop/constants/DndKitProviderPluginsWithoutDropAnimation'; import { DND_KIT_SENSORS } from '@/ui/utilities/drag-and-drop/constants/DndKitSensors'; +import { DragDropItemDndContext } from '@/ui/utilities/drag-and-drop/context/DragDropItemDndContext'; type PageLayoutWidgetDndProviderProps = { children: ReactNode; @@ -17,16 +18,19 @@ type PageLayoutWidgetDndProviderProps = { export const PageLayoutWidgetDndProvider = ({ children, }: PageLayoutWidgetDndProviderProps) => { - const { handlers } = usePageLayoutWidgetDragAndDrop(); + const { contextValues, handlers } = usePageLayoutWidgetDragAndDrop(); return ( - - sensors={DND_KIT_SENSORS} - plugins={DND_KIT_PROVIDER_PLUGINS_WITHOUT_DROP_ANIMATION} - onDragStart={handlers.onDragStart} - onDragEnd={handlers.onDragEnd} - > - {children} - + + + sensors={DND_KIT_SENSORS} + plugins={DND_KIT_PROVIDER_PLUGINS_WITHOUT_DROP_ANIMATION} + onDragStart={handlers.onDragStart} + onDragMove={handlers.onDragMove} + onDragEnd={handlers.onDragEnd} + > + {children} + + ); }; diff --git a/packages/twenty-front/src/modules/page-layout/constants/PageLayoutTabListEndDropZoneWidth.ts b/packages/twenty-front/src/modules/page-layout/constants/PageLayoutTabListEndDropZoneWidth.ts index 1261fdcf52..cab52db224 100644 --- a/packages/twenty-front/src/modules/page-layout/constants/PageLayoutTabListEndDropZoneWidth.ts +++ b/packages/twenty-front/src/modules/page-layout/constants/PageLayoutTabListEndDropZoneWidth.ts @@ -1,3 +1,3 @@ import { TAB_LIST_GAP } from '@/ui/layout/tab-list/constants/TabListGap'; -export const PAGE_LAYOUT_TAB_LIST_END_DROP_ZONE_WIDTH = TAB_LIST_GAP * 2; +export const PAGE_LAYOUT_TAB_LIST_END_DROP_ZONE_WIDTH = TAB_LIST_GAP; diff --git a/packages/twenty-front/src/modules/page-layout/hooks/usePageLayoutWidgetDragAndDrop.ts b/packages/twenty-front/src/modules/page-layout/hooks/usePageLayoutWidgetDragAndDrop.ts index b6eda53f2d..4048479966 100644 --- a/packages/twenty-front/src/modules/page-layout/hooks/usePageLayoutWidgetDragAndDrop.ts +++ b/packages/twenty-front/src/modules/page-layout/hooks/usePageLayoutWidgetDragAndDrop.ts @@ -1,5 +1,5 @@ import { useStore } from 'jotai'; -import { useCallback } from 'react'; +import { useCallback, useState } from 'react'; import { isDefined } from 'twenty-shared/utils'; import { PageLayoutComponentInstanceContext } from '@/page-layout/states/contexts/PageLayoutComponentInstanceContext'; @@ -9,14 +9,19 @@ import { type PageLayoutWidgetDndData } from '@/page-layout/types/PageLayoutWidg import { moveWidgetToTabInDraft } from '@/page-layout/utils/moveWidgetToTabInDraft'; import { moveWidgetWithinTabInDraft } from '@/page-layout/utils/moveWidgetWithinTabInDraft'; import { reorderTabInDraft } from '@/page-layout/utils/reorderTabInDraft'; +import { resolveBeforeTabId } from '@/page-layout/utils/resolveBeforeTabId'; +import { type DragDropItemDndContextValue } from '@/ui/utilities/drag-and-drop/context/DragDropItemDndContext'; import { type DragDropProviderDragEndEvent } from '@/ui/utilities/drag-and-drop/types/DragDropProviderDragEndEvent'; +import { type DragDropProviderDragMoveEvent } from '@/ui/utilities/drag-and-drop/types/DragDropProviderDragMoveEvent'; import { type DragDropProviderDragStartEvent } from '@/ui/utilities/drag-and-drop/types/DragDropProviderDragStartEvent'; import { getDestinationIndex } from '@/ui/utilities/drag-and-drop/utils/getDestinationIndex'; +import { resolveDropFromPointer } from '@/ui/utilities/drag-and-drop/utils/resolveDropFromPointer'; import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow'; import { useAtomComponentStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateCallbackState'; import { useSetAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useSetAtomComponentState'; type DragStartEvent = DragDropProviderDragStartEvent; +type DragMoveEvent = DragDropProviderDragMoveEvent; type DragEndEvent = DragDropProviderDragEndEvent; export const usePageLayoutWidgetDragAndDrop = ( @@ -39,17 +44,68 @@ export const usePageLayoutWidgetDragAndDrop = ( pageLayoutId, ); + const [activeDropTargetIndex, setActiveDropTargetIndex] = useState< + number | null + >(null); + const [activeDroppableId, setActiveDroppableId] = useState( + null, + ); + + const clearWidgetDropTarget = useCallback(() => { + setActiveDropTargetIndex(null); + setActiveDroppableId(null); + }, []); + const onDragStart = useCallback( (event: DragStartEvent) => { const sourceData = event.operation.source?.data as | PageLayoutWidgetDndData | undefined; + clearWidgetDropTarget(); + if (sourceData?.type === 'widget') { setPageLayoutDraggingWidgetId(sourceData.widgetId); } }, - [setPageLayoutDraggingWidgetId], + [clearWidgetDropTarget, setPageLayoutDraggingWidgetId], + ); + + const onDragMove = useCallback( + (event: DragMoveEvent) => { + const sourceData = event.operation.source?.data as + | PageLayoutWidgetDndData + | undefined; + const targetData = event.operation.target?.data as + | PageLayoutWidgetDndData + | undefined; + + if (sourceData?.type === 'widget' && targetData?.type === 'widget-list') { + setActiveDroppableId(targetData.tabId); + setActiveDropTargetIndex(targetData.itemCount); + return; + } + + const isWidgetReorder = + sourceData?.type === 'widget' && targetData?.type === 'widget'; + const isTabReorder = + sourceData?.type === 'tab' && targetData?.type === 'tab'; + + if (!isWidgetReorder && !isTabReorder) { + clearWidgetDropTarget(); + return; + } + + const resolvedDrop = resolveDropFromPointer({ + target: event.operation.target, + pointer: event.operation.position.current, + getDroppableItemCount: () => 0, + }); + + setActiveDropTargetIndex(resolvedDrop?.dropTargetIndex ?? null); + setActiveDroppableId(resolvedDrop?.droppableId ?? null); + }, + [clearWidgetDropTarget], ); const onDragEnd = useCallback( @@ -61,6 +117,8 @@ export const usePageLayoutWidgetDragAndDrop = ( | PageLayoutWidgetDndData | undefined; + clearWidgetDropTarget(); + if ( !event.canceled && sourceData?.type === 'widget' && @@ -100,30 +158,36 @@ export const usePageLayoutWidgetDragAndDrop = ( }); }); } else if (targetData.type === 'widget') { - // The drop line renders above the hovered widget, so the drop targets - // the slot before it; getDestinationIndex compensates for the source - // removal shifting same-tab downward moves by one. const destinationTabId = targetData.tabId; - const destinationIndex = getDestinationIndex({ - dropTargetIndex: targetData.index, - sourceIndex, - sourceDroppableId: sourceTabId, - destinationDroppableId: destinationTabId, + + const resolvedDrop = resolveDropFromPointer({ + target: event.operation.target, + pointer: event.operation.position.current, + getDroppableItemCount: () => 0, }); - store.set(pageLayoutDraftState, (prev) => - destinationTabId === sourceTabId - ? moveWidgetWithinTabInDraft(prev, { - tabId: sourceTabId, - fromIndex: sourceIndex, - toIndex: destinationIndex, - }) - : moveWidgetToTabInDraft(prev, { - widgetId, - destinationTabId, - destinationIndex, - }), - ); + if (isDefined(resolvedDrop)) { + const destinationIndex = getDestinationIndex({ + dropTargetIndex: resolvedDrop.dropTargetIndex, + sourceIndex, + sourceDroppableId: sourceTabId, + destinationDroppableId: destinationTabId, + }); + + store.set(pageLayoutDraftState, (prev) => + destinationTabId === sourceTabId + ? moveWidgetWithinTabInDraft(prev, { + tabId: sourceTabId, + fromIndex: sourceIndex, + toIndex: destinationIndex, + }) + : moveWidgetToTabInDraft(prev, { + widgetId, + destinationTabId, + destinationIndex, + }), + ); + } } } @@ -134,16 +198,7 @@ export const usePageLayoutWidgetDragAndDrop = ( ) { const draggedTabId = sourceData.tabId; - // The drop line renders before the hovered tab, so tab targets insert - // the dragged tab before them; end zones and the more button append. - const beforeTabId = - targetData.type === 'tab' - ? targetData.tabId - : targetData.type === 'tab-list-end' - ? targetData.beforeTabId - : targetData.type === 'tab-more-button' - ? null - : undefined; + const beforeTabId = resolveBeforeTabId(event, targetData); if (beforeTabId !== undefined) { store.set(pageLayoutDraftState, (prev) => @@ -154,8 +209,18 @@ export const usePageLayoutWidgetDragAndDrop = ( setPageLayoutDraggingWidgetId(null); }, - [store, pageLayoutDraftState, setPageLayoutDraggingWidgetId], + [ + store, + pageLayoutDraftState, + setPageLayoutDraggingWidgetId, + clearWidgetDropTarget, + ], ); - return { handlers: { onDragStart, onDragEnd } }; + const contextValues: DragDropItemDndContextValue = { + activeDropTargetIndex, + activeDroppableId, + }; + + return { contextValues, handlers: { onDragStart, onDragMove, onDragEnd } }; }; diff --git a/packages/twenty-front/src/modules/page-layout/types/PageLayoutTabDragData.ts b/packages/twenty-front/src/modules/page-layout/types/PageLayoutTabDragData.ts index a4f6579915..abd2faadbd 100644 --- a/packages/twenty-front/src/modules/page-layout/types/PageLayoutTabDragData.ts +++ b/packages/twenty-front/src/modules/page-layout/types/PageLayoutTabDragData.ts @@ -1,4 +1,7 @@ export type PageLayoutTabDragData = { type: 'tab'; tabId: string; + // The following visible tab (or the first hidden tab, null when none), so a + // drop past this tab's midpoint resolves to the right beforeTabId. + nextTabId: string | null; }; diff --git a/packages/twenty-front/src/modules/page-layout/types/PageLayoutTabListEndDropData.ts b/packages/twenty-front/src/modules/page-layout/types/PageLayoutTabListEndDropData.ts deleted file mode 100644 index ec069af1e8..0000000000 --- a/packages/twenty-front/src/modules/page-layout/types/PageLayoutTabListEndDropData.ts +++ /dev/null @@ -1,5 +0,0 @@ -// beforeTabId null means append after the last tab. -export type PageLayoutTabListEndDropData = { - type: 'tab-list-end'; - beforeTabId: string | null; -}; diff --git a/packages/twenty-front/src/modules/page-layout/types/PageLayoutWidgetDndData.ts b/packages/twenty-front/src/modules/page-layout/types/PageLayoutWidgetDndData.ts index 61640bcba9..d0b3e2f0ad 100644 --- a/packages/twenty-front/src/modules/page-layout/types/PageLayoutWidgetDndData.ts +++ b/packages/twenty-front/src/modules/page-layout/types/PageLayoutWidgetDndData.ts @@ -1,5 +1,4 @@ import { type PageLayoutTabDragData } from '@/page-layout/types/PageLayoutTabDragData'; -import { type PageLayoutTabListEndDropData } from '@/page-layout/types/PageLayoutTabListEndDropData'; import { type PageLayoutTabMoreButtonDropData } from '@/page-layout/types/PageLayoutTabMoreButtonDropData'; import { type PageLayoutTabWidgetDropData } from '@/page-layout/types/PageLayoutTabWidgetDropData'; import { type PageLayoutWidgetDragData } from '@/page-layout/types/PageLayoutWidgetDragData'; @@ -10,5 +9,4 @@ export type PageLayoutWidgetDndData = | PageLayoutTabWidgetDropData | PageLayoutWidgetListDropData | PageLayoutTabDragData - | PageLayoutTabListEndDropData | PageLayoutTabMoreButtonDropData; diff --git a/packages/twenty-front/src/modules/page-layout/types/PageLayoutWidgetListDropData.ts b/packages/twenty-front/src/modules/page-layout/types/PageLayoutWidgetListDropData.ts index 8086921715..c3ccb7145a 100644 --- a/packages/twenty-front/src/modules/page-layout/types/PageLayoutWidgetListDropData.ts +++ b/packages/twenty-front/src/modules/page-layout/types/PageLayoutWidgetListDropData.ts @@ -1,4 +1,5 @@ export type PageLayoutWidgetListDropData = { type: 'widget-list'; tabId: string; + itemCount: number; }; diff --git a/packages/twenty-front/src/modules/page-layout/utils/resolveBeforeTabId.ts b/packages/twenty-front/src/modules/page-layout/utils/resolveBeforeTabId.ts new file mode 100644 index 0000000000..c855686e11 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/utils/resolveBeforeTabId.ts @@ -0,0 +1,39 @@ +import { isDefined } from 'twenty-shared/utils'; + +import { type PageLayoutWidgetDndData } from '@/page-layout/types/PageLayoutWidgetDndData'; +import { type DragDropItemData } from '@/ui/utilities/drag-and-drop/types/DragDropItemData'; +import { type DragDropProviderDragEndEvent } from '@/ui/utilities/drag-and-drop/types/DragDropProviderDragEndEvent'; +import { resolveDropFromPointer } from '@/ui/utilities/drag-and-drop/utils/resolveDropFromPointer'; + +type DragEndEvent = DragDropProviderDragEndEvent; + +// Resolves the tab a dragged tab should be inserted before. +export const resolveBeforeTabId = ( + event: DragEndEvent, + targetData: PageLayoutWidgetDndData, +): string | null | undefined => { + if (targetData.type === 'tab-more-button') { + return null; + } + + if (targetData.type !== 'tab') { + return undefined; + } + + const resolvedDrop = resolveDropFromPointer({ + target: event.operation.target, + pointer: event.operation.position.current, + getDroppableItemCount: () => 0, + }); + const targetSortableData = event.operation.target?.data as + | DragDropItemData + | undefined; + + if (!isDefined(resolvedDrop) || !isDefined(targetSortableData)) { + return targetData.tabId; + } + + return resolvedDrop.dropTargetIndex > targetSortableData.index + ? (targetData.nextTabId ?? null) + : targetData.tabId; +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/fields/components/FieldsConfigurationEditor.tsx b/packages/twenty-front/src/modules/page-layout/widgets/fields/components/FieldsConfigurationEditor.tsx index 0fea7d5ef0..a9c16634d4 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/fields/components/FieldsConfigurationEditor.tsx +++ b/packages/twenty-front/src/modules/page-layout/widgets/fields/components/FieldsConfigurationEditor.tsx @@ -18,16 +18,16 @@ import { useToggleUngroupedFieldVisibilityInDraft } from '@/page-layout/widgets/ import { useUpdateFieldsWidgetEditorGroup } from '@/page-layout/widgets/fields/hooks/useUpdateFieldsWidgetEditorGroup'; import { type FieldsConfigurationDndData } from '@/page-layout/widgets/fields/types/FieldsConfigurationDndData'; import { type FieldsConfigurationGroupDragData } from '@/page-layout/widgets/fields/types/FieldsConfigurationGroupDragData'; -import { type FieldsConfigurationGroupListEndDropData } from '@/page-layout/widgets/fields/types/FieldsConfigurationGroupListEndDropData'; import { getFieldsConfigurationGroupRenameDropdownId } from '@/page-layout/widgets/fields/utils/getFieldsConfigurationGroupRenameDropdownId'; import { useOpenDropdown } from '@/ui/layout/dropdown/hooks/useOpenDropdown'; -import { DragDropItemEndDropZone } from '@/ui/utilities/drag-and-drop/components/DragDropItemEndDropZone'; +import { DragDropItemDropTarget } from '@/ui/utilities/drag-and-drop/components/DragDropItemDropTarget'; import { DragDropItemSortableCell } from '@/ui/utilities/drag-and-drop/components/DragDropItemSortableCell'; import { DND_KIT_PROVIDER_PLUGINS_WITHOUT_DROP_ANIMATION } from '@/ui/utilities/drag-and-drop/constants/DndKitProviderPluginsWithoutDropAnimation'; import { DND_KIT_SENSORS } from '@/ui/utilities/drag-and-drop/constants/DndKitSensors'; +import { DragDropItemDndContext } from '@/ui/utilities/drag-and-drop/context/DragDropItemDndContext'; import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue'; import { useLingui } from '@lingui/react/macro'; -import { useState } from 'react'; +import { Fragment, useState } from 'react'; import { IconNewSection } from 'twenty-ui/icon'; import { MenuItem } from 'twenty-ui/navigation'; @@ -41,10 +41,6 @@ const StyledAddGroupButtonContainer = styled.div` border: 1px solid transparent; `; -const GROUPS_END_DROP_DATA: FieldsConfigurationGroupListEndDropData = { - type: 'group-list-end', -}; - type FieldsConfigurationEditorProps = { pageLayoutId: string; widgetId: string; @@ -81,12 +77,11 @@ export const FieldsConfigurationEditor = ({ widgetId, }); - const { draggingGroupId, handlers } = useFieldsConfigurationEditorDragAndDrop( - { + const { draggingGroupId, contextValues, handlers } = + useFieldsConfigurationEditorDragAndDrop({ pageLayoutId, widgetId, - }, - ); + }); const { toggleFieldVisibility } = useToggleFieldVisibilityInDraft({ pageLayoutId, @@ -165,55 +160,70 @@ export const FieldsConfigurationEditor = ({ } return ( - - sensors={DND_KIT_SENSORS} - plugins={DND_KIT_PROVIDER_PLUGINS_WITHOUT_DROP_ANIMATION} - onDragStart={handlers.onDragStart} - onDragEnd={handlers.onDragEnd} - > - - {sortedGroups.map((group, index) => { - const groupDragData: FieldsConfigurationGroupDragData = { - type: 'group', - groupId: group.id, - index, - }; + + + sensors={DND_KIT_SENSORS} + plugins={DND_KIT_PROVIDER_PLUGINS_WITHOUT_DROP_ANIMATION} + onDragStart={handlers.onDragStart} + onDragMove={handlers.onDragMove} + onDragEnd={handlers.onDragEnd} + > + + {sortedGroups.map((group, index) => { + const groupDragData: FieldsConfigurationGroupDragData = { + type: 'group', + groupId: group.id, + index, + }; - return ( - - handleAddGroup({ afterGroupId: group.id })} - onToggleFieldVisibility={(fieldMetadataId) => - toggleFieldVisibility(group.id, fieldMetadataId) - } - onRenameGroup={handleRenameGroup} - onDeleteGroup={handleDeleteGroup} - renamingGroupValue={renamingGroupValue} - onRenamingGroupValueChange={setRenamingGroupValue} - onStartRename={handleStartRename} - /> - - ); - })} + return ( + + + + + handleAddGroup({ afterGroupId: group.id }) + } + onToggleFieldVisibility={(fieldMetadataId) => + toggleFieldVisibility(group.id, fieldMetadataId) + } + onRenameGroup={handleRenameGroup} + onDeleteGroup={handleDeleteGroup} + renamingGroupValue={renamingGroupValue} + onRenamingGroupValueChange={setRenamingGroupValue} + onStartRename={handleStartRename} + /> + + + ); + })} + + - - - - + + + ); }; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/fields/components/FieldsConfigurationEmptyGroupDropZone.tsx b/packages/twenty-front/src/modules/page-layout/widgets/fields/components/FieldsConfigurationEmptyGroupDropZone.tsx new file mode 100644 index 0000000000..87c4a11166 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/fields/components/FieldsConfigurationEmptyGroupDropZone.tsx @@ -0,0 +1,51 @@ +import { pointerIntersection } from '@dnd-kit/collision'; +import { useDroppable } from '@dnd-kit/react'; +import { styled } from '@linaria/react'; +import { type ReactNode } from 'react'; +import { themeCssVariables } from 'twenty-ui/theme-constants'; + +import { FIELDS_CONFIGURATION_FIELD_DND_TYPE } from '@/page-layout/widgets/fields/constants/FieldsConfigurationFieldDndType'; +import { type FieldsConfigurationFieldListEndDropData } from '@/page-layout/widgets/fields/types/FieldsConfigurationFieldListEndDropData'; + +const StyledEmptyGroupDropZone = styled.div<{ isDropTarget: boolean }>` + align-items: center; + border: 1px dashed + ${({ isDropTarget }) => + isDropTarget + ? themeCssVariables.color.blue + : themeCssVariables.border.color.medium}; + border-radius: ${themeCssVariables.border.radius.sm}; + color: ${themeCssVariables.font.color.light}; + display: flex; + font-size: ${themeCssVariables.font.size.sm}; + justify-content: center; + margin: ${themeCssVariables.spacing[1]} ${themeCssVariables.spacing[2]}; + min-height: ${themeCssVariables.spacing[10]}; +`; + +type FieldsConfigurationEmptyGroupDropZoneProps = { + groupId: string; + children: ReactNode; +}; + +export const FieldsConfigurationEmptyGroupDropZone = ({ + groupId, + children, +}: FieldsConfigurationEmptyGroupDropZoneProps) => { + const emptyGroupDropData: FieldsConfigurationFieldListEndDropData = { + droppableId: groupId, + }; + + const { ref, isDropTarget } = useDroppable({ + id: `fields-configuration-group-${groupId}-empty`, + accept: FIELDS_CONFIGURATION_FIELD_DND_TYPE, + collisionDetector: pointerIntersection, + data: emptyGroupDropData, + }); + + return ( + + {children} + + ); +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/fields/components/FieldsConfigurationGroupEditor.tsx b/packages/twenty-front/src/modules/page-layout/widgets/fields/components/FieldsConfigurationGroupEditor.tsx index b6613fa4cc..1de6b690e2 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/fields/components/FieldsConfigurationGroupEditor.tsx +++ b/packages/twenty-front/src/modules/page-layout/widgets/fields/components/FieldsConfigurationGroupEditor.tsx @@ -1,13 +1,14 @@ import { styled } from '@linaria/react'; import { useLingui } from '@lingui/react/macro'; +import { Fragment } from 'react'; import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem'; +import { FieldsConfigurationEmptyGroupDropZone } from '@/page-layout/widgets/fields/components/FieldsConfigurationEmptyGroupDropZone'; import { FieldsConfigurationFieldEditor } from '@/page-layout/widgets/fields/components/FieldsConfigurationFieldEditor'; import { FieldsConfigurationGroupDropdown } from '@/page-layout/widgets/fields/components/FieldsConfigurationGroupDropdown'; import { FieldsConfigurationGroupRenameInput } from '@/page-layout/widgets/fields/components/FieldsConfigurationGroupRenameInput'; import { FIELDS_CONFIGURATION_FIELD_DND_TYPE } from '@/page-layout/widgets/fields/constants/FieldsConfigurationFieldDndType'; import { type FieldsConfigurationFieldDragData } from '@/page-layout/widgets/fields/types/FieldsConfigurationFieldDragData'; -import { type FieldsConfigurationFieldListEndDropData } from '@/page-layout/widgets/fields/types/FieldsConfigurationFieldListEndDropData'; import { type FieldsWidgetGroup } from '@/page-layout/widgets/fields/types/FieldsWidgetGroup'; import { getFieldsConfigurationGroupRenameDropdownId } from '@/page-layout/widgets/fields/utils/getFieldsConfigurationGroupRenameDropdownId'; import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown'; @@ -15,7 +16,7 @@ import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent import { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/GenericDropdownContentWidth'; import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown'; import { useOpenDropdown } from '@/ui/layout/dropdown/hooks/useOpenDropdown'; -import { DragDropItemEndDropZone } from '@/ui/utilities/drag-and-drop/components/DragDropItemEndDropZone'; +import { DragDropItemDropTarget } from '@/ui/utilities/drag-and-drop/components/DragDropItemDropTarget'; import { DragDropItemSortableCell } from '@/ui/utilities/drag-and-drop/components/DragDropItemSortableCell'; import { DragDropItemSortableHandle } from '@/ui/utilities/drag-and-drop/components/DragDropItemSortableHandle'; @@ -27,24 +28,6 @@ const StyledFieldsDroppable = styled.div` flex-direction: column; `; -const StyledEmptyGroupDropZone = styled(DragDropItemEndDropZone)` - align-items: center; - border: 1px dashed ${themeCssVariables.border.color.medium}; - border-radius: ${themeCssVariables.border.radius.sm}; - color: ${themeCssVariables.font.color.light}; - display: flex; - font-size: ${themeCssVariables.font.size.sm}; - justify-content: center; - margin: ${themeCssVariables.spacing[1]} ${themeCssVariables.spacing[2]}; - min-height: ${themeCssVariables.spacing[10]}; -`; - -// Kept tall enough that appending after the group's last field stays an easy -// target. -const StyledFieldsEndDropZone = styled(DragDropItemEndDropZone)` - min-height: ${themeCssVariables.spacing[4]}; -`; - const StyledGroupContainer = styled.div<{ isDragging: boolean }>` background: ${({ isDragging }) => isDragging ? themeCssVariables.background.primary : 'transparent'}; @@ -132,11 +115,6 @@ export const FieldsConfigurationGroupEditor = ({ onRenameGroup({ groupId, newName }); }; - const fieldsEndDropData: FieldsConfigurationFieldListEndDropData = { - type: 'field-list-end', - groupId: group.id, - }; - const sortedFields = [...group.fields].sort( (a, b) => a.position - b.position, ); @@ -186,13 +164,9 @@ export const FieldsConfigurationGroupEditor = ({ {sortedFields.length === 0 ? ( - + {t`Drop fields here`} - + ) : ( <> {sortedFields.map((field, fieldIndex) => { @@ -203,36 +177,44 @@ export const FieldsConfigurationGroupEditor = ({ }; return ( - - { - onToggleFieldVisibility(field.fieldMetadataItem.id); - }} + + - + + { + onToggleFieldVisibility(field.fieldMetadataItem.id); + }} + /> + + ); })} - )} diff --git a/packages/twenty-front/src/modules/page-layout/widgets/fields/components/FieldsConfigurationUngroupedEditor.tsx b/packages/twenty-front/src/modules/page-layout/widgets/fields/components/FieldsConfigurationUngroupedEditor.tsx index d4b73b5253..f301e38e95 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/fields/components/FieldsConfigurationUngroupedEditor.tsx +++ b/packages/twenty-front/src/modules/page-layout/widgets/fields/components/FieldsConfigurationUngroupedEditor.tsx @@ -1,22 +1,28 @@ import { DragDropProvider } from '@dnd-kit/react'; +import { styled } from '@linaria/react'; +import { useLingui } from '@lingui/react/macro'; +import { Fragment, useState } from 'react'; import { isDefined } from 'twenty-shared/utils'; +import { IconNewSection } from 'twenty-ui/icon'; +import { MenuItem } from 'twenty-ui/navigation'; import { FieldsConfigurationFieldEditor } from '@/page-layout/widgets/fields/components/FieldsConfigurationFieldEditor'; import { FIELDS_CONFIGURATION_FIELD_DND_TYPE } from '@/page-layout/widgets/fields/constants/FieldsConfigurationFieldDndType'; import { type FieldsConfigurationDndData } from '@/page-layout/widgets/fields/types/FieldsConfigurationDndData'; import { type FieldsConfigurationFieldDragData } from '@/page-layout/widgets/fields/types/FieldsConfigurationFieldDragData'; -import { type FieldsConfigurationFieldListEndDropData } from '@/page-layout/widgets/fields/types/FieldsConfigurationFieldListEndDropData'; import { type FieldsWidgetGroupField } from '@/page-layout/widgets/fields/types/FieldsWidgetGroup'; -import { DragDropItemEndDropZone } from '@/ui/utilities/drag-and-drop/components/DragDropItemEndDropZone'; +import { DragDropItemDropTarget } from '@/ui/utilities/drag-and-drop/components/DragDropItemDropTarget'; import { DragDropItemSortableCell } from '@/ui/utilities/drag-and-drop/components/DragDropItemSortableCell'; import { DND_KIT_PROVIDER_PLUGINS_WITHOUT_DROP_ANIMATION } from '@/ui/utilities/drag-and-drop/constants/DndKitProviderPluginsWithoutDropAnimation'; import { DND_KIT_SENSORS } from '@/ui/utilities/drag-and-drop/constants/DndKitSensors'; +import { + DragDropItemDndContext, + type DragDropItemDndContextValue, +} from '@/ui/utilities/drag-and-drop/context/DragDropItemDndContext'; import { type DragDropProviderDragEndEvent } from '@/ui/utilities/drag-and-drop/types/DragDropProviderDragEndEvent'; +import { type DragDropProviderDragMoveEvent } from '@/ui/utilities/drag-and-drop/types/DragDropProviderDragMoveEvent'; import { getDestinationIndex } from '@/ui/utilities/drag-and-drop/utils/getDestinationIndex'; -import { styled } from '@linaria/react'; -import { useLingui } from '@lingui/react/macro'; -import { IconNewSection } from 'twenty-ui/icon'; -import { MenuItem } from 'twenty-ui/navigation'; +import { resolveDropFromPointer } from '@/ui/utilities/drag-and-drop/utils/resolveDropFromPointer'; const UNGROUPED_FIELDS_DROPPABLE_ID = 'ungrouped-fields'; @@ -26,11 +32,6 @@ const StyledFieldsDroppable = styled.div` width: 100%; `; -const UNGROUPED_END_DROP_DATA: FieldsConfigurationFieldListEndDropData = { - type: 'field-list-end', - groupId: UNGROUPED_FIELDS_DROPPABLE_ID, -}; - type FieldsConfigurationUngroupedEditorProps = { ungroupedFields: FieldsWidgetGroupField[]; onMoveField: (sourceIndex: number, destinationIndex: number) => void; @@ -50,35 +51,49 @@ export const FieldsConfigurationUngroupedEditor = ({ (a, b) => a.position - b.position, ); + const [activeDropTargetIndex, setActiveDropTargetIndex] = useState< + number | null + >(null); + + const resolveDrop = ( + event: + | DragDropProviderDragMoveEvent + | DragDropProviderDragEndEvent, + ) => + resolveDropFromPointer({ + target: event.operation.target, + pointer: event.operation.position.current, + defaultOrientation: 'horizontal', + getDroppableItemCount: () => sortedFields.length, + }); + + const handleDragMove = ( + event: DragDropProviderDragMoveEvent, + ) => { + setActiveDropTargetIndex(resolveDrop(event)?.dropTargetIndex ?? null); + }; + const handleDragEnd = ( event: DragDropProviderDragEndEvent, ) => { + setActiveDropTargetIndex(null); + const sourceData = event.operation.source?.data as | FieldsConfigurationDndData | undefined; - const targetData = event.operation.target?.data as - | FieldsConfigurationDndData - | undefined; if (event.canceled || sourceData?.type !== 'field') { return; } - // The drop line renders before the hovered field, so field targets insert - // the dragged field before them; the end drop zone appends it. - const dropTargetIndex = - targetData?.type === 'field' - ? targetData.index - : targetData?.type === 'field-list-end' - ? sortedFields.length - : null; + const resolvedDrop = resolveDrop(event); - if (!isDefined(dropTargetIndex)) { + if (!isDefined(resolvedDrop)) { return; } const destinationIndex = getDestinationIndex({ - dropTargetIndex, + dropTargetIndex: resolvedDrop.dropTargetIndex, sourceIndex: sourceData.index, sourceDroppableId: UNGROUPED_FIELDS_DROPPABLE_ID, destinationDroppableId: UNGROUPED_FIELDS_DROPPABLE_ID, @@ -91,53 +106,69 @@ export const FieldsConfigurationUngroupedEditor = ({ onMoveField(sourceData.index, destinationIndex); }; + const contextValues: DragDropItemDndContextValue = { + activeDropTargetIndex, + activeDroppableId: UNGROUPED_FIELDS_DROPPABLE_ID, + }; + return ( - - sensors={DND_KIT_SENSORS} - plugins={DND_KIT_PROVIDER_PLUGINS_WITHOUT_DROP_ANIMATION} - onDragEnd={handleDragEnd} - > - - {sortedFields.map((field, fieldIndex) => { - const fieldDragData: FieldsConfigurationFieldDragData = { - type: 'field', - groupId: UNGROUPED_FIELDS_DROPPABLE_ID, - index: fieldIndex, - }; + + + sensors={DND_KIT_SENSORS} + plugins={DND_KIT_PROVIDER_PLUGINS_WITHOUT_DROP_ANIMATION} + onDragMove={handleDragMove} + onDragEnd={handleDragEnd} + > + + {sortedFields.map((field, fieldIndex) => { + const fieldDragData: FieldsConfigurationFieldDragData = { + type: 'field', + groupId: UNGROUPED_FIELDS_DROPPABLE_ID, + index: fieldIndex, + }; - return ( - - { - onToggleFieldVisibility(field.fieldMetadataItem.id); - }} - /> - - ); - })} + return ( + + + + { + onToggleFieldVisibility(field.fieldMetadataItem.id); + }} + /> + + + ); + })} + + - - - - + + + ); }; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/fields/hooks/useFieldsConfigurationEditorDragAndDrop.ts b/packages/twenty-front/src/modules/page-layout/widgets/fields/hooks/useFieldsConfigurationEditorDragAndDrop.ts index 7df29753e3..85628382e7 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/fields/hooks/useFieldsConfigurationEditorDragAndDrop.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/fields/hooks/useFieldsConfigurationEditorDragAndDrop.ts @@ -6,15 +6,17 @@ import { FIELDS_CONFIGURATION_GROUPS_DROPPABLE_ID } from '@/page-layout/widgets/ import { useMoveFieldInDraft } from '@/page-layout/widgets/fields/hooks/useMoveFieldInDraft'; import { useReorderFieldsWidgetEditorGroups } from '@/page-layout/widgets/fields/hooks/useReorderFieldsWidgetEditorGroups'; import { type FieldsConfigurationDndData } from '@/page-layout/widgets/fields/types/FieldsConfigurationDndData'; -import { type FieldsConfigurationFieldDragData } from '@/page-layout/widgets/fields/types/FieldsConfigurationFieldDragData'; -import { type FieldsConfigurationGroupDragData } from '@/page-layout/widgets/fields/types/FieldsConfigurationGroupDragData'; +import { type DragDropItemDndContextValue } from '@/ui/utilities/drag-and-drop/context/DragDropItemDndContext'; import { type DragDropProviderDragEndEvent } from '@/ui/utilities/drag-and-drop/types/DragDropProviderDragEndEvent'; +import { type DragDropProviderDragMoveEvent } from '@/ui/utilities/drag-and-drop/types/DragDropProviderDragMoveEvent'; import { type DragDropProviderDragStartEvent } from '@/ui/utilities/drag-and-drop/types/DragDropProviderDragStartEvent'; import { getDestinationIndex } from '@/ui/utilities/drag-and-drop/utils/getDestinationIndex'; +import { resolveDropFromPointer } from '@/ui/utilities/drag-and-drop/utils/resolveDropFromPointer'; import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue'; type DragStartEvent = DragDropProviderDragStartEvent; +type DragMoveEvent = DragDropProviderDragMoveEvent; type DragEndEvent = DragDropProviderDragEndEvent; type UseFieldsConfigurationEditorDragAndDropParams = { @@ -33,6 +35,8 @@ export const useFieldsConfigurationEditorDragAndDrop = ({ const draftGroups = fieldsWidgetGroupsDraft[widgetId] ?? []; + const sortedGroups = [...draftGroups].sort((a, b) => a.position - b.position); + const { reorderGroups } = useReorderFieldsWidgetEditorGroups({ pageLayoutId, widgetId, @@ -44,44 +48,48 @@ export const useFieldsConfigurationEditorDragAndDrop = ({ }); const [draggingGroupId, setDraggingGroupId] = useState(null); + const [activeDropTargetIndex, setActiveDropTargetIndex] = useState< + number | null + >(null); + const [activeDroppableId, setActiveDroppableId] = useState( + null, + ); - const handleGroupDrop = ({ - sourceData, - targetData, - }: { - sourceData: FieldsConfigurationGroupDragData; - targetData: FieldsConfigurationDndData; - }) => { - const sortedGroups = [...draftGroups].sort( - (a, b) => a.position - b.position, - ); - - // The drop line renders before the hovered group, so group targets insert - // the dragged group before them; the end drop zone appends it. - const dropTargetIndex = - targetData.type === 'group' - ? targetData.index - : targetData.type === 'group-list-end' - ? sortedGroups.length - : null; - - if (!isDefined(dropTargetIndex)) { - return; + const getDroppableItemCount = (droppableId: string) => { + if (droppableId === FIELDS_CONFIGURATION_GROUPS_DROPPABLE_ID) { + return sortedGroups.length; } + const group = draftGroups.find((candidate) => candidate.id === droppableId); + + return group?.fields.length ?? 0; + }; + + const clearActiveDropTarget = () => { + setActiveDropTargetIndex(null); + setActiveDroppableId(null); + }; + + const handleGroupDrop = ({ + sourceIndex, + dropTargetIndex, + }: { + sourceIndex: number; + dropTargetIndex: number; + }) => { const destinationIndex = getDestinationIndex({ dropTargetIndex, - sourceIndex: sourceData.index, + sourceIndex, sourceDroppableId: FIELDS_CONFIGURATION_GROUPS_DROPPABLE_ID, destinationDroppableId: FIELDS_CONFIGURATION_GROUPS_DROPPABLE_ID, }); - if (destinationIndex === sourceData.index) { + if (destinationIndex === sourceIndex) { return; } const reorderedGroupIds = sortedGroups.map((group) => group.id); - const [movedGroupId] = reorderedGroupIds.splice(sourceData.index, 1); + const [movedGroupId] = reorderedGroupIds.splice(sourceIndex, 1); reorderedGroupIds.splice(destinationIndex, 0, movedGroupId); reorderGroups(reorderedGroupIds); @@ -89,39 +97,30 @@ export const useFieldsConfigurationEditorDragAndDrop = ({ const handleFieldDrop = ({ sourceData, - targetData, + destinationGroupId, + dropTargetIndex, }: { - sourceData: FieldsConfigurationFieldDragData; - targetData: FieldsConfigurationDndData; + sourceData: FieldsConfigurationDndData & { type: 'field' }; + destinationGroupId: string; + dropTargetIndex: number; }) => { - if (targetData.type !== 'field' && targetData.type !== 'field-list-end') { - return; - } - const destinationGroup = draftGroups.find( - (group) => group.id === targetData.groupId, + (group) => group.id === destinationGroupId, ); if (!isDefined(destinationGroup)) { return; } - // The drop line renders before the hovered field, so field targets insert - // the dragged field before them; the end drop zone appends it to the group. - const dropTargetIndex = - targetData.type === 'field' - ? targetData.index - : destinationGroup.fields.length; - const destinationIndex = getDestinationIndex({ dropTargetIndex, sourceIndex: sourceData.index, sourceDroppableId: sourceData.groupId, - destinationDroppableId: targetData.groupId, + destinationDroppableId: destinationGroupId, }); if ( - targetData.groupId === sourceData.groupId && + destinationGroupId === sourceData.groupId && destinationIndex === sourceData.index ) { return; @@ -129,13 +128,15 @@ export const useFieldsConfigurationEditorDragAndDrop = ({ moveField( sourceData.groupId, - targetData.groupId, + destinationGroupId, sourceData.index, destinationIndex, ); }; const onDragStart = (event: DragStartEvent) => { + clearActiveDropTarget(); + const sourceData = event.operation.source?.data as | FieldsConfigurationDndData | undefined; @@ -145,29 +146,66 @@ export const useFieldsConfigurationEditorDragAndDrop = ({ } }; + const onDragMove = (event: DragMoveEvent) => { + const resolvedDrop = resolveDropFromPointer({ + target: event.operation.target, + pointer: event.operation.position.current, + defaultOrientation: 'horizontal', + getDroppableItemCount, + }); + + setActiveDropTargetIndex(resolvedDrop?.dropTargetIndex ?? null); + setActiveDroppableId(resolvedDrop?.droppableId ?? null); + }; + const onDragEnd = (event: DragEndEvent) => { setDraggingGroupId(null); + clearActiveDropTarget(); const sourceData = event.operation.source?.data as | FieldsConfigurationDndData | undefined; - const targetData = event.operation.target?.data as - | FieldsConfigurationDndData - | undefined; - if (event.canceled || !isDefined(sourceData) || !isDefined(targetData)) { + if (event.canceled || !isDefined(sourceData)) { return; } - if (sourceData.type === 'group') { - handleGroupDrop({ sourceData, targetData }); - } else if (sourceData.type === 'field') { - handleFieldDrop({ sourceData, targetData }); + const resolvedDrop = resolveDropFromPointer({ + target: event.operation.target, + pointer: event.operation.position.current, + defaultOrientation: 'horizontal', + getDroppableItemCount, + }); + + if (!isDefined(resolvedDrop)) { + return; } + + if ( + sourceData.type === 'group' && + resolvedDrop.droppableId === FIELDS_CONFIGURATION_GROUPS_DROPPABLE_ID + ) { + handleGroupDrop({ + sourceIndex: sourceData.index, + dropTargetIndex: resolvedDrop.dropTargetIndex, + }); + } else if (sourceData.type === 'field') { + handleFieldDrop({ + sourceData, + destinationGroupId: resolvedDrop.droppableId, + dropTargetIndex: resolvedDrop.dropTargetIndex, + }); + } + }; + + const contextValues: DragDropItemDndContextValue = { + activeDropTargetIndex, + activeDroppableId, }; return { draggingGroupId, - handlers: { onDragStart, onDragEnd }, + contextValues, + handlers: { onDragStart, onDragMove, onDragEnd }, }; }; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/fields/types/FieldsConfigurationDndData.ts b/packages/twenty-front/src/modules/page-layout/widgets/fields/types/FieldsConfigurationDndData.ts index 430d769004..0b511e48db 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/fields/types/FieldsConfigurationDndData.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/fields/types/FieldsConfigurationDndData.ts @@ -1,10 +1,6 @@ import { type FieldsConfigurationFieldDragData } from '@/page-layout/widgets/fields/types/FieldsConfigurationFieldDragData'; -import { type FieldsConfigurationFieldListEndDropData } from '@/page-layout/widgets/fields/types/FieldsConfigurationFieldListEndDropData'; import { type FieldsConfigurationGroupDragData } from '@/page-layout/widgets/fields/types/FieldsConfigurationGroupDragData'; -import { type FieldsConfigurationGroupListEndDropData } from '@/page-layout/widgets/fields/types/FieldsConfigurationGroupListEndDropData'; export type FieldsConfigurationDndData = | FieldsConfigurationGroupDragData - | FieldsConfigurationGroupListEndDropData - | FieldsConfigurationFieldDragData - | FieldsConfigurationFieldListEndDropData; + | FieldsConfigurationFieldDragData; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/fields/types/FieldsConfigurationFieldListEndDropData.ts b/packages/twenty-front/src/modules/page-layout/widgets/fields/types/FieldsConfigurationFieldListEndDropData.ts index f6cfd007bc..ae6972487e 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/fields/types/FieldsConfigurationFieldListEndDropData.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/fields/types/FieldsConfigurationFieldListEndDropData.ts @@ -1,5 +1,3 @@ -// Catches drops below the last field of a list and drops into an empty group. export type FieldsConfigurationFieldListEndDropData = { - type: 'field-list-end'; - groupId: string; + droppableId: string; }; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/fields/types/FieldsConfigurationGroupListEndDropData.ts b/packages/twenty-front/src/modules/page-layout/widgets/fields/types/FieldsConfigurationGroupListEndDropData.ts deleted file mode 100644 index 1dd5974380..0000000000 --- a/packages/twenty-front/src/modules/page-layout/widgets/fields/types/FieldsConfigurationGroupListEndDropData.ts +++ /dev/null @@ -1,4 +0,0 @@ -// Catches drops below the last group to append the dragged group at the end. -export type FieldsConfigurationGroupListEndDropData = { - type: 'group-list-end'; -}; diff --git a/packages/twenty-front/src/modules/ui/layout/draggable-list/components/DraggableItem.tsx b/packages/twenty-front/src/modules/ui/layout/draggable-list/components/DraggableItem.tsx index a46ed4644d..6a874692a9 100644 --- a/packages/twenty-front/src/modules/ui/layout/draggable-list/components/DraggableItem.tsx +++ b/packages/twenty-front/src/modules/ui/layout/draggable-list/components/DraggableItem.tsx @@ -1,9 +1,10 @@ import { useDragDropMonitor } from '@dnd-kit/react'; import { isFunction } from '@sniptt/guards'; -import { type JSX, useContext, useEffect, useState } from 'react'; +import { Fragment, type JSX, useContext, useEffect, useState } from 'react'; import { isDefined } from 'twenty-shared/utils'; import { DraggableListGroupContext } from '@/ui/layout/draggable-list/contexts/DraggableListGroupContext'; +import { DragDropItemDropTarget } from '@/ui/utilities/drag-and-drop/components/DragDropItemDropTarget'; import { DragDropItemSortableCell } from '@/ui/utilities/drag-and-drop/components/DragDropItemSortableCell'; type DraggableItemProps = { @@ -14,6 +15,7 @@ type DraggableItemProps = { | JSX.Element | ((props: { isDragging: boolean }) => JSX.Element); disableDraggingBackground?: boolean; + restrictMovementTo?: 'x' | 'y' | 'none'; }; export const DraggableItem = ({ @@ -22,6 +24,7 @@ export const DraggableItem = ({ index, itemComponent, disableDraggingBackground = false, + restrictMovementTo = 'y', }: DraggableItemProps) => { const draggableListGroupContext = useContext(DraggableListGroupContext); @@ -38,20 +41,17 @@ export const DraggableItem = ({ }, }); - // The list's end drop zone resolves its drop index from this registry, - // since only the rendered items know how many of them there are. + // Items report their presence so the list can size its trailing drop target. useEffect(() => { if (!isDefined(draggableListGroupContext)) { return; } - const itemIndexByDraggableId = - draggableListGroupContext.itemIndexByDraggableId; - - itemIndexByDraggableId.set(draggableId, index); + const { registerItem, unregisterItem } = draggableListGroupContext; + registerItem(draggableId, index); return () => { - itemIndexByDraggableId.delete(draggableId); + unregisterItem(draggableId); }; }, [draggableListGroupContext, draggableId, index]); @@ -62,19 +62,29 @@ export const DraggableItem = ({ const { group } = draggableListGroupContext; return ( - - {isFunction(itemComponent) - ? itemComponent({ isDragging }) - : itemComponent} - + + + + {isFunction(itemComponent) + ? itemComponent({ isDragging }) + : itemComponent} + + ); }; diff --git a/packages/twenty-front/src/modules/ui/layout/draggable-list/components/DraggableList.tsx b/packages/twenty-front/src/modules/ui/layout/draggable-list/components/DraggableList.tsx index 2a3d7cbd74..9bf09e04c8 100644 --- a/packages/twenty-front/src/modules/ui/layout/draggable-list/components/DraggableList.tsx +++ b/packages/twenty-front/src/modules/ui/layout/draggable-list/components/DraggableList.tsx @@ -2,17 +2,21 @@ import { DragDropProvider } from '@dnd-kit/react'; import { styled } from '@linaria/react'; import { useMemo, useState } from 'react'; import { isDefined } from 'twenty-shared/utils'; -import { themeCssVariables } from 'twenty-ui/theme-constants'; import { v4 } from 'uuid'; -import { DRAGGABLE_LIST_END_DROP_INDEX } from '@/ui/layout/draggable-list/constants/DraggableListEndDropIndex'; import { DraggableListGroupContext } from '@/ui/layout/draggable-list/contexts/DraggableListGroupContext'; import { type DraggableListDropResult } from '@/ui/layout/draggable-list/types/DraggableListDropResult'; -import { DragDropItemEndDropZone } from '@/ui/utilities/drag-and-drop/components/DragDropItemEndDropZone'; +import { DragDropItemDropTarget } from '@/ui/utilities/drag-and-drop/components/DragDropItemDropTarget'; import { DND_KIT_PROVIDER_PLUGINS_WITHOUT_DROP_ANIMATION } from '@/ui/utilities/drag-and-drop/constants/DndKitProviderPluginsWithoutDropAnimation'; import { DND_KIT_SENSORS } from '@/ui/utilities/drag-and-drop/constants/DndKitSensors'; +import { + DragDropItemDndContext, + type DragDropItemDndContextValue, +} from '@/ui/utilities/drag-and-drop/context/DragDropItemDndContext'; import { type DragDropProviderDragEndEvent } from '@/ui/utilities/drag-and-drop/types/DragDropProviderDragEndEvent'; +import { type DragDropProviderDragMoveEvent } from '@/ui/utilities/drag-and-drop/types/DragDropProviderDragMoveEvent'; import { getDestinationIndex } from '@/ui/utilities/drag-and-drop/utils/getDestinationIndex'; +import { resolveDropFromPointer } from '@/ui/utilities/drag-and-drop/utils/resolveDropFromPointer'; type DraggableListItemDndData = { droppableId: string; @@ -28,13 +32,6 @@ const StyledDragDropItemsWrapper = styled.div` width: 100%; `; -// Catches drops after the last item; the negative margin cancels its -// footprint so the list keeps its height. -const StyledEndDropZone = styled(DragDropItemEndDropZone)` - height: ${themeCssVariables.spacing[2]}; - margin-bottom: calc(-1 * ${themeCssVariables.spacing[2]}); -`; - export const DraggableList = ({ draggableItems, onDragEnd, @@ -43,45 +40,80 @@ export const DraggableList = ({ // land on outer providers' targets (or the other way around). const [group] = useState(() => v4()); - // A mutable registry rather than render state: items write their index on - // mount so the end drop zone can resolve the append index at drop time. + // Items register their index so the list can place the trailing drop target + // and resolve the append position in the consumers' own index space, which + // may be offset (a non-draggable header can occupy the leading indices). const [itemIndexByDraggableId] = useState(() => new Map()); + const [trailingIndex, setTrailingIndex] = useState(0); + + const [activeDropTargetIndex, setActiveDropTargetIndex] = useState< + number | null + >(null); const groupContextValue = useMemo( - () => ({ group, itemIndexByDraggableId }), + () => ({ + group, + registerItem: (draggableId: string, index: number) => { + itemIndexByDraggableId.set(draggableId, index); + setTrailingIndex(Math.max(...itemIndexByDraggableId.values()) + 1); + }, + unregisterItem: (draggableId: string) => { + itemIndexByDraggableId.delete(draggableId); + setTrailingIndex( + itemIndexByDraggableId.size === 0 + ? 0 + : Math.max(...itemIndexByDraggableId.values()) + 1, + ); + }, + }), [group, itemIndexByDraggableId], ); + const handleDragMove = ( + event: DragDropProviderDragMoveEvent, + ) => { + const resolvedDrop = resolveDropFromPointer({ + target: event.operation.target, + pointer: event.operation.position.current, + defaultOrientation: 'horizontal', + getDroppableItemCount: () => trailingIndex, + }); + + setActiveDropTargetIndex(resolvedDrop?.dropTargetIndex ?? null); + }; + const handleDragEnd = ( event: DragDropProviderDragEndEvent, ) => { + setActiveDropTargetIndex(null); + const source = event.operation.source; const sourceData = source?.data as DraggableListItemDndData | undefined; - const targetData = event.operation.target?.data as - | DraggableListItemDndData - | undefined; if ( event.canceled || !isDefined(source) || - sourceData?.droppableId !== group || - targetData?.droppableId !== group + sourceData?.droppableId !== group ) { return; } - const dropTargetIndex = - targetData.index === DRAGGABLE_LIST_END_DROP_INDEX - ? itemIndexByDraggableId.size - : targetData.index; + const resolvedDrop = resolveDropFromPointer({ + target: event.operation.target, + pointer: event.operation.position.current, + defaultOrientation: 'horizontal', + getDroppableItemCount: () => trailingIndex, + }); + + if (!isDefined(resolvedDrop) || resolvedDrop.droppableId !== group) { + return; + } - // The drop line renders before the hovered item, so a drop inserts the - // dragged item before it; convert that gap index into the final index. const destinationIndex = getDestinationIndex({ - dropTargetIndex, + dropTargetIndex: resolvedDrop.dropTargetIndex, sourceIndex: sourceData.index, - sourceDroppableId: sourceData.droppableId, - destinationDroppableId: targetData.droppableId, + sourceDroppableId: group, + destinationDroppableId: group, }); if (destinationIndex === sourceData.index) { @@ -95,25 +127,32 @@ export const DraggableList = ({ }); }; + const contextValues: DragDropItemDndContextValue = { + activeDropTargetIndex, + activeDroppableId: group, + }; + return ( - - sensors={DND_KIT_SENSORS} - plugins={DND_KIT_PROVIDER_PLUGINS_WITHOUT_DROP_ANIMATION} - onDragEnd={handleDragEnd} - > - - - {draggableItems} - - - - + + + sensors={DND_KIT_SENSORS} + plugins={DND_KIT_PROVIDER_PLUGINS_WITHOUT_DROP_ANIMATION} + onDragMove={handleDragMove} + onDragEnd={handleDragEnd} + > + + + {draggableItems} + + + + + ); }; diff --git a/packages/twenty-front/src/modules/ui/layout/draggable-list/constants/DraggableListEndDropIndex.ts b/packages/twenty-front/src/modules/ui/layout/draggable-list/constants/DraggableListEndDropIndex.ts deleted file mode 100644 index b169553a98..0000000000 --- a/packages/twenty-front/src/modules/ui/layout/draggable-list/constants/DraggableListEndDropIndex.ts +++ /dev/null @@ -1,3 +0,0 @@ -// Sentinel index carried by the list's end drop zone; resolved to the item -// count at drop time since the zone cannot know how many items are rendered. -export const DRAGGABLE_LIST_END_DROP_INDEX = -1; diff --git a/packages/twenty-front/src/modules/ui/layout/draggable-list/contexts/DraggableListGroupContext.ts b/packages/twenty-front/src/modules/ui/layout/draggable-list/contexts/DraggableListGroupContext.ts index 680a806669..070bef0526 100644 --- a/packages/twenty-front/src/modules/ui/layout/draggable-list/contexts/DraggableListGroupContext.ts +++ b/packages/twenty-front/src/modules/ui/layout/draggable-list/contexts/DraggableListGroupContext.ts @@ -2,7 +2,8 @@ import { createContext } from 'react'; export type DraggableListGroupContextValue = { group: string; - itemIndexByDraggableId: Map; + registerItem: (draggableId: string, index: number) => void; + unregisterItem: (draggableId: string) => void; }; export const DraggableListGroupContext = diff --git a/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/components/DragDropItemDropLine.tsx b/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/components/DragDropItemDropLine.tsx deleted file mode 100644 index b5ab2030b1..0000000000 --- a/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/components/DragDropItemDropLine.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { styled } from '@linaria/react'; -import { themeCssVariables } from 'twenty-ui/theme-constants'; - -type DragDropItemDropLineOrientation = 'horizontal' | 'vertical'; - -// Absolutely positioned over the leading edge of its (position: relative) -// parent so activating the drop target does not reflow the list. Horizontal -// straddles the item's top boundary (vertical lists); vertical draws on its -// left (horizontal lists). -const StyledDropLineContainer = styled.div<{ - $orientation: DragDropItemDropLineOrientation; -}>` - bottom: ${({ $orientation }) => ($orientation === 'vertical' ? '0' : 'auto')}; - left: ${({ $orientation }) => - $orientation === 'vertical' - ? `calc(-1 * ${themeCssVariables.spacing[1]})` - : '0'}; - position: absolute; - right: ${({ $orientation }) => ($orientation === 'vertical' ? 'auto' : '0')}; - top: ${({ $orientation }) => ($orientation === 'vertical' ? '0' : '-1px')}; -`; - -const StyledDropLine = styled.div<{ - $orientation: DragDropItemDropLineOrientation; -}>` - background-color: ${themeCssVariables.color.blue}; - border-radius: ${themeCssVariables.border.radius.sm}; - height: ${({ $orientation }) => - $orientation === 'vertical' ? '100%' : '2px'}; - width: ${({ $orientation }) => - $orientation === 'vertical' ? '2px' : '100%'}; -`; - -type DragDropItemDropLineProps = { - orientation?: DragDropItemDropLineOrientation; - className?: string; -}; - -export const DragDropItemDropLine = ({ - orientation = 'horizontal', - className, -}: DragDropItemDropLineProps) => ( - - - -); diff --git a/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/components/DragDropItemDropTarget.tsx b/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/components/DragDropItemDropTarget.tsx index 4b0c52cf76..8dae0d0995 100644 --- a/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/components/DragDropItemDropTarget.tsx +++ b/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/components/DragDropItemDropTarget.tsx @@ -4,13 +4,18 @@ import { isDefined } from 'twenty-shared/utils'; import { themeCssVariables } from 'twenty-ui/theme-constants'; import { DragDropItemDndContext } from '@/ui/utilities/drag-and-drop/context/DragDropItemDndContext'; - -type DragDropItemDropTargetOrientation = 'vertical' | 'horizontal'; +import { type DragDropItemDropTargetOrientation } from '@/ui/utilities/drag-and-drop/types/DragDropItemDropTargetOrientation'; const StyledDropTarget = styled.div<{ $compact?: boolean; $overlay?: boolean; + $seamAligned?: boolean; }>` + // The line normally nudges up into the gap the dragged item opens; seam-aligned + // targets drop the nudge so the line sits exactly on the boundary between two + // bordered cells instead of overlapping the leading one's border. + --drop-line-nudge: ${({ $seamAligned }) => + $seamAligned ? '0px' : themeCssVariables.spacing[1]}; min-height: ${({ $compact }) => $compact ? '0' : themeCssVariables.spacing[2]}; position: ${({ $overlay }) => ($overlay ? 'absolute' : 'relative')}; @@ -49,8 +54,7 @@ const StyledDropTarget = styled.div<{ height: 2px; left: 0; top: 50%; - transform: translateY(calc(-50% - ${themeCssVariables.spacing[1]})) - scaleX(0.7); + transform: translateY(calc(-50% - var(--drop-line-nudge))) scaleX(0.7); width: 100%; } @@ -65,8 +69,7 @@ const StyledDropTarget = styled.div<{ &[data-orientation='horizontal'][data-drag-over='true']::before { opacity: 1; - transform: translateY(calc(-50% - ${themeCssVariables.spacing[1]})) - scaleX(1); + transform: translateY(calc(-50% - var(--drop-line-nudge))) scaleX(1); } &[data-orientation='horizontal'][data-leading='true']::before { @@ -87,6 +90,9 @@ type DragDropItemDropTargetProps = { index: number; orientation?: DragDropItemDropTargetOrientation; overlay?: boolean; + // Centers the line on the seam between two cells; use for bordered items where + // the default gap nudge would overlap the leading cell's border. + seamAligned?: boolean; }; export const DragDropItemDropTarget = ({ @@ -96,6 +102,7 @@ export const DragDropItemDropTarget = ({ index, orientation, overlay = false, + seamAligned = false, }: DragDropItemDropTargetProps) => { const { activeDropTargetIndex, activeDroppableId } = useContext( DragDropItemDndContext, @@ -110,6 +117,7 @@ export const DragDropItemDropTarget = ({ ( + {children} +); diff --git a/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/components/DragDropItemDroppableSlot.tsx b/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/components/DragDropItemDroppableSlot.tsx deleted file mode 100644 index 3f977d471a..0000000000 --- a/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/components/DragDropItemDroppableSlot.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { pointerIntersection } from '@dnd-kit/collision'; -import { useDroppable } from '@dnd-kit/react'; -import { styled } from '@linaria/react'; -import { type ReactNode } from 'react'; - -import { type DragDropItemData } from '@/ui/utilities/drag-and-drop/types/DragDropItemData'; -import { DND_KIT_COLLISION_PRIORITY } from '@/ui/utilities/drag-and-drop/constants/DndKitCollisionPriority'; - -const StyledSlotWrapper = styled.div` - align-self: stretch; - flex: 0 0 2px; - margin-left: -1px; - margin-right: -1px; - min-height: 0; - position: relative; - z-index: 100; -`; - -type DragDropItemDroppableSlotProps = { - children?: ReactNode; - collisionPriority?: number; - disabled?: boolean; - droppableId: string; - index: number; -}; - -export const DragDropItemDroppableSlot = ({ - children, - collisionPriority = DND_KIT_COLLISION_PRIORITY, - disabled = false, - droppableId, - index, -}: DragDropItemDroppableSlotProps) => { - const id = `${droppableId}::${index}`; - const data: DragDropItemData = { droppableId, index }; - - const { ref } = useDroppable({ - id, - disabled, - collisionPriority, - collisionDetector: pointerIntersection, - data, - }); - - return {children}; -}; diff --git a/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/components/DragDropItemEndDropZone.tsx b/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/components/DragDropItemEndDropZone.tsx deleted file mode 100644 index b9a05e064d..0000000000 --- a/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/components/DragDropItemEndDropZone.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import { pointerIntersection } from '@dnd-kit/collision'; -import { useDroppable } from '@dnd-kit/react'; -import { styled } from '@linaria/react'; -import { type ReactNode } from 'react'; - -import { DragDropItemDropLine } from '@/ui/utilities/drag-and-drop/components/DragDropItemDropLine'; - -const StyledEndDropZone = styled.div` - position: relative; -`; - -type DragDropItemEndDropZoneProps = { - id: string; - accept: string; - data: Record; - dropLine?: 'horizontal' | 'vertical'; - className?: string; - children?: ReactNode; -}; - -// Catches drops after the last sortable item of a list, or into an empty -// list, where there is no item cell to target. Style with styled(...) to give -// the zone its layout footprint. -export const DragDropItemEndDropZone = ({ - id, - accept, - data, - dropLine = 'horizontal', - className, - children, -}: DragDropItemEndDropZoneProps) => { - const { ref, isDropTarget } = useDroppable({ - id, - accept, - collisionDetector: pointerIntersection, - data, - }); - - return ( - - {isDropTarget && } - {children} - - ); -}; diff --git a/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/components/DragDropItemSortableCell.tsx b/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/components/DragDropItemSortableCell.tsx index 5ebfb20bf2..c11992aea5 100644 --- a/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/components/DragDropItemSortableCell.tsx +++ b/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/components/DragDropItemSortableCell.tsx @@ -5,12 +5,13 @@ import { import { useSortable } from '@dnd-kit/react/sortable'; import { styled } from '@linaria/react'; import { type ReactNode } from 'react'; +import { isDefined } from 'twenty-shared/utils'; import { themeCssVariables } from 'twenty-ui/theme-constants'; -import { DragDropItemDropLine } from '@/ui/utilities/drag-and-drop/components/DragDropItemDropLine'; import { DND_KIT_PLUGINS_WITHOUT_OPTIMISTIC } from '@/ui/utilities/drag-and-drop/constants/DndKitPluginsWithoutOptimistic'; import { DRAG_SOURCE_OPACITY } from '@/ui/utilities/drag-and-drop/constants/DragSourceOpacity'; import { DragDropItemSortableHandleRefContext } from '@/ui/utilities/drag-and-drop/context/DragDropItemSortableHandleRefContext'; +import { type DragDropItemDropTargetOrientation } from '@/ui/utilities/drag-and-drop/types/DragDropItemDropTargetOrientation'; import { preventNativeDragStart } from '@/ui/utilities/drag-and-drop/utils/preventNativeDragStart'; const SORTABLE_COLLISION_PRIORITY = 3; @@ -66,7 +67,9 @@ type DragDropItemSortableCellProps = { id: string; index: number; restrictMovementTo?: 'x' | 'y' | 'none'; - dropLine?: 'horizontal' | 'vertical' | 'none'; + // Tags the split axis on the sortable's data so a pointer resolver can pick + // the drop boundary per hovered item across lists of mixed orientations. + orientation?: DragDropItemDropTargetOrientation; type?: string; }; @@ -83,38 +86,33 @@ export const DragDropItemSortableCell = ({ id, index, restrictMovementTo = 'none', - dropLine = 'none', + orientation, type, }: DragDropItemSortableCellProps) => { - const { handleRef, ref, isDragging, isDragSource, isDropTarget } = - useSortable({ - id, + const { handleRef, ref, isDragging, isDragSource } = useSortable({ + id, + index, + group, + type, + accept, + collisionPriority: SORTABLE_COLLISION_PRIORITY, + // Sortable metadata stays authoritative over consumer data so drag + // handlers always resolve the cell's real group and position. + data: { + ...data, + droppableId: group, index, - group, - type, - accept, - collisionPriority: SORTABLE_COLLISION_PRIORITY, - // Sortable metadata stays authoritative over consumer data so drag - // handlers always resolve the cell's real group and position. - data: { - ...data, - droppableId: group, - index, - }, - disabled, - transition: hasTransition ? SORTABLE_TRANSITION : null, - plugins: DND_KIT_PLUGINS_WITHOUT_OPTIMISTIC, - modifiers: [ - ...(restrictMovementTo === 'x' ? [RestrictToHorizontalAxis] : []), - ...(restrictMovementTo === 'y' ? [RestrictToVerticalAxis] : []), - ], - feedback: 'clone', - }); - - // The drag source is its own initial drop target; rendering the line on it - // would bake a stale copy into the placeholder clone taken at drag start. - const shouldShowDropLine = - dropLine !== 'none' && isDropTarget && !isDragSource; + ...(isDefined(orientation) ? { orientation } : {}), + }, + disabled, + transition: hasTransition ? SORTABLE_TRANSITION : null, + plugins: DND_KIT_PLUGINS_WITHOUT_OPTIMISTIC, + modifiers: [ + ...(restrictMovementTo === 'x' ? [RestrictToHorizontalAxis] : []), + ...(restrictMovementTo === 'y' ? [RestrictToVerticalAxis] : []), + ], + feedback: 'clone', + }); return ( @@ -126,7 +124,6 @@ export const DragDropItemSortableCell = ({ $isDraggingHighlighted={highlightWhileDragging && isDragging} onDragStart={preventNativeDragStart} > - {shouldShowDropLine && } {children} diff --git a/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/context/DragDropItemDndContext.tsx b/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/context/DragDropItemDndContext.tsx index 48ed92b660..2be06ea14d 100644 --- a/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/context/DragDropItemDndContext.tsx +++ b/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/context/DragDropItemDndContext.tsx @@ -1,6 +1,6 @@ import { createContext } from 'react'; -type DragDropItemDndContextValue = { +export type DragDropItemDndContextValue = { activeDropTargetIndex: number | null; activeDroppableId?: string | null; }; diff --git a/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/types/DragDropItemData.ts b/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/types/DragDropItemData.ts index 7cbfedcccc..4200435422 100644 --- a/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/types/DragDropItemData.ts +++ b/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/types/DragDropItemData.ts @@ -1,4 +1,9 @@ +import { type DragDropItemDropTargetOrientation } from '@/ui/utilities/drag-and-drop/types/DragDropItemDropTargetOrientation'; + export type DragDropItemData = { droppableId: string; index: number; + // Lets a resolver pick the split axis per hovered item, so one provider can + // drive lists of different orientations (e.g. tabs and widgets). + orientation?: DragDropItemDropTargetOrientation; }; diff --git a/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/types/DragDropItemDropTargetOrientation.ts b/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/types/DragDropItemDropTargetOrientation.ts new file mode 100644 index 0000000000..74eed628dc --- /dev/null +++ b/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/types/DragDropItemDropTargetOrientation.ts @@ -0,0 +1 @@ +export type DragDropItemDropTargetOrientation = 'vertical' | 'horizontal'; diff --git a/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/utils/__tests__/resolveDragDropItemDrop.test.ts b/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/utils/__tests__/resolveDragDropItemDrop.test.ts deleted file mode 100644 index b8ccc95811..0000000000 --- a/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/utils/__tests__/resolveDragDropItemDrop.test.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { resolveDragDropItemDrop } from '@/ui/utilities/drag-and-drop/utils/resolveDragDropItemDrop'; - -const createScrollWrapperElement = ({ - left = 0, - scrollLeft = 0, -}: { - left?: number; - scrollLeft?: number; -}) => - ({ - getBoundingClientRect: () => ({ left }) as DOMRect, - scrollLeft, - }) as unknown as HTMLElement; - -const COLUMN_WIDTHS = [100, 100, 100]; - -describe('resolveDragDropItemDrop', () => { - it('should target the column under the pointer before its midpoint', () => { - const result = resolveDragDropItemDrop({ - pointerX: 40, - sourceIndex: 2, - scrollWrapperElement: createScrollWrapperElement({}), - columnWidths: COLUMN_WIDTHS, - }); - - expect(result).toEqual({ - sourceIndex: 2, - dropTargetIndex: 0, - destinationIndex: 0, - }); - }); - - it('should target the next slot once the pointer passes a column midpoint', () => { - const result = resolveDragDropItemDrop({ - pointerX: 120, - sourceIndex: 2, - scrollWrapperElement: createScrollWrapperElement({}), - columnWidths: COLUMN_WIDTHS, - }); - - expect(result.dropTargetIndex).toBe(1); - expect(result.destinationIndex).toBe(1); - }); - - it('should target the trailing slot when the pointer is past every column', () => { - const result = resolveDragDropItemDrop({ - pointerX: 400, - sourceIndex: 0, - scrollWrapperElement: createScrollWrapperElement({}), - columnWidths: COLUMN_WIDTHS, - }); - - expect(result.dropTargetIndex).toBe(COLUMN_WIDTHS.length); - expect(result.destinationIndex).toBe(COLUMN_WIDTHS.length - 1); - }); - - it('should decrement destinationIndex when dropping to the right of the source', () => { - const result = resolveDragDropItemDrop({ - pointerX: 160, - sourceIndex: 0, - scrollWrapperElement: createScrollWrapperElement({}), - columnWidths: COLUMN_WIDTHS, - }); - - expect(result.dropTargetIndex).toBe(2); - expect(result.destinationIndex).toBe(1); - }); - - it('should keep destinationIndex equal to dropTargetIndex when dropping to the left of the source', () => { - const result = resolveDragDropItemDrop({ - pointerX: 120, - sourceIndex: 2, - scrollWrapperElement: createScrollWrapperElement({}), - columnWidths: COLUMN_WIDTHS, - }); - - expect(result.dropTargetIndex).toBe(1); - expect(result.destinationIndex).toBe(1); - }); - - it('should offset the pointer by the scroll position', () => { - const result = resolveDragDropItemDrop({ - pointerX: 40, - sourceIndex: 2, - scrollWrapperElement: createScrollWrapperElement({ scrollLeft: 100 }), - columnWidths: COLUMN_WIDTHS, - }); - - expect(result.dropTargetIndex).toBe(1); - }); - - it('should offset the pointer by the container left', () => { - const result = resolveDragDropItemDrop({ - pointerX: 90, - sourceIndex: 2, - scrollWrapperElement: createScrollWrapperElement({ left: 50 }), - columnWidths: COLUMN_WIDTHS, - }); - - expect(result.dropTargetIndex).toBe(0); - }); - - it('should subtract the leading offset before resolving the column', () => { - const result = resolveDragDropItemDrop({ - pointerX: 140, - sourceIndex: 2, - scrollWrapperElement: createScrollWrapperElement({}), - columnWidths: COLUMN_WIDTHS, - leadingOffset: 100, - }); - - expect(result.dropTargetIndex).toBe(0); - }); -}); diff --git a/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/utils/__tests__/resolveDropFromPointer.test.ts b/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/utils/__tests__/resolveDropFromPointer.test.ts new file mode 100644 index 0000000000..fc6e3738ac --- /dev/null +++ b/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/utils/__tests__/resolveDropFromPointer.test.ts @@ -0,0 +1,134 @@ +import { SortableDroppable } from '@dnd-kit/dom/sortable'; + +import { type DragDropItemData } from '@/ui/utilities/drag-and-drop/types/DragDropItemData'; +import { type DragDropProviderDropTarget } from '@/ui/utilities/drag-and-drop/types/DragDropProviderDropTarget'; +import { resolveDropFromPointer } from '@/ui/utilities/drag-and-drop/utils/resolveDropFromPointer'; + +type DropTarget = DragDropProviderDropTarget; + +// Instances are built on the real SortableDroppable prototype so the +// resolver's isSortable discrimination stays exercised. +const createSortableTarget = ({ + data, + boundingRectangle, +}: { + data: DragDropItemData; + boundingRectangle: { + left: number; + top: number; + width: number; + height: number; + }; +}): DropTarget => { + const target = Object.create(SortableDroppable.prototype); + + Object.defineProperty(target, 'data', { value: data }); + Object.defineProperty(target, 'shape', { value: { boundingRectangle } }); + + return target as DropTarget; +}; + +const createDroppableTarget = ({ + id, + data, +}: { + id: string; + data?: Partial; +}): DropTarget => ({ id, data }) as DropTarget; + +const CARD_RECTANGLE = { left: 100, top: 200, width: 80, height: 40 }; + +describe('resolveDropFromPointer', () => { + it('should return null without a target', () => { + const result = resolveDropFromPointer({ + target: null, + pointer: { x: 0, y: 0 }, + getDroppableItemCount: () => 0, + }); + + expect(result).toBeNull(); + }); + + it('should target the hovered item before its midpoint on the horizontal default axis', () => { + const result = resolveDropFromPointer({ + target: createSortableTarget({ + data: { droppableId: 'list', index: 2 }, + boundingRectangle: CARD_RECTANGLE, + }), + pointer: { x: 0, y: 210 }, + defaultOrientation: 'horizontal', + getDroppableItemCount: () => 5, + }); + + expect(result).toEqual({ droppableId: 'list', dropTargetIndex: 2 }); + }); + + it('should target the next slot past the hovered item midpoint on the horizontal default axis', () => { + const result = resolveDropFromPointer({ + target: createSortableTarget({ + data: { droppableId: 'list', index: 2 }, + boundingRectangle: CARD_RECTANGLE, + }), + pointer: { x: 0, y: 230 }, + defaultOrientation: 'horizontal', + getDroppableItemCount: () => 5, + }); + + expect(result).toEqual({ droppableId: 'list', dropTargetIndex: 3 }); + }); + + it('should split on the x axis for a vertical orientation', () => { + const result = resolveDropFromPointer({ + target: createSortableTarget({ + data: { droppableId: 'columns', index: 1 }, + boundingRectangle: CARD_RECTANGLE, + }), + pointer: { x: 170, y: 0 }, + defaultOrientation: 'vertical', + getDroppableItemCount: () => 5, + }); + + expect(result).toEqual({ droppableId: 'columns', dropTargetIndex: 2 }); + }); + + it('should let the hovered item orientation override the default orientation', () => { + const result = resolveDropFromPointer({ + target: createSortableTarget({ + data: { droppableId: 'tabs', index: 1, orientation: 'vertical' }, + boundingRectangle: CARD_RECTANGLE, + }), + // Before the item's x midpoint but past its y midpoint: the item's own + // vertical axis must win over the horizontal default. + pointer: { x: 110, y: 230 }, + defaultOrientation: 'horizontal', + getDroppableItemCount: () => 5, + }); + + expect(result).toEqual({ droppableId: 'tabs', dropTargetIndex: 1 }); + }); + + it('should append into a plain droppable using its tagged droppableId', () => { + const result = resolveDropFromPointer({ + target: createDroppableTarget({ + id: 'group-end-zone', + data: { droppableId: 'group-1' }, + }), + pointer: { x: 0, y: 0 }, + getDroppableItemCount: (droppableId) => + droppableId === 'group-1' ? 4 : 0, + }); + + expect(result).toEqual({ droppableId: 'group-1', dropTargetIndex: 4 }); + }); + + it('should fall back to the droppable id when the droppable tags no data', () => { + const result = resolveDropFromPointer({ + target: createDroppableTarget({ id: 'group-2' }), + pointer: { x: 0, y: 0 }, + getDroppableItemCount: (droppableId) => + droppableId === 'group-2' ? 3 : 0, + }); + + expect(result).toEqual({ droppableId: 'group-2', dropTargetIndex: 3 }); + }); +}); diff --git a/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/utils/__tests__/resolveDropTarget.test.ts b/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/utils/__tests__/resolveDropTarget.test.ts deleted file mode 100644 index 1f2bcd39d1..0000000000 --- a/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/utils/__tests__/resolveDropTarget.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { resolveDropTarget } from '@/ui/utilities/drag-and-drop/utils/resolveDropTarget'; - -const cardShape = { boundingRectangle: { top: 100, height: 40 } }; - -describe('resolveDropTarget', () => { - it('should target the card position when the pointer is above its midpoint', () => { - const result = resolveDropTarget({ - pointerY: 110, - cardPosition: 2, - cardShape, - }); - - expect(result.dropTargetIndex).toBe(2); - }); - - it('should target the slot after the card when the pointer is below its midpoint', () => { - const result = resolveDropTarget({ - pointerY: 130, - cardPosition: 2, - cardShape, - }); - - expect(result.dropTargetIndex).toBe(3); - }); - - it('should target the slot after the card when the pointer is exactly on the midpoint', () => { - const result = resolveDropTarget({ - pointerY: 120, - cardPosition: 2, - cardShape, - }); - - expect(result.dropTargetIndex).toBe(3); - }); - - it('should target the card position when the pointer is above the card top', () => { - const result = resolveDropTarget({ - pointerY: 80, - cardPosition: 2, - cardShape, - }); - - expect(result.dropTargetIndex).toBe(2); - }); - - it('should target the slot after the card when the pointer is below the card bottom', () => { - const result = resolveDropTarget({ - pointerY: 180, - cardPosition: 2, - cardShape, - }); - - expect(result.dropTargetIndex).toBe(3); - }); -}); diff --git a/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/utils/resolveDragDropItemDrop.ts b/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/utils/resolveDragDropItemDrop.ts deleted file mode 100644 index c7b0e30468..0000000000 --- a/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/utils/resolveDragDropItemDrop.ts +++ /dev/null @@ -1,67 +0,0 @@ -type ResolveDragDropItemDropArgs = { - pointerX: number; - sourceIndex: number; - scrollWrapperElement: HTMLElement; - columnWidths: number[]; - leadingOffset?: number; -}; - -type ResolvedDragDropItemDrop = { - sourceIndex: number; - dropTargetIndex: number; - destinationIndex: number; -}; - -const getDestinationIndexFromDropTargetIndex = ({ - sourceIndex, - dropTargetIndex, -}: { - sourceIndex: number; - dropTargetIndex: number; -}) => (dropTargetIndex > sourceIndex ? dropTargetIndex - 1 : dropTargetIndex); - -export const resolveDragDropItemDrop = ({ - pointerX, - sourceIndex, - scrollWrapperElement, - columnWidths, - leadingOffset = 0, -}: ResolveDragDropItemDropArgs): ResolvedDragDropItemDrop => { - const scrollContainerRect = scrollWrapperElement.getBoundingClientRect(); - - const contentX = - pointerX - - scrollContainerRect.left + - scrollWrapperElement.scrollLeft - - leadingOffset; - - let left = 0; - - for (const [index, columnWidth] of columnWidths.entries()) { - const midpoint = left + columnWidth / 2; - - if (contentX < midpoint) { - return { - sourceIndex, - dropTargetIndex: index, - destinationIndex: getDestinationIndexFromDropTargetIndex({ - sourceIndex, - dropTargetIndex: index, - }), - }; - } - - left += columnWidth; - } - - const dropTargetIndex = columnWidths.length; - - return { - sourceIndex, - dropTargetIndex, - destinationIndex: getDestinationIndexFromDropTargetIndex({ - sourceIndex, - dropTargetIndex, - }), - }; -}; diff --git a/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/utils/resolveDropFromPointer.ts b/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/utils/resolveDropFromPointer.ts new file mode 100644 index 0000000000..7d7575d0f0 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/utils/resolveDropFromPointer.ts @@ -0,0 +1,61 @@ +import { isSortable } from '@dnd-kit/react/sortable'; +import { isDefined } from 'twenty-shared/utils'; + +import { type DragDropItemData } from '@/ui/utilities/drag-and-drop/types/DragDropItemData'; +import { type DragDropItemDropTargetOrientation } from '@/ui/utilities/drag-and-drop/types/DragDropItemDropTargetOrientation'; +import { type DragDropProviderDropTarget } from '@/ui/utilities/drag-and-drop/types/DragDropProviderDropTarget'; + +type DropTarget = DragDropProviderDropTarget; + +export type ResolvedDrop = { + droppableId: string; + dropTargetIndex: number; +}; + +export const resolveDropFromPointer = ({ + target, + pointer, + defaultOrientation, + getDroppableItemCount, +}: { + target: DropTarget; + pointer: { x: number; y: number }; + defaultOrientation?: DragDropItemDropTargetOrientation; + getDroppableItemCount: (droppableId: string) => number; +}): ResolvedDrop | null => { + if (!isDefined(target)) { + return null; + } + + const targetData = target.data as DragDropItemData | undefined; + + if (isSortable(target)) { + const targetShape = target.shape; + + if (!isDefined(targetData) || !isDefined(targetShape)) { + return null; + } + + // The hovered item's own orientation wins, so a single provider can mix + // lists of different axes; the default covers uniform single-axis lists. + const orientation = targetData.orientation ?? defaultOrientation; + const splitsOnX = orientation === 'vertical'; + + const { left, top, width, height } = targetShape.boundingRectangle; + const targetMidpoint = splitsOnX ? left + width / 2 : top + height / 2; + const pointerMainAxis = splitsOnX ? pointer.x : pointer.y; + + const dropTargetIndex = + pointerMainAxis < targetMidpoint + ? targetData.index + : targetData.index + 1; + + return { droppableId: targetData.droppableId, dropTargetIndex }; + } + + // Dropped over an empty droppable or the empty space past the last item. A + // zone may tag a logical droppableId in its data; fall back to the raw id. + const droppableId = targetData?.droppableId ?? String(target.id); + + return { droppableId, dropTargetIndex: getDroppableItemCount(droppableId) }; +}; diff --git a/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/utils/resolveDropFromPointerY.ts b/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/utils/resolveDropFromPointerY.ts deleted file mode 100644 index cc7c16238d..0000000000 --- a/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/utils/resolveDropFromPointerY.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { isSortable } from '@dnd-kit/react/sortable'; -import { isDefined } from 'twenty-shared/utils'; - -import { type DragDropItemData } from '@/ui/utilities/drag-and-drop/types/DragDropItemData'; -import { type DragDropProviderDropTarget } from '@/ui/utilities/drag-and-drop/types/DragDropProviderDropTarget'; -import { resolveDropTarget } from '@/ui/utilities/drag-and-drop/utils/resolveDropTarget'; - -type DropTarget = DragDropProviderDropTarget; - -export type ResolvedDrop = { - droppableId: string; - dropTargetIndex: number; -}; - -export const resolveDropFromPointerY = ({ - target, - pointerY, - getDroppableItemCount, -}: { - target: DropTarget; - pointerY: number; - getDroppableItemCount: (droppableId: string) => number; -}): ResolvedDrop | null => { - if (!isDefined(target)) { - return null; - } - - if (isSortable(target)) { - const targetData = target.data as DragDropItemData | undefined; - const cardShape = target.shape; - - if (!isDefined(targetData) || !isDefined(cardShape)) { - return null; - } - - const { dropTargetIndex } = resolveDropTarget({ - pointerY, - cardPosition: targetData.index, - cardShape, - }); - - return { droppableId: targetData.droppableId, dropTargetIndex }; - } - - // Dropped over an empty droppable or the empty space below the cards - const droppableId = String(target.id); - - return { droppableId, dropTargetIndex: getDroppableItemCount(droppableId) }; -}; diff --git a/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/utils/resolveDropTarget.ts b/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/utils/resolveDropTarget.ts deleted file mode 100644 index 4c5d6c9569..0000000000 --- a/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/utils/resolveDropTarget.ts +++ /dev/null @@ -1,26 +0,0 @@ -type DropTargetShape = { - boundingRectangle: { - top: number; - height: number; - }; -}; - -export const resolveDropTarget = ({ - pointerY, - cardPosition, - cardShape, -}: { - pointerY: number; - cardPosition: number; - cardShape: DropTargetShape; -}): { - dropTargetIndex: number; -} => { - const { top, height } = cardShape.boundingRectangle; - const cardMidpointY = top + height / 2; - - const dropTargetIndex = - pointerY < cardMidpointY ? cardPosition : cardPosition + 1; - - return { dropTargetIndex }; -}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/form-action/components/WorkflowEditActionFormBuilder.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/form-action/components/WorkflowEditActionFormBuilder.tsx index b9d8336357..e16c9a65db 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/form-action/components/WorkflowEditActionFormBuilder.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/form-action/components/WorkflowEditActionFormBuilder.tsx @@ -5,6 +5,7 @@ import { FormFieldPlaceholder } from '@/object-record/record-field/ui/form-types import { InputLabel } from '@/ui/input/components/InputLabel'; import { DraggableItem } from '@/ui/layout/draggable-list/components/DraggableItem'; import { DraggableList } from '@/ui/layout/draggable-list/components/DraggableList'; +import { DragDropItemSortableHandle } from '@/ui/utilities/drag-and-drop/components/DragDropItemSortableHandle'; import { type DraggableListDropResult } from '@/ui/layout/draggable-list/types/DraggableListDropResult'; import { type WorkflowFormAction, @@ -298,10 +299,12 @@ export const WorkflowEditActionFormBuilder = ({ {showButtons && ( - + + + )}