☑️ 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; __typename?: string;
edges: RecordGqlEdge[]; edges: RecordGqlEdge[];
pageInfo: { pageInfo: {
__typename?: string;
hasNextPage?: boolean; hasNextPage?: boolean;
hasPreviousPage?: boolean; hasPreviousPage?: boolean;
startCursor?: Nullable<string>; 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 { MockedProvider } from '@apollo/client/testing';
import { act, renderHook } from '@testing-library/react'; import { act, renderHook } from '@testing-library/react';
import { ReactNode } from 'react';
import { RecoilRoot } from 'recoil'; import { RecoilRoot } from 'recoil';
import { import {
@ -23,7 +23,7 @@ const mocks = [
}, },
result: jest.fn(() => ({ result: jest.fn(() => ({
data: { data: {
deletePeople: responseData, deletePeople: [responseData],
}, },
})), })),
}, },
@ -49,7 +49,7 @@ describe('useDeleteManyRecords', () => {
await act(async () => { await act(async () => {
const res = await result.current.deleteManyRecords(people); const res = await result.current.deleteManyRecords(people);
expect(res).toBeDefined(); expect(res).toBeDefined();
expect(res).toHaveProperty('id'); expect(res[0]).toHaveProperty('id');
}); });
expect(mocks[0].result).toHaveBeenCalled(); 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 { MockedProvider } from '@apollo/client/testing';
import { renderHook } from '@testing-library/react'; import { renderHook } from '@testing-library/react';
import { ReactNode } from 'react';
import { RecoilRoot, useSetRecoilState } from 'recoil'; import { RecoilRoot, useSetRecoilState } from 'recoil';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState'; 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 { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems'; import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { useGetRecordFromCache } from '@/object-record/cache/hooks/useGetRecordFromCache'; 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 { useDeleteManyRecordsMutation } from '@/object-record/hooks/useDeleteManyRecordsMutation';
import { getDeleteManyRecordsMutationResponseField } from '@/object-record/utils/getDeleteManyRecordsMutationResponseField'; import { getDeleteManyRecordsMutationResponseField } from '@/object-record/utils/getDeleteManyRecordsMutationResponseField';
import { isDefined } from '~/utils/isDefined'; import { isDefined } from '~/utils/isDefined';
import { sleep } from '~/utils/sleep';
import { capitalize } from '~/utils/string/capitalize'; import { capitalize } from '~/utils/string/capitalize';
type useDeleteOneRecordProps = { type useDeleteOneRecordProps = {
@ -16,6 +18,7 @@ type useDeleteOneRecordProps = {
type DeleteManyRecordsOptions = { type DeleteManyRecordsOptions = {
skipOptimisticEffect?: boolean; skipOptimisticEffect?: boolean;
delayInMsBetweenRequests?: number;
}; };
export const useDeleteManyRecords = ({ export const useDeleteManyRecords = ({
@ -45,40 +48,62 @@ export const useDeleteManyRecords = ({
idsToDelete: string[], idsToDelete: string[],
options?: DeleteManyRecordsOptions, options?: DeleteManyRecordsOptions,
) => { ) => {
const deletedRecords = await apolloClient.mutate({ const numberOfBatches = Math.ceil(
mutation: deleteManyRecordsMutation, idsToDelete.length / DEFAULT_MUTATION_BATCH_SIZE,
variables: { );
filter: { id: { in: idsToDelete } },
},
optimisticResponse: options?.skipOptimisticEffect
? undefined
: {
[mutationResponseField]: idsToDelete.map((idToDelete) => ({
__typename: capitalize(objectNameSingular),
id: idToDelete,
})),
},
update: options?.skipOptimisticEffect
? undefined
: (cache, { data }) => {
const records = data?.[mutationResponseField];
if (!records?.length) return; const deletedRecords = [];
const cachedRecords = records for (let batchIndex = 0; batchIndex < numberOfBatches; batchIndex++) {
.map((record) => getRecordFromCache(record.id, cache)) const batchIds = idsToDelete.slice(
.filter(isDefined); batchIndex * DEFAULT_MUTATION_BATCH_SIZE,
(batchIndex + 1) * DEFAULT_MUTATION_BATCH_SIZE,
);
triggerDeleteRecordsOptimisticEffect({ const deletedRecordsResponse = await apolloClient.mutate({
cache, mutation: deleteManyRecordsMutation,
objectMetadataItem, variables: {
recordsToDelete: cachedRecords, filter: { id: { in: batchIds } },
objectMetadataItems, },
}); 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 }; 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 { useQuery, WatchQueryFetchPolicy } from '@apollo/client';
import { isNonEmptyArray } from '@apollo/client/utilities'; import { useRecoilValue } from 'recoil';
import { isNonEmptyString } from '@sniptt/guards';
import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState'; import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { ObjectMetadataItemIdentifier } from '@/object-metadata/types/ObjectMetadataItemIdentifier'; 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 { RecordGqlOperationFindManyResult } from '@/object-record/graphql/types/RecordGqlOperationFindManyResult';
import { RecordGqlOperationGqlRecordFields } from '@/object-record/graphql/types/RecordGqlOperationGqlRecordFields'; import { RecordGqlOperationGqlRecordFields } from '@/object-record/graphql/types/RecordGqlOperationGqlRecordFields';
import { RecordGqlOperationVariables } from '@/object-record/graphql/types/RecordGqlOperationVariables'; import { RecordGqlOperationVariables } from '@/object-record/graphql/types/RecordGqlOperationVariables';
import { useFetchMoreRecordsWithPagination } from '@/object-record/hooks/useFetchMoreRecordsWithPagination';
import { useFindManyRecordsQuery } from '@/object-record/hooks/useFindManyRecordsQuery'; 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 { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { filterUniqueRecordEdgesByCursor } from '@/object-record/utils/filterUniqueRecordEdgesByCursor'; import { OnFindManyRecordsCompleted } from '@/object-record/types/OnFindManyRecordsCompleted';
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar'; import { getQueryIdentifier } from '@/object-record/utils/getQueryIdentifier';
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 { cursorFamilyState } from '../states/cursorFamilyState'; export type UseFindManyRecordsParams<T> = ObjectMetadataItemIdentifier &
import { hasNextPageFamilyState } from '../states/hasNextPageFamilyState'; RecordGqlOperationVariables & {
import { isFetchingMoreRecordsFamilyState } from '../states/isFetchingMoreRecordsFamilyState'; onError?: (error?: Error) => void;
onCompleted?: OnFindManyRecordsCompleted<T>;
skip?: boolean;
recordGqlFields?: RecordGqlOperationGqlRecordFields;
fetchPolicy?: WatchQueryFetchPolicy;
};
export const useFindManyRecords = <T extends ObjectRecord = ObjectRecord>({ export const useFindManyRecords = <T extends ObjectRecord = ObjectRecord>({
objectNameSingular, objectNameSingular,
filter, filter,
orderBy, orderBy,
limit, limit,
onCompleted,
onError,
skip, skip,
recordGqlFields, recordGqlFields,
fetchPolicy, fetchPolicy,
}: ObjectMetadataItemIdentifier & onError,
RecordGqlOperationVariables & { onCompleted,
onCompleted?: ( }: UseFindManyRecordsParams<T>) => {
records: T[], const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState);
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),
);
const { objectMetadataItem } = useObjectMetadataItem({ const { objectMetadataItem } = useObjectMetadataItem({
objectNameSingular, objectNameSingular,
}); });
const { findManyRecordsQuery } = useFindManyRecordsQuery({ const { findManyRecordsQuery } = useFindManyRecordsQuery({
objectNameSingular, objectNameSingular,
recordGqlFields, recordGqlFields,
}); });
const { enqueueSnackBar } = useSnackBar(); const { handleFindManyRecordsError } = useHandleFindManyRecordsError({
const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState); objectMetadataItem,
handleError: onError,
});
const queryIdentifier = getQueryIdentifier({
objectNameSingular,
filter,
orderBy,
limit,
});
const { handleFindManyRecordsCompleted } = useHandleFindManyRecordsCompleted({
objectMetadataItem,
queryIdentifier,
onCompleted,
});
const { data, loading, error, fetchMore } = const { data, loading, error, fetchMore } =
useQuery<RecordGqlOperationFindManyResult>(findManyRecordsQuery, { useQuery<RecordGqlOperationFindManyResult>(findManyRecordsQuery, {
@ -90,147 +71,21 @@ export const useFindManyRecords = <T extends ObjectRecord = ObjectRecord>({
orderBy, orderBy,
}, },
fetchPolicy: fetchPolicy, fetchPolicy: fetchPolicy,
onCompleted: (data) => { onCompleted: handleFindManyRecordsCompleted,
if (!isDefined(data)) { onError: handleFindManyRecordsError,
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);
},
}); });
const fetchMoreRecords = useCallback(async () => { const { fetchMoreRecords, totalCount, records, hasNextPage } =
// Remote objects does not support hasNextPage. We cannot rely on it to fetch more records. useFetchMoreRecordsWithPagination<T>({
if (hasNextPage || (!isAggregationEnabled(objectMetadataItem) && !error)) { objectNameSingular,
setIsFetchingMoreObjects(true); filter,
orderBy,
try { limit,
await fetchMore({ fetchMore,
variables: { data,
filter, error,
orderBy, objectMetadataItem,
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],
);
return { return {
objectMetadataItem, objectMetadataItem,
@ -239,7 +94,7 @@ export const useFindManyRecords = <T extends ObjectRecord = ObjectRecord>({
loading, loading,
error, error,
fetchMoreRecords, fetchMoreRecords,
queryStateIdentifier: findManyQueryStateIdentifier, queryStateIdentifier: queryIdentifier,
hasNextPage, 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 { isNonEmptyString } from '@sniptt/guards';
import { useRecoilCallback, useRecoilValue, useSetRecoilState } from 'recoil'; import { useCallback, useMemo, useState } from 'react';
import { useRecoilCallback, useSetRecoilState } from 'recoil';
import { import {
IconClick, IconClick,
IconFileExport, IconFileExport,
@ -11,11 +11,11 @@ import {
IconTrash, IconTrash,
} from 'twenty-ui'; } from 'twenty-ui';
import { apiConfigState } from '@/client-config/states/apiConfigState';
import { useFavorites } from '@/favorites/hooks/useFavorites'; import { useFavorites } from '@/favorites/hooks/useFavorites';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; 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 { useExecuteQuickActionOnOneRecord } from '@/object-record/hooks/useExecuteQuickActionOnOneRecord';
import { useDeleteTableData } from '@/object-record/record-index/options/hooks/useDeleteTableData';
import { import {
displayedExportProgress, displayedExportProgress,
useExportTableData, useExportTableData,
@ -32,12 +32,14 @@ type useRecordActionBarProps = {
objectMetadataItem: ObjectMetadataItem; objectMetadataItem: ObjectMetadataItem;
selectedRecordIds: string[]; selectedRecordIds: string[];
callback?: () => void; callback?: () => void;
totalNumberOfRecordsSelected?: number;
}; };
export const useRecordActionBar = ({ export const useRecordActionBar = ({
objectMetadataItem, objectMetadataItem,
selectedRecordIds, selectedRecordIds,
callback, callback,
totalNumberOfRecordsSelected,
}: useRecordActionBarProps) => { }: useRecordActionBarProps) => {
const setContextMenuEntries = useSetRecoilState(contextMenuEntriesState); const setContextMenuEntries = useSetRecoilState(contextMenuEntriesState);
const setActionBarEntriesState = useSetRecoilState(actionBarEntriesState); const setActionBarEntriesState = useSetRecoilState(actionBarEntriesState);
@ -46,17 +48,10 @@ export const useRecordActionBar = ({
const { createFavorite, favorites, deleteFavorite } = useFavorites(); const { createFavorite, favorites, deleteFavorite } = useFavorites();
const { deleteManyRecords } = useDeleteManyRecords({
objectNameSingular: objectMetadataItem.nameSingular,
});
const { executeQuickActionOnOneRecord } = useExecuteQuickActionOnOneRecord({ const { executeQuickActionOnOneRecord } = useExecuteQuickActionOnOneRecord({
objectNameSingular: objectMetadataItem.nameSingular, objectNameSingular: objectMetadataItem.nameSingular,
}); });
const apiConfig = useRecoilValue(apiConfigState);
const maxRecords = apiConfig?.mutationMaximumAffectedRecords;
const handleFavoriteButtonClick = useRecoilCallback( const handleFavoriteButtonClick = useRecoilCallback(
({ snapshot }) => ({ snapshot }) =>
() => { () => {
@ -92,24 +87,17 @@ export const useRecordActionBar = ({
], ],
); );
const handleDeleteClick = useCallback(async () => { const baseTableDataParams = {
callback?.(); delayMs: 100,
selectedRecordIds.forEach((recordId) => { objectNameSingular: objectMetadataItem.nameSingular,
const foundFavorite = favorites?.find( recordIndexId: objectMetadataItem.namePlural,
(favorite) => favorite.recordId === recordId, };
);
if (foundFavorite !== undefined) { const { deleteTableData } = useDeleteTableData(baseTableDataParams);
deleteFavorite(foundFavorite.id);
} const handleDeleteClick = useCallback(() => {
}); deleteTableData();
await deleteManyRecords(selectedRecordIds); }, [deleteTableData]);
}, [
callback,
deleteManyRecords,
selectedRecordIds,
favorites,
deleteFavorite,
]);
const handleExecuteQuickActionOnClick = useCallback(async () => { const handleExecuteQuickActionOnClick = useCallback(async () => {
callback?.(); callback?.();
@ -121,62 +109,63 @@ export const useRecordActionBar = ({
}, [callback, executeQuickActionOnOneRecord, selectedRecordIds]); }, [callback, executeQuickActionOnOneRecord, selectedRecordIds]);
const { progress, download } = useExportTableData({ const { progress, download } = useExportTableData({
delayMs: 100, ...baseTableDataParams,
filename: `${objectMetadataItem.nameSingular}.csv`, filename: `${objectMetadataItem.nameSingular}.csv`,
objectNameSingular: objectMetadataItem.nameSingular,
recordIndexId: objectMetadataItem.namePlural,
}); });
const isRemoteObject = objectMetadataItem.isRemote; const isRemoteObject = objectMetadataItem.isRemote;
const baseActions: ContextMenuEntry[] = useMemo( const numberOfSelectedRecords =
() => [ totalNumberOfRecordsSelected ?? selectedRecordIds.length;
{ const canDelete =
label: displayedExportProgress(progress), !isRemoteObject && numberOfSelectedRecords < DELETE_MAX_COUNT;
Icon: IconFileExport,
accent: 'default',
onClick: () => download(),
},
],
[download, progress],
);
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', label: 'Delete',
Icon: IconTrash, Icon: IconTrash,
accent: 'danger', accent: 'danger',
onClick: () => setIsDeleteRecordsModalOpen(true), onClick: () => {
setIsDeleteRecordsModalOpen(true);
handleDeleteClick();
},
ConfirmationModal: ( ConfirmationModal: (
<ConfirmationModal <ConfirmationModal
isOpen={isDeleteRecordsModalOpen} isOpen={isDeleteRecordsModalOpen}
setIsOpen={setIsDeleteRecordsModalOpen} setIsOpen={setIsDeleteRecordsModalOpen}
title={`Delete ${selectedRecordIds.length} ${ title={`Delete ${numberOfSelectedRecords} ${
selectedRecordIds.length === 1 ? `record` : 'records' numberOfSelectedRecords === 1 ? `record` : 'records'
}`} }`}
subtitle={`This action cannot be undone. This will permanently delete ${ subtitle={`This action cannot be undone. This will permanently delete ${
selectedRecordIds.length === 1 numberOfSelectedRecords === 1
? 'this record' ? 'this record'
: 'these records' : 'these records'
}`} }`}
onConfirmClick={() => handleDeleteClick()} onConfirmClick={() => handleDeleteClick()}
deleteButtonText={`Delete ${ deleteButtonText={`Delete ${
selectedRecordIds.length > 1 ? 'Records' : 'Record' numberOfSelectedRecords > 1 ? 'Records' : 'Record'
}`} }`}
/> />
), ),
}, } satisfies ContextMenuEntry)
] : undefined,
: [], ].filter(isDefined),
[ [
download,
progress,
canDelete,
handleDeleteClick, handleDeleteClick,
selectedRecordIds,
isDeleteRecordsModalOpen, isDeleteRecordsModalOpen,
setIsDeleteRecordsModalOpen, numberOfSelectedRecords,
maxRecords,
], ],
); );
@ -193,8 +182,7 @@ export const useRecordActionBar = ({
return { return {
setContextMenuEntries: useCallback(() => { setContextMenuEntries: useCallback(() => {
setContextMenuEntries([ setContextMenuEntries([
...(isRemoteObject ? [] : deletionActions), ...menuActions,
...baseActions,
...(!isRemoteObject && isFavorite && hasOnlyOneRecordSelected ...(!isRemoteObject && isFavorite && hasOnlyOneRecordSelected
? [ ? [
{ {
@ -215,8 +203,7 @@ export const useRecordActionBar = ({
: []), : []),
]); ]);
}, [ }, [
baseActions, menuActions,
deletionActions,
handleFavoriteButtonClick, handleFavoriteButtonClick,
hasOnlyOneRecordSelected, hasOnlyOneRecordSelected,
isFavorite, isFavorite,
@ -245,15 +232,12 @@ export const useRecordActionBar = ({
}, },
] ]
: []), : []),
...(isRemoteObject ? [] : deletionActions), ...menuActions,
...baseActions,
]); ]);
}, [ }, [
baseActions, menuActions,
dataExecuteQuickActionOnmentEnabled, dataExecuteQuickActionOnmentEnabled,
deletionActions,
handleExecuteQuickActionOnClick, handleExecuteQuickActionOnClick,
isRemoteObject,
setActionBarEntriesState, 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 { useRecordActionBar } from '@/object-record/record-action-bar/hooks/useRecordActionBar';
import { useHandleToggleColumnFilter } from '@/object-record/record-index/hooks/useHandleToggleColumnFilter'; import { useHandleToggleColumnFilter } from '@/object-record/record-index/hooks/useHandleToggleColumnFilter';
import { useHandleToggleColumnSort } from '@/object-record/record-index/hooks/useHandleToggleColumnSort'; 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 { useRecordTable } from '@/object-record/record-table/hooks/useRecordTable';
import { useViewStates } from '@/views/hooks/internal/useViewStates';
import { useSetRecordCountInCurrentView } from '@/views/hooks/useSetRecordCountInCurrentView'; import { useSetRecordCountInCurrentView } from '@/views/hooks/useSetRecordCountInCurrentView';
type RecordIndexTableContainerEffectProps = { type RecordIndexTableContainerEffectProps = {
@ -45,12 +47,31 @@ export const RecordIndexTableContainerEffect = ({
setAvailableTableColumns(columnDefinitions); setAvailableTableColumns(columnDefinitions);
}, [columnDefinitions, setAvailableTableColumns]); }, [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 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({ const { setActionBarEntries, setContextMenuEntries } = useRecordActionBar({
objectMetadataItem, objectMetadataItem,
selectedRecordIds: selectedRowIds, selectedRecordIds: selectedRowIds,
callback: resetTableRowSelection, callback: resetTableRowSelection,
totalNumberOfRecordsSelected: numSelected,
}); });
const handleToggleColumnFilter = useHandleToggleColumnFilter({ 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 { 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 { 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 { ColumnDefinition } from '@/object-record/record-table/types/ColumnDefinition';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { FieldMetadataType } from '~/generated/graphql'; import { FieldMetadataType } from '~/generated/graphql';
import { isDefined } from '~/utils/isDefined'; import { isDefined } from '~/utils/isDefined';
import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull'; import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
import { sleep } from '~/utils/sleep';
import { useFindManyParams } from '../../hooks/useLoadRecordIndexTable';
export const download = (blob: Blob, filename: string) => { export const download = (blob: Blob, filename: string) => {
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
@ -127,13 +126,8 @@ const downloader = (mimeType: string, generator: GenerateExport) => {
export const csvDownloader = downloader('text/csv', generateCsv); export const csvDownloader = downloader('text/csv', generateCsv);
type UseExportTableDataOptions = { type UseExportTableDataOptions = Omit<UseTableDataOptions, 'callback'> & {
delayMs: number;
filename: string; filename: string;
maximumRequests?: number;
objectNameSingular: string;
pageSize?: number;
recordIndexId: string;
}; };
export const useExportTableData = ({ export const useExportTableData = ({
@ -144,100 +138,22 @@ export const useExportTableData = ({
pageSize = 30, pageSize = 30,
recordIndexId, recordIndexId,
}: UseExportTableDataOptions) => { }: UseExportTableDataOptions) => {
const [isDownloading, setIsDownloading] = useState(false); const downloadCsv = useMemo(
const [inflight, setInflight] = useState(false); () =>
const [pageCount, setPageCount] = useState(0); (rows: ObjectRecord[], columns: ColumnDefinition<FieldMetadata>[]) => {
const [progress, setProgress] = useState<ExportProgress>({ csvDownloader(filename, { rows, columns });
displayType: 'number', },
}); [filename],
const [previousRecordCount, setPreviousRecordCount] = useState(0);
const { visibleTableColumnsSelector, selectedRowIdsSelector } =
useRecordTableStates(recordIndexId);
const columns = useRecoilValue(visibleTableColumnsSelector());
const selectedRowIds = useRecoilValue(selectedRowIdsSelector());
const hasSelectedRows = selectedRowIds.length > 0;
const findManyRecordsParams = useFindManyParams(
objectNameSingular,
recordIndexId,
); );
const selectedFindManyParams = { const { getTableData: download, progress } = useTableData({
...findManyRecordsParams, delayMs,
filter: { maximumRequests,
...findManyRecordsParams.filter, objectNameSingular,
id: { pageSize,
in: selectedRowIds, recordIndexId,
}, callback: downloadCsv,
},
};
const usedFindManyParams = hasSelectedRows
? selectedFindManyParams
: findManyRecordsParams;
// Todo: this needs to be done on click on the Export not button, not to be reactive. Use Lazy query for example
const { totalCount, records, fetchMoreRecords } = useFindManyRecords({
...usedFindManyParams,
limit: pageSize,
}); });
useEffect(() => { return { progress, download };
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) };
}; };

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 { useRecordTableStates } from '@/object-record/record-table/hooks/internal/useRecordTableStates';
import { ActionBar } from '@/ui/navigation/action-bar/components/ActionBar'; import { ActionBar } from '@/ui/navigation/action-bar/components/ActionBar';
import { useViewStates } from '@/views/hooks/internal/useViewStates';
export const RecordTableActionBar = ({ export const RecordTableActionBar = ({
recordTableId, recordTableId,
}: { }: {
recordTableId: string; 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 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) { if (!selectedRowIds.length) {
return null; 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 { TableHotkeyScope } from '@/object-record/record-table/types/TableHotkeyScope';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys'; import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { useClickOutsideListener } from '@/ui/utilities/pointer-event/hooks/useClickOutsideListener'; 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 = { type RecordTableInternalEffectProps = {
recordTableId: string; recordTableId: string;
@ -30,6 +33,7 @@ export const RecordTableInternalEffect = ({
callback: () => { callback: () => {
leaveTableFocus(); leaveTableFocus();
}, },
mode: ClickOutsideMode.comparePixels,
}); });
useScopedHotkeys( useScopedHotkeys(

View File

@ -8,12 +8,17 @@ import { TableHotkeyScope } from '../../types/TableHotkeyScope';
import { useCloseCurrentTableCellInEditMode } from './useCloseCurrentTableCellInEditMode'; import { useCloseCurrentTableCellInEditMode } from './useCloseCurrentTableCellInEditMode';
import { useDisableSoftFocus } from './useDisableSoftFocus'; import { useDisableSoftFocus } from './useDisableSoftFocus';
import { useSetHasUserSelectedAllRows } from './useSetAllRowSelectedState';
export const useLeaveTableFocus = (recordTableId?: string) => { export const useLeaveTableFocus = (recordTableId?: string) => {
const disableSoftFocus = useDisableSoftFocus(recordTableId); const disableSoftFocus = useDisableSoftFocus(recordTableId);
const closeCurrentCellInEditMode = const closeCurrentCellInEditMode =
useCloseCurrentTableCellInEditMode(recordTableId); useCloseCurrentTableCellInEditMode(recordTableId);
const setHasUserSelectedAllRows = useSetHasUserSelectedAllRows(recordTableId);
const selectAllRows = useSetHasUserSelectedAllRows(recordTableId);
const { isSoftFocusActiveState } = useRecordTableStates(recordTableId); const { isSoftFocusActiveState } = useRecordTableStates(recordTableId);
return useRecoilCallback( return useRecoilCallback(
@ -38,7 +43,15 @@ export const useLeaveTableFocus = (recordTableId?: string) => {
closeCurrentCellInEditMode(); closeCurrentCellInEditMode();
disableSoftFocus(); 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, isRowSelectedComponentFamilyState,
scopeId, scopeId,
), ),
hasUserSelectedAllRowState: extractComponentState( hasUserSelectedAllRowsState: extractComponentState(
hasUserSelectedAllRowsComponentState, hasUserSelectedAllRowsComponentState,
scopeId, 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'; import { getSnapshotValue } from '@/ui/utilities/recoil-scope/utils/getSnapshotValue';
export const useResetTableRowSelection = (recordTableId?: string) => { export const useResetTableRowSelection = (recordTableId?: string) => {
const { tableRowIdsState, isRowSelectedFamilyState } = const {
useRecordTableStates(recordTableId); tableRowIdsState,
isRowSelectedFamilyState,
hasUserSelectedAllRowsState,
} = useRecordTableStates(recordTableId);
return useRecoilCallback( return useRecoilCallback(
({ snapshot, set }) => ({ snapshot, set }) =>
@ -15,7 +18,9 @@ export const useResetTableRowSelection = (recordTableId?: string) => {
for (const rowId of tableRowIds) { for (const rowId of tableRowIds) {
set(isRowSelectedFamilyState(rowId), false); 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'; import { useRecordTableStates } from '@/object-record/record-table/hooks/internal/useRecordTableStates';
export const useSetHasUserSelectedAllRows = (recordTableId?: string) => { export const useSetHasUserSelectedAllRows = (recordTableId?: string) => {
const { hasUserSelectedAllRowState: hasUserSelectedAllRowFamilyState } = const { hasUserSelectedAllRowsState } = useRecordTableStates(recordTableId);
useRecordTableStates(recordTableId);
return useRecoilCallback( return useRecoilCallback(
({ set }) => ({ set }) =>
(selected: boolean) => { (selected: boolean) => {
set(hasUserSelectedAllRowFamilyState, selected); set(hasUserSelectedAllRowsState, selected);
}, },
[hasUserSelectedAllRowFamilyState], [hasUserSelectedAllRowsState],
); );
}; };

View File

@ -19,7 +19,7 @@ export const useSetRecordTableData = ({
tableRowIdsState, tableRowIdsState,
numberOfTableRowsState, numberOfTableRowsState,
isRowSelectedFamilyState, isRowSelectedFamilyState,
hasUserSelectedAllRowState, hasUserSelectedAllRowsState,
} = useRecordTableStates(recordTableId); } = useRecordTableStates(recordTableId);
return useRecoilCallback( return useRecoilCallback(
@ -39,7 +39,7 @@ export const useSetRecordTableData = ({
const hasUserSelectedAllRows = getSnapshotValue( const hasUserSelectedAllRows = getSnapshotValue(
snapshot, snapshot,
hasUserSelectedAllRowState, hasUserSelectedAllRowsState,
); );
const entityIds = newEntityArray.map((entity) => entity.id); const entityIds = newEntityArray.map((entity) => entity.id);
@ -62,7 +62,7 @@ export const useSetRecordTableData = ({
tableRowIdsState, tableRowIdsState,
onEntityCountChange, onEntityCountChange,
isRowSelectedFamilyState, isRowSelectedFamilyState,
hasUserSelectedAllRowState, hasUserSelectedAllRowsState,
], ],
); );
}; };

View File

@ -45,6 +45,7 @@ export const useRecordTable = (props?: useRecordTableProps) => {
onToggleColumnFilterState, onToggleColumnFilterState,
onToggleColumnSortState, onToggleColumnSortState,
pendingRecordIdState, pendingRecordIdState,
hasUserSelectedAllRowsState,
} = useRecordTableStates(recordTableId); } = useRecordTableStates(recordTableId);
const setAvailableTableColumns = useRecoilCallback( const setAvailableTableColumns = useRecoilCallback(
@ -226,5 +227,6 @@ export const useRecordTable = (props?: useRecordTableProps) => {
setOnToggleColumnFilter, setOnToggleColumnFilter,
setOnToggleColumnSort, setOnToggleColumnSort,
setPendingRecordId, 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 { 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 { createComponentReadOnlySelector } from '@/ui/utilities/state/component-state/utils/createComponentReadOnlySelector';
import { AllRowsSelectedStatus } from '../../types/AllRowSelectedStatus'; import { AllRowsSelectedStatus } from '../../types/AllRowSelectedStatus';
@ -10,7 +10,7 @@ export const allRowsSelectedStatusComponentSelector =
get: get:
({ scopeId }) => ({ scopeId }) =>
({ get }) => { ({ get }) => {
const numberOfRows = get(numberOfTableRowsComponentState({ scopeId })); const tableRowIds = get(tableRowIdsComponentState({ scopeId }));
const selectedRowIds = get( const selectedRowIds = get(
selectedRowIdsComponentSelector({ scopeId }), selectedRowIdsComponentSelector({ scopeId }),
@ -21,7 +21,7 @@ export const allRowsSelectedStatusComponentSelector =
const allRowsSelectedStatus = const allRowsSelectedStatus =
numberOfSelectedRows === 0 numberOfSelectedRows === 0
? 'none' ? 'none'
: numberOfRows === numberOfSelectedRows : selectedRowIds.length === tableRowIds.length
? 'all' ? 'all'
: 'some'; : '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 styled from '@emotion/styled';
import { useEffect, useRef } from 'react';
import { useRecoilValue, useSetRecoilState } from 'recoil'; import { useRecoilValue, useSetRecoilState } from 'recoil';
import { actionBarEntriesState } from '@/ui/navigation/action-bar/states/actionBarEntriesState'; import { actionBarEntriesState } from '@/ui/navigation/action-bar/states/actionBarEntriesState';
import { contextMenuIsOpenState } from '@/ui/navigation/context-menu/states/contextMenuIsOpenState'; import { contextMenuIsOpenState } from '@/ui/navigation/context-menu/states/contextMenuIsOpenState';
import SharedNavigationModal from '@/ui/navigation/shared/components/NavigationModal'; import SharedNavigationModal from '@/ui/navigation/shared/components/NavigationModal';
import { isDefined } from '~/utils/isDefined';
import { ActionBarItem } from './ActionBarItem'; import { ActionBarItem } from './ActionBarItem';
type ActionBarProps = { type ActionBarProps = {
selectedIds?: string[]; selectedIds?: string[];
totalNumberOfSelectedRecords?: number;
}; };
const StyledContainerActionBar = styled.div` const StyledContainerActionBar = styled.div`
@ -40,7 +42,10 @@ const StyledLabel = styled.div`
padding-right: ${({ theme }) => theme.spacing(2)}; padding-right: ${({ theme }) => theme.spacing(2)};
`; `;
export const ActionBar = ({ selectedIds = [] }: ActionBarProps) => { export const ActionBar = ({
selectedIds = [],
totalNumberOfSelectedRecords,
}: ActionBarProps) => {
const setContextMenuOpenState = useSetRecoilState(contextMenuIsOpenState); const setContextMenuOpenState = useSetRecoilState(contextMenuIsOpenState);
useEffect(() => { useEffect(() => {
@ -57,6 +62,12 @@ export const ActionBar = ({ selectedIds = [] }: ActionBarProps) => {
return null; return null;
} }
const selectedNumberLabel =
totalNumberOfSelectedRecords ?? selectedIds?.length;
const showSelectedNumberLabel =
isDefined(totalNumberOfSelectedRecords) || Array.isArray(selectedIds);
return ( return (
<> <>
<StyledContainerActionBar <StyledContainerActionBar
@ -64,8 +75,8 @@ export const ActionBar = ({ selectedIds = [] }: ActionBarProps) => {
className="action-bar" className="action-bar"
ref={wrapperRef} ref={wrapperRef}
> >
{selectedIds && ( {showSelectedNumberLabel && (
<StyledLabel>{selectedIds.length} selected:</StyledLabel> <StyledLabel>{selectedNumberLabel} selected:</StyledLabel>
)} )}
{actionBarEntries.map((item, index) => ( {actionBarEntries.map((item, index) => (
<ActionBarItem key={index} item={item} /> <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 = ContextMenuEntry & {
export type ActionBarEntry = {
label: string;
Icon: IconComponent;
accent?: MenuItemAccent;
onClick?: () => void;
subActions?: ActionBarEntry[]; subActions?: ActionBarEntry[];
ConfirmationModal?: JSX.Element;
}; };

View File

@ -1,3 +1,4 @@
import { MouseEvent, ReactNode } from 'react';
import { IconComponent } from 'twenty-ui'; import { IconComponent } from 'twenty-ui';
import { MenuItemAccent } from '@/ui/navigation/menu-item/types/MenuItemAccent'; import { MenuItemAccent } from '@/ui/navigation/menu-item/types/MenuItemAccent';
@ -6,5 +7,6 @@ export type ContextMenuEntry = {
label: string; label: string;
Icon: IconComponent; Icon: IconComponent;
accent?: MenuItemAccent; accent?: MenuItemAccent;
onClick: () => void; onClick?: (event?: MouseEvent<HTMLElement>) => void;
ConfirmationModal?: ReactNode;
}; };

View File

@ -7,6 +7,7 @@ import {
} from '~/generated-metadata/graphql'; } from '~/generated-metadata/graphql';
import { mockedStandardObjectMetadataQueryResult } from '~/testing/mock-data/generated/standard-metadata-query-result'; import { mockedStandardObjectMetadataQueryResult } from '~/testing/mock-data/generated/standard-metadata-query-result';
// TODO: replace with new mock
const customObjectMetadataItemEdge: ObjectEdge = { const customObjectMetadataItemEdge: ObjectEdge = {
__typename: 'objectEdge', __typename: 'objectEdge',
node: { node: {

View File

@ -1,3 +1,5 @@
import { RecordGqlConnection } from '@/object-record/graphql/types/RecordGqlConnection';
export const getPeopleMock = () => { export const getPeopleMock = () => {
const peopleMock = peopleQueryResult.people.edges.map((edge) => edge.node); const peopleMock = peopleQueryResult.people.edges.map((edge) => edge.node);
@ -22,7 +24,7 @@ export const mockedEmptyPersonData = {
__typename: 'Person', __typename: 'Person',
}; };
export const peopleQueryResult = { export const peopleQueryResult: { people: RecordGqlConnection } = {
people: { people: {
__typename: 'PersonConnection', __typename: 'PersonConnection',
totalCount: 15, totalCount: 15,