[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:
committed by
GitHub
parent
46642c81c9
commit
6e7d8ef96c
+3
-16
@@ -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}
|
||||
|
||||
+2
-29
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
+21
-8
@@ -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}
|
||||
|
||||
+94
@@ -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>
|
||||
);
|
||||
};
|
||||
+30
-18
@@ -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" />
|
||||
)}
|
||||
|
||||
+71
@@ -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>
|
||||
);
|
||||
};
|
||||
+45
@@ -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>;
|
||||
};
|
||||
+65
@@ -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>
|
||||
);
|
||||
};
|
||||
+28
@@ -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>
|
||||
);
|
||||
};
|
||||
+1
@@ -0,0 +1 @@
|
||||
export const RECORD_TABLE_HEADER_DROPPABLE_ID = 'record-table-header-droppable';
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
import { createContext } from 'react';
|
||||
|
||||
type RecordTableHeaderDndContextValue = {
|
||||
activeDropTargetIndex: number | null;
|
||||
};
|
||||
|
||||
export const RecordTableHeaderDndContext =
|
||||
createContext<RecordTableHeaderDndContextValue>({
|
||||
activeDropTargetIndex: null,
|
||||
});
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
import { createContext } from 'react';
|
||||
|
||||
export const RecordTableHeaderSortableHandleRefContext = createContext<
|
||||
((element: Element | null) => void) | undefined
|
||||
>(undefined);
|
||||
+197
@@ -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,
|
||||
},
|
||||
};
|
||||
};
|
||||
+30
@@ -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>
|
||||
);
|
||||
};
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
export type RecordTableHeaderDndData = {
|
||||
index: number;
|
||||
droppableId: string;
|
||||
};
|
||||
+70
@@ -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,
|
||||
}),
|
||||
};
|
||||
};
|
||||
+23
@@ -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,
|
||||
};
|
||||
};
|
||||
+9
@@ -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,
|
||||
});
|
||||
+12
@@ -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,
|
||||
];
|
||||
Reference in New Issue
Block a user