🦣🦣🦣 Table virtualization (#14743)

This big PR implements table virtualization with an offset paging,
allowing a way more fluid UX.

It is a v1 that should be improved in the future with partial data
loading and optimization of the browser display performance of a row.

But with this PR we have the solid enough technical foundation, both
frontend and backend, to get to a smooth table UX.

Fixes and improvements after first successful round of development
(needed to have main clean) :

- [x] Delete should refresh virtualized portion only and reset all table
- [x] Fix add new : top and bottom
- [x] Table empty shouldn’t show when first loading
- [x] Fix d&d
- [x]  Fix sorts
- [x] Fix drag when scrolling after a full virtual page (it throws an
error)
- [x] Si update mais qu’on a un sort ou filter, alors il faut trigger le
refresh
- [x]  Reset scroll position between tables
- [x]  Reset scroll shadows between tables
- [x] Setup d&n for virtual list :
https://github.com/hello-pangea/dnd/blob/main/docs/patterns/virtual-lists.md
- [x]  Full table re-render when entering edit mode
- [x] Clean code and prepare for merge

Fixes https://github.com/twentyhq/core-team-issues/issues/1613 that
contains other bugs to be fixed before merge

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Lucas Bordeau
2025-10-11 12:08:10 +02:00
committed by GitHub
parent 720e93a68a
commit 68c86871dd
150 changed files with 3615 additions and 1712 deletions
@@ -1,7 +1,7 @@
import {
computeNewPositionOfRecordWithPosition,
computeNewPositionOfDraggedRecord,
type RecordWithPosition,
} from '@/object-record/utils/computeNewPositionOfRecordWithPosition';
} from '@/object-record/utils/computeNewPositionOfDraggedRecord';
const mockRecordsWithPosition: RecordWithPosition[] = [
{
@@ -24,7 +24,7 @@ const mockRecordsWithPosition: RecordWithPosition[] = [
describe('computeNewPositionOfRecordWithPosition', () => {
it('should compute first position', () => {
const newPosition = computeNewPositionOfRecordWithPosition({
const newPosition = computeNewPositionOfDraggedRecord({
arrayOfRecordsWithPosition: mockRecordsWithPosition,
idOfItemToMove: 'B',
idOfTargetItem: 'A',
@@ -34,7 +34,7 @@ describe('computeNewPositionOfRecordWithPosition', () => {
});
it('should compute last position', () => {
const newPosition = computeNewPositionOfRecordWithPosition({
const newPosition = computeNewPositionOfDraggedRecord({
arrayOfRecordsWithPosition: mockRecordsWithPosition,
idOfItemToMove: 'B',
idOfTargetItem: 'D',
@@ -44,7 +44,7 @@ describe('computeNewPositionOfRecordWithPosition', () => {
});
it('should compute intermediary position after target item', () => {
const newPosition = computeNewPositionOfRecordWithPosition({
const newPosition = computeNewPositionOfDraggedRecord({
arrayOfRecordsWithPosition: mockRecordsWithPosition,
idOfItemToMove: 'A',
idOfTargetItem: 'B',
@@ -54,7 +54,7 @@ describe('computeNewPositionOfRecordWithPosition', () => {
});
it('should compute intermediary position before target item', () => {
const newPosition = computeNewPositionOfRecordWithPosition({
const newPosition = computeNewPositionOfDraggedRecord({
arrayOfRecordsWithPosition: mockRecordsWithPosition,
idOfItemToMove: 'A',
idOfTargetItem: 'C',
@@ -0,0 +1,34 @@
export const computeNewEvenlySpacedPositions = ({
startingPosition,
endingPosition,
numberOfRecordsToInsertBetween,
}: {
startingPosition: number;
endingPosition: number;
numberOfRecordsToInsertBetween: number;
}) => {
const positionGapSize = endingPosition - startingPosition;
if (positionGapSize === 0) {
return Array.from(
{ length: numberOfRecordsToInsertBetween },
() => endingPosition,
);
}
if (positionGapSize < 0) {
throw new Error(
`Cannot compute positions because starting position (${startingPosition}) is after ending position (${endingPosition})`,
);
}
const positionStep = positionGapSize / (numberOfRecordsToInsertBetween + 1);
let newPositionsBetween: number[] = [];
for (let i = 1; i <= numberOfRecordsToInsertBetween; i++) {
newPositionsBetween.push(startingPosition + positionStep * i);
}
return newPositionsBetween;
};
@@ -6,7 +6,7 @@ export type RecordWithPosition = {
position: number;
};
export const computeNewPositionOfRecordWithPosition = ({
export const computeNewPositionOfDraggedRecord = ({
arrayOfRecordsWithPosition,
idOfItemToMove,
idOfTargetItem,
@@ -15,24 +15,16 @@ export const computeNewPositionOfRecordWithPosition = ({
idOfItemToMove: string;
idOfTargetItem: string;
}) => {
const itemToMove = arrayOfRecordsWithPosition.find(
(recordToFind) => recordToFind.id === idOfItemToMove,
);
const targetItem = arrayOfRecordsWithPosition.find(
(recordToFind) => recordToFind.id === idOfTargetItem,
);
if (!isDefined(itemToMove)) {
throw new Error(`Cannot find item to move for id : ${idOfItemToMove}`);
}
if (!isDefined(targetItem)) {
throw new Error(`Cannot find item to move for id : ${idOfTargetItem}`);
}
if (itemToMove.id === targetItem.id) {
return itemToMove.position;
if (idOfItemToMove === idOfTargetItem) {
return targetItem.position;
}
const targetPosition = targetItem.position;
@@ -44,23 +36,35 @@ export const computeNewPositionOfRecordWithPosition = ({
const indexOfItemToMove = sortedRecordsByAscendingPosition.findIndex(
(recordToFind) => recordToFind.id === idOfItemToMove,
);
const itemToMoveIsNotInTable = indexOfItemToMove === -1;
const indexOfTargetItem = sortedRecordsByAscendingPosition.findIndex(
(recordToFind) => recordToFind.id === idOfTargetItem,
);
const lastIndex = sortedRecordsByAscendingPosition.length - 1;
const shouldGoToFirstPosition =
indexOfItemToMove > 0 && indexOfTargetItem === 0;
const shouldGoToFirstPosition = indexOfTargetItem === 0;
const shouldGoToLastPosition =
indexOfItemToMove < lastIndex && indexOfTargetItem === lastIndex;
const shouldGoToLastPosition = indexOfTargetItem === lastIndex;
if (shouldGoToFirstPosition) {
return targetPosition - 1;
} else if (shouldGoToLastPosition) {
return targetPosition + 1;
} else {
if (itemToMoveIsNotInTable) {
const itemBeforeTargetItem =
sortedRecordsByAscendingPosition[indexOfTargetItem - 1];
const intermediaryPosition =
targetItem.position -
(targetItem.position - itemBeforeTargetItem.position) / 2;
return intermediaryPosition;
}
const shouldGoAfterTargetItem = indexOfItemToMove < indexOfTargetItem;
if (shouldGoAfterTargetItem) {
@@ -0,0 +1,139 @@
import { computeNewEvenlySpacedPositions } from '@/object-record/utils/computeNewEvenlySpacedPositions';
import { type RecordWithPosition } from '@/object-record/utils/computeNewPositionOfDraggedRecord';
import { isDefined } from 'twenty-shared/utils';
// TODO : refactor this
export const computeNewPositionsOfDraggedRecords = ({
arrayOfRecordsWithPosition,
draggedRecordId,
targetRecordId,
sourceRecordIds,
}: {
arrayOfRecordsWithPosition: RecordWithPosition[];
draggedRecordId: string;
targetRecordId: string;
sourceRecordIds: string[];
}): RecordWithPosition[] | null => {
const targetItem = arrayOfRecordsWithPosition.find(
(recordToFind) => recordToFind.id === targetRecordId,
);
if (!isDefined(targetItem)) {
throw new Error(`Cannot find item to move for id : ${targetRecordId}`);
}
if (targetRecordId === draggedRecordId) {
return null;
}
const targetIsInSourceRecordIds = sourceRecordIds.includes(targetRecordId);
if (targetIsInSourceRecordIds) {
return null;
}
const targetPosition = targetItem.position;
const indexOfItemToMove = arrayOfRecordsWithPosition.findIndex(
(recordToFind) => recordToFind.id === draggedRecordId,
);
const itemToMoveIsNotInTable = indexOfItemToMove === -1;
const indexOfTargetItem = arrayOfRecordsWithPosition.findIndex(
(recordToFind) => recordToFind.id === targetRecordId,
);
const lastIndex = arrayOfRecordsWithPosition.length - 1;
const shouldGoToFirstPosition = indexOfTargetItem === 0;
const shouldGoToLastPosition = indexOfTargetItem === lastIndex;
if (shouldGoToFirstPosition) {
const newPositions = computeNewEvenlySpacedPositions({
startingPosition: targetPosition - 1,
endingPosition: targetPosition,
numberOfRecordsToInsertBetween: sourceRecordIds.length,
});
const newSourceRecordsWithPosition: RecordWithPosition[] =
sourceRecordIds.map((recordId, index) => ({
id: recordId,
position: newPositions[index],
}));
return newSourceRecordsWithPosition;
} else if (shouldGoToLastPosition) {
const newPositions = computeNewEvenlySpacedPositions({
startingPosition: targetPosition,
endingPosition: targetPosition + sourceRecordIds.length + 1,
numberOfRecordsToInsertBetween: sourceRecordIds.length,
});
const newSourceRecordsWithPosition: RecordWithPosition[] =
sourceRecordIds.map((recordId, index) => ({
id: recordId,
position: newPositions[index],
}));
return newSourceRecordsWithPosition;
} else {
if (itemToMoveIsNotInTable) {
const itemBeforeTargetItem =
arrayOfRecordsWithPosition[indexOfTargetItem - 1];
const newPositions = computeNewEvenlySpacedPositions({
startingPosition: itemBeforeTargetItem.position,
endingPosition: targetItem.position,
numberOfRecordsToInsertBetween: sourceRecordIds.length,
});
const newSourceRecordsWithPosition: RecordWithPosition[] =
sourceRecordIds.map((recordId, index) => ({
id: recordId,
position: newPositions[index],
}));
return newSourceRecordsWithPosition;
}
const shouldGoAfterTargetItem = indexOfItemToMove < indexOfTargetItem;
if (shouldGoAfterTargetItem) {
const itemAfterTargetItem =
arrayOfRecordsWithPosition[indexOfTargetItem + 1];
const newPositions = computeNewEvenlySpacedPositions({
startingPosition: targetItem.position,
endingPosition: itemAfterTargetItem.position,
numberOfRecordsToInsertBetween: sourceRecordIds.length,
});
const newSourceRecordsWithPosition: RecordWithPosition[] =
sourceRecordIds.map((recordId, index) => ({
id: recordId,
position: newPositions[index],
}));
return newSourceRecordsWithPosition;
} else {
const itemBeforeTargetItem =
arrayOfRecordsWithPosition[indexOfTargetItem - 1];
const newPositions = computeNewEvenlySpacedPositions({
startingPosition: itemBeforeTargetItem.position,
endingPosition: targetItem.position,
numberOfRecordsToInsertBetween: sourceRecordIds.length,
});
const newSourceRecordsWithPosition: RecordWithPosition[] =
sourceRecordIds.map((recordId, index) => ({
id: recordId,
position: newPositions[index],
}));
return newSourceRecordsWithPosition;
}
}
};
@@ -32,12 +32,12 @@ query FindMany${capitalize(
objectMetadataItem.nameSingular,
)}FilterInput, $orderBy: [${capitalize(
objectMetadataItem.nameSingular,
)}OrderByInput], $lastCursor: String, $limit: Int) {
)}OrderByInput], $lastCursor: String, $limit: Int, $offset: Int) {
${objectMetadataItem.namePlural}(filter: $filter, orderBy: $orderBy, ${
cursorDirection === 'before'
? 'last: $limit, before: $lastCursor'
: 'first: $limit, after: $lastCursor'
} ){
}, offset: $offset){
edges {
node ${mapObjectMetadataToGraphQLQuery({
objectMetadataItems,