[REFACTOR][BUG] Dynamically compute field to write in cache CREATE (#10130)

# Introduction
While importing records encountering missing expected fields when
writting a fragment from apollo cache

## Updates

### 1/ `createdBy` Default value
When inserting in cache in create single or many we will now make
optimistic behavior on the createdBy value

### 2/ `createRecordInCache` dynamically create `recordGrqlFields`
When creating an entry in cache, we will now dynamically generate fields
to be written in the fragment instead of expecting all of them. As by
nature record could be partial

### 3/ Strictly typed `RecordGqlFields`

# Conclusion
closes #9927
This commit is contained in:
Paul Rastoin
2025-02-13 17:43:54 +01:00
committed by GitHub
parent 58a62ec6f0
commit 5963c0f384
35 changed files with 495 additions and 107 deletions

View File

@ -1,18 +1,23 @@
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { updateRecordFromCache } from '@/object-record/cache/utils/updateRecordFromCache';
import { generateDepthOneRecordGqlFields } from '@/object-record/graphql/utils/generateDepthOneRecordGqlFields';
import { FieldActorForInputValue } from '@/object-record/record-field/types/FieldMetadata';
import { computeOptimisticRecordFromInput } from '@/object-record/utils/computeOptimisticRecordFromInput';
import { InMemoryCache } from '@apollo/client';
import { getCompanyObjectMetadataItem } from '~/testing/mock-data/companies';
import { generatedMockObjectMetadataItems } from '~/testing/mock-data/generatedMockObjectMetadataItems';
import { getPersonObjectMetadataItem } from '~/testing/mock-data/people';
import { mockCurrentWorkspaceMembers } from '~/testing/mock-data/workspace-members';
describe('computeOptimisticRecordFromInput', () => {
const currentWorkspaceMember = mockCurrentWorkspaceMembers[0];
const currentWorkspaceMemberFullname = `${currentWorkspaceMember.name.firstName} ${currentWorkspaceMember.name.lastName}`;
it('should generate correct optimistic record if no relation field is present', () => {
const cache = new InMemoryCache();
const personObjectMetadataItem = getPersonObjectMetadataItem();
const result = computeOptimisticRecordFromInput({
currentWorkspaceMember,
objectMetadataItems: generatedMockObjectMetadataItems,
objectMetadataItem: personObjectMetadataItem,
recordInput: {
@ -26,11 +31,69 @@ describe('computeOptimisticRecordFromInput', () => {
});
});
it('should generate correct optimistic record with actor field', () => {
const cache = new InMemoryCache();
const personObjectMetadataItem = getPersonObjectMetadataItem();
const actorFieldValueForInput: FieldActorForInputValue = {
context: {},
source: 'API',
};
const result = computeOptimisticRecordFromInput({
currentWorkspaceMember,
objectMetadataItems: generatedMockObjectMetadataItems,
objectMetadataItem: personObjectMetadataItem,
recordInput: {
city: 'Paris',
createdBy: actorFieldValueForInput,
},
cache,
});
expect(result).toEqual({
city: 'Paris',
createdBy: {
context: {},
name: currentWorkspaceMemberFullname,
source: 'API',
workspaceMemberId: currentWorkspaceMember.id,
},
});
});
it('should generate correct optimistic record createdBy when recordInput contains id', () => {
const cache = new InMemoryCache();
const personObjectMetadataItem = getPersonObjectMetadataItem();
const result = computeOptimisticRecordFromInput({
currentWorkspaceMember,
objectMetadataItems: generatedMockObjectMetadataItems,
objectMetadataItem: personObjectMetadataItem,
recordInput: {
id: '20202020-058c-4591-a7d7-50a75af6d1e6',
createdBy: {
source: 'SYSTEM',
context: {},
} satisfies FieldActorForInputValue,
},
cache,
});
expect(result).toEqual({
id: '20202020-058c-4591-a7d7-50a75af6d1e6',
createdBy: {
context: {},
name: currentWorkspaceMemberFullname,
source: 'SYSTEM',
workspaceMemberId: currentWorkspaceMember.id,
},
});
});
it('should generate correct optimistic record if relation field is present but cache is empty', () => {
const cache = new InMemoryCache();
const personObjectMetadataItem = getPersonObjectMetadataItem();
const result = computeOptimisticRecordFromInput({
currentWorkspaceMember,
objectMetadataItems: generatedMockObjectMetadataItems,
objectMetadataItem: personObjectMetadataItem,
recordInput: {
@ -73,6 +136,7 @@ describe('computeOptimisticRecordFromInput', () => {
});
const result = computeOptimisticRecordFromInput({
currentWorkspaceMember,
objectMetadataItems: generatedMockObjectMetadataItems,
objectMetadataItem: personObjectMetadataItem,
recordInput: {
@ -117,6 +181,7 @@ describe('computeOptimisticRecordFromInput', () => {
});
const result = computeOptimisticRecordFromInput({
currentWorkspaceMember,
objectMetadataItems: generatedMockObjectMetadataItems,
objectMetadataItem: personObjectMetadataItem,
recordInput: {
@ -136,6 +201,7 @@ describe('computeOptimisticRecordFromInput', () => {
const personObjectMetadataItem = getPersonObjectMetadataItem();
const result = computeOptimisticRecordFromInput({
currentWorkspaceMember,
objectMetadataItems: generatedMockObjectMetadataItems,
objectMetadataItem: personObjectMetadataItem,
recordInput: {
@ -156,6 +222,7 @@ describe('computeOptimisticRecordFromInput', () => {
expect(() =>
computeOptimisticRecordFromInput({
currentWorkspaceMember,
objectMetadataItems: generatedMockObjectMetadataItems,
objectMetadataItem: personObjectMetadataItem,
recordInput: {
@ -167,7 +234,7 @@ describe('computeOptimisticRecordFromInput', () => {
cache,
}),
).toThrowErrorMatchingInlineSnapshot(
`"Should never occur, encountered unknown fields unknwon, foo, bar in objectMetadaItem person"`,
`"Should never occur, encountered unknown fields unknwon, foo, bar in objectMetadataItem person"`,
);
});
@ -177,6 +244,7 @@ describe('computeOptimisticRecordFromInput', () => {
expect(() =>
computeOptimisticRecordFromInput({
currentWorkspaceMember,
objectMetadataItems: generatedMockObjectMetadataItems,
objectMetadataItem: personObjectMetadataItem,
recordInput: {
@ -196,6 +264,7 @@ describe('computeOptimisticRecordFromInput', () => {
expect(() =>
computeOptimisticRecordFromInput({
currentWorkspaceMember,
objectMetadataItems: generatedMockObjectMetadataItems,
objectMetadataItem: personObjectMetadataItem,
recordInput: {

View File

@ -0,0 +1,29 @@
import { CurrentWorkspaceMember } from '@/auth/states/currentWorkspaceMemberState';
import { FieldActorValue } from '@/object-record/record-field/types/FieldMetadata';
import { isDefined } from 'twenty-shared';
export const buildOptimisticActorFieldValueFromCurrentWorkspaceMember = (
currentWorkspaceMember: CurrentWorkspaceMember | null,
): FieldActorValue => {
const defaultActorFieldValue: FieldActorValue = {
context: {},
name: '',
source: 'MANUAL',
workspaceMemberId: null,
};
if (!isDefined(currentWorkspaceMember)) {
return defaultActorFieldValue;
}
const {
id: workspaceMemberId,
name: { firstName, lastName },
} = currentWorkspaceMember;
const name = `${firstName} ${lastName}`;
return {
...defaultActorFieldValue,
name: name,
workspaceMemberId,
};
};

View File

@ -1,14 +1,18 @@
import { isNull, isUndefined } from '@sniptt/guards';
import { CurrentWorkspaceMember } from '@/auth/states/currentWorkspaceMemberState';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import {
getRecordFromCache,
GetRecordFromCacheArgs,
} from '@/object-record/cache/utils/getRecordFromCache';
import { GRAPHQL_TYPENAME_KEY } from '@/object-record/constants/GraphqlTypenameKey';
import { FieldActorValue } from '@/object-record/record-field/types/FieldMetadata';
import { isFieldActor } from '@/object-record/record-field/types/guards/isFieldActor';
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 { buildOptimisticActorFieldValueFromCurrentWorkspaceMember } from '@/object-record/utils/buildOptimisticActorFieldValueFromCurrentWorkspaceMember';
import { getForeignKeyNameFromRelationFieldName } from '@/object-record/utils/getForeignKeyNameFromRelationFieldName';
import { isDefined } from 'twenty-shared';
import { RelationDefinitionType } from '~/generated-metadata/graphql';
@ -17,12 +21,14 @@ import { FieldMetadataType } from '~/generated/graphql';
type ComputeOptimisticCacheRecordInputArgs = {
objectMetadataItem: ObjectMetadataItem;
recordInput: Partial<ObjectRecord>;
currentWorkspaceMember: CurrentWorkspaceMember | null;
} & Pick<GetRecordFromCacheArgs, 'cache' | 'objectMetadataItems'>;
export const computeOptimisticRecordFromInput = ({
objectMetadataItem,
recordInput,
cache,
objectMetadataItems,
currentWorkspaceMember,
}: ComputeOptimisticCacheRecordInputArgs) => {
const unknownRecordInputFields = Object.keys(recordInput).filter(
(recordKey) => {
@ -35,12 +41,14 @@ export const computeOptimisticRecordFromInput = ({
);
if (unknownRecordInputFields.length > 0) {
throw new Error(
`Should never occur, encountered unknown fields ${unknownRecordInputFields.join(', ')} in objectMetadaItem ${objectMetadataItem.nameSingular}`,
`Should never occur, encountered unknown fields ${unknownRecordInputFields.join(', ')} in objectMetadataItem ${objectMetadataItem.nameSingular}`,
);
}
const optimisticRecord: Partial<ObjectRecord> = {};
for (const fieldMetadataItem of objectMetadataItem.fields) {
const recordInputFieldValue: unknown = recordInput[fieldMetadataItem.name];
if (isFieldUuid(fieldMetadataItem)) {
const isRelationFieldId = objectMetadataItem.fields.some(
({ type, relationDefinition }) => {
@ -65,10 +73,19 @@ export const computeOptimisticRecordFromInput = ({
}
}
if (isFieldActor(fieldMetadataItem) && isDefined(recordInputFieldValue)) {
const defaultActorFieldValue =
buildOptimisticActorFieldValueFromCurrentWorkspaceMember(
currentWorkspaceMember,
);
optimisticRecord[fieldMetadataItem.name] = {
...defaultActorFieldValue,
...(recordInputFieldValue as FieldActorValue),
};
continue;
}
const isRelationField = isFieldRelation(fieldMetadataItem);
const recordInputFieldValue: unknown = recordInput[fieldMetadataItem.name];
if (!isRelationField) {
if (!isDefined(recordInputFieldValue)) {
continue;

View File

@ -4,14 +4,19 @@ import { generateEmptyFieldValue } from '@/object-record/utils/generateEmptyFiel
import { v4 } from 'uuid';
import { stripSimpleQuotesFromString } from '~/utils/string/stripSimpleQuotesFromString';
export const generateDefaultFieldValue = (
fieldMetadataItem: Pick<FieldMetadataItem, 'defaultValue' | 'type'>,
) => {
type GenerateEmptyFieldValueArgs = {
fieldMetadataItem: Pick<FieldMetadataItem, 'defaultValue' | 'type'>;
};
export const generateDefaultFieldValue = ({
fieldMetadataItem,
}: GenerateEmptyFieldValueArgs) => {
const defaultValue = isFieldValueEmpty({
fieldValue: fieldMetadataItem.defaultValue,
fieldDefinition: fieldMetadataItem,
})
? generateEmptyFieldValue(fieldMetadataItem)
? generateEmptyFieldValue({
fieldMetadataItem,
})
: stripSimpleQuotesFromString(fieldMetadataItem.defaultValue);
switch (defaultValue) {

View File

@ -1,13 +1,18 @@
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { FieldActorValue } from '@/object-record/record-field/types/FieldMetadata';
import { assertUnreachable } from '@/workflow/utils/assertUnreachable';
import {
FieldMetadataType,
RelationDefinitionType,
} from '~/generated-metadata/graphql';
export const generateEmptyFieldValue = (
fieldMetadataItem: Pick<FieldMetadataItem, 'type' | 'relationDefinition'>,
) => {
export type GenerateEmptyFieldValueArgs = {
fieldMetadataItem: Pick<FieldMetadataItem, 'type' | 'relationDefinition'>;
};
// TODO strictly type each fieldValue following their FieldMetadataType
export const generateEmptyFieldValue = ({
fieldMetadataItem,
}: GenerateEmptyFieldValueArgs) => {
switch (fieldMetadataItem.type) {
case FieldMetadataType.TEXT: {
return '';
@ -94,10 +99,10 @@ export const generateEmptyFieldValue = (
case FieldMetadataType.ACTOR: {
return {
source: 'MANUAL',
workspaceMemberId: null,
name: '',
context: {},
};
name: '',
workspaceMemberId: null,
} satisfies FieldActorValue;
}
case FieldMetadataType.PHONES: {
return {

View File

@ -7,13 +7,14 @@ import { generateDefaultFieldValue } from '@/object-record/utils/generateDefault
import { isDefined } from 'twenty-shared';
import { FieldMetadataType, RelationDefinitionType } from '~/generated/graphql';
type PrefillRecordArgs = {
objectMetadataItem: ObjectMetadataItem;
input: Record<string, unknown>;
};
export const prefillRecord = <T extends ObjectRecord>({
objectMetadataItem,
input,
}: {
objectMetadataItem: ObjectMetadataItem;
input: Record<string, unknown>;
}) => {
}: PrefillRecordArgs) => {
return Object.fromEntries(
objectMetadataItem.fields
.map((fieldMetadataItem) => {
@ -26,12 +27,10 @@ export const prefillRecord = <T extends ObjectRecord>({
throwIfInputRelationDataIsInconsistent(input, fieldMetadataItem);
}
return [
fieldMetadataItem.name,
isUndefined(inputValue)
? generateDefaultFieldValue(fieldMetadataItem)
: inputValue,
];
const fieldValue = isUndefined(inputValue)
? generateDefaultFieldValue({ fieldMetadataItem })
: inputValue;
return [fieldMetadataItem.name, fieldValue];
})
.filter(isDefined),
) as T;