Skip to content

Commit 024ee15

Browse files
authored
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
1 parent a19b9e1 commit 024ee15

20 files changed

Lines changed: 1488 additions & 220 deletions
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
2+
import { getUpdateManyRecordsMutationResponseField } from '@/object-record/utils/getUpdateManyRecordsMutationResponseField';
3+
import { gql } from '@apollo/client';
4+
import { capitalize } from 'twenty-shared/utils';
5+
6+
export const generateUpdateManyRecordsMutation = ({
7+
objectMetadataItem,
8+
}: {
9+
objectMetadataItem: ObjectMetadataItem;
10+
}) => {
11+
const capitalizedObjectNameSingular = capitalize(
12+
objectMetadataItem.nameSingular,
13+
);
14+
const capitalizedObjectNamePlural = capitalize(objectMetadataItem.namePlural);
15+
16+
const mutationResponseField = getUpdateManyRecordsMutationResponseField(
17+
objectMetadataItem.namePlural,
18+
);
19+
20+
const updateManyRecordsMutation = gql`
21+
mutation UpdateMany${capitalizedObjectNamePlural}($filter: ${capitalizedObjectNameSingular}FilterInput!, $data: ${capitalizedObjectNameSingular}UpdateInput!) {
22+
${mutationResponseField}(filter: $filter, data: $data) {
23+
id
24+
}
25+
}
26+
`;
27+
28+
return updateManyRecordsMutation;
29+
};
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
2+
import { gql } from '@apollo/client';
3+
import { getMockPersonRecord } from '~/testing/mock-data/people';
4+
5+
export const query = gql`
6+
mutation UpdateManyPeople(
7+
$filter: PersonFilterInput!
8+
$data: PersonUpdateInput!
9+
) {
10+
updatePeople(filter: $filter, data: $data) {
11+
id
12+
__typename
13+
}
14+
}
15+
`;
16+
17+
export const personIds = [
18+
'a7286b9a-c039-4a89-9567-2dfa7953cda9',
19+
'37faabcd-cb39-4a0a-8618-7e3fda9afca0',
20+
];
21+
22+
export const personRecords = personIds.map<ObjectRecord>((personId, index) =>
23+
getMockPersonRecord({ id: personId }, index),
24+
);
25+
26+
export const updateInput = {
27+
city: 'Updated City',
28+
};
29+
30+
export const variables = {
31+
filter: {
32+
id: {
33+
in: personIds,
34+
},
35+
},
36+
data: updateInput,
37+
};
38+
39+
export const updatedPersonRecords = personIds.map<ObjectRecord>(
40+
(personId, index) =>
41+
getMockPersonRecord({ id: personId, city: 'Updated City' }, index),
42+
);
43+
44+
export const responseData = personIds.map((personId) => ({ id: personId }));
Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
import { renderHook, waitFor } from '@testing-library/react';
2+
3+
import { getRecordFromCache } from '@/object-record/cache/utils/getRecordFromCache';
4+
import { updateRecordFromCache } from '@/object-record/cache/utils/updateRecordFromCache';
5+
import { generateDepthRecordGqlFieldsFromRecord } from '@/object-record/graphql/record-gql-fields/utils/generateDepthRecordGqlFieldsFromRecord';
6+
import {
7+
personIds,
8+
personRecords,
9+
query,
10+
responseData,
11+
updateInput,
12+
variables,
13+
} from '@/object-record/hooks/__mocks__/useUpdateManyRecords';
14+
import { useRefetchAggregateQueries } from '@/object-record/hooks/useRefetchAggregateQueries';
15+
import { useUpdateManyRecords } from '@/object-record/hooks/useUpdateManyRecords';
16+
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
17+
import { InMemoryCache } from '@apollo/client';
18+
import { type MockedResponse } from '@apollo/client/testing';
19+
import { act } from 'react';
20+
import { getJestMetadataAndApolloMocksWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksWrapper';
21+
import { getMockPersonObjectMetadataItem } from '~/testing/mock-data/people';
22+
import { generatedMockObjectMetadataItems } from '~/testing/utils/generatedMockObjectMetadataItems';
23+
24+
const getDefaultMocks = (
25+
overrides?: Partial<MockedResponse>,
26+
): MockedResponse[] => [
27+
{
28+
request: {
29+
query,
30+
variables,
31+
},
32+
result: jest.fn(() => ({
33+
data: {
34+
updatePeople: responseData,
35+
},
36+
})),
37+
...overrides,
38+
},
39+
];
40+
41+
jest.mock('@/object-record/hooks/useRefetchAggregateQueries');
42+
const mockRefetchAggregateQueries = jest.fn();
43+
(useRefetchAggregateQueries as jest.Mock).mockReturnValue({
44+
refetchAggregateQueries: mockRefetchAggregateQueries,
45+
});
46+
47+
const objectMetadataItem = getMockPersonObjectMetadataItem();
48+
const objectMetadataItems = generatedMockObjectMetadataItems;
49+
50+
const expectedCachedRecordsWithUpdatedCity = personRecords.map(
51+
(personRecord) => ({
52+
...personRecord,
53+
city: 'Updated City',
54+
}),
55+
);
56+
57+
describe('useUpdateManyRecords', () => {
58+
let cache: InMemoryCache;
59+
60+
const assertCachedRecordsMatch = (expectedRecords: ObjectRecord[]) => {
61+
expectedRecords.forEach((expectedRecord) => {
62+
const cachedRecord = getRecordFromCache({
63+
cache,
64+
objectMetadataItem,
65+
objectMetadataItems,
66+
recordId: expectedRecord.id,
67+
objectPermissionsByObjectMetadataId: {},
68+
});
69+
expect(cachedRecord).not.toBeNull();
70+
if (cachedRecord === null) throw new Error('Should never occur');
71+
expect(expectedRecord).toMatchObject(cachedRecord);
72+
});
73+
};
74+
75+
const assertCachedRecordsIsNull = (recordIds: string[]) =>
76+
recordIds.forEach((recordId) =>
77+
expect(
78+
getRecordFromCache({
79+
cache,
80+
objectMetadataItem,
81+
objectMetadataItems,
82+
recordId,
83+
objectPermissionsByObjectMetadataId: {},
84+
}),
85+
).toBeNull(),
86+
);
87+
88+
beforeEach(() => {
89+
jest.clearAllMocks();
90+
cache = new InMemoryCache();
91+
});
92+
93+
describe('A. Starting from empty cache', () => {
94+
it('1. Should handle update many records when cache is empty', async () => {
95+
const apolloMocks = getDefaultMocks();
96+
const { result } = renderHook(
97+
() => useUpdateManyRecords({ objectNameSingular: 'person' }),
98+
{
99+
wrapper: getJestMetadataAndApolloMocksWrapper({
100+
apolloMocks,
101+
cache,
102+
}),
103+
},
104+
);
105+
106+
await act(async () => {
107+
const res = await result.current.updateManyRecords({
108+
recordIdsToUpdate: personIds,
109+
updateOneRecordInput: updateInput,
110+
});
111+
expect(res).toEqual(responseData);
112+
assertCachedRecordsIsNull(personIds);
113+
});
114+
115+
expect(apolloMocks[0].result).toHaveBeenCalled();
116+
expect(mockRefetchAggregateQueries).toHaveBeenCalledTimes(1);
117+
});
118+
});
119+
120+
describe('B. Starting from filled cache', () => {
121+
beforeEach(() => {
122+
personRecords.forEach((record) =>
123+
updateRecordFromCache({
124+
cache,
125+
objectMetadataItem,
126+
objectMetadataItems,
127+
record,
128+
recordGqlFields: generateDepthRecordGqlFieldsFromRecord({
129+
objectMetadataItems: generatedMockObjectMetadataItems,
130+
objectMetadataItem,
131+
record,
132+
depth: 1,
133+
}),
134+
objectPermissionsByObjectMetadataId: {},
135+
}),
136+
);
137+
});
138+
139+
it('1. Should handle optimistic behavior after many successful records update', async () => {
140+
const apolloMocks = getDefaultMocks();
141+
const { result } = renderHook(
142+
() => useUpdateManyRecords({ objectNameSingular: 'person' }),
143+
{
144+
wrapper: getJestMetadataAndApolloMocksWrapper({
145+
apolloMocks,
146+
cache,
147+
}),
148+
},
149+
);
150+
151+
await act(async () => {
152+
const res = await result.current.updateManyRecords({
153+
recordIdsToUpdate: personIds,
154+
updateOneRecordInput: updateInput,
155+
});
156+
expect(res).toEqual(responseData);
157+
assertCachedRecordsMatch(expectedCachedRecordsWithUpdatedCity);
158+
});
159+
160+
expect(apolloMocks[0].result).toHaveBeenCalled();
161+
expect(mockRefetchAggregateQueries).toHaveBeenCalledTimes(1);
162+
});
163+
164+
it('2. Should handle optimistic behavior before send many record update', async () => {
165+
const apolloMocks = getDefaultMocks();
166+
const { result } = renderHook(
167+
() => useUpdateManyRecords({ objectNameSingular: 'person' }),
168+
{
169+
wrapper: getJestMetadataAndApolloMocksWrapper({
170+
apolloMocks: getDefaultMocks({
171+
delay: Number.POSITIVE_INFINITY,
172+
}),
173+
cache,
174+
}),
175+
},
176+
);
177+
178+
await act(async () => {
179+
result.current.updateManyRecords({
180+
recordIdsToUpdate: personIds,
181+
updateOneRecordInput: updateInput,
182+
});
183+
await waitFor(() =>
184+
assertCachedRecordsMatch(expectedCachedRecordsWithUpdatedCity),
185+
);
186+
});
187+
188+
expect(apolloMocks[0].result).not.toHaveBeenCalled();
189+
expect(mockRefetchAggregateQueries).not.toHaveBeenCalled();
190+
});
191+
192+
it('3. Should rollback optimistic behavior after failing to update many records', async () => {
193+
const apolloMocks = getDefaultMocks();
194+
const { result } = renderHook(
195+
() => useUpdateManyRecords({ objectNameSingular: 'person' }),
196+
{
197+
wrapper: getJestMetadataAndApolloMocksWrapper({
198+
apolloMocks: getDefaultMocks({
199+
error: new Error('Internal server error'),
200+
}),
201+
cache,
202+
}),
203+
},
204+
);
205+
206+
await act(async () => {
207+
try {
208+
await result.current.updateManyRecords({
209+
recordIdsToUpdate: personIds,
210+
updateOneRecordInput: updateInput,
211+
});
212+
fail('Should have thrown an error');
213+
} catch (e) {
214+
expect(e).toMatchInlineSnapshot(
215+
`[ApolloError: Internal server error]`,
216+
);
217+
assertCachedRecordsMatch(personRecords);
218+
}
219+
});
220+
221+
expect(apolloMocks[0].result).not.toHaveBeenCalled();
222+
expect(mockRefetchAggregateQueries).not.toHaveBeenCalled();
223+
});
224+
});
225+
});
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { renderHook } from '@testing-library/react';
2+
import { print } from 'graphql';
3+
4+
import { useUpdateManyRecordsMutation } from '@/object-record/hooks/useUpdateManyRecordsMutation';
5+
import { getJestMetadataAndApolloMocksWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksWrapper';
6+
7+
const expectedQueryTemplate = `
8+
mutation UpdateManyPeople($filter: PersonFilterInput!, $data: PersonUpdateInput!) {
9+
updatePeople(filter: $filter, data: $data) {
10+
id
11+
}
12+
}
13+
`.replace(/\s/g, '');
14+
15+
const Wrapper = getJestMetadataAndApolloMocksWrapper({
16+
apolloMocks: [],
17+
});
18+
19+
describe('useUpdateManyRecordsMutation', () => {
20+
it('should return a valid updateManyRecordsMutation', () => {
21+
const objectNameSingular = 'person';
22+
23+
const { result } = renderHook(
24+
() =>
25+
useUpdateManyRecordsMutation({
26+
objectNameSingular,
27+
}),
28+
{
29+
wrapper: Wrapper,
30+
},
31+
);
32+
33+
const { updateManyRecordsMutation } = result.current;
34+
35+
expect(updateManyRecordsMutation).toBeDefined();
36+
37+
const printedReceivedQuery = print(updateManyRecordsMutation).replace(
38+
/\s/g,
39+
'',
40+
);
41+
42+
expect(printedReceivedQuery).toEqual(expectedQueryTemplate);
43+
});
44+
});

0 commit comments

Comments
 (0)