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:
committed by
GitHub
parent
6e30405489
commit
17ff17bdec
+5
-11
@@ -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 />
|
||||
|
||||
+49
-60
@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
+7
-5
@@ -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)) {
|
||||
|
||||
+7
-5
@@ -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)) {
|
||||
|
||||
+42
-12
@@ -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>
|
||||
);
|
||||
|
||||
-1
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
+36
-11
@@ -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}
|
||||
|
||||
+120
-72
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
+128
-75
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
+1
-4
@@ -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 />
|
||||
|
||||
+7
-19
@@ -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 />
|
||||
) : (
|
||||
|
||||
+51
-86
@@ -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)]);
|
||||
|
||||
+22
-9
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
+2
-2
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
+4
@@ -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;
|
||||
|
||||
+10
-3
@@ -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) {
|
||||
|
||||
+4
@@ -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 {
|
||||
|
||||
+40
-42
@@ -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>
|
||||
|
||||
+4
-3
@@ -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}
|
||||
|
||||
+39
-31
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
+47
-22
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
+8
-1
@@ -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}
|
||||
|
||||
+13
-9
@@ -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
-1
@@ -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;
|
||||
|
||||
+100
-35
@@ -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;
|
||||
};
|
||||
+71
-61
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
+51
@@ -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>
|
||||
);
|
||||
};
|
||||
+40
-58
@@ -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
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
+102
-71
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
+92
-54
@@ -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
-5
@@ -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
-3
@@ -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;
|
||||
};
|
||||
|
||||
-4
@@ -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';
|
||||
};
|
||||
+32
-22
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
+85
-46
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
-3
@@ -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
-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 =
|
||||
|
||||
-46
@@ -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>
|
||||
);
|
||||
+14
-6
@@ -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
|
||||
|
||||
+24
@@ -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>
|
||||
);
|
||||
-46
@@ -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>;
|
||||
};
|
||||
-45
@@ -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>
|
||||
);
|
||||
};
|
||||
+29
-32
@@ -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
-1
@@ -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;
|
||||
};
|
||||
|
||||
+1
@@ -0,0 +1 @@
|
||||
export type DragDropItemDropTargetOrientation = 'vertical' | 'horizontal';
|
||||
-114
@@ -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);
|
||||
});
|
||||
});
|
||||
+134
@@ -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 });
|
||||
});
|
||||
});
|
||||
-55
@@ -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);
|
||||
});
|
||||
});
|
||||
-67
@@ -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,
|
||||
}),
|
||||
};
|
||||
};
|
||||
+61
@@ -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) };
|
||||
};
|
||||
-49
@@ -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) };
|
||||
};
|
||||
-26
@@ -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 };
|
||||
};
|
||||
+7
-4
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user