[Twenty-Front]: Record board column drag and drop functionality (#22323)

Closes #22321 

- Used `@dnd-kit` library for core drag and drop logic.
- Tried to keep as much similar to #21304 as possible.


https://github.com/user-attachments/assets/bff100cf-d727-4281-a5fa-b010a373f189



<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22323?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: Charles Bochet <charles@twenty.com>
Co-authored-by: Raphaël Bosi <71827178+bosiraphael@users.noreply.github.com>
Co-authored-by: bosiraphael <raphael.bosi@gmail.com>
This commit is contained in:
Priyanshu Bartwal
2026-07-10 19:17:59 +05:30
committed by GitHub
parent ab9e6f30b8
commit 7babc049f5
25 changed files with 680 additions and 305 deletions
@@ -1,12 +1,6 @@
import { RecordBoardAddGroupColumn } from '@/object-record/record-board/components/RecordBoardAddGroupColumn';
import { RecordBoardColumnHeaderWrapper } from '@/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderWrapper';
import { RecordGroupContext } from '@/object-record/record-group/states/context/RecordGroupContext';
import { visibleRecordGroupIdsComponentFamilySelector } from '@/object-record/record-group/states/selectors/visibleRecordGroupIdsComponentFamilySelector';
import { RecordBoardColumnDnd } from '@/object-record/record-board/record-board-column/components/RecordBoardColumnDnd';
import { RecordIndexGroupAggregatesDataLoader } from '@/object-record/record-index/components/RecordIndexGroupAggregatesDataLoader';
import { useAtomComponentFamilySelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentFamilySelectorValue';
import { ViewType } from '@/views/types/ViewType';
import { styled } from '@linaria/react';
import { themeCssVariables } from 'twenty-ui/theme-constants';
const StyledHeaderContainer = styled.div`
display: flex;
@@ -21,32 +15,12 @@ const StyledHeaderContainer = styled.div`
position: sticky;
top: 0;
}
& > *:not(:first-of-type) {
border-left: 1px solid ${themeCssVariables.border.color.light};
}
`;
export const RecordBoardHeader = () => {
const visibleRecordGroupIds = useAtomComponentFamilySelectorValue(
visibleRecordGroupIdsComponentFamilySelector,
ViewType.KANBAN,
);
return (
<StyledHeaderContainer id="record-board-header">
{visibleRecordGroupIds.map((recordGroupId, index) => (
<RecordGroupContext.Provider
key={recordGroupId}
value={{ recordGroupId }}
>
<RecordBoardColumnHeaderWrapper
columnId={recordGroupId}
columnIndex={index}
/>
</RecordGroupContext.Provider>
))}
<RecordBoardAddGroupColumn />
<RecordBoardColumnDnd />
<RecordIndexGroupAggregatesDataLoader />
</StyledHeaderContainer>
);
@@ -0,0 +1,55 @@
import { Fragment } from 'react';
import { RecordBoardAddGroupColumn } from '@/object-record/record-board/components/RecordBoardAddGroupColumn';
import { RecordBoardColumnHeaderWrapper } from '@/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderWrapper';
import { RECORD_BOARD_COLUMN_DROPPABLE_ID } from '@/object-record/record-board/record-board-column/dnd/constants/RecordBoardColumnDroppableId';
import { RecordBoardColumnDndKitProvider } from '@/object-record/record-board/record-board-column/dnd/providers/RecordBoardColumnDndKitProvider';
import { visibleRecordGroupIdsComponentFamilySelector } from '@/object-record/record-group/states/selectors/visibleRecordGroupIdsComponentFamilySelector';
import { RecordGroupContext } from '@/object-record/record-group/states/context/RecordGroupContext';
import { DragDropColumnDropTarget } from '@/ui/utilities/drag-and-drop/components/DragDropColumnDropTarget';
import { DragDropColumnDroppableSlot } from '@/ui/utilities/drag-and-drop/components/DragDropColumnDroppableSlot';
import { DragDropColumnSortableCell } from '@/ui/utilities/drag-and-drop/components/DragDropColumnSortableCell';
import { useAtomComponentFamilySelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentFamilySelectorValue';
import { ViewType } from '@/views/types/ViewType';
export const RecordBoardColumnDnd = () => {
const visibleRecordGroupIds = useAtomComponentFamilySelectorValue(
visibleRecordGroupIdsComponentFamilySelector,
ViewType.KANBAN,
);
return (
<RecordBoardColumnDndKitProvider>
<DragDropColumnDroppableSlot
droppableId={RECORD_BOARD_COLUMN_DROPPABLE_ID}
index={0}
>
<DragDropColumnDropTarget index={0} compact overlay />
</DragDropColumnDroppableSlot>
{visibleRecordGroupIds.map((recordGroupId, index) => (
<Fragment key={recordGroupId}>
<DragDropColumnSortableCell
id={recordGroupId}
index={index}
group={RECORD_BOARD_COLUMN_DROPPABLE_ID}
fill
>
<RecordGroupContext.Provider value={{ recordGroupId }}>
<RecordBoardColumnHeaderWrapper
columnId={recordGroupId}
columnIndex={index}
/>
</RecordGroupContext.Provider>
</DragDropColumnSortableCell>
<DragDropColumnDroppableSlot
droppableId={RECORD_BOARD_COLUMN_DROPPABLE_ID}
index={index + 1}
>
<DragDropColumnDropTarget index={index + 1} compact overlay />
</DragDropColumnDroppableSlot>
</Fragment>
))}
<RecordBoardAddGroupColumn />
</RecordBoardColumnDndKitProvider>
);
};
@@ -6,7 +6,7 @@ import { useObjectPermissionsForObject } from '@/object-record/hooks/useObjectPe
import { RecordBoardContext } from '@/object-record/record-board/contexts/RecordBoardContext';
import { RecordBoardColumnDropdownMenu } from '@/object-record/record-board/record-board-column/components/RecordBoardColumnDropdownMenu';
import { RecordBoardColumnHeaderAggregateDropdown } from '@/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdown';
import { DragDropColumnSortableHandle } from '@/ui/utilities/drag-and-drop/components/DragDropColumnSortableHandle';
import { RECORD_BOARD_COLUMN_WIDTH } from '@/object-record/record-board/constants/RecordBoardColumnWidth';
import { RECORD_BOARD_COLUMN_WIDTH_CSS_VARIABLE_NAME } from '@/object-record/record-board/constants/RecordBoardColumnWidthCssVariableName';
import { RecordBoardColumnResizeHandler } from '@/object-record/record-board/record-board-column/components/RecordBoardColumnResizeHandler';
@@ -19,6 +19,7 @@ import { recordIndexAggregateDisplayValueForGroupValueComponentFamilyState } fro
import { useCreateNewIndexRecord } from '@/object-record/record-table/hooks/useCreateNewIndexRecord';
import { canCreateRecordsForObjectMetadataItem } from '@/object-record/utils/canCreateRecordsForObjectMetadataItem';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { useDisableDragSelectOnPointerDown } from '@/ui/utilities/drag-select/hooks/useDisableDragSelectOnPointerDown';
import { useToggleDropdown } from '@/ui/layout/dropdown/hooks/useToggleDropdown';
import { useAtomComponentFamilyStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentFamilyStateValue';
import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue';
@@ -75,6 +76,10 @@ const StyledColumn = styled.div`
padding: ${themeCssVariables.spacing[2]};
position: relative;
&[data-has-left-border='true'] {
border-left: 1px solid ${themeCssVariables.border.color.light};
}
`;
const StyledTagContainer = styled.div`
@@ -89,7 +94,15 @@ const StyledDropdownContainer = styled.div`
`;
export const RecordBoardColumnHeader = () => {
const { columnDefinition } = useContext(RecordBoardColumnContext);
const { columnDefinition, columnIndex } = useContext(
RecordBoardColumnContext,
);
const {
onPointerCancel: handlePointerCancel,
onPointerDown: handlePointerDown,
onPointerUp: handlePointerUp,
} = useDisableDragSelectOnPointerDown();
const [isHeaderHovered, setIsHeaderHovered] = useState(false);
@@ -136,64 +149,69 @@ export const RecordBoardColumnHeader = () => {
};
return (
<StyledColumn>
<StyledHeader
onMouseEnter={() => setIsHeaderHovered(true)}
onMouseLeave={() => setIsHeaderHovered(false)}
>
<StyledHeaderContainer>
<StyledLeftContainer>
<StyledDropdownContainer>
<Dropdown
dropdownId={dropdownId}
dropdownPlacement="bottom-start"
dropdownOffset={{
x: 0,
y: 10,
}}
clickableComponent={
<StyledTagContainer>
<RecordGroupChip
recordGroupDefinition={columnDefinition}
fieldMetadataItem={selectFieldMetadataItem}
/>
</StyledTagContainer>
}
dropdownComponents={<RecordBoardColumnDropdownMenu />}
/>
</StyledDropdownContainer>
<RecordBoardColumnHeaderAggregateDropdown
aggregateValue={recordIndexAggregateDisplayValueForGroupValue}
dropdownId={`record-board-column-aggregate-dropdown-${columnDefinition.id}`}
objectMetadataItem={objectMetadataItem}
aggregateLabel={recordIndexAggregateDisplayLabel}
/>
</StyledLeftContainer>
<StyledRightContainer>
{isHeaderHovered && (
<StyledHeaderActions>
<LightIconButton
accent="tertiary"
Icon={IconDotsVertical}
onClick={() => {
toggleDropdown({
dropdownComponentInstanceIdFromProps: dropdownId,
});
<StyledColumn data-has-left-border={columnIndex > 0 ? 'true' : undefined}>
<DragDropColumnSortableHandle fill>
<StyledHeader
onMouseEnter={() => setIsHeaderHovered(true)}
onMouseLeave={() => setIsHeaderHovered(false)}
onPointerCancel={handlePointerCancel}
onPointerDown={handlePointerDown}
onPointerUp={handlePointerUp}
>
<StyledHeaderContainer>
<StyledLeftContainer>
<StyledDropdownContainer>
<Dropdown
dropdownId={dropdownId}
dropdownPlacement="bottom-start"
dropdownOffset={{
x: 0,
y: 10,
}}
clickableComponent={
<StyledTagContainer>
<RecordGroupChip
recordGroupDefinition={columnDefinition}
fieldMetadataItem={selectFieldMetadataItem}
/>
</StyledTagContainer>
}
dropdownComponents={<RecordBoardColumnDropdownMenu />}
/>
{canCreateRecords && !hasAnySoftDeleteFilterOnView && (
</StyledDropdownContainer>
<RecordBoardColumnHeaderAggregateDropdown
aggregateValue={recordIndexAggregateDisplayValueForGroupValue}
dropdownId={`record-board-column-aggregate-dropdown-${columnDefinition.id}`}
objectMetadataItem={objectMetadataItem}
aggregateLabel={recordIndexAggregateDisplayLabel}
/>
</StyledLeftContainer>
<StyledRightContainer>
{isHeaderHovered && (
<StyledHeaderActions>
<LightIconButton
accent="tertiary"
Icon={IconPlus}
onClick={handleCreateNewRecordClick}
Icon={IconDotsVertical}
onClick={() => {
toggleDropdown({
dropdownComponentInstanceIdFromProps: dropdownId,
});
}}
/>
)}
</StyledHeaderActions>
)}
</StyledRightContainer>
</StyledHeaderContainer>
</StyledHeader>
{canCreateRecords && !hasAnySoftDeleteFilterOnView && (
<LightIconButton
accent="tertiary"
Icon={IconPlus}
onClick={handleCreateNewRecordClick}
/>
)}
</StyledHeaderActions>
)}
</StyledRightContainer>
</StyledHeaderContainer>
</StyledHeader>
</DragDropColumnSortableHandle>
<RecordBoardColumnResizeHandler />
</StyledColumn>
);
@@ -0,0 +1 @@
export const RECORD_BOARD_COLUMN_DROPPABLE_ID = 'record-board-header';
@@ -0,0 +1,199 @@
import { type DragDropProvider } from '@dnd-kit/react';
import { type ComponentProps, useState } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { RECORD_GROUP_REORDER_CONFIRMATION_MODAL_ID } from '@/object-record/record-group/constants/RecordGroupReorderConfirmationModalId';
import { useReorderRecordGroups } from '@/object-record/record-group/hooks/useReorderRecordGroups';
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 DragDropColumnData } from '@/ui/utilities/drag-and-drop/types/DragDropColumnData';
import { resolveDragDropColumnDrop } from '@/ui/utilities/drag-and-drop/utils/resolveDragDropColumnDrop';
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';
type DragStartPayload = Parameters<
NonNullable<
ComponentProps<typeof DragDropProvider<DragDropColumnData>>['onDragStart']
>
>[0];
type DragMovePayload = Parameters<
NonNullable<
ComponentProps<typeof DragDropProvider<DragDropColumnData>>['onDragMove']
>
>[0];
type DragEndPayload = Parameters<
NonNullable<
ComponentProps<typeof DragDropProvider<DragDropColumnData>>['onDragEnd']
>
>[0];
type PendingReorder = {
fromIndex: number;
toIndex: number;
};
export type RecordBoardColumnDndKitContextValues = {
activeDropTargetIndex: number | null;
};
export const useRecordBoardColumnDndKit = (): {
contextValues: RecordBoardColumnDndKitContextValues;
handlePendingReorderConfirmClick: () => void;
handlers: {
onDragStart: (event: DragStartPayload) => void;
onDragMove: (event: DragMovePayload) => void;
onDragEnd: (event: DragEndPayload) => void;
};
} => {
const { openModal } = useModal();
const { recordIndexId } = useRecordIndexIdFromCurrentContextStore();
const { reorderRecordGroups } = useReorderRecordGroups({
recordIndexId,
viewType: ViewType.KANBAN,
});
const { setDragSelectionStartEnabled } = useDragSelect();
const { getScrollWrapperElement } = useScrollWrapperHTMLElement();
const isRecordGroupDraggableSort = useAtomComponentSelectorValue(
recordIndexRecordGroupIsDraggableSortComponentSelector,
recordIndexId,
);
const visibleRecordGroupIds = useAtomComponentFamilySelectorValue(
visibleRecordGroupIdsComponentFamilySelector,
ViewType.KANBAN,
);
const recordIndexKanbanColumnWidth = useAtomComponentStateValue(
recordIndexKanbanColumnWidthComponentState,
);
const [, setRecordIndexRecordGroupSort] = useAtomComponentState(
recordIndexRecordGroupSortComponentState,
);
const [activeDropTargetIndex, setActiveDropTargetIndex] = useState<
number | null
>(null);
const [pendingReorder, setPendingReorder] = useState<PendingReorder | null>(
null,
);
const resolveDropFromPointerX = ({
pointerX,
sourceIndex,
}: {
pointerX: number;
sourceIndex: number;
}) => {
const { scrollWrapperElement } = getScrollWrapperElement();
if (!isDefined(scrollWrapperElement)) return null;
const columnWidths = visibleRecordGroupIds.map(
() => recordIndexKanbanColumnWidth,
);
if (columnWidths.length === 0) {
return null;
}
return resolveDragDropColumnDrop({
pointerX,
sourceIndex,
scrollWrapperElement,
columnWidths,
});
};
const handleDragStart = (_event: DragStartPayload) => {
setActiveDropTargetIndex(null);
};
const handleDragMove = (event: DragMovePayload) => {
const { operation } = event;
const sourceIndex = operation.source?.data.index;
if (!isDefined(sourceIndex)) {
setActiveDropTargetIndex(null);
return;
}
const resolvedDrop = resolveDropFromPointerX({
pointerX: operation.position.current.x,
sourceIndex,
});
setActiveDropTargetIndex((currentActiveDropTargetIndex) => {
const nextActiveDropTargetIndex = resolvedDrop?.dropTargetIndex ?? null;
return currentActiveDropTargetIndex === nextActiveDropTargetIndex
? currentActiveDropTargetIndex
: nextActiveDropTargetIndex;
});
};
const handleDragEnd = (event: DragEndPayload) => {
const { operation } = event;
const source = operation.source;
setActiveDropTargetIndex(null);
setDragSelectionStartEnabled(true);
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;
if (!isRecordGroupDraggableSort) {
setPendingReorder({
fromIndex: resolvedDrop.sourceIndex,
toIndex: resolvedDrop.destinationIndex,
});
openModal(RECORD_GROUP_REORDER_CONFIRMATION_MODAL_ID);
return;
}
reorderRecordGroups({
fromIndex: resolvedDrop.sourceIndex,
toIndex: resolvedDrop.destinationIndex,
});
};
const handlePendingReorderConfirmClick = () => {
if (!isDefined(pendingReorder)) return;
setRecordIndexRecordGroupSort(RecordGroupSort.Manual);
reorderRecordGroups(pendingReorder);
setPendingReorder(null);
};
return {
contextValues: {
activeDropTargetIndex,
},
handlePendingReorderConfirmClick,
handlers: {
onDragStart: handleDragStart,
onDragMove: handleDragMove,
onDragEnd: handleDragEnd,
},
};
};
@@ -0,0 +1,37 @@
import { DragDropProvider } from '@dnd-kit/react';
import type { ReactNode } from 'react';
import { useRecordBoardColumnDndKit } from '@/object-record/record-board/record-board-column/dnd/hooks/useRecordBoardColumnDndKit';
import { RecordGroupReorderConfirmationModal } from '@/object-record/record-group/components/RecordGroupReorderConfirmationModal';
import { DragDropColumnDndContext } from '@/ui/utilities/drag-and-drop/context/DragDropColumnDndContext';
import { DND_KIT_SENSORS } from '@/ui/utilities/drag-and-drop/constants/DndKitSensors';
import { type DragDropColumnData } from '@/ui/utilities/drag-and-drop/types/DragDropColumnData';
type RecordBoardColumnDndKitProviderProps = {
children: ReactNode;
};
export const RecordBoardColumnDndKitProvider = ({
children,
}: RecordBoardColumnDndKitProviderProps) => {
const { contextValues, handlers, handlePendingReorderConfirmClick } =
useRecordBoardColumnDndKit();
return (
<>
<DragDropColumnDndContext.Provider value={contextValues}>
<DragDropProvider<DragDropColumnData>
sensors={DND_KIT_SENSORS}
onDragStart={handlers.onDragStart}
onDragMove={handlers.onDragMove}
onDragEnd={handlers.onDragEnd}
>
{children}
</DragDropProvider>
</DragDropColumnDndContext.Provider>
<RecordGroupReorderConfirmationModal
onConfirmClick={handlePendingReorderConfirmClick}
/>
</>
);
};
@@ -6,7 +6,7 @@ import { RecordTableColumnHead } from '@/object-record/record-table/record-table
import { RecordTableColumnHeadWithDropdown } from '@/object-record/record-table/record-table-header/components/RecordTableColumnHeadWithDropdown';
import { RecordTableHeaderCellContainer } from '@/object-record/record-table/record-table-header/components/RecordTableHeaderCellContainer';
import { RecordTableHeaderResizeHandler } from '@/object-record/record-table/record-table-header/components/RecordTableHeaderResizeHandler';
import { RecordTableHeaderSortableHandle } from '@/object-record/record-table/record-table-header/dnd/components/RecordTableHeaderSortableHandle';
import { DragDropColumnSortableHandle } from '@/ui/utilities/drag-and-drop/components/DragDropColumnSortableHandle';
import { isRecordTableColumnHeadersReadOnlyComponentState } from '@/object-record/record-table/states/isRecordTableColumnHeadersReadOnlyComponentState';
import { isRecordTableColumnResizableComponentState } from '@/object-record/record-table/states/isRecordTableColumnResizableComponentState';
import { isRecordTableRowActiveComponentFamilyState } from '@/object-record/record-table/states/isRecordTableRowActiveComponentFamilyState';
@@ -15,7 +15,7 @@ import { isRecordTableRowFocusedComponentFamilyState } from '@/object-record/rec
import { isRecordTableScrolledVerticallyComponentState } from '@/object-record/record-table/states/isRecordTableScrolledVerticallyComponentState';
import { resizedFieldMetadataIdComponentState } from '@/object-record/record-table/states/resizedFieldMetadataIdComponentState';
import { getRecordTableColumnFieldWidthClassName } from '@/object-record/record-table/utils/getRecordTableColumnFieldWidthClassName';
import { useDisableDragSelectOnPointerDown } from '@/object-record/record-table/record-table-header/hooks/useDisableDragSelectOnPointerDown';
import { useDisableDragSelectOnPointerDown } from '@/ui/utilities/drag-select/hooks/useDisableDragSelectOnPointerDown';
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';
@@ -104,7 +104,7 @@ export const RecordTableHeaderCell = ({
position="left"
/>
)}
<RecordTableHeaderSortableHandle>
<DragDropColumnSortableHandle>
{isRecordTableColumnHeadersReadOnly ? (
<RecordTableColumnHead recordField={recordField} />
) : (
@@ -113,7 +113,7 @@ export const RecordTableHeaderCell = ({
objectMetadataId={objectMetadataItem.id}
/>
)}
</RecordTableHeaderSortableHandle>
</DragDropColumnSortableHandle>
{isRecordTableColumnResizable && (
<RecordTableHeaderResizeHandler
recordFieldIndex={recordFieldIndex}
@@ -6,12 +6,12 @@ import { RecordTableHeaderCell } from '@/object-record/record-table/record-table
import { RecordTableHeaderEmptyLastColumn } from '@/object-record/record-table/record-table-header/components/RecordTableHeaderEmptyLastColumn';
import { RecordTableHeaderFirstScrollableCell } from '@/object-record/record-table/record-table-header/components/RecordTableHeaderFirstScrollableCell';
import { RecordTableHeaderLastEmptyColumn } from '@/object-record/record-table/record-table-header/components/RecordTableHeaderLastEmptyColumn';
import { RecordTableHeaderDroppableSlot } from '@/object-record/record-table/record-table-header/dnd/components/RecordTableHeaderDroppableSlot';
import { RecordTableHeaderDropTarget } from '@/object-record/record-table/record-table-header/dnd/components/RecordTableHeaderDropTarget';
import { RecordTableHeaderSortableCell } from '@/object-record/record-table/record-table-header/dnd/components/RecordTableHeaderSortableCell';
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 { DragDropColumnDroppableSlot } from '@/ui/utilities/drag-and-drop/components/DragDropColumnDroppableSlot';
import { DragDropColumnDropTarget } from '@/ui/utilities/drag-and-drop/components/DragDropColumnDropTarget';
import { DragDropColumnSortableCell } from '@/ui/utilities/drag-and-drop/components/DragDropColumnSortableCell';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { isDefined } from 'twenty-shared/utils';
@@ -28,16 +28,16 @@ export const RecordTableHeaderDnd = () => {
return (
<RecordTableHeaderDndKitProvider>
<RecordTableHeaderDroppableSlot
<DragDropColumnDroppableSlot
droppableId={RECORD_TABLE_HEADER_DROPPABLE_ID}
index={0}
disabled={isRecordTableColumnHeadersReadOnly}
>
<RecordTableHeaderDropTarget index={0} compact />
</RecordTableHeaderDroppableSlot>
<DragDropColumnDropTarget index={0} compact />
</DragDropColumnDroppableSlot>
{isDefined(firstScrollableRecordField) && (
<RecordTableHeaderSortableCell
<DragDropColumnSortableCell
key={firstScrollableRecordField.fieldMetadataItemId}
id={firstScrollableRecordField.fieldMetadataItemId}
index={0}
@@ -47,19 +47,19 @@ export const RecordTableHeaderDnd = () => {
<RecordTableHeaderFirstScrollableCell
firstScrollableRecordField={firstScrollableRecordField}
/>
</RecordTableHeaderSortableCell>
</DragDropColumnSortableCell>
)}
{recordFieldsWithoutFirstTwo.map((recordField, index) => (
<React.Fragment key={recordField.fieldMetadataItemId}>
<RecordTableHeaderDroppableSlot
<DragDropColumnDroppableSlot
droppableId={RECORD_TABLE_HEADER_DROPPABLE_ID}
index={index + 1}
disabled={isRecordTableColumnHeadersReadOnly}
>
<RecordTableHeaderDropTarget index={index + 1} compact />
</RecordTableHeaderDroppableSlot>
<RecordTableHeaderSortableCell
<DragDropColumnDropTarget index={index + 1} compact />
</DragDropColumnDroppableSlot>
<DragDropColumnSortableCell
id={recordField.fieldMetadataItemId}
index={index + 1}
group={RECORD_TABLE_HEADER_DROPPABLE_ID}
@@ -70,19 +70,19 @@ export const RecordTableHeaderDnd = () => {
recordField={recordField}
recordFieldIndex={index + 2}
/>
</RecordTableHeaderSortableCell>
</DragDropColumnSortableCell>
</React.Fragment>
))}
<RecordTableHeaderDroppableSlot
<DragDropColumnDroppableSlot
droppableId={RECORD_TABLE_HEADER_DROPPABLE_ID}
index={visibleRecordFields.length}
index={visibleRecordFields.length - 1}
disabled={isRecordTableColumnHeadersReadOnly}
>
<RecordTableHeaderDropTarget
index={visibleRecordFields.length}
<DragDropColumnDropTarget
index={visibleRecordFields.length - 1}
compact
/>
</RecordTableHeaderDroppableSlot>
</DragDropColumnDroppableSlot>
{isRecordTableColumnHeadersReadOnly ? (
<RecordTableHeaderEmptyLastColumn />
) : (
@@ -8,7 +8,7 @@ import { RecordTableHeaderResizeHandler } from '@/object-record/record-table/rec
import { RecordTableHeaderCellContainer } from '@/object-record/record-table/record-table-header/components/RecordTableHeaderCellContainer';
import { hasRecordGroupsComponentSelector } from '@/object-record/record-group/states/selectors/hasRecordGroupsComponentSelector';
import { RecordTableHeaderSortableHandle } from '@/object-record/record-table/record-table-header/dnd/components/RecordTableHeaderSortableHandle';
import { DragDropColumnSortableHandle } from '@/ui/utilities/drag-and-drop/components/DragDropColumnSortableHandle';
import { isRecordTableColumnHeadersReadOnlyComponentState } from '@/object-record/record-table/states/isRecordTableColumnHeadersReadOnlyComponentState';
import { isRecordTableColumnResizableComponentState } from '@/object-record/record-table/states/isRecordTableColumnResizableComponentState';
import { isRecordTableRowActiveComponentFamilyState } from '@/object-record/record-table/states/isRecordTableRowActiveComponentFamilyState';
@@ -17,12 +17,12 @@ import { isRecordTableRowFocusedComponentFamilyState } from '@/object-record/rec
import { isRecordTableScrolledVerticallyComponentState } from '@/object-record/record-table/states/isRecordTableScrolledVerticallyComponentState';
import { resizedFieldMetadataIdComponentState } from '@/object-record/record-table/states/resizedFieldMetadataIdComponentState';
import { getRecordTableColumnFieldWidthClassName } from '@/object-record/record-table/utils/getRecordTableColumnFieldWidthClassName';
import { useDisableDragSelectOnPointerDown } from '@/ui/utilities/drag-select/hooks/useDisableDragSelectOnPointerDown';
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 { cx } from '@linaria/core';
import { isDefined } from 'twenty-shared/utils';
import { useDisableDragSelectOnPointerDown } from '@/object-record/record-table/record-table-header/hooks/useDisableDragSelectOnPointerDown';
type RecordTableHeaderFirstScrollableCellProps = {
firstScrollableRecordField: RecordField;
@@ -99,7 +99,7 @@ export const RecordTableHeaderFirstScrollableCell = ({
{isRecordTableColumnResizable && (
<RecordTableHeaderResizeHandler recordFieldIndex={1} position="left" />
)}
<RecordTableHeaderSortableHandle>
<DragDropColumnSortableHandle>
{isRecordTableColumnHeadersReadOnly ? (
<RecordTableColumnHead recordField={firstScrollableRecordField} />
) : (
@@ -108,7 +108,7 @@ export const RecordTableHeaderFirstScrollableCell = ({
objectMetadataId={objectMetadataItem.id}
/>
)}
</RecordTableHeaderSortableHandle>
</DragDropColumnSortableHandle>
{isRecordTableColumnResizable && (
<RecordTableHeaderResizeHandler recordFieldIndex={1} position="right" />
)}
@@ -1,28 +0,0 @@
import { styled } from '@linaria/react';
import { type ReactNode, useContext } from 'react';
import { RecordTableHeaderSortableHandleRefContext } from '@/object-record/record-table/record-table-header/dnd/context/RecordTableHeaderSortableHandleRefContext';
const StyledSortableHandle = styled.div`
height: 100%;
min-width: 0;
outline: none;
`;
type RecordTableHeaderSortableHandleProps = {
children: ReactNode;
};
export const RecordTableHeaderSortableHandle = ({
children,
}: RecordTableHeaderSortableHandleProps) => {
const sortableHandleRef = useContext(
RecordTableHeaderSortableHandleRefContext,
);
return (
<StyledSortableHandle ref={sortableHandleRef}>
{children}
</StyledSortableHandle>
);
};
@@ -1,10 +0,0 @@
import { createContext } from 'react';
type RecordTableHeaderDndContextValue = {
activeDropTargetIndex: number | null;
};
export const RecordTableHeaderDndContext =
createContext<RecordTableHeaderDndContextValue>({
activeDropTargetIndex: null,
});
@@ -1,44 +1,35 @@
import { type DragDropProvider } from '@dnd-kit/react';
import { useStore } from 'jotai';
import { type ComponentProps, useCallback, useState } from 'react';
import { type ComponentProps, useState } from 'react';
import { filterOutByProperty, 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 { resolveRecordTableHeaderDrop } from '@/object-record/record-table/record-table-header/dnd/utils/resolveRecordTableHeaderDrop';
import { useSaveCurrentViewFields } from '@/views/hooks/useSaveCurrentViewFields';
import { mapRecordFieldToViewField } from '@/views/utils/mapRecordFieldToViewField';
import { useAtomComponentStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateCallbackState';
import { isRecordTableHeaderDropProcessingComponentState } from '@/object-record/record-table/record-table-header/states/isRecordTableHeaderDropProcessingComponentState';
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 { type RecordTableHeaderDndData } from '@/object-record/record-table/record-table-header/dnd/types/RecordTableHeaderDndData';
import { isRecordTableCheckboxColumnHiddenComponentState } from '@/object-record/record-table/states/isRecordTableCheckboxColumnHiddenComponentState';
import { isRecordTableDragColumnHiddenComponentState } from '@/object-record/record-table/states/isRecordTableDragColumnHiddenComponentState';
import { type DragDropColumnData } from '@/ui/utilities/drag-and-drop/types/DragDropColumnData';
import { resolveDragDropColumnDrop } from '@/ui/utilities/drag-and-drop/utils/resolveDragDropColumnDrop';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
type DragStartPayload = Parameters<
NonNullable<
ComponentProps<
typeof DragDropProvider<RecordTableHeaderDndData>
>['onDragStart']
ComponentProps<typeof DragDropProvider<DragDropColumnData>>['onDragStart']
>
>[0];
type DragMovePayload = Parameters<
NonNullable<
ComponentProps<
typeof DragDropProvider<RecordTableHeaderDndData>
>['onDragMove']
ComponentProps<typeof DragDropProvider<DragDropColumnData>>['onDragMove']
>
>[0];
type DragEndPayload = Parameters<
NonNullable<
ComponentProps<
typeof DragDropProvider<RecordTableHeaderDndData>
>['onDragEnd']
ComponentProps<typeof DragDropProvider<DragDropColumnData>>['onDragEnd']
>
>[0];
@@ -54,8 +45,6 @@ export const useRecordTableHeaderDndKit = (): {
onDragEnd: (event: DragEndPayload) => void;
};
} => {
const store = useStore();
const { recordTableId, visibleRecordFields } = useRecordTableContextOrThrow();
const { labelIdentifierFieldMetadataItem } = useRecordIndexContextOrThrow();
const { reorderVisibleRecordFields } =
@@ -64,10 +53,6 @@ export const useRecordTableHeaderDndKit = (): {
const { setDragSelectionStartEnabled } = useDragSelect();
const { getScrollWrapperElement } = useScrollWrapperHTMLElement();
const isRecordTableHeaderDropProcessingCallbackState =
useAtomComponentStateCallbackState(
isRecordTableHeaderDropProcessingComponentState,
);
const isRecordTableDragColumnHidden = useAtomComponentStateValue(
isRecordTableDragColumnHiddenComponentState,
);
@@ -100,57 +85,53 @@ export const useRecordTableHeaderDndKit = (): {
: RECORD_TABLE_COLUMN_CHECKBOX_WIDTH) +
(labelIdentifierRecordField?.size ?? 0);
const resolveDropFromPointerX = useCallback(
({ pointerX, sourceIndex }: { pointerX: number; sourceIndex: number }) => {
const { scrollWrapperElement } = getScrollWrapperElement();
if (!isDefined(scrollWrapperElement)) return null;
const resolveDropFromPointerX = ({
pointerX,
sourceIndex,
}: {
pointerX: number;
sourceIndex: number;
}) => {
const { scrollWrapperElement } = getScrollWrapperElement();
if (!isDefined(scrollWrapperElement)) return null;
return resolveRecordTableHeaderDrop({
pointerX,
sourceIndex,
scrollWrapperElement,
nonSortableColumnsWidth,
recordFields: recordFieldsWithoutLabelIdentifier,
});
},
[
getScrollWrapperElement,
nonSortableColumnsWidth,
recordFieldsWithoutLabelIdentifier,
],
);
return resolveDragDropColumnDrop({
pointerX,
sourceIndex,
scrollWrapperElement,
columnWidths: recordFieldsWithoutLabelIdentifier.map(
(recordField) => recordField.size,
),
leadingOffset: nonSortableColumnsWidth,
});
};
const handleDragStart = (_event: DragStartPayload) => {
store.set(isRecordTableHeaderDropProcessingCallbackState, true);
setActiveDropTargetIndex(null);
};
const handleDragMove = useCallback(
(event: DragMovePayload) => {
const { operation } = event;
const sourceIndex = operation.source?.data.index;
const handleDragMove = (event: DragMovePayload) => {
const { operation } = event;
const sourceIndex = operation.source?.data.index;
if (!isDefined(sourceIndex)) {
setActiveDropTargetIndex(null);
return;
}
if (!isDefined(sourceIndex)) {
setActiveDropTargetIndex(null);
return;
}
const resolvedDrop = resolveDropFromPointerX({
pointerX: operation.position.current.x,
sourceIndex,
});
const resolvedDrop = resolveDropFromPointerX({
pointerX: operation.position.current.x,
sourceIndex,
});
setActiveDropTargetIndex((currentActiveDropTargetIndex) => {
const nextActiveDropTargetIndex = resolvedDrop?.dropTargetIndex ?? null;
setActiveDropTargetIndex((currentActiveDropTargetIndex) => {
const nextActiveDropTargetIndex = resolvedDrop?.dropTargetIndex ?? null;
return currentActiveDropTargetIndex === nextActiveDropTargetIndex
? currentActiveDropTargetIndex
: nextActiveDropTargetIndex;
});
},
[resolveDropFromPointerX, setActiveDropTargetIndex],
);
return currentActiveDropTargetIndex === nextActiveDropTargetIndex
? currentActiveDropTargetIndex
: nextActiveDropTargetIndex;
});
};
const handleDragEnd = (event: DragEndPayload) => {
const { operation } = event;
@@ -158,7 +139,6 @@ export const useRecordTableHeaderDndKit = (): {
setActiveDropTargetIndex(null);
setDragSelectionStartEnabled(true);
store.set(isRecordTableHeaderDropProcessingCallbackState, false);
if (event.canceled) return;
if (!isDefined(source)) return;
@@ -1,10 +1,10 @@
import { DragDropProvider } from '@dnd-kit/react';
import type { ReactNode } from 'react';
import { type RecordTableHeaderDndData } from '@/object-record/record-table/record-table-header/dnd/types/RecordTableHeaderDndData';
import { RecordTableHeaderDndContext } from '@/object-record/record-table/record-table-header/dnd/context/RecordTableHeaderDndContext';
import { useRecordTableHeaderDndKit } from '@/object-record/record-table/record-table-header/dnd/hooks/useRecordTableHeaderDndKit';
import { DragDropColumnDndContext } from '@/ui/utilities/drag-and-drop/context/DragDropColumnDndContext';
import { DND_KIT_SENSORS } from '@/ui/utilities/drag-and-drop/constants/DndKitSensors';
import { type DragDropColumnData } from '@/ui/utilities/drag-and-drop/types/DragDropColumnData';
type RecordTableHeaderDndKitProviderProps = {
children: ReactNode;
@@ -16,8 +16,8 @@ export const RecordTableHeaderDndKitProvider = ({
const { contextValues, handlers } = useRecordTableHeaderDndKit();
return (
<RecordTableHeaderDndContext.Provider value={contextValues}>
<DragDropProvider<RecordTableHeaderDndData>
<DragDropColumnDndContext.Provider value={contextValues}>
<DragDropProvider<DragDropColumnData>
sensors={DND_KIT_SENSORS}
onDragStart={handlers.onDragStart}
onDragMove={handlers.onDragMove}
@@ -25,6 +25,6 @@ export const RecordTableHeaderDndKitProvider = ({
>
{children}
</DragDropProvider>
</RecordTableHeaderDndContext.Provider>
</DragDropColumnDndContext.Provider>
);
};
@@ -1,23 +0,0 @@
import { useDragSelect } from '@/ui/utilities/drag-select/hooks/useDragSelect';
import { useCallback } from 'react';
// Prevents the record table cell drag-select from starting when interacting with a column header.
// Pointer capture is intentionally avoided: capturing on pointerdown retargets the pointerup/click
// to the cell and stops the column header dropdown from opening. dnd-kit handles drag pointer tracking.
export const useDisableDragSelectOnPointerDown = () => {
const { setDragSelectionStartEnabled } = useDragSelect();
const handlePointerDown = useCallback(() => {
setDragSelectionStartEnabled(false);
}, [setDragSelectionStartEnabled]);
const handlePointerEnd = useCallback(() => {
setDragSelectionStartEnabled(true);
}, [setDragSelectionStartEnabled]);
return {
onPointerDown: handlePointerDown,
onPointerUp: handlePointerEnd,
onPointerCancel: handlePointerEnd,
};
};
@@ -1,9 +0,0 @@
import { RecordTableComponentInstanceContext } from '@/object-record/record-table/states/context/RecordTableComponentInstanceContext';
import { createAtomComponentState } from '@/ui/utilities/state/jotai/utils/createAtomComponentState';
export const isRecordTableHeaderDropProcessingComponentState =
createAtomComponentState<boolean>({
key: 'isRecordTableHeaderDropProcessingComponentState',
defaultValue: false,
componentInstanceContext: RecordTableComponentInstanceContext,
});
@@ -2,16 +2,16 @@ import { styled } from '@linaria/react';
import { type ReactNode, useContext } from 'react';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { RecordTableHeaderDndContext } from '@/object-record/record-table/record-table-header/dnd/context/RecordTableHeaderDndContext';
import { isDefined } from 'twenty-shared/utils';
import { DragDropColumnDndContext } from '@/ui/utilities/drag-and-drop/context/DragDropColumnDndContext';
const StyledDropTarget = styled.div<{
$compact?: boolean;
$overlay?: boolean;
}>`
height: 100%;
min-height: ${({ $compact }) =>
$compact ? '100%' : themeCssVariables.spacing[2]};
position: relative;
position: ${({ $overlay }) => ($overlay ? 'absolute' : 'relative')};
transition: background-color 120ms ease-out;
width: 100%;
@@ -43,26 +43,27 @@ const StyledDropTarget = styled.div<{
}
`;
type RecordTableHeaderDropTargetProps = {
index: number;
type DragDropColumnDropTargetProps = {
children?: ReactNode;
compact?: boolean;
index: number;
overlay?: boolean;
};
export const RecordTableHeaderDropTarget = ({
index,
export const DragDropColumnDropTarget = ({
children,
compact = false,
}: RecordTableHeaderDropTargetProps) => {
const { activeDropTargetIndex } = useContext(RecordTableHeaderDndContext);
index,
overlay = false,
}: DragDropColumnDropTargetProps) => {
const { activeDropTargetIndex } = useContext(DragDropColumnDndContext);
const isDragOver = isDefined(activeDropTargetIndex)
? activeDropTargetIndex === index
: false;
const isDragOver = activeDropTargetIndex === index;
return (
<StyledDropTarget
$compact={compact}
$overlay={overlay}
data-drag-over={isDragOver ? 'true' : undefined}
>
{children}
@@ -1,9 +1,10 @@
import { type RecordTableHeaderDndData } from '@/object-record/record-table/record-table-header/dnd/types/RecordTableHeaderDndData';
import { pointerIntersection } from '@dnd-kit/collision';
import { useDroppable } from '@dnd-kit/react';
import { styled } from '@linaria/react';
import { type ReactNode } from 'react';
import { type DragDropColumnData } from '@/ui/utilities/drag-and-drop/types/DragDropColumnData';
const StyledSlotWrapper = styled.div`
align-self: stretch;
flex: 0 0 2px;
@@ -16,23 +17,24 @@ const StyledSlotWrapper = styled.div`
const SLOT_COLLISION_PRIORITY = 1;
type RecordTableHeaderDroppableSlotProps = {
type DragDropColumnDroppableSlotProps = {
children?: ReactNode;
collisionPriority?: number;
disabled?: boolean;
droppableId: string;
index: number;
children?: ReactNode;
disabled?: boolean;
collisionPriority?: number;
};
export const RecordTableHeaderDroppableSlot = ({
export const DragDropColumnDroppableSlot = ({
children,
collisionPriority = SLOT_COLLISION_PRIORITY,
disabled = false,
droppableId,
index,
children,
disabled = false,
collisionPriority = SLOT_COLLISION_PRIORITY,
}: RecordTableHeaderDroppableSlotProps) => {
}: DragDropColumnDroppableSlotProps) => {
const id = `${droppableId}::${index}`;
const data: RecordTableHeaderDndData = { droppableId, index };
const data: DragDropColumnData = { droppableId, index };
const { ref } = useDroppable({
id,
disabled,
@@ -4,43 +4,48 @@ import { useSortable } from '@dnd-kit/react/sortable';
import { styled } from '@linaria/react';
import { type ReactNode } from 'react';
import { RecordTableHeaderSortableHandleRefContext } from '@/object-record/record-table/record-table-header/dnd/context/RecordTableHeaderSortableHandleRefContext';
import { DragDropColumnSortableHandleRefContext } from '@/ui/utilities/drag-and-drop/context/DragDropColumnSortableHandleRefContext';
const SORTABLE_COLLISION_PRIORITY = 3;
const PLUGINS_WITHOUT_OPTIMISTIC = [SortableKeyboardPlugin];
// Columns can only be reordered horizontally, so keep the drag preview locked to the header row.
const RECORD_TABLE_HEADER_SORTABLE_MODIFIERS = [RestrictToHorizontalAxis];
const SORTABLE_MODIFIERS = [RestrictToHorizontalAxis];
const RECORD_TABLE_HEADER_SORTABLE_TRANSITION = {
const SORTABLE_TRANSITION = {
duration: 180,
easing: 'cubic-bezier(0.2, 0, 0, 1)',
idle: true,
};
const StyledSortableRoot = styled.div`
const StyledSortableRoot = styled.div<{ $fill?: boolean }>`
display: ${({ $fill }) => ($fill ? 'flex' : 'block')};
height: ${({ $fill }) => ($fill ? '100%' : 'auto')};
min-height: 0;
min-width: ${({ $fill }) => ($fill ? '0' : 'auto')};
outline: none;
position: relative;
will-change: transform;
`;
type RecordTableHeaderSortableCellProps = {
type DragDropColumnSortableCellProps = {
children: ReactNode;
disabled?: boolean;
fill?: boolean;
group: string;
id: string;
index: number;
};
export const RecordTableHeaderSortableCell = ({
export const DragDropColumnSortableCell = ({
children,
disabled = false,
fill = false,
group,
id,
index,
group,
disabled = false,
children,
}: RecordTableHeaderSortableCellProps) => {
}: DragDropColumnSortableCellProps) => {
const { handleRef, ref } = useSortable({
id,
index,
@@ -48,18 +53,20 @@ export const RecordTableHeaderSortableCell = ({
collisionPriority: SORTABLE_COLLISION_PRIORITY,
data: {
droppableId: group,
index: index,
index,
},
disabled,
transition: RECORD_TABLE_HEADER_SORTABLE_TRANSITION,
transition: SORTABLE_TRANSITION,
plugins: PLUGINS_WITHOUT_OPTIMISTIC,
modifiers: RECORD_TABLE_HEADER_SORTABLE_MODIFIERS,
modifiers: SORTABLE_MODIFIERS,
feedback: 'clone',
});
return (
<RecordTableHeaderSortableHandleRefContext.Provider value={handleRef}>
<StyledSortableRoot ref={ref}>{children}</StyledSortableRoot>
</RecordTableHeaderSortableHandleRefContext.Provider>
<DragDropColumnSortableHandleRefContext.Provider value={handleRef}>
<StyledSortableRoot ref={ref} $fill={fill}>
{children}
</StyledSortableRoot>
</DragDropColumnSortableHandleRefContext.Provider>
);
};
@@ -0,0 +1,36 @@
import { styled } from '@linaria/react';
import { type ReactNode, useContext } from 'react';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { DragDropColumnSortableHandleRefContext } from '@/ui/utilities/drag-and-drop/context/DragDropColumnSortableHandleRefContext';
const StyledSortableHandle = styled.div<{ $fill?: boolean }>`
display: ${({ $fill }) => ($fill ? 'flex' : 'block')};
height: 100%;
min-width: 0;
outline: none;
width: ${({ $fill }) => ($fill ? '100%' : 'auto')};
&:focus-visible {
outline: 2px solid ${themeCssVariables.color.blue};
outline-offset: -2px;
}
`;
type DragDropColumnSortableHandleProps = {
children: ReactNode;
fill?: boolean;
};
export const DragDropColumnSortableHandle = ({
children,
fill = false,
}: DragDropColumnSortableHandleProps) => {
const sortableHandleRef = useContext(DragDropColumnSortableHandleRefContext);
return (
<StyledSortableHandle ref={sortableHandleRef} $fill={fill}>
{children}
</StyledSortableHandle>
);
};
@@ -0,0 +1,10 @@
import { createContext } from 'react';
type DragDropColumnDndContextValue = {
activeDropTargetIndex: number | null;
};
export const DragDropColumnDndContext =
createContext<DragDropColumnDndContextValue>({
activeDropTargetIndex: null,
});
@@ -1,5 +1,5 @@
import { createContext } from 'react';
export const RecordTableHeaderSortableHandleRefContext = createContext<
export const DragDropColumnSortableHandleRefContext = createContext<
((element: Element | null) => void) | undefined
>(undefined);
@@ -1,4 +1,4 @@
export type RecordTableHeaderDndData = {
index: number;
export type DragDropColumnData = {
droppableId: string;
index: number;
};
@@ -0,0 +1,114 @@
import { resolveDragDropColumnDrop } from '@/ui/utilities/drag-and-drop/utils/resolveDragDropColumnDrop';
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('resolveDragDropColumnDrop', () => {
it('should target the column under the pointer before its midpoint', () => {
const result = resolveDragDropColumnDrop({
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 = resolveDragDropColumnDrop({
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 = resolveDragDropColumnDrop({
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 = resolveDragDropColumnDrop({
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 = resolveDragDropColumnDrop({
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 = resolveDragDropColumnDrop({
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 = resolveDragDropColumnDrop({
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 = resolveDragDropColumnDrop({
pointerX: 140,
sourceIndex: 2,
scrollWrapperElement: createScrollWrapperElement({}),
columnWidths: COLUMN_WIDTHS,
leadingOffset: 100,
});
expect(result.dropTargetIndex).toBe(0);
});
});
@@ -1,14 +1,12 @@
import { type RecordField } from '@/object-record/record-field/types/RecordField';
type ResolveRecordTableHeaderDropArgs = {
type ResolveDragDropColumnDropArgs = {
pointerX: number;
sourceIndex: number;
scrollWrapperElement: HTMLElement;
nonSortableColumnsWidth: number;
recordFields: RecordField[];
columnWidths: number[];
leadingOffset?: number;
};
type ResolvedRecordTableHeaderDrop = {
type ResolvedDragDropColumnDrop = {
sourceIndex: number;
dropTargetIndex: number;
destinationIndex: number;
@@ -22,26 +20,25 @@ const getDestinationIndexFromDropTargetIndex = ({
dropTargetIndex: number;
}) => (dropTargetIndex > sourceIndex ? dropTargetIndex - 1 : dropTargetIndex);
export const resolveRecordTableHeaderDrop = ({
export const resolveDragDropColumnDrop = ({
pointerX,
sourceIndex,
scrollWrapperElement,
nonSortableColumnsWidth,
recordFields,
}: ResolveRecordTableHeaderDropArgs): ResolvedRecordTableHeaderDrop => {
columnWidths,
leadingOffset = 0,
}: ResolveDragDropColumnDropArgs): ResolvedDragDropColumnDrop => {
const scrollContainerRect = scrollWrapperElement.getBoundingClientRect();
const contentX =
pointerX -
scrollContainerRect.left +
scrollWrapperElement.scrollLeft -
nonSortableColumnsWidth;
leadingOffset;
let left = 0;
for (const [index, field] of recordFields.entries()) {
const width = field.size;
const midpoint = left + width / 2;
for (const [index, columnWidth] of columnWidths.entries()) {
const midpoint = left + columnWidth / 2;
if (contentX < midpoint) {
return {
@@ -54,10 +51,10 @@ export const resolveRecordTableHeaderDrop = ({
};
}
left += width;
left += columnWidth;
}
const dropTargetIndex = recordFields.length;
const dropTargetIndex = columnWidths.length;
return {
sourceIndex,
@@ -0,0 +1,14 @@
import { useDragSelect } from './useDragSelect';
export const useDisableDragSelectOnPointerDown = () => {
const { setDragSelectionStartEnabled } = useDragSelect();
const handlePointerDown = () => setDragSelectionStartEnabled(false);
const handlePointerEnd = () => setDragSelectionStartEnabled(true);
return {
onPointerDown: handlePointerDown,
onPointerUp: handlePointerEnd,
onPointerCancel: handlePointerEnd,
};
};