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 6a41f42f7..0522cea6c 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 000000000..3d68f355b --- /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 efc393d1c..3428a712c 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 9e705d428..faec027f0 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 c213cd7a0..aad93e842 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 1148458a2..5c2b33b16 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 000000000..e8672467d --- /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 b2ba0834b..34f948bd0 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 000000000..dcde53c7d --- /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 000000000..c13a690f9 --- /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 de86ff086..8a06e895d 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 c8379ec6d..f4555f7e6 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 bc767622d..d202c4271 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 5a03a45a0..28a1632df 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 e6257fe8d..a1cbfabe9 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 5dad666c7..5af88f00d 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 e42bb1a63..84296ec13 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 e349c4d52..5eb130f15 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 f4a7c9e7b..672923433 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 ce138d7dd..20d26d97a 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 95d701632..5c55cfd2d 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 8e2b6f27a..70c18219e 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 000000000..8398756fe --- /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 eb9ded875..111a473b6 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 415c1b604..27ac3583c 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 57b01306f..fbd19e39b 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 a71b4ae11..52786434b 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 b951dc417..8ffd11478 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 c88b3447b..06d284868 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 a2f1d9358..6c645dd0b 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 edb267685..5bec90e8e 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 1255bcd95..e69252c1b 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 f27e4f3a3..ba28eaa8b 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 dd81c3603..bb02de9a2 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 34b96364c..5e77a57ec 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, + }), + );