[BUG] Fix record relation optimistic mutation (#9881)

# Introduction
It seems like optimistic caching isn't working as expected for any
record relation mutation, CREATE UPDATE DELETE.
It should not have an impact on the destroy

We included a new `computeOptimisticRecordInput` that will calculate if
a relation is added or detach.

Updated the `triggerCreateRecordsOptimisticEffect` signature we should
have a look to each of its call to determine if it should be checking
cache or not

Related to #9580

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Paul Rastoin
2025-01-29 16:00:59 +01:00
committed by GitHub
parent 7291a1ddcd
commit 29745c6756
17 changed files with 502 additions and 102 deletions

View File

@ -57,7 +57,6 @@ const connectedObjects = {
export const variables = {
idToUpdate: '36abbb63-34ed-4a16-89f5-f549ac55d0f9',
input: {
...basePerson,
name: { firstName: 'John', lastName: 'Doe' },
},
};

View File

@ -11,7 +11,7 @@ import { expect } from '@storybook/test';
import { getJestMetadataAndApolloMocksWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksWrapper';
const person = { id: '36abbb63-34ed-4a16-89f5-f549ac55d0f9' };
const update = {
const updateInput = {
name: {
firstName: 'John',
lastName: 'Doe',
@ -20,7 +20,7 @@ const update = {
const updatePerson = {
...person,
...responseData,
...update,
...updateInput,
};
const mocks = [
@ -64,11 +64,11 @@ describe('useUpdateOneRecord', () => {
await act(async () => {
const res = await result.current.updateOneRecord({
idToUpdate,
updateOneRecordInput: updatePerson,
updateOneRecordInput: updateInput,
});
expect(res).toBeDefined();
expect(res).toHaveProperty('id', person.id);
expect(res).toHaveProperty('name', update.name);
expect(res).toHaveProperty('name', updateInput.name);
});
expect(mocks[0].result).toHaveBeenCalled();

View File

@ -8,15 +8,21 @@ import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadat
import { useCreateOneRecordInCache } from '@/object-record/cache/hooks/useCreateOneRecordInCache';
import { deleteRecordFromCache } from '@/object-record/cache/utils/deleteRecordFromCache';
import { getObjectTypename } from '@/object-record/cache/utils/getObjectTypename';
import { getRecordNodeFromRecord } from '@/object-record/cache/utils/getRecordNodeFromRecord';
import { RecordGqlOperationGqlRecordFields } from '@/object-record/graphql/types/RecordGqlOperationGqlRecordFields';
import { generateDepthOneRecordGqlFields } from '@/object-record/graphql/utils/generateDepthOneRecordGqlFields';
import { useCreateManyRecordsMutation } from '@/object-record/hooks/useCreateManyRecordsMutation';
import { useRefetchAggregateQueries } from '@/object-record/hooks/useRefetchAggregateQueries';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { computeOptimisticRecordFromInput } from '@/object-record/utils/computeOptimisticRecordFromInput';
import { getCreateManyRecordsMutationResponseField } from '@/object-record/utils/getCreateManyRecordsMutationResponseField';
import { sanitizeRecordInput } from '@/object-record/utils/sanitizeRecordInput';
import { isDefined } from '~/utils/isDefined';
type PartialObjectRecordWithId = Partial<ObjectRecord> & {
id: string;
};
type useCreateManyRecordsProps = {
objectNameSingular: string;
recordGqlFields?: RecordGqlOperationGqlRecordFields;
@ -60,42 +66,56 @@ export const useCreateManyRecords = <
recordsToCreate: Partial<CreatedObjectRecord>[],
upsert?: boolean,
) => {
const sanitizedCreateManyRecordsInput = recordsToCreate.map(
(recordToCreate) => {
const idForCreation = recordToCreate?.id ?? (upsert ? undefined : v4());
const sanitizedCreateManyRecordsInput: PartialObjectRecordWithId[] = [];
const recordOptimisticRecordsInput: PartialObjectRecordWithId[] = [];
recordsToCreate.forEach((recordToCreate) => {
const idForCreation = recordToCreate?.id ?? v4();
const sanitizedRecord = {
...sanitizeRecordInput({
objectMetadataItem,
recordInput: recordToCreate,
}),
id: idForCreation,
};
const optimisticRecordInput = {
...computeOptimisticRecordFromInput({
cache: apolloClient.cache,
objectMetadataItem,
objectMetadataItems,
recordInput: recordToCreate,
}),
id: idForCreation,
};
return {
...sanitizeRecordInput({
objectMetadataItem,
recordInput: recordToCreate,
}),
id: idForCreation,
};
},
);
sanitizedCreateManyRecordsInput.push(sanitizedRecord);
recordOptimisticRecordsInput.push(optimisticRecordInput);
});
const recordsCreatedInCache: ObjectRecord[] = [];
for (const recordToCreate of sanitizedCreateManyRecordsInput) {
if (recordToCreate.id === null) {
continue;
}
const recordCreatedInCache = createOneRecordInCache({
...(recordToCreate as { id: string }),
__typename: getObjectTypename(objectMetadataItem.nameSingular),
});
if (isDefined(recordCreatedInCache)) {
recordsCreatedInCache.push(recordCreatedInCache);
}
}
const recordsCreatedInCache = recordOptimisticRecordsInput
.map((recordToCreate) =>
createOneRecordInCache({
...recordToCreate,
__typename: getObjectTypename(objectMetadataItem.nameSingular),
}),
)
.filter(isDefined);
if (recordsCreatedInCache.length > 0) {
const recordNodeCreatedInCache = recordsCreatedInCache
.map((record) =>
getRecordNodeFromRecord({
objectMetadataItem,
objectMetadataItems,
record: record,
computeReferences: false,
}),
)
.filter(isDefined);
triggerCreateRecordsOptimisticEffect({
cache: apolloClient.cache,
objectMetadataItem,
recordsToCreate: recordsCreatedInCache,
recordsToCreate: recordNodeCreatedInCache,
objectMetadataItems,
shouldMatchRootQueryFilter,
});
@ -123,6 +143,7 @@ export const useCreateManyRecords = <
recordsToCreate: records,
objectMetadataItems,
shouldMatchRootQueryFilter,
checkForRecordInCache: true,
});
},
})

View File

@ -9,11 +9,13 @@ import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadat
import { useCreateOneRecordInCache } from '@/object-record/cache/hooks/useCreateOneRecordInCache';
import { deleteRecordFromCache } from '@/object-record/cache/utils/deleteRecordFromCache';
import { getObjectTypename } from '@/object-record/cache/utils/getObjectTypename';
import { getRecordNodeFromRecord } from '@/object-record/cache/utils/getRecordNodeFromRecord';
import { RecordGqlOperationGqlRecordFields } from '@/object-record/graphql/types/RecordGqlOperationGqlRecordFields';
import { generateDepthOneRecordGqlFields } from '@/object-record/graphql/utils/generateDepthOneRecordGqlFields';
import { useCreateOneRecordMutation } from '@/object-record/hooks/useCreateOneRecordMutation';
import { useRefetchAggregateQueries } from '@/object-record/hooks/useRefetchAggregateQueries';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { computeOptimisticRecordFromInput } from '@/object-record/utils/computeOptimisticRecordFromInput';
import { getCreateOneRecordMutationResponseField } from '@/object-record/utils/getCreateOneRecordMutationResponseField';
import { sanitizeRecordInput } from '@/object-record/utils/sanitizeRecordInput';
import { isDefined } from '~/utils/isDefined';
@ -60,33 +62,48 @@ export const useCreateOneRecord = <
objectMetadataNamePlural: objectMetadataItem.namePlural,
});
const createOneRecord = async (input: Partial<CreatedObjectRecord>) => {
const createOneRecord = async (recordInput: Partial<CreatedObjectRecord>) => {
setLoading(true);
const idForCreation = input.id ?? v4();
const idForCreation = recordInput.id ?? v4();
const sanitizedInput = {
...sanitizeRecordInput({
objectMetadataItem,
recordInput: input,
recordInput,
}),
id: idForCreation,
};
const optimisticRecordInput = computeOptimisticRecordFromInput({
cache: apolloClient.cache,
objectMetadataItem,
objectMetadataItems,
recordInput: { ...recordInput, id: idForCreation },
});
const recordCreatedInCache = createOneRecordInCache({
...input,
...optimisticRecordInput,
id: idForCreation,
__typename: getObjectTypename(objectMetadataItem.nameSingular),
});
if (isDefined(recordCreatedInCache)) {
triggerCreateRecordsOptimisticEffect({
cache: apolloClient.cache,
const optimisticRecordNode = getRecordNodeFromRecord({
objectMetadataItem,
recordsToCreate: [recordCreatedInCache],
objectMetadataItems,
shouldMatchRootQueryFilter,
record: recordCreatedInCache,
computeReferences: false,
});
if (optimisticRecordNode !== null) {
triggerCreateRecordsOptimisticEffect({
cache: apolloClient.cache,
objectMetadataItem,
recordsToCreate: [optimisticRecordNode],
objectMetadataItems,
shouldMatchRootQueryFilter,
});
}
}
const mutationResponseField =
@ -100,16 +117,16 @@ export const useCreateOneRecord = <
},
update: (cache, { data }) => {
const record = data?.[mutationResponseField];
if (!record || skipPostOptmisticEffect) return;
triggerCreateRecordsOptimisticEffect({
cache,
objectMetadataItem,
recordsToCreate: [record],
objectMetadataItems,
shouldMatchRootQueryFilter,
});
if (skipPostOptmisticEffect === false && isDefined(record)) {
triggerCreateRecordsOptimisticEffect({
cache,
objectMetadataItem,
recordsToCreate: [record],
objectMetadataItems,
shouldMatchRootQueryFilter,
checkForRecordInCache: true,
});
}
setLoading(false);
},

View File

@ -10,6 +10,7 @@ import { generateDepthOneRecordGqlFields } from '@/object-record/graphql/utils/g
import { useRefetchAggregateQueries } from '@/object-record/hooks/useRefetchAggregateQueries';
import { useUpdateOneRecordMutation } from '@/object-record/hooks/useUpdateOneRecordMutation';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { computeOptimisticRecordFromInput } from '@/object-record/utils/computeOptimisticRecordFromInput';
import { getUpdateOneRecordMutationResponseField } from '@/object-record/utils/getUpdateOneRecordMutationResponseField';
import { sanitizeRecordInput } from '@/object-record/utils/sanitizeRecordInput';
import { capitalize } from 'twenty-shared';
@ -59,12 +60,12 @@ export const useUpdateOneRecord = <
updateOneRecordInput: Partial<Omit<UpdatedObjectRecord, 'id'>>;
optimisticRecord?: Partial<ObjectRecord>;
}) => {
const sanitizedInput = {
...sanitizeRecordInput({
objectMetadataItem,
recordInput: updateOneRecordInput,
}),
};
const optimisticRecordInput = computeOptimisticRecordFromInput({
objectMetadataItem,
recordInput: updateOneRecordInput,
cache: apolloClient.cache,
objectMetadataItems,
});
const cachedRecord = getRecordFromCache<ObjectRecord>(idToUpdate);
@ -73,12 +74,12 @@ export const useUpdateOneRecord = <
objectMetadataItem,
objectMetadataItems,
recordGqlFields: computedRecordGqlFields,
computeReferences: true,
computeReferences: false,
});
const computedOptimisticRecord = {
...cachedRecord,
...(optimisticRecord ?? sanitizedInput),
...(optimisticRecord ?? optimisticRecordInput),
...{ id: idToUpdate },
...{ __typename: capitalize(objectMetadataItem.nameSingular) },
};
@ -89,9 +90,8 @@ export const useUpdateOneRecord = <
objectMetadataItem,
objectMetadataItems,
recordGqlFields: computedRecordGqlFields,
computeReferences: true,
computeReferences: false,
});
if (!optimisticRecordWithConnection || !cachedRecordWithConnection) {
return null;
}
@ -114,6 +114,12 @@ export const useUpdateOneRecord = <
const mutationResponseField =
getUpdateOneRecordMutationResponseField(objectNameSingular);
const sanitizedInput = {
...sanitizeRecordInput({
objectMetadataItem,
recordInput: updateOneRecordInput,
}),
};
const updatedRecord = await apolloClient
.mutate({
mutation: updateOneRecordMutation,