From 5963c0f3842dd2d302edfc066776a07c00878a71 Mon Sep 17 00:00:00 2001 From: Paul Rastoin <45004772+prastoin@users.noreply.github.com> Date: Thu, 13 Feb 2025 17:43:54 +0100 Subject: [PATCH] [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 --- .../mapFieldMetadataToGraphQLQuery.test.ts | 2 +- ...heckObjectMetadataItemHasFieldCreatedBy.ts | 10 +++ .../utils/mapFieldMetadataToGraphQLQuery.ts | 25 +++--- .../utils/mapObjectMetadataToGraphQLQuery.ts | 28 +++--- .../cache/hooks/useCreateOneRecordInCache.ts | 8 +- .../graphql/types/RecordGqlFields.ts | 4 +- .../types/RecordGqlFieldsDeprecated.ts | 1 + .../RecordGqlOperationGqlRecordFields.ts | 4 +- .../generateDepthOneRecordGqlFields.test.ts | 88 +++++++++++++++++++ .../graphql/utils/isRecordGraphlFieldsNode.ts | 10 +++ .../hooks/useCreateManyRecords.ts | 25 +++++- .../object-record/hooks/useCreateOneRecord.ts | 26 +++++- .../object-record/hooks/useUpdateOneRecord.ts | 17 ++-- .../useCombinedFindManyRecords.test.tsx | 16 ++-- ...binedFindManyRecordsQueryVariables.test.ts | 2 +- .../record-field/hooks/useClearField.ts | 4 +- .../record-field/types/FieldMetadata.ts | 22 ++++- .../RightDrawerTitleRecordInlineCell.tsx | 4 +- ...jectRecordsSpreadsheetImportDialog.test.ts | 6 +- ...penObjectRecordsSpreadsheetImportDialog.ts | 11 +-- .../buildRecordFromImportedStructuredRow.ts | 16 ++-- .../computeOptimisticRecordFromInput.test.ts | 71 ++++++++++++++- ...torFieldValueFromCurrentWorkspaceMember.ts | 29 ++++++ .../utils/computeOptimisticRecordFromInput.ts | 25 +++++- .../utils/generateDefaultFieldValue.ts | 13 ++- .../utils/generateEmptyFieldValue.ts | 17 ++-- .../object-record/utils/prefillRecord.ts | 19 ++-- .../prefetch/hooks/usePrefetchedData.ts | 5 +- .../SettingsCompositeFieldTypeConfigs.ts | 2 +- .../preview/hooks/useFieldPreviewValue.ts | 4 +- .../__tests__/getFieldPreviewValue.test.ts | 12 ++- .../preview/utils/getFieldPreviewValue.ts | 11 ++- .../jest/generateEmptyJestRecordNode.ts | 16 ++-- .../src/testing/mock-data/people.ts | 17 ++++ .../testing/mock-data/workspace-members.ts | 32 ++++++- 35 files changed, 495 insertions(+), 107 deletions(-) create mode 100644 packages/twenty-front/src/modules/object-metadata/utils/checkObjectMetadataItemHasFieldCreatedBy.ts create mode 100644 packages/twenty-front/src/modules/object-record/graphql/types/RecordGqlFieldsDeprecated.ts create mode 100644 packages/twenty-front/src/modules/object-record/graphql/utils/__tests__/generateDepthOneRecordGqlFields.test.ts create mode 100644 packages/twenty-front/src/modules/object-record/graphql/utils/isRecordGraphlFieldsNode.ts create mode 100644 packages/twenty-front/src/modules/object-record/utils/buildOptimisticActorFieldValueFromCurrentWorkspaceMember.ts diff --git a/packages/twenty-front/src/modules/object-metadata/utils/__tests__/mapFieldMetadataToGraphQLQuery.test.ts b/packages/twenty-front/src/modules/object-metadata/utils/__tests__/mapFieldMetadataToGraphQLQuery.test.ts index 6a41f42f71..0522cea6cf 100644 --- a/packages/twenty-front/src/modules/object-metadata/utils/__tests__/mapFieldMetadataToGraphQLQuery.test.ts +++ b/packages/twenty-front/src/modules/object-metadata/utils/__tests__/mapFieldMetadataToGraphQLQuery.test.ts @@ -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, diff --git a/packages/twenty-front/src/modules/object-metadata/utils/checkObjectMetadataItemHasFieldCreatedBy.ts b/packages/twenty-front/src/modules/object-metadata/utils/checkObjectMetadataItemHasFieldCreatedBy.ts new file mode 100644 index 0000000000..3d68f355b5 --- /dev/null +++ b/packages/twenty-front/src/modules/object-metadata/utils/checkObjectMetadataItemHasFieldCreatedBy.ts @@ -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', + ); diff --git a/packages/twenty-front/src/modules/object-metadata/utils/mapFieldMetadataToGraphQLQuery.ts b/packages/twenty-front/src/modules/object-metadata/utils/mapFieldMetadataToGraphQLQuery.ts index efc393d1c6..3428a712c0 100644 --- a/packages/twenty-front/src/modules/object-metadata/utils/mapFieldMetadataToGraphQLQuery.ts +++ b/packages/twenty-front/src/modules/object-metadata/utils/mapFieldMetadataToGraphQLQuery.ts @@ -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; + 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; - relationrecordFields?: Record; - 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, })} diff --git a/packages/twenty-front/src/modules/object-metadata/utils/mapObjectMetadataToGraphQLQuery.ts b/packages/twenty-front/src/modules/object-metadata/utils/mapObjectMetadataToGraphQLQuery.ts index 9e705d428c..faec027f0c 100644 --- a/packages/twenty-front/src/modules/object-metadata/utils/mapObjectMetadataToGraphQLQuery.ts +++ b/packages/twenty-front/src/modules/object-metadata/utils/mapObjectMetadataToGraphQLQuery.ts @@ -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; + recordGqlFields?: RecordGqlFields; + computeReferences?: boolean; + isRootLevel?: boolean; +}; export const mapObjectMetadataToGraphQLQuery = ({ objectMetadataItems, objectMetadataItem, recordGqlFields, computeReferences = false, isRootLevel = true, -}: { - objectMetadataItems: ObjectMetadataItem[]; - objectMetadataItem: Pick; - recordGqlFields?: Record; - 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, }); }) diff --git a/packages/twenty-front/src/modules/object-record/cache/hooks/useCreateOneRecordInCache.ts b/packages/twenty-front/src/modules/object-record/cache/hooks/useCreateOneRecordInCache.ts index c213cd7a03..aad93e8422 100644 --- a/packages/twenty-front/src/modules/object-record/cache/hooks/useCreateOneRecordInCache.ts +++ b/packages/twenty-front/src/modules/object-record/cache/hooks/useCreateOneRecordInCache.ts @@ -24,6 +24,10 @@ export const useCreateOneRecordInCache = ({ 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 = ({ objectMetadataItems, objectMetadataItem, computeReferences: true, - recordGqlFields: generateDepthOneRecordGqlFields({ - objectMetadataItem, - }), + recordGqlFields, })} `; diff --git a/packages/twenty-front/src/modules/object-record/graphql/types/RecordGqlFields.ts b/packages/twenty-front/src/modules/object-record/graphql/types/RecordGqlFields.ts index 1148458a2a..5c2b33b168 100644 --- a/packages/twenty-front/src/modules/object-record/graphql/types/RecordGqlFields.ts +++ b/packages/twenty-front/src/modules/object-record/graphql/types/RecordGqlFields.ts @@ -1 +1,3 @@ -export type RecordGqlFields = Record; +export type RecordGqlFields = { + [k: string]: boolean | RecordGqlFields | undefined; +}; diff --git a/packages/twenty-front/src/modules/object-record/graphql/types/RecordGqlFieldsDeprecated.ts b/packages/twenty-front/src/modules/object-record/graphql/types/RecordGqlFieldsDeprecated.ts new file mode 100644 index 0000000000..e8672467d9 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/graphql/types/RecordGqlFieldsDeprecated.ts @@ -0,0 +1 @@ +export type RecordGqlFieldsDeprecated = Record; diff --git a/packages/twenty-front/src/modules/object-record/graphql/types/RecordGqlOperationGqlRecordFields.ts b/packages/twenty-front/src/modules/object-record/graphql/types/RecordGqlOperationGqlRecordFields.ts index b2ba0834bd..34f948bd06 100644 --- a/packages/twenty-front/src/modules/object-record/graphql/types/RecordGqlOperationGqlRecordFields.ts +++ b/packages/twenty-front/src/modules/object-record/graphql/types/RecordGqlOperationGqlRecordFields.ts @@ -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; diff --git a/packages/twenty-front/src/modules/object-record/graphql/utils/__tests__/generateDepthOneRecordGqlFields.test.ts b/packages/twenty-front/src/modules/object-record/graphql/utils/__tests__/generateDepthOneRecordGqlFields.test.ts new file mode 100644 index 0000000000..dcde53c7d6 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/graphql/utils/__tests__/generateDepthOneRecordGqlFields.test.ts @@ -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, +} +`); + }); +}); diff --git a/packages/twenty-front/src/modules/object-record/graphql/utils/isRecordGraphlFieldsNode.ts b/packages/twenty-front/src/modules/object-record/graphql/utils/isRecordGraphlFieldsNode.ts new file mode 100644 index 0000000000..c13a690f9d --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/graphql/utils/isRecordGraphlFieldsNode.ts @@ -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); diff --git a/packages/twenty-front/src/modules/object-record/hooks/useCreateManyRecords.ts b/packages/twenty-front/src/modules/object-record/hooks/useCreateManyRecords.ts index de86ff086e..8a06e895d1 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/useCreateManyRecords.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/useCreateManyRecords.ts @@ -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 & { @@ -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, }; diff --git a/packages/twenty-front/src/modules/object-record/hooks/useCreateOneRecord.ts b/packages/twenty-front/src/modules/object-record/hooks/useCreateOneRecord.ts index c8379ec6da..f4555f7e6b 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/useCreateOneRecord.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/useCreateOneRecord.ts @@ -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( { 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, diff --git a/packages/twenty-front/src/modules/object-record/hooks/useUpdateOneRecord.ts b/packages/twenty-front/src/modules/object-record/hooks/useUpdateOneRecord.ts index bc767622da..d202c42713 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/useUpdateOneRecord.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/useUpdateOneRecord.ts @@ -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; }; - +type UpdateOneRecordArgs = { + idToUpdate: string; + updateOneRecordInput: Partial>; + optimisticRecord?: Partial; +}; 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>; - optimisticRecord?: Partial; - }) => { + }: UpdateOneRecordArgs) => { const optimisticRecordInput = optimisticRecord ?? computeOptimisticRecordFromInput({ objectMetadataItem, + currentWorkspaceMember: currentWorkspaceMember, recordInput: updateOneRecordInput, cache: apolloClient.cache, objectMetadataItems, diff --git a/packages/twenty-front/src/modules/object-record/multiple-objects/hooks/__tests__/useCombinedFindManyRecords.test.tsx b/packages/twenty-front/src/modules/object-record/multiple-objects/hooks/__tests__/useCombinedFindManyRecords.test.tsx index 5a03a45a05..28a1632df8 100644 --- a/packages/twenty-front/src/modules/object-record/multiple-objects/hooks/__tests__/useCombinedFindManyRecords.test.tsx +++ b/packages/twenty-front/src/modules/object-record/multiple-objects/hooks/__tests__/useCombinedFindManyRecords.test.tsx @@ -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: {}, }, ], diff --git a/packages/twenty-front/src/modules/object-record/multiple-objects/hooks/__tests__/useCombinedFindManyRecordsQueryVariables.test.ts b/packages/twenty-front/src/modules/object-record/multiple-objects/hooks/__tests__/useCombinedFindManyRecordsQueryVariables.test.ts index e6257fe8dd..a1cbfabe96 100644 --- a/packages/twenty-front/src/modules/object-record/multiple-objects/hooks/__tests__/useCombinedFindManyRecordsQueryVariables.test.ts +++ b/packages/twenty-front/src/modules/object-record/multiple-objects/hooks/__tests__/useCombinedFindManyRecordsQueryVariables.test.ts @@ -13,7 +13,7 @@ describe('useCombinedFindManyRecordsQueryVariables', () => { firstName: true, lastName: true, }, - } as RecordGqlFields, + } satisfies RecordGqlFields, variables: { filter: { id: { eq: '123' } }, orderBy: [{ createdAt: 'AscNullsLast' }], diff --git a/packages/twenty-front/src/modules/object-record/record-field/hooks/useClearField.ts b/packages/twenty-front/src/modules/object-record/record-field/hooks/useClearField.ts index 5dad666c7e..5af88f00df 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/hooks/useClearField.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/hooks/useClearField.ts @@ -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 }), diff --git a/packages/twenty-front/src/modules/object-record/record-field/types/FieldMetadata.ts b/packages/twenty-front/src/modules/object-record/record-field/types/FieldMetadata.ts index e42bb1a632..84296ec133 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/types/FieldMetadata.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/types/FieldMetadata.ts @@ -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 = { diff --git a/packages/twenty-front/src/modules/object-record/record-right-drawer/components/RightDrawerTitleRecordInlineCell.tsx b/packages/twenty-front/src/modules/object-record/record-right-drawer/components/RightDrawerTitleRecordInlineCell.tsx index e349c4d52a..5eb130f152 100644 --- a/packages/twenty-front/src/modules/object-record/record-right-drawer/components/RightDrawerTitleRecordInlineCell.tsx +++ b/packages/twenty-front/src/modules/object-record/record-right-drawer/components/RightDrawerTitleRecordInlineCell.tsx @@ -26,7 +26,9 @@ export const RightDrawerTitleRecordInlineCell = () => { const draftValue = useRecoilValue(getDraftValueSelector()); useListenRightDrawerClose(() => { - persistField(draftValue); + if (draftValue !== undefined) { + persistField(draftValue); + } closeInlineCell(); }); diff --git a/packages/twenty-front/src/modules/object-record/spreadsheet-import/hooks/__tests__/useOpenObjectRecordsSpreadsheetImportDialog.test.ts b/packages/twenty-front/src/modules/object-record/spreadsheet-import/hooks/__tests__/useOpenObjectRecordsSpreadsheetImportDialog.test.ts index f4a7c9e7bd..6729234331 100644 --- a/packages/twenty-front/src/modules/object-record/spreadsheet-import/hooks/__tests__/useOpenObjectRecordsSpreadsheetImportDialog.test.ts +++ b/packages/twenty-front/src/modules/object-record/spreadsheet-import/hooks/__tests__/useOpenObjectRecordsSpreadsheetImportDialog.test.ts @@ -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', diff --git a/packages/twenty-front/src/modules/object-record/spreadsheet-import/hooks/useOpenObjectRecordsSpreadsheetImportDialog.ts b/packages/twenty-front/src/modules/object-record/spreadsheet-import/hooks/useOpenObjectRecordsSpreadsheetImportDialog.ts index ce138d7dd3..20d26d97ae 100644 --- a/packages/twenty-front/src/modules/object-record/spreadsheet-import/hooks/useOpenObjectRecordsSpreadsheetImportDialog.ts +++ b/packages/twenty-front/src/modules/object-record/spreadsheet-import/hooks/useOpenObjectRecordsSpreadsheetImportDialog.ts @@ -56,16 +56,17 @@ export const useOpenObjectRecordsSpreadsheetImportDialog = ( onSubmit: async (data) => { const createInputs = data.validStructuredRows.map((record) => { const fieldMapping: Record = - 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, diff --git a/packages/twenty-front/src/modules/object-record/spreadsheet-import/utils/buildRecordFromImportedStructuredRow.ts b/packages/twenty-front/src/modules/object-record/spreadsheet-import/utils/buildRecordFromImportedStructuredRow.ts index 95d701632a..5c55cfd2dc 100644 --- a/packages/twenty-front/src/modules/object-record/spreadsheet-import/utils/buildRecordFromImportedStructuredRow.ts +++ b/packages/twenty-front/src/modules/object-record/spreadsheet-import/utils/buildRecordFromImportedStructuredRow.ts @@ -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, - fields: FieldMetadataItem[], -) => { +type BuildRecordFromImportedStructuredRowArgs = { + importedStructuredRow: ImportedStructuredRow; + fields: FieldMetadataItem[]; +}; +export const buildRecordFromImportedStructuredRow = ({ + fields, + importedStructuredRow, +}: BuildRecordFromImportedStructuredRowArgs) => { const recordToBuild: Record = {}; 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: { diff --git a/packages/twenty-front/src/modules/object-record/utils/__tests__/computeOptimisticRecordFromInput.test.ts b/packages/twenty-front/src/modules/object-record/utils/__tests__/computeOptimisticRecordFromInput.test.ts index 8e2b6f27a7..70c18219e6 100644 --- a/packages/twenty-front/src/modules/object-record/utils/__tests__/computeOptimisticRecordFromInput.test.ts +++ b/packages/twenty-front/src/modules/object-record/utils/__tests__/computeOptimisticRecordFromInput.test.ts @@ -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: { diff --git a/packages/twenty-front/src/modules/object-record/utils/buildOptimisticActorFieldValueFromCurrentWorkspaceMember.ts b/packages/twenty-front/src/modules/object-record/utils/buildOptimisticActorFieldValueFromCurrentWorkspaceMember.ts new file mode 100644 index 0000000000..8398756fe8 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/utils/buildOptimisticActorFieldValueFromCurrentWorkspaceMember.ts @@ -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, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/utils/computeOptimisticRecordFromInput.ts b/packages/twenty-front/src/modules/object-record/utils/computeOptimisticRecordFromInput.ts index eb9ded875a..111a473b60 100644 --- a/packages/twenty-front/src/modules/object-record/utils/computeOptimisticRecordFromInput.ts +++ b/packages/twenty-front/src/modules/object-record/utils/computeOptimisticRecordFromInput.ts @@ -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; + currentWorkspaceMember: CurrentWorkspaceMember | null; } & Pick; 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 = {}; 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; diff --git a/packages/twenty-front/src/modules/object-record/utils/generateDefaultFieldValue.ts b/packages/twenty-front/src/modules/object-record/utils/generateDefaultFieldValue.ts index 415c1b6046..27ac3583c5 100644 --- a/packages/twenty-front/src/modules/object-record/utils/generateDefaultFieldValue.ts +++ b/packages/twenty-front/src/modules/object-record/utils/generateDefaultFieldValue.ts @@ -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, -) => { +type GenerateEmptyFieldValueArgs = { + fieldMetadataItem: Pick; +}; +export const generateDefaultFieldValue = ({ + fieldMetadataItem, +}: GenerateEmptyFieldValueArgs) => { const defaultValue = isFieldValueEmpty({ fieldValue: fieldMetadataItem.defaultValue, fieldDefinition: fieldMetadataItem, }) - ? generateEmptyFieldValue(fieldMetadataItem) + ? generateEmptyFieldValue({ + fieldMetadataItem, + }) : stripSimpleQuotesFromString(fieldMetadataItem.defaultValue); switch (defaultValue) { diff --git a/packages/twenty-front/src/modules/object-record/utils/generateEmptyFieldValue.ts b/packages/twenty-front/src/modules/object-record/utils/generateEmptyFieldValue.ts index 57b01306f1..fbd19e39bf 100644 --- a/packages/twenty-front/src/modules/object-record/utils/generateEmptyFieldValue.ts +++ b/packages/twenty-front/src/modules/object-record/utils/generateEmptyFieldValue.ts @@ -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, -) => { +export type GenerateEmptyFieldValueArgs = { + fieldMetadataItem: Pick; +}; +// 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 { diff --git a/packages/twenty-front/src/modules/object-record/utils/prefillRecord.ts b/packages/twenty-front/src/modules/object-record/utils/prefillRecord.ts index a71b4ae11c..52786434b3 100644 --- a/packages/twenty-front/src/modules/object-record/utils/prefillRecord.ts +++ b/packages/twenty-front/src/modules/object-record/utils/prefillRecord.ts @@ -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; +}; export const prefillRecord = ({ objectMetadataItem, input, -}: { - objectMetadataItem: ObjectMetadataItem; - input: Record; -}) => { +}: PrefillRecordArgs) => { return Object.fromEntries( objectMetadataItem.fields .map((fieldMetadataItem) => { @@ -26,12 +27,10 @@ export const prefillRecord = ({ 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; diff --git a/packages/twenty-front/src/modules/prefetch/hooks/usePrefetchedData.ts b/packages/twenty-front/src/modules/prefetch/hooks/usePrefetchedData.ts index b951dc4176..8ffd114785 100644 --- a/packages/twenty-front/src/modules/prefetch/hooks/usePrefetchedData.ts +++ b/packages/twenty-front/src/modules/prefetch/hooks/usePrefetchedData.ts @@ -23,11 +23,12 @@ export const usePrefetchedData = ( objectNameSingular, }); + const recordGqlFields = + operationSignatureFactory({ objectMetadataItem }).fields ?? filter; const { records } = useFindManyRecords({ skip: !isDataPrefetched, objectNameSingular: objectNameSingular, - recordGqlFields: - operationSignatureFactory({ objectMetadataItem }).fields ?? filter, + recordGqlFields, }); return { diff --git a/packages/twenty-front/src/modules/settings/data-model/constants/SettingsCompositeFieldTypeConfigs.ts b/packages/twenty-front/src/modules/settings/data-model/constants/SettingsCompositeFieldTypeConfigs.ts index c88b3447bd..06d284868f 100644 --- a/packages/twenty-front/src/modules/settings/data-model/constants/SettingsCompositeFieldTypeConfigs.ts +++ b/packages/twenty-front/src/modules/settings/data-model/constants/SettingsCompositeFieldTypeConfigs.ts @@ -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 }, diff --git a/packages/twenty-front/src/modules/settings/data-model/fields/preview/hooks/useFieldPreviewValue.ts b/packages/twenty-front/src/modules/settings/data-model/fields/preview/hooks/useFieldPreviewValue.ts index a2f1d93585..6c645dd0b3 100644 --- a/packages/twenty-front/src/modules/settings/data-model/fields/preview/hooks/useFieldPreviewValue.ts +++ b/packages/twenty-front/src/modules/settings/data-model/fields/preview/hooks/useFieldPreviewValue.ts @@ -53,6 +53,8 @@ export const useFieldPreviewValue = ({ case FieldMetadataType.PHONES: return getPhonesFieldPreviewValue({ fieldMetadataItem }); default: - return getFieldPreviewValue({ fieldMetadataItem }); + return getFieldPreviewValue({ + fieldMetadataItem, + }); } }; diff --git a/packages/twenty-front/src/modules/settings/data-model/fields/preview/utils/__tests__/getFieldPreviewValue.test.ts b/packages/twenty-front/src/modules/settings/data-model/fields/preview/utils/__tests__/getFieldPreviewValue.test.ts index edb267685c..5bec90e8e5 100644 --- a/packages/twenty-front/src/modules/settings/data-model/fields/preview/utils/__tests__/getFieldPreviewValue.test.ts +++ b/packages/twenty-front/src/modules/settings/data-model/fields/preview/utils/__tests__/getFieldPreviewValue.test.ts @@ -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(); diff --git a/packages/twenty-front/src/modules/settings/data-model/fields/preview/utils/getFieldPreviewValue.ts b/packages/twenty-front/src/modules/settings/data-model/fields/preview/utils/getFieldPreviewValue.ts index 1255bcd95a..e69252c1b0 100644 --- a/packages/twenty-front/src/modules/settings/data-model/fields/preview/utils/getFieldPreviewValue.ts +++ b/packages/twenty-front/src/modules/settings/data-model/fields/preview/utils/getFieldPreviewValue.ts @@ -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; +}; export const getFieldPreviewValue = ({ fieldMetadataItem, -}: { - fieldMetadataItem: Pick; -}) => { +}: 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); diff --git a/packages/twenty-front/src/testing/jest/generateEmptyJestRecordNode.ts b/packages/twenty-front/src/testing/jest/generateEmptyJestRecordNode.ts index f27e4f3a3c..ba28eaa8b1 100644 --- a/packages/twenty-front/src/testing/jest/generateEmptyJestRecordNode.ts +++ b/packages/twenty-front/src/testing/jest/generateEmptyJestRecordNode.ts @@ -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; + withDepthOneRelation?: boolean; +}; export const generateEmptyJestRecordNode = ({ objectNameSingular, input, withDepthOneRelation = false, -}: { - objectNameSingular: string; - input: Record; - 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, diff --git a/packages/twenty-front/src/testing/mock-data/people.ts b/packages/twenty-front/src/testing/mock-data/people.ts index dd81c3603d..bb02de9a22 100644 --- a/packages/twenty-front/src/testing/mock-data/people.ts +++ b/packages/twenty-front/src/testing/mock-data/people.ts @@ -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, index = 0, diff --git a/packages/twenty-front/src/testing/mock-data/workspace-members.ts b/packages/twenty-front/src/testing/mock-data/workspace-members.ts index 34b96364cd..5e77a57ecc 100644 --- a/packages/twenty-front/src/testing/mock-data/workspace-members.ts +++ b/packages/twenty-front/src/testing/mock-data/workspace-members.ts @@ -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, + }), + );