Improved table flash on reload (#16419)

This PR fixes https://github.com/twentyhq/core-team-issues/issues/1732

There is still some room for improvement but the main goal is reached :
removing the flash effect each time the table virtualization has to
recompute due to an update after initial loading.

# QA

## Create 


https://github.com/user-attachments/assets/1b4fc307-42ce-4ba6-b557-68ac7fcad40f

## Update with sort


https://github.com/user-attachments/assets/e0700f44-8926-4395-8ab8-b32773f21de8

## Update with filter


https://github.com/user-attachments/assets/d325f85a-1a7b-4366-aac3-250331be7575

## Soft delete


https://github.com/user-attachments/assets/2c980183-c637-4aa7-a0ca-244e61396b20

## Restore and destroy


https://github.com/user-attachments/assets/01af9ec7-b442-4686-a1d7-ea1fc543fe62

## Drag & drop


https://github.com/user-attachments/assets/bba76bdb-d4ec-433d-b44e-37fdb9952b06

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Lucas Bordeau
2025-12-11 04:14:20 -10:00
committed by GitHub
parent 3cea19baf4
commit ec8773437e
30 changed files with 446 additions and 156 deletions
@@ -100,7 +100,7 @@ export const useBatchCreateManyRecords = <
await refetchAggregateQueries();
registerObjectOperation(objectNameSingular, { type: 'create-many' });
registerObjectOperation(objectMetadataItem, { type: 'create-many' });
return allCreatedRecords;
};
@@ -243,7 +243,7 @@ export const useCreateManyRecords = <
await refetchAggregateQueries();
}
registerObjectOperation(objectNameSingular, { type: 'create-many' });
registerObjectOperation(objectMetadataItem, { type: 'create-many' });
return createdObjects.data?.[mutationResponseField] ?? [];
};
@@ -187,7 +187,7 @@ export const useCreateOneRecord = <
await refetchAggregateQueries();
registerObjectOperation(objectNameSingular, { type: 'create-one' });
registerObjectOperation(objectMetadataItem, { type: 'create-one' });
if (!isDefined(createdObject.data?.[mutationResponseField])) {
throw new CustomError('Failed to create record');
@@ -224,7 +224,7 @@ export const useDeleteManyRecords = ({
}
await refetchAggregateQueries();
registerObjectOperation(objectNameSingular, {
registerObjectOperation(objectMetadataItem, {
type: 'delete-many',
});
@@ -165,7 +165,7 @@ export const useDeleteOneRecord = ({
await refetchAggregateQueries();
registerObjectOperation(objectNameSingular, {
registerObjectOperation(objectMetadataItem, {
type: 'delete-one',
});
@@ -179,7 +179,6 @@ export const useDeleteOneRecord = ({
deleteOneRecordMutation,
refetchAggregateQueries,
registerObjectOperation,
objectNameSingular,
mutationResponseField,
objectPermissionsByObjectMetadataId,
upsertRecordsInStore,
@@ -145,7 +145,7 @@ export const useDestroyManyRecords = ({
await refetchAggregateQueries();
registerObjectOperation(objectNameSingular, {
registerObjectOperation(objectMetadataItem, {
type: 'destroy-many',
});
@@ -89,7 +89,7 @@ export const useDestroyOneRecord = ({
throw error;
});
registerObjectOperation(objectMetadataItem.nameSingular, {
registerObjectOperation(objectMetadataItem, {
type: 'destroy-one',
});
@@ -225,7 +225,7 @@ export const useIncrementalDeleteManyRecords = <T>({
await refetchAggregateQueries();
registerObjectOperation(objectNameSingular, {
registerObjectOperation(objectMetadataItem, {
type: 'delete-many',
});
@@ -155,7 +155,7 @@ export const useIncrementalDestroyManyRecords = <T>({
await refetchAggregateQueries();
registerObjectOperation(objectNameSingular, {
registerObjectOperation(objectMetadataItem, {
type: 'destroy-many',
});
@@ -103,7 +103,7 @@ export const useMergeManyRecords = <
if (!preview) {
await refetchAggregateQueries();
registerObjectOperation(objectNameSingular, {
registerObjectOperation(objectMetadataItem, {
type: 'merge-records',
});
}
@@ -115,14 +115,13 @@ export const useMergeManyRecords = <
}
},
[
objectMetadataItem.namePlural,
objectMetadataItem,
apolloCoreClient,
mergeManyRecordsMutation,
findOneRecordQuery,
findDuplicateRecordsQuery,
registerObjectOperation,
objectNameSingular,
refetchAggregateQueries,
registerObjectOperation,
],
);
@@ -1,30 +1,27 @@
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import {
objectOperationsState,
type ObjectOperationData,
objectOperationsByObjectNameSingularFamilyState,
} from '@/object-record/states/objectOperationsByObjectNameSingularFamilyState';
} from '@/object-record/states/objectOperationsComponentState';
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();
(objectMetadataItem: ObjectMetadataItem, data: ObjectOperationData) => {
set(objectOperationsState, (currentValue) => {
const newValue = currentValue.concat();
newValue.push({
id: v4(),
timestamp: +new Date(),
data,
});
newValue.push({
id: v4(),
timestamp: +new Date(),
objectMetadataItemId: objectMetadataItem.id,
data,
});
return newValue;
},
);
return newValue;
});
},
[],
);
@@ -200,7 +200,7 @@ export const useRestoreManyRecords = ({
restoredRecords.push(...restoredRecordsForThisBatch);
registerObjectOperation(objectMetadataItem.nameSingular, {
registerObjectOperation(objectMetadataItem, {
type: 'restore-many',
});
@@ -230,9 +230,9 @@ export const useUpdateOneRecord = <
const udpatedRecord = updatedRecord?.data?.[mutationResponseField] ?? null;
registerObjectOperation(objectNameSingular, {
registerObjectOperation(objectMetadataItem, {
type: 'update-one',
result: { updatedRecord, updateInput: updateOneRecordInput },
result: { updateInput: updateOneRecordInput },
});
return udpatedRecord;
@@ -238,10 +238,11 @@ export const useUpdateOneRecordV2 = () => {
throw error;
});
registerObjectOperation(objectMetadataItem.nameSingular, {
registerObjectOperation(objectMetadataItem, {
type: 'update-one',
result: { updatedRecord, updateInput: updateOneRecordInput },
result: { updateInput: updateOneRecordInput },
});
return updatedRecord?.data?.[mutationResponseField] ?? null;
};
@@ -2,6 +2,7 @@ import { type DropResult } from '@hello-pangea/dnd';
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
import { useTriggerTableWithoutGroupDragAndDropOptimisticUpdate } from '@/object-record/record-drag/hooks/useTriggerTableWithoutGroupDragAndDropOptimisticUpdate';
import { originalDragSelectionComponentState } from '@/object-record/record-drag/states/originalDragSelectionComponentState';
import { getDragOperationType } from '@/object-record/record-drag/utils/getDragOperationType';
import { processMultiDrag } from '@/object-record/record-drag/utils/processMultiDrag';
@@ -12,8 +13,6 @@ import { currentRecordSortsComponentState } from '@/object-record/record-sort/st
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { useRecordTableContextOrThrow } from '@/object-record/record-table/contexts/RecordTableContext';
import { selectedRowIdsComponentSelector } from '@/object-record/record-table/states/selectors/selectedRowIdsComponentSelector';
import { useResetVirtualizationBecauseDataChanged } from '@/object-record/record-table/virtualization/hooks/useResetVirtualizationBecauseDataChanged';
import { useTriggerFetchPages } from '@/object-record/record-table/virtualization/hooks/useTriggerFetchPages';
import { type RecordWithPosition } from '@/object-record/utils/computeNewPositionOfDraggedRecord';
import { useModal } from '@/ui/layout/modal/hooks/useModal';
import { useRecoilComponentCallbackState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackState';
@@ -46,10 +45,8 @@ export const useProcessTableWithoutGroupRecordDrop = () => {
const { openModal } = useModal();
const { resetVirtualization } =
useResetVirtualizationBecauseDataChanged(objectNameSingular);
const { triggerFetchPagesWithoutDebounce } = useTriggerFetchPages();
const { triggerTableWithoutGroupDragAndDropOptimisticUpdate } =
useTriggerTableWithoutGroupDragAndDropOptimisticUpdate();
const processTableWithoutGroupRecordDrop = useRecoilCallback(
({ snapshot }) =>
@@ -115,6 +112,10 @@ export const useProcessTableWithoutGroupRecordDrop = () => {
return;
}
triggerTableWithoutGroupDragAndDropOptimisticUpdate([
singleDragResult,
]);
updateOneRow({
idToUpdate: singleDragResult.id,
updateOneRecordInput: {
@@ -145,6 +146,10 @@ export const useProcessTableWithoutGroupRecordDrop = () => {
isDroppedAfterList,
});
triggerTableWithoutGroupDragAndDropOptimisticUpdate(
multiDragResult.recordUpdates,
);
for (const update of multiDragResult.recordUpdates) {
updateOneRow({
idToUpdate: update.id,
@@ -154,10 +159,6 @@ export const useProcessTableWithoutGroupRecordDrop = () => {
});
}
}
await resetVirtualization();
await triggerFetchPagesWithoutDebounce();
},
[
selectedRowIdsSelector,
@@ -166,8 +167,7 @@ export const useProcessTableWithoutGroupRecordDrop = () => {
currentRecordSorts,
originalDragSelectionCallbackState,
allRecordIdsWithoutGroupCallbackSelector,
resetVirtualization,
triggerFetchPagesWithoutDebounce,
triggerTableWithoutGroupDragAndDropOptimisticUpdate,
],
);
@@ -0,0 +1,125 @@
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { useLoadRecordsToVirtualRows } from '@/object-record/record-table/virtualization/hooks/useLoadRecordsToVirtualRows';
import { lastScrollPositionComponentState } from '@/object-record/record-table/virtualization/states/lastScrollPositionComponentState';
import { recordIdByRealIndexComponentFamilyState } from '@/object-record/record-table/virtualization/states/recordIdByRealIndexComponentFamilyState';
import { totalNumberOfRecordsToVirtualizeComponentState } from '@/object-record/record-table/virtualization/states/totalNumberOfRecordsToVirtualizeComponentState';
import { getVirtualizationOverscanWindow } from '@/object-record/record-table/virtualization/utils/getVirtualizationOverscanWindow';
import { type RecordWithPosition } from '@/object-record/utils/computeNewPositionOfDraggedRecord';
import { useScrollWrapperHTMLElement } from '@/ui/utilities/scroll/hooks/useScrollWrapperHTMLElement';
import { useRecoilComponentCallbackState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackState';
import { getSnapshotValue } from '@/ui/utilities/state/utils/getSnapshotValue';
import { useRecoilCallback } from 'recoil';
import { findById, isDefined } from 'twenty-shared/utils';
import { sortByProperty } from '~/utils/array/sortByProperty';
// TODO: does not work when scrolling while dragging and does not work if not paired with a network refetch right after
// But it's sufficient right now for the main use case
export const useTriggerTableWithoutGroupDragAndDropOptimisticUpdate = () => {
const recordIdByRealIndexCallbackFamilyState =
useRecoilComponentCallbackState(recordIdByRealIndexComponentFamilyState);
const lastScrollPositionCallbackState = useRecoilComponentCallbackState(
lastScrollPositionComponentState,
);
const { scrollWrapperHTMLElement } = useScrollWrapperHTMLElement();
const totalNumberOfRecordsToVirtualizeCallbackState =
useRecoilComponentCallbackState(
totalNumberOfRecordsToVirtualizeComponentState,
);
const { loadRecordsToVirtualRows } = useLoadRecordsToVirtualRows();
const triggerTableWithoutGroupDragAndDropOptimisticUpdate = useRecoilCallback(
({ snapshot }) =>
(updatedRecords: RecordWithPosition[]) => {
const tableScrollWrapperHeight =
scrollWrapperHTMLElement?.clientHeight ?? 0;
const lastScrollPosition = getSnapshotValue(
snapshot,
lastScrollPositionCallbackState,
);
const totalNumberOfRecordsToVirtualize =
getSnapshotValue(
snapshot,
totalNumberOfRecordsToVirtualizeCallbackState,
) ?? 0;
const {
firstRealIndexInOverscanWindow,
lastRealIndexInOverscanWindow,
} = getVirtualizationOverscanWindow(
lastScrollPosition,
tableScrollWrapperHeight,
totalNumberOfRecordsToVirtualize,
);
const recordsInOverscanWindowToReorder: RecordWithPosition[] = [];
for (
let realIndex = firstRealIndexInOverscanWindow;
realIndex <= lastRealIndexInOverscanWindow;
realIndex++
) {
const recordIdAtRealIndex = getSnapshotValue(
snapshot,
recordIdByRealIndexCallbackFamilyState({ realIndex }),
);
if (!isDefined(recordIdAtRealIndex)) {
continue;
}
const correspondingRecordInStore = getSnapshotValue(
snapshot,
recordStoreFamilyState(recordIdAtRealIndex),
);
if (
isDefined(correspondingRecordInStore) &&
isDefined(correspondingRecordInStore.position)
) {
const correspondingDraggedRecord = updatedRecords.find(
findById(correspondingRecordInStore.id),
);
const hasRecordBeenDragged = isDefined(correspondingDraggedRecord);
const positionToUse = hasRecordBeenDragged
? correspondingDraggedRecord.position
: correspondingRecordInStore.position;
recordsInOverscanWindowToReorder.push({
id: correspondingRecordInStore.id,
position: positionToUse,
});
}
}
const shouldReorderRecordsInOverscanWindow =
recordsInOverscanWindowToReorder.length > 0;
if (!shouldReorderRecordsInOverscanWindow) {
return;
}
const reorderedRecordsWithPosition =
recordsInOverscanWindowToReorder.toSorted(sortByProperty('position'));
loadRecordsToVirtualRows({
records: reorderedRecordsWithPosition as any[],
startingRealIndex: firstRealIndexInOverscanWindow,
});
},
[
lastScrollPositionCallbackState,
loadRecordsToVirtualRows,
recordIdByRealIndexCallbackFamilyState,
scrollWrapperHTMLElement?.clientHeight,
totalNumberOfRecordsToVirtualizeCallbackState,
],
);
return { triggerTableWithoutGroupDragAndDropOptimisticUpdate };
};
@@ -32,6 +32,7 @@ export const RecordTableDraggableTr = ({
return (
<Draggable
key={recordId}
draggableId={recordId}
index={draggableIndex}
isDragDisabled={isDragDisabled}
@@ -1,15 +1,26 @@
import { useActiveFieldMetadataItems } from '@/object-metadata/hooks/useActiveFieldMetadataItems';
import { useGetObjectMetadataItemById } from '@/object-metadata/hooks/useGetObjectMetadataItemById';
import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState';
import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext';
import { currentRecordSortsComponentState } from '@/object-record/record-sort/states/currentRecordSortsComponentState';
import { useRecordTableContextOrThrow } from '@/object-record/record-table/contexts/RecordTableContext';
import { useResetVirtualizationBecauseDataChanged } from '@/object-record/record-table/virtualization/hooks/useResetVirtualizationBecauseDataChanged';
import { lastObjectOperationThatResettedVirtualizationComponentState } from '@/object-record/record-table/virtualization/states/lastObjectOperationThatResettedVirtualizationComponentState';
import { objectOperationsByObjectNameSingularFamilyState } from '@/object-record/states/objectOperationsByObjectNameSingularFamilyState';
import { objectOperationsState } from '@/object-record/states/objectOperationsComponentState';
import { useRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentState';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { useEffect } from 'react';
import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { isDefined, mapById } from 'twenty-shared/utils';
import { FieldMetadataType } from '~/generated/graphql';
export const RecordTableVirtualizedDataChangedEffect = () => {
const { objectMetadataItem } = useRecordIndexContextOrThrow();
const { objectNameSingular } = useRecordTableContextOrThrow();
const { getObjectMetadataItemById } = useGetObjectMetadataItemById();
const { resetVirtualizationBecauseDataChanged } =
useResetVirtualizationBecauseDataChanged(objectNameSingular);
@@ -20,12 +31,22 @@ export const RecordTableVirtualizedDataChangedEffect = () => {
lastObjectOperationThatResettedVirtualizationComponentState,
);
const objectOperationsForObjectNameSingular = useRecoilValue(
objectOperationsByObjectNameSingularFamilyState({ objectNameSingular }),
const { activeFieldMetadataItems } = useActiveFieldMetadataItems({
objectMetadataItem,
});
const currentRecordSorts = useRecoilComponentValue(
currentRecordSortsComponentState,
);
const currentRecordFilters = useRecoilComponentValue(
currentRecordFiltersComponentState,
);
const objectOperations = useRecoilValue(objectOperationsState);
useEffect(() => {
const lastObjectOperation = objectOperationsForObjectNameSingular.at(-1);
const lastObjectOperation = objectOperations.at(-1);
if (
!isDefined(lastObjectOperationThatResettedVirtualization) &&
@@ -41,14 +62,64 @@ export const RecordTableVirtualizedDataChangedEffect = () => {
setLastObjectOperationThatResettedVirtualization(lastObjectOperation);
if (isDefined(lastObjectOperation)) {
resetVirtualizationBecauseDataChanged(lastObjectOperation);
if (
lastObjectOperation.objectMetadataItemId !== objectMetadataItem.id
) {
return;
}
if (lastObjectOperation.data.type === 'update-one') {
const updatedFieldNames =
Object.keys(lastObjectOperation?.data.result.updateInput ?? {}) ??
[];
const updatedFieldMetadataItems = activeFieldMetadataItems.filter(
(fieldMetadataItemToFilter) =>
updatedFieldNames.includes(fieldMetadataItemToFilter.name) ||
(fieldMetadataItemToFilter.type === FieldMetadataType.RELATION &&
updatedFieldNames.includes(
`${fieldMetadataItemToFilter.name}Id`,
)),
);
const updatedFieldMetadataItemIds =
updatedFieldMetadataItems.map(mapById);
const thereIsAnUpdateOnAFilteredField = currentRecordFilters.some(
(recordFilter) =>
updatedFieldMetadataItemIds.includes(
recordFilter.fieldMetadataId,
),
);
const thereIsAnUpdateOnASortedField = currentRecordSorts.some(
(recordSort) =>
updatedFieldMetadataItemIds.includes(recordSort.fieldMetadataId),
);
if (updatedFieldNames.includes('position')) {
resetVirtualizationBecauseDataChanged();
} else if (
thereIsAnUpdateOnAFilteredField ||
thereIsAnUpdateOnASortedField
) {
resetVirtualizationBecauseDataChanged();
}
} else {
resetVirtualizationBecauseDataChanged();
}
}
}
}, [
lastObjectOperationThatResettedVirtualization,
objectOperationsForObjectNameSingular,
objectOperations,
setLastObjectOperationThatResettedVirtualization,
resetVirtualizationBecauseDataChanged,
activeFieldMetadataItems,
currentRecordFilters,
currentRecordSorts,
getObjectMetadataItemById,
objectMetadataItem.id,
]);
return <></>;
@@ -0,0 +1 @@
export const TABLE_VIRTUALIZATION_NUMBER_OF_OVERSCAN_PAGES = 3;
@@ -0,0 +1 @@
export const TABLE_VIRTUALIZATION_NUMBER_OF_RECORDS_PER_PAGE = 10;
@@ -3,20 +3,20 @@ import { useLazyFindManyRecords } from '@/object-record/hooks/useLazyFindManyRec
import { useRecordsFieldVisibleGqlFields } from '@/object-record/record-field/hooks/useRecordsFieldVisibleGqlFields';
import { useFindManyRecordIndexTableParams } from '@/object-record/record-index/hooks/useFindManyRecordIndexTableParams';
import { recordIndexAllRecordIdsComponentSelector } from '@/object-record/record-index/states/selectors/recordIndexAllRecordIdsComponentSelector';
import { useTriggerFetchPages } from '@/object-record/record-table/virtualization/hooks/useTriggerFetchPages';
import { dataLoadingStatusByRealIndexComponentFamilyState } from '@/object-record/record-table/virtualization/states/dataLoadingStatusByRealIndexComponentFamilyState';
import { dataPagesLoadedComponentState } from '@/object-record/record-table/virtualization/states/dataPagesLoadedComponentState';
import { lastScrollPositionComponentState } from '@/object-record/record-table/virtualization/states/lastScrollPositionComponentState';
import { recordIdByRealIndexComponentFamilyState } from '@/object-record/record-table/virtualization/states/recordIdByRealIndexComponentFamilyState';
import { tableHasAnyFilterOrSortComponentSelector } from '@/object-record/record-table/virtualization/states/tableHasAnyFilterOrSortComponentSelector';
import { totalNumberOfRecordsToVirtualizeComponentState } from '@/object-record/record-table/virtualization/states/totalNumberOfRecordsToVirtualizeComponentState';
import { type ObjectOperation } from '@/object-record/states/objectOperationsByObjectNameSingularFamilyState';
import { getVirtualizationOverscanWindow } from '@/object-record/record-table/virtualization/utils/getVirtualizationOverscanWindow';
import { useScrollWrapperHTMLElement } from '@/ui/utilities/scroll/hooks/useScrollWrapperHTMLElement';
import { useRecoilComponentCallbackState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackState';
import { useRecoilComponentFamilyCallbackState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentFamilyCallbackState';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { getSnapshotValue } from '@/ui/utilities/state/utils/getSnapshotValue';
import { useCallback } from 'react';
import { useRecoilCallback } from 'recoil';
import { sleep } from '~/utils/sleep';
export const useResetVirtualizationBecauseDataChanged = (
objectNameSingular: string,
@@ -26,6 +26,7 @@ export const useResetVirtualizationBecauseDataChanged = (
});
const params = useFindManyRecordIndexTableParams(objectNameSingular);
const { scrollWrapperHTMLElement } = useScrollWrapperHTMLElement();
// TODO: we could optimize this by using an aggregate or using only id: true in recordGqlFields
const recordGqlFields = useRecordsFieldVisibleGqlFields({
@@ -48,6 +49,10 @@ export const useResetVirtualizationBecauseDataChanged = (
dataPagesLoadedComponentState,
);
const lastScrollPositionCallbackState = useRecoilComponentCallbackState(
lastScrollPositionComponentState,
);
const recordIdByRealIndexCallbackState =
useRecoilComponentFamilyCallbackState(
recordIdByRealIndexComponentFamilyState,
@@ -58,10 +63,6 @@ export const useResetVirtualizationBecauseDataChanged = (
dataLoadingStatusByRealIndexComponentFamilyState,
);
const recordIndexAllRecordIdsSelector = useRecoilComponentCallbackState(
recordIndexAllRecordIdsComponentSelector,
);
const { triggerFetchPagesWithoutDebounce } = useTriggerFetchPages();
const resetVirtualization = useRecoilCallback(
@@ -69,68 +70,71 @@ export const useResetVirtualizationBecauseDataChanged = (
async () => {
const { totalCount } = await findManyRecordsLazy();
const currentRecordIds = getSnapshotValue(
const tableScrollWrapperHeight =
scrollWrapperHTMLElement?.clientHeight ?? 0;
const lastScrollPosition = getSnapshotValue(
snapshot,
recordIndexAllRecordIdsSelector,
lastScrollPositionCallbackState,
);
for (const [index] of currentRecordIds.entries()) {
set(
dataLoadingStatusByRealIndexCallbackState({
realIndex: index,
}),
null,
);
const totalNumberOfRecordsToVirtualize =
getSnapshotValue(
snapshot,
totalNumberOfRecordsToVirtualizeCallbackState,
) ?? 0;
set(
recordIdByRealIndexCallbackState({
realIndex: index,
}),
null,
);
const {
firstRealIndexInOverscanWindow,
lastRealIndexInOverscanWindow,
} = getVirtualizationOverscanWindow(
lastScrollPosition,
tableScrollWrapperHeight,
totalNumberOfRecordsToVirtualize,
);
for (let i = 0; i < totalNumberOfRecordsToVirtualize; i++) {
const indexIsInOverscanWindow =
i >= firstRealIndexInOverscanWindow &&
i <= lastRealIndexInOverscanWindow;
if (!indexIsInOverscanWindow) {
set(
dataLoadingStatusByRealIndexCallbackState({
realIndex: i,
}),
null,
);
set(
recordIdByRealIndexCallbackState({
realIndex: i,
}),
null,
);
}
}
set(
recordIndexAllRecordIdsSelector,
currentRecordIds.slice(0, totalCount),
);
set(dataPagesLoadedCallbackState, []);
set(totalNumberOfRecordsToVirtualizeCallbackState, totalCount);
},
[
dataPagesLoadedCallbackState,
recordIdByRealIndexCallbackState,
dataLoadingStatusByRealIndexCallbackState,
recordIndexAllRecordIdsSelector,
findManyRecordsLazy,
scrollWrapperHTMLElement?.clientHeight,
lastScrollPositionCallbackState,
totalNumberOfRecordsToVirtualizeCallbackState,
dataPagesLoadedCallbackState,
dataLoadingStatusByRealIndexCallbackState,
recordIdByRealIndexCallbackState,
],
);
const tableHasAnyFilterOrSort = useRecoilComponentValue(
tableHasAnyFilterOrSortComponentSelector,
);
const resetVirtualizationBecauseDataChanged = useCallback(async () => {
await resetVirtualization();
const resetVirtualizationBecauseDataChanged = useCallback(
async (objectOperation: ObjectOperation) => {
if (!objectOperation.data.type.startsWith('update')) {
await resetVirtualization();
await sleep(50);
await triggerFetchPagesWithoutDebounce();
} else {
if (tableHasAnyFilterOrSort) {
await resetVirtualization();
await triggerFetchPagesWithoutDebounce();
}
}
},
[
resetVirtualization,
triggerFetchPagesWithoutDebounce,
tableHasAnyFilterOrSort,
],
);
await triggerFetchPagesWithoutDebounce();
}, [resetVirtualization, triggerFetchPagesWithoutDebounce]);
return {
resetVirtualizationBecauseDataChanged,
@@ -1,13 +1,14 @@
import { useLazyFindManyRecordsWithOffset } from '@/object-record/hooks/useLazyFindManyRecordsWithOffset';
import { useUpsertRecordsInStore } from '@/object-record/record-store/hooks/useUpsertRecordsInStore';
import { RECORD_TABLE_ROW_HEIGHT } from '@/object-record/record-table/constants/RecordTableRowHeight';
import { useRecordTableContextOrThrow } from '@/object-record/record-table/contexts/RecordTableContext';
import { TABLE_VIRTUALIZATION_NUMBER_OF_RECORDS_PER_PAGE } from '@/object-record/record-table/virtualization/constants/TableVirtualizationNumberOfRecordsPerPage';
import { useLoadRecordsToVirtualRows } from '@/object-record/record-table/virtualization/hooks/useLoadRecordsToVirtualRows';
import { dataPagesLoadedComponentState } from '@/object-record/record-table/virtualization/states/dataPagesLoadedComponentState';
import { lastScrollPositionComponentState } from '@/object-record/record-table/virtualization/states/lastScrollPositionComponentState';
import { lowDetailsActivatedComponentState } from '@/object-record/record-table/virtualization/states/lowDetailsActivatedComponentState';
import { totalNumberOfRecordsToVirtualizeComponentState } from '@/object-record/record-table/virtualization/states/totalNumberOfRecordsToVirtualizeComponentState';
import { getVirtualizationOverscanWindow } from '@/object-record/record-table/virtualization/utils/getVirtualizationOverscanWindow';
import { useScrollWrapperHTMLElement } from '@/ui/utilities/scroll/hooks/useScrollWrapperHTMLElement';
import { useRecoilComponentCallbackState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackState';
import { getSnapshotValue } from '@/ui/utilities/state/utils/getSnapshotValue';
@@ -23,9 +24,6 @@ const TIME_BETWEEN_UI_BATCH_UPDATE = 25;
const PAGING_FOR_UI_UPDATE = 10;
const TIME_BETWEEN_TWO_REQUETS = 25;
const DATA_PAGE_SIZE = 10;
const DATA_PAGE_OVERSCAN = 3;
export const useTriggerFetchPages = () => {
const { objectNameSingular } = useRecordTableContextOrThrow();
const { scrollWrapperHTMLElement } = useScrollWrapperHTMLElement();
@@ -75,40 +73,18 @@ export const useTriggerFetchPages = () => {
lastScrollPositionCallbackState,
);
const numberOfRowsDisplayedInTable = Math.min(
Math.floor(tableScrollWrapperHeight / (RECORD_TABLE_ROW_HEIGHT + 1)),
30,
);
const halfNumberOfRowsVisible = Math.floor(
numberOfRowsDisplayedInTable / 2,
);
const realIndexAtTheMiddleOfTheTable =
Math.floor(lastScrollPosition / (RECORD_TABLE_ROW_HEIGHT + 1)) +
halfNumberOfRowsVisible;
const pageForRealIndex = Math.ceil(
realIndexAtTheMiddleOfTheTable / DATA_PAGE_SIZE,
);
const totalNumberOfRealIndices =
const totalNumberOfRecordsToVirtualize =
getSnapshotValue(
snapshot,
totalNumberOfRecordsToVirtualizeCallbackState,
) ?? 0;
const maxPage = Math.ceil(totalNumberOfRealIndices / DATA_PAGE_SIZE);
const overscanPageAtTop = Math.max(
0,
pageForRealIndex - DATA_PAGE_OVERSCAN,
);
const overscanPageAtBottom = Math.min(
maxPage,
pageForRealIndex + DATA_PAGE_OVERSCAN,
);
const { overscanPageAtBottom, overscanPageAtTop } =
getVirtualizationOverscanWindow(
lastScrollPosition,
tableScrollWrapperHeight,
totalNumberOfRecordsToVirtualize,
);
const pagesAlreadyLoaded = getSnapshotValue(
snapshot,
@@ -150,10 +126,13 @@ export const useTriggerFetchPages = () => {
if (pagesAreContiguous) {
const startingRealIndexToFetch =
(pagesToFetch.at(0) ?? 0) * DATA_PAGE_SIZE;
(pagesToFetch.at(0) ?? 0) *
TABLE_VIRTUALIZATION_NUMBER_OF_RECORDS_PER_PAGE;
const endingRealIndexToFetch =
(pagesToFetch.at(-1) ?? 0) * DATA_PAGE_SIZE + DATA_PAGE_SIZE;
(pagesToFetch.at(-1) ?? 0) *
TABLE_VIRTUALIZATION_NUMBER_OF_RECORDS_PER_PAGE +
TABLE_VIRTUALIZATION_NUMBER_OF_RECORDS_PER_PAGE;
const numberOfRecordsToFetch =
endingRealIndexToFetch - startingRealIndexToFetch;
@@ -1,5 +1,5 @@
import { RecordTableComponentInstanceContext } from '@/object-record/record-table/states/context/RecordTableComponentInstanceContext';
import { type ObjectOperation } from '@/object-record/states/objectOperationsByObjectNameSingularFamilyState';
import { type ObjectOperation } from '@/object-record/states/objectOperationsComponentState';
import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState';
import { type Nullable } from 'twenty-shared/types';
@@ -0,0 +1,46 @@
import { RecordTableComponentInstanceContext } from '@/object-record/record-table/states/context/RecordTableComponentInstanceContext';
import { recordIdByRealIndexComponentFamilyState } from '@/object-record/record-table/virtualization/states/recordIdByRealIndexComponentFamilyState';
import { totalNumberOfRecordsToVirtualizeComponentState } from '@/object-record/record-table/virtualization/states/totalNumberOfRecordsToVirtualizeComponentState';
import { createComponentSelector } from '@/ui/utilities/state/component-state/utils/createComponentSelector';
import { isDefined } from 'twenty-shared/utils';
export const realIndexByRecordIdComponentSelector = createComponentSelector<{
realIndexByRecordIdMap: Map<string, number>;
}>({
key: 'realIndexByRecordIdComponentSelector',
componentInstanceContext: RecordTableComponentInstanceContext,
get:
({ instanceId }) =>
({ get }) => {
const totalNumberOfRecordsToVirtualize = get(
totalNumberOfRecordsToVirtualizeComponentState.atomFamily({
instanceId,
}),
);
const realIndexByRecordIdMap = new Map<string, number>();
if (!isDefined(totalNumberOfRecordsToVirtualize)) {
return { realIndexByRecordIdMap };
}
for (
let realIndex = 0;
realIndex < totalNumberOfRecordsToVirtualize;
realIndex++
) {
const recordId = get(
recordIdByRealIndexComponentFamilyState.atomFamily({
instanceId,
familyKey: { realIndex },
}),
);
if (isDefined(recordId)) {
realIndexByRecordIdMap.set(recordId, realIndex);
}
}
return { realIndexByRecordIdMap };
},
});
@@ -0,0 +1,53 @@
import { RECORD_TABLE_ROW_HEIGHT } from '@/object-record/record-table/constants/RecordTableRowHeight';
import { TABLE_VIRTUALIZATION_NUMBER_OF_OVERSCAN_PAGES } from '@/object-record/record-table/virtualization/constants/TableVirtualizationNumberOfOverscanPages';
import { TABLE_VIRTUALIZATION_NUMBER_OF_RECORDS_PER_PAGE } from '@/object-record/record-table/virtualization/constants/TableVirtualizationNumberOfRecordsPerPage';
export const getVirtualizationOverscanWindow = (
scrollPosition: number,
scrollWrapperHeight: number,
totalNumberOfRecordsToVirtualize: number,
) => {
const numberOfRowsDisplayedInTable = Math.min(
Math.floor(scrollWrapperHeight / (RECORD_TABLE_ROW_HEIGHT + 1)),
30,
);
const halfNumberOfRowsVisible = Math.floor(numberOfRowsDisplayedInTable / 2);
const realIndexAtTheMiddleOfTheTable =
Math.floor(scrollPosition / (RECORD_TABLE_ROW_HEIGHT + 1)) +
halfNumberOfRowsVisible;
const pageForRealIndex = Math.ceil(
realIndexAtTheMiddleOfTheTable /
TABLE_VIRTUALIZATION_NUMBER_OF_RECORDS_PER_PAGE,
);
const maxPage = Math.ceil(
totalNumberOfRecordsToVirtualize /
TABLE_VIRTUALIZATION_NUMBER_OF_RECORDS_PER_PAGE,
);
const overscanPageAtTop = Math.max(
0,
pageForRealIndex - TABLE_VIRTUALIZATION_NUMBER_OF_OVERSCAN_PAGES,
);
const overscanPageAtBottom = Math.min(
maxPage,
pageForRealIndex + TABLE_VIRTUALIZATION_NUMBER_OF_OVERSCAN_PAGES,
);
const firstRealIndexInOverscanWindow =
overscanPageAtTop * TABLE_VIRTUALIZATION_NUMBER_OF_RECORDS_PER_PAGE;
const lastRealIndexInOverscanWindow =
overscanPageAtBottom * TABLE_VIRTUALIZATION_NUMBER_OF_RECORDS_PER_PAGE;
return {
overscanPageAtTop,
overscanPageAtBottom,
firstRealIndexInOverscanWindow,
lastRealIndexInOverscanWindow,
};
};
@@ -1,18 +1,16 @@
import { createFamilyState } from '@/ui/utilities/state/utils/createFamilyState';
import { createState } from 'twenty-ui/utilities';
export type ObjectOperationData =
| {
type: 'update-one';
result: {
updateInput: any;
updatedRecord: any;
};
}
| {
type: 'update-many';
result: {
updateInputs: any[];
updatedRecords: any[];
};
}
| {
@@ -30,12 +28,12 @@ export type ObjectOperationData =
export type ObjectOperation = {
id: string;
objectMetadataItemId: string;
data: ObjectOperationData;
timestamp: number;
};
export const objectOperationsByObjectNameSingularFamilyState =
createFamilyState<ObjectOperation[], { objectNameSingular: string }>({
key: 'objectOperationsByObjectNameSingularFamilyState',
defaultValue: [],
});
export const objectOperationsState = createState<ObjectOperation[]>({
key: 'objectOperationsState',
defaultValue: [],
});
@@ -1,4 +1,6 @@
import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useRegisterObjectOperation } from '@/object-record/hooks/useRegisterObjectOperation';
import { useStopWorkflowRunMutation } from '~/generated-metadata/graphql';
@@ -9,17 +11,20 @@ export const useStopWorkflowRun = () => {
});
const { registerObjectOperation } = useRegisterObjectOperation();
const { objectMetadataItem } = useObjectMetadataItem({
objectNameSingular: CoreObjectNameSingular.WorkflowRun,
});
const stopWorkflowRun = async (workflowRunId: string) => {
const { data } = await mutate({
await mutate({
variables: {
workflowRunId,
},
});
registerObjectOperation('workflowRun', {
registerObjectOperation(objectMetadataItem, {
type: 'update-one',
result: {
updatedRecord: data?.stopWorkflowRun,
updateInput: { id: workflowRunId },
},
});
@@ -0,0 +1,3 @@
export const mapById = <T extends { id: string }>(itemToMap: T) => {
return itemToMap.id;
};
@@ -0,0 +1,5 @@
export const mapByProperty =
<T extends { id: string }>(propertyName: keyof T) =>
(itemToMap: T) => {
return itemToMap[propertyName];
};
@@ -14,6 +14,8 @@ export { findByProperty } from './array/findByProperty';
export { findOrThrow } from './array/findOrThrow';
export { getContiguousIncrementalValues } from './array/getContiguousIncrementalValues';
export { isNonEmptyArray } from './array/isNonEmptyArray';
export { mapById } from './array/mapById';
export { mapByProperty } from './array/mapByProperty';
export { sumByProperty } from './array/sumByProperty';
export { upsertIntoArrayOfObjectsComparingId } from './array/upsertIntoArrayOfObjectComparingId';
export { assertUnreachable } from './assertUnreachable';