☑️ 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:
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1 @@
|
||||
export const DEFAULT_MUTATION_BATCH_SIZE = 30;
|
||||
@ -0,0 +1 @@
|
||||
export const DEFAULT_QUERY_PAGE_SIZE = 30;
|
||||
@ -0,0 +1 @@
|
||||
export const DELETE_MAX_COUNT = 10000;
|
||||
@ -6,6 +6,7 @@ export type RecordGqlConnection = {
|
||||
__typename?: string;
|
||||
edges: RecordGqlEdge[];
|
||||
pageInfo: {
|
||||
__typename?: string;
|
||||
hasNextPage?: boolean;
|
||||
hasPreviousPage?: boolean;
|
||||
startCursor?: Nullable<string>;
|
||||
|
||||
@ -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),
|
||||
};
|
||||
@ -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();
|
||||
|
||||
@ -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),
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -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';
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
@ -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,
|
||||
]),
|
||||
};
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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 };
|
||||
};
|
||||
@ -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 };
|
||||
};
|
||||
|
||||
@ -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?.();
|
||||
},
|
||||
};
|
||||
};
|
||||
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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,
|
||||
],
|
||||
);
|
||||
};
|
||||
|
||||
@ -109,7 +109,7 @@ export const useRecordTableStates = (recordTableId?: string) => {
|
||||
isRowSelectedComponentFamilyState,
|
||||
scopeId,
|
||||
),
|
||||
hasUserSelectedAllRowState: extractComponentState(
|
||||
hasUserSelectedAllRowsState: extractComponentState(
|
||||
hasUserSelectedAllRowsComponentState,
|
||||
scopeId,
|
||||
),
|
||||
|
||||
@ -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],
|
||||
);
|
||||
};
|
||||
|
||||
@ -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],
|
||||
);
|
||||
};
|
||||
|
||||
@ -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,
|
||||
],
|
||||
);
|
||||
};
|
||||
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@ -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';
|
||||
|
||||
|
||||
@ -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;
|
||||
@ -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;
|
||||
};
|
||||
@ -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;
|
||||
@ -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} />
|
||||
|
||||
@ -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;
|
||||
};
|
||||
|
||||
@ -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;
|
||||
};
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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,
|
||||
|
||||
Reference in New Issue
Block a user