feat: implement row re-ordering via drag and drop (#4846) (#5580)

# Context

Currently, the Twenty platform incorporates "positions" for rows on the
backend, which are functional within the Kanban view. However, this
advantageous feature has yet to be leveraged within list views.

# Feature Proposal

## Implement Row-Reordering via Drag-and-Drop on Frontend (#4846)

- This PR addresses the implementation of row reordering via
Drag-and-Drop on frontend. The objective is to enrich the list view
functionality by introducing a grip that dynamically appears upon
hovering over the left space preceding the checkbox container. This grip
empowers users to effortlessly reposition rows within the list.

#### Proposal Highlights:

- **Enhanced User Interaction**: Introduce a draggable grip to
facilitate intuitive row reordering, enhancing user experience and
productivity.
- **Preservation of Design Aesthetics**: By excluding the grip from the
first row and maintaining the left gap, we uphold design integrity while
providing enhanced functionality.
- **Consistency with Existing Features**: Align with existing
drag-and-drop functionalities within the platform, such as Favorites
re-ordering or Fields re-ordering in table options, ensuring a seamless
user experience.

## Implementation Strategy

### Grip Implementation:

- Add an extra column to the table (header + body) to accommodate the
grip cell, which displays the IconListViewGrip when its container is
hovered over.
- Ensure the preceding left-space is maintained by setting the
corresponding width for this column and removing padding from the table
container (while maintaining padding in other page elements and the
Kanban view for coherence).

### Row Drag and Drop:

- Implement row drag-and-drop functionality using draggableList and
draggableItem, based on the existing logic in the KanbanView for row
repositioning.
- Create a draggableTableBody and apply it to the current
RecordTableBody (including modal open triggering - if dragging while
sorting exists).
- Apply the draggableItem logic to RecordTableRow.

### Sorting Modal Implementation:

- Reuse the ConfirmationModel for the removeSortingModal.
- Create a new state to address the modal.
- Implement sorting removal logic in the corresponding modal file.

## Outcome

- The left-side margin is preserved.
- The grip appears upon hovering.
- Dragging a row gives it and maintains an aesthetic appearance.
- Dropping a row updates its position, and the table gets a new
configuration.
- If sorting is present, dropping a row activates a modal. Clicking on
the "Remove Sorting" button will deactivate any sorting (clicking on
"Cancel" will close the modal), and the table will revert to its default
configuration by position, allowing manual row reordering. Row
repositioning will not occur if sorting is not removed.
- The record table maintains its overall consistency.
- There are no conflicts with DragSelect functionality.


https://github.com/twentyhq/twenty/assets/92337535/73de96cc-4aac-41a9-b4ec-2b8d1c928d04

---------

Co-authored-by: Vasco Paisana <vasco.paisana@tecnico.ulisboa.pt>
Co-authored-by: Lucas Bordeau <bordeau.lucas@gmail.com>
Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
This commit is contained in:
kikoleitao 2024-06-13 16:22:51 +01:00 committed by GitHub
parent 81c4939812
commit 21dbd6441a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 481 additions and 105 deletions

View File

@ -34,6 +34,9 @@ const StyledContainer = styled.div`
height: 100%;
width: 100%;
overflow: auto;
`;
const StyledContainerWithPadding = styled.div`
padding-left: ${({ theme }) => theme.table.horizontalCellPadding};
`;
@ -109,6 +112,7 @@ export const RecordIndexContainer = ({
<StyledContainer>
<RecordFieldValueSelectorContextProvider>
<SpreadsheetImportProvider>
<StyledContainerWithPadding>
<ViewBar
viewBarId={recordIndexId}
optionsDropdownButton={
@ -147,7 +151,9 @@ export const RecordIndexContainer = ({
objectNamePlural={objectNamePlural}
viewBarId={recordIndexId}
/>
</StyledContainerWithPadding>
</SpreadsheetImportProvider>
{recordIndexViewType === ViewType.Table && (
<>
<RecordIndexTableContainer
@ -163,8 +169,9 @@ export const RecordIndexContainer = ({
/>
</>
)}
{recordIndexViewType === ViewType.Kanban && (
<>
<StyledContainerWithPadding>
<RecordIndexBoardContainer
recordBoardId={recordIndexId}
viewBarId={recordIndexId}
@ -179,7 +186,7 @@ export const RecordIndexContainer = ({
objectNameSingular={objectNameSingular}
recordBoardId={recordIndexId}
/>
</>
</StyledContainerWithPadding>
)}
</RecordFieldValueSelectorContextProvider>
</StyledContainer>

View File

@ -2,6 +2,7 @@ import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
import { RecordUpdateHookParams } from '@/object-record/record-field/contexts/FieldContext';
import { RecordTableActionBar } from '@/object-record/record-table/action-bar/components/RecordTableActionBar';
import { RecordTableWithWrappers } from '@/object-record/record-table/components/RecordTableWithWrappers';
import { RemoveSortingModal } from '@/object-record/record-table/components/RemoveSortingModal';
import { RecordTableContextMenu } from '@/object-record/record-table/context-menu/components/RecordTableContextMenu';
type RecordIndexTableContainerProps = {
@ -38,6 +39,7 @@ export const RecordIndexTableContainer = ({
createRecord={createRecord}
/>
<RecordTableActionBar recordTableId={recordTableId} />
<RemoveSortingModal recordTableId={recordTableId} />
<RecordTableContextMenu recordTableId={recordTableId} />
</>
);

View File

@ -0,0 +1,29 @@
import styled from '@emotion/styled';
import { IconListViewGrip } from '@/ui/input/components/IconListViewGrip';
const StyledContainer = styled.div`
cursor: grab;
width: 16px;
height: 32px;
z-index: 200;
display: flex;
&:hover .icon {
opacity: 1;
}
`;
const StyledIconWrapper = styled.div<{ isDragging: boolean }>`
opacity: ${({ isDragging }) => (isDragging ? 1 : 0)};
transition: opacity 0.1s;
`;
export const GripCell = ({ isDragging }: { isDragging: boolean }) => {
return (
<StyledContainer>
<StyledIconWrapper className="icon" isDragging={isDragging}>
<IconListViewGrip />
</StyledIconWrapper>
</StyledContainer>
);
};

View File

@ -43,15 +43,14 @@ const StyledTable = styled.table<{
border-right-color: transparent;
}
:first-of-type {
border-left-color: transparent;
border-right-color: transparent;
border-top-color: transparent;
border-bottom-color: transparent;
}
}
td {
border-bottom: 1px solid ${({ theme }) => theme.border.color.light};
color: ${({ theme }) => theme.font.color.primary};
padding: 0;
border-right: 1px solid ${({ theme }) => theme.border.color.light};
text-align: left;
@ -60,8 +59,8 @@ const StyledTable = styled.table<{
border-right-color: transparent;
}
:first-of-type {
border-left-color: transparent;
border-right-color: transparent;
border-top-color: transparent;
border-bottom-color: transparent;
}
}
@ -70,35 +69,58 @@ const StyledTable = styled.table<{
border-right: 1px solid ${({ theme }) => theme.border.color.light};
}
thead th:nth-of-type(-n + 2),
tbody td:nth-of-type(-n + 2) {
thead th {
position: sticky;
z-index: 2;
border-right: none;
top: 0;
z-index: 9;
}
thead th:nth-of-type(1),
thead th:nth-of-type(2),
thead th:nth-of-type(3) {
z-index: 12;
background-color: ${({ theme }) => theme.background.primary};
}
thead th:nth-of-type(1) {
width: 9px;
left: 0;
border-right-color: ${({ theme }) => theme.background.primary};
}
thead th:nth-of-type(2) {
left: 9px;
border-right-color: ${({ theme }) => theme.background.primary};
}
thead th:nth-of-type(3) {
left: 39px;
}
tbody td:nth-of-type(1),
tbody td:nth-of-type(2),
tbody td:nth-of-type(3) {
position: sticky;
z-index: 1;
}
tbody td:nth-of-type(1) {
left: 0;
z-index: 7;
}
// Label identifier column
thead th:nth-of-type(1),
thead th:nth-of-type(2) {
left: 0;
top: 0;
tbody td:nth-of-type(2) {
left: 9px;
z-index: 5;
}
tbody td:nth-of-type(3) {
left: 39px;
z-index: 6;
}
thead th:nth-of-type(n + 3) {
top: 0;
z-index: 5;
position: sticky;
}
thead th:nth-of-type(2),
tbody td:nth-of-type(2) {
left: calc(${({ theme }) => theme.table.checkboxColumnWidth} - 2px);
thead th:nth-of-type(3),
tbody td:nth-of-type(3) {
${({ freezeFirstColumns }) =>
freezeFirstColumns &&
css`
@ -125,11 +147,6 @@ const StyledTable = styled.table<{
`}
}
}
thead th:nth-of-type(3),
tbody td:nth-of-type(3) {
border-left: 1px solid ${({ theme }) => theme.border.color.light};
}
`;
type RecordTableProps = {
@ -229,7 +246,10 @@ export const RecordTable = ({
<StyledTable className="entity-table-cell">
<RecordTableHeader createRecord={createRecord} />
<RecordTableBodyEffect objectNameSingular={objectNameSingular} />
<RecordTableBody objectNameSingular={objectNameSingular} />
<RecordTableBody
objectNameSingular={objectNameSingular}
recordTableId={recordTableId}
/>
</StyledTable>
</RecordTableContext.Provider>
)}

View File

@ -1,16 +1,18 @@
import { useRecoilValue } from 'recoil';
import { RecordTableBodyFetchMoreLoader } from '@/object-record/record-table/components/RecordTableBodyFetchMoreLoader';
import { RecordTablePendingRow } from '@/object-record/record-table/components/RecordTablePendingRow';
import { RecordTableRow } from '@/object-record/record-table/components/RecordTableRow';
import { useRecordTableStates } from '@/object-record/record-table/hooks/internal/useRecordTableStates';
import { DraggableTableBody } from '@/ui/layout/draggable-list/components/DraggableTableBody';
type RecordTableBodyProps = {
objectNameSingular: string;
recordTableId: string;
};
export const RecordTableBody = ({
objectNameSingular,
recordTableId,
}: RecordTableBodyProps) => {
const { tableRowIdsState } = useRecordTableStates();
@ -18,16 +20,23 @@ export const RecordTableBody = ({
return (
<>
<tbody>
<RecordTablePendingRow />
{tableRowIds.map((recordId, rowIndex) => (
<DraggableTableBody
objectNameSingular={objectNameSingular}
recordTableId={recordTableId}
draggableItems={
<>
{tableRowIds.map((recordId, rowIndex) => {
return (
<RecordTableRow
key={recordId}
recordId={recordId}
rowIndex={rowIndex}
/>
))}
</tbody>
);
})}
</>
}
/>
<RecordTableBodyFetchMoreLoader objectNameSingular={objectNameSingular} />
</>
);

View File

@ -70,6 +70,7 @@ export const RecordTableHeader = ({
return (
<StyledTableHead data-select-disable>
<tr>
<th></th>
<th
style={{
width: 30,

View File

@ -1,6 +1,8 @@
import { useContext } from 'react';
import { useInView } from 'react-intersection-observer';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { Draggable } from '@hello-pangea/dnd';
import { useRecoilValue } from 'recoil';
import { getBasePathToShowPage } from '@/object-metadata/utils/getBasePathToShowPage';
@ -13,6 +15,7 @@ import { useRecordTableStates } from '@/object-record/record-table/hooks/interna
import { ScrollWrapperContext } from '@/ui/utilities/scroll/components/ScrollWrapper';
import { CheckboxCell } from './CheckboxCell';
import { GripCell } from './GripCell';
type RecordTableRowProps = {
recordId: string;
@ -21,7 +24,38 @@ type RecordTableRowProps = {
};
const StyledTd = styled.td`
position: relative;
user-select: none;
`;
const StyledTr = styled.tr<{ isDragging: boolean }>`
border: 1px solid transparent;
transition: border-left-color 0.2s ease-in-out;
td:nth-of-type(-n + 2) {
background-color: ${({ theme }) => theme.background.primary};
border-right-color: ${({ theme }) => theme.background.primary};
}
${({ isDragging }) =>
isDragging &&
`
td:nth-of-type(1) {
background-color: transparent;
border-color: transparent;
}
td:nth-of-type(2) {
background-color: transparent;
border-color: transparent;
}
td:nth-of-type(3) {
background-color: transparent;
border-color: transparent;
}
`}
`;
export const RecordTableRow = ({
@ -45,6 +79,8 @@ export const RecordTableRow = ({
rootMargin: '1000px',
});
const theme = useTheme();
return (
<RecordTableRowContext.Provider
value={{
@ -61,15 +97,40 @@ export const RecordTableRow = ({
}}
>
<RecordValueSetterEffect recordId={recordId} />
<tr
ref={elementRef}
<Draggable key={recordId} draggableId={recordId} index={rowIndex}>
{(draggableProvided, draggableSnapshot) => (
<StyledTr
ref={(node) => {
elementRef(node);
draggableProvided.innerRef(node);
}}
// eslint-disable-next-line react/jsx-props-no-spreading
{...draggableProvided.draggableProps}
style={{
...draggableProvided.draggableProps.style,
background: draggableSnapshot.isDragging
? theme.background.transparent.light
: 'none',
borderColor: draggableSnapshot.isDragging
? `${theme.border.color.medium}`
: 'transparent',
}}
isDragging={draggableSnapshot.isDragging}
data-testid={`row-id-${recordId}`}
data-selectable-id={recordId}
>
<StyledTd>
<CheckboxCell />
<StyledTd
// eslint-disable-next-line react/jsx-props-no-spreading
{...draggableProvided.dragHandleProps}
data-select-disable
>
<GripCell isDragging={draggableSnapshot.isDragging} />
</StyledTd>
{inView
<StyledTd>
{!draggableSnapshot.isDragging && <CheckboxCell />}
</StyledTd>
{inView || draggableSnapshot.isDragging
? visibleTableColumns.map((column, columnIndex) => (
<RecordTableCellContext.Provider
value={{
@ -78,14 +139,18 @@ export const RecordTableRow = ({
}}
key={column.fieldMetadataId}
>
{draggableSnapshot.isDragging && columnIndex > 0 ? null : (
<RecordTableCellFieldContextWrapper />
)}
</RecordTableCellContext.Provider>
))
: visibleTableColumns.map((column) => (
<td key={column.fieldMetadataId}></td>
<StyledTd key={column.fieldMetadataId}></StyledTd>
))}
<td></td>
</tr>
<StyledTd />
</StyledTr>
)}
</Draggable>
</RecordTableRowContext.Provider>
);
};

View File

@ -0,0 +1,44 @@
import { useRecoilState } from 'recoil';
import { isRemoveSortingModalOpenState } from '@/object-record/record-table/states/isRemoveSortingModalOpenState';
import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
import { useCombinedViewSorts } from '@/views/hooks/useCombinedViewSorts';
import { useGetCurrentView } from '@/views/hooks/useGetCurrentView';
export const RemoveSortingModal = ({
recordTableId,
}: {
recordTableId: string;
}) => {
const { currentViewWithCombinedFiltersAndSorts } =
useGetCurrentView(recordTableId);
const viewSorts = currentViewWithCombinedFiltersAndSorts?.viewSorts || [];
const fieldMetadataIds = viewSorts.map(
(viewSort) => viewSort.fieldMetadataId,
);
const isRemoveSortingModalOpen = useRecoilState(
isRemoveSortingModalOpenState,
);
const { removeCombinedViewSort } = useCombinedViewSorts(recordTableId);
const handleRemoveClick = () => {
fieldMetadataIds.forEach((id) => {
removeCombinedViewSort(id);
});
};
return (
<>
<ConfirmationModal
isOpen={isRemoveSortingModalOpen[0]}
setIsOpen={isRemoveSortingModalOpen[1]}
title={'Remove sorting?'}
subtitle={<>This is required to enable manual row reordering.</>}
onConfirmClick={() => handleRemoveClick()}
deleteButtonText={'Remove Sorting'}
/>
</>
);
};

View File

@ -0,0 +1,90 @@
import { DropResult } from '@hello-pangea/dnd';
import { useRecoilCallback } from 'recoil';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { isDefined } from '~/utils/isDefined';
export const useComputeNewRowPosition = () => {
return useRecoilCallback(
({ snapshot }) =>
(result: DropResult, tableRowIds: string[]) => {
if (!isDefined(result.destination)) {
return;
}
const draggedRecordId = result.draggableId;
const destinationIndex = result.destination.index;
const sourceIndex = result.source.index;
const recordBeforeId = tableRowIds[destinationIndex - 1];
const recordDestinationId = tableRowIds[destinationIndex];
const recordAfterDestinationId = tableRowIds[destinationIndex + 1];
const recordBefore = recordBeforeId
? snapshot
.getLoadable(recordStoreFamilyState(recordBeforeId))
.getValue()
: null;
const recordDestination = recordDestinationId
? snapshot
.getLoadable(recordStoreFamilyState(recordDestinationId))
.getValue()
: null;
const recordAfterDestination = recordAfterDestinationId
? snapshot
.getLoadable(recordStoreFamilyState(recordAfterDestinationId))
.getValue()
: null;
const computeNewPosition = (destIndex: number, sourceIndex: number) => {
const moveToFirstPosition = destIndex === 0;
const moveToLastPosition = destIndex === tableRowIds.length - 1;
const moveAfterSource = destIndex > sourceIndex;
const firstRecord = tableRowIds[0]
? snapshot
.getLoadable(recordStoreFamilyState(tableRowIds[0]))
.getValue()
: null;
const lastRecord = tableRowIds[tableRowIds.length - 1]
? snapshot
.getLoadable(
recordStoreFamilyState(tableRowIds[tableRowIds.length - 1]),
)
.getValue()
: null;
const firstRecordPosition = firstRecord?.position ?? 0;
if (moveToFirstPosition) {
if (firstRecordPosition <= 0) {
return firstRecordPosition - 1;
} else {
return firstRecordPosition / 2;
}
} else if (moveToLastPosition) {
return lastRecord?.position + 1;
} else if (moveAfterSource) {
const recordAfterDestinationPosition =
recordAfterDestination?.position ?? 0;
const recordDestinationPosition = recordDestination?.position ?? 0;
return (
(recordAfterDestinationPosition + recordDestinationPosition) / 2
);
} else {
return (
recordDestination?.position -
(recordDestination?.position - recordBefore?.position) / 2
);
}
};
const newPosition = computeNewPosition(destinationIndex, sourceIndex);
return { draggedRecordId, newPosition };
},
[],
);
};

View File

@ -0,0 +1,6 @@
import { createState } from 'twenty-ui';
export const isRemoveSortingModalOpenState = createState<boolean>({
key: 'isRemoveSortingModalOpenState',
defaultValue: false,
});

View File

@ -0,0 +1,11 @@
import IconListViewGripRaw from '@/ui/input/components/list-view-grip.svg?react';
import { IconComponentProps } from '@ui/display/icon/types/IconComponent';
type IconListViewGripProps = Pick<IconComponentProps, 'size' | 'stroke'>;
export const IconListViewGrip = (props: IconListViewGripProps) => {
const width = props.size ?? 8;
const height = props.size ?? 32;
return <IconListViewGripRaw height={height} width={width} />;
};

View File

@ -0,0 +1,5 @@
<svg width="8" height="32" xmlns="http://www.w3.org/2000/svg" fill="none">
<path stroke="null" id="svg_1" fill="#D6D6D6" d="m0,7.5l4.5,0c0.82843,0 1.5,0.67157 1.5,1.5c0,0.82843 -0.67157,1.5 -1.5,1.5l-4.5,0l0,-3z"/>
<path id="svg_2" fill="#D6D6D6" d="m0,14.5l4.5,0c0.82843,0 1.5,0.6716 1.5,1.5c0,0.8284 -0.67157,1.5 -1.5,1.5l-4.5,0l0,-3z"/>
<path id="svg_3" fill="#D6D6D6" d="m0,21.5l4.5,0c0.82843,0 1.5,0.6716 1.5,1.5c0,0.8284 -0.67157,1.5 -1.5,1.5l-4.5,0l0,-3z"/>
</svg>

After

Width:  |  Height:  |  Size: 478 B

View File

@ -0,0 +1,87 @@
import { useState } from 'react';
import styled from '@emotion/styled';
import { DragDropContext, Droppable, DropResult } from '@hello-pangea/dnd';
import { useRecoilValue, useSetRecoilState } from 'recoil';
import { v4 } from 'uuid';
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
import { RecordTablePendingRow } from '@/object-record/record-table/components/RecordTablePendingRow';
import { useRecordTableStates } from '@/object-record/record-table/hooks/internal/useRecordTableStates';
import { useComputeNewRowPosition } from '@/object-record/record-table/hooks/useComputeNewRowPosition';
import { isRemoveSortingModalOpenState } from '@/object-record/record-table/states/isRemoveSortingModalOpenState';
import { useGetCurrentView } from '@/views/hooks/useGetCurrentView';
import { isDefined } from '~/utils/isDefined';
type DraggableTableBodyProps = {
draggableItems: React.ReactNode;
objectNameSingular: string;
recordTableId: string;
};
const StyledTbody = styled.tbody`
overflow: hidden;
`;
export const DraggableTableBody = ({
objectNameSingular,
draggableItems,
recordTableId,
}: DraggableTableBodyProps) => {
const [v4Persistable] = useState(v4());
const { tableRowIdsState } = useRecordTableStates();
const tableRowIds = useRecoilValue(tableRowIdsState);
const { updateOneRecord: updateOneRow } = useUpdateOneRecord({
objectNameSingular,
});
const { currentViewWithCombinedFiltersAndSorts } =
useGetCurrentView(recordTableId);
const viewSorts = currentViewWithCombinedFiltersAndSorts?.viewSorts || [];
const setIsRemoveSortingModalOpenState = useSetRecoilState(
isRemoveSortingModalOpenState,
);
const computeNewRowPosition = useComputeNewRowPosition();
const handleDragEnd = (result: DropResult) => {
if (viewSorts.length > 0) {
setIsRemoveSortingModalOpenState(true);
return;
}
const computeResult = computeNewRowPosition(result, tableRowIds);
if (!isDefined(computeResult)) {
return;
}
updateOneRow({
idToUpdate: computeResult.draggedRecordId,
updateOneRecordInput: {
position: computeResult.newPosition,
},
});
};
return (
<DragDropContext onDragEnd={handleDragEnd}>
<Droppable droppableId={v4Persistable}>
{(provided) => (
<StyledTbody
ref={provided.innerRef}
// eslint-disable-next-line react/jsx-props-no-spreading
{...provided.droppableProps}
>
<RecordTablePendingRow />
{draggableItems}
{provided.placeholder}
</StyledTbody>
)}
</Droppable>
</DragDropContext>
);
};