[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

@ -6,6 +6,9 @@ process.env.TZ = 'GMT';
const jestConfig: JestConfigWithTsJest = { const jestConfig: JestConfigWithTsJest = {
// to enable logs, comment out the following line // to enable logs, comment out the following line
silent: true, silent: true,
// For more information please have a look to official docs https://jestjs.io/docs/configuration/#prettierpath-string
// Prettier v3 will should be supported in jest v30 https://github.com/jestjs/jest/releases/tag/v30.0.0-alpha.1
prettierPath: null,
displayName: 'twenty-front', displayName: 'twenty-front',
preset: '../../jest.preset.js', preset: '../../jest.preset.js',
setupFilesAfterEnv: ['./setupTests.ts'], setupFilesAfterEnv: ['./setupTests.ts'],

View File

@ -80,7 +80,11 @@ export const useOpenCreateActivityDrawer = ({
setViewableRecordNameSingular(activityObjectNameSingular); setViewableRecordNameSingular(activityObjectNameSingular);
const activity = await createOneActivity({ const activity = await createOneActivity({
assigneeId: customAssignee?.id, ...(activityObjectNameSingular === CoreObjectNameSingular.Task
? {
assigneeId: customAssignee?.id,
}
: {}),
position: 'last', position: 'last',
}); });

View File

@ -11,6 +11,8 @@ import { isRecordMatchingFilter } from '@/object-record/record-filter/utils/isRe
import { CachedObjectRecordQueryVariables } from '@/apollo/types/CachedObjectRecordQueryVariables'; import { CachedObjectRecordQueryVariables } from '@/apollo/types/CachedObjectRecordQueryVariables';
import { encodeCursor } from '@/apollo/utils/encodeCursor'; import { encodeCursor } from '@/apollo/utils/encodeCursor';
import { getRecordFromCache } from '@/object-record/cache/utils/getRecordFromCache';
import { getRecordNodeFromRecord } from '@/object-record/cache/utils/getRecordNodeFromRecord';
import { isDefined } from '~/utils/isDefined'; import { isDefined } from '~/utils/isDefined';
import { parseApolloStoreFieldName } from '~/utils/parseApolloStoreFieldName'; import { parseApolloStoreFieldName } from '~/utils/parseApolloStoreFieldName';
@ -19,28 +21,49 @@ import { parseApolloStoreFieldName } from '~/utils/parseApolloStoreFieldName';
We need to refactor how the record creation works in the RecordTable so the created record row is temporarily displayed with a local state, We need to refactor how the record creation works in the RecordTable so the created record row is temporarily displayed with a local state,
then we'll be able to uncomment the code below so the cached lists are updated coherently with the variables. then we'll be able to uncomment the code below so the cached lists are updated coherently with the variables.
*/ */
type TriggerCreateRecordsOptimisticEffectArgs = {
cache: ApolloCache<object>;
objectMetadataItem: ObjectMetadataItem;
recordsToCreate: RecordGqlNode[];
objectMetadataItems: ObjectMetadataItem[];
shouldMatchRootQueryFilter?: boolean;
checkForRecordInCache?: boolean;
};
export const triggerCreateRecordsOptimisticEffect = ({ export const triggerCreateRecordsOptimisticEffect = ({
cache, cache,
objectMetadataItem, objectMetadataItem,
recordsToCreate, recordsToCreate,
objectMetadataItems, objectMetadataItems,
shouldMatchRootQueryFilter, shouldMatchRootQueryFilter,
}: { checkForRecordInCache = false,
cache: ApolloCache<unknown>; }: TriggerCreateRecordsOptimisticEffectArgs) => {
objectMetadataItem: ObjectMetadataItem; const getRecordNodeFromCache = (recordId: string): RecordGqlNode | null => {
recordsToCreate: RecordGqlNode[]; const cachedRecord = getRecordFromCache({
objectMetadataItems: ObjectMetadataItem[]; cache,
shouldMatchRootQueryFilter?: boolean; objectMetadataItem,
}) => { objectMetadataItems,
recordsToCreate.forEach((record) => recordId,
});
return getRecordNodeFromRecord({
objectMetadataItem,
objectMetadataItems,
record: cachedRecord,
computeReferences: false,
});
};
recordsToCreate.forEach((record) => {
const currentSourceRecord = checkForRecordInCache
? getRecordNodeFromCache(record.id)
: null;
triggerUpdateRelationsOptimisticEffect({ triggerUpdateRelationsOptimisticEffect({
cache, cache,
sourceObjectMetadataItem: objectMetadataItem, sourceObjectMetadataItem: objectMetadataItem,
currentSourceRecord: null, currentSourceRecord,
updatedSourceRecord: record, updatedSourceRecord: record,
objectMetadataItems, objectMetadataItems,
}), });
); });
cache.modify<StoreObject>({ cache.modify<StoreObject>({
fields: { fields: {

View File

@ -7,7 +7,6 @@ import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { isObjectRecordConnection } from '@/object-record/cache/utils/isObjectRecordConnection'; import { isObjectRecordConnection } from '@/object-record/cache/utils/isObjectRecordConnection';
import { RecordGqlConnection } from '@/object-record/graphql/types/RecordGqlConnection'; import { RecordGqlConnection } from '@/object-record/graphql/types/RecordGqlConnection';
import { RecordGqlNode } from '@/object-record/graphql/types/RecordGqlNode'; import { RecordGqlNode } from '@/object-record/graphql/types/RecordGqlNode';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { ApolloCache } from '@apollo/client'; import { ApolloCache } from '@apollo/client';
import { isArray } from '@sniptt/guards'; import { isArray } from '@sniptt/guards';
import { FieldMetadataType } from '~/generated-metadata/graphql'; import { FieldMetadataType } from '~/generated-metadata/graphql';
@ -17,8 +16,8 @@ import { isDefined } from '~/utils/isDefined';
type triggerUpdateRelationsOptimisticEffectArgs = { type triggerUpdateRelationsOptimisticEffectArgs = {
cache: ApolloCache<unknown>; cache: ApolloCache<unknown>;
sourceObjectMetadataItem: ObjectMetadataItem; sourceObjectMetadataItem: ObjectMetadataItem;
currentSourceRecord: ObjectRecord | null; currentSourceRecord: RecordGqlNode | null;
updatedSourceRecord: ObjectRecord | null; updatedSourceRecord: RecordGqlNode | null;
objectMetadataItems: ObjectMetadataItem[]; objectMetadataItems: ObjectMetadataItem[];
}; };
export const triggerUpdateRelationsOptimisticEffect = ({ export const triggerUpdateRelationsOptimisticEffect = ({

View File

@ -26,7 +26,6 @@ export const useCreateFavorite = () => {
); );
createOneFavorite({ createOneFavorite({
[targetObjectNameSingular]: targetRecord,
[`${targetObjectNameSingular}Id`]: targetRecord.id, [`${targetObjectNameSingular}Id`]: targetRecord.id,
position: maxPosition + 1, position: maxPosition + 1,
workspaceMemberId: currentWorkspaceMemberId, workspaceMemberId: currentWorkspaceMemberId,

View File

@ -9,19 +9,20 @@ import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { capitalize } from 'twenty-shared'; import { capitalize } from 'twenty-shared';
import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull'; import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
export type GetRecordFromCacheArgs = {
cache: ApolloCache<object>;
recordId: string;
objectMetadataItems: ObjectMetadataItem[];
objectMetadataItem: ObjectMetadataItem;
recordGqlFields?: RecordGqlFields;
};
export const getRecordFromCache = <T extends ObjectRecord = ObjectRecord>({ export const getRecordFromCache = <T extends ObjectRecord = ObjectRecord>({
objectMetadataItem, objectMetadataItem,
objectMetadataItems, objectMetadataItems,
cache, cache,
recordId, recordId,
recordGqlFields, recordGqlFields,
}: { }: GetRecordFromCacheArgs) => {
cache: ApolloCache<object>;
recordId: string;
objectMetadataItems: ObjectMetadataItem[];
objectMetadataItem: ObjectMetadataItem;
recordGqlFields?: RecordGqlFields;
}) => {
if (isUndefinedOrNull(objectMetadataItem)) { if (isUndefinedOrNull(objectMetadataItem)) {
return null; return null;
} }

View File

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

View File

@ -11,7 +11,7 @@ import { expect } from '@storybook/test';
import { getJestMetadataAndApolloMocksWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksWrapper'; import { getJestMetadataAndApolloMocksWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksWrapper';
const person = { id: '36abbb63-34ed-4a16-89f5-f549ac55d0f9' }; const person = { id: '36abbb63-34ed-4a16-89f5-f549ac55d0f9' };
const update = { const updateInput = {
name: { name: {
firstName: 'John', firstName: 'John',
lastName: 'Doe', lastName: 'Doe',
@ -20,7 +20,7 @@ const update = {
const updatePerson = { const updatePerson = {
...person, ...person,
...responseData, ...responseData,
...update, ...updateInput,
}; };
const mocks = [ const mocks = [
@ -64,11 +64,11 @@ describe('useUpdateOneRecord', () => {
await act(async () => { await act(async () => {
const res = await result.current.updateOneRecord({ const res = await result.current.updateOneRecord({
idToUpdate, idToUpdate,
updateOneRecordInput: updatePerson, updateOneRecordInput: updateInput,
}); });
expect(res).toBeDefined(); expect(res).toBeDefined();
expect(res).toHaveProperty('id', person.id); expect(res).toHaveProperty('id', person.id);
expect(res).toHaveProperty('name', update.name); expect(res).toHaveProperty('name', updateInput.name);
}); });
expect(mocks[0].result).toHaveBeenCalled(); 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 { useCreateOneRecordInCache } from '@/object-record/cache/hooks/useCreateOneRecordInCache';
import { deleteRecordFromCache } from '@/object-record/cache/utils/deleteRecordFromCache'; import { deleteRecordFromCache } from '@/object-record/cache/utils/deleteRecordFromCache';
import { getObjectTypename } from '@/object-record/cache/utils/getObjectTypename'; 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 { RecordGqlOperationGqlRecordFields } from '@/object-record/graphql/types/RecordGqlOperationGqlRecordFields';
import { generateDepthOneRecordGqlFields } from '@/object-record/graphql/utils/generateDepthOneRecordGqlFields'; import { generateDepthOneRecordGqlFields } from '@/object-record/graphql/utils/generateDepthOneRecordGqlFields';
import { useCreateManyRecordsMutation } from '@/object-record/hooks/useCreateManyRecordsMutation'; import { useCreateManyRecordsMutation } from '@/object-record/hooks/useCreateManyRecordsMutation';
import { useRefetchAggregateQueries } from '@/object-record/hooks/useRefetchAggregateQueries'; import { useRefetchAggregateQueries } from '@/object-record/hooks/useRefetchAggregateQueries';
import { ObjectRecord } from '@/object-record/types/ObjectRecord'; import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { computeOptimisticRecordFromInput } from '@/object-record/utils/computeOptimisticRecordFromInput';
import { getCreateManyRecordsMutationResponseField } from '@/object-record/utils/getCreateManyRecordsMutationResponseField'; import { getCreateManyRecordsMutationResponseField } from '@/object-record/utils/getCreateManyRecordsMutationResponseField';
import { sanitizeRecordInput } from '@/object-record/utils/sanitizeRecordInput'; import { sanitizeRecordInput } from '@/object-record/utils/sanitizeRecordInput';
import { isDefined } from '~/utils/isDefined'; import { isDefined } from '~/utils/isDefined';
type PartialObjectRecordWithId = Partial<ObjectRecord> & {
id: string;
};
type useCreateManyRecordsProps = { type useCreateManyRecordsProps = {
objectNameSingular: string; objectNameSingular: string;
recordGqlFields?: RecordGqlOperationGqlRecordFields; recordGqlFields?: RecordGqlOperationGqlRecordFields;
@ -60,42 +66,56 @@ export const useCreateManyRecords = <
recordsToCreate: Partial<CreatedObjectRecord>[], recordsToCreate: Partial<CreatedObjectRecord>[],
upsert?: boolean, upsert?: boolean,
) => { ) => {
const sanitizedCreateManyRecordsInput = recordsToCreate.map( const sanitizedCreateManyRecordsInput: PartialObjectRecordWithId[] = [];
(recordToCreate) => { const recordOptimisticRecordsInput: PartialObjectRecordWithId[] = [];
const idForCreation = recordToCreate?.id ?? (upsert ? undefined : v4()); 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 { sanitizedCreateManyRecordsInput.push(sanitizedRecord);
...sanitizeRecordInput({ recordOptimisticRecordsInput.push(optimisticRecordInput);
objectMetadataItem, });
recordInput: recordToCreate,
}),
id: idForCreation,
};
},
);
const recordsCreatedInCache: ObjectRecord[] = []; const recordsCreatedInCache = recordOptimisticRecordsInput
.map((recordToCreate) =>
for (const recordToCreate of sanitizedCreateManyRecordsInput) { createOneRecordInCache({
if (recordToCreate.id === null) { ...recordToCreate,
continue; __typename: getObjectTypename(objectMetadataItem.nameSingular),
} }),
)
const recordCreatedInCache = createOneRecordInCache({ .filter(isDefined);
...(recordToCreate as { id: string }),
__typename: getObjectTypename(objectMetadataItem.nameSingular),
});
if (isDefined(recordCreatedInCache)) {
recordsCreatedInCache.push(recordCreatedInCache);
}
}
if (recordsCreatedInCache.length > 0) { if (recordsCreatedInCache.length > 0) {
const recordNodeCreatedInCache = recordsCreatedInCache
.map((record) =>
getRecordNodeFromRecord({
objectMetadataItem,
objectMetadataItems,
record: record,
computeReferences: false,
}),
)
.filter(isDefined);
triggerCreateRecordsOptimisticEffect({ triggerCreateRecordsOptimisticEffect({
cache: apolloClient.cache, cache: apolloClient.cache,
objectMetadataItem, objectMetadataItem,
recordsToCreate: recordsCreatedInCache, recordsToCreate: recordNodeCreatedInCache,
objectMetadataItems, objectMetadataItems,
shouldMatchRootQueryFilter, shouldMatchRootQueryFilter,
}); });
@ -123,6 +143,7 @@ export const useCreateManyRecords = <
recordsToCreate: records, recordsToCreate: records,
objectMetadataItems, objectMetadataItems,
shouldMatchRootQueryFilter, 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 { useCreateOneRecordInCache } from '@/object-record/cache/hooks/useCreateOneRecordInCache';
import { deleteRecordFromCache } from '@/object-record/cache/utils/deleteRecordFromCache'; import { deleteRecordFromCache } from '@/object-record/cache/utils/deleteRecordFromCache';
import { getObjectTypename } from '@/object-record/cache/utils/getObjectTypename'; 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 { RecordGqlOperationGqlRecordFields } from '@/object-record/graphql/types/RecordGqlOperationGqlRecordFields';
import { generateDepthOneRecordGqlFields } from '@/object-record/graphql/utils/generateDepthOneRecordGqlFields'; import { generateDepthOneRecordGqlFields } from '@/object-record/graphql/utils/generateDepthOneRecordGqlFields';
import { useCreateOneRecordMutation } from '@/object-record/hooks/useCreateOneRecordMutation'; import { useCreateOneRecordMutation } from '@/object-record/hooks/useCreateOneRecordMutation';
import { useRefetchAggregateQueries } from '@/object-record/hooks/useRefetchAggregateQueries'; import { useRefetchAggregateQueries } from '@/object-record/hooks/useRefetchAggregateQueries';
import { ObjectRecord } from '@/object-record/types/ObjectRecord'; import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { computeOptimisticRecordFromInput } from '@/object-record/utils/computeOptimisticRecordFromInput';
import { getCreateOneRecordMutationResponseField } from '@/object-record/utils/getCreateOneRecordMutationResponseField'; import { getCreateOneRecordMutationResponseField } from '@/object-record/utils/getCreateOneRecordMutationResponseField';
import { sanitizeRecordInput } from '@/object-record/utils/sanitizeRecordInput'; import { sanitizeRecordInput } from '@/object-record/utils/sanitizeRecordInput';
import { isDefined } from '~/utils/isDefined'; import { isDefined } from '~/utils/isDefined';
@ -60,33 +62,48 @@ export const useCreateOneRecord = <
objectMetadataNamePlural: objectMetadataItem.namePlural, objectMetadataNamePlural: objectMetadataItem.namePlural,
}); });
const createOneRecord = async (input: Partial<CreatedObjectRecord>) => { const createOneRecord = async (recordInput: Partial<CreatedObjectRecord>) => {
setLoading(true); setLoading(true);
const idForCreation = input.id ?? v4(); const idForCreation = recordInput.id ?? v4();
const sanitizedInput = { const sanitizedInput = {
...sanitizeRecordInput({ ...sanitizeRecordInput({
objectMetadataItem, objectMetadataItem,
recordInput: input, recordInput,
}), }),
id: idForCreation, id: idForCreation,
}; };
const optimisticRecordInput = computeOptimisticRecordFromInput({
cache: apolloClient.cache,
objectMetadataItem,
objectMetadataItems,
recordInput: { ...recordInput, id: idForCreation },
});
const recordCreatedInCache = createOneRecordInCache({ const recordCreatedInCache = createOneRecordInCache({
...input, ...optimisticRecordInput,
id: idForCreation, id: idForCreation,
__typename: getObjectTypename(objectMetadataItem.nameSingular), __typename: getObjectTypename(objectMetadataItem.nameSingular),
}); });
if (isDefined(recordCreatedInCache)) { if (isDefined(recordCreatedInCache)) {
triggerCreateRecordsOptimisticEffect({ const optimisticRecordNode = getRecordNodeFromRecord({
cache: apolloClient.cache,
objectMetadataItem, objectMetadataItem,
recordsToCreate: [recordCreatedInCache],
objectMetadataItems, objectMetadataItems,
shouldMatchRootQueryFilter, record: recordCreatedInCache,
computeReferences: false,
}); });
if (optimisticRecordNode !== null) {
triggerCreateRecordsOptimisticEffect({
cache: apolloClient.cache,
objectMetadataItem,
recordsToCreate: [optimisticRecordNode],
objectMetadataItems,
shouldMatchRootQueryFilter,
});
}
} }
const mutationResponseField = const mutationResponseField =
@ -100,16 +117,16 @@ export const useCreateOneRecord = <
}, },
update: (cache, { data }) => { update: (cache, { data }) => {
const record = data?.[mutationResponseField]; const record = data?.[mutationResponseField];
if (skipPostOptmisticEffect === false && isDefined(record)) {
if (!record || skipPostOptmisticEffect) return; triggerCreateRecordsOptimisticEffect({
cache,
triggerCreateRecordsOptimisticEffect({ objectMetadataItem,
cache, recordsToCreate: [record],
objectMetadataItem, objectMetadataItems,
recordsToCreate: [record], shouldMatchRootQueryFilter,
objectMetadataItems, checkForRecordInCache: true,
shouldMatchRootQueryFilter, });
}); }
setLoading(false); setLoading(false);
}, },

View File

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

View File

@ -30,6 +30,7 @@ import { isFieldArray } from '@/object-record/record-field/types/guards/isFieldA
import { isFieldArrayValue } from '@/object-record/record-field/types/guards/isFieldArrayValue'; import { isFieldArrayValue } from '@/object-record/record-field/types/guards/isFieldArrayValue';
import { isFieldRichText } from '@/object-record/record-field/types/guards/isFieldRichText'; import { isFieldRichText } from '@/object-record/record-field/types/guards/isFieldRichText';
import { isFieldRichTextValue } from '@/object-record/record-field/types/guards/isFieldRichTextValue'; import { isFieldRichTextValue } from '@/object-record/record-field/types/guards/isFieldRichTextValue';
import { getForeignKeyNameFromRelationFieldName } from '@/object-record/utils/getForeignKeyNameFromRelationFieldName';
import { FieldContext } from '../contexts/FieldContext'; import { FieldContext } from '../contexts/FieldContext';
import { isFieldBoolean } from '../types/guards/isFieldBoolean'; import { isFieldBoolean } from '../types/guards/isFieldBoolean';
import { isFieldBooleanValue } from '../types/guards/isFieldBooleanValue'; import { isFieldBooleanValue } from '../types/guards/isFieldBooleanValue';
@ -153,8 +154,8 @@ export const usePersistField = () => {
variables: { variables: {
where: { id: recordId }, where: { id: recordId },
updateOneRecordInput: { updateOneRecordInput: {
[fieldName]: value, [getForeignKeyNameFromRelationFieldName(fieldName)]:
[`${fieldName}Id`]: value?.id ?? null, value?.id ?? null,
}, },
}, },
}); });

View File

@ -35,6 +35,7 @@ import { InlineCellHotkeyScope } from '@/object-record/record-inline-cell/types/
import { RecordDetailRecordsListItem } from '@/object-record/record-show/record-detail-section/components/RecordDetailRecordsListItem'; import { RecordDetailRecordsListItem } from '@/object-record/record-show/record-detail-section/components/RecordDetailRecordsListItem';
import { RecordValueSetterEffect } from '@/object-record/record-store/components/RecordValueSetterEffect'; import { RecordValueSetterEffect } from '@/object-record/record-store/components/RecordValueSetterEffect';
import { ObjectRecord } from '@/object-record/types/ObjectRecord'; import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { getForeignKeyNameFromRelationFieldName } from '@/object-record/utils/getForeignKeyNameFromRelationFieldName';
import { isFieldCellSupported } from '@/object-record/utils/isFieldCellSupported'; import { isFieldCellSupported } from '@/object-record/utils/isFieldCellSupported';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown'; import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
@ -162,7 +163,9 @@ export const RecordDetailRelationRecordsListItem = ({
updateOneRelationRecord({ updateOneRelationRecord({
idToUpdate: relationRecord.id, idToUpdate: relationRecord.id,
updateOneRecordInput: { updateOneRecordInput: {
[relationFieldMetadataItem.name]: null, [getForeignKeyNameFromRelationFieldName(
relationFieldMetadataItem.name,
)]: null,
}, },
}); });
}; };

View File

@ -0,0 +1,182 @@
import { updateRecordFromCache } from '@/object-record/cache/utils/updateRecordFromCache';
import { computeOptimisticRecordFromInput } from '@/object-record/utils/computeOptimisticRecordFromInput';
import { InMemoryCache } from '@apollo/client';
import { generatedMockObjectMetadataItems } from '~/testing/mock-data/generatedMockObjectMetadataItems';
describe('computeOptimisticRecordFromInput', () => {
it('should generate correct optimistic record if no relation field is present', () => {
const cache = new InMemoryCache();
const personObjectMetadataItem = generatedMockObjectMetadataItems.find(
(item) => item.nameSingular === 'person',
);
if (!personObjectMetadataItem) {
throw new Error('Person object metadata item not found');
}
const result = computeOptimisticRecordFromInput({
objectMetadataItems: generatedMockObjectMetadataItems,
objectMetadataItem: personObjectMetadataItem,
recordInput: {
city: 'Paris',
},
cache,
});
expect(result).toEqual({
city: 'Paris',
});
});
it('should generate correct optimistic record if relation field is present but cache is empty', () => {
const cache = new InMemoryCache();
const personObjectMetadataItem = generatedMockObjectMetadataItems.find(
(item) => item.nameSingular === 'person',
);
if (!personObjectMetadataItem) {
throw new Error('Person object metadata item not found');
}
const result = computeOptimisticRecordFromInput({
objectMetadataItems: generatedMockObjectMetadataItems,
objectMetadataItem: personObjectMetadataItem,
recordInput: {
companyId: '123',
},
cache,
});
expect(result).toEqual({
companyId: '123',
});
});
it('should generate correct optimistic record if relation field is present and cache is not empty', () => {
const cache = new InMemoryCache();
const personObjectMetadataItem = generatedMockObjectMetadataItems.find(
(item) => item.nameSingular === 'person',
);
if (!personObjectMetadataItem) {
throw new Error('Person object metadata item not found');
}
const companyObjectMetadataItem = generatedMockObjectMetadataItems.find(
(item) => item.nameSingular === 'company',
);
if (!companyObjectMetadataItem) {
throw new Error('Company object metadata item not found');
}
const companyRecord = {
id: '123',
__typename: 'Company',
};
updateRecordFromCache({
objectMetadataItems: generatedMockObjectMetadataItems,
objectMetadataItem: {
...companyObjectMetadataItem,
fields: companyObjectMetadataItem.fields.filter(
(field) => field.name === 'id',
),
},
cache,
record: companyRecord,
});
const result = computeOptimisticRecordFromInput({
objectMetadataItems: generatedMockObjectMetadataItems,
objectMetadataItem: personObjectMetadataItem,
recordInput: {
companyId: '123',
},
cache,
});
expect(result).toEqual({
companyId: '123',
company: companyRecord,
});
});
it('should generate correct optimistic record if relation field is null and cache is empty', () => {
const cache = new InMemoryCache();
const personObjectMetadataItem = generatedMockObjectMetadataItems.find(
(item) => item.nameSingular === 'person',
);
if (!personObjectMetadataItem) {
throw new Error('Person object metadata item not found');
}
const result = computeOptimisticRecordFromInput({
objectMetadataItems: generatedMockObjectMetadataItems,
objectMetadataItem: personObjectMetadataItem,
recordInput: {
companyId: null,
},
cache,
});
expect(result).toEqual({
companyId: null,
company: null,
});
});
it('should throw an error if recordInput contains fiels unrelated to the current objectMetadata', () => {
const cache = new InMemoryCache();
const personObjectMetadataItem = generatedMockObjectMetadataItems.find(
(item) => item.nameSingular === 'person',
);
if (!personObjectMetadataItem) {
throw new Error('Person object metadata item not found');
}
expect(() =>
computeOptimisticRecordFromInput({
objectMetadataItems: generatedMockObjectMetadataItems,
objectMetadataItem: personObjectMetadataItem,
recordInput: {
unknwon: 'unknown',
foo: 'foo',
bar: 'bar',
city: 'Paris',
},
cache,
}),
).toThrowErrorMatchingInlineSnapshot(
`"Should never occur, encountered unknown fields unknwon, foo, bar in objectMetadaItem person"`,
);
});
it('should throw an error if recordInput contains both the relationFieldId and relationField', () => {
const cache = new InMemoryCache();
const personObjectMetadataItem = generatedMockObjectMetadataItems.find(
(item) => item.nameSingular === 'person',
);
if (!personObjectMetadataItem) {
throw new Error('Person object metadata item not found');
}
expect(() =>
computeOptimisticRecordFromInput({
objectMetadataItems: generatedMockObjectMetadataItems,
objectMetadataItem: personObjectMetadataItem,
recordInput: {
companyId: '123',
company: {},
},
cache,
}),
).toThrowErrorMatchingInlineSnapshot(
`"Should never provide relation mutation through anything else than the fieldId e.g companyId"`,
);
});
});

View File

@ -0,0 +1,153 @@
import { isNull, isUndefined } from '@sniptt/guards';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import {
getRecordFromCache,
GetRecordFromCacheArgs,
} from '@/object-record/cache/utils/getRecordFromCache';
import { isFieldRelation } from '@/object-record/record-field/types/guards/isFieldRelation';
import { isFieldUuid } from '@/object-record/record-field/types/guards/isFieldUuid';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { getForeignKeyNameFromRelationFieldName } from '@/object-record/utils/getForeignKeyNameFromRelationFieldName';
import { RelationDefinitionType } from '~/generated-metadata/graphql';
import { FieldMetadataType } from '~/generated/graphql';
import { isDefined } from '~/utils/isDefined';
type ComputeOptimisticCacheRecordInputArgs = {
objectMetadataItem: ObjectMetadataItem;
recordInput: Partial<ObjectRecord>;
} & Pick<GetRecordFromCacheArgs, 'cache' | 'objectMetadataItems'>;
export const computeOptimisticRecordFromInput = ({
objectMetadataItem,
recordInput,
cache,
objectMetadataItems,
}: ComputeOptimisticCacheRecordInputArgs) => {
const unknownRecordInputFields = Object.keys(recordInput).filter(
(fieldName) =>
objectMetadataItem.fields.find(({ name }) => name === fieldName) ===
undefined,
);
if (unknownRecordInputFields.length > 0) {
throw new Error(
`Should never occur, encountered unknown fields ${unknownRecordInputFields.join(', ')} in objectMetadaItem ${objectMetadataItem.nameSingular}`,
);
}
const optimisticRecord: Partial<ObjectRecord> = {};
for (const fieldMetadataItem of objectMetadataItem.fields) {
if (isFieldUuid(fieldMetadataItem)) {
const isRelationFieldId = objectMetadataItem.fields.some(
({ type, relationDefinition }) => {
if (type !== FieldMetadataType.RELATION) {
return false;
}
if (!isDefined(relationDefinition)) {
return false;
}
const sourceFieldName = relationDefinition.sourceFieldMetadata.name;
return (
getForeignKeyNameFromRelationFieldName(sourceFieldName) ===
fieldMetadataItem.name
);
},
);
if (isRelationFieldId) {
continue;
}
}
const isRelationField = isFieldRelation(fieldMetadataItem);
const recordInputFieldValue: unknown = recordInput[fieldMetadataItem.name];
if (!isRelationField) {
if (!isDefined(recordInputFieldValue)) {
continue;
}
if (!fieldMetadataItem.isNullable && recordInputFieldValue == null) {
continue;
}
optimisticRecord[fieldMetadataItem.name] = recordInputFieldValue;
continue;
}
if (
fieldMetadataItem.relationDefinition?.direction ===
RelationDefinitionType.ONE_TO_MANY
) {
continue;
}
const isManyToOneRelation =
fieldMetadataItem.relationDefinition?.direction ===
RelationDefinitionType.MANY_TO_ONE;
if (!isManyToOneRelation) {
continue;
}
if (isDefined(recordInputFieldValue)) {
throw new Error(
'Should never provide relation mutation through anything else than the fieldId e.g companyId',
);
}
const relationFieldIdName = getForeignKeyNameFromRelationFieldName(
fieldMetadataItem.name,
);
const recordInputFieldIdValue: string | null | undefined =
recordInput[relationFieldIdName];
if (isUndefined(recordInputFieldIdValue)) {
continue;
}
const relationIdFieldMetadataItem = objectMetadataItem.fields.find(
(field) => field.name === relationFieldIdName,
);
if (!isDefined(relationIdFieldMetadataItem)) {
throw new Error(
'Should never occur, encountered unknown relationId within relations definitions',
);
}
if (isNull(recordInputFieldIdValue)) {
optimisticRecord[relationFieldIdName] = null;
optimisticRecord[fieldMetadataItem.name] = null;
continue;
}
const targetNameSingular =
fieldMetadataItem.relationDefinition?.targetObjectMetadata.nameSingular;
const targetObjectMetataDataItem = objectMetadataItems.find(
({ nameSingular }) => nameSingular === targetNameSingular,
);
if (!isDefined(targetObjectMetataDataItem)) {
throw new Error(
'Should never occur, encountered invalid relation definition',
);
}
const cachedRecord = getRecordFromCache({
cache,
objectMetadataItem: targetObjectMetataDataItem,
objectMetadataItems,
recordId: recordInputFieldIdValue as string,
});
optimisticRecord[relationFieldIdName] = recordInputFieldIdValue;
if (!isDefined(cachedRecord) || Object.keys(cachedRecord).length <= 0) {
continue;
}
optimisticRecord[fieldMetadataItem.name] = cachedRecord;
}
return optimisticRecord;
};

View File

@ -0,0 +1,2 @@
export const getForeignKeyNameFromRelationFieldName = (nameSingular: string) =>
`${nameSingular}Id`;

View File

@ -1,11 +1,8 @@
import { isString } from '@sniptt/guards';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { ObjectRecord } from '@/object-record/types/ObjectRecord'; import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { RelationDefinitionType } from '~/generated-metadata/graphql'; import { RelationDefinitionType } from '~/generated-metadata/graphql';
import { FieldMetadataType } from '~/generated/graphql'; import { FieldMetadataType } from '~/generated/graphql';
import { isDefined } from '~/utils/isDefined'; import { isDefined } from '~/utils/isDefined';
import { getUrlHostName } from '~/utils/url/getUrlHostName';
export const sanitizeRecordInput = ({ export const sanitizeRecordInput = ({
objectMetadataItem, objectMetadataItem,
@ -56,15 +53,5 @@ export const sanitizeRecordInput = ({
}) })
.filter(isDefined), .filter(isDefined),
); );
if ( return filteredResultRecord;
!(
isDefined(filteredResultRecord.domainName) &&
isString(filteredResultRecord.domainName)
)
)
return filteredResultRecord;
return {
...filteredResultRecord,
domainName: getUrlHostName(filteredResultRecord.domainName as string),
};
}; };