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, + });