Dnd library migration fixes and changes (#23752)

Follow-up to #23211. Fixed issues, and simplified where possible.

The core idea: every sortable list now resolves its drop position the
same way —
"sortable over sortable", comparing the pointer against the hovered
item's
midpoint — instead of each surface owning bespoke droppable slots and
end-drop
zones.

## Refactors
- New `resolveDropFromPointer` handles both axes in one util; items can
tag their
  own `orientation`, so one provider can drive lists of mixed axes.
- Dropped `DragDropItemDroppableSlot` and `DragDropItemDropLine` path.
Record table/board headers, page-layout tabs & widgets, and fields
config all
derive the drop index from the hovered sortable, matching record-board
cards.
- `DragDropItemSortableCell` is now the single sortable primitive, with
drag
  optionally delegated to an explicit `DragDropItemSortableHandle`.
- Removed end-drop constants/types; lists now place a trailing append
target and
  resolve the append position in the consumer's own index space.

## Fixes
- Dragging a row within grouped records threw an error — the drag
overlay now
  resolves the source row's record-group context.
- Multi-select drag counter chip didn't show — drag state was read from
the
  wrong component scope instead of the active `recordIndexId`.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23752?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->

---------

Co-authored-by: Félix Malfait <felix@twenty.com>
This commit is contained in:
Priyanshu Bartwal
2026-08-05 20:21:51 +05:30
committed by GitHub
parent 6e30405489
commit 17ff17bdec
59 changed files with 1543 additions and 1345 deletions
@@ -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 (
<RecordBoardColumnDndKitProvider>
<DragDropItemDroppableSlot
droppableId={RECORD_BOARD_COLUMN_DROPPABLE_ID}
index={0}
>
<DragDropItemDropTargetSlot>
<DragDropItemDropTarget index={0} orientation="vertical" overlay />
</DragDropItemDroppableSlot>
</DragDropItemDropTargetSlot>
{visibleRecordGroupIds.map((recordGroupId, index) => (
<Fragment key={recordGroupId}>
<DragDropItemSortableCell
@@ -42,16 +39,13 @@ export const RecordBoardColumnDnd = () => {
/>
</RecordGroupContext.Provider>
</DragDropItemSortableCell>
<DragDropItemDroppableSlot
droppableId={RECORD_BOARD_COLUMN_DROPPABLE_ID}
index={index + 1}
>
<DragDropItemDropTargetSlot>
<DragDropItemDropTarget
index={index + 1}
orientation="vertical"
overlay
/>
</DragDropItemDroppableSlot>
</DragDropItemDropTargetSlot>
</Fragment>
))}
<RecordBoardAddGroupColumn />
@@ -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<number | null>(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,
});
};
@@ -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)) {
@@ -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)) {
@@ -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 (
<StyledNoRecordGroupContainer>
<RecordTableVirtualizedBodyPlaceholder />
@@ -41,16 +69,18 @@ export const RecordTableNoRecordGroupRows = () => {
/>
);
})}
<DragDropItemEndDropZone
id="record-table-no-record-group-end-drop-zone"
accept={RECORD_TABLE_ROW_DND_TYPE}
data={{
droppableId: RECORD_TABLE_NO_RECORD_GROUP_DROPPABLE_ID,
index: totalNumberOfRecordsToVirtualize,
}}
>
<StyledEndDropZone ref={endDropZoneRef}>
{/* Zero footprint at rest; expands during a row drag so the zone
stays droppable even when the add-new row is hidden. */}
<DragDropItemDropTarget
index={totalNumberOfRecordsToVirtualize}
droppableId={RECORD_TABLE_NO_RECORD_GROUP_DROPPABLE_ID}
orientation="horizontal"
compact={!isDraggingRecord}
seamAligned
/>
<RecordTableNoRecordGroupAddNew />
</DragDropItemEndDropZone>
</StyledEndDropZone>
<RecordTableVirtualizedDebugHelper />
</StyledNoRecordGroupContainer>
);
@@ -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;
};
@@ -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 = () => {
/>
);
})}
<StyledRecordGroupEndDropZone
id={`record-group-end-drop-zone-${currentRecordGroupId}`}
accept={RECORD_TABLE_ROW_DND_TYPE}
data={{
droppableId: currentRecordGroupId,
index: recordIndexRecordIdsByGroup.length,
}}
>
<StyledRecordGroupDropTarget ref={endDropZoneRef}>
{/* Zero footprint at rest; expands during a row drag so the zone
stays droppable even when neither action row below renders. */}
<DragDropItemDropTarget
index={recordIndexRecordIdsByGroup.length}
droppableId={currentRecordGroupId}
orientation="horizontal"
compact={!isDraggingRecord}
seamAligned
/>
<RecordTableRecordGroupSectionLoadMore />
<RecordTableRecordGroupSectionAddNew />
</StyledRecordGroupEndDropZone>
</StyledRecordGroupDropTarget>
<RecordTableAggregateFooter
key={currentRecordGroupId}
currentRecordGroupId={currentRecordGroupId}
@@ -1,6 +1,6 @@
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';
@@ -11,13 +11,18 @@ import { useRecordTableContextOrThrow } from '@/object-record/record-table/conte
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 { totalNumberOfRecordsToVirtualizeComponentState } from '@/object-record/record-table/virtualization/states/totalNumberOfRecordsToVirtualizeComponentState';
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 { useAtomComponentSelectorCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorCallbackState';
import { useAtomComponentStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateCallbackState';
export const RecordTableBodyNoRecordGroupDragDropContextProvider = ({
children,
@@ -32,6 +37,11 @@ export const RecordTableBodyNoRecordGroupDragDropContextProvider = ({
recordTableId,
);
const totalNumberOfRecordsToVirtualize = useAtomComponentStateCallbackState(
totalNumberOfRecordsToVirtualizeComponentState,
recordTableId,
);
const store = useStore();
const { startRecordDrag } = useStartRecordDrag(recordIndexId);
@@ -39,84 +49,122 @@ export const RecordTableBodyNoRecordGroupDragDropContextProvider = ({
const { processTableWithoutGroupRecordDrop } =
useProcessTableWithoutGroupRecordDrop();
const handleDragStart = useCallback(
(event: DragDropProviderDragStartEvent<DragDropItemData>) => {
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<string | null>(
null,
);
const handleDragEnd = useCallback(
(event: DragDropProviderDragEndEvent<DragDropItemData>) => {
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<DragDropItemData>,
) => {
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<DragDropItemData>,
) => {
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<DragDropItemData>,
) => {
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 (
<DragDropProvider<DragDropItemData>
sensors={DND_KIT_SENSORS}
plugins={DND_KIT_PROVIDER_PLUGINS_WITHOUT_DROP_ANIMATION}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
<DragDropItemDndContext.Provider
value={{ activeDropTargetIndex, activeDroppableId }}
>
{children}
<DragOverlay>
{(source) => <RecordTableRowDragOverlayContent source={source} />}
</DragOverlay>
</DragDropProvider>
<DragDropProvider<DragDropItemData>
sensors={DND_KIT_SENSORS}
plugins={DND_KIT_PROVIDER_PLUGINS_WITHOUT_DROP_ANIMATION}
onDragStart={handleDragStart}
onDragMove={handleDragMove}
onDragEnd={handleDragEnd}
>
{children}
<DragOverlay>
{(source) => <RecordTableRowDragOverlayContent source={source} />}
</DragOverlay>
</DragDropProvider>
</DragDropItemDndContext.Provider>
);
};
@@ -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<DragDropItemData>) => {
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<string | null>(
null,
);
const handleDragEnd = useCallback(
(event: DragDropProviderDragEndEvent<DragDropItemData>) => {
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<DragDropItemData>,
) => {
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<DragDropItemData>,
) => {
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<DragDropItemData>,
) => {
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 (
<DragDropProvider<DragDropItemData>
sensors={DND_KIT_SENSORS}
plugins={DND_KIT_PROVIDER_PLUGINS_WITHOUT_DROP_ANIMATION}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
<DragDropItemDndContext.Provider
value={{ activeDropTargetIndex, activeDroppableId }}
>
{children}
<DragOverlay>
{(source) => <RecordTableRowDragOverlayContent source={source} />}
</DragOverlay>
</DragDropProvider>
<DragDropProvider<DragDropItemData>
sensors={DND_KIT_SENSORS}
plugins={DND_KIT_PROVIDER_PLUGINS_WITHOUT_DROP_ANIMATION}
onDragStart={handleDragStart}
onDragMove={handleDragMove}
onDragEnd={handleDragEnd}
>
{children}
<DragOverlay>
{(source) => (
<RecordTableRecordGroupBodyContextProvider>
<RecordTableRowDragOverlayContent source={source} />
</RecordTableRecordGroupBodyContextProvider>
)}
</DragOverlay>
</DragDropProvider>
</DragDropItemDndContext.Provider>
);
};
@@ -38,10 +38,7 @@ export const RecordTableRecordGroupsBody = () => {
<>
<RecordTableBodyRecordGroupDragDropContextProvider>
{visibleRecordGroupIds.map((recordGroupId, index) => (
<RecordTableRecordGroupBodyContextProvider
key={recordGroupId}
recordGroupId={recordGroupId}
>
<RecordTableRecordGroupBodyContextProvider key={recordGroupId}>
<RecordGroupContext.Provider value={{ recordGroupId }}>
<RecordTableBody data-replay-ignore-mutations="true">
<RecordTableRecordGroupSection />
@@ -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 (
<RecordTableHeaderDndKitProvider>
<DragDropItemDroppableSlot
droppableId={RECORD_TABLE_HEADER_DROPPABLE_ID}
index={0}
disabled={isRecordTableColumnHeadersReadOnly}
>
<DragDropItemDropTargetSlot>
<DragDropItemDropTarget index={0} orientation="vertical" compact />
</DragDropItemDroppableSlot>
</DragDropItemDropTargetSlot>
{isDefined(firstScrollableRecordField) && (
<DragDropItemSortableCell
@@ -53,17 +49,13 @@ export const RecordTableHeaderDnd = () => {
{recordFieldsWithoutFirstTwo.map((recordField, index) => (
<React.Fragment key={recordField.fieldMetadataItemId}>
<DragDropItemDroppableSlot
droppableId={RECORD_TABLE_HEADER_DROPPABLE_ID}
index={index + 1}
disabled={isRecordTableColumnHeadersReadOnly}
>
<DragDropItemDropTargetSlot>
<DragDropItemDropTarget
index={index + 1}
orientation="vertical"
compact
/>
</DragDropItemDroppableSlot>
</DragDropItemDropTargetSlot>
<DragDropItemSortableCell
id={recordField.fieldMetadataItemId}
index={index + 1}
@@ -79,17 +71,13 @@ export const RecordTableHeaderDnd = () => {
</DragDropItemSortableCell>
</React.Fragment>
))}
<DragDropItemDroppableSlot
droppableId={RECORD_TABLE_HEADER_DROPPABLE_ID}
index={visibleRecordFields.length - 1}
disabled={isRecordTableColumnHeadersReadOnly}
>
<DragDropItemDropTargetSlot>
<DragDropItemDropTarget
index={visibleRecordFields.length - 1}
orientation="vertical"
compact
/>
</DragDropItemDroppableSlot>
</DragDropItemDropTargetSlot>
{isRecordTableColumnHeadersReadOnly ? (
<RecordTableHeaderEmptyLastColumn />
) : (
@@ -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<number | null>(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)]);
@@ -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 = ({
<DragDropItemSortableHandleRefContext.Provider value={handleRef}>
<RecordTableRowDraggableContextProvider value={{ isDragging: false }}>
{children}
<RecordTableRowMultiDragPreview />
</RecordTableRowDraggableContextProvider>
</DragDropItemSortableHandleRefContext.Provider>
{isDropTarget && !isDragSource && <StyledRowDropLine />}
{!isDragSource && (
<StyledRowDropTargetSlot>
<DragDropItemDropTarget
index={draggableIndex}
droppableId={droppableId}
orientation="horizontal"
compact
seamAligned
/>
</StyledRowDropTargetSlot>
)}
</RecordTableTr>
);
};
@@ -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 = ({
</RecordTableRowDraggableContextProvider>
</RecordTableTr>
</StyledRowClipContainer>
<RecordTableRowMultiDragCounterChip />
<RecordTableRowMultiDragPreview recordId={recordId} />
</StyledRowDragOverlayCSSBridge>
);
};
@@ -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;
@@ -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) {
@@ -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<string>) => {
const { recordIndexId } = useRecordIndexContextOrThrow();
const isRecordIdSecondaryDragMultiple = useAtomComponentFamilyStateValue(
isRecordIdSecondaryDragMultipleComponentFamilyState,
{ recordId: recordId ?? '' },
recordIndexId,
);
return {