diff --git a/packages/twenty-front/src/modules/action-menu/actions/record-actions/multiple-records/hooks/useDeleteMultipleRecordsAction.tsx b/packages/twenty-front/src/modules/action-menu/actions/record-actions/multiple-records/hooks/useDeleteMultipleRecordsAction.tsx index 19fa6d75e..d240e761c 100644 --- a/packages/twenty-front/src/modules/action-menu/actions/record-actions/multiple-records/hooks/useDeleteMultipleRecordsAction.tsx +++ b/packages/twenty-front/src/modules/action-menu/actions/record-actions/multiple-records/hooks/useDeleteMultipleRecordsAction.tsx @@ -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(); } diff --git a/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/hooks/useDeleteSingleRecordAction.tsx b/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/hooks/useDeleteSingleRecordAction.tsx index cb58be678..6adbbab02 100644 --- a/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/hooks/useDeleteSingleRecordAction.tsx +++ b/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/hooks/useDeleteSingleRecordAction.tsx @@ -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(); } diff --git a/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/hooks/useDestroySingleRecordAction.tsx b/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/hooks/useDestroySingleRecordAction.tsx index c024df812..e9e37d822 100644 --- a/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/hooks/useDestroySingleRecordAction.tsx +++ b/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/hooks/useDestroySingleRecordAction.tsx @@ -61,7 +61,7 @@ export const useDestroySingleRecordAction: SingleRecordActionHookWithObjectMetad } onConfirmClick={async () => { await handleDeleteClick(); - onActionExecutedCallback?.(); + onActionExecutedCallback?.({ key: 'destroy-single-record' }); if (isInRightDrawer) { closeRightDrawer(); } diff --git a/packages/twenty-front/src/modules/action-menu/components/RecordIndexActionMenu.tsx b/packages/twenty-front/src/modules/action-menu/components/RecordIndexActionMenu.tsx index 8eafd9040..1e3789a9e 100644 --- a/packages/twenty-front/src/modules/action-menu/components/RecordIndexActionMenu.tsx +++ b/packages/twenty-front/src/modules/action-menu/components/RecordIndexActionMenu.tsx @@ -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 && ( {}, + onActionStartedCallback: (action) => { + if (action.key === 'delete-multiple-records') { + setIsLoadMoreLocked(true); + } + }, + onActionExecutedCallback: (action) => { + if (action.key === 'delete-multiple-records') { + setIsLoadMoreLocked(false); + } + }, }} > {isPageHeaderV2Enabled ? ( diff --git a/packages/twenty-front/src/modules/action-menu/components/RecordIndexActionMenuButtons.tsx b/packages/twenty-front/src/modules/action-menu/components/RecordIndexActionMenuButtons.tsx index fdafd08d2..80c1080e0 100644 --- a/packages/twenty-front/src/modules/action-menu/components/RecordIndexActionMenuButtons.tsx +++ b/packages/twenty-front/src/modules/action-menu/components/RecordIndexActionMenuButtons.tsx @@ -28,7 +28,7 @@ export const RecordIndexActionMenuButtons = () => { variant="secondary" accent="default" title={entry.shortLabel} - onClick={() => entry.onClick?.()} + onClick={entry.onClick} ariaLabel={entry.label} /> ))} diff --git a/packages/twenty-front/src/modules/action-menu/contexts/ActionMenuContext.ts b/packages/twenty-front/src/modules/action-menu/contexts/ActionMenuContext.ts index 0c1482f40..65355f11f 100644 --- a/packages/twenty-front/src/modules/action-menu/contexts/ActionMenuContext.ts +++ b/packages/twenty-front/src/modules/action-menu/contexts/ActionMenuContext.ts @@ -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({ isInRightDrawer: false, + onActionStartedCallback: () => {}, onActionExecutedCallback: () => {}, }); diff --git a/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerUpdateRecordOptimisticEffectByBatch.ts b/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerUpdateRecordOptimisticEffectByBatch.ts new file mode 100644 index 000000000..0b115f958 --- /dev/null +++ b/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerUpdateRecordOptimisticEffectByBatch.ts @@ -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; + 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({ + fields: { + [objectMetadataItem.namePlural]: ( + rootQueryCachedResponse, + { readField, storeFieldName, toReference }, + ) => { + const shouldSkip = !isObjectRecordConnectionWithRefs( + objectMetadataItem.nameSingular, + rootQueryCachedResponse, + ); + + if (shouldSkip) { + return rootQueryCachedResponse; + } + + const rootQueryConnection = rootQueryCachedResponse; + + const { fieldVariables: rootQueryVariables } = + parseApolloStoreFieldName( + storeFieldName, + ); + + const rootQueryCurrentEdges = + readField('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, + }; + }, + }, + }); +}; diff --git a/packages/twenty-front/src/modules/object-record/hooks/useDeleteManyRecords.ts b/packages/twenty-front/src/modules/object-record/hooks/useDeleteManyRecords.ts index 404320943..4de099eb2 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/useDeleteManyRecords.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/useDeleteManyRecords.ts @@ -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, }); } diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnFetchMoreLoader.tsx b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnFetchMoreLoader.tsx index 1143aaf5e..809237272 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnFetchMoreLoader.tsx +++ b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnFetchMoreLoader.tsx @@ -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 (
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 8b239017f..6a9ad6eb7 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 @@ -230,6 +230,7 @@ export const RecordIndexContainer = () => { objectNamePlural={objectNamePlural} viewBarId={recordIndexId} /> + {recordIndexViewType === ViewType.Table && ( @@ -255,7 +256,9 @@ export const RecordIndexContainer = () => { )} - {!isPageHeaderV2Enabled && } + {!isPageHeaderV2Enabled && ( + + )} diff --git a/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexPageHeader.tsx b/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexPageHeader.tsx index c2d6584ff..757eb4715 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexPageHeader.tsx +++ b/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexPageHeader.tsx @@ -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 && ( <> - + )} diff --git a/packages/twenty-front/src/modules/object-record/record-index/states/isRecordIndexLoadMoreLockedComponentState.ts b/packages/twenty-front/src/modules/object-record/record-index/states/isRecordIndexLoadMoreLockedComponentState.ts new file mode 100644 index 000000000..dbdb3a0f3 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-index/states/isRecordIndexLoadMoreLockedComponentState.ts @@ -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({ + key: 'isRecordIndexLoadMoreLockedComponentState', + componentInstanceContext: ViewComponentInstanceContext, + defaultValue: false, + }); diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableBodyFetchMoreLoader.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableBodyFetchMoreLoader.tsx index 3ef7983c0..3a73c095c 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableBodyFetchMoreLoader.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableBodyFetchMoreLoader.tsx @@ -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, diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-section/components/RecordTableRecordGroupSectionLoadMore.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-section/components/RecordTableRecordGroupSectionLoadMore.tsx index e51745f6b..4f959e054 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-section/components/RecordTableRecordGroupSectionLoadMore.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-section/components/RecordTableRecordGroupSectionLoadMore.tsx @@ -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; } diff --git a/packages/twenty-server/src/engine/core-modules/message-queue/jobs.module.ts b/packages/twenty-server/src/engine/core-modules/message-queue/jobs.module.ts index ea8edafb8..1707e74ef 100644 --- a/packages/twenty-server/src/engine/core-modules/message-queue/jobs.module.ts +++ b/packages/twenty-server/src/engine/core-modules/message-queue/jobs.module.ts @@ -22,6 +22,7 @@ import { CleanInactiveWorkspaceJob } from 'src/engine/workspace-manager/workspac import { CalendarEventParticipantManagerModule } from 'src/modules/calendar/calendar-event-participant-manager/calendar-event-participant-manager.module'; import { CalendarModule } from 'src/modules/calendar/calendar.module'; import { AutoCompaniesAndContactsCreationJobModule } from 'src/modules/contact-creation-manager/jobs/auto-companies-and-contacts-creation-job.module'; +import { FavoriteModule } from 'src/modules/favorite/favorite.module'; import { MessagingModule } from 'src/modules/messaging/messaging.module'; import { TimelineJobModule } from 'src/modules/timeline/jobs/timeline-job.module'; import { TimelineActivityModule } from 'src/modules/timeline/timeline-activity.module'; @@ -50,6 +51,7 @@ import { WorkflowModule } from 'src/modules/workflow/workflow.module'; TimelineJobModule, WebhookJobModule, WorkflowModule, + FavoriteModule, ], providers: [ CleanInactiveWorkspaceJob, diff --git a/packages/twenty-server/src/engine/core-modules/message-queue/message-queue.constants.ts b/packages/twenty-server/src/engine/core-modules/message-queue/message-queue.constants.ts index 42262c151..31804805d 100644 --- a/packages/twenty-server/src/engine/core-modules/message-queue/message-queue.constants.ts +++ b/packages/twenty-server/src/engine/core-modules/message-queue/message-queue.constants.ts @@ -18,4 +18,5 @@ export enum MessageQueue { testQueue = 'test-queue', workflowQueue = 'workflow-queue', serverlessFunctionQueue = 'serverless-function-queue', + favoriteQueue = 'favorite-queue', } diff --git a/packages/twenty-server/src/modules/favorite/constants/favorite-deletion-batch-size.ts b/packages/twenty-server/src/modules/favorite/constants/favorite-deletion-batch-size.ts new file mode 100644 index 000000000..14e4b40c8 --- /dev/null +++ b/packages/twenty-server/src/modules/favorite/constants/favorite-deletion-batch-size.ts @@ -0,0 +1 @@ +export const FAVORITE_DELETION_BATCH_SIZE = 100; diff --git a/packages/twenty-server/src/modules/favorite/favorite.module.ts b/packages/twenty-server/src/modules/favorite/favorite.module.ts new file mode 100644 index 000000000..5fac130bc --- /dev/null +++ b/packages/twenty-server/src/modules/favorite/favorite.module.ts @@ -0,0 +1,24 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; +import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; +import { FavoriteDeletionJob } from 'src/modules/favorite/jobs/favorite-deletion.job'; +import { FavoriteDeletionListener } from 'src/modules/favorite/listeners/favorite-deletion.listener'; +import { FavoriteDeletionService } from 'src/modules/favorite/services/favorite-deletion.service'; + +@Module({ + imports: [ + TypeOrmModule.forFeature( + [ObjectMetadataEntity, FieldMetadataEntity], + 'metadata', + ), + ], + providers: [ + FavoriteDeletionService, + FavoriteDeletionListener, + FavoriteDeletionJob, + ], + exports: [], +}) +export class FavoriteModule {} diff --git a/packages/twenty-server/src/modules/favorite/jobs/favorite-deletion.job.ts b/packages/twenty-server/src/modules/favorite/jobs/favorite-deletion.job.ts new file mode 100644 index 000000000..140466c2f --- /dev/null +++ b/packages/twenty-server/src/modules/favorite/jobs/favorite-deletion.job.ts @@ -0,0 +1,29 @@ +import { Scope } from '@nestjs/common'; + +import { Process } from 'src/engine/core-modules/message-queue/decorators/process.decorator'; +import { Processor } from 'src/engine/core-modules/message-queue/decorators/processor.decorator'; +import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants'; +import { FavoriteDeletionService } from 'src/modules/favorite/services/favorite-deletion.service'; + +export type FavoriteDeletionJobData = { + workspaceId: string; + deletedRecordIds: string[]; +}; + +@Processor({ + queueName: MessageQueue.favoriteQueue, + scope: Scope.REQUEST, +}) +export class FavoriteDeletionJob { + constructor( + private readonly favoriteDeletionService: FavoriteDeletionService, + ) {} + + @Process(FavoriteDeletionJob.name) + async handle(data: FavoriteDeletionJobData): Promise { + await this.favoriteDeletionService.deleteFavoritesForDeletedRecords( + data.deletedRecordIds, + data.workspaceId, + ); + } +} diff --git a/packages/twenty-server/src/modules/favorite/listeners/favorite-deletion.listener.ts b/packages/twenty-server/src/modules/favorite/listeners/favorite-deletion.listener.ts new file mode 100644 index 000000000..2a41cdc76 --- /dev/null +++ b/packages/twenty-server/src/modules/favorite/listeners/favorite-deletion.listener.ts @@ -0,0 +1,36 @@ +import { Injectable } from '@nestjs/common'; + +import { OnDatabaseBatchEvent } from 'src/engine/api/graphql/graphql-query-runner/decorators/on-database-batch-event.decorator'; +import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action'; +import { ObjectRecordDeleteEvent } from 'src/engine/core-modules/event-emitter/types/object-record-delete.event'; +import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator'; +import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants'; +import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service'; +import { WorkspaceEventBatch } from 'src/engine/workspace-event-emitter/types/workspace-event.type'; +import { + FavoriteDeletionJob, + FavoriteDeletionJobData, +} from 'src/modules/favorite/jobs/favorite-deletion.job'; + +@Injectable() +export class FavoriteDeletionListener { + constructor( + @InjectMessageQueue(MessageQueue.favoriteQueue) + private readonly messageQueueService: MessageQueueService, + ) {} + + @OnDatabaseBatchEvent('*', DatabaseEventAction.DELETED) + async handleDeletedEvent( + payload: WorkspaceEventBatch, + ) { + const deletedRecordIds = payload.events.map(({ recordId }) => recordId); + + await this.messageQueueService.add( + FavoriteDeletionJob.name, + { + workspaceId: payload.workspaceId, + deletedRecordIds, + }, + ); + } +} diff --git a/packages/twenty-server/src/modules/favorite/services/favorite-deletion.service.ts b/packages/twenty-server/src/modules/favorite/services/favorite-deletion.service.ts new file mode 100644 index 000000000..32f62a34b --- /dev/null +++ b/packages/twenty-server/src/modules/favorite/services/favorite-deletion.service.ts @@ -0,0 +1,87 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; + +import { In, Repository } from 'typeorm'; + +import { + FieldMetadataEntity, + FieldMetadataType, +} from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; +import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; +import { TwentyORMManager } from 'src/engine/twenty-orm/twenty-orm.manager'; +import { FAVORITE_DELETION_BATCH_SIZE } from 'src/modules/favorite/constants/favorite-deletion-batch-size'; +import { FavoriteWorkspaceEntity } from 'src/modules/favorite/standard-objects/favorite.workspace-entity'; + +@Injectable() +export class FavoriteDeletionService { + constructor( + @InjectRepository(ObjectMetadataEntity, 'metadata') + private readonly objectMetadataRepository: Repository, + + @InjectRepository(FieldMetadataEntity, 'metadata') + private readonly fieldMetadataRepository: Repository, + private readonly twentyORMManager: TwentyORMManager, + ) {} + + async deleteFavoritesForDeletedRecords( + deletedRecordIds: string[], + workspaceId: string, + ): Promise { + const favoriteRepository = + await this.twentyORMManager.getRepository( + 'favorite', + ); + + const favoriteObjectMetadata = await this.objectMetadataRepository.findOne({ + where: { + nameSingular: 'favorite', + workspaceId, + }, + }); + + if (!favoriteObjectMetadata) { + throw new Error('Favorite object metadata not found'); + } + + const favoriteFields = await this.fieldMetadataRepository.find({ + where: { + objectMetadataId: favoriteObjectMetadata.id, + type: FieldMetadataType.RELATION, + }, + }); + + const favoritesToDelete = await favoriteRepository.find({ + select: { + id: true, + }, + where: favoriteFields.map((field) => ({ + [`${field.name}Id`]: In(deletedRecordIds), + })), + withDeleted: true, + }); + + if (favoritesToDelete.length === 0) { + return; + } + + const favoriteIdsToDelete = favoritesToDelete.map( + (favorite) => favorite.id, + ); + + const batches: string[][] = []; + + for ( + let i = 0; + i < favoriteIdsToDelete.length; + i += FAVORITE_DELETION_BATCH_SIZE + ) { + batches.push( + favoriteIdsToDelete.slice(i, i + FAVORITE_DELETION_BATCH_SIZE), + ); + } + + for (const batch of batches) { + await favoriteRepository.delete(batch); + } + } +} diff --git a/packages/twenty-server/src/modules/modules.module.ts b/packages/twenty-server/src/modules/modules.module.ts index d3cc91566..10bde37ef 100644 --- a/packages/twenty-server/src/modules/modules.module.ts +++ b/packages/twenty-server/src/modules/modules.module.ts @@ -3,6 +3,7 @@ import { Module } from '@nestjs/common'; import { CalendarModule } from 'src/modules/calendar/calendar.module'; import { ConnectedAccountModule } from 'src/modules/connected-account/connected-account.module'; import { FavoriteFolderModule } from 'src/modules/favorite-folder/favorite-folder.module'; +import { FavoriteModule } from 'src/modules/favorite/favorite.module'; import { MessagingModule } from 'src/modules/messaging/messaging.module'; import { ViewModule } from 'src/modules/view/view.module'; import { WorkflowModule } from 'src/modules/workflow/workflow.module'; @@ -15,6 +16,7 @@ import { WorkflowModule } from 'src/modules/workflow/workflow.module'; ViewModule, WorkflowModule, FavoriteFolderModule, + FavoriteModule, ], providers: [], exports: [],