From d560d2573615628afffdebff3371ce3a1db424cb Mon Sep 17 00:00:00 2001 From: gitstart-twenty <140154534+gitstart-twenty@users.noreply.github.com> Date: Mon, 15 Jul 2024 06:26:10 -0400 Subject: [PATCH] =?UTF-8?q?=E2=98=91=EF=B8=8F=20Refacto=20"Select=20All/Un?= =?UTF-8?q?select=20all"=20on=20indexes=20(#5320)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### Description - Refacto "Select All/Unselect all" on indexes - Add sequential mass deletion from front end (limited to 10k records) - Fixed coverage with new unit tests on new useFetchAllRecordIds hook and other utils ### Refs Closes #4397 Closes #5169 ### Demo https://github.com/twentyhq/twenty/assets/26528466/2658ad2c-827e-4670-b42b-3092e268ff32 --------- Co-authored-by: gitstart-twenty Co-authored-by: v1b3m Co-authored-by: Toledodev Co-authored-by: Félix Malfait Co-authored-by: Lucas Bordeau --- ...etObjectMetadataItemBySingularName.test.ts | 17 ++ .../isObjectRecordConnection.test.ts | 27 ++ .../constants/DefaultMutationBatchSize.ts | 1 + .../constants/DefaultQueryPageSize.ts | 1 + .../object-record/constants/DeleteMaxCount.ts | 1 + .../graphql/types/RecordGqlConnection.ts | 1 + .../hooks/__mocks__/useFetchAllRecordIds.ts | 81 ++++++ .../__tests__/useDeleteManyRecords.test.tsx | 6 +- .../__tests__/useFetchAllRecordIds.test.tsx | 107 ++++++++ .../__tests__/useFindManyRecords.test.tsx | 2 +- .../hooks/useDeleteManyRecords.ts | 93 ++++--- .../hooks/useFetchAllRecordIds.ts | 78 ++++++ .../useFetchMoreRecordsWithPagination.ts | 229 +++++++++++++++++ .../object-record/hooks/useFindManyRecords.ts | 243 ++++-------------- .../useHandleFindManyRecordsCompleted.ts | 53 ++++ .../hooks/useHandleFindManyRecordsError.ts | 34 +++ .../hooks/useLazyFindManyRecords.ts | 115 +++++++++ .../hooks/useRecordActionBar.tsx | 118 ++++----- .../RecordIndexTableContainerEffect.tsx | 21 ++ .../options/hooks/useDeleteTableData.ts | 76 ++++++ .../options/hooks/useExportTableData.ts | 122 ++------- .../options/hooks/useTableData.ts | 204 +++++++++++++++ .../states/recordDeleteProgressState.ts | 0 .../components/RecordTableActionBar.tsx | 28 +- .../components/RecordTableInternalEffect.tsx | 6 +- .../hooks/internal/useLeaveTableFocus.ts | 15 +- .../hooks/internal/useRecordTableStates.ts | 2 +- .../internal/useResetTableRowSelection.ts | 11 +- .../internal/useSetAllRowSelectedState.ts | 7 +- .../hooks/internal/useSetRecordTableData.ts | 6 +- .../record-table/hooks/useRecordTable.ts | 2 + .../allRowsSelectedStatusComponentSelector.ts | 6 +- .../types/OnFindManyRecordsCompleted.ts | 9 + .../types/UseFindManyRecordsParams.ts | 14 + .../object-record/utils/getQueryIdentifier.ts | 11 + .../action-bar/components/ActionBar.tsx | 19 +- .../action-bar/types/ActionBarEntry.ts | 11 +- .../context-menu/types/ContextMenuEntry.ts | 4 +- .../src/testing/mock-data/metadata.ts | 1 + .../src/testing/mock-data/people.ts | 4 +- 40 files changed, 1351 insertions(+), 435 deletions(-) create mode 100644 packages/twenty-front/src/modules/object-metadata/utils/__tests__/getObjectMetadataItemBySingularName.test.ts create mode 100644 packages/twenty-front/src/modules/object-record/cache/utils/__tests__/isObjectRecordConnection.test.ts create mode 100644 packages/twenty-front/src/modules/object-record/constants/DefaultMutationBatchSize.ts create mode 100644 packages/twenty-front/src/modules/object-record/constants/DefaultQueryPageSize.ts create mode 100644 packages/twenty-front/src/modules/object-record/constants/DeleteMaxCount.ts create mode 100644 packages/twenty-front/src/modules/object-record/hooks/__mocks__/useFetchAllRecordIds.ts create mode 100644 packages/twenty-front/src/modules/object-record/hooks/__tests__/useFetchAllRecordIds.test.tsx create mode 100644 packages/twenty-front/src/modules/object-record/hooks/useFetchAllRecordIds.ts create mode 100644 packages/twenty-front/src/modules/object-record/hooks/useFetchMoreRecordsWithPagination.ts create mode 100644 packages/twenty-front/src/modules/object-record/hooks/useHandleFindManyRecordsCompleted.ts create mode 100644 packages/twenty-front/src/modules/object-record/hooks/useHandleFindManyRecordsError.ts create mode 100644 packages/twenty-front/src/modules/object-record/hooks/useLazyFindManyRecords.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-index/options/hooks/useDeleteTableData.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-index/options/hooks/useTableData.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-index/options/states/recordDeleteProgressState.ts create mode 100644 packages/twenty-front/src/modules/object-record/types/OnFindManyRecordsCompleted.ts create mode 100644 packages/twenty-front/src/modules/object-record/types/UseFindManyRecordsParams.ts create mode 100644 packages/twenty-front/src/modules/object-record/utils/getQueryIdentifier.ts diff --git a/packages/twenty-front/src/modules/object-metadata/utils/__tests__/getObjectMetadataItemBySingularName.test.ts b/packages/twenty-front/src/modules/object-metadata/utils/__tests__/getObjectMetadataItemBySingularName.test.ts new file mode 100644 index 000000000000..07e88a26f066 --- /dev/null +++ b/packages/twenty-front/src/modules/object-metadata/utils/__tests__/getObjectMetadataItemBySingularName.test.ts @@ -0,0 +1,17 @@ +import { getObjectMetadataItemByNameSingular } from '@/object-metadata/utils/getObjectMetadataItemBySingularName'; +import { getObjectMetadataItemsMock } from '@/object-metadata/utils/getObjectMetadataItemsMock'; + +const mockObjectMetadataItems = getObjectMetadataItemsMock(); + +describe('getObjectMetadataItemBySingularName', () => { + it('should work as expected', () => { + const firstObjectMetadataItem = mockObjectMetadataItems[0]; + + const foundObjectMetadataItem = getObjectMetadataItemByNameSingular({ + objectMetadataItems: mockObjectMetadataItems, + objectNameSingular: firstObjectMetadataItem.nameSingular, + }); + + expect(foundObjectMetadataItem.id).toEqual(firstObjectMetadataItem.id); + }); +}); diff --git a/packages/twenty-front/src/modules/object-record/cache/utils/__tests__/isObjectRecordConnection.test.ts b/packages/twenty-front/src/modules/object-record/cache/utils/__tests__/isObjectRecordConnection.test.ts new file mode 100644 index 000000000000..bbe8b38db7bb --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/cache/utils/__tests__/isObjectRecordConnection.test.ts @@ -0,0 +1,27 @@ +import { peopleQueryResult } from '~/testing/mock-data/people'; + +import { isObjectRecordConnection } from '@/object-record/cache/utils/isObjectRecordConnection'; + +describe('isObjectRecordConnection', () => { + it('should work with query result', () => { + const validQueryResult = peopleQueryResult.people; + + const isValidQueryResult = isObjectRecordConnection( + 'person', + validQueryResult, + ); + + expect(isValidQueryResult).toEqual(true); + }); + + it('should fail with invalid result', () => { + const invalidResult = { test: 123 }; + + const isValidQueryResult = isObjectRecordConnection( + 'person', + invalidResult, + ); + + expect(isValidQueryResult).toEqual(false); + }); +}); diff --git a/packages/twenty-front/src/modules/object-record/constants/DefaultMutationBatchSize.ts b/packages/twenty-front/src/modules/object-record/constants/DefaultMutationBatchSize.ts new file mode 100644 index 000000000000..6864c5e05e4b --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/constants/DefaultMutationBatchSize.ts @@ -0,0 +1 @@ +export const DEFAULT_MUTATION_BATCH_SIZE = 30; diff --git a/packages/twenty-front/src/modules/object-record/constants/DefaultQueryPageSize.ts b/packages/twenty-front/src/modules/object-record/constants/DefaultQueryPageSize.ts new file mode 100644 index 000000000000..cb8e9f295d4d --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/constants/DefaultQueryPageSize.ts @@ -0,0 +1 @@ +export const DEFAULT_QUERY_PAGE_SIZE = 30; diff --git a/packages/twenty-front/src/modules/object-record/constants/DeleteMaxCount.ts b/packages/twenty-front/src/modules/object-record/constants/DeleteMaxCount.ts new file mode 100644 index 000000000000..0f1b3a7c3bdf --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/constants/DeleteMaxCount.ts @@ -0,0 +1 @@ +export const DELETE_MAX_COUNT = 10000; diff --git a/packages/twenty-front/src/modules/object-record/graphql/types/RecordGqlConnection.ts b/packages/twenty-front/src/modules/object-record/graphql/types/RecordGqlConnection.ts index 543a55b2fc48..df64c8e35fe2 100644 --- a/packages/twenty-front/src/modules/object-record/graphql/types/RecordGqlConnection.ts +++ b/packages/twenty-front/src/modules/object-record/graphql/types/RecordGqlConnection.ts @@ -6,6 +6,7 @@ export type RecordGqlConnection = { __typename?: string; edges: RecordGqlEdge[]; pageInfo: { + __typename?: string; hasNextPage?: boolean; hasPreviousPage?: boolean; startCursor?: Nullable; diff --git a/packages/twenty-front/src/modules/object-record/hooks/__mocks__/useFetchAllRecordIds.ts b/packages/twenty-front/src/modules/object-record/hooks/__mocks__/useFetchAllRecordIds.ts new file mode 100644 index 000000000000..8b25676b6a20 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/hooks/__mocks__/useFetchAllRecordIds.ts @@ -0,0 +1,81 @@ +import { RecordGqlConnection } from '@/object-record/graphql/types/RecordGqlConnection'; +import { gql } from '@apollo/client'; + +import { peopleQueryResult } from '~/testing/mock-data/people'; + + +export const query = gql` + query FindManyPeople($filter: PersonFilterInput, $orderBy: [PersonOrderByInput], $lastCursor: String, $limit: Int) { + people(filter: $filter, orderBy: $orderBy, first: $limit, after: $lastCursor){ + edges { + node { + __typename + id + } + cursor + } + pageInfo { + hasNextPage + startCursor + endCursor + } + totalCount + } + } +`; + +export const mockPageSize = 2; + +export const peopleMockWithIdsOnly: RecordGqlConnection = { ...peopleQueryResult.people,edges: peopleQueryResult.people.edges.map((edge) => ({ ...edge, node: { __typename: 'Person', id: edge.node.id } })) }; + +export const firstRequestLastCursor = peopleMockWithIdsOnly.edges[mockPageSize].cursor; +export const secondRequestLastCursor = peopleMockWithIdsOnly.edges[mockPageSize * 2].cursor; +export const thirdRequestLastCursor = peopleMockWithIdsOnly.edges[mockPageSize * 3].cursor; + +export const variablesFirstRequest = { + filter: undefined, + limit: undefined, + orderBy: undefined +}; + +export const variablesSecondRequest = { + filter: undefined, + limit: undefined, + orderBy: undefined, + lastCursor: firstRequestLastCursor +}; + +export const variablesThirdRequest = { + filter: undefined, + limit: undefined, + orderBy: undefined, + lastCursor: secondRequestLastCursor +} + +const paginateRequestResponse = (response: RecordGqlConnection, start: number, end: number, hasNextPage: boolean, totalCount: number) => { + return { + ...response, + edges: [ + ...response.edges.slice(start, end) + ], + pageInfo: { + ...response.pageInfo, + startCursor: response.edges[start].cursor, + endCursor: response.edges[end].cursor, + hasNextPage, + } satisfies RecordGqlConnection['pageInfo'], + totalCount, + } +} + +export const responseFirstRequest = { + people: paginateRequestResponse(peopleMockWithIdsOnly, 0, mockPageSize, true, 6), +}; + +export const responseSecondRequest = { + people: paginateRequestResponse(peopleMockWithIdsOnly, mockPageSize, mockPageSize * 2, true, 6), +}; + +export const responseThirdRequest = { + people: paginateRequestResponse(peopleMockWithIdsOnly, mockPageSize * 2, mockPageSize * 3, false, 6), +}; diff --git a/packages/twenty-front/src/modules/object-record/hooks/__tests__/useDeleteManyRecords.test.tsx b/packages/twenty-front/src/modules/object-record/hooks/__tests__/useDeleteManyRecords.test.tsx index 548c1f0d6aa5..1c7dd33d33e6 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/__tests__/useDeleteManyRecords.test.tsx +++ b/packages/twenty-front/src/modules/object-record/hooks/__tests__/useDeleteManyRecords.test.tsx @@ -1,6 +1,6 @@ -import { ReactNode } from 'react'; import { MockedProvider } from '@apollo/client/testing'; import { act, renderHook } from '@testing-library/react'; +import { ReactNode } from 'react'; import { RecoilRoot } from 'recoil'; import { @@ -23,7 +23,7 @@ const mocks = [ }, result: jest.fn(() => ({ data: { - deletePeople: responseData, + deletePeople: [responseData], }, })), }, @@ -49,7 +49,7 @@ describe('useDeleteManyRecords', () => { await act(async () => { const res = await result.current.deleteManyRecords(people); expect(res).toBeDefined(); - expect(res).toHaveProperty('id'); + expect(res[0]).toHaveProperty('id'); }); expect(mocks[0].result).toHaveBeenCalled(); diff --git a/packages/twenty-front/src/modules/object-record/hooks/__tests__/useFetchAllRecordIds.test.tsx b/packages/twenty-front/src/modules/object-record/hooks/__tests__/useFetchAllRecordIds.test.tsx new file mode 100644 index 000000000000..fc32df77ebb0 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/hooks/__tests__/useFetchAllRecordIds.test.tsx @@ -0,0 +1,107 @@ +import { MockedProvider } from '@apollo/client/testing'; +import { act, renderHook } from '@testing-library/react'; +import { ReactNode, useEffect } from 'react'; +import { RecoilRoot, useRecoilState } from 'recoil'; + +import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState'; +import { getObjectMetadataItemsMock } from '@/object-metadata/utils/getObjectMetadataItemsMock'; +import { + mockPageSize, + peopleMockWithIdsOnly, + query, + responseFirstRequest, + responseSecondRequest, + responseThirdRequest, + variablesFirstRequest, + variablesSecondRequest, + variablesThirdRequest, +} from '@/object-record/hooks/__mocks__/useFetchAllRecordIds'; +import { useFetchAllRecordIds } from '@/object-record/hooks/useFetchAllRecordIds'; +import { SnackBarManagerScopeInternalContext } from '@/ui/feedback/snack-bar-manager/scopes/scope-internal-context/SnackBarManagerScopeInternalContext'; + +const mocks = [ + { + delay: 100, + request: { + query, + variables: variablesFirstRequest, + }, + result: jest.fn(() => ({ + data: responseFirstRequest, + })), + }, + { + delay: 100, + request: { + query, + variables: variablesSecondRequest, + }, + result: jest.fn(() => ({ + data: responseSecondRequest, + })), + }, + { + delay: 100, + request: { + query, + variables: variablesThirdRequest, + }, + result: jest.fn(() => ({ + data: responseThirdRequest, + })), + }, +]; + +describe('useFetchAllRecordIds', () => { + it('fetches all record ids with fetch more synchronous loop', async () => { + const Wrapper = ({ children }: { children: ReactNode }) => ( + + + + {children} + + + + ); + + const { result } = renderHook( + () => { + const [, setObjectMetadataItems] = useRecoilState( + objectMetadataItemsState, + ); + + useEffect(() => { + setObjectMetadataItems(getObjectMetadataItemsMock()); + }, [setObjectMetadataItems]); + + return useFetchAllRecordIds({ + objectNameSingular: 'person', + pageSize: mockPageSize, + }); + }, + { + wrapper: Wrapper, + }, + ); + + const { fetchAllRecordIds } = result.current; + + let recordIds: string[] = []; + + await act(async () => { + recordIds = await fetchAllRecordIds(); + }); + + expect(mocks[0].result).toHaveBeenCalled(); + expect(mocks[1].result).toHaveBeenCalled(); + expect(mocks[2].result).toHaveBeenCalled(); + + expect(recordIds).toEqual( + peopleMockWithIdsOnly.edges.map((edge) => edge.node.id).slice(0, 6), + ); + }); +}); diff --git a/packages/twenty-front/src/modules/object-record/hooks/__tests__/useFindManyRecords.test.tsx b/packages/twenty-front/src/modules/object-record/hooks/__tests__/useFindManyRecords.test.tsx index 57e18be23cba..6b7d71c18b5d 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/__tests__/useFindManyRecords.test.tsx +++ b/packages/twenty-front/src/modules/object-record/hooks/__tests__/useFindManyRecords.test.tsx @@ -1,6 +1,6 @@ -import { ReactNode } from 'react'; import { MockedProvider } from '@apollo/client/testing'; import { renderHook } from '@testing-library/react'; +import { ReactNode } from 'react'; import { RecoilRoot, useSetRecoilState } from 'recoil'; import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState'; 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 f189d516da1b..a9af8e812318 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/useDeleteManyRecords.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/useDeleteManyRecords.ts @@ -4,9 +4,11 @@ import { triggerDeleteRecordsOptimisticEffect } from '@/apollo/optimistic-effect import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems'; import { useGetRecordFromCache } from '@/object-record/cache/hooks/useGetRecordFromCache'; +import { DEFAULT_MUTATION_BATCH_SIZE } from '@/object-record/constants/DefaultMutationBatchSize'; import { useDeleteManyRecordsMutation } from '@/object-record/hooks/useDeleteManyRecordsMutation'; import { getDeleteManyRecordsMutationResponseField } from '@/object-record/utils/getDeleteManyRecordsMutationResponseField'; import { isDefined } from '~/utils/isDefined'; +import { sleep } from '~/utils/sleep'; import { capitalize } from '~/utils/string/capitalize'; type useDeleteOneRecordProps = { @@ -16,6 +18,7 @@ type useDeleteOneRecordProps = { type DeleteManyRecordsOptions = { skipOptimisticEffect?: boolean; + delayInMsBetweenRequests?: number; }; export const useDeleteManyRecords = ({ @@ -45,40 +48,62 @@ export const useDeleteManyRecords = ({ idsToDelete: string[], options?: DeleteManyRecordsOptions, ) => { - const deletedRecords = await apolloClient.mutate({ - mutation: deleteManyRecordsMutation, - variables: { - filter: { id: { in: idsToDelete } }, - }, - optimisticResponse: options?.skipOptimisticEffect - ? undefined - : { - [mutationResponseField]: idsToDelete.map((idToDelete) => ({ - __typename: capitalize(objectNameSingular), - id: idToDelete, - })), - }, - update: options?.skipOptimisticEffect - ? undefined - : (cache, { data }) => { - const records = data?.[mutationResponseField]; - - if (!records?.length) return; - - const cachedRecords = records - .map((record) => getRecordFromCache(record.id, cache)) - .filter(isDefined); - - triggerDeleteRecordsOptimisticEffect({ - cache, - objectMetadataItem, - recordsToDelete: cachedRecords, - objectMetadataItems, - }); - }, - }); - - return deletedRecords.data?.[mutationResponseField] ?? null; + const numberOfBatches = Math.ceil( + idsToDelete.length / DEFAULT_MUTATION_BATCH_SIZE, + ); + + const deletedRecords = []; + + for (let batchIndex = 0; batchIndex < numberOfBatches; batchIndex++) { + const batchIds = idsToDelete.slice( + batchIndex * DEFAULT_MUTATION_BATCH_SIZE, + (batchIndex + 1) * DEFAULT_MUTATION_BATCH_SIZE, + ); + + const deletedRecordsResponse = await apolloClient.mutate({ + mutation: deleteManyRecordsMutation, + variables: { + filter: { id: { in: batchIds } }, + }, + optimisticResponse: options?.skipOptimisticEffect + ? undefined + : { + [mutationResponseField]: batchIds.map((idToDelete) => ({ + __typename: capitalize(objectNameSingular), + id: idToDelete, + })), + }, + update: options?.skipOptimisticEffect + ? undefined + : (cache, { data }) => { + const records = data?.[mutationResponseField]; + + if (!records?.length) return; + + const cachedRecords = records + .map((record) => getRecordFromCache(record.id, cache)) + .filter(isDefined); + + triggerDeleteRecordsOptimisticEffect({ + cache, + objectMetadataItem, + recordsToDelete: cachedRecords, + objectMetadataItems, + }); + }, + }); + + const deletedRecordsForThisBatch = + deletedRecordsResponse.data?.[mutationResponseField] ?? []; + + deletedRecords.push(...deletedRecordsForThisBatch); + + if (isDefined(options?.delayInMsBetweenRequests)) { + await sleep(options.delayInMsBetweenRequests); + } + } + + return deletedRecords; }; return { deleteManyRecords }; diff --git a/packages/twenty-front/src/modules/object-record/hooks/useFetchAllRecordIds.ts b/packages/twenty-front/src/modules/object-record/hooks/useFetchAllRecordIds.ts new file mode 100644 index 000000000000..0e70a8ed7b68 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/hooks/useFetchAllRecordIds.ts @@ -0,0 +1,78 @@ +import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; +import { DEFAULT_QUERY_PAGE_SIZE } from '@/object-record/constants/DefaultQueryPageSize'; +import { UseFindManyRecordsParams } from '@/object-record/hooks/useFetchMoreRecordsWithPagination'; +import { useLazyFindManyRecords } from '@/object-record/hooks/useLazyFindManyRecords'; +import { useCallback } from 'react'; +import { isDefined } from '~/utils/isDefined'; + +type UseLazyFetchAllRecordIdsParams = Omit< + UseFindManyRecordsParams, + 'skip' +> & { pageSize?: number }; + +export const useFetchAllRecordIds = ({ + objectNameSingular, + filter, + orderBy, + pageSize = DEFAULT_QUERY_PAGE_SIZE, +}: UseLazyFetchAllRecordIdsParams) => { + const { fetchMore, findManyRecords } = useLazyFindManyRecords({ + objectNameSingular, + filter, + orderBy, + recordGqlFields: { id: true }, + }); + + const { objectMetadataItem } = useObjectMetadataItem({ + objectNameSingular, + }); + + const fetchAllRecordIds = useCallback(async () => { + if (!isDefined(findManyRecords)) { + return []; + } + + const findManyRecordsDataResult = await findManyRecords(); + + const firstQueryResult = + findManyRecordsDataResult?.data?.[objectMetadataItem.namePlural]; + + const totalCount = firstQueryResult?.totalCount ?? 1; + + const recordsCount = firstQueryResult?.edges.length ?? 0; + + const recordIdSet = new Set( + firstQueryResult?.edges?.map((edge) => edge.node.id) ?? [], + ); + + const remainingCount = totalCount - recordsCount; + + const remainingPages = Math.ceil(remainingCount / pageSize); + + let lastCursor = firstQueryResult?.pageInfo.endCursor ?? ''; + + for (let i = 0; i < remainingPages; i++) { + const rawResult = await fetchMore?.({ + variables: { + lastCursor: lastCursor, + }, + }); + + const fetchMoreResult = rawResult?.data?.[objectMetadataItem.namePlural]; + + for (const edge of fetchMoreResult.edges) { + recordIdSet.add(edge.node.id); + } + + lastCursor = fetchMoreResult.pageInfo.endCursor ?? ''; + } + + const recordIds = Array.from(recordIdSet); + + return recordIds; + }, [fetchMore, findManyRecords, objectMetadataItem.namePlural, pageSize]); + + return { + fetchAllRecordIds, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/hooks/useFetchMoreRecordsWithPagination.ts b/packages/twenty-front/src/modules/object-record/hooks/useFetchMoreRecordsWithPagination.ts new file mode 100644 index 000000000000..2cc6be1c0b87 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/hooks/useFetchMoreRecordsWithPagination.ts @@ -0,0 +1,229 @@ +import { + ApolloError, + ApolloQueryResult, + FetchMoreQueryOptions, + OperationVariables, + WatchQueryFetchPolicy, +} from '@apollo/client'; +import { isNonEmptyArray } from '@apollo/client/utilities'; +import { isNonEmptyString } from '@sniptt/guards'; +import { useMemo } from 'react'; +import { useRecoilCallback, useRecoilState, useSetRecoilState } from 'recoil'; + +import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { ObjectMetadataItemIdentifier } from '@/object-metadata/types/ObjectMetadataItemIdentifier'; +import { isAggregationEnabled } from '@/object-metadata/utils/isAggregationEnabled'; +import { getRecordsFromRecordConnection } from '@/object-record/cache/utils/getRecordsFromRecordConnection'; +import { RecordGqlEdge } from '@/object-record/graphql/types/RecordGqlEdge'; +import { RecordGqlOperationFindManyResult } from '@/object-record/graphql/types/RecordGqlOperationFindManyResult'; +import { RecordGqlOperationGqlRecordFields } from '@/object-record/graphql/types/RecordGqlOperationGqlRecordFields'; +import { RecordGqlOperationVariables } from '@/object-record/graphql/types/RecordGqlOperationVariables'; +import { useHandleFindManyRecordsError } from '@/object-record/hooks/useHandleFindManyRecordsError'; +import { ObjectRecord } from '@/object-record/types/ObjectRecord'; +import { OnFindManyRecordsCompleted } from '@/object-record/types/OnFindManyRecordsCompleted'; +import { filterUniqueRecordEdgesByCursor } from '@/object-record/utils/filterUniqueRecordEdgesByCursor'; +import { getQueryIdentifier } from '@/object-record/utils/getQueryIdentifier'; +import { isDefined } from '~/utils/isDefined'; +import { capitalize } from '~/utils/string/capitalize'; + +import { cursorFamilyState } from '../states/cursorFamilyState'; +import { hasNextPageFamilyState } from '../states/hasNextPageFamilyState'; +import { isFetchingMoreRecordsFamilyState } from '../states/isFetchingMoreRecordsFamilyState'; + +export type UseFindManyRecordsParams = ObjectMetadataItemIdentifier & + RecordGqlOperationVariables & { + onCompleted?: OnFindManyRecordsCompleted; + skip?: boolean; + recordGqlFields?: RecordGqlOperationGqlRecordFields; + fetchPolicy?: WatchQueryFetchPolicy; + }; + +type UseFindManyRecordsStateParams< + T, + TData = RecordGqlOperationFindManyResult, +> = Omit< + UseFindManyRecordsParams, + 'skip' | 'recordGqlFields' | 'fetchPolicy' +> & { + data: RecordGqlOperationFindManyResult | undefined; + error: ApolloError | undefined; + fetchMore< + TFetchData = TData, + TFetchVars extends OperationVariables = OperationVariables, + >( + fetchMoreOptions: FetchMoreQueryOptions & { + updateQuery?: ( + previousQueryResult: TData, + options: { + fetchMoreResult: TFetchData; + variables: TFetchVars; + }, + ) => TData; + }, + ): Promise>; + objectMetadataItem: ObjectMetadataItem; +}; + +export const useFetchMoreRecordsWithPagination = < + T extends ObjectRecord = ObjectRecord, +>({ + objectNameSingular, + filter, + orderBy, + limit, + data, + error, + fetchMore, + objectMetadataItem, + onCompleted, +}: UseFindManyRecordsStateParams) => { + const queryIdentifier = getQueryIdentifier({ + objectNameSingular, + filter, + limit, + orderBy, + }); + + const [hasNextPage] = useRecoilState(hasNextPageFamilyState(queryIdentifier)); + + const setIsFetchingMoreObjects = useSetRecoilState( + isFetchingMoreRecordsFamilyState(queryIdentifier), + ); + + const { handleFindManyRecordsError } = useHandleFindManyRecordsError({ + objectMetadataItem, + }); + + // TODO: put this into a util inspired from https://github.com/apollographql/apollo-client/blob/master/src/utilities/policies/pagination.ts + // This function is equivalent to merge function + read function in field policy + const fetchMoreRecords = useRecoilCallback( + ({ snapshot, set }) => + async () => { + const hasNextPageLocal = snapshot + .getLoadable(hasNextPageFamilyState(queryIdentifier)) + .getValue(); + + const lastCursorLocal = snapshot + .getLoadable(cursorFamilyState(queryIdentifier)) + .getValue(); + + // Remote objects does not support hasNextPage. We cannot rely on it to fetch more records. + if ( + hasNextPageLocal || + (!isAggregationEnabled(objectMetadataItem) && !error) + ) { + setIsFetchingMoreObjects(true); + + try { + const { data: fetchMoreDataResult } = await fetchMore({ + variables: { + filter, + orderBy, + lastCursor: isNonEmptyString(lastCursorLocal) + ? lastCursorLocal + : undefined, + }, + updateQuery: (prev, { fetchMoreResult }) => { + const previousEdges = + prev?.[objectMetadataItem.namePlural]?.edges; + const nextEdges = + fetchMoreResult?.[objectMetadataItem.namePlural]?.edges; + + let newEdges: RecordGqlEdge[] = previousEdges ?? []; + + if (isNonEmptyArray(nextEdges)) { + newEdges = filterUniqueRecordEdgesByCursor([ + ...newEdges, + ...(fetchMoreResult?.[objectMetadataItem.namePlural] + ?.edges ?? []), + ]); + } + + const pageInfo = + fetchMoreResult?.[objectMetadataItem.namePlural]?.pageInfo; + + if (isDefined(data?.[objectMetadataItem.namePlural])) { + set( + cursorFamilyState(queryIdentifier), + pageInfo.endCursor ?? '', + ); + set( + hasNextPageFamilyState(queryIdentifier), + pageInfo.hasNextPage ?? false, + ); + } + + const records = getRecordsFromRecordConnection({ + recordConnection: { + edges: newEdges, + pageInfo, + }, + }) as T[]; + + onCompleted?.(records, { + pageInfo, + totalCount: + fetchMoreResult?.[objectMetadataItem.namePlural] + ?.totalCount, + }); + + return Object.assign({}, prev, { + [objectMetadataItem.namePlural]: { + __typename: `${capitalize( + objectMetadataItem.nameSingular, + )}Connection`, + edges: newEdges, + pageInfo: + fetchMoreResult?.[objectMetadataItem.namePlural].pageInfo, + totalCount: + fetchMoreResult?.[objectMetadataItem.namePlural] + .totalCount, + }, + } as RecordGqlOperationFindManyResult); + }, + }); + + return { + data: fetchMoreDataResult?.[objectMetadataItem.namePlural], + }; + } catch (error) { + handleFindManyRecordsError(error as ApolloError); + } finally { + setIsFetchingMoreObjects(false); + } + } + }, + [ + objectMetadataItem, + error, + setIsFetchingMoreObjects, + fetchMore, + filter, + orderBy, + data, + onCompleted, + handleFindManyRecordsError, + queryIdentifier, + ], + ); + + const totalCount = data?.[objectMetadataItem.namePlural]?.totalCount; + + const records = useMemo( + () => + data?.[objectMetadataItem.namePlural] + ? getRecordsFromRecordConnection({ + recordConnection: data?.[objectMetadataItem.namePlural], + }) + : ([] as T[]), + + [data, objectMetadataItem.namePlural], + ); + + return { + fetchMoreRecords, + totalCount, + records, + hasNextPage, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/hooks/useFindManyRecords.ts b/packages/twenty-front/src/modules/object-record/hooks/useFindManyRecords.ts index befc85849d7e..00629c91a6b6 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/useFindManyRecords.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/useFindManyRecords.ts @@ -1,85 +1,66 @@ -import { useCallback, useMemo } from 'react'; import { useQuery, WatchQueryFetchPolicy } from '@apollo/client'; -import { isNonEmptyArray } from '@apollo/client/utilities'; -import { isNonEmptyString } from '@sniptt/guards'; -import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil'; +import { useRecoilValue } from 'recoil'; import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState'; import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; import { ObjectMetadataItemIdentifier } from '@/object-metadata/types/ObjectMetadataItemIdentifier'; -import { isAggregationEnabled } from '@/object-metadata/utils/isAggregationEnabled'; -import { getRecordsFromRecordConnection } from '@/object-record/cache/utils/getRecordsFromRecordConnection'; -import { RecordGqlConnection } from '@/object-record/graphql/types/RecordGqlConnection'; -import { RecordGqlEdge } from '@/object-record/graphql/types/RecordGqlEdge'; import { RecordGqlOperationFindManyResult } from '@/object-record/graphql/types/RecordGqlOperationFindManyResult'; import { RecordGqlOperationGqlRecordFields } from '@/object-record/graphql/types/RecordGqlOperationGqlRecordFields'; import { RecordGqlOperationVariables } from '@/object-record/graphql/types/RecordGqlOperationVariables'; +import { useFetchMoreRecordsWithPagination } from '@/object-record/hooks/useFetchMoreRecordsWithPagination'; import { useFindManyRecordsQuery } from '@/object-record/hooks/useFindManyRecordsQuery'; +import { useHandleFindManyRecordsCompleted } from '@/object-record/hooks/useHandleFindManyRecordsCompleted'; +import { useHandleFindManyRecordsError } from '@/object-record/hooks/useHandleFindManyRecordsError'; import { ObjectRecord } from '@/object-record/types/ObjectRecord'; -import { filterUniqueRecordEdgesByCursor } from '@/object-record/utils/filterUniqueRecordEdgesByCursor'; -import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar'; -import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; -import { isDefined } from '~/utils/isDefined'; -import { logError } from '~/utils/logError'; -import { capitalize } from '~/utils/string/capitalize'; +import { OnFindManyRecordsCompleted } from '@/object-record/types/OnFindManyRecordsCompleted'; +import { getQueryIdentifier } from '@/object-record/utils/getQueryIdentifier'; -import { cursorFamilyState } from '../states/cursorFamilyState'; -import { hasNextPageFamilyState } from '../states/hasNextPageFamilyState'; -import { isFetchingMoreRecordsFamilyState } from '../states/isFetchingMoreRecordsFamilyState'; +export type UseFindManyRecordsParams = ObjectMetadataItemIdentifier & + RecordGqlOperationVariables & { + onError?: (error?: Error) => void; + onCompleted?: OnFindManyRecordsCompleted; + skip?: boolean; + recordGqlFields?: RecordGqlOperationGqlRecordFields; + fetchPolicy?: WatchQueryFetchPolicy; + }; export const useFindManyRecords = ({ objectNameSingular, filter, orderBy, limit, - onCompleted, - onError, skip, recordGqlFields, fetchPolicy, -}: ObjectMetadataItemIdentifier & - RecordGqlOperationVariables & { - onCompleted?: ( - records: T[], - options?: { - pageInfo?: RecordGqlConnection['pageInfo']; - totalCount?: number; - }, - ) => void; - onError?: (error?: Error) => void; - skip?: boolean; - recordGqlFields?: RecordGqlOperationGqlRecordFields; - fetchPolicy?: WatchQueryFetchPolicy; - }) => { - const findManyQueryStateIdentifier = - objectNameSingular + - JSON.stringify(filter) + - JSON.stringify(orderBy) + - limit; - - const [lastCursor, setLastCursor] = useRecoilState( - cursorFamilyState(findManyQueryStateIdentifier), - ); - - const [hasNextPage, setHasNextPage] = useRecoilState( - hasNextPageFamilyState(findManyQueryStateIdentifier), - ); - - const setIsFetchingMoreObjects = useSetRecoilState( - isFetchingMoreRecordsFamilyState(findManyQueryStateIdentifier), - ); - + onError, + onCompleted, +}: UseFindManyRecordsParams) => { + const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState); const { objectMetadataItem } = useObjectMetadataItem({ objectNameSingular, }); - const { findManyRecordsQuery } = useFindManyRecordsQuery({ objectNameSingular, recordGqlFields, }); - const { enqueueSnackBar } = useSnackBar(); - const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState); + const { handleFindManyRecordsError } = useHandleFindManyRecordsError({ + objectMetadataItem, + handleError: onError, + }); + + const queryIdentifier = getQueryIdentifier({ + objectNameSingular, + filter, + orderBy, + limit, + }); + + const { handleFindManyRecordsCompleted } = useHandleFindManyRecordsCompleted({ + objectMetadataItem, + queryIdentifier, + onCompleted, + }); const { data, loading, error, fetchMore } = useQuery(findManyRecordsQuery, { @@ -90,147 +71,21 @@ export const useFindManyRecords = ({ orderBy, }, fetchPolicy: fetchPolicy, - onCompleted: (data) => { - if (!isDefined(data)) { - onCompleted?.([]); - } - - const pageInfo = data?.[objectMetadataItem.namePlural]?.pageInfo; - - const records = getRecordsFromRecordConnection({ - recordConnection: data?.[objectMetadataItem.namePlural], - }) as T[]; - - onCompleted?.(records, { - pageInfo, - totalCount: data?.[objectMetadataItem.namePlural]?.totalCount, - }); - - if (isDefined(data?.[objectMetadataItem.namePlural])) { - setLastCursor(pageInfo.endCursor ?? ''); - setHasNextPage(pageInfo.hasNextPage ?? false); - } - }, - onError: (error) => { - logError( - `useFindManyRecords for "${objectMetadataItem.namePlural}" error : ` + - error, - ); - enqueueSnackBar( - `Error during useFindManyRecords for "${objectMetadataItem.namePlural}", ${error.message}`, - { - variant: SnackBarVariant.Error, - }, - ); - onError?.(error); - }, + onCompleted: handleFindManyRecordsCompleted, + onError: handleFindManyRecordsError, }); - const fetchMoreRecords = useCallback(async () => { - // Remote objects does not support hasNextPage. We cannot rely on it to fetch more records. - if (hasNextPage || (!isAggregationEnabled(objectMetadataItem) && !error)) { - setIsFetchingMoreObjects(true); - - try { - await fetchMore({ - variables: { - filter, - orderBy, - lastCursor: isNonEmptyString(lastCursor) ? lastCursor : undefined, - }, - updateQuery: (prev, { fetchMoreResult }) => { - const previousEdges = prev?.[objectMetadataItem.namePlural]?.edges; - const nextEdges = - fetchMoreResult?.[objectMetadataItem.namePlural]?.edges; - - let newEdges: RecordGqlEdge[] = previousEdges ?? []; - - if (isNonEmptyArray(nextEdges)) { - newEdges = filterUniqueRecordEdgesByCursor([ - ...newEdges, - ...(fetchMoreResult?.[objectMetadataItem.namePlural]?.edges ?? - []), - ]); - } - - const pageInfo = - fetchMoreResult?.[objectMetadataItem.namePlural]?.pageInfo; - - if (isDefined(data?.[objectMetadataItem.namePlural])) { - setLastCursor(pageInfo.endCursor ?? ''); - setHasNextPage(pageInfo.hasNextPage ?? false); - } - - const records = getRecordsFromRecordConnection({ - recordConnection: { - edges: newEdges, - pageInfo, - }, - }) as T[]; - - onCompleted?.(records, { - pageInfo, - totalCount: - fetchMoreResult?.[objectMetadataItem.namePlural]?.totalCount, - }); - - return Object.assign({}, prev, { - [objectMetadataItem.namePlural]: { - __typename: `${capitalize( - objectMetadataItem.nameSingular, - )}Connection`, - edges: newEdges, - pageInfo: - fetchMoreResult?.[objectMetadataItem.namePlural].pageInfo, - totalCount: - fetchMoreResult?.[objectMetadataItem.namePlural].totalCount, - }, - } as RecordGqlOperationFindManyResult); - }, - }); - } catch (error) { - logError( - `fetchMoreObjects for "${objectMetadataItem.namePlural}" error : ` + - error, - ); - enqueueSnackBar( - `Error during fetchMoreObjects for "${objectMetadataItem.namePlural}", ${error}`, - { - variant: SnackBarVariant.Error, - }, - ); - } finally { - setIsFetchingMoreObjects(false); - } - } - }, [ - hasNextPage, - objectMetadataItem, - error, - setIsFetchingMoreObjects, - fetchMore, - filter, - orderBy, - lastCursor, - data, - onCompleted, - setLastCursor, - setHasNextPage, - enqueueSnackBar, - ]); - - const totalCount = data?.[objectMetadataItem.namePlural]?.totalCount; - - const records = useMemo( - () => - data?.[objectMetadataItem.namePlural] - ? getRecordsFromRecordConnection({ - recordConnection: data?.[objectMetadataItem.namePlural], - }) - : ([] as T[]), - - [data, objectMetadataItem.namePlural], - ); + const { fetchMoreRecords, totalCount, records, hasNextPage } = + useFetchMoreRecordsWithPagination({ + objectNameSingular, + filter, + orderBy, + limit, + fetchMore, + data, + error, + objectMetadataItem, + }); return { objectMetadataItem, @@ -239,7 +94,7 @@ export const useFindManyRecords = ({ loading, error, fetchMoreRecords, - queryStateIdentifier: findManyQueryStateIdentifier, + queryStateIdentifier: queryIdentifier, hasNextPage, }; }; diff --git a/packages/twenty-front/src/modules/object-record/hooks/useHandleFindManyRecordsCompleted.ts b/packages/twenty-front/src/modules/object-record/hooks/useHandleFindManyRecordsCompleted.ts new file mode 100644 index 000000000000..f0f89bfb2e6f --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/hooks/useHandleFindManyRecordsCompleted.ts @@ -0,0 +1,53 @@ +import { useRecoilState } from 'recoil'; + +import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { getRecordsFromRecordConnection } from '@/object-record/cache/utils/getRecordsFromRecordConnection'; +import { RecordGqlOperationFindManyResult } from '@/object-record/graphql/types/RecordGqlOperationFindManyResult'; +import { cursorFamilyState } from '@/object-record/states/cursorFamilyState'; +import { hasNextPageFamilyState } from '@/object-record/states/hasNextPageFamilyState'; +import { OnFindManyRecordsCompleted } from '@/object-record/types/OnFindManyRecordsCompleted'; +import { isDefined } from '~/utils/isDefined'; + +export const useHandleFindManyRecordsCompleted = ({ + queryIdentifier, + onCompleted, + objectMetadataItem, +}: { + queryIdentifier: string; + objectMetadataItem: ObjectMetadataItem; + onCompleted?: OnFindManyRecordsCompleted; +}) => { + const [, setLastCursor] = useRecoilState(cursorFamilyState(queryIdentifier)); + + const [, setHasNextPage] = useRecoilState( + hasNextPageFamilyState(queryIdentifier), + ); + + const handleFindManyRecordsCompleted = ( + data: RecordGqlOperationFindManyResult, + ) => { + if (!isDefined(data)) { + onCompleted?.([]); + } + + const pageInfo = data?.[objectMetadataItem.namePlural]?.pageInfo; + + const records = getRecordsFromRecordConnection({ + recordConnection: data?.[objectMetadataItem.namePlural], + }) as T[]; + + onCompleted?.(records, { + pageInfo, + totalCount: data?.[objectMetadataItem.namePlural]?.totalCount, + }); + + if (isDefined(data?.[objectMetadataItem.namePlural])) { + setLastCursor(pageInfo.endCursor ?? ''); + setHasNextPage(pageInfo.hasNextPage ?? false); + } + }; + + return { + handleFindManyRecordsCompleted, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/hooks/useHandleFindManyRecordsError.ts b/packages/twenty-front/src/modules/object-record/hooks/useHandleFindManyRecordsError.ts new file mode 100644 index 000000000000..1b5a8cae64ef --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/hooks/useHandleFindManyRecordsError.ts @@ -0,0 +1,34 @@ +import { ApolloError } from '@apollo/client'; + +import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar'; +import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; +import { logError } from '~/utils/logError'; + +export const useHandleFindManyRecordsError = ({ + handleError, + objectMetadataItem, +}: { + objectMetadataItem: ObjectMetadataItem; + handleError?: (error?: Error) => void; +}) => { + const { enqueueSnackBar } = useSnackBar(); + + const handleFindManyRecordsError = (error: ApolloError) => { + logError( + `useFindManyRecords for "${objectMetadataItem.namePlural}" error : ` + + error, + ); + enqueueSnackBar( + `Error during useFindManyRecords for "${objectMetadataItem.namePlural}", ${error.message}`, + { + variant: SnackBarVariant.Error, + }, + ); + handleError?.(error); + }; + + return { + handleFindManyRecordsError, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/hooks/useLazyFindManyRecords.ts b/packages/twenty-front/src/modules/object-record/hooks/useLazyFindManyRecords.ts new file mode 100644 index 000000000000..caf315296d4c --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/hooks/useLazyFindManyRecords.ts @@ -0,0 +1,115 @@ +import { useLazyQuery } from '@apollo/client'; +import { useRecoilCallback } from 'recoil'; + +import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; +import { RecordGqlOperationFindManyResult } from '@/object-record/graphql/types/RecordGqlOperationFindManyResult'; +import { useFetchMoreRecordsWithPagination } from '@/object-record/hooks/useFetchMoreRecordsWithPagination'; +import { UseFindManyRecordsParams } from '@/object-record/hooks/useFindManyRecords'; +import { useFindManyRecordsQuery } from '@/object-record/hooks/useFindManyRecordsQuery'; +import { useHandleFindManyRecordsCompleted } from '@/object-record/hooks/useHandleFindManyRecordsCompleted'; +import { useHandleFindManyRecordsError } from '@/object-record/hooks/useHandleFindManyRecordsError'; +import { cursorFamilyState } from '@/object-record/states/cursorFamilyState'; +import { hasNextPageFamilyState } from '@/object-record/states/hasNextPageFamilyState'; +import { ObjectRecord } from '@/object-record/types/ObjectRecord'; +import { getQueryIdentifier } from '@/object-record/utils/getQueryIdentifier'; + +type UseLazyFindManyRecordsParams = Omit< + UseFindManyRecordsParams, + 'skip' +>; + +export const useLazyFindManyRecords = ({ + objectNameSingular, + filter, + orderBy, + limit, + recordGqlFields, + fetchPolicy, + onCompleted, + onError, +}: UseLazyFindManyRecordsParams) => { + const { objectMetadataItem } = useObjectMetadataItem({ + objectNameSingular, + }); + + const { findManyRecordsQuery } = useFindManyRecordsQuery({ + objectNameSingular, + recordGqlFields, + }); + + const { handleFindManyRecordsError } = useHandleFindManyRecordsError({ + objectMetadataItem, + handleError: onError, + }); + + const queryIdentifier = getQueryIdentifier({ + objectNameSingular, + filter, + orderBy, + limit, + }); + + const { handleFindManyRecordsCompleted } = useHandleFindManyRecordsCompleted({ + objectMetadataItem, + queryIdentifier, + onCompleted, + }); + + const [findManyRecords, { data, loading, error, fetchMore }] = + useLazyQuery(findManyRecordsQuery, { + variables: { + filter, + limit, + orderBy, + }, + fetchPolicy: fetchPolicy, + onCompleted: handleFindManyRecordsCompleted, + onError: handleFindManyRecordsError, + }); + + const { fetchMoreRecords, totalCount, records } = + useFetchMoreRecordsWithPagination({ + objectNameSingular, + filter, + orderBy, + limit, + onCompleted, + fetchMore, + data, + error, + objectMetadataItem, + }); + + const findManyRecordsLazy = useRecoilCallback( + ({ set }) => + async () => { + const result = await findManyRecords(); + + const hasNextPage = + result?.data?.[objectMetadataItem.namePlural]?.pageInfo.hasNextPage ?? + false; + + const lastCursor = + result?.data?.[objectMetadataItem.namePlural]?.pageInfo.endCursor ?? + ''; + + set(hasNextPageFamilyState(queryIdentifier), hasNextPage); + set(cursorFamilyState(queryIdentifier), lastCursor); + + return result; + }, + [queryIdentifier, findManyRecords, objectMetadataItem], + ); + + return { + objectMetadataItem, + records, + totalCount, + loading, + error, + fetchMore, + fetchMoreRecordsWithPagination: fetchMoreRecords, + queryStateIdentifier: queryIdentifier, + findManyRecords: findManyRecordsLazy, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-action-bar/hooks/useRecordActionBar.tsx b/packages/twenty-front/src/modules/object-record/record-action-bar/hooks/useRecordActionBar.tsx index 70e35b0a5955..490467dae757 100644 --- a/packages/twenty-front/src/modules/object-record/record-action-bar/hooks/useRecordActionBar.tsx +++ b/packages/twenty-front/src/modules/object-record/record-action-bar/hooks/useRecordActionBar.tsx @@ -1,6 +1,6 @@ -import { useCallback, useMemo, useState } from 'react'; import { isNonEmptyString } from '@sniptt/guards'; -import { useRecoilCallback, useRecoilValue, useSetRecoilState } from 'recoil'; +import { useCallback, useMemo, useState } from 'react'; +import { useRecoilCallback, useSetRecoilState } from 'recoil'; import { IconClick, IconFileExport, @@ -11,11 +11,11 @@ import { IconTrash, } from 'twenty-ui'; -import { apiConfigState } from '@/client-config/states/apiConfigState'; import { useFavorites } from '@/favorites/hooks/useFavorites'; import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; -import { useDeleteManyRecords } from '@/object-record/hooks/useDeleteManyRecords'; +import { DELETE_MAX_COUNT } from '@/object-record/constants/DeleteMaxCount'; import { useExecuteQuickActionOnOneRecord } from '@/object-record/hooks/useExecuteQuickActionOnOneRecord'; +import { useDeleteTableData } from '@/object-record/record-index/options/hooks/useDeleteTableData'; import { displayedExportProgress, useExportTableData, @@ -32,12 +32,14 @@ type useRecordActionBarProps = { objectMetadataItem: ObjectMetadataItem; selectedRecordIds: string[]; callback?: () => void; + totalNumberOfRecordsSelected?: number; }; export const useRecordActionBar = ({ objectMetadataItem, selectedRecordIds, callback, + totalNumberOfRecordsSelected, }: useRecordActionBarProps) => { const setContextMenuEntries = useSetRecoilState(contextMenuEntriesState); const setActionBarEntriesState = useSetRecoilState(actionBarEntriesState); @@ -46,17 +48,10 @@ export const useRecordActionBar = ({ const { createFavorite, favorites, deleteFavorite } = useFavorites(); - const { deleteManyRecords } = useDeleteManyRecords({ - objectNameSingular: objectMetadataItem.nameSingular, - }); - const { executeQuickActionOnOneRecord } = useExecuteQuickActionOnOneRecord({ objectNameSingular: objectMetadataItem.nameSingular, }); - const apiConfig = useRecoilValue(apiConfigState); - const maxRecords = apiConfig?.mutationMaximumAffectedRecords; - const handleFavoriteButtonClick = useRecoilCallback( ({ snapshot }) => () => { @@ -92,24 +87,17 @@ export const useRecordActionBar = ({ ], ); - const handleDeleteClick = useCallback(async () => { - callback?.(); - selectedRecordIds.forEach((recordId) => { - const foundFavorite = favorites?.find( - (favorite) => favorite.recordId === recordId, - ); - if (foundFavorite !== undefined) { - deleteFavorite(foundFavorite.id); - } - }); - await deleteManyRecords(selectedRecordIds); - }, [ - callback, - deleteManyRecords, - selectedRecordIds, - favorites, - deleteFavorite, - ]); + const baseTableDataParams = { + delayMs: 100, + objectNameSingular: objectMetadataItem.nameSingular, + recordIndexId: objectMetadataItem.namePlural, + }; + + const { deleteTableData } = useDeleteTableData(baseTableDataParams); + + const handleDeleteClick = useCallback(() => { + deleteTableData(); + }, [deleteTableData]); const handleExecuteQuickActionOnClick = useCallback(async () => { callback?.(); @@ -121,62 +109,63 @@ export const useRecordActionBar = ({ }, [callback, executeQuickActionOnOneRecord, selectedRecordIds]); const { progress, download } = useExportTableData({ - delayMs: 100, + ...baseTableDataParams, filename: `${objectMetadataItem.nameSingular}.csv`, - objectNameSingular: objectMetadataItem.nameSingular, - recordIndexId: objectMetadataItem.namePlural, }); const isRemoteObject = objectMetadataItem.isRemote; - const baseActions: ContextMenuEntry[] = useMemo( - () => [ - { - label: displayedExportProgress(progress), - Icon: IconFileExport, - accent: 'default', - onClick: () => download(), - }, - ], - [download, progress], - ); + const numberOfSelectedRecords = + totalNumberOfRecordsSelected ?? selectedRecordIds.length; + const canDelete = + !isRemoteObject && numberOfSelectedRecords < DELETE_MAX_COUNT; - const deletionActions: ContextMenuEntry[] = useMemo( + const menuActions: ContextMenuEntry[] = useMemo( () => - maxRecords !== undefined && selectedRecordIds.length <= maxRecords - ? [ - { + [ + { + label: displayedExportProgress(progress), + Icon: IconFileExport, + accent: 'default', + onClick: () => download(), + } satisfies ContextMenuEntry, + canDelete + ? ({ label: 'Delete', Icon: IconTrash, accent: 'danger', - onClick: () => setIsDeleteRecordsModalOpen(true), + onClick: () => { + setIsDeleteRecordsModalOpen(true); + handleDeleteClick(); + }, ConfirmationModal: ( handleDeleteClick()} deleteButtonText={`Delete ${ - selectedRecordIds.length > 1 ? 'Records' : 'Record' + numberOfSelectedRecords > 1 ? 'Records' : 'Record' }`} /> ), - }, - ] - : [], + } satisfies ContextMenuEntry) + : undefined, + ].filter(isDefined), [ + download, + progress, + canDelete, handleDeleteClick, - selectedRecordIds, isDeleteRecordsModalOpen, - setIsDeleteRecordsModalOpen, - maxRecords, + numberOfSelectedRecords, ], ); @@ -193,8 +182,7 @@ export const useRecordActionBar = ({ return { setContextMenuEntries: useCallback(() => { setContextMenuEntries([ - ...(isRemoteObject ? [] : deletionActions), - ...baseActions, + ...menuActions, ...(!isRemoteObject && isFavorite && hasOnlyOneRecordSelected ? [ { @@ -215,8 +203,7 @@ export const useRecordActionBar = ({ : []), ]); }, [ - baseActions, - deletionActions, + menuActions, handleFavoriteButtonClick, hasOnlyOneRecordSelected, isFavorite, @@ -245,15 +232,12 @@ export const useRecordActionBar = ({ }, ] : []), - ...(isRemoteObject ? [] : deletionActions), - ...baseActions, + ...menuActions, ]); }, [ - baseActions, + menuActions, dataExecuteQuickActionOnmentEnabled, - deletionActions, handleExecuteQuickActionOnClick, - isRemoteObject, setActionBarEntriesState, ]), }; diff --git a/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexTableContainerEffect.tsx b/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexTableContainerEffect.tsx index 6677d042665d..32ae2ffc5091 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexTableContainerEffect.tsx +++ b/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexTableContainerEffect.tsx @@ -6,7 +6,9 @@ import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadata import { useRecordActionBar } from '@/object-record/record-action-bar/hooks/useRecordActionBar'; import { useHandleToggleColumnFilter } from '@/object-record/record-index/hooks/useHandleToggleColumnFilter'; import { useHandleToggleColumnSort } from '@/object-record/record-index/hooks/useHandleToggleColumnSort'; +import { useRecordTableStates } from '@/object-record/record-table/hooks/internal/useRecordTableStates'; import { useRecordTable } from '@/object-record/record-table/hooks/useRecordTable'; +import { useViewStates } from '@/views/hooks/internal/useViewStates'; import { useSetRecordCountInCurrentView } from '@/views/hooks/useSetRecordCountInCurrentView'; type RecordIndexTableContainerEffectProps = { @@ -45,12 +47,31 @@ export const RecordIndexTableContainerEffect = ({ setAvailableTableColumns(columnDefinitions); }, [columnDefinitions, setAvailableTableColumns]); + const { tableRowIdsState, hasUserSelectedAllRowsState } = + useRecordTableStates(recordTableId); + + const { entityCountInCurrentViewState } = useViewStates(recordTableId); + const entityCountInCurrentView = useRecoilValue( + entityCountInCurrentViewState, + ); + const hasUserSelectedAllRows = useRecoilValue(hasUserSelectedAllRowsState); + const tableRowIds = useRecoilValue(tableRowIdsState); + const selectedRowIds = useRecoilValue(selectedRowIdsSelector()); + const numSelected = + hasUserSelectedAllRows && entityCountInCurrentView + ? selectedRowIds.length === tableRowIds.length + ? entityCountInCurrentView + : entityCountInCurrentView - + (tableRowIds.length - selectedRowIds.length) // unselected row Ids + : selectedRowIds.length; + const { setActionBarEntries, setContextMenuEntries } = useRecordActionBar({ objectMetadataItem, selectedRecordIds: selectedRowIds, callback: resetTableRowSelection, + totalNumberOfRecordsSelected: numSelected, }); const handleToggleColumnFilter = useHandleToggleColumnFilter({ diff --git a/packages/twenty-front/src/modules/object-record/record-index/options/hooks/useDeleteTableData.ts b/packages/twenty-front/src/modules/object-record/record-index/options/hooks/useDeleteTableData.ts new file mode 100644 index 000000000000..8c2fca1b9ede --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-index/options/hooks/useDeleteTableData.ts @@ -0,0 +1,76 @@ +import { useFavorites } from '@/favorites/hooks/useFavorites'; +import { useDeleteManyRecords } from '@/object-record/hooks/useDeleteManyRecords'; +import { useFetchAllRecordIds } from '@/object-record/hooks/useFetchAllRecordIds'; +import { UseTableDataOptions } from '@/object-record/record-index/options/hooks/useTableData'; +import { useRecordTable } from '@/object-record/record-table/hooks/useRecordTable'; +import { tableRowIdsComponentState } from '@/object-record/record-table/states/tableRowIdsComponentState'; +import { getScopeIdFromComponentId } from '@/ui/utilities/recoil-scope/utils/getScopeIdFromComponentId'; +import { useRecoilValue } from 'recoil'; + +type UseDeleteTableDataOptions = Omit; + +export const useDeleteTableData = ({ + objectNameSingular, + recordIndexId, +}: UseDeleteTableDataOptions) => { + const { fetchAllRecordIds } = useFetchAllRecordIds({ + objectNameSingular, + }); + + const { + resetTableRowSelection, + selectedRowIdsSelector, + hasUserSelectedAllRowsState, + } = useRecordTable({ + recordTableId: recordIndexId, + }); + + const tableRowIds = useRecoilValue( + tableRowIdsComponentState({ + scopeId: getScopeIdFromComponentId(recordIndexId), + }), + ); + + const { deleteManyRecords } = useDeleteManyRecords({ + objectNameSingular, + }); + const { favorites, deleteFavorite } = useFavorites(); + + const selectedRowIds = useRecoilValue(selectedRowIdsSelector()); + + const hasUserSelectedAllRows = useRecoilValue(hasUserSelectedAllRowsState); + + const deleteRecords = async () => { + let recordIdsToDelete = selectedRowIds; + + if (hasUserSelectedAllRows) { + const allRecordIds = await fetchAllRecordIds(); + + const unselectedRecordIds = tableRowIds.filter( + (recordId) => !selectedRowIds.includes(recordId), + ); + + recordIdsToDelete = allRecordIds.filter( + (recordId) => !unselectedRecordIds.includes(recordId), + ); + } + + 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, + }); + }; + + return { deleteTableData: deleteRecords }; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-index/options/hooks/useExportTableData.ts b/packages/twenty-front/src/modules/object-record/record-index/options/hooks/useExportTableData.ts index 72a2144c678a..c186bf1e2903 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/options/hooks/useExportTableData.ts +++ b/packages/twenty-front/src/modules/object-record/record-index/options/hooks/useExportTableData.ts @@ -1,17 +1,16 @@ -import { useEffect, useState } from 'react'; +import { useMemo } from 'react'; import { json2csv } from 'json-2-csv'; -import { useRecoilValue } from 'recoil'; -import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords'; import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata'; -import { useRecordTableStates } from '@/object-record/record-table/hooks/internal/useRecordTableStates'; +import { + useTableData, + UseTableDataOptions, +} from '@/object-record/record-index/options/hooks/useTableData'; import { ColumnDefinition } from '@/object-record/record-table/types/ColumnDefinition'; +import { ObjectRecord } from '@/object-record/types/ObjectRecord'; import { FieldMetadataType } from '~/generated/graphql'; import { isDefined } from '~/utils/isDefined'; import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull'; -import { sleep } from '~/utils/sleep'; - -import { useFindManyParams } from '../../hooks/useLoadRecordIndexTable'; export const download = (blob: Blob, filename: string) => { const url = URL.createObjectURL(blob); @@ -127,13 +126,8 @@ const downloader = (mimeType: string, generator: GenerateExport) => { export const csvDownloader = downloader('text/csv', generateCsv); -type UseExportTableDataOptions = { - delayMs: number; +type UseExportTableDataOptions = Omit & { filename: string; - maximumRequests?: number; - objectNameSingular: string; - pageSize?: number; - recordIndexId: string; }; export const useExportTableData = ({ @@ -144,100 +138,22 @@ export const useExportTableData = ({ pageSize = 30, recordIndexId, }: UseExportTableDataOptions) => { - const [isDownloading, setIsDownloading] = useState(false); - const [inflight, setInflight] = useState(false); - const [pageCount, setPageCount] = useState(0); - const [progress, setProgress] = useState({ - displayType: 'number', - }); - const [previousRecordCount, setPreviousRecordCount] = useState(0); - - const { visibleTableColumnsSelector, selectedRowIdsSelector } = - useRecordTableStates(recordIndexId); - - const columns = useRecoilValue(visibleTableColumnsSelector()); - const selectedRowIds = useRecoilValue(selectedRowIdsSelector()); - - const hasSelectedRows = selectedRowIds.length > 0; - - const findManyRecordsParams = useFindManyParams( - objectNameSingular, - recordIndexId, - ); - - const selectedFindManyParams = { - ...findManyRecordsParams, - filter: { - ...findManyRecordsParams.filter, - id: { - in: selectedRowIds, + const downloadCsv = useMemo( + () => + (rows: ObjectRecord[], columns: ColumnDefinition[]) => { + csvDownloader(filename, { rows, columns }); }, - }, - }; - - const usedFindManyParams = hasSelectedRows - ? selectedFindManyParams - : findManyRecordsParams; - - // Todo: this needs to be done on click on the Export not button, not to be reactive. Use Lazy query for example - const { totalCount, records, fetchMoreRecords } = useFindManyRecords({ - ...usedFindManyParams, - limit: pageSize, - }); - - useEffect(() => { - const MAXIMUM_REQUESTS = isDefined(totalCount) - ? Math.min(maximumRequests, totalCount / pageSize) - : maximumRequests; - - const downloadCsv = (rows: object[]) => { - csvDownloader(filename, { rows, columns }); - setIsDownloading(false); - setProgress({ - displayType: 'number', - }); - }; - - const fetchNextPage = async () => { - setInflight(true); - setPreviousRecordCount(records.length); - await fetchMoreRecords(); - setPageCount((state) => state + 1); - setProgress({ - exportedRecordCount: records.length, - totalRecordCount: totalCount, - displayType: totalCount ? 'percentage' : 'number', - }); - await sleep(delayMs); - setInflight(false); - }; - - if (!isDownloading || inflight) { - return; - } + [filename], + ); - if ( - pageCount >= MAXIMUM_REQUESTS || - records.length === previousRecordCount - ) { - downloadCsv(records); - } else { - fetchNextPage(); - } - }, [ + const { getTableData: download, progress } = useTableData({ delayMs, - fetchMoreRecords, - filename, - inflight, - isDownloading, - pageCount, - records, - totalCount, - columns, maximumRequests, + objectNameSingular, pageSize, - previousRecordCount, - ]); + recordIndexId, + callback: downloadCsv, + }); - return { progress, download: () => setIsDownloading(true) }; + return { progress, download }; }; diff --git a/packages/twenty-front/src/modules/object-record/record-index/options/hooks/useTableData.ts b/packages/twenty-front/src/modules/object-record/record-index/options/hooks/useTableData.ts new file mode 100644 index 000000000000..b389d906dbb8 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-index/options/hooks/useTableData.ts @@ -0,0 +1,204 @@ +import { useEffect, useMemo, useState } from 'react'; +import { useRecoilValue } from 'recoil'; + +import { useLazyFindManyRecords } from '@/object-record/hooks/useLazyFindManyRecords'; +import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata'; +import { useRecordTableStates } from '@/object-record/record-table/hooks/internal/useRecordTableStates'; +import { ColumnDefinition } from '@/object-record/record-table/types/ColumnDefinition'; +import { ObjectRecord } from '@/object-record/types/ObjectRecord'; +import { isDefined } from '~/utils/isDefined'; + +import { useFindManyParams } from '../../hooks/useLoadRecordIndexTable'; + +export const sleep = (ms: number) => + new Promise((resolve) => setTimeout(resolve, ms)); + +export const percentage = (part: number, whole: number): number => { + return Math.round((part / whole) * 100); +}; + +export type UseTableDataOptions = { + delayMs: number; + maximumRequests?: number; + objectNameSingular: string; + pageSize?: number; + recordIndexId: string; + callback: ( + rows: ObjectRecord[], + columns: ColumnDefinition[], + ) => void | Promise; +}; + +type ExportProgress = { + exportedRecordCount?: number; + totalRecordCount?: number; + displayType: 'percentage' | 'number'; +}; + +export const useTableData = ({ + delayMs, + maximumRequests = 100, + objectNameSingular, + pageSize = 30, + recordIndexId, + callback, +}: UseTableDataOptions) => { + const [isDownloading, setIsDownloading] = useState(false); + const [inflight, setInflight] = useState(false); + const [pageCount, setPageCount] = useState(0); + const [progress, setProgress] = useState({ + displayType: 'number', + }); + const [previousRecordCount, setPreviousRecordCount] = useState(0); + + const { + visibleTableColumnsSelector, + selectedRowIdsSelector, + tableRowIdsState, + hasUserSelectedAllRowsState, + } = useRecordTableStates(recordIndexId); + + const columns = useRecoilValue(visibleTableColumnsSelector()); + const selectedRowIds = useRecoilValue(selectedRowIdsSelector()); + + const hasUserSelectedAllRows = useRecoilValue(hasUserSelectedAllRowsState); + const tableRowIds = useRecoilValue(tableRowIdsState); + + // user has checked select all and then unselected some rows + const userHasUnselectedSomeRows = + hasUserSelectedAllRows && selectedRowIds.length < tableRowIds.length; + + const hasSelectedRows = + selectedRowIds.length > 0 && + !(hasUserSelectedAllRows && selectedRowIds.length === tableRowIds.length); + + const unselectedRowIds = useMemo( + () => + userHasUnselectedSomeRows + ? tableRowIds.filter((id) => !selectedRowIds.includes(id)) + : [], + [userHasUnselectedSomeRows, tableRowIds, selectedRowIds], + ); + + const findManyRecordsParams = useFindManyParams( + objectNameSingular, + recordIndexId, + ); + + const selectedFindManyParams = { + ...findManyRecordsParams, + filter: { + ...findManyRecordsParams.filter, + id: { + in: selectedRowIds, + }, + }, + }; + + const unselectedFindManyParams = { + ...findManyRecordsParams, + filter: { + ...findManyRecordsParams.filter, + not: { + id: { + in: unselectedRowIds, + }, + }, + }, + }; + + const usedFindManyParams = + hasSelectedRows && !userHasUnselectedSomeRows + ? selectedFindManyParams + : userHasUnselectedSomeRows + ? unselectedFindManyParams + : findManyRecordsParams; + + const { + findManyRecords, + totalCount, + records, + fetchMoreRecordsWithPagination, + loading, + } = useLazyFindManyRecords({ + ...usedFindManyParams, + limit: pageSize, + }); + + useEffect(() => { + const MAXIMUM_REQUESTS = isDefined(totalCount) + ? Math.min(maximumRequests, totalCount / pageSize) + : maximumRequests; + + const fetchNextPage = async () => { + setInflight(true); + setPreviousRecordCount(records.length); + + await fetchMoreRecordsWithPagination(); + + setPageCount((state) => state + 1); + setProgress({ + exportedRecordCount: records.length, + totalRecordCount: totalCount, + displayType: totalCount ? 'percentage' : 'number', + }); + await sleep(delayMs); + setInflight(false); + }; + + if (!isDownloading || inflight || loading) { + return; + } + + if ( + pageCount >= MAXIMUM_REQUESTS || + (isDefined(totalCount) && records.length === totalCount) + ) { + setPageCount(0); + + const complete = () => { + setPageCount(0); + setPreviousRecordCount(0); + setIsDownloading(false); + setProgress({ + displayType: 'number', + }); + }; + + const res = callback(records, columns); + + if (res instanceof Promise) { + res.then(complete); + } else { + complete(); + } + } else { + fetchNextPage(); + } + }, [ + delayMs, + fetchMoreRecordsWithPagination, + inflight, + isDownloading, + pageCount, + records, + totalCount, + columns, + maximumRequests, + pageSize, + loading, + callback, + previousRecordCount, + ]); + + return { + progress, + isDownloading, + getTableData: () => { + setPageCount(0); + setPreviousRecordCount(0); + setIsDownloading(true); + findManyRecords?.(); + }, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-index/options/states/recordDeleteProgressState.ts b/packages/twenty-front/src/modules/object-record/record-index/options/states/recordDeleteProgressState.ts new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/packages/twenty-front/src/modules/object-record/record-table/action-bar/components/RecordTableActionBar.tsx b/packages/twenty-front/src/modules/object-record/record-table/action-bar/components/RecordTableActionBar.tsx index f7241a5e2a48..f41025398c22 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/action-bar/components/RecordTableActionBar.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/action-bar/components/RecordTableActionBar.tsx @@ -2,19 +2,43 @@ import { useRecoilValue } from 'recoil'; import { useRecordTableStates } from '@/object-record/record-table/hooks/internal/useRecordTableStates'; import { ActionBar } from '@/ui/navigation/action-bar/components/ActionBar'; +import { useViewStates } from '@/views/hooks/internal/useViewStates'; export const RecordTableActionBar = ({ recordTableId, }: { recordTableId: string; }) => { - const { selectedRowIdsSelector } = useRecordTableStates(recordTableId); + const { + selectedRowIdsSelector, + tableRowIdsState, + hasUserSelectedAllRowsState, + } = useRecordTableStates(recordTableId); + const { entityCountInCurrentViewState } = useViewStates(recordTableId); + const entityCountInCurrentView = useRecoilValue( + entityCountInCurrentViewState, + ); + const hasUserSelectedAllRows = useRecoilValue(hasUserSelectedAllRowsState); + const tableRowIds = useRecoilValue(tableRowIdsState); const selectedRowIds = useRecoilValue(selectedRowIdsSelector()); + const totalNumberOfSelectedRecords = + hasUserSelectedAllRows && entityCountInCurrentView + ? selectedRowIds.length === tableRowIds.length + ? entityCountInCurrentView + : entityCountInCurrentView - + (tableRowIds.length - selectedRowIds.length) // unselected row Ids + : selectedRowIds.length; + if (!selectedRowIds.length) { return null; } - return ; + return ( + + ); }; diff --git a/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableInternalEffect.tsx b/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableInternalEffect.tsx index 9a3f062e0f2c..77e2f228794e 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableInternalEffect.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableInternalEffect.tsx @@ -5,7 +5,10 @@ import { useRecordTable } from '@/object-record/record-table/hooks/useRecordTabl import { TableHotkeyScope } from '@/object-record/record-table/types/TableHotkeyScope'; import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys'; import { useClickOutsideListener } from '@/ui/utilities/pointer-event/hooks/useClickOutsideListener'; -import { useListenClickOutsideByClassName } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside'; +import { + ClickOutsideMode, + useListenClickOutsideByClassName, +} from '@/ui/utilities/pointer-event/hooks/useListenClickOutside'; type RecordTableInternalEffectProps = { recordTableId: string; @@ -30,6 +33,7 @@ export const RecordTableInternalEffect = ({ callback: () => { leaveTableFocus(); }, + mode: ClickOutsideMode.comparePixels, }); useScopedHotkeys( diff --git a/packages/twenty-front/src/modules/object-record/record-table/hooks/internal/useLeaveTableFocus.ts b/packages/twenty-front/src/modules/object-record/record-table/hooks/internal/useLeaveTableFocus.ts index 0ee3aa62d6a8..0a52eb083239 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/hooks/internal/useLeaveTableFocus.ts +++ b/packages/twenty-front/src/modules/object-record/record-table/hooks/internal/useLeaveTableFocus.ts @@ -8,12 +8,17 @@ import { TableHotkeyScope } from '../../types/TableHotkeyScope'; import { useCloseCurrentTableCellInEditMode } from './useCloseCurrentTableCellInEditMode'; import { useDisableSoftFocus } from './useDisableSoftFocus'; +import { useSetHasUserSelectedAllRows } from './useSetAllRowSelectedState'; export const useLeaveTableFocus = (recordTableId?: string) => { const disableSoftFocus = useDisableSoftFocus(recordTableId); const closeCurrentCellInEditMode = useCloseCurrentTableCellInEditMode(recordTableId); + const setHasUserSelectedAllRows = useSetHasUserSelectedAllRows(recordTableId); + + const selectAllRows = useSetHasUserSelectedAllRows(recordTableId); + const { isSoftFocusActiveState } = useRecordTableStates(recordTableId); return useRecoilCallback( @@ -38,7 +43,15 @@ export const useLeaveTableFocus = (recordTableId?: string) => { closeCurrentCellInEditMode(); disableSoftFocus(); + setHasUserSelectedAllRows(false); + selectAllRows(false); }, - [closeCurrentCellInEditMode, disableSoftFocus, isSoftFocusActiveState], + [ + closeCurrentCellInEditMode, + disableSoftFocus, + isSoftFocusActiveState, + selectAllRows, + setHasUserSelectedAllRows, + ], ); }; diff --git a/packages/twenty-front/src/modules/object-record/record-table/hooks/internal/useRecordTableStates.ts b/packages/twenty-front/src/modules/object-record/record-table/hooks/internal/useRecordTableStates.ts index 1e178da5bd7a..1fbc4a8bd810 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/hooks/internal/useRecordTableStates.ts +++ b/packages/twenty-front/src/modules/object-record/record-table/hooks/internal/useRecordTableStates.ts @@ -109,7 +109,7 @@ export const useRecordTableStates = (recordTableId?: string) => { isRowSelectedComponentFamilyState, scopeId, ), - hasUserSelectedAllRowState: extractComponentState( + hasUserSelectedAllRowsState: extractComponentState( hasUserSelectedAllRowsComponentState, scopeId, ), diff --git a/packages/twenty-front/src/modules/object-record/record-table/hooks/internal/useResetTableRowSelection.ts b/packages/twenty-front/src/modules/object-record/record-table/hooks/internal/useResetTableRowSelection.ts index 7e86f581909c..1b6263739111 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/hooks/internal/useResetTableRowSelection.ts +++ b/packages/twenty-front/src/modules/object-record/record-table/hooks/internal/useResetTableRowSelection.ts @@ -4,8 +4,11 @@ import { useRecordTableStates } from '@/object-record/record-table/hooks/interna import { getSnapshotValue } from '@/ui/utilities/recoil-scope/utils/getSnapshotValue'; export const useResetTableRowSelection = (recordTableId?: string) => { - const { tableRowIdsState, isRowSelectedFamilyState } = - useRecordTableStates(recordTableId); + const { + tableRowIdsState, + isRowSelectedFamilyState, + hasUserSelectedAllRowsState, + } = useRecordTableStates(recordTableId); return useRecoilCallback( ({ snapshot, set }) => @@ -15,7 +18,9 @@ export const useResetTableRowSelection = (recordTableId?: string) => { for (const rowId of tableRowIds) { set(isRowSelectedFamilyState(rowId), false); } + + set(hasUserSelectedAllRowsState, false); }, - [tableRowIdsState, isRowSelectedFamilyState], + [tableRowIdsState, isRowSelectedFamilyState, hasUserSelectedAllRowsState], ); }; diff --git a/packages/twenty-front/src/modules/object-record/record-table/hooks/internal/useSetAllRowSelectedState.ts b/packages/twenty-front/src/modules/object-record/record-table/hooks/internal/useSetAllRowSelectedState.ts index 4544c4b71f95..5df32d9006d2 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/hooks/internal/useSetAllRowSelectedState.ts +++ b/packages/twenty-front/src/modules/object-record/record-table/hooks/internal/useSetAllRowSelectedState.ts @@ -3,14 +3,13 @@ import { useRecoilCallback } from 'recoil'; import { useRecordTableStates } from '@/object-record/record-table/hooks/internal/useRecordTableStates'; export const useSetHasUserSelectedAllRows = (recordTableId?: string) => { - const { hasUserSelectedAllRowState: hasUserSelectedAllRowFamilyState } = - useRecordTableStates(recordTableId); + const { hasUserSelectedAllRowsState } = useRecordTableStates(recordTableId); return useRecoilCallback( ({ set }) => (selected: boolean) => { - set(hasUserSelectedAllRowFamilyState, selected); + set(hasUserSelectedAllRowsState, selected); }, - [hasUserSelectedAllRowFamilyState], + [hasUserSelectedAllRowsState], ); }; diff --git a/packages/twenty-front/src/modules/object-record/record-table/hooks/internal/useSetRecordTableData.ts b/packages/twenty-front/src/modules/object-record/record-table/hooks/internal/useSetRecordTableData.ts index 64ca715b896a..eeaf15147c4a 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/hooks/internal/useSetRecordTableData.ts +++ b/packages/twenty-front/src/modules/object-record/record-table/hooks/internal/useSetRecordTableData.ts @@ -19,7 +19,7 @@ export const useSetRecordTableData = ({ tableRowIdsState, numberOfTableRowsState, isRowSelectedFamilyState, - hasUserSelectedAllRowState, + hasUserSelectedAllRowsState, } = useRecordTableStates(recordTableId); return useRecoilCallback( @@ -39,7 +39,7 @@ export const useSetRecordTableData = ({ const hasUserSelectedAllRows = getSnapshotValue( snapshot, - hasUserSelectedAllRowState, + hasUserSelectedAllRowsState, ); const entityIds = newEntityArray.map((entity) => entity.id); @@ -62,7 +62,7 @@ export const useSetRecordTableData = ({ tableRowIdsState, onEntityCountChange, isRowSelectedFamilyState, - hasUserSelectedAllRowState, + hasUserSelectedAllRowsState, ], ); }; diff --git a/packages/twenty-front/src/modules/object-record/record-table/hooks/useRecordTable.ts b/packages/twenty-front/src/modules/object-record/record-table/hooks/useRecordTable.ts index 785baebe165c..6cad63df5448 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/hooks/useRecordTable.ts +++ b/packages/twenty-front/src/modules/object-record/record-table/hooks/useRecordTable.ts @@ -45,6 +45,7 @@ export const useRecordTable = (props?: useRecordTableProps) => { onToggleColumnFilterState, onToggleColumnSortState, pendingRecordIdState, + hasUserSelectedAllRowsState, } = useRecordTableStates(recordTableId); const setAvailableTableColumns = useRecoilCallback( @@ -226,5 +227,6 @@ export const useRecordTable = (props?: useRecordTableProps) => { setOnToggleColumnFilter, setOnToggleColumnSort, setPendingRecordId, + hasUserSelectedAllRowsState, }; }; diff --git a/packages/twenty-front/src/modules/object-record/record-table/states/selectors/allRowsSelectedStatusComponentSelector.ts b/packages/twenty-front/src/modules/object-record/record-table/states/selectors/allRowsSelectedStatusComponentSelector.ts index c0a42ea81015..6ce6b42ff6c7 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/states/selectors/allRowsSelectedStatusComponentSelector.ts +++ b/packages/twenty-front/src/modules/object-record/record-table/states/selectors/allRowsSelectedStatusComponentSelector.ts @@ -1,5 +1,5 @@ -import { numberOfTableRowsComponentState } from '@/object-record/record-table/states/numberOfTableRowsComponentState'; import { selectedRowIdsComponentSelector } from '@/object-record/record-table/states/selectors/selectedRowIdsComponentSelector'; +import { tableRowIdsComponentState } from '@/object-record/record-table/states/tableRowIdsComponentState'; import { createComponentReadOnlySelector } from '@/ui/utilities/state/component-state/utils/createComponentReadOnlySelector'; import { AllRowsSelectedStatus } from '../../types/AllRowSelectedStatus'; @@ -10,7 +10,7 @@ export const allRowsSelectedStatusComponentSelector = get: ({ scopeId }) => ({ get }) => { - const numberOfRows = get(numberOfTableRowsComponentState({ scopeId })); + const tableRowIds = get(tableRowIdsComponentState({ scopeId })); const selectedRowIds = get( selectedRowIdsComponentSelector({ scopeId }), @@ -21,7 +21,7 @@ export const allRowsSelectedStatusComponentSelector = const allRowsSelectedStatus = numberOfSelectedRows === 0 ? 'none' - : numberOfRows === numberOfSelectedRows + : selectedRowIds.length === tableRowIds.length ? 'all' : 'some'; diff --git a/packages/twenty-front/src/modules/object-record/types/OnFindManyRecordsCompleted.ts b/packages/twenty-front/src/modules/object-record/types/OnFindManyRecordsCompleted.ts new file mode 100644 index 000000000000..97003380167d --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/types/OnFindManyRecordsCompleted.ts @@ -0,0 +1,9 @@ +import { RecordGqlConnection } from '@/object-record/graphql/types/RecordGqlConnection'; + +export type OnFindManyRecordsCompleted = ( + records: T[], + options?: { + pageInfo?: RecordGqlConnection['pageInfo']; + totalCount?: number; + }, +) => void; diff --git a/packages/twenty-front/src/modules/object-record/types/UseFindManyRecordsParams.ts b/packages/twenty-front/src/modules/object-record/types/UseFindManyRecordsParams.ts new file mode 100644 index 000000000000..1a15c6040ecc --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/types/UseFindManyRecordsParams.ts @@ -0,0 +1,14 @@ +import { WatchQueryFetchPolicy } from '@apollo/client'; + +import { ObjectMetadataItemIdentifier } from '@/object-metadata/types/ObjectMetadataItemIdentifier'; +import { RecordGqlOperationGqlRecordFields } from '@/object-record/graphql/types/RecordGqlOperationGqlRecordFields'; +import { RecordGqlOperationVariables } from '@/object-record/graphql/types/RecordGqlOperationVariables'; +import { OnFindManyRecordsCompleted } from '@/object-record/types/OnFindManyRecordsCompleted'; + +export type UseFindManyRecordsParams = ObjectMetadataItemIdentifier & + RecordGqlOperationVariables & { + onCompleted?: OnFindManyRecordsCompleted; + skip?: boolean; + recordGqlFields?: RecordGqlOperationGqlRecordFields; + fetchPolicy?: WatchQueryFetchPolicy; + }; diff --git a/packages/twenty-front/src/modules/object-record/utils/getQueryIdentifier.ts b/packages/twenty-front/src/modules/object-record/utils/getQueryIdentifier.ts new file mode 100644 index 000000000000..20fc7d79f921 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/utils/getQueryIdentifier.ts @@ -0,0 +1,11 @@ +import { RecordGqlOperationVariables } from '@/object-record/graphql/types/RecordGqlOperationVariables'; + +export const getQueryIdentifier = ({ + objectNameSingular, + filter, + orderBy, + limit, +}: RecordGqlOperationVariables & { + objectNameSingular: string; +}) => + objectNameSingular + JSON.stringify(filter) + JSON.stringify(orderBy) + limit; diff --git a/packages/twenty-front/src/modules/ui/navigation/action-bar/components/ActionBar.tsx b/packages/twenty-front/src/modules/ui/navigation/action-bar/components/ActionBar.tsx index 97e565844f5c..1cb6a4af4f80 100644 --- a/packages/twenty-front/src/modules/ui/navigation/action-bar/components/ActionBar.tsx +++ b/packages/twenty-front/src/modules/ui/navigation/action-bar/components/ActionBar.tsx @@ -1,15 +1,17 @@ -import { useEffect, useRef } from 'react'; import styled from '@emotion/styled'; +import { useEffect, useRef } from 'react'; import { useRecoilValue, useSetRecoilState } from 'recoil'; import { actionBarEntriesState } from '@/ui/navigation/action-bar/states/actionBarEntriesState'; import { contextMenuIsOpenState } from '@/ui/navigation/context-menu/states/contextMenuIsOpenState'; import SharedNavigationModal from '@/ui/navigation/shared/components/NavigationModal'; +import { isDefined } from '~/utils/isDefined'; import { ActionBarItem } from './ActionBarItem'; type ActionBarProps = { selectedIds?: string[]; + totalNumberOfSelectedRecords?: number; }; const StyledContainerActionBar = styled.div` @@ -40,7 +42,10 @@ const StyledLabel = styled.div` padding-right: ${({ theme }) => theme.spacing(2)}; `; -export const ActionBar = ({ selectedIds = [] }: ActionBarProps) => { +export const ActionBar = ({ + selectedIds = [], + totalNumberOfSelectedRecords, +}: ActionBarProps) => { const setContextMenuOpenState = useSetRecoilState(contextMenuIsOpenState); useEffect(() => { @@ -57,6 +62,12 @@ export const ActionBar = ({ selectedIds = [] }: ActionBarProps) => { return null; } + const selectedNumberLabel = + totalNumberOfSelectedRecords ?? selectedIds?.length; + + const showSelectedNumberLabel = + isDefined(totalNumberOfSelectedRecords) || Array.isArray(selectedIds); + return ( <> { className="action-bar" ref={wrapperRef} > - {selectedIds && ( - {selectedIds.length} selected: + {showSelectedNumberLabel && ( + {selectedNumberLabel} selected: )} {actionBarEntries.map((item, index) => ( diff --git a/packages/twenty-front/src/modules/ui/navigation/action-bar/types/ActionBarEntry.ts b/packages/twenty-front/src/modules/ui/navigation/action-bar/types/ActionBarEntry.ts index f37a9b2e84cb..a276736cfb1c 100644 --- a/packages/twenty-front/src/modules/ui/navigation/action-bar/types/ActionBarEntry.ts +++ b/packages/twenty-front/src/modules/ui/navigation/action-bar/types/ActionBarEntry.ts @@ -1,12 +1,5 @@ -import { IconComponent } from 'twenty-ui'; +import { ContextMenuEntry } from '@/ui/navigation/context-menu/types/ContextMenuEntry'; -import { MenuItemAccent } from '@/ui/navigation/menu-item/types/MenuItemAccent'; - -export type ActionBarEntry = { - label: string; - Icon: IconComponent; - accent?: MenuItemAccent; - onClick?: () => void; +export type ActionBarEntry = ContextMenuEntry & { subActions?: ActionBarEntry[]; - ConfirmationModal?: JSX.Element; }; diff --git a/packages/twenty-front/src/modules/ui/navigation/context-menu/types/ContextMenuEntry.ts b/packages/twenty-front/src/modules/ui/navigation/context-menu/types/ContextMenuEntry.ts index d56820d6ebe1..416a41419f62 100644 --- a/packages/twenty-front/src/modules/ui/navigation/context-menu/types/ContextMenuEntry.ts +++ b/packages/twenty-front/src/modules/ui/navigation/context-menu/types/ContextMenuEntry.ts @@ -1,3 +1,4 @@ +import { MouseEvent, ReactNode } from 'react'; import { IconComponent } from 'twenty-ui'; import { MenuItemAccent } from '@/ui/navigation/menu-item/types/MenuItemAccent'; @@ -6,5 +7,6 @@ export type ContextMenuEntry = { label: string; Icon: IconComponent; accent?: MenuItemAccent; - onClick: () => void; + onClick?: (event?: MouseEvent) => void; + ConfirmationModal?: ReactNode; }; diff --git a/packages/twenty-front/src/testing/mock-data/metadata.ts b/packages/twenty-front/src/testing/mock-data/metadata.ts index 94b5f910c24c..d6ec68da1d70 100644 --- a/packages/twenty-front/src/testing/mock-data/metadata.ts +++ b/packages/twenty-front/src/testing/mock-data/metadata.ts @@ -7,6 +7,7 @@ import { } from '~/generated-metadata/graphql'; import { mockedStandardObjectMetadataQueryResult } from '~/testing/mock-data/generated/standard-metadata-query-result'; +// TODO: replace with new mock const customObjectMetadataItemEdge: ObjectEdge = { __typename: 'objectEdge', node: { diff --git a/packages/twenty-front/src/testing/mock-data/people.ts b/packages/twenty-front/src/testing/mock-data/people.ts index e0aaa78486c3..89bff03b013e 100644 --- a/packages/twenty-front/src/testing/mock-data/people.ts +++ b/packages/twenty-front/src/testing/mock-data/people.ts @@ -1,3 +1,5 @@ +import { RecordGqlConnection } from '@/object-record/graphql/types/RecordGqlConnection'; + export const getPeopleMock = () => { const peopleMock = peopleQueryResult.people.edges.map((edge) => edge.node); @@ -22,7 +24,7 @@ export const mockedEmptyPersonData = { __typename: 'Person', }; -export const peopleQueryResult = { +export const peopleQueryResult: { people: RecordGqlConnection } = { people: { __typename: 'PersonConnection', totalCount: 15,