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

View File

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

View File

@ -0,0 +1 @@
export const DEFAULT_MUTATION_BATCH_SIZE = 30;

View File

@ -0,0 +1 @@
export const DEFAULT_QUERY_PAGE_SIZE = 30;

View File

@ -0,0 +1 @@
export const DELETE_MAX_COUNT = 10000;

View File

@ -6,6 +6,7 @@ export type RecordGqlConnection = {
__typename?: string;
edges: RecordGqlEdge[];
pageInfo: {
__typename?: string;
hasNextPage?: boolean;
hasPreviousPage?: boolean;
startCursor?: Nullable<string>;

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

View File

@ -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: (
<ConfirmationModal
isOpen={isDeleteRecordsModalOpen}
setIsOpen={setIsDeleteRecordsModalOpen}
title={`Delete ${selectedRecordIds.length} ${
selectedRecordIds.length === 1 ? `record` : 'records'
title={`Delete ${numberOfSelectedRecords} ${
numberOfSelectedRecords === 1 ? `record` : 'records'
}`}
subtitle={`This action cannot be undone. This will permanently delete ${
selectedRecordIds.length === 1
numberOfSelectedRecords === 1
? 'this record'
: 'these records'
}`}
onConfirmClick={() => 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,
]),
};

View File

@ -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({

View File

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

View File

@ -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<UseTableDataOptions, 'callback'> & {
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<ExportProgress>({
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 downloadCsv = useMemo(
() =>
(rows: ObjectRecord[], columns: ColumnDefinition<FieldMetadata>[]) => {
csvDownloader(filename, { rows, columns });
},
[filename],
);
const selectedFindManyParams = {
...findManyRecordsParams,
filter: {
...findManyRecordsParams.filter,
id: {
in: selectedRowIds,
},
},
};
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,
const { getTableData: download, progress } = useTableData({
delayMs,
maximumRequests,
objectNameSingular,
pageSize,
recordIndexId,
callback: downloadCsv,
});
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;
}
if (
pageCount >= MAXIMUM_REQUESTS ||
records.length === previousRecordCount
) {
downloadCsv(records);
} else {
fetchNextPage();
}
}, [
delayMs,
fetchMoreRecords,
filename,
inflight,
isDownloading,
pageCount,
records,
totalCount,
columns,
maximumRequests,
pageSize,
previousRecordCount,
]);
return { progress, download: () => setIsDownloading(true) };
return { progress, download };
};

View File

@ -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<FieldMetadata>[],
) => void | Promise<void>;
};
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<ExportProgress>({
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?.();
},
};
};

View File

@ -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 <ActionBar selectedIds={selectedRowIds} />;
return (
<ActionBar
selectedIds={selectedRowIds}
totalNumberOfSelectedRecords={totalNumberOfSelectedRecords}
/>
);
};

View File

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

View File

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

View File

@ -109,7 +109,7 @@ export const useRecordTableStates = (recordTableId?: string) => {
isRowSelectedComponentFamilyState,
scopeId,
),
hasUserSelectedAllRowState: extractComponentState(
hasUserSelectedAllRowsState: extractComponentState(
hasUserSelectedAllRowsComponentState,
scopeId,
),

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,9 @@
import { RecordGqlConnection } from '@/object-record/graphql/types/RecordGqlConnection';
export type OnFindManyRecordsCompleted<T> = (
records: T[],
options?: {
pageInfo?: RecordGqlConnection['pageInfo'];
totalCount?: number;
},
) => void;

View File

@ -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<T> = ObjectMetadataItemIdentifier &
RecordGqlOperationVariables & {
onCompleted?: OnFindManyRecordsCompleted<T>;
skip?: boolean;
recordGqlFields?: RecordGqlOperationGqlRecordFields;
fetchPolicy?: WatchQueryFetchPolicy;
};

View File

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

View File

@ -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 (
<>
<StyledContainerActionBar
@ -64,8 +75,8 @@ export const ActionBar = ({ selectedIds = [] }: ActionBarProps) => {
className="action-bar"
ref={wrapperRef}
>
{selectedIds && (
<StyledLabel>{selectedIds.length} selected:</StyledLabel>
{showSelectedNumberLabel && (
<StyledLabel>{selectedNumberLabel} selected:</StyledLabel>
)}
{actionBarEntries.map((item, index) => (
<ActionBarItem key={index} item={item} />

View File

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

View File

@ -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<HTMLElement>) => void;
ConfirmationModal?: ReactNode;
};

View File

@ -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: {

View File

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