diff --git a/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexContainer.tsx b/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexContainer.tsx index e74b9e9be..28b6aa9a6 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexContainer.tsx +++ b/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexContainer.tsx @@ -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 = ({ - - } - onCurrentViewChange={(view) => { - if (!view) { - return; + + } + 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); - }} - /> - + 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); + }} + /> + + + {recordIndexViewType === ViewType.Table && ( <> )} + {recordIndexViewType === ViewType.Kanban && ( - <> + - + )} diff --git a/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexTableContainer.tsx b/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexTableContainer.tsx index 51a5caf58..31be052ff 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexTableContainer.tsx +++ b/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexTableContainer.tsx @@ -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} /> + ); diff --git a/packages/twenty-front/src/modules/object-record/record-table/components/GripCell.tsx b/packages/twenty-front/src/modules/object-record/record-table/components/GripCell.tsx new file mode 100644 index 000000000..b9900026d --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/components/GripCell.tsx @@ -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 ( + + + + + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-table/components/RecordTable.tsx b/packages/twenty-front/src/modules/object-record/record-table/components/RecordTable.tsx index 92280dfd1..2afb86e98 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/components/RecordTable.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/components/RecordTable.tsx @@ -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 = ({ - + )} diff --git a/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableBody.tsx b/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableBody.tsx index 3947067f4..640bfffee 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableBody.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableBody.tsx @@ -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 ( <> - - - {tableRowIds.map((recordId, rowIndex) => ( - - ))} - + + {tableRowIds.map((recordId, rowIndex) => { + return ( + + ); + })} + + } + /> ); diff --git a/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableHeader.tsx b/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableHeader.tsx index edc262e72..9fe828dd9 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableHeader.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableHeader.tsx @@ -70,6 +70,7 @@ export const RecordTableHeader = ({ return ( + 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 ( - - - - - {inView - ? visibleTableColumns.map((column, columnIndex) => ( - - - - )) - : visibleTableColumns.map((column) => ( - - ))} - - + + + {(draggableProvided, draggableSnapshot) => ( + { + 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} + > + + + + + {!draggableSnapshot.isDragging && } + + {inView || draggableSnapshot.isDragging + ? visibleTableColumns.map((column, columnIndex) => ( + + {draggableSnapshot.isDragging && columnIndex > 0 ? null : ( + + )} + + )) + : visibleTableColumns.map((column) => ( + + ))} + + + )} + ); }; diff --git a/packages/twenty-front/src/modules/object-record/record-table/components/RemoveSortingModal.tsx b/packages/twenty-front/src/modules/object-record/record-table/components/RemoveSortingModal.tsx new file mode 100644 index 000000000..9bfb93b60 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/components/RemoveSortingModal.tsx @@ -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 ( + <> + This is required to enable manual row reordering.} + onConfirmClick={() => handleRemoveClick()} + deleteButtonText={'Remove Sorting'} + /> + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-table/hooks/useComputeNewRowPosition.ts b/packages/twenty-front/src/modules/object-record/record-table/hooks/useComputeNewRowPosition.ts new file mode 100644 index 000000000..0ef47ec49 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/hooks/useComputeNewRowPosition.ts @@ -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 }; + }, + [], + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-table/states/isRemoveSortingModalOpenState.ts b/packages/twenty-front/src/modules/object-record/record-table/states/isRemoveSortingModalOpenState.ts new file mode 100644 index 000000000..9f8627f2a --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/states/isRemoveSortingModalOpenState.ts @@ -0,0 +1,6 @@ +import { createState } from 'twenty-ui'; + +export const isRemoveSortingModalOpenState = createState({ + key: 'isRemoveSortingModalOpenState', + defaultValue: false, +}); diff --git a/packages/twenty-front/src/modules/ui/input/components/IconListViewGrip.tsx b/packages/twenty-front/src/modules/ui/input/components/IconListViewGrip.tsx new file mode 100644 index 000000000..8c6c425d4 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/input/components/IconListViewGrip.tsx @@ -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; + +export const IconListViewGrip = (props: IconListViewGripProps) => { + const width = props.size ?? 8; + const height = props.size ?? 32; + + return ; +}; diff --git a/packages/twenty-front/src/modules/ui/input/components/list-view-grip.svg b/packages/twenty-front/src/modules/ui/input/components/list-view-grip.svg new file mode 100644 index 000000000..b8a82f167 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/input/components/list-view-grip.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/twenty-front/src/modules/ui/layout/draggable-list/components/DraggableTableBody.tsx b/packages/twenty-front/src/modules/ui/layout/draggable-list/components/DraggableTableBody.tsx new file mode 100644 index 000000000..c1513d828 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/layout/draggable-list/components/DraggableTableBody.tsx @@ -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 ( + + + {(provided) => ( + + + {draggableItems} + {provided.placeholder} + + )} + + + ); +};