[Twenty-front]: Record table Header drag and drop functionality (#21304)

Closes #21303 and
https://github.com/twentyhq/core-team-issues/issues/151




https://github.com/user-attachments/assets/45cee1be-464f-467e-a1c0-cf5354ff87db

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Priyanshu Bartwal
2026-06-23 12:07:04 +05:30
committed by GitHub
parent 46642c81c9
commit 6e7d8ef96c
19 changed files with 720 additions and 71 deletions
@@ -1,9 +1,4 @@
import { PointerActivationConstraints } from '@dnd-kit/dom';
import {
DragDropProvider,
KeyboardSensor,
PointerSensor,
} from '@dnd-kit/react';
import { DragDropProvider } from '@dnd-kit/react';
import type { ReactNode } from 'react';
import type { NavigationSections } from '@/navigation-menu-item/common/constants/NavigationSections.constants';
@@ -12,15 +7,7 @@ import { NavigationDropTargetContext } from '@/navigation-menu-item/common/conte
import { NavigationMenuItemDragContext } from '@/navigation-menu-item/common/contexts/NavigationMenuItemDragContext';
import type { DraggableData } from '@/navigation-menu-item/common/types/navigationMenuItemDndKitDraggableData';
import { useNavigationMenuItemDndKit } from '@/navigation-menu-item/display/dnd/hooks/useNavigationMenuItemDndKit';
const NAVIGATION_MENU_ITEM_DND_SENSORS = [
PointerSensor.configure({
activationConstraints: [
new PointerActivationConstraints.Distance({ value: 8 }),
],
}),
KeyboardSensor,
];
import { DND_KIT_SENSORS } from '@/ui/utilities/drag-and-drop/constants/DndKitSensors';
type NavigationMenuItemDndKitProviderProps = {
section: NavigationSections;
@@ -38,7 +25,7 @@ export const NavigationMenuItemDndKitProvider = ({
<NavigationMenuItemDragContext.Provider value={contextValues.drag}>
<NavigationDropTargetContext.Provider value={contextValues.dropTarget}>
<DragDropProvider<DraggableData>
sensors={NAVIGATION_MENU_ITEM_DND_SENSORS}
sensors={DND_KIT_SENSORS}
onDragStart={handlers.onDragStart}
onDragOver={handlers.onDragOver}
onDragEnd={handlers.onDragEnd}
@@ -1,16 +1,10 @@
import { TABLE_Z_INDEX } from '@/object-record/record-table/constants/TableZIndex';
import { useRecordTableContextOrThrow } from '@/object-record/record-table/contexts/RecordTableContext';
import { RecordTableHeaderAddColumnButton } from '@/object-record/record-table/record-table-header/components/RecordTableHeaderAddColumnButton';
import { RecordTableHeaderCell } from '@/object-record/record-table/record-table-header/components/RecordTableHeaderCell';
import { RecordTableHeaderCheckboxColumn } from '@/object-record/record-table/record-table-header/components/RecordTableHeaderCheckboxColumn';
import { RecordTableHeaderDnd } from '@/object-record/record-table/record-table-header/components/RecordTableHeaderDnd';
import { RecordTableHeaderDragDropColumn } from '@/object-record/record-table/record-table-header/components/RecordTableHeaderDragDropColumn';
import { RecordTableHeaderEmptyLastColumn } from '@/object-record/record-table/record-table-header/components/RecordTableHeaderEmptyLastColumn';
import { RecordTableHeaderFirstCell } from '@/object-record/record-table/record-table-header/components/RecordTableHeaderFirstCell';
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 { useResizeTableHeader } from '@/object-record/record-table/record-table-header/hooks/useResizeTableHeader';
import { isRecordTableCheckboxColumnHiddenComponentState } from '@/object-record/record-table/states/isRecordTableCheckboxColumnHiddenComponentState';
import { isRecordTableColumnHeadersReadOnlyComponentState } from '@/object-record/record-table/states/isRecordTableColumnHeadersReadOnlyComponentState';
import { isRecordTableDragColumnHiddenComponentState } from '@/object-record/record-table/states/isRecordTableDragColumnHiddenComponentState';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { styled } from '@linaria/react';
@@ -24,12 +18,6 @@ const StyledHeaderContainer = styled.div`
`;
export const RecordTableHeader = () => {
const { visibleRecordFields } = useRecordTableContextOrThrow();
const isRecordTableColumnHeadersReadOnly = useAtomComponentStateValue(
isRecordTableColumnHeadersReadOnlyComponentState,
);
const isRecordTableDragColumnHidden = useAtomComponentStateValue(
isRecordTableDragColumnHiddenComponentState,
);
@@ -38,8 +26,6 @@ export const RecordTableHeader = () => {
isRecordTableCheckboxColumnHiddenComponentState,
);
const recordFieldsWithoutFirstTwo = visibleRecordFields.slice(2);
useResizeTableHeader();
return (
@@ -49,20 +35,7 @@ export const RecordTableHeader = () => {
<RecordTableHeaderCheckboxColumn />
)}
<RecordTableHeaderFirstCell />
<RecordTableHeaderFirstScrollableCell />
{recordFieldsWithoutFirstTwo.map((recordField, index) => (
<RecordTableHeaderCell
key={recordField.fieldMetadataItemId}
recordField={recordField}
recordFieldIndex={index + 2}
/>
))}
{isRecordTableColumnHeadersReadOnly ? (
<RecordTableHeaderEmptyLastColumn />
) : (
<RecordTableHeaderAddColumnButton />
)}
<RecordTableHeaderLastEmptyColumn />
<RecordTableHeaderDnd />
</StyledHeaderContainer>
);
};
@@ -6,6 +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 { 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';
@@ -14,6 +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 { 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';
@@ -31,6 +33,12 @@ export const RecordTableHeaderCell = ({
}: RecordTableHeaderCellProps) => {
const { objectMetadataItem } = useRecordTableContextOrThrow();
const {
onPointerCancel: handlePointerCancel,
onPointerDown: handlePointerDown,
onPointerUp: handlePointerUp,
} = useDisableDragSelectOnPointerDown();
const isRecordTableColumnHeadersReadOnly = useAtomComponentStateValue(
isRecordTableColumnHeadersReadOnlyComponentState,
);
@@ -83,6 +91,9 @@ export const RecordTableHeaderCell = ({
getRecordTableColumnFieldWidthClassName(recordFieldIndex),
)}
key={recordField.fieldMetadataItemId}
onPointerCancel={handlePointerCancel}
onPointerDown={handlePointerDown}
onPointerUp={handlePointerUp}
shouldDisplayBorderBottom={shouldDisplayBorderBottom}
isResizing={isResizingAnyColumn}
isReadOnly={isRecordTableColumnHeadersReadOnly}
@@ -93,14 +104,16 @@ export const RecordTableHeaderCell = ({
position="left"
/>
)}
{isRecordTableColumnHeadersReadOnly ? (
<RecordTableColumnHead recordField={recordField} />
) : (
<RecordTableColumnHeadWithDropdown
recordField={recordField}
objectMetadataId={objectMetadataItem.id}
/>
)}
<RecordTableHeaderSortableHandle>
{isRecordTableColumnHeadersReadOnly ? (
<RecordTableColumnHead recordField={recordField} />
) : (
<RecordTableColumnHeadWithDropdown
recordField={recordField}
objectMetadataId={objectMetadataItem.id}
/>
)}
</RecordTableHeaderSortableHandle>
{isRecordTableColumnResizable && (
<RecordTableHeaderResizeHandler
recordFieldIndex={recordFieldIndex}
@@ -0,0 +1,94 @@
import React from 'react';
import { useRecordTableContextOrThrow } from '@/object-record/record-table/contexts/RecordTableContext';
import { RecordTableHeaderAddColumnButton } from '@/object-record/record-table/record-table-header/components/RecordTableHeaderAddColumnButton';
import { RecordTableHeaderCell } from '@/object-record/record-table/record-table-header/components/RecordTableHeaderCell';
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 { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { isDefined } from 'twenty-shared/utils';
export const RecordTableHeaderDnd = () => {
const { visibleRecordFields } = useRecordTableContextOrThrow();
const isRecordTableColumnHeadersReadOnly = useAtomComponentStateValue(
isRecordTableColumnHeadersReadOnlyComponentState,
);
const firstScrollableRecordField = visibleRecordFields[1];
const recordFieldsWithoutFirstTwo = visibleRecordFields.slice(2);
return (
<RecordTableHeaderDndKitProvider>
<RecordTableHeaderDroppableSlot
droppableId={RECORD_TABLE_HEADER_DROPPABLE_ID}
index={0}
disabled={isRecordTableColumnHeadersReadOnly}
>
<RecordTableHeaderDropTarget index={0} compact />
</RecordTableHeaderDroppableSlot>
{isDefined(firstScrollableRecordField) && (
<RecordTableHeaderSortableCell
key={firstScrollableRecordField.fieldMetadataItemId}
id={firstScrollableRecordField.fieldMetadataItemId}
index={0}
group={RECORD_TABLE_HEADER_DROPPABLE_ID}
disabled={isRecordTableColumnHeadersReadOnly}
>
<RecordTableHeaderFirstScrollableCell
firstScrollableRecordField={firstScrollableRecordField}
/>
</RecordTableHeaderSortableCell>
)}
{recordFieldsWithoutFirstTwo.map((recordField, index) => (
<React.Fragment key={recordField.fieldMetadataItemId}>
<RecordTableHeaderDroppableSlot
droppableId={RECORD_TABLE_HEADER_DROPPABLE_ID}
index={index + 1}
disabled={isRecordTableColumnHeadersReadOnly}
>
<RecordTableHeaderDropTarget index={index + 1} compact />
</RecordTableHeaderDroppableSlot>
<RecordTableHeaderSortableCell
id={recordField.fieldMetadataItemId}
index={index + 1}
group={RECORD_TABLE_HEADER_DROPPABLE_ID}
disabled={isRecordTableColumnHeadersReadOnly}
>
<RecordTableHeaderCell
key={recordField.fieldMetadataItemId}
recordField={recordField}
recordFieldIndex={index + 2}
/>
</RecordTableHeaderSortableCell>
</React.Fragment>
))}
<RecordTableHeaderDroppableSlot
droppableId={RECORD_TABLE_HEADER_DROPPABLE_ID}
index={visibleRecordFields.length}
disabled={isRecordTableColumnHeadersReadOnly}
>
<RecordTableHeaderDropTarget
index={visibleRecordFields.length}
compact
/>
</RecordTableHeaderDroppableSlot>
{isRecordTableColumnHeadersReadOnly ? (
<RecordTableHeaderEmptyLastColumn />
) : (
<RecordTableHeaderAddColumnButton />
)}
<RecordTableHeaderLastEmptyColumn />
</RecordTableHeaderDndKitProvider>
);
};
@@ -8,6 +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 { 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';
@@ -21,10 +22,22 @@ import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/
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';
export const RecordTableHeaderFirstScrollableCell = () => {
const { objectMetadataItem, visibleRecordFields } =
useRecordTableContextOrThrow();
type RecordTableHeaderFirstScrollableCellProps = {
firstScrollableRecordField: RecordField;
};
export const RecordTableHeaderFirstScrollableCell = ({
firstScrollableRecordField,
}: RecordTableHeaderFirstScrollableCellProps) => {
const { objectMetadataItem } = useRecordTableContextOrThrow();
const {
onPointerCancel: handlePointerCancel,
onPointerDown: handlePointerDown,
onPointerUp: handlePointerUp,
} = useDisableDragSelectOnPointerDown();
const isRecordTableColumnHeadersReadOnly = useAtomComponentStateValue(
isRecordTableColumnHeadersReadOnlyComponentState,
@@ -44,8 +57,6 @@ export const RecordTableHeaderFirstScrollableCell = () => {
0,
);
const recordField = visibleRecordFields[1] as RecordField | undefined;
const isRecordTableRowFocusActive = useAtomComponentStateValue(
isRecordTableRowFocusActiveComponentState,
);
@@ -73,14 +84,13 @@ export const RecordTableHeaderFirstScrollableCell = () => {
const isResizingAnyColumn = isDefined(resizedFieldMetadataId);
if (!recordField) {
return <></>;
}
return (
<RecordTableHeaderCellContainer
className={cx('header-cell', getRecordTableColumnFieldWidthClassName(1))}
key={recordField.fieldMetadataItemId}
key={firstScrollableRecordField.fieldMetadataItemId}
onPointerCancel={handlePointerCancel}
onPointerDown={handlePointerDown}
onPointerUp={handlePointerUp}
shouldDisplayBorderBottom={shouldDisplayBorderBottom}
zIndex={TABLE_Z_INDEX.headerColumns.headerColumnsNormal}
isResizing={isResizingAnyColumn}
@@ -89,14 +99,16 @@ export const RecordTableHeaderFirstScrollableCell = () => {
{isRecordTableColumnResizable && (
<RecordTableHeaderResizeHandler recordFieldIndex={1} position="left" />
)}
{isRecordTableColumnHeadersReadOnly ? (
<RecordTableColumnHead recordField={recordField} />
) : (
<RecordTableColumnHeadWithDropdown
recordField={recordField}
objectMetadataId={objectMetadataItem.id}
/>
)}
<RecordTableHeaderSortableHandle>
{isRecordTableColumnHeadersReadOnly ? (
<RecordTableColumnHead recordField={firstScrollableRecordField} />
) : (
<RecordTableColumnHeadWithDropdown
recordField={firstScrollableRecordField}
objectMetadataId={objectMetadataItem.id}
/>
)}
</RecordTableHeaderSortableHandle>
{isRecordTableColumnResizable && (
<RecordTableHeaderResizeHandler recordFieldIndex={1} position="right" />
)}
@@ -0,0 +1,71 @@
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';
const StyledDropTarget = styled.div<{
$compact?: boolean;
}>`
height: 100%;
min-height: ${({ $compact }) =>
$compact ? '100%' : themeCssVariables.spacing[2]};
position: relative;
transition: background-color 120ms ease-out;
width: 100%;
&::before {
background-color: ${themeCssVariables.color.blue};
border-radius: 0 ${themeCssVariables.border.radius.sm}
${themeCssVariables.border.radius.sm} 0;
content: '';
height: 100%;
left: 0;
opacity: 0;
position: absolute;
top: 0;
transform: scaleY(0.7);
transform-origin: center;
transition:
opacity 120ms ease-out,
transform 120ms ease-out;
width: 2px;
}
&[data-drag-over='true'] {
background-color: ${themeCssVariables.background.transparent.blue};
}
&[data-drag-over='true']::before {
opacity: 1;
transform: scaleY(1);
}
`;
type RecordTableHeaderDropTargetProps = {
index: number;
children?: ReactNode;
compact?: boolean;
};
export const RecordTableHeaderDropTarget = ({
index,
children,
compact = false,
}: RecordTableHeaderDropTargetProps) => {
const { activeDropTargetIndex } = useContext(RecordTableHeaderDndContext);
const isDragOver = isDefined(activeDropTargetIndex)
? activeDropTargetIndex === index
: false;
return (
<StyledDropTarget
$compact={compact}
data-drag-over={isDragOver ? 'true' : undefined}
>
{children}
</StyledDropTarget>
);
};
@@ -0,0 +1,45 @@
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';
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;
`;
const SLOT_COLLISION_PRIORITY = 1;
type RecordTableHeaderDroppableSlotProps = {
droppableId: string;
index: number;
children?: ReactNode;
disabled?: boolean;
collisionPriority?: number;
};
export const RecordTableHeaderDroppableSlot = ({
droppableId,
index,
children,
disabled = false,
collisionPriority = SLOT_COLLISION_PRIORITY,
}: RecordTableHeaderDroppableSlotProps) => {
const id = `${droppableId}::${index}`;
const data: RecordTableHeaderDndData = { droppableId, index };
const { ref } = useDroppable({
id,
disabled,
collisionPriority,
collisionDetector: pointerIntersection,
data,
});
return <StyledSlotWrapper ref={ref}>{children}</StyledSlotWrapper>;
};
@@ -0,0 +1,65 @@
import { RestrictToHorizontalAxis } from '@dnd-kit/abstract/modifiers';
import { SortableKeyboardPlugin } from '@dnd-kit/dom/sortable';
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';
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 RECORD_TABLE_HEADER_SORTABLE_TRANSITION = {
duration: 180,
easing: 'cubic-bezier(0.2, 0, 0, 1)',
idle: true,
};
const StyledSortableRoot = styled.div`
min-height: 0;
outline: none;
position: relative;
will-change: transform;
`;
type RecordTableHeaderSortableCellProps = {
children: ReactNode;
disabled?: boolean;
group: string;
id: string;
index: number;
};
export const RecordTableHeaderSortableCell = ({
id,
index,
group,
disabled = false,
children,
}: RecordTableHeaderSortableCellProps) => {
const { handleRef, ref } = useSortable({
id,
index,
group,
collisionPriority: SORTABLE_COLLISION_PRIORITY,
data: {
droppableId: group,
index: index,
},
disabled,
transition: RECORD_TABLE_HEADER_SORTABLE_TRANSITION,
plugins: PLUGINS_WITHOUT_OPTIMISTIC,
modifiers: RECORD_TABLE_HEADER_SORTABLE_MODIFIERS,
feedback: 'clone',
});
return (
<RecordTableHeaderSortableHandleRefContext.Provider value={handleRef}>
<StyledSortableRoot ref={ref}>{children}</StyledSortableRoot>
</RecordTableHeaderSortableHandleRefContext.Provider>
);
};
@@ -0,0 +1,28 @@
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>
);
};
@@ -0,0 +1 @@
export const RECORD_TABLE_HEADER_DROPPABLE_ID = 'record-table-header-droppable';
@@ -0,0 +1,10 @@
import { createContext } from 'react';
type RecordTableHeaderDndContextValue = {
activeDropTargetIndex: number | null;
};
export const RecordTableHeaderDndContext =
createContext<RecordTableHeaderDndContextValue>({
activeDropTargetIndex: null,
});
@@ -0,0 +1,5 @@
import { createContext } from 'react';
export const RecordTableHeaderSortableHandleRefContext = createContext<
((element: Element | null) => void) | undefined
>(undefined);
@@ -0,0 +1,197 @@
import { type DragDropProvider } from '@dnd-kit/react';
import { useStore } from 'jotai';
import { type ComponentProps, useCallback, 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 { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
type DragStartPayload = Parameters<
NonNullable<
ComponentProps<
typeof DragDropProvider<RecordTableHeaderDndData>
>['onDragStart']
>
>[0];
type DragMovePayload = Parameters<
NonNullable<
ComponentProps<
typeof DragDropProvider<RecordTableHeaderDndData>
>['onDragMove']
>
>[0];
type DragEndPayload = Parameters<
NonNullable<
ComponentProps<
typeof DragDropProvider<RecordTableHeaderDndData>
>['onDragEnd']
>
>[0];
export type RecordTableHeaderDndKitContextValues = {
activeDropTargetIndex: number | null;
};
export const useRecordTableHeaderDndKit = (): {
contextValues: RecordTableHeaderDndKitContextValues;
handlers: {
onDragStart: (event: DragStartPayload) => void;
onDragMove: (event: DragMovePayload) => void;
onDragEnd: (event: DragEndPayload) => void;
};
} => {
const store = useStore();
const { recordTableId, visibleRecordFields } = useRecordTableContextOrThrow();
const { labelIdentifierFieldMetadataItem } = useRecordIndexContextOrThrow();
const { reorderVisibleRecordFields } =
useReorderVisibleRecordFields(recordTableId);
const { saveViewFields } = useSaveCurrentViewFields();
const { setDragSelectionStartEnabled } = useDragSelect();
const { getScrollWrapperElement } = useScrollWrapperHTMLElement();
const isRecordTableHeaderDropProcessingCallbackState =
useAtomComponentStateCallbackState(
isRecordTableHeaderDropProcessingComponentState,
);
const isRecordTableDragColumnHidden = useAtomComponentStateValue(
isRecordTableDragColumnHiddenComponentState,
);
const isRecordTableCheckboxColumnHidden = useAtomComponentStateValue(
isRecordTableCheckboxColumnHiddenComponentState,
);
const [activeDropTargetIndex, setActiveDropTargetIndex] = useState<
number | null
>(null);
const recordFieldsWithoutLabelIdentifier = visibleRecordFields.filter(
filterOutByProperty(
'fieldMetadataItemId',
labelIdentifierFieldMetadataItem?.id,
),
);
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 = useCallback(
({ 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,
],
);
const handleDragStart = (_event: DragStartPayload) => {
store.set(isRecordTableHeaderDropProcessingCallbackState, true);
setActiveDropTargetIndex(null);
};
const handleDragMove = useCallback(
(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;
});
},
[resolveDropFromPointerX, setActiveDropTargetIndex],
);
const handleDragEnd = (event: DragEndPayload) => {
const { operation } = event;
const source = operation.source;
setActiveDropTargetIndex(null);
setDragSelectionStartEnabled(true);
store.set(isRecordTableHeaderDropProcessingCallbackState, false);
if (event.canceled) return;
if (!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;
// 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,
});
saveViewFields([mapRecordFieldToViewField(updatedRecordField)]);
};
const contextValues: RecordTableHeaderDndKitContextValues = {
activeDropTargetIndex,
};
return {
contextValues,
handlers: {
onDragStart: handleDragStart,
onDragMove: handleDragMove,
onDragEnd: handleDragEnd,
},
};
};
@@ -0,0 +1,30 @@
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 { DND_KIT_SENSORS } from '@/ui/utilities/drag-and-drop/constants/DndKitSensors';
type RecordTableHeaderDndKitProviderProps = {
children: ReactNode;
};
export const RecordTableHeaderDndKitProvider = ({
children,
}: RecordTableHeaderDndKitProviderProps) => {
const { contextValues, handlers } = useRecordTableHeaderDndKit();
return (
<RecordTableHeaderDndContext.Provider value={contextValues}>
<DragDropProvider<RecordTableHeaderDndData>
sensors={DND_KIT_SENSORS}
onDragStart={handlers.onDragStart}
onDragMove={handlers.onDragMove}
onDragEnd={handlers.onDragEnd}
>
{children}
</DragDropProvider>
</RecordTableHeaderDndContext.Provider>
);
};
@@ -0,0 +1,4 @@
export type RecordTableHeaderDndData = {
index: number;
droppableId: string;
};
@@ -0,0 +1,70 @@
import { type RecordField } from '@/object-record/record-field/types/RecordField';
type ResolveRecordTableHeaderDropArgs = {
pointerX: number;
sourceIndex: number;
scrollWrapperElement: HTMLElement;
nonSortableColumnsWidth: number;
recordFields: RecordField[];
};
type ResolvedRecordTableHeaderDrop = {
sourceIndex: number;
dropTargetIndex: number;
destinationIndex: number;
};
const getDestinationIndexFromDropTargetIndex = ({
sourceIndex,
dropTargetIndex,
}: {
sourceIndex: number;
dropTargetIndex: number;
}) => (dropTargetIndex > sourceIndex ? dropTargetIndex - 1 : dropTargetIndex);
export const resolveRecordTableHeaderDrop = ({
pointerX,
sourceIndex,
scrollWrapperElement,
nonSortableColumnsWidth,
recordFields,
}: ResolveRecordTableHeaderDropArgs): ResolvedRecordTableHeaderDrop => {
const scrollContainerRect = scrollWrapperElement.getBoundingClientRect();
const contentX =
pointerX -
scrollContainerRect.left +
scrollWrapperElement.scrollLeft -
nonSortableColumnsWidth;
let left = 0;
for (const [index, field] of recordFields.entries()) {
const width = field.size;
const midpoint = left + width / 2;
if (contentX < midpoint) {
return {
sourceIndex,
dropTargetIndex: index,
destinationIndex: getDestinationIndexFromDropTargetIndex({
sourceIndex,
dropTargetIndex: index,
}),
};
}
left += width;
}
const dropTargetIndex = recordFields.length;
return {
sourceIndex,
dropTargetIndex,
destinationIndex: getDestinationIndexFromDropTargetIndex({
sourceIndex,
dropTargetIndex,
}),
};
};
@@ -0,0 +1,23 @@
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,
};
};
@@ -0,0 +1,9 @@
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,
});
@@ -0,0 +1,12 @@
import { PointerActivationConstraints } from '@dnd-kit/dom';
import { KeyboardSensor, PointerSensor } from '@dnd-kit/react';
// Pointer drags only start past 8px so clicks on draggable items still register.
export const DND_KIT_SENSORS = [
PointerSensor.configure({
activationConstraints: [
new PointerActivationConstraints.Distance({ value: 8 }),
],
}),
KeyboardSensor,
];