☑️ Refacto "Select All/Unselect all" on indexes (#5320)

### 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 <gitstart-twenty@users.noreply.github.com>
Co-authored-by: v1b3m <vibenjamin6@gmail.com>
Co-authored-by: Toledodev <rafael.toledo@engenharia.ufjf.br>
Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
Co-authored-by: Lucas Bordeau <bordeau.lucas@gmail.com>
This commit is contained in:
gitstart-twenty
2024-07-15 06:26:10 -04:00
committed by GitHub
parent e5d76a33ed
commit d560d25736
40 changed files with 1349 additions and 433 deletions

View File

@ -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),
};

View File

@ -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();

View File

@ -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 }) => (
<RecoilRoot>
<SnackBarManagerScopeInternalContext.Provider
value={{
scopeId: 'snack-bar-manager',
}}
>
<MockedProvider mocks={mocks} addTypename={false}>
{children}
</MockedProvider>
</SnackBarManagerScopeInternalContext.Provider>
</RecoilRoot>
);
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),
);
});
});

View File

@ -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';

View File

@ -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];
const numberOfBatches = Math.ceil(
idsToDelete.length / DEFAULT_MUTATION_BATCH_SIZE,
);
if (!records?.length) return;
const deletedRecords = [];
const cachedRecords = records
.map((record) => getRecordFromCache(record.id, cache))
.filter(isDefined);
for (let batchIndex = 0; batchIndex < numberOfBatches; batchIndex++) {
const batchIds = idsToDelete.slice(
batchIndex * DEFAULT_MUTATION_BATCH_SIZE,
(batchIndex + 1) * DEFAULT_MUTATION_BATCH_SIZE,
);
triggerDeleteRecordsOptimisticEffect({
cache,
objectMetadataItem,
recordsToDelete: cachedRecords,
objectMetadataItems,
});
},
});
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];
return deletedRecords.data?.[mutationResponseField] ?? null;
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 };

View File

@ -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<T> = Omit<
UseFindManyRecordsParams<T>,
'skip'
> & { pageSize?: number };
export const useFetchAllRecordIds = <T>({
objectNameSingular,
filter,
orderBy,
pageSize = DEFAULT_QUERY_PAGE_SIZE,
}: UseLazyFetchAllRecordIdsParams<T>) => {
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,
};
};

View File

@ -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<T> = ObjectMetadataItemIdentifier &
RecordGqlOperationVariables & {
onCompleted?: OnFindManyRecordsCompleted<T>;
skip?: boolean;
recordGqlFields?: RecordGqlOperationGqlRecordFields;
fetchPolicy?: WatchQueryFetchPolicy;
};
type UseFindManyRecordsStateParams<
T,
TData = RecordGqlOperationFindManyResult,
> = Omit<
UseFindManyRecordsParams<T>,
'skip' | 'recordGqlFields' | 'fetchPolicy'
> & {
data: RecordGqlOperationFindManyResult | undefined;
error: ApolloError | undefined;
fetchMore<
TFetchData = TData,
TFetchVars extends OperationVariables = OperationVariables,
>(
fetchMoreOptions: FetchMoreQueryOptions<TFetchVars, TFetchData> & {
updateQuery?: (
previousQueryResult: TData,
options: {
fetchMoreResult: TFetchData;
variables: TFetchVars;
},
) => TData;
},
): Promise<ApolloQueryResult<TFetchData>>;
objectMetadataItem: ObjectMetadataItem;
};
export const useFetchMoreRecordsWithPagination = <
T extends ObjectRecord = ObjectRecord,
>({
objectNameSingular,
filter,
orderBy,
limit,
data,
error,
fetchMore,
objectMetadataItem,
onCompleted,
}: UseFindManyRecordsStateParams<T>) => {
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<T>({
recordConnection: data?.[objectMetadataItem.namePlural],
})
: ([] as T[]),
[data, objectMetadataItem.namePlural],
);
return {
fetchMoreRecords,
totalCount,
records,
hasNextPage,
};
};

View File

@ -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<T> = ObjectMetadataItemIdentifier &
RecordGqlOperationVariables & {
onError?: (error?: Error) => void;
onCompleted?: OnFindManyRecordsCompleted<T>;
skip?: boolean;
recordGqlFields?: RecordGqlOperationGqlRecordFields;
fetchPolicy?: WatchQueryFetchPolicy;
};
export const useFindManyRecords = <T extends ObjectRecord = ObjectRecord>({
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<T>) => {
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<RecordGqlOperationFindManyResult>(findManyRecordsQuery, {
@ -90,147 +71,21 @@ export const useFindManyRecords = <T extends ObjectRecord = ObjectRecord>({
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<T>({
recordConnection: data?.[objectMetadataItem.namePlural],
})
: ([] as T[]),
[data, objectMetadataItem.namePlural],
);
const { fetchMoreRecords, totalCount, records, hasNextPage } =
useFetchMoreRecordsWithPagination<T>({
objectNameSingular,
filter,
orderBy,
limit,
fetchMore,
data,
error,
objectMetadataItem,
});
return {
objectMetadataItem,
@ -239,7 +94,7 @@ export const useFindManyRecords = <T extends ObjectRecord = ObjectRecord>({
loading,
error,
fetchMoreRecords,
queryStateIdentifier: findManyQueryStateIdentifier,
queryStateIdentifier: queryIdentifier,
hasNextPage,
};
};

View File

@ -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 = <T>({
queryIdentifier,
onCompleted,
objectMetadataItem,
}: {
queryIdentifier: string;
objectMetadataItem: ObjectMetadataItem;
onCompleted?: OnFindManyRecordsCompleted<T>;
}) => {
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,
};
};

View File

@ -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,
};
};

View File

@ -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<T> = Omit<
UseFindManyRecordsParams<T>,
'skip'
>;
export const useLazyFindManyRecords = <T extends ObjectRecord = ObjectRecord>({
objectNameSingular,
filter,
orderBy,
limit,
recordGqlFields,
fetchPolicy,
onCompleted,
onError,
}: UseLazyFindManyRecordsParams<T>) => {
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<RecordGqlOperationFindManyResult>(findManyRecordsQuery, {
variables: {
filter,
limit,
orderBy,
},
fetchPolicy: fetchPolicy,
onCompleted: handleFindManyRecordsCompleted,
onError: handleFindManyRecordsError,
});
const { fetchMoreRecords, totalCount, records } =
useFetchMoreRecordsWithPagination<T>({
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,
};
};