[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

@ -95,7 +95,7 @@ idealCustomerProfile
it('should return only return relation subFields that are in recordGqlFields', async () => {
const res = mapFieldMetadataToGraphQLQuery({
objectMetadataItems: generatedMockObjectMetadataItems,
relationrecordFields: {
relationRecordGqlFields: {
accountOwner: { id: true, name: true },
people: true,
xLink: true,

View File

@ -0,0 +1,10 @@
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { FieldMetadataType } from 'twenty-shared';
export const checkObjectMetadataItemHasFieldCreatedBy = (
objectMetadataItem: ObjectMetadataItem,
) =>
objectMetadataItem.fields.some(
(field) =>
field.type === FieldMetadataType.ACTOR && field.name === 'createdBy',
);

View File

@ -1,26 +1,27 @@
import { isUndefined } from '@sniptt/guards';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { mapObjectMetadataToGraphQLQuery } from '@/object-metadata/utils/mapObjectMetadataToGraphQLQuery';
import { isUndefined } from '@sniptt/guards';
import {
FieldMetadataType,
RelationDefinitionType,
} from '~/generated-metadata/graphql';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { RecordGqlFields } from '@/object-record/graphql/types/RecordGqlFields';
import { FieldMetadataItem } from '../types/FieldMetadataItem';
type MapFieldMetadataToGraphQLQueryArgs = {
objectMetadataItems: ObjectMetadataItem[];
field: Pick<FieldMetadataItem, 'name' | 'type' | 'relationDefinition'>;
relationRecordGqlFields?: RecordGqlFields;
computeReferences?: boolean;
};
// TODO: change ObjectMetadataItems mock before refactoring with relationDefinition computed field
export const mapFieldMetadataToGraphQLQuery = ({
objectMetadataItems,
field,
relationrecordFields,
relationRecordGqlFields,
computeReferences = false,
}: {
objectMetadataItems: ObjectMetadataItem[];
field: Pick<FieldMetadataItem, 'name' | 'type' | 'relationDefinition'>;
relationrecordFields?: Record<string, any>;
computeReferences?: boolean;
}): any => {
}: MapFieldMetadataToGraphQLQueryArgs): string => {
const fieldType = field.type;
const fieldIsSimpleValue = [
@ -61,7 +62,7 @@ export const mapFieldMetadataToGraphQLQuery = ({
${mapObjectMetadataToGraphQLQuery({
objectMetadataItems,
objectMetadataItem: relationMetadataItem,
recordGqlFields: relationrecordFields,
recordGqlFields: relationRecordGqlFields,
computeReferences: computeReferences,
isRootLevel: false,
})}`;
@ -87,7 +88,7 @@ ${mapObjectMetadataToGraphQLQuery({
node ${mapObjectMetadataToGraphQLQuery({
objectMetadataItems,
objectMetadataItem: relationMetadataItem,
recordGqlFields: relationrecordFields,
recordGqlFields: relationRecordGqlFields,
computeReferences,
isRootLevel: false,
})}

View File

@ -1,20 +1,23 @@
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { mapFieldMetadataToGraphQLQuery } from '@/object-metadata/utils/mapFieldMetadataToGraphQLQuery';
import { shouldFieldBeQueried } from '@/object-metadata/utils/shouldFieldBeQueried';
import { RecordGqlFields } from '@/object-record/graphql/types/RecordGqlFields';
import { isRecordGqlFieldsNode } from '@/object-record/graphql/utils/isRecordGraphlFieldsNode';
type MapObjectMetadataToGraphQLQueryArgs = {
objectMetadataItems: ObjectMetadataItem[];
objectMetadataItem: Pick<ObjectMetadataItem, 'nameSingular' | 'fields'>;
recordGqlFields?: RecordGqlFields;
computeReferences?: boolean;
isRootLevel?: boolean;
};
export const mapObjectMetadataToGraphQLQuery = ({
objectMetadataItems,
objectMetadataItem,
recordGqlFields,
computeReferences = false,
isRootLevel = true,
}: {
objectMetadataItems: ObjectMetadataItem[];
objectMetadataItem: Pick<ObjectMetadataItem, 'nameSingular' | 'fields'>;
recordGqlFields?: Record<string, any>;
computeReferences?: boolean;
isRootLevel?: boolean;
}): any => {
}: MapObjectMetadataToGraphQLQueryArgs): string => {
const fieldsThatShouldBeQueried =
objectMetadataItem?.fields
.filter((field) => field.isActive)
@ -36,13 +39,16 @@ export const mapObjectMetadataToGraphQLQuery = ({
__typename
${fieldsThatShouldBeQueried
.map((field) => {
const currentRecordGqlFields = recordGqlFields?.[field.name];
const relationRecordGqlFields = isRecordGqlFieldsNode(
currentRecordGqlFields,
)
? currentRecordGqlFields
: undefined;
return mapFieldMetadataToGraphQLQuery({
objectMetadataItems,
field,
relationrecordFields:
typeof recordGqlFields?.[field.name] === 'boolean'
? undefined
: recordGqlFields?.[field.name],
relationRecordGqlFields,
computeReferences,
});
})

View File

@ -24,6 +24,10 @@ export const useCreateOneRecordInCache = <T extends ObjectRecord>({
const apolloClient = useApolloClient();
return (record: ObjectRecord) => {
const recordGqlFields = generateDepthOneRecordGqlFields({
objectMetadataItem,
record,
});
const fragment = gql`
fragment Create${capitalize(
objectMetadataItem.nameSingular,
@ -33,9 +37,7 @@ export const useCreateOneRecordInCache = <T extends ObjectRecord>({
objectMetadataItems,
objectMetadataItem,
computeReferences: true,
recordGqlFields: generateDepthOneRecordGqlFields({
objectMetadataItem,
}),
recordGqlFields,
})}
`;

View File

@ -1 +1,3 @@
export type RecordGqlFields = Record<string, any>;
export type RecordGqlFields = {
[k: string]: boolean | RecordGqlFields | undefined;
};

View File

@ -0,0 +1 @@
export type RecordGqlFieldsDeprecated = Record<string, any>;

View File

@ -1,3 +1,3 @@
import { RecordGqlFields } from '@/object-record/graphql/types/RecordGqlFields';
import { RecordGqlFieldsDeprecated } from '@/object-record/graphql/types/RecordGqlFieldsDeprecated';
export type RecordGqlOperationGqlRecordFields = RecordGqlFields;
export type RecordGqlOperationGqlRecordFields = RecordGqlFieldsDeprecated;

View File

@ -0,0 +1,88 @@
import { generateDepthOneRecordGqlFields } from '@/object-record/graphql/utils/generateDepthOneRecordGqlFields';
import {
getPersonObjectMetadataItem,
getPersonRecord,
} from '~/testing/mock-data/people';
describe('generateDepthOneRecordGqlFields', () => {
const objectMetadataItem = getPersonObjectMetadataItem();
it('Should handle basic call with both objectMetadataItem and record', () => {
const personRecord = getPersonRecord();
const result = generateDepthOneRecordGqlFields({
objectMetadataItem,
record: personRecord,
});
expect(result).toMatchInlineSnapshot(`
{
"attachments": false,
"avatarUrl": false,
"calendarEventParticipants": false,
"city": true,
"company": true,
"companyId": false,
"createdAt": true,
"createdBy": true,
"deletedAt": true,
"emails": false,
"favorites": false,
"id": true,
"intro": false,
"jobTitle": true,
"linkedinLink": true,
"messageParticipants": false,
"name": true,
"noteTargets": true,
"performanceRating": false,
"phones": true,
"pointOfContactForOpportunities": false,
"position": true,
"searchVector": false,
"taskTargets": true,
"timelineActivities": false,
"updatedAt": false,
"whatsapp": false,
"workPreference": false,
"xLink": true,
}
`);
});
it('Should handle basic call with standalone objectMetadataItem', () => {
const result = generateDepthOneRecordGqlFields({
objectMetadataItem,
});
expect(result).toMatchInlineSnapshot(`
{
"attachments": true,
"avatarUrl": true,
"calendarEventParticipants": true,
"city": true,
"company": true,
"companyId": true,
"createdAt": true,
"createdBy": true,
"deletedAt": true,
"emails": true,
"favorites": true,
"id": true,
"intro": true,
"jobTitle": true,
"linkedinLink": true,
"messageParticipants": true,
"name": true,
"noteTargets": true,
"performanceRating": true,
"phones": true,
"pointOfContactForOpportunities": true,
"position": true,
"searchVector": true,
"taskTargets": true,
"timelineActivities": true,
"updatedAt": true,
"whatsapp": true,
"workPreference": true,
"xLink": true,
}
`);
});
});

View File

@ -0,0 +1,10 @@
import { RecordGqlFields } from '@/object-record/graphql/types/RecordGqlFields';
import { isDefined } from 'twenty-shared';
export const isRecordGqlFieldsNode = (
recordGql: RecordGqlFields | boolean | undefined,
): recordGql is RecordGqlFields =>
isDefined(recordGql) &&
typeof recordGql === 'object' &&
recordGql !== null &&
!Array.isArray(recordGql);

View File

@ -3,8 +3,10 @@ import { v4 } from 'uuid';
import { triggerCreateRecordsOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerCreateRecordsOptimisticEffect';
import { triggerDestroyRecordsOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerDestroyRecordsOptimisticEffect';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { checkObjectMetadataItemHasFieldCreatedBy } from '@/object-metadata/utils/checkObjectMetadataItemHasFieldCreatedBy';
import { useCreateOneRecordInCache } from '@/object-record/cache/hooks/useCreateOneRecordInCache';
import { deleteRecordFromCache } from '@/object-record/cache/utils/deleteRecordFromCache';
import { getObjectTypename } from '@/object-record/cache/utils/getObjectTypename';
@ -13,10 +15,12 @@ import { RecordGqlOperationGqlRecordFields } from '@/object-record/graphql/types
import { generateDepthOneRecordGqlFields } from '@/object-record/graphql/utils/generateDepthOneRecordGqlFields';
import { useCreateManyRecordsMutation } from '@/object-record/hooks/useCreateManyRecordsMutation';
import { useRefetchAggregateQueries } from '@/object-record/hooks/useRefetchAggregateQueries';
import { FieldActorForInputValue } from '@/object-record/record-field/types/FieldMetadata';
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 { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-shared';
type PartialObjectRecordWithId = Partial<ObjectRecord> & {
@ -44,6 +48,9 @@ export const useCreateManyRecords = <
objectNameSingular,
});
const objectMetadataHasCreatedByField =
checkObjectMetadataItemHasFieldCreatedBy(objectMetadataItem);
const computedRecordGqlFields =
recordGqlFields ?? generateDepthOneRecordGqlFields({ objectMetadataItem });
@ -56,6 +63,8 @@ export const useCreateManyRecords = <
objectMetadataItem,
});
const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState);
const { objectMetadataItems } = useObjectMetadataItems();
const { refetchAggregateQueries } = useRefetchAggregateQueries({
@ -77,12 +86,26 @@ export const useCreateManyRecords = <
}),
id: idForCreation,
};
const baseOptimisticRecordInputCreatedBy:
| { createdBy: FieldActorForInputValue }
| undefined = objectMetadataHasCreatedByField
? {
createdBy: {
source: 'MANUAL',
context: {},
},
}
: undefined;
const optimisticRecordInput = {
...computeOptimisticRecordFromInput({
cache: apolloClient.cache,
objectMetadataItem,
objectMetadataItems,
recordInput: recordToCreate,
currentWorkspaceMember: currentWorkspaceMember,
recordInput: {
...baseOptimisticRecordInputCreatedBy,
...recordToCreate,
},
}),
id: idForCreation,
};

View File

@ -4,8 +4,10 @@ import { v4 } from 'uuid';
import { triggerCreateRecordsOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerCreateRecordsOptimisticEffect';
import { triggerDestroyRecordsOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerDestroyRecordsOptimisticEffect';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { checkObjectMetadataItemHasFieldCreatedBy } from '@/object-metadata/utils/checkObjectMetadataItemHasFieldCreatedBy';
import { useCreateOneRecordInCache } from '@/object-record/cache/hooks/useCreateOneRecordInCache';
import { deleteRecordFromCache } from '@/object-record/cache/utils/deleteRecordFromCache';
import { getObjectTypename } from '@/object-record/cache/utils/getObjectTypename';
@ -14,10 +16,12 @@ import { RecordGqlOperationGqlRecordFields } from '@/object-record/graphql/types
import { generateDepthOneRecordGqlFields } from '@/object-record/graphql/utils/generateDepthOneRecordGqlFields';
import { useCreateOneRecordMutation } from '@/object-record/hooks/useCreateOneRecordMutation';
import { useRefetchAggregateQueries } from '@/object-record/hooks/useRefetchAggregateQueries';
import { FieldActorForInputValue } from '@/object-record/record-field/types/FieldMetadata';
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 { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-shared';
type useCreateOneRecordProps = {
@ -42,6 +46,9 @@ export const useCreateOneRecord = <
objectNameSingular,
});
const objectMetadataHasCreatedByField =
checkObjectMetadataItemHasFieldCreatedBy(objectMetadataItem);
const computedRecordGqlFields =
recordGqlFields ?? generateDepthOneRecordGqlFields({ objectMetadataItem });
@ -50,6 +57,8 @@ export const useCreateOneRecord = <
recordGqlFields: computedRecordGqlFields,
});
const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState);
const createOneRecordInCache = useCreateOneRecordInCache<CreatedObjectRecord>(
{
objectMetadataItem,
@ -75,11 +84,26 @@ export const useCreateOneRecord = <
id: idForCreation,
};
const baseOptimisticRecordInputCreatedBy:
| { createdBy: FieldActorForInputValue }
| undefined = objectMetadataHasCreatedByField
? {
createdBy: {
source: 'MANUAL',
context: {},
},
}
: undefined;
const optimisticRecordInput = computeOptimisticRecordFromInput({
cache: apolloClient.cache,
currentWorkspaceMember: currentWorkspaceMember,
objectMetadataItem,
objectMetadataItems,
recordInput: { ...recordInput, id: idForCreation },
recordInput: {
...baseOptimisticRecordInputCreatedBy,
...recordInput,
id: idForCreation,
},
});
const recordCreatedInCache = createOneRecordInCache({
...optimisticRecordInput,

View File

@ -1,6 +1,7 @@
import { useApolloClient } from '@apollo/client';
import { triggerUpdateRecordOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerUpdateRecordOptimisticEffect';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { useGetRecordFromCache } from '@/object-record/cache/hooks/useGetRecordFromCache';
@ -15,6 +16,7 @@ import { computeOptimisticRecordFromInput } from '@/object-record/utils/computeO
import { getUpdateOneRecordMutationResponseField } from '@/object-record/utils/getUpdateOneRecordMutationResponseField';
import { sanitizeRecordInput } from '@/object-record/utils/sanitizeRecordInput';
import { isNull } from '@sniptt/guards';
import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-shared';
import { buildRecordFromKeysWithSameValue } from '~/utils/array/buildRecordFromKeysWithSameValue';
@ -22,7 +24,11 @@ type useUpdateOneRecordProps = {
objectNameSingular: string;
recordGqlFields?: Record<string, any>;
};
type UpdateOneRecordArgs<UpdatedObjectRecord> = {
idToUpdate: string;
updateOneRecordInput: Partial<Omit<UpdatedObjectRecord, 'id'>>;
optimisticRecord?: Partial<ObjectRecord>;
};
export const useUpdateOneRecord = <
UpdatedObjectRecord extends ObjectRecord = ObjectRecord,
>({
@ -47,6 +53,8 @@ export const useUpdateOneRecord = <
recordGqlFields: computedRecordGqlFields,
});
const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState);
const { objectMetadataItems } = useObjectMetadataItems();
const { refetchAggregateQueries } = useRefetchAggregateQueries({
@ -57,15 +65,12 @@ export const useUpdateOneRecord = <
idToUpdate,
updateOneRecordInput,
optimisticRecord,
}: {
idToUpdate: string;
updateOneRecordInput: Partial<Omit<UpdatedObjectRecord, 'id'>>;
optimisticRecord?: Partial<ObjectRecord>;
}) => {
}: UpdateOneRecordArgs<UpdatedObjectRecord>) => {
const optimisticRecordInput =
optimisticRecord ??
computeOptimisticRecordFromInput({
objectMetadataItem,
currentWorkspaceMember: currentWorkspaceMember,
recordInput: updateOneRecordInput,
cache: apolloClient.cache,
objectMetadataItems,

View File

@ -212,7 +212,7 @@ describe('useCombinedFindManyRecords', () => {
firstName: true,
lastName: true,
},
} as RecordGqlFields,
} satisfies RecordGqlFields,
variables: {},
},
{
@ -220,7 +220,7 @@ describe('useCombinedFindManyRecords', () => {
fields: {
id: true,
name: true,
} as RecordGqlFields,
} satisfies RecordGqlFields,
variables: {},
},
],
@ -283,7 +283,7 @@ describe('useCombinedFindManyRecords', () => {
firstName: true,
lastName: true,
},
} as RecordGqlFields,
} satisfies RecordGqlFields,
variables: {
limit: 1,
cursorFilter: {
@ -349,7 +349,7 @@ describe('useCombinedFindManyRecords', () => {
firstName: true,
lastName: true,
},
} as RecordGqlFields,
} satisfies RecordGqlFields,
variables: {
limit: 1,
cursorFilter: {
@ -415,7 +415,7 @@ describe('useCombinedFindManyRecords', () => {
firstName: true,
lastName: true,
},
} as RecordGqlFields,
} satisfies RecordGqlFields,
variables: {
limit: 1,
},
@ -495,7 +495,7 @@ describe('useCombinedFindManyRecords', () => {
firstName: true,
lastName: true,
},
} as RecordGqlFields,
} satisfies RecordGqlFields,
variables: {
limit: 1,
cursorFilter: {
@ -509,7 +509,7 @@ describe('useCombinedFindManyRecords', () => {
fields: {
id: true,
name: true,
} as RecordGqlFields,
} satisfies RecordGqlFields,
variables: {
limit: 1,
},
@ -558,7 +558,7 @@ describe('useCombinedFindManyRecords', () => {
objectNameSingular: 'person',
fields: {
id: true,
} as RecordGqlFields,
} satisfies RecordGqlFields,
variables: {},
},
],

View File

@ -13,7 +13,7 @@ describe('useCombinedFindManyRecordsQueryVariables', () => {
firstName: true,
lastName: true,
},
} as RecordGqlFields,
} satisfies RecordGqlFields,
variables: {
filter: { id: { eq: '123' } },
orderBy: [{ createdAt: 'AscNullsLast' }],

View File

@ -42,7 +42,9 @@ export const useClearField = () => {
const fieldName = fieldDefinition.metadata.fieldName;
const emptyFieldValue = generateEmptyFieldValue(foundFieldMetadataItem);
const emptyFieldValue = generateEmptyFieldValue({
fieldMetadataItem: foundFieldMetadataItem,
});
set(
recordStoreFamilySelector({ recordId, fieldName }),

View File

@ -279,15 +279,29 @@ export type FieldRichTextV2Value = {
export type FieldRichTextValue = null | string;
type FieldActorSource =
| 'API'
| 'IMPORT'
| 'EMAIL'
| 'CALENDAR'
| 'MANUAL'
| 'SYSTEM'
| 'WORKFLOW';
export type FieldActorValue = {
source: string;
workspaceMemberId?: string;
source: FieldActorSource;
workspaceMemberId: string | null;
name: string;
context?: {
context: {
provider?: ConnectedAccountProvider;
};
} | null;
};
export type FieldActorForInputValue = Pick<
FieldActorValue,
'context' | 'source'
>;
export type FieldArrayValue = string[];
export type PhoneRecord = {

View File

@ -26,7 +26,9 @@ export const RightDrawerTitleRecordInlineCell = () => {
const draftValue = useRecoilValue(getDraftValueSelector());
useListenRightDrawerClose(() => {
persistField(draftValue);
if (draftValue !== undefined) {
persistField(draftValue);
}
closeInlineCell();
});

View File

@ -8,6 +8,7 @@ import { spreadsheetImportDialogState } from '@/spreadsheet-import/states/spread
import { useOpenObjectRecordsSpreadsheetImportDialog } from '@/object-record/spreadsheet-import/hooks/useOpenObjectRecordsSpreadsheetImportDialog';
import { FieldActorForInputValue } from '@/object-record/record-field/types/FieldMetadata';
import { getJestMetadataAndApolloMocksWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksWrapper';
const companyId = 'cb2e9f4b-20c3-4759-9315-4ffeecfaf71a';
@ -294,7 +295,10 @@ const companyMocks = [
variables: {
data: [
{
createdBy: { source: 'IMPORT' },
createdBy: {
source: 'IMPORT',
context: {},
} satisfies FieldActorForInputValue,
employees: 0,
idealCustomerProfile: true,
name: 'Example Company',

View File

@ -56,16 +56,17 @@ export const useOpenObjectRecordsSpreadsheetImportDialog = (
onSubmit: async (data) => {
const createInputs = data.validStructuredRows.map((record) => {
const fieldMapping: Record<string, any> =
buildRecordFromImportedStructuredRow(
record,
availableFieldMetadataItems,
);
buildRecordFromImportedStructuredRow({
importedStructuredRow: record,
fields: availableFieldMetadataItems,
});
return fieldMapping;
});
try {
await createManyRecords(createInputs, true);
const upsert = true;
await createManyRecords(createInputs, upsert);
} catch (error: any) {
enqueueSnackBar(error?.message || 'Something went wrong', {
variant: SnackBarVariant.Error,

View File

@ -1,5 +1,6 @@
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import {
FieldActorForInputValue,
FieldAddressValue,
FieldEmailsValue,
FieldLinksValue,
@ -15,10 +16,14 @@ import { FieldMetadataType } from '~/generated-metadata/graphql';
import { castToString } from '~/utils/castToString';
import { convertCurrencyAmountToCurrencyMicros } from '~/utils/convertCurrencyToCurrencyMicros';
export const buildRecordFromImportedStructuredRow = (
importedStructuredRow: ImportedStructuredRow<any>,
fields: FieldMetadataItem[],
) => {
type BuildRecordFromImportedStructuredRowArgs = {
importedStructuredRow: ImportedStructuredRow<any>;
fields: FieldMetadataItem[];
};
export const buildRecordFromImportedStructuredRow = ({
fields,
importedStructuredRow,
}: BuildRecordFromImportedStructuredRowArgs) => {
const recordToBuild: Record<string, any> = {};
const {
@ -219,7 +224,8 @@ export const buildRecordFromImportedStructuredRow = (
case FieldMetadataType.ACTOR:
recordToBuild[field.name] = {
source: 'IMPORT',
};
context: {},
} satisfies FieldActorForInputValue;
break;
case FieldMetadataType.ARRAY:
case FieldMetadataType.MULTI_SELECT: {

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;

View File

@ -23,11 +23,12 @@ export const usePrefetchedData = <T extends ObjectRecord>(
objectNameSingular,
});
const recordGqlFields =
operationSignatureFactory({ objectMetadataItem }).fields ?? filter;
const { records } = useFindManyRecords<T>({
skip: !isDataPrefetched,
objectNameSingular: objectNameSingular,
recordGqlFields:
operationSignatureFactory({ objectMetadataItem }).fields ?? filter,
recordGqlFields,
});
return {

View File

@ -181,7 +181,7 @@ export const SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS = {
context: 'Context',
},
exampleValue: {
source: 'source',
source: 'IMPORT',
name: 'name',
workspaceMemberId: 'id',
context: { provider: ConnectedAccountProvider.GOOGLE },

View File

@ -53,6 +53,8 @@ export const useFieldPreviewValue = ({
case FieldMetadataType.PHONES:
return getPhonesFieldPreviewValue({ fieldMetadataItem });
default:
return getFieldPreviewValue({ fieldMetadataItem });
return getFieldPreviewValue({
fieldMetadataItem,
});
}
};

View File

@ -24,7 +24,9 @@ describe('getFieldPreviewValue', () => {
}
// When
const result = getFieldPreviewValue({ fieldMetadataItem });
const result = getFieldPreviewValue({
fieldMetadataItem,
});
// Then
expect(result).toBe(false);
@ -42,7 +44,9 @@ describe('getFieldPreviewValue', () => {
}
// When
const result = getFieldPreviewValue({ fieldMetadataItem });
const result = getFieldPreviewValue({
fieldMetadataItem,
});
// Then
expect(result).toBe(2000);
@ -63,7 +67,9 @@ describe('getFieldPreviewValue', () => {
}
// When
const result = getFieldPreviewValue({ fieldMetadataItem });
const result = getFieldPreviewValue({
fieldMetadataItem,
});
// Then
expect(result).toBeNull();

View File

@ -5,11 +5,12 @@ import { getSettingsFieldTypeConfig } from '@/settings/data-model/utils/getSetti
import { isFieldTypeSupportedInSettings } from '@/settings/data-model/utils/isFieldTypeSupportedInSettings';
import { isDefined } from 'twenty-shared';
type getFieldPreviewValueArgs = {
fieldMetadataItem: Pick<FieldMetadataItem, 'type' | 'defaultValue'>;
};
export const getFieldPreviewValue = ({
fieldMetadataItem,
}: {
fieldMetadataItem: Pick<FieldMetadataItem, 'type' | 'defaultValue'>;
}) => {
}: getFieldPreviewValueArgs) => {
if (!isFieldTypeSupportedInSettings(fieldMetadataItem.type)) return null;
if (
@ -18,7 +19,9 @@ export const getFieldPreviewValue = ({
fieldValue: fieldMetadataItem.defaultValue,
})
) {
return generateDefaultFieldValue(fieldMetadataItem);
return generateDefaultFieldValue({
fieldMetadataItem,
});
}
const fieldTypeConfig = getSettingsFieldTypeConfig(fieldMetadataItem.type);

View File

@ -3,15 +3,16 @@ import { generateDepthOneRecordGqlFields } from '@/object-record/graphql/utils/g
import { prefillRecord } from '@/object-record/utils/prefillRecord';
import { generatedMockObjectMetadataItems } from '~/testing/mock-data/generatedMockObjectMetadataItems';
type GenerateEmptyJestRecordNodeArgs = {
objectNameSingular: string;
input: Record<string, unknown>;
withDepthOneRelation?: boolean;
};
export const generateEmptyJestRecordNode = ({
objectNameSingular,
input,
withDepthOneRelation = false,
}: {
objectNameSingular: string;
input: Record<string, unknown>;
withDepthOneRelation?: boolean;
}) => {
}: GenerateEmptyJestRecordNodeArgs) => {
const objectMetadataItem = generatedMockObjectMetadataItems.find(
(item) => item.nameSingular === objectNameSingular,
);
@ -22,7 +23,10 @@ export const generateEmptyJestRecordNode = ({
);
}
const prefilledRecord = prefillRecord({ objectMetadataItem, input });
const prefilledRecord = prefillRecord({
objectMetadataItem,
input,
});
return getRecordNodeFromRecord({
record: prefilledRecord,

View File

@ -1,5 +1,6 @@
import { RecordGqlConnection } from '@/object-record/graphql/types/RecordGqlConnection';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { FieldMetadataType } from 'twenty-shared';
import { generatedMockObjectMetadataItems } from '~/testing/mock-data/generatedMockObjectMetadataItems';
export const getPeopleMock = (): ObjectRecord[] => {
@ -20,6 +21,22 @@ export const getPersonObjectMetadataItem = () => {
return personObjectMetadataItem;
};
export const getPersonFieldMetadataItem = (
fieldMetadataType: FieldMetadataType,
objectMetadataItem = getPersonObjectMetadataItem(),
) => {
const result = objectMetadataItem.fields.find(
(field) => field.type === fieldMetadataType,
);
if (!result) {
throw new Error(
`Person fieldmetadata item type ${fieldMetadataType} not found`,
);
}
return result;
};
export const getPersonRecord = (
overrides?: Partial<ObjectRecord>,
index = 0,

View File

@ -1,10 +1,15 @@
export const mockWorkspaceMembers = [
import { CurrentWorkspaceMember } from '@/auth/states/currentWorkspaceMemberState';
import { WorkspaceMember } from '@/workspace-member/types/WorkspaceMember';
export const mockWorkspaceMembers: WorkspaceMember[] = [
{
id: '20202020-1553-45c6-a028-5a9064cce07f',
name: {
firstName: 'Jane',
lastName: 'Doe',
},
__typename: 'WorkspaceMember',
userEmail: 'jane.doe@twenty.com',
locale: 'en',
avatarUrl: '',
createdAt: '2023-12-18T09:51:19.645Z',
@ -18,6 +23,8 @@ export const mockWorkspaceMembers = [
firstName: 'John',
lastName: 'Wick',
},
userEmail: 'john.wick@twenty.com',
__typename: 'WorkspaceMember',
locale: 'en',
avatarUrl: '',
createdAt: '2023-12-18T09:51:19.645Z',
@ -26,3 +33,26 @@ export const mockWorkspaceMembers = [
colorScheme: 'Dark' as const,
},
];
export const mockCurrentWorkspaceMembers: CurrentWorkspaceMember[] =
mockWorkspaceMembers.map(
({
id,
locale,
name,
avatarUrl,
colorScheme,
dateFormat,
timeFormat,
timeZone,
}) => ({
id,
locale,
name,
avatarUrl,
colorScheme,
dateFormat,
timeFormat,
timeZone,
}),
);