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