diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-header/components/RecordTableHeader.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-header/components/RecordTableHeader.tsx
index 01ba0b11814f1..65c4187c9f0f1 100644
--- a/packages/twenty-front/src/modules/object-record/record-table/record-table-header/components/RecordTableHeader.tsx
+++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-header/components/RecordTableHeader.tsx
@@ -5,6 +5,7 @@ import { RecordTableHeaderAddColumnButton } from '@/object-record/record-table/r
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 { RecordTableHeaderDragDropColumn } from '@/object-record/record-table/record-table-header/components/RecordTableHeaderDragDropColumn';
+import { RecordTableHeaderDragDropContext } from '@/object-record/record-table/record-table-header/components/RecordTableHeaderDragDropContext';
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';
@@ -16,6 +17,7 @@ import { isRecordTableDragColumnHiddenComponentState } from '@/object-record/rec
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { styled } from '@linaria/react';
import { filterOutByProperty } from 'twenty-shared/utils';
+import { Droppable } from '@hello-pangea/dnd';
const StyledHeaderContainer = styled.div`
display: flex;
@@ -53,28 +55,46 @@ export const RecordTableHeader = () => {
useResizeTableHeader();
return (
-
- {!isRecordTableDragColumnHidden && }
- {!isRecordTableCheckboxColumnHidden && (
-
- )}
-
-
- {recordFieldsWithoutLabelIdentifierAndFirstOne.map(
- (recordField, index) => (
-
- ),
- )}
- {isRecordTableColumnHeadersReadOnly ? (
-
- ) : (
-
- )}
-
-
+
+
+ {(droppableProvided) => (
+
+ {!isRecordTableDragColumnHidden && (
+
+ )}
+ {!isRecordTableCheckboxColumnHidden && (
+
+ )}
+
+
+ {recordFieldsWithoutLabelIdentifierAndFirstOne.map(
+ (recordField, index) => (
+
+ ),
+ )}
+ {isRecordTableColumnHeadersReadOnly ? (
+
+ ) : (
+
+ )}
+
+ {droppableProvided.placeholder}
+
+ )}
+
+
);
};
diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-header/components/RecordTableHeaderCell.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-header/components/RecordTableHeaderCell.tsx
index 346235c8328b0..7b265406e48b7 100644
--- a/packages/twenty-front/src/modules/object-record/record-table/record-table-header/components/RecordTableHeaderCell.tsx
+++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-header/components/RecordTableHeaderCell.tsx
@@ -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 { isRecordTableHeaderDropProcessingComponentState } from '@/object-record/record-table/record-table-header/states/isRecordTableHeaderDropProcessingComponentState';
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,23 +15,36 @@ 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 { useDragSelect } from '@/ui/utilities/drag-select/hooks/useDragSelect';
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 { Draggable } from '@hello-pangea/dnd';
import { cx } from '@linaria/core';
+import { styled } from '@linaria/react';
+import { useCallback } from 'react';
import { isDefined } from 'twenty-shared/utils';
+const StyledDragHandle = styled.div`
+ height: 100%;
+ width: 100%;
+`;
+
type RecordTableHeaderCellProps = {
recordField: RecordField;
+ draggableIndex: number;
recordFieldIndex: number;
};
export const RecordTableHeaderCell = ({
recordField,
+ draggableIndex,
recordFieldIndex,
}: RecordTableHeaderCellProps) => {
const { objectMetadataItem } = useRecordTableContextOrThrow();
+ const { setDragSelectionStartEnabled } = useDragSelect();
+
const isRecordTableColumnHeadersReadOnly = useAtomComponentStateValue(
isRecordTableColumnHeadersReadOnlyComponentState,
);
@@ -69,6 +83,10 @@ export const RecordTableHeaderCell = ({
resizedFieldMetadataIdComponentState,
);
+ const isRecordTableHeaderDropProcessing = useAtomComponentStateValue(
+ isRecordTableHeaderDropProcessingComponentState,
+ );
+
const isResizingAnyColumn = isDefined(resizedFieldMetadataId);
const shouldDisplayBorderBottom =
@@ -76,37 +94,67 @@ export const RecordTableHeaderCell = ({
!isFirstRowActiveOrFocused ||
isRecordTableScrolledVertically;
+ const handlePointerDown = useCallback(() => {
+ setDragSelectionStartEnabled(false);
+ }, [setDragSelectionStartEnabled]);
+
+ const handlePointerUp = useCallback(() => {
+ setDragSelectionStartEnabled(true);
+ }, [setDragSelectionStartEnabled]);
+
return (
-
- {isRecordTableColumnResizable && (
-
- )}
- {isRecordTableColumnHeadersReadOnly ? (
-
- ) : (
-
- )}
- {isRecordTableColumnResizable && (
-
+ {(draggableProvided) => (
+
+ {isRecordTableColumnResizable && (
+
+ )}
+
+ {isRecordTableColumnHeadersReadOnly ? (
+
+ ) : (
+
+ )}
+
+ {isRecordTableColumnResizable && (
+
+ )}
+
)}
-
+
);
};
diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-header/components/RecordTableHeaderDragDropContext.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-header/components/RecordTableHeaderDragDropContext.tsx
new file mode 100644
index 0000000000000..ed2bf7abe4df1
--- /dev/null
+++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-header/components/RecordTableHeaderDragDropContext.tsx
@@ -0,0 +1,50 @@
+import { useProcessTableColumnDrop } from '@/object-record/record-table/record-table-header/hooks/useProcessTableColumnDrop';
+import { useResetRecordTableHeaderDragStates } from '@/object-record/record-table/record-table-header/hooks/useResetRecordTableHeaderDragState';
+import { isRecordTableHeaderDropProcessingComponentState } from '@/object-record/record-table/record-table-header/states/isRecordTableHeaderDropProcessingComponentState';
+import { useAtomComponentStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateCallbackState';
+import { DragDropContext, type OnDragEndResponder } from '@hello-pangea/dnd';
+import { useStore } from 'jotai';
+import { useCallback } from 'react';
+
+export const RecordTableHeaderDragDropContext = ({
+ children,
+}: React.PropsWithChildren) => {
+ const store = useStore();
+
+ const isRecordTableHeaderDropProcessingCallbackState =
+ useAtomComponentStateCallbackState(
+ isRecordTableHeaderDropProcessingComponentState,
+ );
+
+ const { processTableColumnDrop } = useProcessTableColumnDrop();
+
+ const { resetRecordTableHeaderDragStates } =
+ useResetRecordTableHeaderDragStates();
+
+ const handleDragStart = useCallback(() => {
+ store.set(isRecordTableHeaderDropProcessingCallbackState, true);
+ }, [store, isRecordTableHeaderDropProcessingCallbackState]);
+
+ const handleDragEnd: OnDragEndResponder = useCallback(
+ (result) => {
+ if (!result.destination) {
+ resetRecordTableHeaderDragStates();
+ return;
+ }
+
+ try {
+ processTableColumnDrop(result);
+ } catch (error) {
+ resetRecordTableHeaderDragStates();
+ throw error;
+ }
+ },
+ [processTableColumnDrop, resetRecordTableHeaderDragStates],
+ );
+
+ return (
+
+ {children}
+
+ );
+};
diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-header/components/RecordTableHeaderFirstScrollableCell.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-header/components/RecordTableHeaderFirstScrollableCell.tsx
index 320086b7aa026..9d95c3b00f414 100644
--- a/packages/twenty-front/src/modules/object-record/record-table/record-table-header/components/RecordTableHeaderFirstScrollableCell.tsx
+++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-header/components/RecordTableHeaderFirstScrollableCell.tsx
@@ -9,6 +9,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 { isRecordTableHeaderDropProcessingComponentState } from '@/object-record/record-table/record-table-header/states/isRecordTableHeaderDropProcessingComponentState';
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';
@@ -20,13 +21,24 @@ import { getRecordTableColumnFieldWidthClassName } from '@/object-record/record-
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 { Draggable } from '@hello-pangea/dnd';
import { cx } from '@linaria/core';
+import { styled } from '@linaria/react';
import { filterOutByProperty, isDefined } from 'twenty-shared/utils';
+import { useCallback } from 'react';
+import { useDragSelect } from '@/ui/utilities/drag-select/hooks/useDragSelect';
+
+const StyledDragHandle = styled.div`
+ height: 100%;
+ width: 100%;
+`;
export const RecordTableHeaderFirstScrollableCell = () => {
const { objectMetadataItem, visibleRecordFields } =
useRecordTableContextOrThrow();
+ const { setDragSelectionStartEnabled } = useDragSelect();
+
const isRecordTableColumnHeadersReadOnly = useAtomComponentStateValue(
isRecordTableColumnHeadersReadOnlyComponentState,
);
@@ -81,33 +93,75 @@ export const RecordTableHeaderFirstScrollableCell = () => {
const isResizingAnyColumn = isDefined(resizedFieldMetadataId);
+ const isRecordTableHeaderDropProcessing = useAtomComponentStateValue(
+ isRecordTableHeaderDropProcessingComponentState,
+ );
+
+ const handlePointerDown = useCallback(() => {
+ setDragSelectionStartEnabled(false);
+ }, [setDragSelectionStartEnabled]);
+
+ const handlePointerUp = useCallback(() => {
+ setDragSelectionStartEnabled(true);
+ }, [setDragSelectionStartEnabled]);
+
if (!recordField) {
return <>>;
}
return (
-
- {isRecordTableColumnResizable && (
-
- )}
- {isRecordTableColumnHeadersReadOnly ? (
-
- ) : (
-
- )}
- {isRecordTableColumnResizable && (
-
+ {(draggableProvided) => (
+
+ {isRecordTableColumnResizable && (
+
+ )}
+
+ {isRecordTableColumnHeadersReadOnly ? (
+
+ ) : (
+
+ )}
+
+ {isRecordTableColumnResizable && (
+
+ )}
+
)}
-
+
);
};
diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-header/hooks/useProcessTableColumnDrop.ts b/packages/twenty-front/src/modules/object-record/record-table/record-table-header/hooks/useProcessTableColumnDrop.ts
new file mode 100644
index 0000000000000..f413f270db110
--- /dev/null
+++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-header/hooks/useProcessTableColumnDrop.ts
@@ -0,0 +1,33 @@
+import { type DropResult } from '@hello-pangea/dnd';
+import { useCallback } from 'react';
+import { isDefined } from 'twenty-shared/utils';
+
+import { useReorderColumns } from '@/object-record/record-table/record-table-header/hooks/useReorderColumns';
+import { useResetRecordTableHeaderDragStates } from '@/object-record/record-table/record-table-header/hooks/useResetRecordTableHeaderDragState';
+
+export const useProcessTableColumnDrop = () => {
+ const { reorderColumns } = useReorderColumns();
+
+ const { resetRecordTableHeaderDragStates } =
+ useResetRecordTableHeaderDragStates();
+
+ const processTableColumnDrop = useCallback(
+ async (headerColumnDropResult: DropResult) => {
+ const source = headerColumnDropResult.source;
+ const destination = headerColumnDropResult.destination;
+
+ if (!isDefined(source) || !isDefined(destination)) return;
+
+ try {
+ await reorderColumns({ source, destination });
+ } finally {
+ resetRecordTableHeaderDragStates();
+ }
+ },
+ [reorderColumns, resetRecordTableHeaderDragStates],
+ );
+
+ return {
+ processTableColumnDrop,
+ };
+};
diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-header/hooks/useReorderColumns.ts b/packages/twenty-front/src/modules/object-record/record-table/record-table-header/hooks/useReorderColumns.ts
new file mode 100644
index 0000000000000..60470577e67a3
--- /dev/null
+++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-header/hooks/useReorderColumns.ts
@@ -0,0 +1,97 @@
+import { useUpdateRecordField } from '@/object-record/record-field/hooks/useUpdateRecordField';
+import { visibleRecordFieldsComponentSelector } from '@/object-record/record-field/states/visibleRecordFieldsComponentSelector';
+import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext';
+import { useAtomComponentSelectorCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorCallbackState';
+import { useSaveCurrentViewFields } from '@/views/hooks/useSaveCurrentViewFields';
+import { mapRecordFieldToViewField } from '@/views/utils/mapRecordFieldToViewField';
+import { type DraggableLocation } from '@hello-pangea/dnd';
+import { useStore } from 'jotai';
+import { useCallback } from 'react';
+import { filterOutByProperty } from 'twenty-shared/utils';
+import { moveArrayItem } from '~/utils/array/moveArrayItem';
+
+export const useReorderColumns = (recordTableId?: string) => {
+ const store = useStore();
+
+ const { labelIdentifierFieldMetadataItem } = useRecordIndexContextOrThrow();
+
+ const visibleRecordFieldsCallbackState =
+ useAtomComponentSelectorCallbackState(
+ visibleRecordFieldsComponentSelector,
+ recordTableId,
+ );
+
+ const { saveViewFields } = useSaveCurrentViewFields();
+
+ const { updateRecordField } = useUpdateRecordField(recordTableId);
+
+ const reorderColumns = useCallback(
+ async ({
+ source,
+ destination,
+ }: {
+ source: DraggableLocation;
+ destination: DraggableLocation;
+ }) => {
+ if (source.index === destination.index) {
+ return;
+ }
+
+ const draggableFields = store
+ .get(visibleRecordFieldsCallbackState)
+ .filter(
+ filterOutByProperty(
+ 'fieldMetadataItemId',
+ labelIdentifierFieldMetadataItem?.id,
+ ),
+ );
+
+ if (
+ source.index < 0 ||
+ destination.index < 0 ||
+ source.index >= draggableFields.length ||
+ destination.index >= draggableFields.length
+ ) {
+ return;
+ }
+
+ const reorderedFields = moveArrayItem(draggableFields, {
+ fromIndex: source.index,
+ toIndex: destination.index,
+ });
+
+ const orderedPositions = draggableFields.map((field) => field.position);
+
+ const updatedViewFields = reorderedFields
+ .map((field, index) => {
+ const newPosition = orderedPositions[index];
+
+ if (field.position === newPosition) {
+ return null;
+ }
+
+ const updatedField = updateRecordField(field.fieldMetadataItemId, {
+ position: newPosition,
+ });
+
+ return mapRecordFieldToViewField(updatedField);
+ })
+ .filter((viewField) => viewField !== null);
+
+ if (updatedViewFields.length === 0) {
+ return;
+ }
+
+ await saveViewFields(updatedViewFields);
+ },
+ [
+ store,
+ visibleRecordFieldsCallbackState,
+ labelIdentifierFieldMetadataItem?.id,
+ updateRecordField,
+ saveViewFields,
+ ],
+ );
+
+ return { reorderColumns };
+};
diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-header/hooks/useResetRecordTableHeaderDragState.ts b/packages/twenty-front/src/modules/object-record/record-table/record-table-header/hooks/useResetRecordTableHeaderDragState.ts
new file mode 100644
index 0000000000000..b16ce3bfdea33
--- /dev/null
+++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-header/hooks/useResetRecordTableHeaderDragState.ts
@@ -0,0 +1,29 @@
+import { isRecordTableHeaderDropProcessingComponentState } from '@/object-record/record-table/record-table-header/states/isRecordTableHeaderDropProcessingComponentState';
+import { useDragSelect } from '@/ui/utilities/drag-select/hooks/useDragSelect';
+import { useAtomComponentStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateCallbackState';
+import { useCallback } from 'react';
+import { useStore } from 'jotai';
+
+export const useResetRecordTableHeaderDragStates = () => {
+ const store = useStore();
+
+ const isRecordTableHeaderDropProcessingCallbackState =
+ useAtomComponentStateCallbackState(
+ isRecordTableHeaderDropProcessingComponentState,
+ );
+
+ const { setDragSelectionStartEnabled } = useDragSelect();
+
+ const resetRecordTableHeaderDragStates = useCallback(() => {
+ store.set(isRecordTableHeaderDropProcessingCallbackState, false);
+ setDragSelectionStartEnabled(true);
+ }, [
+ store,
+ isRecordTableHeaderDropProcessingCallbackState,
+ setDragSelectionStartEnabled,
+ ]);
+
+ return {
+ resetRecordTableHeaderDragStates,
+ };
+};
diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-header/states/isRecordTableHeaderDropProcessingComponentState.ts b/packages/twenty-front/src/modules/object-record/record-table/record-table-header/states/isRecordTableHeaderDropProcessingComponentState.ts
new file mode 100644
index 0000000000000..cf7a2abb8655c
--- /dev/null
+++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-header/states/isRecordTableHeaderDropProcessingComponentState.ts
@@ -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({
+ key: 'isRecordTableHeaderDropProcessingComponentState',
+ defaultValue: false,
+ componentInstanceContext: RecordTableComponentInstanceContext,
+ });