mirror of
https://github.com/twentyhq/twenty.git
synced 2024-11-29 19:10:19 +03:00
# 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:
parent
81c4939812
commit
21dbd6441a
@ -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,45 +112,48 @@ export const RecordIndexContainer = ({
|
||||
<StyledContainer>
|
||||
<RecordFieldValueSelectorContextProvider>
|
||||
<SpreadsheetImportProvider>
|
||||
<ViewBar
|
||||
viewBarId={recordIndexId}
|
||||
optionsDropdownButton={
|
||||
<RecordIndexOptionsDropdown
|
||||
recordIndexId={recordIndexId}
|
||||
objectNameSingular={objectNameSingular}
|
||||
viewType={recordIndexViewType ?? ViewType.Table}
|
||||
/>
|
||||
}
|
||||
onCurrentViewChange={(view) => {
|
||||
if (!view) {
|
||||
return;
|
||||
<StyledContainerWithPadding>
|
||||
<ViewBar
|
||||
viewBarId={recordIndexId}
|
||||
optionsDropdownButton={
|
||||
<RecordIndexOptionsDropdown
|
||||
recordIndexId={recordIndexId}
|
||||
objectNameSingular={objectNameSingular}
|
||||
viewType={recordIndexViewType ?? ViewType.Table}
|
||||
/>
|
||||
}
|
||||
onCurrentViewChange={(view) => {
|
||||
if (!view) {
|
||||
return;
|
||||
}
|
||||
|
||||
onViewFieldsChange(view.viewFields);
|
||||
setTableFilters(
|
||||
mapViewFiltersToFilters(view.viewFilters, filterDefinitions),
|
||||
);
|
||||
setRecordIndexFilters(
|
||||
mapViewFiltersToFilters(view.viewFilters, filterDefinitions),
|
||||
);
|
||||
setTableSorts(
|
||||
mapViewSortsToSorts(view.viewSorts, sortDefinitions),
|
||||
);
|
||||
setRecordIndexSorts(
|
||||
mapViewSortsToSorts(view.viewSorts, sortDefinitions),
|
||||
);
|
||||
setRecordIndexViewType(view.type);
|
||||
setRecordIndexViewKanbanFieldMetadataIdState(
|
||||
view.kanbanFieldMetadataId,
|
||||
);
|
||||
setRecordIndexIsCompactModeActive(view.isCompact);
|
||||
}}
|
||||
/>
|
||||
<RecordIndexViewBarEffect
|
||||
objectNamePlural={objectNamePlural}
|
||||
viewBarId={recordIndexId}
|
||||
/>
|
||||
onViewFieldsChange(view.viewFields);
|
||||
setTableFilters(
|
||||
mapViewFiltersToFilters(view.viewFilters, filterDefinitions),
|
||||
);
|
||||
setRecordIndexFilters(
|
||||
mapViewFiltersToFilters(view.viewFilters, filterDefinitions),
|
||||
);
|
||||
setTableSorts(
|
||||
mapViewSortsToSorts(view.viewSorts, sortDefinitions),
|
||||
);
|
||||
setRecordIndexSorts(
|
||||
mapViewSortsToSorts(view.viewSorts, sortDefinitions),
|
||||
);
|
||||
setRecordIndexViewType(view.type);
|
||||
setRecordIndexViewKanbanFieldMetadataIdState(
|
||||
view.kanbanFieldMetadataId,
|
||||
);
|
||||
setRecordIndexIsCompactModeActive(view.isCompact);
|
||||
}}
|
||||
/>
|
||||
<RecordIndexViewBarEffect
|
||||
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>
|
||||
|
@ -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} />
|
||||
</>
|
||||
);
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
)}
|
||||
|
@ -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) => (
|
||||
<RecordTableRow
|
||||
key={recordId}
|
||||
recordId={recordId}
|
||||
rowIndex={rowIndex}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
<DraggableTableBody
|
||||
objectNameSingular={objectNameSingular}
|
||||
recordTableId={recordTableId}
|
||||
draggableItems={
|
||||
<>
|
||||
{tableRowIds.map((recordId, rowIndex) => {
|
||||
return (
|
||||
<RecordTableRow
|
||||
key={recordId}
|
||||
recordId={recordId}
|
||||
rowIndex={rowIndex}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<RecordTableBodyFetchMoreLoader objectNameSingular={objectNameSingular} />
|
||||
</>
|
||||
);
|
||||
|
@ -70,6 +70,7 @@ export const RecordTableHeader = ({
|
||||
return (
|
||||
<StyledTableHead data-select-disable>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th
|
||||
style={{
|
||||
width: 30,
|
||||
|
@ -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`
|
||||
background-color: ${({ theme }) => theme.background.primary};
|
||||
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,31 +97,60 @@ export const RecordTableRow = ({
|
||||
}}
|
||||
>
|
||||
<RecordValueSetterEffect recordId={recordId} />
|
||||
<tr
|
||||
ref={elementRef}
|
||||
data-testid={`row-id-${recordId}`}
|
||||
data-selectable-id={recordId}
|
||||
>
|
||||
<StyledTd>
|
||||
<CheckboxCell />
|
||||
</StyledTd>
|
||||
{inView
|
||||
? visibleTableColumns.map((column, columnIndex) => (
|
||||
<RecordTableCellContext.Provider
|
||||
value={{
|
||||
columnDefinition: column,
|
||||
columnIndex,
|
||||
}}
|
||||
key={column.fieldMetadataId}
|
||||
>
|
||||
<RecordTableCellFieldContextWrapper />
|
||||
</RecordTableCellContext.Provider>
|
||||
))
|
||||
: visibleTableColumns.map((column) => (
|
||||
<td key={column.fieldMetadataId}></td>
|
||||
))}
|
||||
<td></td>
|
||||
</tr>
|
||||
|
||||
<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
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
{...draggableProvided.dragHandleProps}
|
||||
data-select-disable
|
||||
>
|
||||
<GripCell isDragging={draggableSnapshot.isDragging} />
|
||||
</StyledTd>
|
||||
<StyledTd>
|
||||
{!draggableSnapshot.isDragging && <CheckboxCell />}
|
||||
</StyledTd>
|
||||
{inView || draggableSnapshot.isDragging
|
||||
? visibleTableColumns.map((column, columnIndex) => (
|
||||
<RecordTableCellContext.Provider
|
||||
value={{
|
||||
columnDefinition: column,
|
||||
columnIndex,
|
||||
}}
|
||||
key={column.fieldMetadataId}
|
||||
>
|
||||
{draggableSnapshot.isDragging && columnIndex > 0 ? null : (
|
||||
<RecordTableCellFieldContextWrapper />
|
||||
)}
|
||||
</RecordTableCellContext.Provider>
|
||||
))
|
||||
: visibleTableColumns.map((column) => (
|
||||
<StyledTd key={column.fieldMetadataId}></StyledTd>
|
||||
))}
|
||||
<StyledTd />
|
||||
</StyledTr>
|
||||
)}
|
||||
</Draggable>
|
||||
</RecordTableRowContext.Provider>
|
||||
);
|
||||
};
|
||||
|
@ -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'}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
@ -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 };
|
||||
},
|
||||
[],
|
||||
);
|
||||
};
|
@ -0,0 +1,6 @@
|
||||
import { createState } from 'twenty-ui';
|
||||
|
||||
export const isRemoveSortingModalOpenState = createState<boolean>({
|
||||
key: 'isRemoveSortingModalOpenState',
|
||||
defaultValue: false,
|
||||
});
|
@ -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} />;
|
||||
};
|
@ -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 |
@ -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>
|
||||
);
|
||||
};
|
Loading…
Reference in New Issue
Block a user