Add useUpdateManyRecords hook and message folders sync status mutation (#16694)

- Added new `useUpdateManyRecords` hook for batch record updates with
optimistic cache updates
- Added `updateMessageFoldersSyncStatus` hook for managing message
folder sync state
- Redesigned Message Folders List with BreadCrumb and Animations
This commit is contained in:
neo773
2025-12-19 21:52:59 +05:30
committed by GitHub
parent a19b9e1cb8
commit 024ee152a0
20 changed files with 1490 additions and 222 deletions
@@ -0,0 +1,44 @@
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { gql } from '@apollo/client';
import { getMockPersonRecord } from '~/testing/mock-data/people';
export const query = gql`
mutation UpdateManyPeople(
$filter: PersonFilterInput!
$data: PersonUpdateInput!
) {
updatePeople(filter: $filter, data: $data) {
id
__typename
}
}
`;
export const personIds = [
'a7286b9a-c039-4a89-9567-2dfa7953cda9',
'37faabcd-cb39-4a0a-8618-7e3fda9afca0',
];
export const personRecords = personIds.map<ObjectRecord>((personId, index) =>
getMockPersonRecord({ id: personId }, index),
);
export const updateInput = {
city: 'Updated City',
};
export const variables = {
filter: {
id: {
in: personIds,
},
},
data: updateInput,
};
export const updatedPersonRecords = personIds.map<ObjectRecord>(
(personId, index) =>
getMockPersonRecord({ id: personId, city: 'Updated City' }, index),
);
export const responseData = personIds.map((personId) => ({ id: personId }));
@@ -0,0 +1,225 @@
import { renderHook, waitFor } from '@testing-library/react';
import { getRecordFromCache } from '@/object-record/cache/utils/getRecordFromCache';
import { updateRecordFromCache } from '@/object-record/cache/utils/updateRecordFromCache';
import { generateDepthRecordGqlFieldsFromRecord } from '@/object-record/graphql/record-gql-fields/utils/generateDepthRecordGqlFieldsFromRecord';
import {
personIds,
personRecords,
query,
responseData,
updateInput,
variables,
} from '@/object-record/hooks/__mocks__/useUpdateManyRecords';
import { useRefetchAggregateQueries } from '@/object-record/hooks/useRefetchAggregateQueries';
import { useUpdateManyRecords } from '@/object-record/hooks/useUpdateManyRecords';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { InMemoryCache } from '@apollo/client';
import { type MockedResponse } from '@apollo/client/testing';
import { act } from 'react';
import { getJestMetadataAndApolloMocksWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksWrapper';
import { getMockPersonObjectMetadataItem } from '~/testing/mock-data/people';
import { generatedMockObjectMetadataItems } from '~/testing/utils/generatedMockObjectMetadataItems';
const getDefaultMocks = (
overrides?: Partial<MockedResponse>,
): MockedResponse[] => [
{
request: {
query,
variables,
},
result: jest.fn(() => ({
data: {
updatePeople: responseData,
},
})),
...overrides,
},
];
jest.mock('@/object-record/hooks/useRefetchAggregateQueries');
const mockRefetchAggregateQueries = jest.fn();
(useRefetchAggregateQueries as jest.Mock).mockReturnValue({
refetchAggregateQueries: mockRefetchAggregateQueries,
});
const objectMetadataItem = getMockPersonObjectMetadataItem();
const objectMetadataItems = generatedMockObjectMetadataItems;
const expectedCachedRecordsWithUpdatedCity = personRecords.map(
(personRecord) => ({
...personRecord,
city: 'Updated City',
}),
);
describe('useUpdateManyRecords', () => {
let cache: InMemoryCache;
const assertCachedRecordsMatch = (expectedRecords: ObjectRecord[]) => {
expectedRecords.forEach((expectedRecord) => {
const cachedRecord = getRecordFromCache({
cache,
objectMetadataItem,
objectMetadataItems,
recordId: expectedRecord.id,
objectPermissionsByObjectMetadataId: {},
});
expect(cachedRecord).not.toBeNull();
if (cachedRecord === null) throw new Error('Should never occur');
expect(expectedRecord).toMatchObject(cachedRecord);
});
};
const assertCachedRecordsIsNull = (recordIds: string[]) =>
recordIds.forEach((recordId) =>
expect(
getRecordFromCache({
cache,
objectMetadataItem,
objectMetadataItems,
recordId,
objectPermissionsByObjectMetadataId: {},
}),
).toBeNull(),
);
beforeEach(() => {
jest.clearAllMocks();
cache = new InMemoryCache();
});
describe('A. Starting from empty cache', () => {
it('1. Should handle update many records when cache is empty', async () => {
const apolloMocks = getDefaultMocks();
const { result } = renderHook(
() => useUpdateManyRecords({ objectNameSingular: 'person' }),
{
wrapper: getJestMetadataAndApolloMocksWrapper({
apolloMocks,
cache,
}),
},
);
await act(async () => {
const res = await result.current.updateManyRecords({
recordIdsToUpdate: personIds,
updateOneRecordInput: updateInput,
});
expect(res).toEqual(responseData);
assertCachedRecordsIsNull(personIds);
});
expect(apolloMocks[0].result).toHaveBeenCalled();
expect(mockRefetchAggregateQueries).toHaveBeenCalledTimes(1);
});
});
describe('B. Starting from filled cache', () => {
beforeEach(() => {
personRecords.forEach((record) =>
updateRecordFromCache({
cache,
objectMetadataItem,
objectMetadataItems,
record,
recordGqlFields: generateDepthRecordGqlFieldsFromRecord({
objectMetadataItems: generatedMockObjectMetadataItems,
objectMetadataItem,
record,
depth: 1,
}),
objectPermissionsByObjectMetadataId: {},
}),
);
});
it('1. Should handle optimistic behavior after many successful records update', async () => {
const apolloMocks = getDefaultMocks();
const { result } = renderHook(
() => useUpdateManyRecords({ objectNameSingular: 'person' }),
{
wrapper: getJestMetadataAndApolloMocksWrapper({
apolloMocks,
cache,
}),
},
);
await act(async () => {
const res = await result.current.updateManyRecords({
recordIdsToUpdate: personIds,
updateOneRecordInput: updateInput,
});
expect(res).toEqual(responseData);
assertCachedRecordsMatch(expectedCachedRecordsWithUpdatedCity);
});
expect(apolloMocks[0].result).toHaveBeenCalled();
expect(mockRefetchAggregateQueries).toHaveBeenCalledTimes(1);
});
it('2. Should handle optimistic behavior before send many record update', async () => {
const apolloMocks = getDefaultMocks();
const { result } = renderHook(
() => useUpdateManyRecords({ objectNameSingular: 'person' }),
{
wrapper: getJestMetadataAndApolloMocksWrapper({
apolloMocks: getDefaultMocks({
delay: Number.POSITIVE_INFINITY,
}),
cache,
}),
},
);
await act(async () => {
result.current.updateManyRecords({
recordIdsToUpdate: personIds,
updateOneRecordInput: updateInput,
});
await waitFor(() =>
assertCachedRecordsMatch(expectedCachedRecordsWithUpdatedCity),
);
});
expect(apolloMocks[0].result).not.toHaveBeenCalled();
expect(mockRefetchAggregateQueries).not.toHaveBeenCalled();
});
it('3. Should rollback optimistic behavior after failing to update many records', async () => {
const apolloMocks = getDefaultMocks();
const { result } = renderHook(
() => useUpdateManyRecords({ objectNameSingular: 'person' }),
{
wrapper: getJestMetadataAndApolloMocksWrapper({
apolloMocks: getDefaultMocks({
error: new Error('Internal server error'),
}),
cache,
}),
},
);
await act(async () => {
try {
await result.current.updateManyRecords({
recordIdsToUpdate: personIds,
updateOneRecordInput: updateInput,
});
fail('Should have thrown an error');
} catch (e) {
expect(e).toMatchInlineSnapshot(
`[ApolloError: Internal server error]`,
);
assertCachedRecordsMatch(personRecords);
}
});
expect(apolloMocks[0].result).not.toHaveBeenCalled();
expect(mockRefetchAggregateQueries).not.toHaveBeenCalled();
});
});
});
@@ -0,0 +1,44 @@
import { renderHook } from '@testing-library/react';
import { print } from 'graphql';
import { useUpdateManyRecordsMutation } from '@/object-record/hooks/useUpdateManyRecordsMutation';
import { getJestMetadataAndApolloMocksWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksWrapper';
const expectedQueryTemplate = `
mutation UpdateManyPeople($filter: PersonFilterInput!, $data: PersonUpdateInput!) {
updatePeople(filter: $filter, data: $data) {
id
}
}
`.replace(/\s/g, '');
const Wrapper = getJestMetadataAndApolloMocksWrapper({
apolloMocks: [],
});
describe('useUpdateManyRecordsMutation', () => {
it('should return a valid updateManyRecordsMutation', () => {
const objectNameSingular = 'person';
const { result } = renderHook(
() =>
useUpdateManyRecordsMutation({
objectNameSingular,
}),
{
wrapper: Wrapper,
},
);
const { updateManyRecordsMutation } = result.current;
expect(updateManyRecordsMutation).toBeDefined();
const printedReceivedQuery = print(updateManyRecordsMutation).replace(
/\s/g,
'',
);
expect(printedReceivedQuery).toEqual(expectedQueryTemplate);
});
});
@@ -0,0 +1,268 @@
import { triggerUpdateRecordOptimisticEffectByBatch } from '@/apollo/optimistic-effect/utils/triggerUpdateRecordOptimisticEffectByBatch';
import { apiConfigState } from '@/client-config/states/apiConfigState';
import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { useGetRecordFromCache } from '@/object-record/cache/hooks/useGetRecordFromCache';
import { getObjectTypename } from '@/object-record/cache/utils/getObjectTypename';
import { getRecordNodeFromRecord } from '@/object-record/cache/utils/getRecordNodeFromRecord';
import { updateRecordFromCache } from '@/object-record/cache/utils/updateRecordFromCache';
import { DEFAULT_MUTATION_BATCH_SIZE } from '@/object-record/constants/DefaultMutationBatchSize';
import { useGenerateDepthRecordGqlFieldsFromObject } from '@/object-record/graphql/record-gql-fields/hooks/useGenerateDepthRecordGqlFieldsFromObject';
import { generateDepthRecordGqlFieldsFromRecord } from '@/object-record/graphql/record-gql-fields/utils/generateDepthRecordGqlFieldsFromRecord';
import { type RecordGqlNode } from '@/object-record/graphql/types/RecordGqlNode';
import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions';
import { useRefetchAggregateQueries } from '@/object-record/hooks/useRefetchAggregateQueries';
import { useRegisterObjectOperation } from '@/object-record/hooks/useRegisterObjectOperation';
import { useUpdateManyRecordsMutation } from '@/object-record/hooks/useUpdateManyRecordsMutation';
import { useUpsertRecordsInStore } from '@/object-record/record-store/hooks/useUpsertRecordsInStore';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { getUpdateManyRecordsMutationResponseField } from '@/object-record/utils/getUpdateManyRecordsMutationResponseField';
import { sanitizeRecordInput } from '@/object-record/utils/sanitizeRecordInput';
import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { sleep } from '~/utils/sleep';
type UseUpdateManyRecordsProps = {
objectNameSingular: string;
recordGqlFields?: Record<string, any>;
};
export type UpdateManyRecordsProps<T extends ObjectRecord = ObjectRecord> = {
recordIdsToUpdate: string[];
updateOneRecordInput: Partial<Omit<T, 'id'>>;
skipOptimisticEffect?: boolean;
delayInMsBetweenRequests?: number;
};
export const useUpdateManyRecords = <T extends ObjectRecord = ObjectRecord>({
objectNameSingular,
recordGqlFields,
}: UseUpdateManyRecordsProps) => {
const { registerObjectOperation } = useRegisterObjectOperation();
const { upsertRecordsInStore } = useUpsertRecordsInStore();
const apiConfig = useRecoilValue(apiConfigState);
const mutationPageSize =
apiConfig?.mutationMaximumAffectedRecords ?? DEFAULT_MUTATION_BATCH_SIZE;
const apolloCoreClient = useApolloCoreClient();
const { objectMetadataItem } = useObjectMetadataItem({
objectNameSingular,
});
const { recordGqlFields: depthOneRecordGqlFields } =
useGenerateDepthRecordGqlFieldsFromObject({
objectNameSingular,
depth: 1,
});
const computedRecordGqlFields = recordGqlFields ?? depthOneRecordGqlFields;
const getRecordFromCache = useGetRecordFromCache({
objectNameSingular,
});
const { updateManyRecordsMutation } = useUpdateManyRecordsMutation({
objectNameSingular,
});
const { objectMetadataItems } = useObjectMetadataItems();
const { objectPermissionsByObjectMetadataId } = useObjectPermissions();
const { refetchAggregateQueries } = useRefetchAggregateQueries({
objectMetadataNamePlural: objectMetadataItem.namePlural,
});
const mutationResponseField = getUpdateManyRecordsMutationResponseField(
objectMetadataItem.namePlural,
);
const updateManyRecords = async ({
recordIdsToUpdate,
updateOneRecordInput,
delayInMsBetweenRequests,
skipOptimisticEffect = false,
}: UpdateManyRecordsProps<T>) => {
const numberOfBatches = Math.ceil(
recordIdsToUpdate.length / mutationPageSize,
);
const updatedRecords: ObjectRecord[] = [];
for (let batchIndex = 0; batchIndex < numberOfBatches; batchIndex++) {
const batchedIdsToUpdate = recordIdsToUpdate.slice(
batchIndex * mutationPageSize,
(batchIndex + 1) * mutationPageSize,
);
const cachedRecords = batchedIdsToUpdate
.map((idToUpdate) =>
getRecordFromCache(idToUpdate, apolloCoreClient.cache),
)
.filter(isDefined);
const cachedRecordsNode: RecordGqlNode[] = [];
const computedOptimisticRecordsNode: RecordGqlNode[] = [];
const computedOptimisticRecords: ObjectRecord[] = [];
if (!skipOptimisticEffect) {
cachedRecords.forEach((cachedRecord) => {
const cachedRecordNode = getRecordNodeFromRecord<ObjectRecord>({
record: cachedRecord,
objectMetadataItem,
objectMetadataItems,
recordGqlFields: computedRecordGqlFields,
computeReferences: false,
});
const computedOptimisticRecord = {
...cachedRecord,
...updateOneRecordInput,
__typename: getObjectTypename(objectMetadataItem.nameSingular),
};
computedOptimisticRecords.push(computedOptimisticRecord);
const optimisticRecordNode = getRecordNodeFromRecord<ObjectRecord>({
record: computedOptimisticRecord,
objectMetadataItem,
objectMetadataItems,
recordGqlFields: computedRecordGqlFields,
computeReferences: false,
});
if (isDefined(optimisticRecordNode) && isDefined(cachedRecordNode)) {
const recordGqlFieldsFromRecord =
generateDepthRecordGqlFieldsFromRecord({
objectMetadataItem,
objectMetadataItems,
record: updateOneRecordInput,
depth: 1,
});
updateRecordFromCache({
objectMetadataItems,
objectMetadataItem,
cache: apolloCoreClient.cache,
record: computedOptimisticRecord,
recordGqlFields: recordGqlFieldsFromRecord,
objectPermissionsByObjectMetadataId,
});
computedOptimisticRecordsNode.push(optimisticRecordNode);
cachedRecordsNode.push(cachedRecordNode);
}
});
triggerUpdateRecordOptimisticEffectByBatch({
cache: apolloCoreClient.cache,
objectMetadataItem,
currentRecords: cachedRecordsNode,
updatedRecords: computedOptimisticRecordsNode,
objectMetadataItems,
objectPermissionsByObjectMetadataId,
upsertRecordsInStore,
});
}
const sanitizedInput = sanitizeRecordInput({
objectMetadataItem,
recordInput: updateOneRecordInput,
});
const updatedRecordsResponse = await apolloCoreClient
.mutate<Record<string, ObjectRecord[]>>({
mutation: updateManyRecordsMutation,
variables: {
filter: { id: { in: batchedIdsToUpdate } },
data: sanitizedInput,
},
})
.catch((error: Error) => {
if (skipOptimisticEffect) {
throw error;
}
const revertedCachedRecordsNode: RecordGqlNode[] = [];
const revertedOptimisticRecordsNode: RecordGqlNode[] = [];
cachedRecords.forEach((cachedRecord, index) => {
const recordGqlFieldsFromRecord =
generateDepthRecordGqlFieldsFromRecord({
objectMetadataItem,
objectMetadataItems,
record: cachedRecord,
depth: 1,
});
updateRecordFromCache({
objectMetadataItems,
objectMetadataItem,
cache: apolloCoreClient.cache,
record: cachedRecord,
recordGqlFields: recordGqlFieldsFromRecord,
objectPermissionsByObjectMetadataId,
});
const cachedRecordWithConnection =
getRecordNodeFromRecord<ObjectRecord>({
record: cachedRecord,
objectMetadataItem,
objectMetadataItems,
recordGqlFields: computedRecordGqlFields,
computeReferences: false,
});
const optimisticRecordWithConnection =
computedOptimisticRecordsNode[index];
if (
isDefined(optimisticRecordWithConnection) &&
isDefined(cachedRecordWithConnection)
) {
revertedCachedRecordsNode.push(cachedRecordWithConnection);
revertedOptimisticRecordsNode.push(
optimisticRecordWithConnection,
);
}
});
triggerUpdateRecordOptimisticEffectByBatch({
cache: apolloCoreClient.cache,
objectMetadataItem,
currentRecords: revertedOptimisticRecordsNode,
updatedRecords: revertedCachedRecordsNode,
objectMetadataItems,
objectPermissionsByObjectMetadataId,
upsertRecordsInStore,
});
throw error;
});
const updatedRecordsForThisBatch =
updatedRecordsResponse.data?.[mutationResponseField] ?? [];
updatedRecords.push(...updatedRecordsForThisBatch);
if (isDefined(delayInMsBetweenRequests)) {
await sleep(delayInMsBetweenRequests);
}
}
await refetchAggregateQueries();
registerObjectOperation(objectMetadataItem, {
type: 'update-many',
result: {
updateInputs: recordIdsToUpdate.map((id) => ({
id,
...updateOneRecordInput,
})),
},
});
return updatedRecords;
};
return { updateManyRecords };
};
@@ -0,0 +1,26 @@
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { generateUpdateManyRecordsMutation } from '@/object-metadata/utils/generateUpdateManyRecordsMutation';
import { EMPTY_MUTATION } from '@/object-record/constants/EmptyMutation';
import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
export const useUpdateManyRecordsMutation = ({
objectNameSingular,
}: {
objectNameSingular: string;
}) => {
const { objectMetadataItem } = useObjectMetadataItem({
objectNameSingular,
});
if (isUndefinedOrNull(objectMetadataItem)) {
return { updateManyRecordsMutation: EMPTY_MUTATION };
}
const updateManyRecordsMutation = generateUpdateManyRecordsMutation({
objectMetadataItem,
});
return {
updateManyRecordsMutation,
};
};
@@ -0,0 +1,5 @@
import { capitalize } from 'twenty-shared/utils';
export const getUpdateManyRecordsMutationResponseField = (
objectNamePlural: string,
) => `update${capitalize(objectNamePlural)}`;