[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:
@ -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"`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -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;
|
||||
};
|
||||
@ -0,0 +1,2 @@
|
||||
export const getForeignKeyNameFromRelationFieldName = (nameSingular: string) =>
|
||||
`${nameSingular}Id`;
|
||||
@ -1,11 +1,8 @@
|
||||
import { isString } from '@sniptt/guards';
|
||||
|
||||
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
|
||||
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
|
||||
import { RelationDefinitionType } from '~/generated-metadata/graphql';
|
||||
import { FieldMetadataType } from '~/generated/graphql';
|
||||
import { isDefined } from '~/utils/isDefined';
|
||||
import { getUrlHostName } from '~/utils/url/getUrlHostName';
|
||||
|
||||
export const sanitizeRecordInput = ({
|
||||
objectMetadataItem,
|
||||
@ -56,15 +53,5 @@ export const sanitizeRecordInput = ({
|
||||
})
|
||||
.filter(isDefined),
|
||||
);
|
||||
if (
|
||||
!(
|
||||
isDefined(filteredResultRecord.domainName) &&
|
||||
isString(filteredResultRecord.domainName)
|
||||
)
|
||||
)
|
||||
return filteredResultRecord;
|
||||
return {
|
||||
...filteredResultRecord,
|
||||
domainName: getUrlHostName(filteredResultRecord.domainName as string),
|
||||
};
|
||||
return filteredResultRecord;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user