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

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

## Updates

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

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

### 3/ Strictly typed `RecordGqlFields`

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,18 +1,23 @@
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { updateRecordFromCache } from '@/object-record/cache/utils/updateRecordFromCache'; import { updateRecordFromCache } from '@/object-record/cache/utils/updateRecordFromCache';
import { generateDepthOneRecordGqlFields } from '@/object-record/graphql/utils/generateDepthOneRecordGqlFields'; 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 { computeOptimisticRecordFromInput } from '@/object-record/utils/computeOptimisticRecordFromInput';
import { InMemoryCache } from '@apollo/client'; import { InMemoryCache } from '@apollo/client';
import { getCompanyObjectMetadataItem } from '~/testing/mock-data/companies'; import { getCompanyObjectMetadataItem } from '~/testing/mock-data/companies';
import { generatedMockObjectMetadataItems } from '~/testing/mock-data/generatedMockObjectMetadataItems'; import { generatedMockObjectMetadataItems } from '~/testing/mock-data/generatedMockObjectMetadataItems';
import { getPersonObjectMetadataItem } from '~/testing/mock-data/people'; import { getPersonObjectMetadataItem } from '~/testing/mock-data/people';
import { mockCurrentWorkspaceMembers } from '~/testing/mock-data/workspace-members';
describe('computeOptimisticRecordFromInput', () => { 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', () => { it('should generate correct optimistic record if no relation field is present', () => {
const cache = new InMemoryCache(); const cache = new InMemoryCache();
const personObjectMetadataItem = getPersonObjectMetadataItem(); const personObjectMetadataItem = getPersonObjectMetadataItem();
const result = computeOptimisticRecordFromInput({ const result = computeOptimisticRecordFromInput({
currentWorkspaceMember,
objectMetadataItems: generatedMockObjectMetadataItems, objectMetadataItems: generatedMockObjectMetadataItems,
objectMetadataItem: personObjectMetadataItem, objectMetadataItem: personObjectMetadataItem,
recordInput: { 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', () => { it('should generate correct optimistic record if relation field is present but cache is empty', () => {
const cache = new InMemoryCache(); const cache = new InMemoryCache();
const personObjectMetadataItem = getPersonObjectMetadataItem(); const personObjectMetadataItem = getPersonObjectMetadataItem();
const result = computeOptimisticRecordFromInput({ const result = computeOptimisticRecordFromInput({
currentWorkspaceMember,
objectMetadataItems: generatedMockObjectMetadataItems, objectMetadataItems: generatedMockObjectMetadataItems,
objectMetadataItem: personObjectMetadataItem, objectMetadataItem: personObjectMetadataItem,
recordInput: { recordInput: {
@ -73,6 +136,7 @@ describe('computeOptimisticRecordFromInput', () => {
}); });
const result = computeOptimisticRecordFromInput({ const result = computeOptimisticRecordFromInput({
currentWorkspaceMember,
objectMetadataItems: generatedMockObjectMetadataItems, objectMetadataItems: generatedMockObjectMetadataItems,
objectMetadataItem: personObjectMetadataItem, objectMetadataItem: personObjectMetadataItem,
recordInput: { recordInput: {
@ -117,6 +181,7 @@ describe('computeOptimisticRecordFromInput', () => {
}); });
const result = computeOptimisticRecordFromInput({ const result = computeOptimisticRecordFromInput({
currentWorkspaceMember,
objectMetadataItems: generatedMockObjectMetadataItems, objectMetadataItems: generatedMockObjectMetadataItems,
objectMetadataItem: personObjectMetadataItem, objectMetadataItem: personObjectMetadataItem,
recordInput: { recordInput: {
@ -136,6 +201,7 @@ describe('computeOptimisticRecordFromInput', () => {
const personObjectMetadataItem = getPersonObjectMetadataItem(); const personObjectMetadataItem = getPersonObjectMetadataItem();
const result = computeOptimisticRecordFromInput({ const result = computeOptimisticRecordFromInput({
currentWorkspaceMember,
objectMetadataItems: generatedMockObjectMetadataItems, objectMetadataItems: generatedMockObjectMetadataItems,
objectMetadataItem: personObjectMetadataItem, objectMetadataItem: personObjectMetadataItem,
recordInput: { recordInput: {
@ -156,6 +222,7 @@ describe('computeOptimisticRecordFromInput', () => {
expect(() => expect(() =>
computeOptimisticRecordFromInput({ computeOptimisticRecordFromInput({
currentWorkspaceMember,
objectMetadataItems: generatedMockObjectMetadataItems, objectMetadataItems: generatedMockObjectMetadataItems,
objectMetadataItem: personObjectMetadataItem, objectMetadataItem: personObjectMetadataItem,
recordInput: { recordInput: {
@ -167,7 +234,7 @@ describe('computeOptimisticRecordFromInput', () => {
cache, cache,
}), }),
).toThrowErrorMatchingInlineSnapshot( ).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(() => expect(() =>
computeOptimisticRecordFromInput({ computeOptimisticRecordFromInput({
currentWorkspaceMember,
objectMetadataItems: generatedMockObjectMetadataItems, objectMetadataItems: generatedMockObjectMetadataItems,
objectMetadataItem: personObjectMetadataItem, objectMetadataItem: personObjectMetadataItem,
recordInput: { recordInput: {
@ -196,6 +264,7 @@ describe('computeOptimisticRecordFromInput', () => {
expect(() => expect(() =>
computeOptimisticRecordFromInput({ computeOptimisticRecordFromInput({
currentWorkspaceMember,
objectMetadataItems: generatedMockObjectMetadataItems, objectMetadataItems: generatedMockObjectMetadataItems,
objectMetadataItem: personObjectMetadataItem, objectMetadataItem: personObjectMetadataItem,
recordInput: { recordInput: {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,5 +1,6 @@
import { RecordGqlConnection } from '@/object-record/graphql/types/RecordGqlConnection'; import { RecordGqlConnection } from '@/object-record/graphql/types/RecordGqlConnection';
import { ObjectRecord } from '@/object-record/types/ObjectRecord'; import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { FieldMetadataType } from 'twenty-shared';
import { generatedMockObjectMetadataItems } from '~/testing/mock-data/generatedMockObjectMetadataItems'; import { generatedMockObjectMetadataItems } from '~/testing/mock-data/generatedMockObjectMetadataItems';
export const getPeopleMock = (): ObjectRecord[] => { export const getPeopleMock = (): ObjectRecord[] => {
@ -20,6 +21,22 @@ export const getPersonObjectMetadataItem = () => {
return personObjectMetadataItem; 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 = ( export const getPersonRecord = (
overrides?: Partial<ObjectRecord>, overrides?: Partial<ObjectRecord>,
index = 0, index = 0,

View File

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