🦣🦣🦣 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,13 +1,19 @@
import { renderHook } from '@testing-library/react';
import { print } from 'graphql';
import { PERSON_FRAGMENT_WITH_DEPTH_ZERO_RELATIONS } from '@/object-record/hooks/__mocks__/personFragments';
import { useFindManyRecordsQuery } from '@/object-record/hooks/useFindManyRecordsQuery';
import { print } from 'graphql';
import { getJestMetadataAndApolloMocksWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksWrapper';
const expectedQueryTemplate = `
query FindManyPeople($filter: PersonFilterInput, $orderBy: [PersonOrderByInput], $lastCursor: String, $limit: Int) {
people(filter: $filter, orderBy: $orderBy, first: $limit, after: $lastCursor) {
query FindManyPeople($filter: PersonFilterInput, $orderBy: [PersonOrderByInput], $lastCursor: String, $limit: Int, $offset: Int) {
people(
filter: $filter
orderBy: $orderBy
first: $limit
after: $lastCursor
offset: $offset
) {
edges {
node {
${PERSON_FRAGMENT_WITH_DEPTH_ZERO_RELATIONS}
@@ -74,12 +74,14 @@ const mock: MockedResponse = {
$orderBy: [PersonOrderByInput]
$lastCursor: String
$limit: Int
$offset: Int
) {
people(
filter: $filter
orderBy: $orderBy
first: $limit
after: $lastCursor
offset: $offset
) {
edges {
node {
@@ -35,12 +35,14 @@ const mocks: MockedResponse[] = [
$orderBy: [PersonOrderByInput]
$lastCursor: String
$limit: Int
$offset: Int
) {
people(
filter: $filter
orderBy: $orderBy
first: $limit
after: $lastCursor
offset: $offset
) {
edges {
node {
@@ -6,6 +6,7 @@ import {
type useCreateManyRecordsProps,
} from '@/object-record/hooks/useCreateManyRecords';
import { useRefetchAggregateQueries } from '@/object-record/hooks/useRefetchAggregateQueries';
import { useRegisterObjectOperation } from '@/object-record/hooks/useRegisterObjectOperation';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { ApolloError } from '@apollo/client';
@@ -26,6 +27,8 @@ export const useBatchCreateManyRecords = <
setBatchedRecordsCount?: (count: number) => void;
abortController?: AbortController;
}) => {
const { registerObjectOperation } = useRegisterObjectOperation();
const { createManyRecords } = useCreateManyRecords({
objectNameSingular,
recordGqlFields,
@@ -97,6 +100,8 @@ export const useBatchCreateManyRecords = <
await refetchAggregateQueries();
registerObjectOperation(objectNameSingular, { type: 'create-many' });
return allCreatedRecords;
};
@@ -16,6 +16,7 @@ import { type RecordGqlOperationGqlRecordFields } from '@/object-record/graphql/
import { useCreateManyRecordsMutation } from '@/object-record/hooks/useCreateManyRecordsMutation';
import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions';
import { useRefetchAggregateQueries } from '@/object-record/hooks/useRefetchAggregateQueries';
import { useRegisterObjectOperation } from '@/object-record/hooks/useRegisterObjectOperation';
import { type FieldActorForInputValue } from '@/object-record/record-field/ui/types/FieldMetadata';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { computeOptimisticRecordFromInput } from '@/object-record/utils/computeOptimisticRecordFromInput';
@@ -49,6 +50,8 @@ export const useCreateManyRecords = <
shouldMatchRootQueryFilter,
shouldRefetchAggregateQueries = true,
}: useCreateManyRecordsProps) => {
const { registerObjectOperation } = useRegisterObjectOperation();
const apolloCoreClient = useApolloCoreClient();
const { objectMetadataItem } = useObjectMetadataItem({
@@ -228,7 +231,11 @@ export const useCreateManyRecords = <
throw error;
});
if (shouldRefetchAggregateQueries) await refetchAggregateQueries();
if (shouldRefetchAggregateQueries) {
await refetchAggregateQueries();
}
registerObjectOperation(objectNameSingular, { type: 'create-many' });
return createdObjects.data?.[mutationResponseField] ?? [];
};
@@ -16,6 +16,7 @@ import { type RecordGqlOperationGqlRecordFields } from '@/object-record/graphql/
import { useCreateOneRecordMutation } from '@/object-record/hooks/useCreateOneRecordMutation';
import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions';
import { useRefetchAggregateQueries } from '@/object-record/hooks/useRefetchAggregateQueries';
import { useRegisterObjectOperation } from '@/object-record/hooks/useRegisterObjectOperation';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { computeOptimisticCreateRecordBaseRecordInput } from '@/object-record/utils/computeOptimisticCreateRecordBaseRecordInput';
import { computeOptimisticRecordFromInput } from '@/object-record/utils/computeOptimisticRecordFromInput';
@@ -38,6 +39,7 @@ export const useCreateOneRecord = <
skipPostOptimisticEffect = false,
shouldMatchRootQueryFilter,
}: useCreateOneRecordProps) => {
const { registerObjectOperation } = useRegisterObjectOperation();
const apolloCoreClient = useApolloCoreClient();
const [loading, setLoading] = useState(false);
@@ -174,6 +176,9 @@ export const useCreateOneRecord = <
});
await refetchAggregateQueries();
registerObjectOperation(objectNameSingular, { type: 'create-one' });
return createdObject.data?.[mutationResponseField] ?? null;
};
@@ -12,6 +12,7 @@ import { type RecordGqlNode } from '@/object-record/graphql/types/RecordGqlNode'
import { useDeleteManyRecordsMutation } from '@/object-record/hooks/useDeleteManyRecordsMutation';
import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions';
import { useRefetchAggregateQueries } from '@/object-record/hooks/useRefetchAggregateQueries';
import { useRegisterObjectOperation } from '@/object-record/hooks/useRegisterObjectOperation';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { getDeleteManyRecordsMutationResponseField } from '@/object-record/utils/getDeleteManyRecordsMutationResponseField';
import { useRecoilValue } from 'recoil';
@@ -32,6 +33,7 @@ export type DeleteManyRecordsProps = {
export const useDeleteManyRecords = ({
objectNameSingular,
}: useDeleteManyRecordProps) => {
const { registerObjectOperation } = useRegisterObjectOperation();
const apiConfig = useRecoilValue(apiConfigState);
const mutationPageSize =
@@ -215,6 +217,11 @@ export const useDeleteManyRecords = ({
}
}
await refetchAggregateQueries();
registerObjectOperation(objectNameSingular, {
type: 'delete-many',
});
return deletedRecords;
};
@@ -12,6 +12,7 @@ import { updateRecordFromCache } from '@/object-record/cache/utils/updateRecordF
import { useDeleteOneRecordMutation } from '@/object-record/hooks/useDeleteOneRecordMutation';
import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions';
import { useRefetchAggregateQueries } from '@/object-record/hooks/useRefetchAggregateQueries';
import { useRegisterObjectOperation } from '@/object-record/hooks/useRegisterObjectOperation';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { getDeleteOneRecordMutationResponseField } from '@/object-record/utils/getDeleteOneRecordMutationResponseField';
import { isNull } from '@sniptt/guards';
@@ -24,6 +25,7 @@ type useDeleteOneRecordProps = {
export const useDeleteOneRecord = ({
objectNameSingular,
}: useDeleteOneRecordProps) => {
const { registerObjectOperation } = useRegisterObjectOperation();
const apolloCoreClient = useApolloCoreClient();
const { objectMetadataItem } = useObjectMetadataItem({
@@ -154,6 +156,11 @@ export const useDeleteOneRecord = ({
});
await refetchAggregateQueries();
registerObjectOperation(objectNameSingular, {
type: 'delete-one',
});
return deletedRecord.data?.[mutationResponseField] ?? null;
},
[
@@ -165,6 +172,8 @@ export const useDeleteOneRecord = ({
objectMetadataItems,
objectPermissionsByObjectMetadataId,
refetchAggregateQueries,
objectNameSingular,
registerObjectOperation,
],
);
@@ -9,6 +9,7 @@ import { DEFAULT_MUTATION_BATCH_SIZE } from '@/object-record/constants/DefaultMu
import { useDestroyManyRecordsMutation } from '@/object-record/hooks/useDestroyManyRecordsMutation';
import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions';
import { useRefetchAggregateQueries } from '@/object-record/hooks/useRefetchAggregateQueries';
import { useRegisterObjectOperation } from '@/object-record/hooks/useRegisterObjectOperation';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { getDestroyManyRecordsMutationResponseField } from '@/object-record/utils/getDestroyManyRecordsMutationResponseField';
import { useRecoilValue } from 'recoil';
@@ -29,6 +30,7 @@ export type DestroyManyRecordsProps = {
export const useDestroyManyRecords = ({
objectNameSingular,
}: useDestroyManyRecordProps) => {
const { registerObjectOperation } = useRegisterObjectOperation();
const apiConfig = useRecoilValue(apiConfigState);
const mutationPageSize =
@@ -137,6 +139,11 @@ export const useDestroyManyRecords = ({
}
await refetchAggregateQueries();
registerObjectOperation(objectNameSingular, {
type: 'destroy-many',
});
return destroyedRecords;
};
@@ -8,6 +8,7 @@ import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadat
import { useGetRecordFromCache } from '@/object-record/cache/hooks/useGetRecordFromCache';
import { useDestroyOneRecordMutation } from '@/object-record/hooks/useDestroyOneRecordMutation';
import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions';
import { useRegisterObjectOperation } from '@/object-record/hooks/useRegisterObjectOperation';
import { getDestroyOneRecordMutationResponseField } from '@/object-record/utils/getDestroyOneRecordMutationResponseField';
import { capitalize, isDefined } from 'twenty-shared/utils';
@@ -19,6 +20,8 @@ type useDestroyOneRecordProps = {
export const useDestroyOneRecord = ({
objectNameSingular,
}: useDestroyOneRecordProps) => {
const { registerObjectOperation } = useRegisterObjectOperation();
const apolloCoreClient = useApolloCoreClient();
const { objectMetadataItem } = useObjectMetadataItem({
@@ -81,6 +84,10 @@ export const useDestroyOneRecord = ({
throw error;
});
registerObjectOperation(objectMetadataItem.nameSingular, {
type: 'destroy-one',
});
return deletedRecord.data?.[mutationResponseField] ?? null;
},
[
@@ -92,6 +99,7 @@ export const useDestroyOneRecord = ({
objectNameSingular,
objectMetadataItems,
objectPermissionsByObjectMetadataId,
registerObjectOperation,
],
);
@@ -24,6 +24,7 @@ import { type OnFindManyRecordsCompleted } from '@/object-record/types/OnFindMan
import { filterUniqueRecordEdgesByCursor } from '@/object-record/utils/filterUniqueRecordEdgesByCursor';
import { getQueryIdentifier } from '@/object-record/utils/getQueryIdentifier';
import { DEFAULT_SEARCH_REQUEST_LIMIT } from '@/object-record/constants/DefaultSearchRequestLimit';
import { capitalize, isDefined } from 'twenty-shared/utils';
import { cursorFamilyState } from '../states/cursorFamilyState';
import { hasNextPageFamilyState } from '../states/hasNextPageFamilyState';
@@ -88,7 +89,7 @@ export const useLazyFetchMoreRecordsWithPagination = <
// This function is equivalent to merge function + read function in field policy
const fetchMoreRecordsLazy = useRecoilCallback(
({ snapshot, set }) =>
async () => {
async (limit = DEFAULT_SEARCH_REQUEST_LIMIT) => {
const hasNextPageLocal = snapshot
.getLoadable(hasNextPageFamilyState(queryIdentifier))
.getValue();
@@ -105,6 +106,7 @@ export const useLazyFetchMoreRecordsWithPagination = <
try {
const { data: fetchMoreDataResult } = await fetchMore({
variables: {
limit,
filter,
orderBy,
lastCursor: isNonEmptyString(lastCursorLocal)
@@ -96,7 +96,7 @@ export const useLazyFindManyRecords = <T extends ObjectRecord = ObjectRecord>({
return {
data: null,
records: [],
records: null,
totalCount: 0,
hasNextPage: false,
error: undefined,
@@ -0,0 +1,105 @@
import { useLazyQuery } from '@apollo/client';
import { useRecoilCallback } from 'recoil';
import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { getRecordsFromRecordConnection } from '@/object-record/cache/utils/getRecordsFromRecordConnection';
import { type RecordGqlOperationFindManyResult } from '@/object-record/graphql/types/RecordGqlOperationFindManyResult';
import { type UseFindManyRecordsParams } from '@/object-record/hooks/useFindManyRecords';
import { useFindManyRecordsQuery } from '@/object-record/hooks/useFindManyRecordsQuery';
import { useHandleFindManyRecordsError } from '@/object-record/hooks/useHandleFindManyRecordsError';
import { useObjectPermissionsForObject } from '@/object-record/hooks/useObjectPermissionsForObject';
import { useRecordsFieldVisibleGqlFields } from '@/object-record/record-field/hooks/useRecordsFieldVisibleGqlFields';
import { useFindManyRecordIndexTableParams } from '@/object-record/record-index/hooks/useFindManyRecordIndexTableParams';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
type UseLazyFindManyRecordsWithOffsetParams = Pick<
UseFindManyRecordsParams<ObjectRecord>,
'objectNameSingular'
>;
export const useLazyFindManyRecordsWithOffset = ({
objectNameSingular,
}: UseLazyFindManyRecordsWithOffsetParams) => {
const { objectMetadataItem } = useObjectMetadataItem({
objectNameSingular,
});
const params = useFindManyRecordIndexTableParams(objectNameSingular);
const recordGqlFields = useRecordsFieldVisibleGqlFields({
objectMetadataItem,
});
const apolloCoreClient = useApolloCoreClient();
const { findManyRecordsQuery } = useFindManyRecordsQuery({
objectNameSingular,
recordGqlFields,
});
const { handleFindManyRecordsError } = useHandleFindManyRecordsError({
objectMetadataItem,
});
const objectPermissions = useObjectPermissionsForObject(
objectMetadataItem.id,
);
const hasReadPermission = objectPermissions.canReadObjectRecords;
const [findManyRecords] = useLazyQuery<RecordGqlOperationFindManyResult>(
findManyRecordsQuery,
{
variables: {
...params,
},
onError: handleFindManyRecordsError,
client: apolloCoreClient,
},
);
const findManyRecordsLazyWithOffset = useRecoilCallback(
() => async (limit: number, offset: number) => {
if (!hasReadPermission) {
return {
data: null,
records: null,
totalCount: 0,
error: undefined,
};
}
const result = await findManyRecords({
variables: {
limit,
offset,
},
});
const records = getRecordsFromRecordConnection({
recordConnection: {
edges: result?.data?.[objectMetadataItem.namePlural]?.edges ?? [],
pageInfo: {
hasNextPage: false,
hasPreviousPage: false,
startCursor: '',
endCursor: '',
},
},
});
return {
data: result?.data,
records,
error: result?.error,
};
},
[hasReadPermission, findManyRecords, objectMetadataItem.namePlural],
);
return {
findManyRecordsLazyWithOffset,
};
};
@@ -55,8 +55,9 @@ export const useLoadSelectedRecordsInContextStore = ({
objectRecordIds.length,
);
const records = await findManyRecordsLazy();
upsertRecords(records.records);
const { records } = await findManyRecordsLazy();
upsertRecords(records ?? []);
};
},
[objectRecordIds, objectMetadataItemId, findManyRecordsLazy, upsertRecords],
@@ -8,6 +8,7 @@ import { useFindDuplicateRecordsQuery } from '@/object-record/hooks/useFindDupli
import { useFindOneRecordQuery } from '@/object-record/hooks/useFindOneRecordQuery';
import { useMergeManyRecordsMutation } from '@/object-record/hooks/useMergeManyRecordsMutation';
import { useRefetchAggregateQueries } from '@/object-record/hooks/useRefetchAggregateQueries';
import { useRegisterObjectOperation } from '@/object-record/hooks/useRegisterObjectOperation';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { getMergeManyRecordsMutationResponseField } from '@/object-record/utils/getMergeManyRecordsMutationResponseField';
import { getOperationName } from '@apollo/client/utilities';
@@ -27,6 +28,7 @@ export const useMergeManyRecords = <
objectNameSingular,
recordGqlFields,
}: UseMergeManyRecordsProps) => {
const { registerObjectOperation } = useRegisterObjectOperation();
const apolloCoreClient = useApolloCoreClient();
const [loading, setLoading] = useState(false);
@@ -103,6 +105,10 @@ export const useMergeManyRecords = <
await refetchAggregateQueries();
}
registerObjectOperation(objectNameSingular, {
type: 'merge-records',
});
return mergedObject.data?.[mutationResponseField] ?? null;
} catch (error) {
setLoading(false);
@@ -116,6 +122,8 @@ export const useMergeManyRecords = <
mergeManyRecordsMutation,
objectMetadataItem.namePlural,
refetchAggregateQueries,
registerObjectOperation,
objectNameSingular,
],
);
@@ -0,0 +1,35 @@
import {
type ObjectOperationData,
objectOperationsByObjectNameSingularFamilyState,
} from '@/object-record/states/objectOperationsByObjectNameSingularFamilyState';
import { useRecoilCallback } from 'recoil';
import { v4 } from 'uuid';
export const useRegisterObjectOperation = () => {
const registerObjectOperation = useRecoilCallback(
({ set }) =>
(objectNameSingular: string, data: ObjectOperationData) => {
set(
objectOperationsByObjectNameSingularFamilyState({
objectNameSingular,
}),
(currentValue) => {
const newValue = currentValue.concat();
newValue.push({
id: v4(),
timestamp: +new Date(),
data,
});
return newValue;
},
);
},
[],
);
return {
registerObjectOperation,
};
};
@@ -9,6 +9,7 @@ import { getRecordNodeFromRecord } from '@/object-record/cache/utils/getRecordNo
import { updateRecordFromCache } from '@/object-record/cache/utils/updateRecordFromCache';
import { DEFAULT_MUTATION_BATCH_SIZE } from '@/object-record/constants/DefaultMutationBatchSize';
import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions';
import { useRegisterObjectOperation } from '@/object-record/hooks/useRegisterObjectOperation';
import { useRestoreManyRecordsMutation } from '@/object-record/hooks/useRestoreManyRecordsMutation';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { getRestoreManyRecordsMutationResponseField } from '@/object-record/utils/getRestoreManyRecordsMutationResponseField';
@@ -30,6 +31,8 @@ type RestoreManyRecordsProps = {
export const useRestoreManyRecords = ({
objectNameSingular,
}: useRestoreManyRecordProps) => {
const { registerObjectOperation } = useRegisterObjectOperation();
const apiConfig = useRecoilValue(apiConfigState);
const mutationPageSize =
@@ -191,6 +194,10 @@ export const useRestoreManyRecords = ({
restoredRecords.push(...restoredRecordsForThisBatch);
registerObjectOperation(objectMetadataItem.nameSingular, {
type: 'restore-many',
});
if (isDefined(delayInMsBetweenRequests)) {
await sleep(delayInMsBetweenRequests);
}
@@ -11,6 +11,7 @@ import { useGenerateDepthRecordGqlFieldsFromObject } from '@/object-record/graph
import { generateDepthRecordGqlFieldsFromRecord } from '@/object-record/graphql/record-gql-fields/utils/generateDepthRecordGqlFieldsFromRecord';
import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions';
import { useRefetchAggregateQueries } from '@/object-record/hooks/useRefetchAggregateQueries';
import { useRegisterObjectOperation } from '@/object-record/hooks/useRegisterObjectOperation';
import { useUpdateOneRecordMutation } from '@/object-record/hooks/useUpdateOneRecordMutation';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { computeOptimisticRecordFromInput } from '@/object-record/utils/computeOptimisticRecordFromInput';
@@ -36,6 +37,7 @@ export const useUpdateOneRecord = <
objectNameSingular,
recordGqlFields,
}: useUpdateOneRecordProps) => {
const { registerObjectOperation } = useRegisterObjectOperation();
const apolloCoreClient = useApolloCoreClient();
const { objectMetadataItem } = useObjectMetadataItem({
@@ -217,7 +219,15 @@ export const useUpdateOneRecord = <
});
await refetchAggregateQueries();
return updatedRecord?.data?.[mutationResponseField] ?? null;
const udpatedRecord = updatedRecord?.data?.[mutationResponseField] ?? null;
registerObjectOperation(objectNameSingular, {
type: 'update-one',
result: { updatedRecord, updateInput: updateOneRecordInput },
});
return udpatedRecord;
};
return {