9018 fix batch delete (#9149)

Closes #9018
This commit is contained in:
Raphaël Bosi
2024-12-20 10:46:24 +01:00
committed by GitHub
parent a0b5720831
commit 925294675c
22 changed files with 413 additions and 50 deletions

View File

@ -8,8 +8,6 @@ import { contextStoreFiltersComponentState } from '@/context-store/states/contex
import { contextStoreNumberOfSelectedRecordsComponentState } from '@/context-store/states/contextStoreNumberOfSelectedRecordsComponentState';
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
import { computeContextStoreFilters } from '@/context-store/utils/computeContextStoreFilters';
import { useDeleteFavorite } from '@/favorites/hooks/useDeleteFavorite';
import { useFavorites } from '@/favorites/hooks/useFavorites';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { DEFAULT_QUERY_PAGE_SIZE } from '@/object-record/constants/DefaultQueryPageSize';
import { DELETE_MAX_COUNT } from '@/object-record/constants/DeleteMaxCount';
@ -40,9 +38,6 @@ export const useDeleteMultipleRecordsAction = ({
objectNameSingular: objectMetadataItem.nameSingular,
});
const { sortedFavorites: favorites } = useFavorites();
const { deleteFavorite } = useDeleteFavorite();
const contextStoreNumberOfSelectedRecords = useRecoilComponentValueV2(
contextStoreNumberOfSelectedRecordsComponentState,
);
@ -76,26 +71,8 @@ export const useDeleteMultipleRecordsAction = ({
resetTableRowSelection();
for (const recordIdToDelete of recordIdsToDelete) {
const foundFavorite = favorites?.find(
(favorite) => favorite.recordId === recordIdToDelete,
);
if (foundFavorite !== undefined) {
deleteFavorite(foundFavorite.id);
}
}
await deleteManyRecords(recordIdsToDelete, {
delayInMsBetweenRequests: 50,
});
}, [
deleteFavorite,
deleteManyRecords,
favorites,
fetchAllRecordIds,
resetTableRowSelection,
]);
await deleteManyRecords(recordIdsToDelete);
}, [deleteManyRecords, fetchAllRecordIds, resetTableRowSelection]);
const isRemoteObject = objectMetadataItem.isRemote;
@ -105,7 +82,7 @@ export const useDeleteMultipleRecordsAction = ({
contextStoreNumberOfSelectedRecords < DELETE_MAX_COUNT &&
contextStoreNumberOfSelectedRecords > 0;
const { isInRightDrawer, onActionExecutedCallback } =
const { isInRightDrawer, onActionStartedCallback, onActionExecutedCallback } =
useContext(ActionMenuContext);
const registerDeleteMultipleRecordsAction = ({
@ -133,9 +110,14 @@ export const useDeleteMultipleRecordsAction = ({
setIsOpen={setIsDeleteRecordsModalOpen}
title={'Delete Records'}
subtitle={`Are you sure you want to delete these records? They can be recovered from the Options menu.`}
onConfirmClick={() => {
handleDeleteClick();
onActionExecutedCallback?.();
onConfirmClick={async () => {
onActionStartedCallback?.({
key: 'delete-multiple-records',
});
await handleDeleteClick();
onActionExecutedCallback?.({
key: 'delete-multiple-records',
});
if (isInRightDrawer) {
closeRightDrawer();
}

View File

@ -54,8 +54,7 @@ export const useDeleteSingleRecordAction: SingleRecordActionHookWithObjectMetada
const isRemoteObject = objectMetadataItem.isRemote;
const { isInRightDrawer, onActionExecutedCallback } =
useContext(ActionMenuContext);
const { isInRightDrawer } = useContext(ActionMenuContext);
const shouldBeRegistered =
!isRemoteObject && isNull(selectedRecord?.deletedAt);
@ -81,7 +80,6 @@ export const useDeleteSingleRecordAction: SingleRecordActionHookWithObjectMetada
}
onConfirmClick={() => {
handleDeleteClick();
onActionExecutedCallback?.();
if (isInRightDrawer) {
closeRightDrawer();
}

View File

@ -61,7 +61,7 @@ export const useDestroySingleRecordAction: SingleRecordActionHookWithObjectMetad
}
onConfirmClick={async () => {
await handleDeleteClick();
onActionExecutedCallback?.();
onActionExecutedCallback?.({ key: 'destroy-single-record' });
if (isInRightDrawer) {
closeRightDrawer();
}

View File

@ -8,11 +8,13 @@ import { RecordIndexActionMenuEffect } from '@/action-menu/components/RecordInde
import { ActionMenuContext } from '@/action-menu/contexts/ActionMenuContext';
import { contextStoreCurrentObjectMetadataIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataIdComponentState';
import { isRecordIndexLoadMoreLockedComponentState } from '@/object-record/record-index/states/isRecordIndexLoadMoreLockedComponentState';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { useIsMobile } from 'twenty-ui';
export const RecordIndexActionMenu = () => {
export const RecordIndexActionMenu = ({ indexId }: { indexId: string }) => {
const contextStoreCurrentObjectMetadataId = useRecoilComponentValueV2(
contextStoreCurrentObjectMetadataIdComponentState,
);
@ -25,13 +27,27 @@ export const RecordIndexActionMenu = () => {
const isMobile = useIsMobile();
const setIsLoadMoreLocked = useSetRecoilComponentStateV2(
isRecordIndexLoadMoreLockedComponentState,
indexId,
);
return (
<>
{contextStoreCurrentObjectMetadataId && (
<ActionMenuContext.Provider
value={{
isInRightDrawer: false,
onActionExecutedCallback: () => {},
onActionStartedCallback: (action) => {
if (action.key === 'delete-multiple-records') {
setIsLoadMoreLocked(true);
}
},
onActionExecutedCallback: (action) => {
if (action.key === 'delete-multiple-records') {
setIsLoadMoreLocked(false);
}
},
}}
>
{isPageHeaderV2Enabled ? (

View File

@ -28,7 +28,7 @@ export const RecordIndexActionMenuButtons = () => {
variant="secondary"
accent="default"
title={entry.shortLabel}
onClick={() => entry.onClick?.()}
onClick={entry.onClick}
ariaLabel={entry.label}
/>
))}

View File

@ -2,10 +2,12 @@ import { createContext } from 'react';
type ActionMenuContextType = {
isInRightDrawer: boolean;
onActionExecutedCallback: () => void;
onActionStartedCallback?: (action: { key: string }) => void;
onActionExecutedCallback?: (action: { key: string }) => void;
};
export const ActionMenuContext = createContext<ActionMenuContextType>({
isInRightDrawer: false,
onActionStartedCallback: () => {},
onActionExecutedCallback: () => {},
});

View File

@ -0,0 +1,132 @@
import { ApolloCache, StoreObject } from '@apollo/client';
import { sortCachedObjectEdges } from '@/apollo/optimistic-effect/utils/sortCachedObjectEdges';
import { triggerUpdateRelationsOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerUpdateRelationsOptimisticEffect';
import { CachedObjectRecordQueryVariables } from '@/apollo/types/CachedObjectRecordQueryVariables';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { RecordGqlRefEdge } from '@/object-record/cache/types/RecordGqlRefEdge';
import { getEdgeTypename } from '@/object-record/cache/utils/getEdgeTypename';
import { isObjectRecordConnectionWithRefs } from '@/object-record/cache/utils/isObjectRecordConnectionWithRefs';
import { RecordGqlNode } from '@/object-record/graphql/types/RecordGqlNode';
import { isRecordMatchingFilter } from '@/object-record/record-filter/utils/isRecordMatchingFilter';
import { isDefined } from '~/utils/isDefined';
import { parseApolloStoreFieldName } from '~/utils/parseApolloStoreFieldName';
// TODO: add extensive unit tests for this function
// That will also serve as documentation
export const triggerUpdateRecordOptimisticEffectByBatch = ({
cache,
objectMetadataItem,
currentRecords,
updatedRecords,
objectMetadataItems,
}: {
cache: ApolloCache<unknown>;
objectMetadataItem: ObjectMetadataItem;
currentRecords: RecordGqlNode[];
updatedRecords: RecordGqlNode[];
objectMetadataItems: ObjectMetadataItem[];
}) => {
for (const [index, currentRecord] of currentRecords.entries()) {
triggerUpdateRelationsOptimisticEffect({
cache,
sourceObjectMetadataItem: objectMetadataItem,
currentSourceRecord: currentRecord,
updatedSourceRecord: updatedRecords[index],
objectMetadataItems,
});
}
cache.modify<StoreObject>({
fields: {
[objectMetadataItem.namePlural]: (
rootQueryCachedResponse,
{ readField, storeFieldName, toReference },
) => {
const shouldSkip = !isObjectRecordConnectionWithRefs(
objectMetadataItem.nameSingular,
rootQueryCachedResponse,
);
if (shouldSkip) {
return rootQueryCachedResponse;
}
const rootQueryConnection = rootQueryCachedResponse;
const { fieldVariables: rootQueryVariables } =
parseApolloStoreFieldName<CachedObjectRecordQueryVariables>(
storeFieldName,
);
const rootQueryCurrentEdges =
readField<RecordGqlRefEdge[]>('edges', rootQueryConnection) ?? [];
let rootQueryNextEdges = [...rootQueryCurrentEdges];
const rootQueryFilter = rootQueryVariables?.filter;
const rootQueryOrderBy = rootQueryVariables?.orderBy;
for (const updatedRecord of updatedRecords) {
const updatedRecordMatchesThisRootQueryFilter =
isRecordMatchingFilter({
record: updatedRecord,
filter: rootQueryFilter ?? {},
objectMetadataItem,
});
const updatedRecordIndexInRootQueryEdges =
rootQueryCurrentEdges.findIndex(
(cachedEdge) =>
readField('id', cachedEdge.node) === updatedRecord.id,
);
const updatedRecordFoundInRootQueryEdges =
updatedRecordIndexInRootQueryEdges > -1;
const updatedRecordShouldBeAddedToRootQueryEdges =
updatedRecordMatchesThisRootQueryFilter &&
!updatedRecordFoundInRootQueryEdges;
const updatedRecordShouldBeRemovedFromRootQueryEdges =
!updatedRecordMatchesThisRootQueryFilter &&
updatedRecordFoundInRootQueryEdges;
if (updatedRecordShouldBeAddedToRootQueryEdges) {
const updatedRecordNodeReference = toReference(updatedRecord);
if (isDefined(updatedRecordNodeReference)) {
rootQueryNextEdges.push({
__typename: getEdgeTypename(objectMetadataItem.nameSingular),
node: updatedRecordNodeReference,
cursor: '',
});
}
}
if (updatedRecordShouldBeRemovedFromRootQueryEdges) {
rootQueryNextEdges.splice(updatedRecordIndexInRootQueryEdges, 1);
}
}
const rootQueryNextEdgesShouldBeSorted = isDefined(rootQueryOrderBy);
if (
rootQueryNextEdgesShouldBeSorted &&
Object.getOwnPropertyNames(rootQueryOrderBy).length > 0
) {
rootQueryNextEdges = sortCachedObjectEdges({
edges: rootQueryNextEdges,
orderBy: rootQueryOrderBy,
readCacheField: readField,
});
}
return {
...rootQueryConnection,
edges: rootQueryNextEdges,
};
},
},
});
};

View File

@ -1,6 +1,7 @@
import { useApolloClient } from '@apollo/client';
import { triggerUpdateRecordOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerUpdateRecordOptimisticEffect';
import { triggerUpdateRecordOptimisticEffectByBatch } from '@/apollo/optimistic-effect/utils/triggerUpdateRecordOptimisticEffectByBatch';
import { apiConfigState } from '@/client-config/states/apiConfigState';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
@ -8,6 +9,7 @@ import { useGetRecordFromCache } from '@/object-record/cache/hooks/useGetRecordF
import { getRecordNodeFromRecord } from '@/object-record/cache/utils/getRecordNodeFromRecord';
import { updateRecordFromCache } from '@/object-record/cache/utils/updateRecordFromCache';
import { DEFAULT_MUTATION_BATCH_SIZE } from '@/object-record/constants/DefaultMutationBatchSize';
import { RecordGqlNode } from '@/object-record/graphql/types/RecordGqlNode';
import { useDeleteManyRecordsMutation } from '@/object-record/hooks/useDeleteManyRecordsMutation';
import { useRefetchAggregateQueries } from '@/object-record/hooks/useRefetchAggregateQueries';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
@ -80,6 +82,9 @@ export const useDeleteManyRecords = ({
.map((idToDelete) => getRecordFromCache(idToDelete, apolloClient.cache))
.filter(isDefined);
const cachedRecordsWithConnection: RecordGqlNode[] = [];
const optimisticRecordsWithConnection: RecordGqlNode[] = [];
if (!options?.skipOptimisticEffect) {
cachedRecords.forEach((cachedRecord) => {
if (!cachedRecord || !cachedRecord.id) {
@ -112,20 +117,23 @@ export const useDeleteManyRecords = ({
return null;
}
cachedRecordsWithConnection.push(cachedRecordWithConnection);
optimisticRecordsWithConnection.push(optimisticRecordWithConnection);
updateRecordFromCache({
objectMetadataItems,
objectMetadataItem,
cache: apolloClient.cache,
record: computedOptimisticRecord,
});
});
triggerUpdateRecordOptimisticEffect({
cache: apolloClient.cache,
objectMetadataItem,
currentRecord: cachedRecordWithConnection,
updatedRecord: optimisticRecordWithConnection,
objectMetadataItems,
});
triggerUpdateRecordOptimisticEffectByBatch({
cache: apolloClient.cache,
objectMetadataItem,
currentRecords: cachedRecordsWithConnection,
updatedRecords: optimisticRecordsWithConnection,
objectMetadataItems,
});
}

View File

@ -7,6 +7,8 @@ import { GRAY_SCALE } from 'twenty-ui';
import { RecordBoardColumnContext } from '@/object-record/record-board/record-board-column/contexts/RecordBoardColumnContext';
import { isRecordBoardFetchingRecordsByColumnFamilyState } from '@/object-record/record-board/states/isRecordBoardFetchingRecordsByColumnFamilyState';
import { recordBoardShouldFetchMoreInColumnComponentFamilyState } from '@/object-record/record-board/states/recordBoardShouldFetchMoreInColumnComponentFamilyState';
import { isRecordIndexLoadMoreLockedComponentState } from '@/object-record/record-index/states/isRecordIndexLoadMoreLockedComponentState';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { useSetRecoilComponentFamilyStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentFamilyStateV2';
const StyledText = styled.div`
@ -31,11 +33,23 @@ export const RecordBoardColumnFetchMoreLoader = () => {
columnDefinition.id,
);
const isLoadMoreLocked = useRecoilComponentValueV2(
isRecordIndexLoadMoreLockedComponentState,
);
const { ref, inView } = useInView();
useEffect(() => {
if (isLoadMoreLocked) {
return;
}
setShouldFetchMore(inView);
}, [setShouldFetchMore, inView]);
}, [setShouldFetchMore, inView, isLoadMoreLocked]);
if (isLoadMoreLocked) {
return null;
}
return (
<div ref={ref}>

View File

@ -230,6 +230,7 @@ export const RecordIndexContainer = () => {
objectNamePlural={objectNamePlural}
viewBarId={recordIndexId}
/>
<RecordIndexTableContainerEffect />
</SpreadsheetImportProvider>
<RecordIndexFiltersToContextStoreEffect />
{recordIndexViewType === ViewType.Table && (
@ -255,7 +256,9 @@ export const RecordIndexContainer = () => {
<RecordIndexBoardDataLoaderEffect recordBoardId={recordIndexId} />
</StyledContainerWithPadding>
)}
{!isPageHeaderV2Enabled && <RecordIndexActionMenu />}
{!isPageHeaderV2Enabled && (
<RecordIndexActionMenu indexId={recordIndexId} />
)}
</RecordFieldValueSelectorContextProvider>
</StyledContainer>
</>

View File

@ -29,6 +29,8 @@ export const RecordIndexPageHeader = () => {
const recordIndexViewType = useRecoilValue(recordIndexViewTypeState);
const { recordIndexId } = useRecordIndexContextOrThrow();
const numberOfSelectedRecords = useRecoilComponentValueV2(
contextStoreNumberOfSelectedRecordsComponentState,
);
@ -64,7 +66,7 @@ export const RecordIndexPageHeader = () => {
{isPageHeaderV2Enabled && (
<>
<RecordIndexActionMenu />
<RecordIndexActionMenu indexId={recordIndexId} />
<PageHeaderOpenCommandMenuButton />
</>
)}

View File

@ -0,0 +1,9 @@
import { createComponentStateV2 } from '@/ui/utilities/state/component-state/utils/createComponentStateV2';
import { ViewComponentInstanceContext } from '@/views/states/contexts/ViewComponentInstanceContext';
export const isRecordIndexLoadMoreLockedComponentState =
createComponentStateV2<boolean>({
key: 'isRecordIndexLoadMoreLockedComponentState',
componentInstanceContext: ViewComponentInstanceContext,
defaultValue: false,
});

View File

@ -4,6 +4,7 @@ import { useInView } from 'react-intersection-observer';
import { useRecoilCallback } from 'recoil';
import { GRAY_SCALE } from 'twenty-ui';
import { isRecordIndexLoadMoreLockedComponentState } from '@/object-record/record-index/states/isRecordIndexLoadMoreLockedComponentState';
import { useRecordTable } from '@/object-record/record-table/hooks/useRecordTable';
import { hasRecordTableFetchedAllRecordsComponentStateV2 } from '@/object-record/record-table/states/hasRecordTableFetchedAllRecordsComponentStateV2';
import { RecordTableWithWrappersScrollWrapperContext } from '@/ui/utilities/scroll/contexts/ScrollWrapperContexts';
@ -22,11 +23,19 @@ const StyledText = styled.div`
export const RecordTableBodyFetchMoreLoader = () => {
const { setRecordTableLastRowVisible } = useRecordTable();
const isRecordTableLoadMoreLocked = useRecoilComponentValueV2(
isRecordIndexLoadMoreLockedComponentState,
);
const onLastRowVisible = useRecoilCallback(
() => async (inView: boolean) => {
if (isRecordTableLoadMoreLocked) {
return;
}
setRecordTableLastRowVisible(inView);
},
[setRecordTableLastRowVisible],
[setRecordTableLastRowVisible, isRecordTableLoadMoreLocked],
);
const scrollWrapperRef = useContext(
@ -37,7 +46,8 @@ export const RecordTableBodyFetchMoreLoader = () => {
hasRecordTableFetchedAllRecordsComponentStateV2,
);
const showLoadingMoreRow = !hasRecordTableFetchedAllRecordsComponents;
const showLoadingMoreRow =
!hasRecordTableFetchedAllRecordsComponents && !isRecordTableLoadMoreLocked;
const { ref: tbodyRef } = useInView({
onChange: onLastRowVisible,

View File

@ -1,5 +1,6 @@
import { useCurrentRecordGroupId } from '@/object-record/record-group/hooks/useCurrentRecordGroupId';
import { useLazyLoadRecordIndexTable } from '@/object-record/record-index/hooks/useLazyLoadRecordIndexTable';
import { isRecordIndexLoadMoreLockedComponentState } from '@/object-record/record-index/states/isRecordIndexLoadMoreLockedComponentState';
import { recordIndexHasFetchedAllRecordsByGroupComponentState } from '@/object-record/record-index/states/recordIndexHasFetchedAllRecordsByGroupComponentState';
import { recordIndexAllRecordIdsComponentSelector } from '@/object-record/record-index/states/selectors/recordIndexAllRecordIdsComponentSelector';
import { useRecordTableContextOrThrow } from '@/object-record/record-table/contexts/RecordTableContext';
@ -20,6 +21,10 @@ export const RecordTableRecordGroupSectionLoadMore = () => {
currentRecordGroupId,
);
const isLoadMoreLocked = useRecoilComponentValueV2(
isRecordIndexLoadMoreLockedComponentState,
);
const recordIds = useRecoilComponentValueV2(
recordIndexAllRecordIdsComponentSelector,
);
@ -28,7 +33,7 @@ export const RecordTableRecordGroupSectionLoadMore = () => {
fetchMoreRecords();
};
if (hasFetchedAllRecords) {
if (hasFetchedAllRecords || isLoadMoreLocked) {
return null;
}