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 {
@@ -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 (
<DragDropItemSortableCell
key={tab.id}
id={tab.id}
index={visibleTabCount + index}
group={PAGE_LAYOUT_TAB_LIST_DROPPABLE_IDS.OVERFLOW_TABS}
data={tabDragData}
type={PAGE_LAYOUT_TAB_DND_TYPE}
accept={PAGE_LAYOUT_TAB_DND_TYPE}
disabled={disabled}
hasTransition={false}
dropLine="horizontal"
>
<StyledOverflowMenuItemWrapper>
<PageLayoutTabMenuItemSelectAvatar
tab={tab}
selected={tab.id === activeTabId}
onClick={() => handleTabSelect(tab.id)}
disabled={disabled}
showEditButton={shouldShowEditButton}
onEditClick={handleEditClick}
/>
</StyledOverflowMenuItemWrapper>
</DragDropItemSortableCell>
<Fragment key={tab.id}>
<DragDropItemDropTarget
index={visibleTabCount + index}
droppableId={
PAGE_LAYOUT_TAB_LIST_DROPPABLE_IDS.OVERFLOW_TABS
}
orientation="horizontal"
compact
/>
<DragDropItemSortableCell
id={tab.id}
index={visibleTabCount + index}
group={PAGE_LAYOUT_TAB_LIST_DROPPABLE_IDS.OVERFLOW_TABS}
data={tabDragData}
type={PAGE_LAYOUT_TAB_DND_TYPE}
accept={PAGE_LAYOUT_TAB_DND_TYPE}
disabled={disabled}
hasTransition={false}
orientation="horizontal"
>
<StyledOverflowMenuItemWrapper>
<PageLayoutTabMenuItemSelectAvatar
tab={tab}
selected={tab.id === activeTabId}
onClick={() => handleTabSelect(tab.id)}
disabled={disabled}
showEditButton={shouldShowEditButton}
onEditClick={handleEditClick}
/>
</StyledOverflowMenuItemWrapper>
</DragDropItemSortableCell>
</Fragment>
);
})}
<StyledOverflowEndDropZone
id={`${PAGE_LAYOUT_TAB_LIST_DROPPABLE_IDS.OVERFLOW_TABS}-end`}
accept={PAGE_LAYOUT_TAB_DND_TYPE}
data={OVERFLOW_END_DROP_DATA}
<DragDropItemDropTarget
index={visibleTabCount + hiddenTabs.length}
droppableId={PAGE_LAYOUT_TAB_LIST_DROPPABLE_IDS.OVERFLOW_TABS}
orientation="horizontal"
compact
/>
</DropdownMenuItemsContainer>
</DropdownContent>
@@ -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"
>
<StyledTabContainer
onClick={onSelect}
@@ -6,10 +6,7 @@ import { type SingleTabProps } from '@/ui/layout/tab-list/types/SingleTabProps';
import { PAGE_LAYOUT_TAB_LIST_DROPPABLE_IDS } from '@/page-layout/components/PageLayoutTabListDroppableIds';
import { PageLayoutTabListReorderableTab } from '@/page-layout/components/PageLayoutTabListReorderableTab';
import { PAGE_LAYOUT_TAB_DND_TYPE } from '@/page-layout/constants/PageLayoutTabDndType';
import { PAGE_LAYOUT_TAB_LIST_END_DROP_ZONE_WIDTH } from '@/page-layout/constants/PageLayoutTabListEndDropZoneWidth';
import { type PageLayoutTabListEndDropData } from '@/page-layout/types/PageLayoutTabListEndDropData';
import { DragDropItemEndDropZone } from '@/ui/utilities/drag-and-drop/components/DragDropItemEndDropZone';
import { DragDropItemDropTarget } from '@/ui/utilities/drag-and-drop/components/DragDropItemDropTarget';
type PageLayoutTabListVisibleTabsProps = {
visibleTabs: SingleTabProps[];
@@ -35,12 +32,14 @@ const StyledTabContainer = styled.div`
}
`;
// Catches drops after the last visible tab; inserting before the first hidden
// tab keeps the dropped tab visible instead of sending it to the overflow.
// Its width is reserved by PageLayoutTabList's container measurement.
const StyledEndDropZone = styled(DragDropItemEndDropZone)`
align-self: stretch;
flex: 0 0 ${PAGE_LAYOUT_TAB_LIST_END_DROP_ZONE_WIDTH}px;
const StyledTabSlot = styled.div`
display: flex;
`;
const StyledLeadingDropTarget = styled.div`
flex: 0 0 2px;
margin-left: -1px;
margin-right: -1px;
`;
export const PageLayoutTabListVisibleTabs = ({
@@ -56,31 +55,40 @@ export const PageLayoutTabListVisibleTabs = ({
firstHiddenTabId,
}: PageLayoutTabListVisibleTabsProps) => {
if (canReorder) {
const endDropData: PageLayoutTabListEndDropData = {
type: 'tab-list-end',
beforeTabId: firstHiddenTabId,
};
const shownTabs = visibleTabs.slice(0, visibleTabCount);
return (
<StyledTabContainer>
{visibleTabs.slice(0, visibleTabCount).map((tab, index) => (
<PageLayoutTabListReorderableTab
key={tab.id}
tab={tab}
index={index}
group={PAGE_LAYOUT_TAB_LIST_DROPPABLE_IDS.VISIBLE_TABS}
isActive={tab.id === activeTabId}
disabled={tab.disabled ?? loading}
isWidgetDropTarget={widgetDropTargetTabIds.has(tab.id)}
onSelect={() => onSelectTab(tab.id)}
/>
{shownTabs.map((tab, index) => (
<StyledTabSlot key={tab.id}>
<StyledLeadingDropTarget>
<DragDropItemDropTarget
index={index}
droppableId={PAGE_LAYOUT_TAB_LIST_DROPPABLE_IDS.VISIBLE_TABS}
orientation="vertical"
compact
/>
</StyledLeadingDropTarget>
<PageLayoutTabListReorderableTab
tab={tab}
index={index}
group={PAGE_LAYOUT_TAB_LIST_DROPPABLE_IDS.VISIBLE_TABS}
nextTabId={shownTabs[index + 1]?.id ?? firstHiddenTabId}
isActive={tab.id === activeTabId}
disabled={tab.disabled ?? loading}
isWidgetDropTarget={widgetDropTargetTabIds.has(tab.id)}
onSelect={() => onSelectTab(tab.id)}
/>
</StyledTabSlot>
))}
<StyledEndDropZone
id={`${PAGE_LAYOUT_TAB_LIST_DROPPABLE_IDS.VISIBLE_TABS}-end`}
accept={PAGE_LAYOUT_TAB_DND_TYPE}
data={endDropData}
dropLine="vertical"
/>
<StyledLeadingDropTarget>
<DragDropItemDropTarget
index={visibleTabCount}
droppableId={PAGE_LAYOUT_TAB_LIST_DROPPABLE_IDS.VISIBLE_TABS}
orientation="vertical"
compact
/>
</StyledLeadingDropTarget>
</StyledTabContainer>
);
}
@@ -8,8 +8,10 @@ import { PAGE_LAYOUT_WIDGET_DND_TYPE } from '@/page-layout/constants/PageLayoutW
import { WidgetRenderer } from '@/page-layout/widgets/components/WidgetRenderer';
import { useIsInPinnedTab } from '@/page-layout/widgets/hooks/useIsInPinnedTab';
import { useLayoutRenderingContext } from '@/ui/layout/contexts/LayoutRenderingContext';
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 { 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';
@@ -32,12 +34,18 @@ const StyledVerticalListContainer = styled.div<{
: themeCssVariables.spacing[2]};
`;
const StyledEndDropZone = styled(DragDropItemEndDropZone)`
const StyledDropTarget = styled.div`
display: flex;
flex: 1;
flex-direction: column;
gap: ${themeCssVariables.spacing[4]};
min-height: ${themeCssVariables.spacing[6]};
position: relative;
`;
const StyledWidgetSlot = styled.div`
display: flex;
flex-direction: column;
`;
type PageLayoutVerticalListEditorProps = {
@@ -64,8 +72,16 @@ export const PageLayoutVerticalListEditor = ({
const endDropData: PageLayoutWidgetListDropData = {
type: 'widget-list',
tabId,
itemCount: widgets.length,
};
const { ref: endDropZoneRef } = useDroppable({
id: `page-layout-widget-list-${tabId}`,
accept: PAGE_LAYOUT_WIDGET_DND_TYPE,
collisionDetector: pointerIntersection,
data: endDropData,
});
return (
<StyledVerticalListContainer
variant={variant}
@@ -80,29 +96,38 @@ export const PageLayoutVerticalListEditor = ({
};
return (
<DragDropItemSortableCell
key={widget.id}
id={widget.id}
index={index}
group={tabId}
data={widgetDragData}
type={PAGE_LAYOUT_WIDGET_DND_TYPE}
accept={PAGE_LAYOUT_WIDGET_DND_TYPE}
hasTransition={false}
highlightWhileDragging={true}
dropLine="horizontal"
>
<WidgetRenderer widget={widget} />
</DragDropItemSortableCell>
<StyledWidgetSlot key={widget.id}>
<DragDropItemDropTarget
index={index}
droppableId={tabId}
orientation="horizontal"
compact
/>
<DragDropItemSortableCell
id={widget.id}
index={index}
group={tabId}
data={widgetDragData}
type={PAGE_LAYOUT_WIDGET_DND_TYPE}
accept={PAGE_LAYOUT_WIDGET_DND_TYPE}
hasTransition={false}
highlightWhileDragging={true}
orientation="horizontal"
>
<WidgetRenderer widget={widget} />
</DragDropItemSortableCell>
</StyledWidgetSlot>
);
})}
<StyledEndDropZone
id={`page-layout-widget-list-${tabId}`}
accept={PAGE_LAYOUT_WIDGET_DND_TYPE}
data={endDropData}
>
<StyledDropTarget ref={endDropZoneRef}>
<DragDropItemDropTarget
index={widgets.length}
droppableId={tabId}
orientation="horizontal"
compact
/>
{trailingElement}
</StyledEndDropZone>
</StyledDropTarget>
</StyledVerticalListContainer>
);
};
@@ -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 (
<StyledDropTarget
ref={ref}
isActive={isDropTarget || pageLayoutGridDragHoveredTabId === tabId}
isActive={isActive}
data-widget-hover={isActive || undefined}
{...{ [PAGE_LAYOUT_TAB_DROP_TARGET_DATA_ATTRIBUTE]: tabId }}
>
{children}
@@ -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 (
<DragDropProvider<PageLayoutWidgetDndData>
sensors={DND_KIT_SENSORS}
plugins={DND_KIT_PROVIDER_PLUGINS_WITHOUT_DROP_ANIMATION}
onDragStart={handlers.onDragStart}
onDragEnd={handlers.onDragEnd}
>
{children}
</DragDropProvider>
<DragDropItemDndContext.Provider value={contextValues}>
<DragDropProvider<PageLayoutWidgetDndData>
sensors={DND_KIT_SENSORS}
plugins={DND_KIT_PROVIDER_PLUGINS_WITHOUT_DROP_ANIMATION}
onDragStart={handlers.onDragStart}
onDragMove={handlers.onDragMove}
onDragEnd={handlers.onDragEnd}
>
{children}
</DragDropProvider>
</DragDropItemDndContext.Provider>
);
};
@@ -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;
@@ -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<PageLayoutWidgetDndData>;
type DragMoveEvent = DragDropProviderDragMoveEvent<PageLayoutWidgetDndData>;
type DragEndEvent = DragDropProviderDragEndEvent<PageLayoutWidgetDndData>;
export const usePageLayoutWidgetDragAndDrop = (
@@ -39,17 +44,68 @@ export const usePageLayoutWidgetDragAndDrop = (
pageLayoutId,
);
const [activeDropTargetIndex, setActiveDropTargetIndex] = useState<
number | null
>(null);
const [activeDroppableId, setActiveDroppableId] = useState<string | null>(
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 } };
};
@@ -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;
};
@@ -1,5 +0,0 @@
// beforeTabId null means append after the last tab.
export type PageLayoutTabListEndDropData = {
type: 'tab-list-end';
beforeTabId: string | null;
};
@@ -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;
@@ -1,4 +1,5 @@
export type PageLayoutWidgetListDropData = {
type: 'widget-list';
tabId: string;
itemCount: number;
};
@@ -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<PageLayoutWidgetDndData>;
// 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;
};
@@ -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 (
<DragDropProvider<FieldsConfigurationDndData>
sensors={DND_KIT_SENSORS}
plugins={DND_KIT_PROVIDER_PLUGINS_WITHOUT_DROP_ANIMATION}
onDragStart={handlers.onDragStart}
onDragEnd={handlers.onDragEnd}
>
<StyledGroupsDroppable>
{sortedGroups.map((group, index) => {
const groupDragData: FieldsConfigurationGroupDragData = {
type: 'group',
groupId: group.id,
index,
};
<DragDropItemDndContext.Provider value={contextValues}>
<DragDropProvider<FieldsConfigurationDndData>
sensors={DND_KIT_SENSORS}
plugins={DND_KIT_PROVIDER_PLUGINS_WITHOUT_DROP_ANIMATION}
onDragStart={handlers.onDragStart}
onDragMove={handlers.onDragMove}
onDragEnd={handlers.onDragEnd}
>
<StyledGroupsDroppable>
{sortedGroups.map((group, index) => {
const groupDragData: FieldsConfigurationGroupDragData = {
type: 'group',
groupId: group.id,
index,
};
return (
<DragDropItemSortableCell
key={group.id}
id={group.id}
index={index}
group={FIELDS_CONFIGURATION_GROUPS_DROPPABLE_ID}
data={groupDragData}
type={FIELDS_CONFIGURATION_GROUP_DND_TYPE}
accept={FIELDS_CONFIGURATION_GROUP_DND_TYPE}
hasTransition={false}
dropLine="horizontal"
>
<FieldsConfigurationGroupEditor
group={group}
objectMetadataItem={objectMetadataItem}
isDragging={draggingGroupId === group.id}
onAddGroup={() => handleAddGroup({ afterGroupId: group.id })}
onToggleFieldVisibility={(fieldMetadataId) =>
toggleFieldVisibility(group.id, fieldMetadataId)
}
onRenameGroup={handleRenameGroup}
onDeleteGroup={handleDeleteGroup}
renamingGroupValue={renamingGroupValue}
onRenamingGroupValueChange={setRenamingGroupValue}
onStartRename={handleStartRename}
/>
</DragDropItemSortableCell>
);
})}
return (
<Fragment key={group.id}>
<DragDropItemDropTarget
index={index}
droppableId={FIELDS_CONFIGURATION_GROUPS_DROPPABLE_ID}
orientation="horizontal"
compact
seamAligned
/>
<DragDropItemSortableCell
id={group.id}
index={index}
group={FIELDS_CONFIGURATION_GROUPS_DROPPABLE_ID}
data={groupDragData}
type={FIELDS_CONFIGURATION_GROUP_DND_TYPE}
accept={FIELDS_CONFIGURATION_GROUP_DND_TYPE}
hasTransition={false}
orientation="horizontal"
>
<FieldsConfigurationGroupEditor
group={group}
objectMetadataItem={objectMetadataItem}
isDragging={draggingGroupId === group.id}
onAddGroup={() =>
handleAddGroup({ afterGroupId: group.id })
}
onToggleFieldVisibility={(fieldMetadataId) =>
toggleFieldVisibility(group.id, fieldMetadataId)
}
onRenameGroup={handleRenameGroup}
onDeleteGroup={handleDeleteGroup}
renamingGroupValue={renamingGroupValue}
onRenamingGroupValueChange={setRenamingGroupValue}
onStartRename={handleStartRename}
/>
</DragDropItemSortableCell>
</Fragment>
);
})}
<DragDropItemDropTarget
index={sortedGroups.length}
droppableId={FIELDS_CONFIGURATION_GROUPS_DROPPABLE_ID}
orientation="horizontal"
compact
seamAligned
/>
<DragDropItemEndDropZone
id={`${FIELDS_CONFIGURATION_GROUPS_DROPPABLE_ID}-end`}
accept={FIELDS_CONFIGURATION_GROUP_DND_TYPE}
data={GROUPS_END_DROP_DATA}
>
<StyledAddGroupButtonContainer>
<MenuItem
LeftIcon={IconNewSection}
@@ -223,8 +233,8 @@ export const FieldsConfigurationEditor = ({
withIconContainerBackground={false}
/>
</StyledAddGroupButtonContainer>
</DragDropItemEndDropZone>
</StyledGroupsDroppable>
</DragDropProvider>
</StyledGroupsDroppable>
</DragDropProvider>
</DragDropItemDndContext.Provider>
);
};
@@ -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 (
<StyledEmptyGroupDropZone ref={ref} isDropTarget={isDropTarget}>
{children}
</StyledEmptyGroupDropZone>
);
};
@@ -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 = ({
<StyledFieldsDroppable>
{sortedFields.length === 0 ? (
<StyledEmptyGroupDropZone
id={`fields-configuration-group-${group.id}-end`}
accept={FIELDS_CONFIGURATION_FIELD_DND_TYPE}
data={fieldsEndDropData}
>
<FieldsConfigurationEmptyGroupDropZone groupId={group.id}>
{t`Drop fields here`}
</StyledEmptyGroupDropZone>
</FieldsConfigurationEmptyGroupDropZone>
) : (
<>
{sortedFields.map((field, fieldIndex) => {
@@ -203,36 +177,44 @@ export const FieldsConfigurationGroupEditor = ({
};
return (
<DragDropItemSortableCell
key={field.fieldMetadataItem.id}
id={field.fieldMetadataItem.id}
index={fieldIndex}
group={group.id}
data={fieldDragData}
type={FIELDS_CONFIGURATION_FIELD_DND_TYPE}
accept={FIELDS_CONFIGURATION_FIELD_DND_TYPE}
hasTransition={false}
highlightWhileDragging
dropLine="horizontal"
>
<FieldsConfigurationFieldEditor
field={{
fieldMetadataId: field.fieldMetadataItem.id,
position: field.position,
isVisible: field.isVisible,
}}
fieldMetadata={field.fieldMetadataItem}
onToggleVisibility={() => {
onToggleFieldVisibility(field.fieldMetadataItem.id);
}}
<Fragment key={field.fieldMetadataItem.id}>
<DragDropItemDropTarget
index={fieldIndex}
droppableId={group.id}
orientation="horizontal"
compact
/>
</DragDropItemSortableCell>
<DragDropItemSortableCell
id={field.fieldMetadataItem.id}
index={fieldIndex}
group={group.id}
data={fieldDragData}
type={FIELDS_CONFIGURATION_FIELD_DND_TYPE}
accept={FIELDS_CONFIGURATION_FIELD_DND_TYPE}
hasTransition={false}
highlightWhileDragging
orientation="horizontal"
>
<FieldsConfigurationFieldEditor
field={{
fieldMetadataId: field.fieldMetadataItem.id,
position: field.position,
isVisible: field.isVisible,
}}
fieldMetadata={field.fieldMetadataItem}
onToggleVisibility={() => {
onToggleFieldVisibility(field.fieldMetadataItem.id);
}}
/>
</DragDropItemSortableCell>
</Fragment>
);
})}
<StyledFieldsEndDropZone
id={`fields-configuration-group-${group.id}-end`}
accept={FIELDS_CONFIGURATION_FIELD_DND_TYPE}
data={fieldsEndDropData}
<DragDropItemDropTarget
index={sortedFields.length}
droppableId={group.id}
orientation="horizontal"
compact
/>
</>
)}
@@ -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<FieldsConfigurationDndData>
| DragDropProviderDragEndEvent<FieldsConfigurationDndData>,
) =>
resolveDropFromPointer({
target: event.operation.target,
pointer: event.operation.position.current,
defaultOrientation: 'horizontal',
getDroppableItemCount: () => sortedFields.length,
});
const handleDragMove = (
event: DragDropProviderDragMoveEvent<FieldsConfigurationDndData>,
) => {
setActiveDropTargetIndex(resolveDrop(event)?.dropTargetIndex ?? null);
};
const handleDragEnd = (
event: DragDropProviderDragEndEvent<FieldsConfigurationDndData>,
) => {
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 (
<DragDropProvider<FieldsConfigurationDndData>
sensors={DND_KIT_SENSORS}
plugins={DND_KIT_PROVIDER_PLUGINS_WITHOUT_DROP_ANIMATION}
onDragEnd={handleDragEnd}
>
<StyledFieldsDroppable>
{sortedFields.map((field, fieldIndex) => {
const fieldDragData: FieldsConfigurationFieldDragData = {
type: 'field',
groupId: UNGROUPED_FIELDS_DROPPABLE_ID,
index: fieldIndex,
};
<DragDropItemDndContext.Provider value={contextValues}>
<DragDropProvider<FieldsConfigurationDndData>
sensors={DND_KIT_SENSORS}
plugins={DND_KIT_PROVIDER_PLUGINS_WITHOUT_DROP_ANIMATION}
onDragMove={handleDragMove}
onDragEnd={handleDragEnd}
>
<StyledFieldsDroppable>
{sortedFields.map((field, fieldIndex) => {
const fieldDragData: FieldsConfigurationFieldDragData = {
type: 'field',
groupId: UNGROUPED_FIELDS_DROPPABLE_ID,
index: fieldIndex,
};
return (
<DragDropItemSortableCell
key={field.fieldMetadataItem.id}
id={field.fieldMetadataItem.id}
index={fieldIndex}
group={UNGROUPED_FIELDS_DROPPABLE_ID}
data={fieldDragData}
type={FIELDS_CONFIGURATION_FIELD_DND_TYPE}
accept={FIELDS_CONFIGURATION_FIELD_DND_TYPE}
hasTransition={false}
highlightWhileDragging
dropLine="horizontal"
>
<FieldsConfigurationFieldEditor
field={{
fieldMetadataId: field.fieldMetadataItem.id,
position: field.position,
isVisible: field.isVisible,
}}
fieldMetadata={field.fieldMetadataItem}
onToggleVisibility={() => {
onToggleFieldVisibility(field.fieldMetadataItem.id);
}}
/>
</DragDropItemSortableCell>
);
})}
return (
<Fragment key={field.fieldMetadataItem.id}>
<DragDropItemDropTarget
index={fieldIndex}
droppableId={UNGROUPED_FIELDS_DROPPABLE_ID}
orientation="horizontal"
compact
/>
<DragDropItemSortableCell
id={field.fieldMetadataItem.id}
index={fieldIndex}
group={UNGROUPED_FIELDS_DROPPABLE_ID}
data={fieldDragData}
type={FIELDS_CONFIGURATION_FIELD_DND_TYPE}
accept={FIELDS_CONFIGURATION_FIELD_DND_TYPE}
hasTransition={false}
highlightWhileDragging
orientation="horizontal"
>
<FieldsConfigurationFieldEditor
field={{
fieldMetadataId: field.fieldMetadataItem.id,
position: field.position,
isVisible: field.isVisible,
}}
fieldMetadata={field.fieldMetadataItem}
onToggleVisibility={() => {
onToggleFieldVisibility(field.fieldMetadataItem.id);
}}
/>
</DragDropItemSortableCell>
</Fragment>
);
})}
<DragDropItemDropTarget
index={sortedFields.length}
droppableId={UNGROUPED_FIELDS_DROPPABLE_ID}
orientation="horizontal"
compact
/>
<DragDropItemEndDropZone
id={`${UNGROUPED_FIELDS_DROPPABLE_ID}-end`}
accept={FIELDS_CONFIGURATION_FIELD_DND_TYPE}
data={UNGROUPED_END_DROP_DATA}
>
<MenuItem
LeftIcon={IconNewSection}
text={t`Add a Group`}
@@ -145,8 +176,8 @@ export const FieldsConfigurationUngroupedEditor = ({
withIconContainer
withIconContainerBackground={false}
/>
</DragDropItemEndDropZone>
</StyledFieldsDroppable>
</DragDropProvider>
</StyledFieldsDroppable>
</DragDropProvider>
</DragDropItemDndContext.Provider>
);
};
@@ -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<FieldsConfigurationDndData>;
type DragMoveEvent = DragDropProviderDragMoveEvent<FieldsConfigurationDndData>;
type DragEndEvent = DragDropProviderDragEndEvent<FieldsConfigurationDndData>;
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<string | null>(null);
const [activeDropTargetIndex, setActiveDropTargetIndex] = useState<
number | null
>(null);
const [activeDroppableId, setActiveDroppableId] = useState<string | null>(
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 },
};
};
@@ -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;
@@ -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;
};
@@ -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';
};
@@ -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 (
<DragDropItemSortableCell
id={draggableId}
index={index}
group={group}
type={group}
accept={group}
disabled={isDragDisabled}
highlightWhileDragging={!disableDraggingBackground}
dropLine="horizontal"
>
{isFunction(itemComponent)
? itemComponent({ isDragging })
: itemComponent}
</DragDropItemSortableCell>
<Fragment>
<DragDropItemDropTarget
index={index}
droppableId={group}
orientation="horizontal"
compact
seamAligned
/>
<DragDropItemSortableCell
id={draggableId}
index={index}
group={group}
type={group}
accept={group}
disabled={isDragDisabled}
highlightWhileDragging={!disableDraggingBackground}
orientation="horizontal"
restrictMovementTo={restrictMovementTo}
>
{isFunction(itemComponent)
? itemComponent({ isDragging })
: itemComponent}
</DragDropItemSortableCell>
</Fragment>
);
};
@@ -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<string, number>());
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<DraggableListItemDndData>,
) => {
const resolvedDrop = resolveDropFromPointer({
target: event.operation.target,
pointer: event.operation.position.current,
defaultOrientation: 'horizontal',
getDroppableItemCount: () => trailingIndex,
});
setActiveDropTargetIndex(resolvedDrop?.dropTargetIndex ?? null);
};
const handleDragEnd = (
event: DragDropProviderDragEndEvent<DraggableListItemDndData>,
) => {
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 (
<DragDropProvider<DraggableListItemDndData>
sensors={DND_KIT_SENSORS}
plugins={DND_KIT_PROVIDER_PLUGINS_WITHOUT_DROP_ANIMATION}
onDragEnd={handleDragEnd}
>
<DraggableListGroupContext.Provider value={groupContextValue}>
<StyledDragDropItemsWrapper>
{draggableItems}
<StyledEndDropZone
id={`${group}-end-drop-zone`}
accept={group}
data={{
droppableId: group,
index: DRAGGABLE_LIST_END_DROP_INDEX,
}}
/>
</StyledDragDropItemsWrapper>
</DraggableListGroupContext.Provider>
</DragDropProvider>
<DragDropItemDndContext.Provider value={contextValues}>
<DragDropProvider<DraggableListItemDndData>
sensors={DND_KIT_SENSORS}
plugins={DND_KIT_PROVIDER_PLUGINS_WITHOUT_DROP_ANIMATION}
onDragMove={handleDragMove}
onDragEnd={handleDragEnd}
>
<DraggableListGroupContext.Provider value={groupContextValue}>
<StyledDragDropItemsWrapper>
{draggableItems}
<DragDropItemDropTarget
index={trailingIndex}
droppableId={group}
orientation="horizontal"
compact
seamAligned
/>
</StyledDragDropItemsWrapper>
</DraggableListGroupContext.Provider>
</DragDropProvider>
</DragDropItemDndContext.Provider>
);
};
@@ -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;
@@ -2,7 +2,8 @@ import { createContext } from 'react';
export type DraggableListGroupContextValue = {
group: string;
itemIndexByDraggableId: Map<string, number>;
registerItem: (draggableId: string, index: number) => void;
unregisterItem: (draggableId: string) => void;
};
export const DraggableListGroupContext =
@@ -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) => (
<StyledDropLineContainer $orientation={orientation} className={className}>
<StyledDropLine $orientation={orientation} />
</StyledDropLineContainer>
);
@@ -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 = ({
<StyledDropTarget
$compact={compact}
$overlay={overlay}
$seamAligned={seamAligned}
data-orientation={orientation}
data-leading={
orientation === 'horizontal' && index === 0 ? 'true' : undefined
@@ -0,0 +1,24 @@
import { styled } from '@linaria/react';
import { type ReactNode } from 'react';
// Zero-footprint seam between adjacent sortable cells: 2px of indicator space
// pulled 1px into each neighbor so the cells keep their layout.
const StyledDropTargetSlot = styled.div`
align-self: stretch;
flex: 0 0 2px;
margin-left: -1px;
margin-right: -1px;
min-height: 0;
position: relative;
z-index: 100;
`;
type DragDropItemDropTargetSlotProps = {
children: ReactNode;
};
export const DragDropItemDropTargetSlot = ({
children,
}: DragDropItemDropTargetSlotProps) => (
<StyledDropTargetSlot>{children}</StyledDropTargetSlot>
);
@@ -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 <StyledSlotWrapper ref={ref}>{children}</StyledSlotWrapper>;
};
@@ -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<string, unknown>;
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 (
<StyledEndDropZone ref={ref} className={className}>
{isDropTarget && <DragDropItemDropLine orientation={dropLine} />}
{children}
</StyledEndDropZone>
);
};
@@ -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 (
<DragDropItemSortableHandleRefContext.Provider value={handleRef}>
@@ -126,7 +124,6 @@ export const DragDropItemSortableCell = ({
$isDraggingHighlighted={highlightWhileDragging && isDragging}
onDragStart={preventNativeDragStart}
>
{shouldShowDropLine && <DragDropItemDropLine orientation={dropLine} />}
{children}
</StyledSortableRoot>
</DragDropItemSortableHandleRefContext.Provider>
@@ -1,6 +1,6 @@
import { createContext } from 'react';
type DragDropItemDndContextValue = {
export type DragDropItemDndContextValue = {
activeDropTargetIndex: number | null;
activeDroppableId?: string | null;
};
@@ -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;
};
@@ -0,0 +1 @@
export type DragDropItemDropTargetOrientation = 'vertical' | 'horizontal';
@@ -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);
});
});
@@ -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<DragDropItemData>;
// 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<DragDropItemData>;
}): 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 });
});
});
@@ -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);
});
});
@@ -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,
}),
};
};
@@ -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<DragDropItemData>;
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) };
};
@@ -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<DragDropItemData>;
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) };
};
@@ -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 };
};
@@ -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 && (
<StyledGripButtonContainer>
<LightIconButton
Icon={IconGripVertical}
aria-label={t`Reorder field`}
/>
<DragDropItemSortableHandle>
<LightIconButton
Icon={IconGripVertical}
aria-label={t`Reorder field`}
/>
</DragDropItemSortableHandle>
</StyledGripButtonContainer>
)}