diff --git a/packages/twenty-front/src/modules/object-metadata/hooks/useDoObjectMetadataItemsExist.ts b/packages/twenty-front/src/modules/object-metadata/hooks/useDoObjectMetadataItemsExist.ts new file mode 100644 index 0000000000..3c38ebf59f --- /dev/null +++ b/packages/twenty-front/src/modules/object-metadata/hooks/useDoObjectMetadataItemsExist.ts @@ -0,0 +1,17 @@ +import { useRecoilValue } from 'recoil'; + +import { objectMetadataItemsBySingularNameSelector } from '@/object-metadata/states/objectMetadataItemsBySingularNameSelector'; + +import { isDefined } from 'twenty-shared/utils'; + +export const useDoObjectMetadataItemsExist = ( + objectNameSingulars: string[], +) => { + const objectMetadataItems = useRecoilValue( + objectMetadataItemsBySingularNameSelector(objectNameSingulars), + ); + + return objectMetadataItems.every((objectMetadataItem) => + isDefined(objectMetadataItem), + ); +}; diff --git a/packages/twenty-front/src/modules/object-metadata/states/objectMetadataItemsBySingularNameSelector.ts b/packages/twenty-front/src/modules/object-metadata/states/objectMetadataItemsBySingularNameSelector.ts new file mode 100644 index 0000000000..361de5e971 --- /dev/null +++ b/packages/twenty-front/src/modules/object-metadata/states/objectMetadataItemsBySingularNameSelector.ts @@ -0,0 +1,27 @@ +import { selectorFamily } from 'recoil'; + +import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState'; +import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { isDefined } from 'twenty-shared/utils'; + +export const objectMetadataItemsBySingularNameSelector = selectorFamily< + ObjectMetadataItem[], + string[] +>({ + key: 'objectMetadataItemsSelector', + get: + (objectNameSingulars: string[]) => + ({ get }) => { + const objectMetadataItems = get(objectMetadataItemsState); + + return objectNameSingulars + .map( + (objectNameSingular) => + objectMetadataItems.find( + (objectMetadataItem) => + objectMetadataItem.nameSingular === objectNameSingular, + ) ?? null, + ) + .filter(isDefined); + }, +}); diff --git a/packages/twenty-front/src/modules/object-record/hooks/useAttachMorphRelatedRecordFromRecord.ts b/packages/twenty-front/src/modules/object-record/hooks/useAttachMorphRelatedRecordFromRecord.ts new file mode 100644 index 0000000000..02df02bb21 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/hooks/useAttachMorphRelatedRecordFromRecord.ts @@ -0,0 +1,193 @@ +import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient'; +import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems'; +import { getRecordFromCache } from '@/object-record/cache/utils/getRecordFromCache'; +import { updateRecordFromCache } from '@/object-record/cache/utils/updateRecordFromCache'; +import { computeDepthOneRecordGqlFieldsFromRecord } from '@/object-record/graphql/utils/computeDepthOneRecordGqlFieldsFromRecord'; +import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions'; +import { useUpdateMultipleRecordsFromManyObjects } from '@/object-record/hooks/useUpdateMultipleRecordsFromManyObjects'; +import { getTargetFieldMetadataName } from '@/object-record/multiple-objects/utils/getTargetFieldMetadataName'; +import { FieldContext } from '@/object-record/record-field/ui/contexts/FieldContext'; +import { isFieldMorphRelation } from '@/object-record/record-field/ui/types/guards/isFieldMorphRelation'; +import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState'; +import { type ObjectRecord } from '@/object-record/types/ObjectRecord'; +import { useContext } from 'react'; +import { useRecoilCallback } from 'recoil'; +import { computeMorphRelationFieldName, isDefined } from 'twenty-shared/utils'; + +export const useAttachMorphRelatedRecordFromRecord = () => { + const apolloCoreClient = useApolloCoreClient(); + const { fieldDefinition } = useContext(FieldContext); + const { objectMetadataItems } = useObjectMetadataItems(); + const { updateMultipleRecordsFromManyObjects } = + useUpdateMultipleRecordsFromManyObjects(); + + if (!isFieldMorphRelation(fieldDefinition)) { + throw new Error('Field is not a morph relation'); + } + + const objectNameSingular = + fieldDefinition.metadata.morphRelations[0].sourceObjectMetadata + .nameSingular; + const objectMetadataItem = objectMetadataItems.find( + (objectMetadataItem) => + objectMetadataItem.nameSingular === objectNameSingular, + ); + + if (!isDefined(objectMetadataItem)) { + throw new Error('Could not find object metadata item'); + } + + const { objectPermissionsByObjectMetadataId } = useObjectPermissions(); + + const updateOneRecordAndAttachMorphRelations = useRecoilCallback( + ({ set, snapshot }) => + async ({ + recordId, + relatedRecordId, + objectNameSingulars, + }: { + recordId: string; + relatedRecordId: string; + objectNameSingulars: string[]; + }) => { + const relatedObjectMetadataItems = objectMetadataItems.filter( + (objectMetadataItem) => + objectNameSingulars.includes(objectMetadataItem.nameSingular), + ); + + const parentRecord = snapshot + .getLoadable(recordStoreFamilyState(recordId)) + .getValue(); + + if (isDefined(parentRecord)) { + relatedObjectMetadataItems.forEach((relatedObjectMetadataItem) => { + const computedFieldName = computeMorphRelationFieldName({ + fieldName: fieldDefinition.metadata.fieldName, + relationDirection: fieldDefinition.metadata.relationType, + nameSingular: relatedObjectMetadataItem.nameSingular, + namePlural: relatedObjectMetadataItem.namePlural, + }); + + const currentMorphFieldValue = parentRecord[computedFieldName]; + + const objectRecordFromCache = getRecordFromCache({ + objectMetadataItem: relatedObjectMetadataItem, + recordId: relatedRecordId, + cache: apolloCoreClient.cache, + objectMetadataItems, + objectPermissionsByObjectMetadataId, + }); + + if (!isDefined(objectRecordFromCache)) { + return; + } + + set(recordStoreFamilyState(recordId), { + ...parentRecord, + [computedFieldName]: [ + ...currentMorphFieldValue, + objectRecordFromCache, + ], + }); + }); + } + + const relatedObjectMetadataItemsWithCachedRecord = + relatedObjectMetadataItems + .map((objectMetadataItem) => { + const cachedRelatedRecord = getRecordFromCache({ + objectMetadataItem, + recordId: relatedRecordId, + cache: apolloCoreClient.cache, + objectMetadataItems, + objectPermissionsByObjectMetadataId, + }); + return { + cachedRelatedRecord, + objectMetadataItem, + }; + }) + .find((item) => isDefined(item.cachedRelatedRecord)); + + if (!relatedObjectMetadataItemsWithCachedRecord) { + throw new Error('Could not find cached related record'); + } + + const { + objectMetadataItem: relatedObjectMetadataItem, + cachedRelatedRecord, + } = relatedObjectMetadataItemsWithCachedRecord; + + if ( + !isDefined(cachedRelatedRecord) || + !isDefined(cachedRelatedRecord.id) + ) { + throw new Error('Could not find cached related record'); + } + + const fieldOnRelatedObject = getTargetFieldMetadataName({ + fieldDefinition, + objectNameSingular: relatedObjectMetadataItem.nameSingular, + }); + if (!isDefined(fieldOnRelatedObject)) { + throw new Error('Could not find field on related object'); + } + + const previousRecordId = + cachedRelatedRecord?.[`${fieldOnRelatedObject}Id`]; + + if (isDefined(previousRecordId)) { + const previousRecord = getRecordFromCache({ + objectMetadataItem, + recordId: previousRecordId, + cache: apolloCoreClient.cache, + objectMetadataItems, + objectPermissionsByObjectMetadataId, + }); + + const previousRecordWithRelation = { + ...cachedRelatedRecord, + [fieldOnRelatedObject]: previousRecord, + }; + + const gqlFields = computeDepthOneRecordGqlFieldsFromRecord({ + objectMetadataItem: relatedObjectMetadataItem, + record: previousRecordWithRelation, + }); + + updateRecordFromCache({ + objectMetadataItems, + objectMetadataItem: relatedObjectMetadataItem, + cache: apolloCoreClient.cache, + record: { + ...cachedRelatedRecord, + [fieldOnRelatedObject]: previousRecord, + }, + recordGqlFields: gqlFields, + objectPermissionsByObjectMetadataId, + }); + } + + const updatedManyRecordsArgs = [ + { + idToUpdate: relatedRecordId, + objectNameSingulars, + relatedRecordId: recordId, + // recordGqlFields: gqlFields, + }, + ]; + + await updateMultipleRecordsFromManyObjects(updatedManyRecordsArgs); + }, + [ + fieldDefinition, + objectMetadataItems, + objectPermissionsByObjectMetadataId, + updateMultipleRecordsFromManyObjects, + apolloCoreClient.cache, + objectMetadataItem, + ], + ); + + return { updateOneRecordAndAttachMorphRelations }; +}; diff --git a/packages/twenty-front/src/modules/object-record/hooks/useDetachMorphRelatedRecordFromRecord.ts b/packages/twenty-front/src/modules/object-record/hooks/useDetachMorphRelatedRecordFromRecord.ts new file mode 100644 index 0000000000..50307e3b1e --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/hooks/useDetachMorphRelatedRecordFromRecord.ts @@ -0,0 +1,176 @@ +import { type Reference } from '@apollo/client'; + +import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient'; +import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems'; +import { getRecordFromCache } from '@/object-record/cache/utils/getRecordFromCache'; +import { getRefName } from '@/object-record/cache/utils/getRefName'; +import { modifyRecordFromCache } from '@/object-record/cache/utils/modifyRecordFromCache'; +import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions'; +import { useUpdateMultipleRecordsFromManyObjects } from '@/object-record/hooks/useUpdateMultipleRecordsFromManyObjects'; +import { FieldContext } from '@/object-record/record-field/ui/contexts/FieldContext'; +import { isFieldMorphRelation } from '@/object-record/record-field/ui/types/guards/isFieldMorphRelation'; +import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState'; +import { type ObjectRecord } from '@/object-record/types/ObjectRecord'; +import { useContext } from 'react'; +import { useRecoilCallback } from 'recoil'; +import { computeMorphRelationFieldName, isDefined } from 'twenty-shared/utils'; + +export const useDetachMorphRelatedRecordFromRecord = () => { + const apolloCoreClient = useApolloCoreClient(); + const { fieldDefinition } = useContext(FieldContext); + const { objectMetadataItems } = useObjectMetadataItems(); + const { updateMultipleRecordsFromManyObjects } = + useUpdateMultipleRecordsFromManyObjects(); + + if (!isFieldMorphRelation(fieldDefinition)) { + throw new Error('Field is not a morph relation'); + } + + const objectNameSingular = + fieldDefinition.metadata.morphRelations[0].sourceObjectMetadata + .nameSingular; + const objectMetadataItem = objectMetadataItems.find( + (objectMetadataItem) => + objectMetadataItem.nameSingular === objectNameSingular, + ); + + if (!isDefined(objectMetadataItem)) { + throw new Error('Could not find object metadata item'); + } + const { objectPermissionsByObjectMetadataId } = useObjectPermissions(); + + const updateOneRecordAndDetachMorphRelations = useRecoilCallback( + ({ set, snapshot }) => + async ({ + recordId, + relatedRecordId, + objectNameSingulars, + }: { + recordId: string; + relatedRecordId: string; + objectNameSingulars: string[]; + }) => { + const relatedObjectMetadataItems = objectMetadataItems.filter( + (objectMetadataItem) => + objectNameSingulars.includes(objectMetadataItem.nameSingular), + ); + + const parentRecord = snapshot + .getLoadable(recordStoreFamilyState(recordId)) + .getValue(); + + if (isDefined(parentRecord)) { + relatedObjectMetadataItems.forEach((relatedObjectMetadataItem) => { + const computedFieldName = computeMorphRelationFieldName({ + fieldName: fieldDefinition.metadata.fieldName, + relationDirection: fieldDefinition.metadata.relationType, + nameSingular: relatedObjectMetadataItem.nameSingular, + namePlural: relatedObjectMetadataItem.namePlural, + }); + + const currentMorphFieldValue = parentRecord[ + computedFieldName + ] as ObjectRecord[]; + + const objectRecordFromCache = getRecordFromCache({ + objectMetadataItem: relatedObjectMetadataItem, + recordId: relatedRecordId, + cache: apolloCoreClient.cache, + objectMetadataItems, + objectPermissionsByObjectMetadataId, + }); + + if (!isDefined(objectRecordFromCache)) { + return; + } + + if (Array.isArray(currentMorphFieldValue)) { + set(recordStoreFamilyState(recordId), { + ...parentRecord, + [computedFieldName]: currentMorphFieldValue.filter( + (record) => record.id !== relatedRecordId, + ), + }); + } + }); + } + + const relatedObjectMetadataItemsWithCachedRecord = + relatedObjectMetadataItems + .map((objectMetadataItem) => { + const cachedRelatedRecord = getRecordFromCache({ + objectMetadataItem, + recordId: relatedRecordId, + cache: apolloCoreClient.cache, + objectMetadataItems, + objectPermissionsByObjectMetadataId, + }); + return { + cachedRelatedRecord, + objectMetadataItem, + }; + }) + .find((item) => isDefined(item.cachedRelatedRecord)); + + if (!relatedObjectMetadataItemsWithCachedRecord) { + throw new Error('Could not find cached related record'); + } + + const { objectMetadataItem: relatedObjectMetadataItem } = + relatedObjectMetadataItemsWithCachedRecord; + + modifyRecordFromCache({ + objectMetadataItem, + cache: apolloCoreClient.cache, + fieldModifiers: { + [fieldDefinition.metadata.fieldName]: ( + fieldNameOnRecordObjectConnection, + { readField }, + ) => { + const edges = readField<{ node: Reference }[]>( + 'edges', + fieldNameOnRecordObjectConnection, + ); + + if (!edges) return fieldNameOnRecordObjectConnection; + + return { + ...fieldNameOnRecordObjectConnection, + edges: edges.filter( + (edge) => + !( + edge.node.__ref === + getRefName( + relatedObjectMetadataItem.nameSingular, + relatedRecordId, + ) + ), + ), + }; + }, + }, + recordId, + }); + + const updatedManyRecordsArgs = [ + { + idToUpdate: relatedRecordId, + objectNameSingulars, + relatedRecordId: null, + }, + ]; + + await updateMultipleRecordsFromManyObjects(updatedManyRecordsArgs); + }, + [ + fieldDefinition, + objectMetadataItems, + objectPermissionsByObjectMetadataId, + updateMultipleRecordsFromManyObjects, + apolloCoreClient.cache, + objectMetadataItem, + ], + ); + + return { updateOneRecordAndDetachMorphRelations }; +}; diff --git a/packages/twenty-front/src/modules/object-record/hooks/useObjectRecordSearchRecords.ts b/packages/twenty-front/src/modules/object-record/hooks/useObjectRecordSearchRecords.ts index ce05d15316..196ae5773c 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/useObjectRecordSearchRecords.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/useObjectRecordSearchRecords.ts @@ -1,8 +1,7 @@ import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState'; import { MAX_SEARCH_RESULTS } from '@/command-menu/constants/MaxSearchResults'; import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient'; -import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; -import { type ObjectMetadataItemIdentifier } from '@/object-metadata/types/ObjectMetadataItemIdentifier'; +import { useDoObjectMetadataItemsExist } from '@/object-metadata/hooks/useDoObjectMetadataItemsExist'; import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; import { type WatchQueryFetchPolicy } from '@apollo/client'; import { useMemo } from 'react'; @@ -14,7 +13,9 @@ import { } from '~/generated/graphql'; import { logError } from '~/utils/logError'; -export type UseSearchRecordsParams = ObjectMetadataItemIdentifier & { +// maybe we should look at ObjectMetadataItemIdentifier to update the API even though there are many location to update +export type UseSearchRecordsParams = { + objectNameSingulars: string[]; limit?: number; onError?: (error?: Error) => void; skip?: boolean; @@ -24,7 +25,7 @@ export type UseSearchRecordsParams = ObjectMetadataItemIdentifier & { }; export const useObjectRecordSearchRecords = ({ - objectNameSingular, + objectNameSingulars, searchInput, limit, skip, @@ -32,30 +33,25 @@ export const useObjectRecordSearchRecords = ({ fetchPolicy, }: UseSearchRecordsParams) => { const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState); - const { objectMetadataItem } = useObjectMetadataItem({ - objectNameSingular, - }); + const areDefined = useDoObjectMetadataItemsExist(objectNameSingulars); const { enqueueErrorSnackBar } = useSnackBar(); const apolloCoreClient = useApolloCoreClient(); const { data, loading, error, previousData } = useSearchQuery({ skip: - skip || - !objectMetadataItem || - !currentWorkspaceMember || - !isDefined(searchInput), + skip || !areDefined || !currentWorkspaceMember || !isDefined(searchInput), variables: { searchInput: searchInput ?? '', limit: limit ?? MAX_SEARCH_RESULTS, filter: filter ?? {}, - includedObjectNameSingulars: [objectNameSingular], + includedObjectNameSingulars: objectNameSingulars, }, fetchPolicy: fetchPolicy, client: apolloCoreClient, onError: (error) => { logError( - `useSearchRecords for "${objectMetadataItem.namePlural}" error : ` + + `useSearchRecords for "${objectNameSingulars.join(', ')}" error : ` + error, ); enqueueErrorSnackBar({ @@ -72,7 +68,6 @@ export const useObjectRecordSearchRecords = ({ ); return { - objectMetadataItem, searchRecords, loading, error, diff --git a/packages/twenty-front/src/modules/object-record/hooks/useUpdateMultipleRecordsFromManyObjects.ts b/packages/twenty-front/src/modules/object-record/hooks/useUpdateMultipleRecordsFromManyObjects.ts new file mode 100644 index 0000000000..f3065b069f --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/hooks/useUpdateMultipleRecordsFromManyObjects.ts @@ -0,0 +1,285 @@ +import { triggerUpdateRecordOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerUpdateRecordOptimisticEffect'; +import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState'; +import { CustomError } from '@/error-handler/CustomError'; +import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient'; +import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems'; +import { getObjectTypename } from '@/object-record/cache/utils/getObjectTypename'; +import { getRecordFromCache } from '@/object-record/cache/utils/getRecordFromCache'; +import { getRecordNodeFromRecord } from '@/object-record/cache/utils/getRecordNodeFromRecord'; +import { updateRecordFromCache } from '@/object-record/cache/utils/updateRecordFromCache'; +import { computeDepthOneRecordGqlFieldsFromRecord } from '@/object-record/graphql/utils/computeDepthOneRecordGqlFieldsFromRecord'; +import { generateDepthOneRecordGqlFields } from '@/object-record/graphql/utils/generateDepthOneRecordGqlFields'; +import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions'; +import { generateUpdateOneRecordMutation } from '@/object-record/multiple-objects/utils/generateUpdateOneRecordMutation'; +import { getTargetFieldMetadataName } from '@/object-record/multiple-objects/utils/getTargetFieldMetadataName'; +import { FieldContext } from '@/object-record/record-field/ui/contexts/FieldContext'; +import { isFieldMorphRelation } from '@/object-record/record-field/ui/types/guards/isFieldMorphRelation'; +import { isFieldRelation } from '@/object-record/record-field/ui/types/guards/isFieldRelation'; +import { type ObjectRecord } from '@/object-record/types/ObjectRecord'; +import { computeOptimisticRecordFromInput } from '@/object-record/utils/computeOptimisticRecordFromInput'; +import { getAggregateQueryName } from '@/object-record/utils/getAggregateQueryName'; +import { getUpdateOneRecordMutationResponseField } from '@/object-record/utils/getUpdateOneRecordMutationResponseField'; +import { sanitizeRecordInput } from '@/object-record/utils/sanitizeRecordInput'; +import { isNull } from '@sniptt/guards'; +import { useContext } from 'react'; +import { useRecoilValue } from 'recoil'; +import { isDefined } from 'twenty-shared/utils'; +import { buildRecordFromKeysWithSameValue } from '~/utils/array/buildRecordFromKeysWithSameValue'; + +type UpdateManyRecordArgs = { + idToUpdate: string; + relatedRecordId: string | null; + objectNameSingulars: string[]; + recordGqlFields?: Record; +}; + +export const useUpdateMultipleRecordsFromManyObjects = () => { + const { fieldDefinition } = useContext(FieldContext); + const apolloCoreClient = useApolloCoreClient(); + + const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState); + + const { objectMetadataItems } = useObjectMetadataItems(); + const { objectPermissionsByObjectMetadataId } = useObjectPermissions(); + + const updateMultipleRecordsFromManyObjects = async ( + updatedManyRecordsArgs: UpdateManyRecordArgs[], + ) => { + for (const { + idToUpdate, + objectNameSingulars, + relatedRecordId, + recordGqlFields, + } of updatedManyRecordsArgs) { + const objectMetadataItemArray = objectMetadataItems.filter( + (objectMetadataItem) => + objectNameSingulars.includes(objectMetadataItem.nameSingular), + ); + + if (objectMetadataItemArray.length === 0) { + throw new CustomError( + `Object metadata item not found ${objectNameSingulars.join(', ')}`, + 'OBJECT_METADATA_ITEM_NOT_FOUND', + ); + } + + const objectMetadataItemWithCachedRecord = objectMetadataItemArray + .map((objectMetadataItem) => { + const cachedRecord = getRecordFromCache({ + objectMetadataItem, + recordId: idToUpdate, + cache: apolloCoreClient.cache, + objectMetadataItems, + objectPermissionsByObjectMetadataId, + }); + return { + cachedRecord, + objectMetadataItem, + }; + }) + .find((item) => isDefined(item.cachedRecord)); + + if (!isDefined(objectMetadataItemWithCachedRecord)) { + throw new CustomError( + `Record not found ${idToUpdate}`, + 'RECORD_NOT_FOUND_IN_OBJECT_METADATA_ITEM', + ); + } + + const { objectMetadataItem, cachedRecord } = + objectMetadataItemWithCachedRecord; + + if ( + !isFieldRelation(fieldDefinition) && + !isFieldMorphRelation(fieldDefinition) + ) { + throw new CustomError( + `Should never happen`, + 'TARGET_FIELD_NAME_NOT_FOUND', + ); + } + + const targetFieldName = getTargetFieldMetadataName({ + fieldDefinition, + objectNameSingular: objectMetadataItem.nameSingular, + }); + + if (!isDefined(targetFieldName)) { + throw new CustomError( + `Cannot find Target field name for the (morph) relation field ${fieldDefinition.metadata.fieldName} on ${objectMetadataItem.nameSingular}`, + 'TARGET_FIELD_NAME_NOT_FOUND', + ); + } + + const updateOneRecordInput = { + [`${targetFieldName}Id`]: relatedRecordId, + }; + + const optimisticRecordInput = computeOptimisticRecordFromInput({ + objectMetadataItem, + currentWorkspaceMember: currentWorkspaceMember, + recordInput: updateOneRecordInput, + cache: apolloCoreClient.cache, + objectMetadataItems, + objectPermissionsByObjectMetadataId, + }); + + const computedRecordGqlFields = + recordGqlFields ?? + generateDepthOneRecordGqlFields({ objectMetadataItem }); + + const cachedRecordWithConnection = getRecordNodeFromRecord({ + record: cachedRecord, + objectMetadataItem, + objectMetadataItems, + recordGqlFields: computedRecordGqlFields, + computeReferences: false, + }); + + const computedOptimisticRecord = { + ...cachedRecord, + ...optimisticRecordInput, + id: idToUpdate, + __typename: getObjectTypename(objectMetadataItem.nameSingular), + }; + + const optimisticRecordWithConnection = + getRecordNodeFromRecord({ + record: computedOptimisticRecord, + objectMetadataItem, + objectMetadataItems, + recordGqlFields: computedRecordGqlFields, + computeReferences: false, + }); + + const shouldHandleOptimisticCache = + !isNull(cachedRecord) && + isDefined(optimisticRecordWithConnection) && + isDefined(cachedRecordWithConnection); + + if (shouldHandleOptimisticCache) { + const recordGqlFields = computeDepthOneRecordGqlFieldsFromRecord({ + objectMetadataItem, + record: optimisticRecordInput, + }); + + updateRecordFromCache({ + objectMetadataItems, + objectMetadataItem, + cache: apolloCoreClient.cache, + record: computedOptimisticRecord, + recordGqlFields, + objectPermissionsByObjectMetadataId, + }); + + triggerUpdateRecordOptimisticEffect({ + cache: apolloCoreClient.cache, + objectMetadataItem, + currentRecord: cachedRecordWithConnection, + updatedRecord: optimisticRecordWithConnection, + objectMetadataItems, + }); + } + + const mutationResponseField = getUpdateOneRecordMutationResponseField( + objectMetadataItem.nameSingular, + ); + + const sanitizedInput = { + ...sanitizeRecordInput({ + objectMetadataItem, + recordInput: updateOneRecordInput, + }), + }; + + const updateOneRecordMutation = generateUpdateOneRecordMutation({ + objectMetadataItem, + objectMetadataItems, + recordGqlFields: computedRecordGqlFields, + computeReferences: false, + objectPermissionsByObjectMetadataId, + }); + + const updatedRecord = await apolloCoreClient + .mutate({ + mutation: updateOneRecordMutation, + variables: { + idToUpdate, + input: sanitizedInput, + }, + update: (cache, { data }) => { + const record = data?.[mutationResponseField]; + if (!isDefined(record)) return; + + triggerUpdateRecordOptimisticEffect({ + cache, + objectMetadataItem, + currentRecord: computedOptimisticRecord, + updatedRecord: record, + objectMetadataItems, + }); + }, + }) + .catch((error: Error) => { + if (!shouldHandleOptimisticCache) { + throw error; + } + const cachedRecordKeys = new Set(Object.keys(cachedRecord)); + const recordKeysAddedByOptimisticCache = Object.keys( + optimisticRecordInput, + ).filter((diffKey) => !cachedRecordKeys.has(diffKey)); + + const recordGqlFields = { + ...computeDepthOneRecordGqlFieldsFromRecord({ + objectMetadataItem, + record: cachedRecord, + }), + ...buildRecordFromKeysWithSameValue( + recordKeysAddedByOptimisticCache, + true, + ), + }; + + updateRecordFromCache({ + objectMetadataItems, + objectMetadataItem, + cache: apolloCoreClient.cache, + record: { + ...cachedRecord, + ...buildRecordFromKeysWithSameValue( + recordKeysAddedByOptimisticCache, + null, + ), + }, + recordGqlFields, + objectPermissionsByObjectMetadataId, + }); + + triggerUpdateRecordOptimisticEffect({ + cache: apolloCoreClient.cache, + objectMetadataItem, + currentRecord: optimisticRecordWithConnection, + updatedRecord: cachedRecordWithConnection, + objectMetadataItems, + }); + + throw error; + }); + + const refetchAggregateQueries = async () => { + const queryName = getAggregateQueryName(objectMetadataItem.namePlural); + + await apolloCoreClient.refetchQueries({ + include: [queryName], + }); + }; + await refetchAggregateQueries(); + + return updatedRecord?.data?.[mutationResponseField] ?? null; + } + }; + + return { + updateMultipleRecordsFromManyObjects, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/hooks/useUpdateOneRecordMutation.ts b/packages/twenty-front/src/modules/object-record/hooks/useUpdateOneRecordMutation.ts index 1055e652a4..bdfb65c9f9 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/useUpdateOneRecordMutation.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/useUpdateOneRecordMutation.ts @@ -1,15 +1,11 @@ -import gql from 'graphql-tag'; import { useRecoilValue } from 'recoil'; import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState'; -import { mapObjectMetadataToGraphQLQuery } from '@/object-metadata/utils/mapObjectMetadataToGraphQLQuery'; import { EMPTY_MUTATION } from '@/object-record/constants/EmptyMutation'; import { type RecordGqlOperationGqlRecordFields } from '@/object-record/graphql/types/RecordGqlOperationGqlRecordFields'; -import { generateDepthOneRecordGqlFields } from '@/object-record/graphql/utils/generateDepthOneRecordGqlFields'; import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions'; -import { getUpdateOneRecordMutationResponseField } from '@/object-record/utils/getUpdateOneRecordMutationResponseField'; -import { capitalize } from 'twenty-shared/utils'; +import { generateUpdateOneRecordMutation } from '@/object-record/multiple-objects/utils/generateUpdateOneRecordMutation'; import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull'; export const useUpdateOneRecordMutation = ({ @@ -33,31 +29,13 @@ export const useUpdateOneRecordMutation = ({ return { updateOneRecordMutation: EMPTY_MUTATION }; } - const appliedRecordGqlFields = - recordGqlFields ?? - generateDepthOneRecordGqlFields({ - objectMetadataItem, - }); - - const capitalizedObjectName = capitalize(objectMetadataItem.nameSingular); - - const mutationResponseField = getUpdateOneRecordMutationResponseField( - objectMetadataItem.nameSingular, - ); - - const updateOneRecordMutation = gql` - mutation UpdateOne${capitalizedObjectName}($idToUpdate: UUID!, $input: ${capitalizedObjectName}UpdateInput!) { - ${mutationResponseField}(id: $idToUpdate, data: $input) ${mapObjectMetadataToGraphQLQuery( - { - objectMetadataItems, - objectMetadataItem, - computeReferences, - recordGqlFields: appliedRecordGqlFields, - objectPermissionsByObjectMetadataId, - }, - )} - } - `; + const updateOneRecordMutation = generateUpdateOneRecordMutation({ + objectMetadataItem, + objectMetadataItems, + recordGqlFields, + computeReferences, + objectPermissionsByObjectMetadataId, + }); return { updateOneRecordMutation, diff --git a/packages/twenty-front/src/modules/object-record/multiple-objects/utils/generateUpdateOneRecordMutation.ts b/packages/twenty-front/src/modules/object-record/multiple-objects/utils/generateUpdateOneRecordMutation.ts new file mode 100644 index 0000000000..5d30f08b5c --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/multiple-objects/utils/generateUpdateOneRecordMutation.ts @@ -0,0 +1,53 @@ +import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { mapObjectMetadataToGraphQLQuery } from '@/object-metadata/utils/mapObjectMetadataToGraphQLQuery'; +import { type RecordGqlOperationGqlRecordFields } from '@/object-record/graphql/types/RecordGqlOperationGqlRecordFields'; +import { generateDepthOneRecordGqlFields } from '@/object-record/graphql/utils/generateDepthOneRecordGqlFields'; +import { getUpdateOneRecordMutationResponseField } from '@/object-record/utils/getUpdateOneRecordMutationResponseField'; +import { gql } from '@apollo/client'; +import { type ObjectPermissions } from 'twenty-shared/types'; +import { capitalize } from 'twenty-shared/utils'; + +export const generateUpdateOneRecordMutation = ({ + objectMetadataItem, + objectMetadataItems, + recordGqlFields, + computeReferences, + objectPermissionsByObjectMetadataId, +}: { + objectMetadataItem: ObjectMetadataItem; + objectMetadataItems: ObjectMetadataItem[]; + recordGqlFields?: RecordGqlOperationGqlRecordFields; + computeReferences: boolean; + objectPermissionsByObjectMetadataId: Record< + string, + ObjectPermissions & { objectMetadataId: string } + >; +}) => { + const appliedRecordGqlFields = + recordGqlFields ?? + generateDepthOneRecordGqlFields({ + objectMetadataItem, + }); + + const capitalizedObjectName = capitalize(objectMetadataItem.nameSingular); + + const mutationResponseField = getUpdateOneRecordMutationResponseField( + objectMetadataItem.nameSingular, + ); + + const updateOneRecordMutation = gql` + mutation UpdateOne${capitalizedObjectName}($idToUpdate: UUID!, $input: ${capitalizedObjectName}UpdateInput!){ + ${mutationResponseField}(id: $idToUpdate, data: $input) ${mapObjectMetadataToGraphQLQuery( + { + objectMetadataItems, + objectMetadataItem, + computeReferences, + recordGqlFields: appliedRecordGqlFields, + objectPermissionsByObjectMetadataId, + }, + )} + } +`; + + return updateOneRecordMutation; +}; diff --git a/packages/twenty-front/src/modules/object-record/multiple-objects/utils/getTargetFieldMetadataName.ts b/packages/twenty-front/src/modules/object-record/multiple-objects/utils/getTargetFieldMetadataName.ts new file mode 100644 index 0000000000..cf93fca43a --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/multiple-objects/utils/getTargetFieldMetadataName.ts @@ -0,0 +1,25 @@ +import { type FieldDefinition } from '@/object-record/record-field/ui/types/FieldDefinition'; +import { + type FieldMorphRelationMetadata, + type FieldRelationMetadata, +} from '@/object-record/record-field/ui/types/FieldMetadata'; +import { isFieldRelation } from '@/object-record/record-field/ui/types/guards/isFieldRelation'; + +export const getTargetFieldMetadataName = ({ + fieldDefinition, + objectNameSingular, +}: { + fieldDefinition: + | FieldDefinition + | FieldDefinition; + objectNameSingular: string; +}) => { + if (isFieldRelation(fieldDefinition)) { + return fieldDefinition.metadata.targetFieldMetadataName; + } else { + return fieldDefinition.metadata.morphRelations.find( + (morphRelation) => + morphRelation.targetObjectMetadata.nameSingular === objectNameSingular, + )?.targetFieldMetadata.name; + } +}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/components/FieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/ui/components/FieldInput.tsx index fb60adeab9..f50306f62c 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/ui/components/FieldInput.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/components/FieldInput.tsx @@ -14,6 +14,7 @@ import { isFieldPhones } from '@/object-record/record-field/ui/types/guards/isFi import { isFieldRelationFromManyObjects } from '@/object-record/record-field/ui/types/guards/isFieldRelationFromManyObjects'; import { ArrayFieldInput } from '@/object-record/record-field/ui/meta-types/input/components/ArrayFieldInput'; +import { MorphRelationOneToManyFieldInput } from '@/object-record/record-field/ui/meta-types/input/components/MorphRelationOneToManyFieldInput'; import { RichTextFieldInput } from '@/object-record/record-field/ui/meta-types/input/components/RichTextFieldInput'; import { isFieldAddress } from '@/object-record/record-field/ui/types/guards/isFieldAddress'; import { isFieldArray } from '@/object-record/record-field/ui/types/guards/isFieldArray'; @@ -24,6 +25,7 @@ import { isFieldDateTime } from '@/object-record/record-field/ui/types/guards/is import { isFieldEmails } from '@/object-record/record-field/ui/types/guards/isFieldEmails'; import { isFieldFullName } from '@/object-record/record-field/ui/types/guards/isFieldFullName'; import { isFieldLinks } from '@/object-record/record-field/ui/types/guards/isFieldLinks'; +import { isFieldMorphRelationOneToMany } from '@/object-record/record-field/ui/types/guards/isFieldMorphRelationOneToMany'; import { isFieldMultiSelect } from '@/object-record/record-field/ui/types/guards/isFieldMultiSelect'; import { isFieldNumber } from '@/object-record/record-field/ui/types/guards/isFieldNumber'; import { isFieldRating } from '@/object-record/record-field/ui/types/guards/isFieldRating'; @@ -50,6 +52,8 @@ export const FieldInput = () => { ) : isFieldRelationFromManyObjects(fieldDefinition) ? ( + ) : isFieldMorphRelationOneToMany(fieldDefinition) ? ( + ) : isFieldPhones(fieldDefinition) ? ( ) : isFieldText(fieldDefinition) ? ( diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/hooks/useOpenFieldInputEditMode.ts b/packages/twenty-front/src/modules/object-record/record-field/ui/hooks/useOpenFieldInputEditMode.ts index 1c782f2413..a859c4e118 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/ui/hooks/useOpenFieldInputEditMode.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/hooks/useOpenFieldInputEditMode.ts @@ -5,6 +5,7 @@ import { type Task } from '@/activities/types/Task'; import { type TaskTarget } from '@/activities/types/TaskTarget'; import { getActivityTargetObjectRecords } from '@/activities/utils/getActivityTargetObjectRecords'; import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState'; +import { useOpenMorphRelationFromManyFieldInput } from '@/object-record/record-field/ui/meta-types/input/hooks/useOpenMorphRelationFromManyFieldInput'; import { useOpenRelationFromManyFieldInput } from '@/object-record/record-field/ui/meta-types/input/hooks/useOpenRelationFromManyFieldInput'; import { useOpenRelationToOneFieldInput } from '@/object-record/record-field/ui/meta-types/input/hooks/useOpenRelationToOneFieldInput'; import { type FieldDefinition } from '@/object-record/record-field/ui/types/FieldDefinition'; @@ -13,6 +14,8 @@ import { type FieldRelationFromManyValue, type FieldRelationValue, } from '@/object-record/record-field/ui/types/FieldMetadata'; +import { isFieldMorphRelation } from '@/object-record/record-field/ui/types/guards/isFieldMorphRelation'; +import { isFieldMorphRelationOneToMany } from '@/object-record/record-field/ui/types/guards/isFieldMorphRelationOneToMany'; import { isFieldRelationFromManyObjects } from '@/object-record/record-field/ui/types/guards/isFieldRelationFromManyObjects'; import { isFieldRelationToOneObject } from '@/object-record/record-field/ui/types/guards/isFieldRelationToOneObject'; import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState'; @@ -29,6 +32,9 @@ export const useOpenFieldInputEditMode = () => { const { openRelationFromManyFieldInput } = useOpenRelationFromManyFieldInput(); + const { openMorphRelationFromManyFieldInput } = + useOpenMorphRelationFromManyFieldInput(); + const { openActivityTargetCellEditMode } = useOpenActivityTargetCellEditMode(); @@ -95,6 +101,19 @@ export const useOpenFieldInputEditMode = () => { return; } + if (isFieldMorphRelationOneToMany(fieldDefinition)) { + if (!isFieldMorphRelation(fieldDefinition)) { + throw new Error('Field is not a morph relation one to many'); + } + + openMorphRelationFromManyFieldInput({ + recordId, + prefix, + fieldDefinition, + }); + return; + } + if (isFieldRelationFromManyObjects(fieldDefinition)) { if ( isDefined( @@ -133,6 +152,7 @@ export const useOpenFieldInputEditMode = () => { }, [ openActivityTargetCellEditMode, + openMorphRelationFromManyFieldInput, openRelationFromManyFieldInput, openRelationToOneFieldInput, pushFocusItemToFocusStack, diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/hooks/useMorphRelationField.ts b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/hooks/useMorphRelationField.ts new file mode 100644 index 0000000000..0d156daaf9 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/hooks/useMorphRelationField.ts @@ -0,0 +1,48 @@ +import { useContext } from 'react'; +import { useRecoilState } from 'recoil'; + +import { useGetButtonIcon } from '@/object-record/record-field/ui/hooks/useGetButtonIcon'; +import { type FieldRelationValue } from '@/object-record/record-field/ui/types/FieldMetadata'; +import { recordStoreFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreFamilySelector'; +import { FieldMetadataType } from '~/generated-metadata/graphql'; + +import { FieldContext } from '@/object-record/record-field/ui/contexts/FieldContext'; +import { recordFieldInputDraftValueComponentState } from '@/object-record/record-field/ui/states/recordFieldInputDraftValueComponentState'; +import { assertFieldMetadata } from '@/object-record/record-field/ui/types/guards/assertFieldMetadata'; +import { isFieldMorphRelation } from '@/object-record/record-field/ui/types/guards/isFieldMorphRelation'; +import { type ObjectRecord } from '@/object-record/types/ObjectRecord'; +import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; + +export const useMorphRelationField = < + T extends ObjectRecord | ObjectRecord[], +>() => { + const { recordId, fieldDefinition, maxWidth } = useContext(FieldContext); + const button = useGetButtonIcon(); + + assertFieldMetadata( + FieldMetadataType.MORPH_RELATION, + isFieldMorphRelation, + fieldDefinition, + ); + + const fieldName = fieldDefinition.metadata.fieldName; + + const [fieldValue, setFieldValue] = useRecoilState>( + recordStoreFamilySelector({ recordId, fieldName }), + ); + + const draftValue = useRecoilComponentValue( + recordFieldInputDraftValueComponentState, + ); + + const initialSearchValue = draftValue; + + return { + fieldDefinition, + fieldValue, + initialSearchValue, + setFieldValue, + maxWidth: button && maxWidth ? maxWidth - 28 : maxWidth, + recordId, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/components/MorphRelationOneToManyFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/components/MorphRelationOneToManyFieldInput.tsx new file mode 100644 index 0000000000..be999eaeb3 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/components/MorphRelationOneToManyFieldInput.tsx @@ -0,0 +1,45 @@ +import { useContext } from 'react'; + +import { FieldInputEventContext } from '@/object-record/record-field/ui/contexts/FieldInputEventContext'; +import { useUpdateMorphRelationManyToOneFieldInput } from '@/object-record/record-field/ui/meta-types/input/hooks/useUpdateMorphRelationManyToOneFieldInput'; +import { RecordFieldComponentInstanceContext } from '@/object-record/record-field/ui/states/contexts/RecordFieldComponentInstanceContext'; +import { recordFieldInputLayoutDirectionComponentState } from '@/object-record/record-field/ui/states/recordFieldInputLayoutDirectionComponentState'; +import { MultipleRecordPicker } from '@/object-record/record-picker/multiple-record-picker/components/MultipleRecordPicker'; +import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow'; +import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; + +export const MorphRelationOneToManyFieldInput = () => { + const instanceId = useAvailableComponentInstanceIdOrThrow( + RecordFieldComponentInstanceContext, + ); + + const { onSubmit } = useContext(FieldInputEventContext); + + const { updateMorphRelationManyToOne } = + useUpdateMorphRelationManyToOneFieldInput(); + + const handleSubmit = () => { + onSubmit?.({ skipPersist: true }); + }; + + const layoutDirection = useRecoilComponentValue( + recordFieldInputLayoutDirectionComponentState, + ); + + return ( + { + updateMorphRelationManyToOne(morphItem); + }} + onClickOutside={handleSubmit} + layoutDirection={ + layoutDirection === 'downward' + ? 'search-bar-on-top' + : 'search-bar-on-bottom' + } + /> + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/hooks/useOpenMorphRelationFromManyFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/hooks/useOpenMorphRelationFromManyFieldInput.tsx new file mode 100644 index 0000000000..b4420c4656 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/hooks/useOpenMorphRelationFromManyFieldInput.tsx @@ -0,0 +1,140 @@ +import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState'; + +import { type FieldDefinition } from '@/object-record/record-field/ui/types/FieldDefinition'; +import { type FieldMorphRelationMetadata } from '@/object-record/record-field/ui/types/FieldMetadata'; + +import { useMultipleRecordPickerOpen } from '@/object-record/record-picker/multiple-record-picker/hooks/useMultipleRecordPickerOpen'; +import { useMultipleRecordPickerPerformSearch } from '@/object-record/record-picker/multiple-record-picker/hooks/useMultipleRecordPickerPerformSearch'; +import { multipleRecordPickerPickableMorphItemsComponentState } from '@/object-record/record-picker/multiple-record-picker/states/multipleRecordPickerPickableMorphItemsComponentState'; +import { multipleRecordPickerSearchableObjectMetadataItemsComponentState } from '@/object-record/record-picker/multiple-record-picker/states/multipleRecordPickerSearchableObjectMetadataItemsComponentState'; +import { type RecordPickerPickableMorphItem } from '@/object-record/record-picker/types/RecordPickerPickableMorphItem'; +import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState'; +import { recordStoreMorphOneToManyValueWithObjectNameFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreMorphOneToManyValueWithObjectNameFamilySelector'; +import { getRecordFieldInputInstanceId } from '@/object-record/utils/getRecordFieldInputId'; +import { usePushFocusItemToFocusStack } from '@/ui/utilities/focus/hooks/usePushFocusItemToFocusStack'; +import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType'; +import { useRecoilCallback } from 'recoil'; +import { isDefined } from 'twenty-shared/utils'; + +export const useOpenMorphRelationFromManyFieldInput = () => { + const { performSearch } = useMultipleRecordPickerPerformSearch(); + const { openMultipleRecordPicker } = useMultipleRecordPickerOpen(); + + const { pushFocusItemToFocusStack } = usePushFocusItemToFocusStack(); + + const openMorphRelationFromManyFieldInput = useRecoilCallback( + ({ set, snapshot }) => + ({ + recordId, + prefix, + fieldDefinition, + }: { + recordId: string; + prefix?: string; + fieldDefinition: FieldDefinition; + }) => { + const recordPickerInstanceId = getRecordFieldInputInstanceId({ + recordId, + fieldName: fieldDefinition.metadata.fieldName, + prefix, + }); + + const objectNameSingulars = fieldDefinition.metadata.morphRelations.map( + ({ targetObjectMetadata }) => targetObjectMetadata.nameSingular, + ); + + const morphValuesWithObjectNameSingularFieldValue = snapshot + .getLoadable( + recordStoreMorphOneToManyValueWithObjectNameFamilySelector({ + recordId, + morphRelations: fieldDefinition.metadata.morphRelations, + fieldName: fieldDefinition.metadata.fieldName, + }), + ) + .getValue(); + + const objectMetadataItems = snapshot + .getLoadable(objectMetadataItemsState) + .getValue(); + + const objectMetadataItemArray = objectMetadataItems.filter( + (objectMetadataItem) => + objectNameSingulars.includes(objectMetadataItem.nameSingular), + ); + + if ( + !isDefined(objectMetadataItemArray) || + objectMetadataItemArray.length === 0 + ) { + return; + } + + openMultipleRecordPicker(recordPickerInstanceId); + + const pickableMorphItems: RecordPickerPickableMorphItem[] = + morphValuesWithObjectNameSingularFieldValue.flatMap( + (recordWithObjectNameSingular) => { + const objectMetadataItem = objectMetadataItemArray.find( + (objectMetadataItem) => + objectMetadataItem.nameSingular === + recordWithObjectNameSingular.objectNameSingular, + ); + + if (!objectMetadataItem) { + return []; + } + + return recordWithObjectNameSingular.value.map((recordValue) => ({ + objectMetadataId: objectMetadataItem.id, + recordId: recordValue.id, + isSelected: true, + isMatchingSearchFilter: true, + })); + }, + ); + + const records = morphValuesWithObjectNameSingularFieldValue.flatMap( + (recordWithObjectNameSingular) => recordWithObjectNameSingular.value, + ); + + for (const record of records) { + set(recordStoreFamilyState(record.id), record); + } + + set( + multipleRecordPickerPickableMorphItemsComponentState.atomFamily({ + instanceId: recordPickerInstanceId, + }), + pickableMorphItems, + ); + + set( + multipleRecordPickerSearchableObjectMetadataItemsComponentState.atomFamily( + { instanceId: recordPickerInstanceId }, + ), + objectMetadataItemArray, + ); + + performSearch({ + multipleRecordPickerInstanceId: recordPickerInstanceId, + forceSearchFilter: '', + forceSearchableObjectMetadataItems: objectMetadataItemArray, + forcePickableMorphItems: pickableMorphItems, + }); + + pushFocusItemToFocusStack({ + focusId: recordPickerInstanceId, + component: { + type: FocusComponentType.DROPDOWN, + instanceId: recordPickerInstanceId, + }, + globalHotkeysConfig: { + enableGlobalHotkeysConflictingWithKeyboard: false, + }, + }); + }, + [openMultipleRecordPicker, performSearch, pushFocusItemToFocusStack], + ); + + return { openMorphRelationFromManyFieldInput }; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/hooks/useUpdateMorphRelationManyToOneFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/hooks/useUpdateMorphRelationManyToOneFieldInput.tsx new file mode 100644 index 0000000000..ef5c97719d --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/hooks/useUpdateMorphRelationManyToOneFieldInput.tsx @@ -0,0 +1,66 @@ +import { useContext } from 'react'; +import { useRecoilCallback } from 'recoil'; + +import { useAttachMorphRelatedRecordFromRecord } from '@/object-record/hooks/useAttachMorphRelatedRecordFromRecord'; +import { useDetachMorphRelatedRecordFromRecord } from '@/object-record/hooks/useDetachMorphRelatedRecordFromRecord'; +import { FieldContext } from '@/object-record/record-field/ui/contexts/FieldContext'; +import { assertFieldMetadata } from '@/object-record/record-field/ui/types/guards/assertFieldMetadata'; +import { isFieldMorphRelation } from '@/object-record/record-field/ui/types/guards/isFieldMorphRelation'; +import { type RecordPickerPickableMorphItem } from '@/object-record/record-picker/types/RecordPickerPickableMorphItem'; +import { FieldMetadataType } from '~/generated-metadata/graphql'; + +export const useUpdateMorphRelationManyToOneFieldInput = () => { + const { recordId, fieldDefinition } = useContext(FieldContext); + + assertFieldMetadata( + FieldMetadataType.MORPH_RELATION, + isFieldMorphRelation, + fieldDefinition, + ); + + if (!fieldDefinition.metadata.objectMetadataNameSingular) { + throw new Error('ObjectMetadataNameSingular is required'); + } + + const { updateOneRecordAndDetachMorphRelations } = + useDetachMorphRelatedRecordFromRecord(); + + const { updateOneRecordAndAttachMorphRelations } = + useAttachMorphRelatedRecordFromRecord(); + + const updateMorphRelationManyToOne = useRecoilCallback( + () => + async ( + morphItem: Pick< + RecordPickerPickableMorphItem, + 'recordId' | 'isSelected' + >, + ) => { + const recordObjectNameSingulars = + fieldDefinition.metadata.morphRelations.map( + (morphRelation) => morphRelation.targetObjectMetadata.nameSingular, + ); + if (morphItem.isSelected) { + await updateOneRecordAndAttachMorphRelations({ + recordId, + relatedRecordId: morphItem.recordId, + objectNameSingulars: recordObjectNameSingulars, + }); + } else { + await updateOneRecordAndDetachMorphRelations({ + recordId, + relatedRecordId: morphItem.recordId, + objectNameSingulars: recordObjectNameSingulars, + }); + } + }, + [ + fieldDefinition.metadata.morphRelations, + recordId, + updateOneRecordAndAttachMorphRelations, + updateOneRecordAndDetachMorphRelations, + ], + ); + + return { updateMorphRelationManyToOne }; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-picker/single-record-picker/components/SingleRecordPickerMenuItemsWithSearch.tsx b/packages/twenty-front/src/modules/object-record/record-picker/single-record-picker/components/SingleRecordPickerMenuItemsWithSearch.tsx index bddc62af26..1e1a54495d 100644 --- a/packages/twenty-front/src/modules/object-record/record-picker/single-record-picker/components/SingleRecordPickerMenuItemsWithSearch.tsx +++ b/packages/twenty-front/src/modules/object-record/record-picker/single-record-picker/components/SingleRecordPickerMenuItemsWithSearch.tsx @@ -58,7 +58,7 @@ export const SingleRecordPickerMenuItemsWithSearch = ({ ); const { records } = useSingleRecordPickerRecords({ - objectNameSingular, + objectNameSingulars: [objectNameSingular], excludedRecordIds, }); diff --git a/packages/twenty-front/src/modules/object-record/record-picker/single-record-picker/hooks/useSingleRecordPickerRecords.ts b/packages/twenty-front/src/modules/object-record/record-picker/single-record-picker/hooks/useSingleRecordPickerRecords.ts index 9e25fe0277..62e64b4ad5 100644 --- a/packages/twenty-front/src/modules/object-record/record-picker/single-record-picker/hooks/useSingleRecordPickerRecords.ts +++ b/packages/twenty-front/src/modules/object-record/record-picker/single-record-picker/hooks/useSingleRecordPickerRecords.ts @@ -4,10 +4,10 @@ import { useFilteredSearchRecordQuery } from '@/search/hooks/useFilteredSearchRe import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; export const useSingleRecordPickerRecords = ({ - objectNameSingular, + objectNameSingulars, excludedRecordIds = [], }: { - objectNameSingular: string; + objectNameSingulars: string[]; excludedRecordIds?: string[]; }) => { const recordPickerSearchFilter = useRecoilComponentValue( @@ -17,12 +17,11 @@ export const useSingleRecordPickerRecords = ({ const selectedRecordId = useRecoilComponentValue( singleRecordPickerSelectedIdComponentState, ); - const records = useFilteredSearchRecordQuery({ searchFilter: recordPickerSearchFilter, selectedIds: selectedRecordId ? [selectedRecordId] : [], excludedRecordIds: excludedRecordIds, - objectNameSingular, + objectNameSingulars, }); return { records }; diff --git a/packages/twenty-front/src/modules/search/hooks/__tests__/useFilteredSearchRecordQuery.test.tsx b/packages/twenty-front/src/modules/search/hooks/__tests__/useFilteredSearchRecordQuery.test.tsx index 918db59e35..99c15ce60b 100644 --- a/packages/twenty-front/src/modules/search/hooks/__tests__/useFilteredSearchRecordQuery.test.tsx +++ b/packages/twenty-front/src/modules/search/hooks/__tests__/useFilteredSearchRecordQuery.test.tsx @@ -87,7 +87,7 @@ describe('useFilteredSearchRecordQuery', () => { selectedIds: ['1'], limit: 10, excludedRecordIds: ['2'], - objectNameSingular: 'person', + objectNameSingulars: ['person'], searchFilter: 'Entity', }); }, diff --git a/packages/twenty-front/src/modules/search/hooks/useFilteredSearchRecordQuery.ts b/packages/twenty-front/src/modules/search/hooks/useFilteredSearchRecordQuery.ts index fa7fb50aa7..49c0c6a741 100644 --- a/packages/twenty-front/src/modules/search/hooks/useFilteredSearchRecordQuery.ts +++ b/packages/twenty-front/src/modules/search/hooks/useFilteredSearchRecordQuery.ts @@ -8,13 +8,13 @@ export const useFilteredSearchRecordQuery = ({ selectedIds, limit, excludedRecordIds = [], - objectNameSingular, + objectNameSingulars, searchFilter, }: { selectedIds: string[]; limit?: number; excludedRecordIds?: string[]; - objectNameSingular: string; + objectNameSingulars: string[]; searchFilter?: string; }): { selectedRecords: SingleRecordPickerRecord[]; @@ -26,7 +26,7 @@ export const useFilteredSearchRecordQuery = ({ const { loading: selectedRecordsLoading, searchRecords: selectedRecords } = useObjectRecordSearchRecords({ - objectNameSingular, + objectNameSingulars, filter: selectedIdsFilter, skip: !selectedIds.length, searchInput: '', @@ -36,7 +36,7 @@ export const useFilteredSearchRecordQuery = ({ loading: filteredSelectedRecordsLoading, searchRecords: filteredSelectedRecords, } = useObjectRecordSearchRecords({ - objectNameSingular, + objectNameSingulars, filter: selectedIdsFilter, skip: !selectedIds.length, searchInput: searchFilter, @@ -48,7 +48,7 @@ export const useFilteredSearchRecordQuery = ({ : undefined; const { loading: recordsToSelectLoading, searchRecords: recordsToSelect } = useObjectRecordSearchRecords({ - objectNameSingular, + objectNameSingulars, filter: notFilter, limit: limit ?? DEFAULT_SEARCH_REQUEST_LIMIT, searchInput: searchFilter, diff --git a/packages/twenty-front/src/modules/settings/roles/role-assignment/components/SettingsRoleAssignmentWorkspaceMemberPickerDropdown.tsx b/packages/twenty-front/src/modules/settings/roles/role-assignment/components/SettingsRoleAssignmentWorkspaceMemberPickerDropdown.tsx index 09de44ddf9..0f2af2a971 100644 --- a/packages/twenty-front/src/modules/settings/roles/role-assignment/components/SettingsRoleAssignmentWorkspaceMemberPickerDropdown.tsx +++ b/packages/twenty-front/src/modules/settings/roles/role-assignment/components/SettingsRoleAssignmentWorkspaceMemberPickerDropdown.tsx @@ -23,7 +23,7 @@ export const SettingsRoleAssignmentWorkspaceMemberPickerDropdown = ({ const { loading, searchRecords: workspaceMembers } = useObjectRecordSearchRecords({ - objectNameSingular: CoreObjectNameSingular.WorkspaceMember, + objectNameSingulars: [CoreObjectNameSingular.WorkspaceMember], searchInput: searchFilter, });