diff --git a/packages/twenty-front/src/modules/apollo/optimistic-effect/hooks/useGetRelationFieldsToOptimisticallyUpdate.ts b/packages/twenty-front/src/modules/apollo/optimistic-effect/hooks/useGetRelationFieldsToOptimisticallyUpdate.ts deleted file mode 100644 index 4e5c6245e..000000000 --- a/packages/twenty-front/src/modules/apollo/optimistic-effect/hooks/useGetRelationFieldsToOptimisticallyUpdate.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { useRecoilCallback } from 'recoil'; - -import { TriggerUpdateRelationFieldOptimisticEffectParams } from '@/apollo/optimistic-effect/utils/triggerUpdateRelationFieldOptimisticEffect'; -import { objectMetadataItemFamilySelector } from '@/object-metadata/states/objectMetadataItemFamilySelector'; -import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; -import { ObjectRecord } from '@/object-record/types/ObjectRecord'; -import { FieldMetadataType } from '~/generated-metadata/graphql'; - -export const useGetRelationFieldsToOptimisticallyUpdate = () => - useRecoilCallback( - ({ snapshot }) => - ({ - cachedRecord, - objectMetadataItem, - updateRecordInput, - }: { - cachedRecord: UpdatedObjectRecord & { __typename: string }; - objectMetadataItem: ObjectMetadataItem; - updateRecordInput: Partial>; - }) => - Object.entries(updateRecordInput).reduce< - Pick< - TriggerUpdateRelationFieldOptimisticEffectParams, - | 'relationObjectMetadataNameSingular' - | 'relationFieldName' - | 'previousRelationRecord' - | 'nextRelationRecord' - >[] - >((result, [fieldName, nextRelationRecord]) => { - const fieldDefinition = objectMetadataItem.fields.find( - (fieldMetadataItem) => fieldMetadataItem.name === fieldName, - ); - - if (fieldDefinition?.type !== FieldMetadataType.Relation) - return result; - - const relationObjectMetadataNameSingular = ( - fieldDefinition.toRelationMetadata?.fromObjectMetadata || - fieldDefinition.fromRelationMetadata?.toObjectMetadata - )?.nameSingular; - const relationFieldMetadataId = - fieldDefinition.toRelationMetadata?.fromFieldMetadataId || - fieldDefinition.fromRelationMetadata?.toFieldMetadataId; - - if (!relationObjectMetadataNameSingular || !relationFieldMetadataId) - return result; - - const relationObjectMetadataItem = snapshot - .getLoadable( - objectMetadataItemFamilySelector({ - objectName: relationObjectMetadataNameSingular, - objectNameType: 'singular', - }), - ) - .valueOrThrow(); - - if (!relationObjectMetadataItem) return result; - - const relationFieldName = relationObjectMetadataItem.fields.find( - (fieldMetadataItem) => - fieldMetadataItem.id === relationFieldMetadataId, - )?.name; - - if (!relationFieldName) return result; - - return [ - ...result, - { - relationObjectMetadataNameSingular, - relationFieldName, - previousRelationRecord: cachedRecord[fieldName], - nextRelationRecord, - }, - ]; - }, []), - ); diff --git a/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/isCachedObjectConnection.ts b/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/isCachedObjectRecordConnection.ts similarity index 66% rename from packages/twenty-front/src/modules/apollo/optimistic-effect/utils/isCachedObjectConnection.ts rename to packages/twenty-front/src/modules/apollo/optimistic-effect/utils/isCachedObjectRecordConnection.ts index e77b14119..92186f3df 100644 --- a/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/isCachedObjectConnection.ts +++ b/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/isCachedObjectRecordConnection.ts @@ -4,15 +4,24 @@ import { z } from 'zod'; import { CachedObjectRecordConnection } from '@/apollo/types/CachedObjectRecordConnection'; import { capitalize } from '~/utils/string/capitalize'; -export const isCachedObjectConnection = ( +export const isCachedObjectRecordConnection = ( objectNameSingular: string, storeValue: StoreValue, ): storeValue is CachedObjectRecordConnection => { const objectConnectionTypeName = `${capitalize( objectNameSingular, )}Connection`; + const objectEdgeTypeName = `${capitalize(objectNameSingular)}Edge`; const cachedObjectConnectionSchema = z.object({ __typename: z.literal(objectConnectionTypeName), + edges: z.array( + z.object({ + __typename: z.literal(objectEdgeTypeName), + node: z.object({ + __ref: z.string().startsWith(`${capitalize(objectNameSingular)}:`), + }), + }), + ), }); const cachedConnectionValidation = cachedObjectConnectionSchema.safeParse(storeValue); diff --git a/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/isObjectRecordConnection.ts b/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/isObjectRecordConnection.ts new file mode 100644 index 000000000..7017d15c0 --- /dev/null +++ b/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/isObjectRecordConnection.ts @@ -0,0 +1,28 @@ +import { z } from 'zod'; + +import { ObjectRecordConnection } from '@/object-record/types/ObjectRecordConnection'; +import { capitalize } from '~/utils/string/capitalize'; + +export const isObjectRecordConnection = ( + objectNameSingular: string, + value: unknown, +): value is ObjectRecordConnection => { + const objectConnectionTypeName = `${capitalize( + objectNameSingular, + )}Connection`; + const objectEdgeTypeName = `${capitalize(objectNameSingular)}Edge`; + const objectConnectionSchema = z.object({ + __typename: z.literal(objectConnectionTypeName), + edges: z.array( + z.object({ + __typename: z.literal(objectEdgeTypeName), + node: z.object({ + id: z.string().uuid(), + }), + }), + ), + }); + const connectionValidation = objectConnectionSchema.safeParse(value); + + return connectionValidation.success; +}; diff --git a/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerAttachRelationOptimisticEffect.ts b/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerAttachRelationOptimisticEffect.ts new file mode 100644 index 000000000..68ac70a34 --- /dev/null +++ b/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerAttachRelationOptimisticEffect.ts @@ -0,0 +1,59 @@ +import { ApolloCache, StoreObject } from '@apollo/client'; + +import { isCachedObjectRecordConnection } from '@/apollo/optimistic-effect/utils/isCachedObjectRecordConnection'; +import { CachedObjectRecordEdge } from '@/apollo/types/CachedObjectRecordEdge'; +import { capitalize } from '~/utils/string/capitalize'; + +export const triggerAttachRelationOptimisticEffect = ({ + cache, + objectNameSingular, + recordId, + relationObjectMetadataNameSingular, + relationFieldName, + relationRecordId, +}: { + cache: ApolloCache; + objectNameSingular: string; + recordId: string; + relationObjectMetadataNameSingular: string; + relationFieldName: string; + relationRecordId: string; +}) => { + const recordTypeName = capitalize(objectNameSingular); + const relationRecordTypeName = capitalize(relationObjectMetadataNameSingular); + + cache.modify({ + id: cache.identify({ + id: relationRecordId, + __typename: relationRecordTypeName, + }), + fields: { + [relationFieldName]: (cachedFieldValue, { toReference }) => { + const nodeReference = toReference({ + id: recordId, + __typename: recordTypeName, + }); + + if (!nodeReference) return cachedFieldValue; + + if ( + isCachedObjectRecordConnection(objectNameSingular, cachedFieldValue) + ) { + // To many objects => add record to next relation field list + const nextEdges: CachedObjectRecordEdge[] = [ + ...cachedFieldValue.edges, + { + __typename: `${recordTypeName}Edge`, + node: nodeReference, + cursor: '', + }, + ]; + return { ...cachedFieldValue, edges: nextEdges }; + } + + // To one object => attach next relation record + return nodeReference; + }, + }, + }); +}; diff --git a/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerCreateRecordsOptimisticEffect.ts b/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerCreateRecordsOptimisticEffect.ts index 4ca7e2f92..19dbe252c 100644 --- a/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerCreateRecordsOptimisticEffect.ts +++ b/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerCreateRecordsOptimisticEffect.ts @@ -1,6 +1,6 @@ import { ApolloCache, StoreObject } from '@apollo/client'; -import { isCachedObjectConnection } from '@/apollo/optimistic-effect/utils/isCachedObjectConnection'; +import { isCachedObjectRecordConnection } from '@/apollo/optimistic-effect/utils/isCachedObjectRecordConnection'; import { CachedObjectRecord } from '@/apollo/types/CachedObjectRecord'; import { CachedObjectRecordEdge } from '@/apollo/types/CachedObjectRecordEdge'; import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; @@ -36,7 +36,7 @@ export const triggerCreateRecordsOptimisticEffect = ({ }, ) => { if ( - !isCachedObjectConnection( + !isCachedObjectRecordConnection( objectMetadataItem.nameSingular, cachedConnection, ) diff --git a/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerDeleteRecordsOptimisticEffect.ts b/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerDeleteRecordsOptimisticEffect.ts index 280ad5f73..eaa1ecc56 100644 --- a/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerDeleteRecordsOptimisticEffect.ts +++ b/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerDeleteRecordsOptimisticEffect.ts @@ -1,6 +1,6 @@ import { ApolloCache, StoreObject } from '@apollo/client'; -import { isCachedObjectConnection } from '@/apollo/optimistic-effect/utils/isCachedObjectConnection'; +import { isCachedObjectRecordConnection } from '@/apollo/optimistic-effect/utils/isCachedObjectRecordConnection'; import { CachedObjectRecord } from '@/apollo/types/CachedObjectRecord'; import { CachedObjectRecordEdge } from '@/apollo/types/CachedObjectRecordEdge'; import { CachedObjectRecordQueryVariables } from '@/apollo/types/CachedObjectRecordQueryVariables'; @@ -24,7 +24,7 @@ export const triggerDeleteRecordsOptimisticEffect = ({ { DELETE, readField, storeFieldName }, ) => { if ( - !isCachedObjectConnection( + !isCachedObjectRecordConnection( objectMetadataItem.nameSingular, cachedConnection, ) diff --git a/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerDetachRelationOptimisticEffect.ts b/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerDetachRelationOptimisticEffect.ts new file mode 100644 index 000000000..f68265920 --- /dev/null +++ b/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerDetachRelationOptimisticEffect.ts @@ -0,0 +1,49 @@ +import { ApolloCache, StoreObject } from '@apollo/client'; + +import { isCachedObjectRecordConnection } from '@/apollo/optimistic-effect/utils/isCachedObjectRecordConnection'; +import { capitalize } from '~/utils/string/capitalize'; + +export const triggerDetachRelationOptimisticEffect = ({ + cache, + objectNameSingular, + recordId, + relationObjectMetadataNameSingular, + relationFieldName, + relationRecordId, +}: { + cache: ApolloCache; + objectNameSingular: string; + recordId: string; + relationObjectMetadataNameSingular: string; + relationFieldName: string; + relationRecordId: string; +}) => { + const relationRecordTypeName = capitalize(relationObjectMetadataNameSingular); + + cache.modify({ + id: cache.identify({ + id: relationRecordId, + __typename: relationRecordTypeName, + }), + fields: { + [relationFieldName]: (cachedFieldValue, { isReference, readField }) => { + // To many objects => remove record from previous relation field list + if ( + isCachedObjectRecordConnection(objectNameSingular, cachedFieldValue) + ) { + const nextEdges = cachedFieldValue.edges.filter( + ({ node }) => readField('id', node) !== recordId, + ); + return { ...cachedFieldValue, edges: nextEdges }; + } + + // To one object => detach previous relation record + if (isReference(cachedFieldValue)) { + return null; + } + + return cachedFieldValue; + }, + }, + }); +}; diff --git a/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerUpdateRecordOptimisticEffect.ts b/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerUpdateRecordOptimisticEffect.ts index 4da9b2839..23bf9be29 100644 --- a/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerUpdateRecordOptimisticEffect.ts +++ b/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerUpdateRecordOptimisticEffect.ts @@ -1,6 +1,6 @@ import { ApolloCache, StoreObject } from '@apollo/client'; -import { isCachedObjectConnection } from '@/apollo/optimistic-effect/utils/isCachedObjectConnection'; +import { isCachedObjectRecordConnection } from '@/apollo/optimistic-effect/utils/isCachedObjectRecordConnection'; import { sortCachedObjectEdges } from '@/apollo/optimistic-effect/utils/sortCachedObjectEdges'; import { CachedObjectRecord } from '@/apollo/types/CachedObjectRecord'; import { CachedObjectRecordEdge } from '@/apollo/types/CachedObjectRecordEdge'; @@ -31,7 +31,7 @@ export const triggerUpdateRecordOptimisticEffect = ({ { DELETE, readField, storeFieldName, toReference }, ) => { if ( - !isCachedObjectConnection( + !isCachedObjectRecordConnection( objectMetadataItem.nameSingular, cachedConnection, ) diff --git a/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerUpdateRelationFieldOptimisticEffect.ts b/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerUpdateRelationFieldOptimisticEffect.ts deleted file mode 100644 index 0f77085c9..000000000 --- a/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerUpdateRelationFieldOptimisticEffect.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { ApolloCache, StoreObject } from '@apollo/client'; - -import { isCachedObjectConnection } from '@/apollo/optimistic-effect/utils/isCachedObjectConnection'; -import { CachedObjectRecordEdge } from '@/apollo/types/CachedObjectRecordEdge'; -import { ObjectRecord } from '@/object-record/types/ObjectRecord'; -import { capitalize } from '~/utils/string/capitalize'; - -export type TriggerUpdateRelationFieldOptimisticEffectParams = { - cache: ApolloCache; - objectNameSingular: string; - record: ObjectRecord; - relationObjectMetadataNameSingular: string; - relationFieldName: string; - previousRelationRecord: ObjectRecord | null; - nextRelationRecord: ObjectRecord | null; -}; - -export const triggerUpdateRelationFieldOptimisticEffect = ({ - cache, - objectNameSingular, - record, - relationObjectMetadataNameSingular, - relationFieldName, - previousRelationRecord, - nextRelationRecord, -}: TriggerUpdateRelationFieldOptimisticEffectParams) => { - const recordTypeName = capitalize(objectNameSingular); - const relationRecordTypeName = capitalize(relationObjectMetadataNameSingular); - - if (previousRelationRecord) { - cache.modify({ - id: cache.identify({ - ...previousRelationRecord, - __typename: relationRecordTypeName, - }), - fields: { - [relationFieldName]: (cachedFieldValue, { isReference, readField }) => { - // To many objects => remove record from previous relation field list - if (isCachedObjectConnection(objectNameSingular, cachedFieldValue)) { - const nextEdges = cachedFieldValue.edges.filter( - ({ node }) => readField('id', node) !== record.id, - ); - return { ...cachedFieldValue, edges: nextEdges }; - } - - // To one object => detach previous relation record - if (isReference(cachedFieldValue)) { - return null; - } - }, - }, - }); - } - - if (nextRelationRecord) { - cache.modify({ - id: cache.identify({ - ...nextRelationRecord, - __typename: relationRecordTypeName, - }), - fields: { - [relationFieldName]: (cachedFieldValue, { toReference }) => { - const nodeReference = toReference(record); - - if (!nodeReference) return cachedFieldValue; - - if (isCachedObjectConnection(objectNameSingular, cachedFieldValue)) { - // To many objects => add record to next relation field list - const nextEdges: CachedObjectRecordEdge[] = [ - ...cachedFieldValue.edges, - { - __typename: `${recordTypeName}Edge`, - node: nodeReference, - cursor: '', - }, - ]; - return { ...cachedFieldValue, edges: nextEdges }; - } - - // To one object => attach next relation record - return nodeReference; - }, - }, - }); - } -}; diff --git a/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useGetRelationMetadata.test.tsx b/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useGetRelationMetadata.test.tsx new file mode 100644 index 000000000..b13554ed6 --- /dev/null +++ b/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useGetRelationMetadata.test.tsx @@ -0,0 +1,70 @@ +import { ReactNode, useEffect } from 'react'; +import { MockedProvider } from '@apollo/client/testing'; +import { renderHook } from '@testing-library/react'; +import { RecoilRoot, useSetRecoilState } from 'recoil'; + +import { useGetRelationMetadata } from '@/object-metadata/hooks/useGetRelationMetadata'; +import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState'; +import { getObjectMetadataItemsMock } from '@/object-metadata/utils/getObjectMetadataItemsMock'; + +import { TestApolloMetadataClientProvider } from '../__mocks__/ApolloMetadataClientProvider'; + +const Wrapper = ({ children }: { children: ReactNode }) => ( + + + + {children} + + + +); + +describe('useGetRelationMetadata', () => { + it('should return correct properties', async () => { + const objectMetadataItems = getObjectMetadataItemsMock(); + const objectMetadata = objectMetadataItems.find( + (item) => item.nameSingular === 'person', + )!; + const fieldMetadataItem = objectMetadata.fields.find( + (field) => field.name === 'opportunities', + )!; + + const { result } = renderHook( + () => { + const setMetadataItems = useSetRecoilState(objectMetadataItemsState); + + useEffect(() => { + setMetadataItems(objectMetadataItems); + }, [setMetadataItems]); + + return useGetRelationMetadata(); + }, + { + wrapper: Wrapper, + initialProps: {}, + }, + ); + + const { + relationFieldMetadataItem, + relationObjectMetadataItem, + relationType, + } = result.current({ fieldMetadataItem }) ?? {}; + + const expectedRelationObjectMetadataItem = objectMetadataItems.find( + (item) => item.nameSingular === 'opportunity', + ); + const expectedRelationFieldMetadataItem = + expectedRelationObjectMetadataItem?.fields.find( + (field) => field.name === 'person', + ); + + expect(relationObjectMetadataItem).toEqual( + expectedRelationObjectMetadataItem, + ); + expect(relationFieldMetadataItem).toEqual( + expectedRelationFieldMetadataItem, + ); + expect(relationType).toBe('ONE_TO_MANY'); + }); +}); diff --git a/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useRelationMetadata.test.tsx b/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useRelationMetadata.test.tsx deleted file mode 100644 index d377424bf..000000000 --- a/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useRelationMetadata.test.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { ReactNode } from 'react'; -import { MockedProvider } from '@apollo/client/testing'; -import { renderHook } from '@testing-library/react'; -import { RecoilRoot } from 'recoil'; - -import { useRelationMetadata } from '@/object-metadata/hooks/useRelationMetadata'; -import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; -import { getObjectMetadataItemsMock } from '@/object-metadata/utils/getObjectMetadataItemsMock'; - -import { TestApolloMetadataClientProvider } from '../__mocks__/ApolloMetadataClientProvider'; - -const Wrapper = ({ children }: { children: ReactNode }) => ( - - - - {children} - - - -); - -describe('useRelationMetadata', () => { - it('should return correct properties', async () => { - const { result, rerender } = renderHook( - ({ fieldMetadataItem }: { fieldMetadataItem?: FieldMetadataItem }) => - useRelationMetadata({ fieldMetadataItem }), - { - wrapper: Wrapper, - initialProps: {}, - }, - ); - - const { - relationFieldMetadataItem, - relationObjectMetadataItem, - relationType, - } = result.current; - - expect(relationFieldMetadataItem).toBeUndefined(); - expect(relationObjectMetadataItem).toBeUndefined(); - expect(relationType).toBeUndefined(); - - const objectMetadataItems = getObjectMetadataItemsMock(); - const objectMetadata = objectMetadataItems.find( - (item) => item.nameSingular === 'person', - )!; - const fieldMetadataItem = objectMetadata.fields.find( - (field) => field.name === 'opportunities', - )!; - - rerender({ fieldMetadataItem }); - - expect(result.current.relationType).toBe('ONE_TO_MANY'); - }); -}); diff --git a/packages/twenty-front/src/modules/object-metadata/hooks/useGetRelationMetadata.ts b/packages/twenty-front/src/modules/object-metadata/hooks/useGetRelationMetadata.ts new file mode 100644 index 000000000..8b8765d4f --- /dev/null +++ b/packages/twenty-front/src/modules/object-metadata/hooks/useGetRelationMetadata.ts @@ -0,0 +1,67 @@ +import { useRecoilCallback } from 'recoil'; + +import { objectMetadataItemFamilySelector } from '@/object-metadata/states/objectMetadataItemFamilySelector'; +import { RelationType } from '@/settings/data-model/types/RelationType'; +import { + FieldMetadataType, + RelationMetadataType, +} from '~/generated-metadata/graphql'; + +import { FieldMetadataItem } from '../types/FieldMetadataItem'; + +export const useGetRelationMetadata = () => + useRecoilCallback( + ({ snapshot }) => + ({ fieldMetadataItem }: { fieldMetadataItem: FieldMetadataItem }) => { + if (fieldMetadataItem.type !== FieldMetadataType.Relation) return null; + + const relationMetadata = + fieldMetadataItem.fromRelationMetadata || + fieldMetadataItem.toRelationMetadata; + + if (!relationMetadata) return null; + + const relationFieldMetadataId = + 'toFieldMetadataId' in relationMetadata + ? relationMetadata.toFieldMetadataId + : relationMetadata.fromFieldMetadataId; + + if (!relationFieldMetadataId) return null; + + const relationType = + relationMetadata.relationType === RelationMetadataType.OneToMany && + fieldMetadataItem.toRelationMetadata + ? 'MANY_TO_ONE' + : (relationMetadata.relationType as RelationType); + + const relationObjectMetadataNameSingular = + 'toObjectMetadata' in relationMetadata + ? relationMetadata.toObjectMetadata.nameSingular + : relationMetadata.fromObjectMetadata.nameSingular; + + const relationObjectMetadataItem = snapshot + .getLoadable( + objectMetadataItemFamilySelector({ + objectName: relationObjectMetadataNameSingular, + objectNameType: 'singular', + }), + ) + .valueOrThrow(); + + if (!relationObjectMetadataItem) return null; + + const relationFieldMetadataItem = + relationObjectMetadataItem.fields.find( + (field) => field.id === relationFieldMetadataId, + ); + + if (!relationFieldMetadataItem) return null; + + return { + relationFieldMetadataItem, + relationObjectMetadataItem, + relationType, + }; + }, + [], + ); diff --git a/packages/twenty-front/src/modules/object-metadata/hooks/useRelationMetadata.ts b/packages/twenty-front/src/modules/object-metadata/hooks/useRelationMetadata.ts deleted file mode 100644 index b6d2657d8..000000000 --- a/packages/twenty-front/src/modules/object-metadata/hooks/useRelationMetadata.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { RelationType } from '@/settings/data-model/types/RelationType'; -import { RelationMetadataType } from '~/generated-metadata/graphql'; - -import { useObjectMetadataItemForSettings } from '../hooks/useObjectMetadataItemForSettings'; -import { FieldMetadataItem } from '../types/FieldMetadataItem'; - -export const useRelationMetadata = ({ - fieldMetadataItem, -}: { - fieldMetadataItem?: FieldMetadataItem; -}) => { - const { findObjectMetadataItemById } = useObjectMetadataItemForSettings(); - - const relationMetadata = - fieldMetadataItem?.fromRelationMetadata || - fieldMetadataItem?.toRelationMetadata; - - const relationType = - relationMetadata?.relationType === RelationMetadataType.OneToMany && - fieldMetadataItem?.toRelationMetadata - ? 'MANY_TO_ONE' - : (relationMetadata?.relationType as RelationType | undefined); - - const relationObjectMetadataId = - relationMetadata && 'toObjectMetadata' in relationMetadata - ? relationMetadata.toObjectMetadata.id - : relationMetadata?.fromObjectMetadata.id; - - const relationObjectMetadataItem = relationObjectMetadataId - ? findObjectMetadataItemById(relationObjectMetadataId) - : undefined; - - const relationFieldMetadataId = - relationMetadata && 'toFieldMetadataId' in relationMetadata - ? relationMetadata.toFieldMetadataId - : relationMetadata?.fromFieldMetadataId; - - const relationFieldMetadataItem = relationFieldMetadataId - ? relationObjectMetadataItem?.fields?.find( - (field) => field.id === relationFieldMetadataId, - ) - : undefined; - - return { - relationFieldMetadataItem, - relationObjectMetadataItem, - relationType, - }; -}; diff --git a/packages/twenty-front/src/modules/object-record/cache/hooks/useGetRecordFromCache.ts b/packages/twenty-front/src/modules/object-record/cache/hooks/useGetRecordFromCache.ts index 78986eec3..a81109367 100644 --- a/packages/twenty-front/src/modules/object-record/cache/hooks/useGetRecordFromCache.ts +++ b/packages/twenty-front/src/modules/object-record/cache/hooks/useGetRecordFromCache.ts @@ -15,6 +15,7 @@ export const useGetRecordFromCache = ({ return ( recordId: string, + cache = apolloClient.cache, ) => { if (!objectMetadataItem) { return null; @@ -31,7 +32,6 @@ export const useGetRecordFromCache = ({ } `; - const cache = apolloClient.cache; const cachedRecordId = cache.identify({ __typename: capitalize(objectMetadataItem.nameSingular), id: recordId, diff --git a/packages/twenty-front/src/modules/object-record/hooks/useDeleteManyRecords.ts b/packages/twenty-front/src/modules/object-record/hooks/useDeleteManyRecords.ts index f62fa9570..080a19cb2 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/useDeleteManyRecords.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/useDeleteManyRecords.ts @@ -1,9 +1,14 @@ import { useApolloClient } from '@apollo/client'; +import { isObjectRecordConnection } from '@/apollo/optimistic-effect/utils/isObjectRecordConnection'; import { triggerDeleteRecordsOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerDeleteRecordsOptimisticEffect'; +import { triggerDetachRelationOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerDetachRelationOptimisticEffect'; +import { useGetRelationMetadata } from '@/object-metadata/hooks/useGetRelationMetadata'; import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; import { getDeleteManyRecordsMutationResponseField } from '@/object-record/hooks/useGenerateDeleteManyRecordMutation'; -import { ObjectRecordQueryFilter } from '@/object-record/record-filter/types/ObjectRecordQueryFilter'; +import { ObjectRecord } from '@/object-record/types/ObjectRecord'; +import { ObjectRecordConnection } from '@/object-record/types/ObjectRecordConnection'; +import { isDefined } from '~/utils/isDefined'; import { capitalize } from '~/utils/string/capitalize'; type useDeleteOneRecordProps = { @@ -14,9 +19,11 @@ type useDeleteOneRecordProps = { export const useDeleteManyRecords = ({ objectNameSingular, }: useDeleteOneRecordProps) => { - const { objectMetadataItem, deleteManyRecordsMutation } = + const { objectMetadataItem, deleteManyRecordsMutation, getRecordFromCache } = useObjectMetadataItem({ objectNameSingular }); + const getRelationMetadata = useGetRelationMetadata(); + const apolloClient = useApolloClient(); const mutationResponseField = getDeleteManyRecordsMutationResponseField( @@ -24,16 +31,10 @@ export const useDeleteManyRecords = ({ ); const deleteManyRecords = async (idsToDelete: string[]) => { - const deleteRecordFilter: ObjectRecordQueryFilter = { - id: { - in: idsToDelete, - }, - }; const deletedRecords = await apolloClient.mutate({ mutation: deleteManyRecordsMutation, variables: { - filter: deleteRecordFilter, - // atMost: idsToDelete.length, + filter: { id: { in: idsToDelete } }, }, optimisticResponse: { [mutationResponseField]: idsToDelete.map((idToDelete) => ({ @@ -46,10 +47,49 @@ export const useDeleteManyRecords = ({ if (!records?.length) return; + objectMetadataItem.fields.forEach((fieldMetadataItem) => { + const relationMetadata = getRelationMetadata({ fieldMetadataItem }); + + if (!relationMetadata) return; + + const { relationObjectMetadataItem, relationFieldMetadataItem } = + relationMetadata; + + records.forEach((record) => { + const cachedRecord = getRecordFromCache(record.id, cache); + + if (!cachedRecord) return; + + const previousFieldValue: + | ObjectRecordConnection + | ObjectRecord + | null = cachedRecord[fieldMetadataItem.name]; + + const relationRecordIds = isObjectRecordConnection( + relationObjectMetadataItem.nameSingular, + previousFieldValue, + ) + ? previousFieldValue.edges.map(({ node }) => node.id) + : [previousFieldValue?.id].filter(isDefined); + + relationRecordIds.forEach((relationRecordId) => + triggerDetachRelationOptimisticEffect({ + cache, + objectNameSingular, + recordId: record.id, + relationObjectMetadataNameSingular: + relationObjectMetadataItem.nameSingular, + relationFieldName: relationFieldMetadataItem.name, + relationRecordId, + }), + ); + }); + }); + triggerDeleteRecordsOptimisticEffect({ cache, objectMetadataItem, - records, + records: records, }); }, }); diff --git a/packages/twenty-front/src/modules/object-record/hooks/useDeleteOneRecord.ts b/packages/twenty-front/src/modules/object-record/hooks/useDeleteOneRecord.ts index 5987d1472..389b2e646 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/useDeleteOneRecord.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/useDeleteOneRecord.ts @@ -1,9 +1,15 @@ import { useCallback } from 'react'; import { useApolloClient } from '@apollo/client'; +import { isObjectRecordConnection } from '@/apollo/optimistic-effect/utils/isObjectRecordConnection'; import { triggerDeleteRecordsOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerDeleteRecordsOptimisticEffect'; +import { triggerDetachRelationOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerDetachRelationOptimisticEffect'; +import { useGetRelationMetadata } from '@/object-metadata/hooks/useGetRelationMetadata'; import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; +import { ObjectRecord } from '@/object-record/types/ObjectRecord'; +import { ObjectRecordConnection } from '@/object-record/types/ObjectRecordConnection'; import { getDeleteOneRecordMutationResponseField } from '@/object-record/utils/generateDeleteOneRecordMutation'; +import { isDefined } from '~/utils/isDefined'; import { capitalize } from '~/utils/string/capitalize'; type useDeleteOneRecordProps = { @@ -14,9 +20,10 @@ type useDeleteOneRecordProps = { export const useDeleteOneRecord = ({ objectNameSingular, }: useDeleteOneRecordProps) => { - const { objectMetadataItem, deleteOneRecordMutation } = useObjectMetadataItem( - { objectNameSingular }, - ); + const { objectMetadataItem, deleteOneRecordMutation, getRecordFromCache } = + useObjectMetadataItem({ objectNameSingular }); + + const getRelationMetadata = useGetRelationMetadata(); const apolloClient = useApolloClient(); @@ -39,6 +46,43 @@ export const useDeleteOneRecord = ({ if (!record) return; + objectMetadataItem.fields.forEach((fieldMetadataItem) => { + const relationMetadata = getRelationMetadata({ fieldMetadataItem }); + + if (!relationMetadata) return; + + const { relationObjectMetadataItem, relationFieldMetadataItem } = + relationMetadata; + + const cachedRecord = getRecordFromCache(record.id, cache); + + if (!cachedRecord) return; + + const previousFieldValue: + | ObjectRecordConnection + | ObjectRecord + | null = cachedRecord[fieldMetadataItem.name]; + + const relationRecordIds = isObjectRecordConnection( + relationObjectMetadataItem.nameSingular, + previousFieldValue, + ) + ? previousFieldValue.edges.map(({ node }) => node.id) + : [previousFieldValue?.id].filter(isDefined); + + relationRecordIds.forEach((relationRecordId) => + triggerDetachRelationOptimisticEffect({ + cache, + objectNameSingular, + recordId: record.id, + relationObjectMetadataNameSingular: + relationObjectMetadataItem.nameSingular, + relationFieldName: relationFieldMetadataItem.name, + relationRecordId, + }), + ); + }); + triggerDeleteRecordsOptimisticEffect({ cache, objectMetadataItem, @@ -52,6 +96,8 @@ export const useDeleteOneRecord = ({ [ apolloClient, deleteOneRecordMutation, + getRecordFromCache, + getRelationMetadata, mutationResponseField, objectMetadataItem, objectNameSingular, diff --git a/packages/twenty-front/src/modules/object-record/hooks/useUpdateOneRecord.ts b/packages/twenty-front/src/modules/object-record/hooks/useUpdateOneRecord.ts index 796e41742..611c34dd2 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/useUpdateOneRecord.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/useUpdateOneRecord.ts @@ -1,12 +1,15 @@ import { useApolloClient } from '@apollo/client'; -import { useGetRelationFieldsToOptimisticallyUpdate } from '@/apollo/optimistic-effect/hooks/useGetRelationFieldsToOptimisticallyUpdate'; +import { isObjectRecordConnection } from '@/apollo/optimistic-effect/utils/isObjectRecordConnection'; +import { triggerAttachRelationOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerAttachRelationOptimisticEffect'; +import { triggerDetachRelationOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerDetachRelationOptimisticEffect'; import { triggerUpdateRecordOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerUpdateRecordOptimisticEffect'; -import { triggerUpdateRelationFieldOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerUpdateRelationFieldOptimisticEffect'; +import { useGetRelationMetadata } from '@/object-metadata/hooks/useGetRelationMetadata'; import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; import { getUpdateOneRecordMutationResponseField } from '@/object-record/hooks/useGenerateUpdateOneRecordMutation'; import { ObjectRecord } from '@/object-record/types/ObjectRecord'; import { sanitizeRecordInput } from '@/object-record/utils/sanitizeRecordInput'; +import { isDeeplyEqual } from '~/utils/isDeeplyEqual'; import { capitalize } from '~/utils/string/capitalize'; type useUpdateOneRecordProps = { @@ -21,8 +24,7 @@ export const useUpdateOneRecord = < const { objectMetadataItem, updateOneRecordMutation, getRecordFromCache } = useObjectMetadataItem({ objectNameSingular }); - const getRelationFieldsToOptimisticallyUpdate = - useGetRelationFieldsToOptimisticallyUpdate(); + const getRelationMetadata = useGetRelationMetadata(); const apolloClient = useApolloClient(); @@ -48,14 +50,6 @@ export const useUpdateOneRecord = < id: idToUpdate, }; - const updatedRelationFields = cachedRecord - ? getRelationFieldsToOptimisticallyUpdate({ - cachedRecord, - objectMetadataItem, - updateRecordInput: updateOneRecordInput, - }) - : []; - const mutationResponseField = getUpdateOneRecordMutationResponseField(objectNameSingular); @@ -73,29 +67,59 @@ export const useUpdateOneRecord = < if (!record) return; + objectMetadataItem.fields.forEach((fieldMetadataItem) => { + const relationMetadata = getRelationMetadata({ fieldMetadataItem }); + + if (!relationMetadata) return; + + const { relationObjectMetadataItem, relationFieldMetadataItem } = + relationMetadata; + + const previousFieldValue = cachedRecord?.[fieldMetadataItem.name]; + const nextFieldValue = + updateOneRecordInput[fieldMetadataItem.name] ?? null; + + if ( + !(fieldMetadataItem.name in updateOneRecordInput) || + isObjectRecordConnection( + relationObjectMetadataItem.nameSingular, + previousFieldValue, + ) || + isDeeplyEqual(previousFieldValue, nextFieldValue) + ) { + return; + } + + if (previousFieldValue) { + triggerDetachRelationOptimisticEffect({ + cache, + objectNameSingular, + recordId: record.id, + relationObjectMetadataNameSingular: + relationObjectMetadataItem.nameSingular, + relationFieldName: relationFieldMetadataItem.name, + relationRecordId: previousFieldValue.id, + }); + } + + if (nextFieldValue) { + triggerAttachRelationOptimisticEffect({ + cache, + objectNameSingular, + recordId: record.id, + relationObjectMetadataNameSingular: + relationObjectMetadataItem.nameSingular, + relationFieldName: relationFieldMetadataItem.name, + relationRecordId: nextFieldValue.id, + }); + } + }); + triggerUpdateRecordOptimisticEffect({ cache, objectMetadataItem, record, }); - - updatedRelationFields.forEach( - ({ - relationObjectMetadataNameSingular, - relationFieldName, - previousRelationRecord, - nextRelationRecord, - }) => - triggerUpdateRelationFieldOptimisticEffect({ - cache, - objectNameSingular, - record, - relationObjectMetadataNameSingular, - relationFieldName, - previousRelationRecord, - nextRelationRecord, - }), - ); }, }); diff --git a/packages/twenty-front/src/modules/settings/data-model/object-details/components/SettingsObjectFieldItemTableRow.tsx b/packages/twenty-front/src/modules/settings/data-model/object-details/components/SettingsObjectFieldItemTableRow.tsx index bec44f3b5..242f8a6a6 100644 --- a/packages/twenty-front/src/modules/settings/data-model/object-details/components/SettingsObjectFieldItemTableRow.tsx +++ b/packages/twenty-front/src/modules/settings/data-model/object-details/components/SettingsObjectFieldItemTableRow.tsx @@ -1,9 +1,9 @@ -import { ReactNode } from 'react'; +import { ReactNode, useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; import { useTheme } from '@emotion/react'; import styled from '@emotion/styled'; -import { useRelationMetadata } from '@/object-metadata/hooks/useRelationMetadata'; +import { useGetRelationMetadata } from '@/object-metadata/hooks/useGetRelationMetadata'; import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; import { getObjectSlug } from '@/object-metadata/utils/getObjectSlug'; import { FieldIdentifierType } from '@/settings/data-model/types/FieldIdentifierType'; @@ -53,9 +53,13 @@ export const SettingsObjectFieldItemTableRow = ({ const fieldDataTypeIsSupported = fieldMetadataItem.type in settingsFieldMetadataTypes; - const { relationObjectMetadataItem, relationType } = useRelationMetadata({ - fieldMetadataItem, - }); + const getRelationMetadata = useGetRelationMetadata(); + + const { relationObjectMetadataItem, relationType } = + useMemo( + () => getRelationMetadata({ fieldMetadataItem }), + [fieldMetadataItem, getRelationMetadata], + ) ?? {}; if (!fieldDataTypeIsSupported) return null; diff --git a/packages/twenty-front/src/pages/settings/data-model/SettingsObjectFieldEdit.tsx b/packages/twenty-front/src/pages/settings/data-model/SettingsObjectFieldEdit.tsx index 853b53453..e8f1a681e 100644 --- a/packages/twenty-front/src/pages/settings/data-model/SettingsObjectFieldEdit.tsx +++ b/packages/twenty-front/src/pages/settings/data-model/SettingsObjectFieldEdit.tsx @@ -1,9 +1,9 @@ -import { useEffect } from 'react'; +import { useEffect, useMemo } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import { useFieldMetadataItem } from '@/object-metadata/hooks/useFieldMetadataItem'; +import { useGetRelationMetadata } from '@/object-metadata/hooks/useGetRelationMetadata'; import { useObjectMetadataItemForSettings } from '@/object-metadata/hooks/useObjectMetadataItemForSettings'; -import { useRelationMetadata } from '@/object-metadata/hooks/useRelationMetadata'; import { getFieldSlug } from '@/object-metadata/utils/getFieldSlug'; import { isLabelIdentifierField } from '@/object-metadata/utils/isLabelIdentifierField'; import { SaveAndCancelButtons } from '@/settings/components/SaveAndCancelButtons/SaveAndCancelButtons'; @@ -43,11 +43,21 @@ export const SettingsObjectFieldEdit = () => { metadataField.isActive && getFieldSlug(metadataField) === fieldSlug, ); + const getRelationMetadata = useGetRelationMetadata(); const { relationFieldMetadataItem, relationObjectMetadataItem, relationType, - } = useRelationMetadata({ fieldMetadataItem: activeMetadataField }); + } = + useMemo( + () => + activeMetadataField + ? getRelationMetadata({ + fieldMetadataItem: activeMetadataField, + }) + : null, + [activeMetadataField, getRelationMetadata], + ) ?? {}; const { formValues,