diff --git a/packages/twenty-front/src/modules/activities/files/components/AttachmentRow.tsx b/packages/twenty-front/src/modules/activities/files/components/AttachmentRow.tsx index afd543a8c..fd69c39b1 100644 --- a/packages/twenty-front/src/modules/activities/files/components/AttachmentRow.tsx +++ b/packages/twenty-front/src/modules/activities/files/components/AttachmentRow.tsx @@ -62,10 +62,9 @@ export const AttachmentRow = ({ attachment }: { attachment: Attachment }) => { [attachment?.id], ); - const { deleteOneRecord: deleteOneAttachment } = - useDeleteOneRecord({ - objectNameSingular: CoreObjectNameSingular.Attachment, - }); + const { deleteOneRecord: deleteOneAttachment } = useDeleteOneRecord({ + objectNameSingular: CoreObjectNameSingular.Attachment, + }); const handleDelete = () => { deleteOneAttachment(attachment.id); diff --git a/packages/twenty-front/src/modules/activities/files/hooks/useUploadAttachmentFile.tsx b/packages/twenty-front/src/modules/activities/files/hooks/useUploadAttachmentFile.tsx index 7fbdd8fb8..1a0cceee7 100644 --- a/packages/twenty-front/src/modules/activities/files/hooks/useUploadAttachmentFile.tsx +++ b/packages/twenty-front/src/modules/activities/files/hooks/useUploadAttachmentFile.tsx @@ -45,7 +45,7 @@ export const useUploadAttachmentFile = () => { fullPath: attachmentUrl, type: getFileType(file.name), [targetableObjectFieldIdName]: targetableObject.id, - }; + } as Partial; await createOneAttachment(attachmentToCreate); }; diff --git a/packages/twenty-front/src/modules/apollo/optimistic-effect/hooks/__tests__/useOptimisticEffect.test.tsx b/packages/twenty-front/src/modules/apollo/optimistic-effect/hooks/__tests__/useOptimisticEffect.test.tsx deleted file mode 100644 index ce7f07f6e..000000000 --- a/packages/twenty-front/src/modules/apollo/optimistic-effect/hooks/__tests__/useOptimisticEffect.test.tsx +++ /dev/null @@ -1,100 +0,0 @@ -import { useApolloClient } from '@apollo/client'; -import { MockedProvider } from '@apollo/client/testing'; -import { act, renderHook, waitFor } from '@testing-library/react'; -import { RecoilRoot, useRecoilValue } from 'recoil'; - -import { useOptimisticEffect } from '@/apollo/optimistic-effect/hooks/useOptimisticEffect'; -import { optimisticEffectState } from '@/apollo/optimistic-effect/states/optimisticEffectState'; -import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; - -const Wrapper = ({ children }: { children: React.ReactNode }) => ( - - {children} - -); - -describe('useOptimisticEffect', () => { - it('should work as expected', async () => { - const { result } = renderHook( - () => { - const optimisticEffect = useRecoilValue(optimisticEffectState); - const client = useApolloClient(); - const { findManyRecordsQuery } = useObjectMetadataItem({ - objectNameSingular: 'person', - }); - return { - ...useOptimisticEffect({ objectNameSingular: 'person' }), - optimisticEffect, - cache: client.cache, - findManyRecordsQuery, - }; - }, - { - wrapper: Wrapper, - }, - ); - - const { - registerOptimisticEffect, - unregisterOptimisticEffect, - triggerOptimisticEffects, - optimisticEffect, - findManyRecordsQuery, - } = result.current; - - expect(registerOptimisticEffect).toBeDefined(); - expect(typeof registerOptimisticEffect).toBe('function'); - expect(optimisticEffect).toEqual({}); - - const optimisticEffectDefinition = { - variables: {}, - definition: { - typename: 'Person', - resolver: () => ({ - people: [], - pageInfo: { - endCursor: '', - hasNextPage: false, - hasPreviousPage: false, - startCursor: '', - }, - edges: [], - }), - }, - }; - - act(() => { - registerOptimisticEffect(optimisticEffectDefinition); - }); - - await waitFor(() => { - expect(result.current.optimisticEffect).toHaveProperty('Person-{}'); - }); - - expect( - result.current.cache.readQuery({ query: findManyRecordsQuery }), - ).toBeNull(); - - act(() => { - triggerOptimisticEffects({ - typename: 'Person', - createdRecords: [{ id: 'id-0' }], - }); - }); - - await waitFor(() => { - expect( - result.current.cache.readQuery({ query: findManyRecordsQuery }), - ).toHaveProperty('people'); - }); - - act(() => { - unregisterOptimisticEffect(optimisticEffectDefinition); - }); - - await waitFor(() => { - expect(result.current.optimisticEffect).not.toHaveProperty('Person-{}'); - expect(result.current.optimisticEffect).toEqual({}); - }); - }); -}); diff --git a/packages/twenty-front/src/modules/apollo/optimistic-effect/hooks/useOptimisticEffect.ts b/packages/twenty-front/src/modules/apollo/optimistic-effect/hooks/useOptimisticEffect.ts deleted file mode 100644 index effcfe1ea..000000000 --- a/packages/twenty-front/src/modules/apollo/optimistic-effect/hooks/useOptimisticEffect.ts +++ /dev/null @@ -1,198 +0,0 @@ -import { useApolloClient } from '@apollo/client'; -import { isNonEmptyArray } from '@sniptt/guards'; -import { useRecoilCallback } from 'recoil'; - -import { computeOptimisticEffectKey } from '@/apollo/optimistic-effect/utils/computeOptimisticEffectKey'; -import { - EMPTY_QUERY, - useObjectMetadataItem, -} from '@/object-metadata/hooks/useObjectMetadataItem'; -import { ObjectMetadataItemIdentifier } from '@/object-metadata/types/ObjectMetadataItemIdentifier'; -import { ObjectRecordQueryVariables } from '@/object-record/types/ObjectRecordQueryVariables'; - -import { optimisticEffectState } from '../states/optimisticEffectState'; -import { - OptimisticEffect, - OptimisticEffectWriter, -} from '../types/internal/OptimisticEffect'; -import { OptimisticEffectDefinition } from '../types/OptimisticEffectDefinition'; - -export const useOptimisticEffect = ({ - objectNameSingular, -}: ObjectMetadataItemIdentifier) => { - const apolloClient = useApolloClient(); - - const { findManyRecordsQuery, objectMetadataItem } = useObjectMetadataItem({ - objectNameSingular, - }); - - const unregisterOptimisticEffect = useRecoilCallback( - ({ snapshot, set }) => - ({ - variables, - definition, - }: { - variables: ObjectRecordQueryVariables; - definition: OptimisticEffectDefinition; - }) => { - const optimisticEffects = snapshot - .getLoadable(optimisticEffectState) - .getValue(); - - const computedKey = computeOptimisticEffectKey({ - variables, - definition, - }); - - const { [computedKey]: _, ...rest } = optimisticEffects; - - set(optimisticEffectState, rest); - }, - ); - - const registerOptimisticEffect = useRecoilCallback( - ({ snapshot, set }) => - ({ - variables, - definition, - }: { - variables: ObjectRecordQueryVariables; - definition: OptimisticEffectDefinition; - }) => { - if (findManyRecordsQuery === EMPTY_QUERY) { - throw new Error( - `Trying to register an optimistic effect for unknown object ${objectNameSingular}`, - ); - } - - const optimisticEffects = snapshot - .getLoadable(optimisticEffectState) - .getValue(); - - const optimisticEffectWriter: OptimisticEffectWriter = ({ - cache, - createdRecords, - updatedRecords, - deletedRecordIds, - query, - variables, - objectMetadataItem, - }) => { - if (objectMetadataItem) { - const existingData = cache.readQuery({ - query: findManyRecordsQuery, - variables, - }); - - if ( - !existingData && - (isNonEmptyArray(updatedRecords) || - isNonEmptyArray(deletedRecordIds)) - ) { - return; - } - - cache.writeQuery({ - query: findManyRecordsQuery, - variables, - data: { - [objectMetadataItem.namePlural]: definition.resolver({ - currentCacheData: (existingData as any)?.[ - objectMetadataItem.namePlural - ], - updatedRecords, - createdRecords, - deletedRecordIds, - variables, - }), - }, - }); - - return; - } - - const existingData = cache.readQuery({ - query: query ?? findManyRecordsQuery, - variables, - }); - - if (!existingData) { - return; - } - }; - - const computedKey = computeOptimisticEffectKey({ - variables, - definition, - }); - - const optimisticEffect = { - variables, - typename: definition.typename, - query: definition.query, - writer: optimisticEffectWriter, - objectMetadataItem, - } satisfies OptimisticEffect; - - set(optimisticEffectState, { - ...optimisticEffects, - [computedKey]: optimisticEffect, - }); - }, - [findManyRecordsQuery, objectNameSingular, objectMetadataItem], - ); - - const triggerOptimisticEffects = useRecoilCallback( - ({ snapshot }) => - ({ - typename, - createdRecords = [], - updatedRecords = [], - deletedRecordIds, - }: { - typename: string; - createdRecords?: Record[]; - updatedRecords?: Record[]; - deletedRecordIds?: string[]; - }) => { - const optimisticEffects = snapshot - .getLoadable(optimisticEffectState) - .getValue(); - - for (const optimisticEffect of Object.values(optimisticEffects)) { - // We need to update the typename when createObject type differs from listObject types - // It is the case for apiKey, where the creation route returns an ApiKeyToken type - const formattedCreatedRecords = createdRecords.map((createdRecord) => - typename.endsWith('Edge') - ? createdRecord - : { ...createdRecord, __typename: typename }, - ); - - const formattedUpdatedRecords = updatedRecords.map((updatedRecord) => - typename.endsWith('Edge') - ? updatedRecord - : { ...updatedRecord, __typename: typename }, - ); - - if (optimisticEffect.typename === typename) { - optimisticEffect.writer({ - cache: apolloClient.cache, - query: optimisticEffect.query, - createdRecords: formattedCreatedRecords, - updatedRecords: formattedUpdatedRecords, - deletedRecordIds, - variables: optimisticEffect.variables, - objectMetadataItem: optimisticEffect.objectMetadataItem, - }); - } - } - }, - [apolloClient.cache], - ); - - return { - registerOptimisticEffect, - triggerOptimisticEffects, - unregisterOptimisticEffect, - }; -}; diff --git a/packages/twenty-front/src/modules/apollo/optimistic-effect/states/optimisticEffectState.ts b/packages/twenty-front/src/modules/apollo/optimistic-effect/states/optimisticEffectState.ts deleted file mode 100644 index 7d91f5e7d..000000000 --- a/packages/twenty-front/src/modules/apollo/optimistic-effect/states/optimisticEffectState.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { atom } from 'recoil'; - -import { OptimisticEffect } from '../types/internal/OptimisticEffect'; - -export const optimisticEffectState = atom>({ - key: 'optimisticEffectState', - default: {}, -}); diff --git a/packages/twenty-front/src/modules/apollo/optimistic-effect/types/OptimisticEffectDefinition.ts b/packages/twenty-front/src/modules/apollo/optimistic-effect/types/OptimisticEffectDefinition.ts deleted file mode 100644 index 93ca668c4..000000000 --- a/packages/twenty-front/src/modules/apollo/optimistic-effect/types/OptimisticEffectDefinition.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { DocumentNode } from 'graphql'; - -import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; - -import { OptimisticEffectResolver } from './OptimisticEffectResolver'; - -export type OptimisticEffectDefinition = { - query?: DocumentNode; - typename: string; - resolver: OptimisticEffectResolver; - objectMetadataItem?: ObjectMetadataItem; -}; diff --git a/packages/twenty-front/src/modules/apollo/optimistic-effect/types/OptimisticEffectResolver.ts b/packages/twenty-front/src/modules/apollo/optimistic-effect/types/OptimisticEffectResolver.ts deleted file mode 100644 index 91de6ca05..000000000 --- a/packages/twenty-front/src/modules/apollo/optimistic-effect/types/OptimisticEffectResolver.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { OperationVariables } from '@apollo/client'; - -export type OptimisticEffectResolver = ({ - currentCacheData, - createdRecords, - updatedRecords, - deletedRecordIds, - variables, -}: { - currentCacheData: any; //TODO: Change when decommissioning v1 - createdRecords?: Record[]; - updatedRecords?: Record[]; - deletedRecordIds?: string[]; - variables: OperationVariables; -}) => void; diff --git a/packages/twenty-front/src/modules/apollo/optimistic-effect/types/internal/OptimisticEffect.ts b/packages/twenty-front/src/modules/apollo/optimistic-effect/types/internal/OptimisticEffect.ts deleted file mode 100644 index d59125c32..000000000 --- a/packages/twenty-front/src/modules/apollo/optimistic-effect/types/internal/OptimisticEffect.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { ApolloCache, DocumentNode } from '@apollo/client'; - -import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; -import { ObjectRecordQueryVariables } from '@/object-record/types/ObjectRecordQueryVariables'; - -export type OptimisticEffectWriter = ({ - cache, - query, - createdRecords, - updatedRecords, - deletedRecordIds, - variables, - objectMetadataItem, -}: { - cache: ApolloCache; - query?: DocumentNode; - createdRecords?: Record[]; - updatedRecords?: Record[]; - deletedRecordIds?: string[]; - variables: ObjectRecordQueryVariables; - objectMetadataItem: ObjectMetadataItem; -}) => void; - -export type OptimisticEffect = { - query?: DocumentNode; - typename: string; - variables: ObjectRecordQueryVariables; - writer: OptimisticEffectWriter; - objectMetadataItem: ObjectMetadataItem; -}; diff --git a/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/computeOptimisticEffectKey.ts b/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/computeOptimisticEffectKey.ts deleted file mode 100644 index 680c7d474..000000000 --- a/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/computeOptimisticEffectKey.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { OptimisticEffectDefinition } from '@/apollo/optimistic-effect/types/OptimisticEffectDefinition'; -import { ObjectRecordQueryVariables } from '@/object-record/types/ObjectRecordQueryVariables'; - -export const computeOptimisticEffectKey = ({ - variables, - definition, -}: { - variables: ObjectRecordQueryVariables; - definition: OptimisticEffectDefinition; -}) => { - const computedKey = - (definition.objectMetadataItem?.namePlural ?? definition.typename) + - '-' + - JSON.stringify(variables); - - return computedKey; -}; diff --git a/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/isCachedObjectConnection.ts b/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/isCachedObjectConnection.ts new file mode 100644 index 000000000..e77b14119 --- /dev/null +++ b/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/isCachedObjectConnection.ts @@ -0,0 +1,21 @@ +import { StoreValue } from '@apollo/client'; +import { z } from 'zod'; + +import { CachedObjectRecordConnection } from '@/apollo/types/CachedObjectRecordConnection'; +import { capitalize } from '~/utils/string/capitalize'; + +export const isCachedObjectConnection = ( + objectNameSingular: string, + storeValue: StoreValue, +): storeValue is CachedObjectRecordConnection => { + const objectConnectionTypeName = `${capitalize( + objectNameSingular, + )}Connection`; + const cachedObjectConnectionSchema = z.object({ + __typename: z.literal(objectConnectionTypeName), + }); + const cachedConnectionValidation = + cachedObjectConnectionSchema.safeParse(storeValue); + + return cachedConnectionValidation.success; +}; diff --git a/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/sortCachedObjectEdges.ts b/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/sortCachedObjectEdges.ts new file mode 100644 index 000000000..10d4514af --- /dev/null +++ b/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/sortCachedObjectEdges.ts @@ -0,0 +1,66 @@ +import { Reference, StoreObject } from '@apollo/client'; +import { ReadFieldFunction } from '@apollo/client/cache/core/types/common'; + +import { CachedObjectRecordEdge } from '@/apollo/types/CachedObjectRecordEdge'; +import { OrderBy } from '@/object-metadata/types/OrderBy'; +import { OrderByField } from '@/object-metadata/types/OrderByField'; +import { isDefined } from '~/utils/isDefined'; +import { sortAsc, sortDesc, sortNullsFirst, sortNullsLast } from '~/utils/sort'; + +export const sortCachedObjectEdges = ({ + edges, + orderBy, + readCacheField, +}: { + edges: CachedObjectRecordEdge[]; + orderBy: OrderByField; + readCacheField: ReadFieldFunction; +}) => { + const [orderByFieldName, orderByFieldValue] = Object.entries(orderBy)[0]; + const [orderBySubFieldName, orderBySubFieldValue] = + typeof orderByFieldValue === 'string' + ? [] + : Object.entries(orderByFieldValue)[0]; + + const readFieldValueToSort = ( + edge: CachedObjectRecordEdge, + ): string | number | null => { + const recordFromCache = edge.node; + const fieldValue = + readCacheField( + orderByFieldName, + recordFromCache, + ) ?? null; + const isSubFieldFilter = isDefined(fieldValue) && !!orderBySubFieldName; + + if (!isSubFieldFilter) return fieldValue as string | number | null; + + const subFieldValue = + readCacheField( + orderBySubFieldName, + fieldValue as Reference | StoreObject, + ) ?? null; + + return subFieldValue; + }; + + const orderByValue = orderBySubFieldValue || (orderByFieldValue as OrderBy); + + const isAsc = orderByValue.startsWith('Asc'); + const isNullsFirst = orderByValue.endsWith('NullsFirst'); + + return [...edges].sort((edgeA, edgeB) => { + const fieldValueA = readFieldValueToSort(edgeA); + const fieldValueB = readFieldValueToSort(edgeB); + + if (fieldValueA === null || fieldValueB === null) { + return isNullsFirst + ? sortNullsFirst(fieldValueA, fieldValueB) + : sortNullsLast(fieldValueA, fieldValueB); + } + + return isAsc + ? sortAsc(fieldValueA, fieldValueB) + : sortDesc(fieldValueA, fieldValueB); + }); +}; 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 new file mode 100644 index 000000000..13072b212 --- /dev/null +++ b/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerCreateRecordsOptimisticEffect.ts @@ -0,0 +1,112 @@ +import { ApolloCache, StoreObject } from '@apollo/client'; + +import { isCachedObjectConnection } from '@/apollo/optimistic-effect/utils/isCachedObjectConnection'; +import { CachedObjectRecord } from '@/apollo/types/CachedObjectRecord'; +import { CachedObjectRecordEdge } from '@/apollo/types/CachedObjectRecordEdge'; +import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { capitalize } from '~/utils/string/capitalize'; + +/* + TODO: for now new records are added to all cached record lists, no matter what the variables (filters, orderBy, etc.) are. + We need to refactor how the record creation works in the RecordTable so the created record row is temporarily displayed with a local state, + then we'll be able to uncomment the code below so the cached lists are updated coherently with the variables. +*/ +export const triggerCreateRecordsOptimisticEffect = ({ + cache, + objectMetadataItem, + records, +}: { + cache: ApolloCache; + objectMetadataItem: ObjectMetadataItem; + records: CachedObjectRecord[]; +}) => { + const objectEdgeTypeName = `${capitalize( + objectMetadataItem.nameSingular, + )}Edge`; + + cache.modify({ + fields: { + [objectMetadataItem.namePlural]: ( + cachedConnection, + { + INVALIDATE: _INVALIDATE, + readField, + storeFieldName: _storeFieldName, + toReference, + }, + ) => { + if ( + !isCachedObjectConnection( + objectMetadataItem.nameSingular, + cachedConnection, + ) + ) + return cachedConnection; + + /* const { variables } = + parseApolloStoreFieldName( + storeFieldName, + ); */ + + const cachedEdges = readField( + 'edges', + cachedConnection, + ); + const nextCachedEdges = cachedEdges ? [...cachedEdges] : []; + + const hasAddedRecords = records + .map((record) => { + /* const matchesFilter = + !variables?.filter || + isRecordMatchingFilter({ + record, + filter: variables.filter, + objectMetadataItem, + }); */ + + if (/* matchesFilter && */ record.id) { + const nodeReference = toReference(record); + + if (nodeReference) { + nextCachedEdges.unshift({ + __typename: objectEdgeTypeName, + node: nodeReference, + cursor: '', + }); + + return true; + } + } + + return false; + }) + .some((hasAddedRecord) => hasAddedRecord); + + if (!hasAddedRecords) return cachedConnection; + + /* if (variables?.orderBy) { + nextCachedEdges = sortCachedObjectEdges({ + edges: nextCachedEdges, + orderBy: variables.orderBy, + readCacheField: readField, + }); + } + + if (isDefined(variables?.first)) { + if ( + cachedEdges?.length === variables.first && + nextCachedEdges.length < variables.first + ) { + return INVALIDATE; + } + + if (nextCachedEdges.length > variables.first) { + nextCachedEdges.splice(variables.first); + } + } */ + + return { ...cachedConnection, edges: nextCachedEdges }; + }, + }, + }); +}; 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 new file mode 100644 index 000000000..853b04310 --- /dev/null +++ b/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerDeleteRecordsOptimisticEffect.ts @@ -0,0 +1,65 @@ +import { ApolloCache, StoreObject } from '@apollo/client'; + +import { isCachedObjectConnection } from '@/apollo/optimistic-effect/utils/isCachedObjectConnection'; +import { CachedObjectRecord } from '@/apollo/types/CachedObjectRecord'; +import { CachedObjectRecordEdge } from '@/apollo/types/CachedObjectRecordEdge'; +import { CachedObjectRecordQueryVariables } from '@/apollo/types/CachedObjectRecordQueryVariables'; +import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { isDefined } from '~/utils/isDefined'; +import { parseApolloStoreFieldName } from '~/utils/parseApolloStoreFieldName'; + +export const triggerDeleteRecordsOptimisticEffect = ({ + cache, + objectMetadataItem, + records, +}: { + cache: ApolloCache; + objectMetadataItem: ObjectMetadataItem; + records: Pick[]; +}) => { + cache.modify({ + fields: { + [objectMetadataItem.namePlural]: ( + cachedConnection, + { INVALIDATE, readField, storeFieldName }, + ) => { + if ( + !isCachedObjectConnection( + objectMetadataItem.nameSingular, + cachedConnection, + ) + ) + return cachedConnection; + + const { variables } = + parseApolloStoreFieldName( + storeFieldName, + ); + + const recordIds = records.map(({ id }) => id); + + const cachedEdges = readField( + 'edges', + cachedConnection, + ); + const nextCachedEdges = + cachedEdges?.filter((cachedEdge) => { + const nodeId = readField('id', cachedEdge.node); + return nodeId && !recordIds.includes(nodeId); + }) || []; + + if ( + isDefined(variables?.first) && + cachedEdges?.length === variables.first && + nextCachedEdges.length < variables.first + ) { + return INVALIDATE; + } + + return { ...cachedConnection, edges: nextCachedEdges }; + }, + }, + }); + + records.forEach((record) => cache.evict({ id: cache.identify(record) })); +}; 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 new file mode 100644 index 000000000..c754f4619 --- /dev/null +++ b/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerUpdateRecordOptimisticEffect.ts @@ -0,0 +1,110 @@ +import { ApolloCache, StoreObject } from '@apollo/client'; + +import { isCachedObjectConnection } from '@/apollo/optimistic-effect/utils/isCachedObjectConnection'; +import { sortCachedObjectEdges } from '@/apollo/optimistic-effect/utils/sortCachedObjectEdges'; +import { CachedObjectRecord } from '@/apollo/types/CachedObjectRecord'; +import { CachedObjectRecordEdge } from '@/apollo/types/CachedObjectRecordEdge'; +import { CachedObjectRecordQueryVariables } from '@/apollo/types/CachedObjectRecordQueryVariables'; +import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { isRecordMatchingFilter } from '@/object-record/record-filter/utils/isRecordMatchingFilter'; +import { isDefined } from '~/utils/isDefined'; +import { parseApolloStoreFieldName } from '~/utils/parseApolloStoreFieldName'; +import { capitalize } from '~/utils/string/capitalize'; + +export const triggerUpdateRecordOptimisticEffect = ({ + cache, + objectMetadataItem, + record, +}: { + cache: ApolloCache; + objectMetadataItem: ObjectMetadataItem; + record: CachedObjectRecord; +}) => { + const objectEdgeTypeName = `${capitalize( + objectMetadataItem.nameSingular, + )}Edge`; + + cache.modify({ + fields: { + [objectMetadataItem.namePlural]: ( + cachedConnection, + { INVALIDATE, readField, storeFieldName, toReference }, + ) => { + if ( + !isCachedObjectConnection( + objectMetadataItem.nameSingular, + cachedConnection, + ) + ) + return cachedConnection; + + const { variables } = + parseApolloStoreFieldName( + storeFieldName, + ); + + const cachedEdges = readField( + 'edges', + cachedConnection, + ); + let nextCachedEdges = cachedEdges ? [...cachedEdges] : []; + + if (variables?.filter) { + const matchesFilter = isRecordMatchingFilter({ + record, + filter: variables.filter, + objectMetadataItem, + }); + const recordIndex = nextCachedEdges.findIndex( + (cachedEdge) => readField('id', cachedEdge.node) === record.id, + ); + + if (matchesFilter && recordIndex === -1) { + const nodeReference = toReference(record); + nodeReference && + nextCachedEdges.push({ + __typename: objectEdgeTypeName, + node: nodeReference, + cursor: '', + }); + } + + if (!matchesFilter && recordIndex > -1) { + nextCachedEdges.splice(recordIndex, 1); + } + } + + if (variables?.orderBy) { + nextCachedEdges = sortCachedObjectEdges({ + edges: nextCachedEdges, + orderBy: variables.orderBy, + readCacheField: readField, + }); + } + + if (isDefined(variables?.first)) { + // If previous edges length was exactly at the required limit, + // but after update next edges length is under the limit, + // we cannot for sure know if re-fetching the query + // would return more edges, so we cannot optimistically deduce + // the query's result. + // In this case, invalidate the cache entry so it can be re-fetched. + if ( + cachedEdges?.length === variables.first && + nextCachedEdges.length < variables.first + ) { + return INVALIDATE; + } + + // If next edges length exceeds the required limit, + // trim the next edges array to the correct length. + if (nextCachedEdges.length > variables.first) { + nextCachedEdges.splice(variables.first); + } + } + + return { ...cachedConnection, edges: nextCachedEdges }; + }, + }, + }); +}; diff --git a/packages/twenty-front/src/modules/apollo/types/CachedObjectRecord.ts b/packages/twenty-front/src/modules/apollo/types/CachedObjectRecord.ts new file mode 100644 index 000000000..a2037ddaa --- /dev/null +++ b/packages/twenty-front/src/modules/apollo/types/CachedObjectRecord.ts @@ -0,0 +1,3 @@ +import { ObjectRecord } from '@/object-record/types/ObjectRecord'; + +export type CachedObjectRecord = ObjectRecord & { __typename: string }; diff --git a/packages/twenty-front/src/modules/apollo/types/CachedObjectRecordConnection.ts b/packages/twenty-front/src/modules/apollo/types/CachedObjectRecordConnection.ts new file mode 100644 index 000000000..2be8f206b --- /dev/null +++ b/packages/twenty-front/src/modules/apollo/types/CachedObjectRecordConnection.ts @@ -0,0 +1,9 @@ +import { CachedObjectRecordEdge } from '@/apollo/types/CachedObjectRecordEdge'; +import { ObjectRecordConnection } from '@/object-record/types/ObjectRecordConnection'; + +export type CachedObjectRecordConnection = Omit< + ObjectRecordConnection, + 'edges' +> & { + edges: CachedObjectRecordEdge[]; +}; diff --git a/packages/twenty-front/src/modules/apollo/types/CachedObjectRecordEdge.ts b/packages/twenty-front/src/modules/apollo/types/CachedObjectRecordEdge.ts new file mode 100644 index 000000000..7ed37067b --- /dev/null +++ b/packages/twenty-front/src/modules/apollo/types/CachedObjectRecordEdge.ts @@ -0,0 +1,7 @@ +import { Reference } from '@apollo/client'; + +import { ObjectRecordEdge } from '@/object-record/types/ObjectRecordEdge'; + +export type CachedObjectRecordEdge = Omit & { + node: Reference; +}; diff --git a/packages/twenty-front/src/modules/apollo/types/CachedObjectRecordQueryVariables.ts b/packages/twenty-front/src/modules/apollo/types/CachedObjectRecordQueryVariables.ts new file mode 100644 index 000000000..c7430ce70 --- /dev/null +++ b/packages/twenty-front/src/modules/apollo/types/CachedObjectRecordQueryVariables.ts @@ -0,0 +1,6 @@ +import { ObjectRecordQueryVariables } from '@/object-record/types/ObjectRecordQueryVariables'; + +export type CachedObjectRecordQueryVariables = Omit< + ObjectRecordQueryVariables, + 'limit' +> & { first?: ObjectRecordQueryVariables['limit'] }; diff --git a/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useRecordOptimisticEffect.test.tsx b/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useRecordOptimisticEffect.test.tsx deleted file mode 100644 index 24a29c8dd..000000000 --- a/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useRecordOptimisticEffect.test.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { ReactNode } from 'react'; -import { MockedProvider } from '@apollo/client/testing'; -import { renderHook } from '@testing-library/react'; -import { RecoilRoot } from 'recoil'; - -import { useRecordOptimisticEffect } from '@/object-metadata/hooks/useRecordOptimisticEffect'; -import { getObjectMetadataItemsMock } from '@/object-metadata/utils/getObjectMetadataItemsMock'; - -const mockRegisterOptimisticEffect = jest.fn(); - -jest.mock('@/apollo/optimistic-effect/hooks/useOptimisticEffect', () => ({ - useOptimisticEffect: jest.fn(() => ({ - registerOptimisticEffect: mockRegisterOptimisticEffect, - unregisterOptimisticEffect: jest.fn(), - })), -})); - -const Wrapper = ({ children }: { children: ReactNode }) => ( - - {children} - -); - -const mockObjectMetadataItems = getObjectMetadataItemsMock(); - -describe('useRecordOptimisticEffect', () => { - it('should work as expected', async () => { - const objectMetadataItem = mockObjectMetadataItems.find( - (item) => item.namePlural === 'people', - )!; - - renderHook(() => useRecordOptimisticEffect({ objectMetadataItem }), { - wrapper: Wrapper, - }); - - expect(mockRegisterOptimisticEffect).toHaveBeenCalled(); - }); -}); diff --git a/packages/twenty-front/src/modules/object-metadata/hooks/useRecordOptimisticEffect.ts b/packages/twenty-front/src/modules/object-metadata/hooks/useRecordOptimisticEffect.ts deleted file mode 100644 index bf00e3c08..000000000 --- a/packages/twenty-front/src/modules/object-metadata/hooks/useRecordOptimisticEffect.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { useEffect } from 'react'; - -import { useOptimisticEffect } from '@/apollo/optimistic-effect/hooks/useOptimisticEffect'; -import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; -import { OrderByField } from '@/object-metadata/types/OrderByField'; -import { getRecordOptimisticEffectDefinition } from '@/object-record/graphql/optimistic-effect-definition/getRecordOptimisticEffectDefinition'; -import { ObjectRecordQueryFilter } from '@/object-record/record-filter/types/ObjectRecordQueryFilter'; - -export const useRecordOptimisticEffect = ({ - objectMetadataItem, - filter, - orderBy, - limit, -}: { - objectMetadataItem: ObjectMetadataItem; - filter?: ObjectRecordQueryFilter; - orderBy?: OrderByField; - limit?: number; -}) => { - const { registerOptimisticEffect, unregisterOptimisticEffect } = - useOptimisticEffect({ - objectNameSingular: objectMetadataItem.nameSingular, - }); - - useEffect(() => { - registerOptimisticEffect({ - definition: getRecordOptimisticEffectDefinition({ - objectMetadataItem, - }), - variables: { - filter, - orderBy, - limit, - }, - }); - - return () => { - unregisterOptimisticEffect({ - definition: getRecordOptimisticEffectDefinition({ - objectMetadataItem, - }), - variables: { - filter, - orderBy, - limit, - }, - }); - }; - }, [ - registerOptimisticEffect, - filter, - orderBy, - limit, - objectMetadataItem, - unregisterOptimisticEffect, - ]); -}; diff --git a/packages/twenty-front/src/modules/object-record/graphql/optimistic-effect-definition/getRecordOptimisticEffectDefinition.ts b/packages/twenty-front/src/modules/object-record/graphql/optimistic-effect-definition/getRecordOptimisticEffectDefinition.ts deleted file mode 100644 index 3111bcef8..000000000 --- a/packages/twenty-front/src/modules/object-record/graphql/optimistic-effect-definition/getRecordOptimisticEffectDefinition.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { isNonEmptyArray } from '@sniptt/guards'; -import { produce } from 'immer'; - -import { OptimisticEffectDefinition } from '@/apollo/optimistic-effect/types/OptimisticEffectDefinition'; -import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; -import { isRecordMatchingFilter } from '@/object-record/record-filter/utils/isRecordMatchingFilter'; -import { ObjectRecordConnection } from '@/object-record/types/ObjectRecordConnection'; -import { isDefined } from '~/utils/isDefined'; -import { capitalize } from '~/utils/string/capitalize'; - -export const getRecordOptimisticEffectDefinition = ({ - objectMetadataItem, -}: { - objectMetadataItem: ObjectMetadataItem; -}): OptimisticEffectDefinition => ({ - typename: `${capitalize(objectMetadataItem.nameSingular)}Edge`, - resolver: ({ - currentCacheData: currentData, - createdRecords, - updatedRecords, - deletedRecordIds, - variables, - }) => { - const newRecordPaginatedCacheField = produce>( - currentData as ObjectRecordConnection, - (draft) => { - const existingDataIsEmpty = !draft || !draft.edges || !draft.edges[0]; - - if (isNonEmptyArray(createdRecords)) { - if (existingDataIsEmpty) { - return { - __typename: `${capitalize( - objectMetadataItem.nameSingular, - )}Connection`, - edges: createdRecords.map((createdRecord) => ({ - __typename: `${capitalize( - objectMetadataItem.nameSingular, - )}Edge`, - node: createdRecord, - cursor: '', - })), - pageInfo: { - endCursor: '', - hasNextPage: false, - hasPreviousPage: false, - startCursor: '', - }, - }; - } else { - for (const createdRecord of createdRecords) { - const existingRecord = draft.edges.find( - (edge) => edge.node.id === createdRecord.id, - ); - - if (existingRecord) { - existingRecord.node = createdRecord; - continue; - } - - draft.edges.unshift({ - node: createdRecord, - cursor: '', - __typename: `${capitalize( - objectMetadataItem.nameSingular, - )}Edge`, - }); - } - } - } - - if (isNonEmptyArray(deletedRecordIds)) { - draft.edges = draft.edges.filter( - (edge) => !deletedRecordIds.includes(edge.node.id), - ); - } - - if (isNonEmptyArray(updatedRecords)) { - for (const updatedRecord of updatedRecords) { - const updatedRecordIsOutOfQueryFilter = - isDefined(variables.filter) && - !isRecordMatchingFilter({ - record: updatedRecord, - filter: variables.filter, - objectMetadataItem, - }); - - if (updatedRecordIsOutOfQueryFilter) { - draft.edges = draft.edges.filter( - (edge) => edge.node.id !== updatedRecord.id, - ); - } else { - const foundUpdatedRecordInCacheQuery = draft.edges.find( - (edge) => edge.node.id === updatedRecord.id, - ); - - if (foundUpdatedRecordInCacheQuery) { - foundUpdatedRecordInCacheQuery.node = updatedRecord; - } else { - // TODO: add order by - draft.edges.push({ - node: updatedRecord, - cursor: '', - __typename: `${capitalize( - objectMetadataItem.nameSingular, - )}Edge`, - }); - } - } - } - } - }, - ); - - return newRecordPaginatedCacheField; - }, - objectMetadataItem, -}); diff --git a/packages/twenty-front/src/modules/object-record/hooks/useCreateManyRecords.ts b/packages/twenty-front/src/modules/object-record/hooks/useCreateManyRecords.ts index f8e7a77d5..504f44008 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/useCreateManyRecords.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/useCreateManyRecords.ts @@ -1,77 +1,67 @@ import { useApolloClient } from '@apollo/client'; -import { v4 } from 'uuid'; -import { useOptimisticEffect } from '@/apollo/optimistic-effect/hooks/useOptimisticEffect'; +import { triggerCreateRecordsOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerCreateRecordsOptimisticEffect'; import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; import { ObjectMetadataItemIdentifier } from '@/object-metadata/types/ObjectMetadataItemIdentifier'; -import { useGenerateEmptyRecord } from '@/object-record/hooks/useGenerateEmptyRecord'; +import { useGenerateCachedObjectRecord } from '@/object-record/hooks/useGenerateCachedObjectRecord'; +import { getCreateManyRecordsMutationResponseField } from '@/object-record/hooks/useGenerateCreateManyRecordMutation'; import { ObjectRecord } from '@/object-record/types/ObjectRecord'; -import { capitalize } from '~/utils/string/capitalize'; +import { sanitizeRecordInput } from '@/object-record/utils/sanitizeRecordInput'; -export const useCreateManyRecords = ({ +export const useCreateManyRecords = < + CreatedObjectRecord extends ObjectRecord = ObjectRecord, +>({ objectNameSingular, }: ObjectMetadataItemIdentifier) => { - const { triggerOptimisticEffects } = useOptimisticEffect({ - objectNameSingular, - }); - const { objectMetadataItem, createManyRecordsMutation } = useObjectMetadataItem({ objectNameSingular, }); - const { generateEmptyRecord } = useGenerateEmptyRecord({ + const { generateCachedObjectRecord } = useGenerateCachedObjectRecord({ objectMetadataItem, }); const apolloClient = useApolloClient(); - const createManyRecords = async (data: Partial[]) => { - const withIds = data.map((record) => ({ - ...record, - id: (record.id as string) ?? v4(), - })); + const createManyRecords = async (data: Partial[]) => { + const optimisticallyCreatedRecords = data.map((record) => + generateCachedObjectRecord(record), + ); - withIds.forEach((record) => { - const emptyRecord: T | undefined = generateEmptyRecord({ - id: record.id, - } as T); + const sanitizedCreateManyRecordsInput = data.map((input, index) => + sanitizeRecordInput({ + objectMetadataItem, + recordInput: { ...input, id: optimisticallyCreatedRecords[index].id }, + }), + ); - if (emptyRecord) { - triggerOptimisticEffects({ - typename: `${capitalize(objectMetadataItem.nameSingular)}Edge`, - createdRecords: [emptyRecord], - }); - } - }); + const mutationResponseField = getCreateManyRecordsMutationResponseField( + objectMetadataItem.namePlural, + ); const createdObjects = await apolloClient.mutate({ mutation: createManyRecordsMutation, variables: { - data: withIds, + data: sanitizedCreateManyRecordsInput, }, optimisticResponse: { - [`create${capitalize(objectMetadataItem.namePlural)}`]: withIds.map( - (record) => generateEmptyRecord({ id: record.id }), - ), + [mutationResponseField]: optimisticallyCreatedRecords, + }, + update: (cache, { data }) => { + const records = data?.[mutationResponseField]; + + if (!records?.length) return; + + triggerCreateRecordsOptimisticEffect({ + cache, + objectMetadataItem, + records, + }); }, }); - if (!createdObjects.data) { - return null; - } - - const createdRecords = - createdObjects.data[ - `create${capitalize(objectMetadataItem.namePlural)}` - ] ?? []; - - triggerOptimisticEffects({ - typename: `${capitalize(objectMetadataItem.nameSingular)}Edge`, - createdRecords, - }); - - return createdRecords as T[]; + return createdObjects.data?.[mutationResponseField] ?? []; }; return { createManyRecords }; diff --git a/packages/twenty-front/src/modules/object-record/hooks/useCreateOneRecord.ts b/packages/twenty-front/src/modules/object-record/hooks/useCreateOneRecord.ts index a2cc0d9d7..cb7898c2e 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/useCreateOneRecord.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/useCreateOneRecord.ts @@ -1,73 +1,66 @@ import { useApolloClient } from '@apollo/client'; -import { v4 } from 'uuid'; -import { useOptimisticEffect } from '@/apollo/optimistic-effect/hooks/useOptimisticEffect'; +import { triggerCreateRecordsOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerCreateRecordsOptimisticEffect'; import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; -import { useGenerateEmptyRecord } from '@/object-record/hooks/useGenerateEmptyRecord'; +import { useGenerateCachedObjectRecord } from '@/object-record/hooks/useGenerateCachedObjectRecord'; +import { getCreateOneRecordMutationResponseField } from '@/object-record/hooks/useGenerateCreateOneRecordMutation'; +import { ObjectRecord } from '@/object-record/types/ObjectRecord'; import { sanitizeRecordInput } from '@/object-record/utils/sanitizeRecordInput'; -import { capitalize } from '~/utils/string/capitalize'; type useCreateOneRecordProps = { objectNameSingular: string; }; -export const useCreateOneRecord = ({ +export const useCreateOneRecord = < + CreatedObjectRecord extends ObjectRecord = ObjectRecord, +>({ objectNameSingular, }: useCreateOneRecordProps) => { - const { triggerOptimisticEffects } = useOptimisticEffect({ - objectNameSingular, - }); - const { objectMetadataItem, createOneRecordMutation } = useObjectMetadataItem( - { - objectNameSingular, - }, + { objectNameSingular }, ); // TODO: type this with a minimal type at least with Record const apolloClient = useApolloClient(); - const { generateEmptyRecord } = useGenerateEmptyRecord({ + const { generateCachedObjectRecord } = useGenerateCachedObjectRecord({ objectMetadataItem, }); - const createOneRecord = async (input: Record) => { - const recordId = v4(); + const createOneRecord = async (input: Partial) => { + const optimisticallyCreatedRecord = + generateCachedObjectRecord(input); - const generatedEmptyRecord = generateEmptyRecord({ - id: recordId, - createdAt: new Date().toISOString(), - ...input, - }); - - const sanitizedUpdateOneRecordInput = sanitizeRecordInput({ + const sanitizedCreateOneRecordInput = sanitizeRecordInput({ objectMetadataItem, - recordInput: input, + recordInput: { ...input, id: optimisticallyCreatedRecord.id }, }); - triggerOptimisticEffects({ - typename: `${capitalize(objectMetadataItem.nameSingular)}Edge`, - createdRecords: [generatedEmptyRecord], - }); + const mutationResponseField = + getCreateOneRecordMutationResponseField(objectNameSingular); const createdObject = await apolloClient.mutate({ mutation: createOneRecordMutation, variables: { - input: { id: recordId, ...sanitizedUpdateOneRecordInput }, + input: sanitizedCreateOneRecordInput, }, optimisticResponse: { - [`create${capitalize(objectMetadataItem.nameSingular)}`]: - generatedEmptyRecord, + [mutationResponseField]: optimisticallyCreatedRecord, + }, + update: (cache, { data }) => { + const record = data?.[mutationResponseField]; + + if (!record) return; + + triggerCreateRecordsOptimisticEffect({ + cache, + objectMetadataItem, + records: [record], + }); }, }); - if (!createdObject.data) { - return null; - } - - return createdObject.data[ - `create${capitalize(objectMetadataItem.nameSingular)}` - ] as T; + return createdObject.data?.[mutationResponseField] ?? null; }; return { 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 16bbcc72b..f62fa9570 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,8 @@ import { useApolloClient } from '@apollo/client'; -import { getOperationName } from '@apollo/client/utilities'; -import { useOptimisticEffect } from '@/apollo/optimistic-effect/hooks/useOptimisticEffect'; -import { useOptimisticEvict } from '@/apollo/optimistic-effect/hooks/useOptimisticEvict'; +import { triggerDeleteRecordsOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerDeleteRecordsOptimisticEffect'; import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; +import { getDeleteManyRecordsMutationResponseField } from '@/object-record/hooks/useGenerateDeleteManyRecordMutation'; import { ObjectRecordQueryFilter } from '@/object-record/record-filter/types/ObjectRecordQueryFilter'; import { capitalize } from '~/utils/string/capitalize'; @@ -12,39 +11,19 @@ type useDeleteOneRecordProps = { refetchFindManyQuery?: boolean; }; -export const useDeleteManyRecords = ({ +export const useDeleteManyRecords = ({ objectNameSingular, - refetchFindManyQuery = false, }: useDeleteOneRecordProps) => { - const { performOptimisticEvict } = useOptimisticEvict(); - const { triggerOptimisticEffects } = useOptimisticEffect({ - objectNameSingular, - }); - - const { - objectMetadataItem, - deleteManyRecordsMutation, - findManyRecordsQuery, - } = useObjectMetadataItem({ - objectNameSingular, - }); + const { objectMetadataItem, deleteManyRecordsMutation } = + useObjectMetadataItem({ objectNameSingular }); const apolloClient = useApolloClient(); + const mutationResponseField = getDeleteManyRecordsMutationResponseField( + objectMetadataItem.namePlural, + ); + const deleteManyRecords = async (idsToDelete: string[]) => { - triggerOptimisticEffects({ - typename: `${capitalize(objectMetadataItem.nameSingular)}Edge`, - deletedRecordIds: idsToDelete, - }); - - idsToDelete.forEach((idToDelete) => { - performOptimisticEvict( - capitalize(objectMetadataItem.nameSingular), - 'id', - idToDelete, - ); - }); - const deleteRecordFilter: ObjectRecordQueryFilter = { id: { in: idsToDelete, @@ -56,14 +35,26 @@ export const useDeleteManyRecords = ({ filter: deleteRecordFilter, // atMost: idsToDelete.length, }, - refetchQueries: refetchFindManyQuery - ? [getOperationName(findManyRecordsQuery) ?? ''] - : [], + optimisticResponse: { + [mutationResponseField]: idsToDelete.map((idToDelete) => ({ + __typename: capitalize(objectNameSingular), + id: idToDelete, + })), + }, + update: (cache, { data }) => { + const records = data?.[mutationResponseField]; + + if (!records?.length) return; + + triggerDeleteRecordsOptimisticEffect({ + cache, + objectMetadataItem, + records, + }); + }, }); - return deletedRecords.data[ - `delete${capitalize(objectMetadataItem.namePlural)}` - ] as T; + return deletedRecords.data?.[mutationResponseField] ?? null; }; return { deleteManyRecords }; 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 01eb92017..5987d1472 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/useDeleteOneRecord.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/useDeleteOneRecord.ts @@ -1,10 +1,9 @@ import { useCallback } from 'react'; import { useApolloClient } from '@apollo/client'; -import { getOperationName } from '@apollo/client/utilities'; -import { useOptimisticEffect } from '@/apollo/optimistic-effect/hooks/useOptimisticEffect'; -import { useOptimisticEvict } from '@/apollo/optimistic-effect/hooks/useOptimisticEvict'; +import { triggerDeleteRecordsOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerDeleteRecordsOptimisticEffect'; import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; +import { getDeleteOneRecordMutationResponseField } from '@/object-record/utils/generateDeleteOneRecordMutation'; import { capitalize } from '~/utils/string/capitalize'; type useDeleteOneRecordProps = { @@ -12,57 +11,50 @@ type useDeleteOneRecordProps = { refetchFindManyQuery?: boolean; }; -export const useDeleteOneRecord = ({ +export const useDeleteOneRecord = ({ objectNameSingular, - refetchFindManyQuery = false, }: useDeleteOneRecordProps) => { - const { performOptimisticEvict } = useOptimisticEvict(); - const { triggerOptimisticEffects } = useOptimisticEffect({ - objectNameSingular, - }); - - const { objectMetadataItem, deleteOneRecordMutation, findManyRecordsQuery } = - useObjectMetadataItem({ - objectNameSingular, - }); + const { objectMetadataItem, deleteOneRecordMutation } = useObjectMetadataItem( + { objectNameSingular }, + ); const apolloClient = useApolloClient(); + const mutationResponseField = + getDeleteOneRecordMutationResponseField(objectNameSingular); + const deleteOneRecord = useCallback( async (idToDelete: string) => { - triggerOptimisticEffects({ - typename: `${capitalize(objectMetadataItem.nameSingular)}Edge`, - deletedRecordIds: [idToDelete], - }); - - performOptimisticEvict( - capitalize(objectMetadataItem.nameSingular), - 'id', - idToDelete, - ); - const deletedRecord = await apolloClient.mutate({ mutation: deleteOneRecordMutation, - variables: { - idToDelete, + variables: { idToDelete }, + optimisticResponse: { + [mutationResponseField]: { + __typename: capitalize(objectNameSingular), + id: idToDelete, + }, + }, + update: (cache, { data }) => { + const record = data?.[mutationResponseField]; + + if (!record) return; + + triggerDeleteRecordsOptimisticEffect({ + cache, + objectMetadataItem, + records: [record], + }); }, - refetchQueries: refetchFindManyQuery - ? [getOperationName(findManyRecordsQuery) ?? ''] - : [], }); - return deletedRecord.data[ - `delete${capitalize(objectMetadataItem.nameSingular)}` - ] as T; + return deletedRecord.data?.[mutationResponseField] ?? null; }, [ - triggerOptimisticEffects, - objectMetadataItem.nameSingular, - performOptimisticEvict, apolloClient, deleteOneRecordMutation, - refetchFindManyQuery, - findManyRecordsQuery, + mutationResponseField, + objectMetadataItem, + objectNameSingular, ], ); diff --git a/packages/twenty-front/src/modules/object-record/hooks/useFindManyRecords.ts b/packages/twenty-front/src/modules/object-record/hooks/useFindManyRecords.ts index a3773eda5..323028f11 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/useFindManyRecords.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/useFindManyRecords.ts @@ -6,14 +6,12 @@ import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil'; import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; -import { useRecordOptimisticEffect } from '@/object-metadata/hooks/useRecordOptimisticEffect'; import { ObjectMetadataItemIdentifier } from '@/object-metadata/types/ObjectMetadataItemIdentifier'; -import { OrderByField } from '@/object-metadata/types/OrderByField'; import { useMapConnectionToRecords } from '@/object-record/hooks/useMapConnectionToRecords'; -import { ObjectRecordQueryFilter } from '@/object-record/record-filter/types/ObjectRecordQueryFilter'; import { ObjectRecord } from '@/object-record/types/ObjectRecord'; import { ObjectRecordConnection } from '@/object-record/types/ObjectRecordConnection'; import { ObjectRecordEdge } from '@/object-record/types/ObjectRecordEdge'; +import { ObjectRecordQueryVariables } from '@/object-record/types/ObjectRecordQueryVariables'; import { filterUniqueRecordEdgesByCursor } from '@/object-record/utils/filterUniqueRecordEdgesByCursor'; import { DEFAULT_SEARCH_REQUEST_LIMIT } from '@/search/hooks/useFilteredSearchEntityQuery'; import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; @@ -34,14 +32,12 @@ export const useFindManyRecords = ({ onCompleted, skip, useRecordsWithoutConnection = false, -}: ObjectMetadataItemIdentifier & { - filter?: ObjectRecordQueryFilter; - orderBy?: OrderByField; - limit?: number; - onCompleted?: (data: ObjectRecordConnection) => void; - skip?: boolean; - useRecordsWithoutConnection?: boolean; -}) => { +}: ObjectMetadataItemIdentifier & + ObjectRecordQueryVariables & { + onCompleted?: (data: ObjectRecordConnection) => void; + skip?: boolean; + useRecordsWithoutConnection?: boolean; + }) => { const findManyQueryStateIdentifier = objectNameSingular + JSON.stringify(filter) + @@ -64,13 +60,6 @@ export const useFindManyRecords = ({ objectNameSingular, }); - useRecordOptimisticEffect({ - objectMetadataItem, - filter, - orderBy, - limit, - }); - const { enqueueSnackBar } = useSnackBar(); const currentWorkspace = useRecoilValue(currentWorkspaceState); diff --git a/packages/twenty-front/src/modules/object-record/hooks/useGenerateCachedObjectRecord.ts b/packages/twenty-front/src/modules/object-record/hooks/useGenerateCachedObjectRecord.ts new file mode 100644 index 000000000..635056ed7 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/hooks/useGenerateCachedObjectRecord.ts @@ -0,0 +1,41 @@ +import { v4 } from 'uuid'; +import { z } from 'zod'; + +import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { ObjectRecord } from '@/object-record/types/ObjectRecord'; +import { generateEmptyFieldValue } from '@/object-record/utils/generateEmptyFieldValue'; +import { capitalize } from '~/utils/string/capitalize'; + +export const useGenerateCachedObjectRecord = ({ + objectMetadataItem, +}: { + objectMetadataItem: ObjectMetadataItem; +}) => { + const generateCachedObjectRecord = < + GeneratedObjectRecord extends ObjectRecord, + >( + input: Record, + ) => { + const recordSchema = z.object( + Object.fromEntries( + objectMetadataItem.fields.map((fieldMetadataItem) => [ + fieldMetadataItem.name, + z.unknown().default(generateEmptyFieldValue(fieldMetadataItem)), + ]), + ), + ); + + return { + __typename: capitalize(objectMetadataItem.nameSingular), + ...recordSchema.parse({ + id: v4(), + createdAt: new Date().toISOString(), + ...input, + }), + } as GeneratedObjectRecord & { __typename: string }; + }; + + return { + generateCachedObjectRecord, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/hooks/useGenerateCreateManyRecordMutation.ts b/packages/twenty-front/src/modules/object-record/hooks/useGenerateCreateManyRecordMutation.ts index 63fa8a790..4a11ac787 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/useGenerateCreateManyRecordMutation.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/useGenerateCreateManyRecordMutation.ts @@ -5,6 +5,10 @@ import { EMPTY_MUTATION } from '@/object-metadata/hooks/useObjectMetadataItem'; import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; import { capitalize } from '~/utils/string/capitalize'; +export const getCreateManyRecordsMutationResponseField = ( + objectNamePlural: string, +) => `create${capitalize(objectNamePlural)}`; + export const useGenerateCreateManyRecordMutation = ({ objectMetadataItem, }: { @@ -16,11 +20,15 @@ export const useGenerateCreateManyRecordMutation = ({ return EMPTY_MUTATION; } + const mutationResponseField = getCreateManyRecordsMutationResponseField( + objectMetadataItem.namePlural, + ); + return gql` mutation Create${capitalize( objectMetadataItem.namePlural, )}($data: [${capitalize(objectMetadataItem.nameSingular)}CreateInput!]!) { - create${capitalize(objectMetadataItem.namePlural)}(data: $data) { + ${mutationResponseField}(data: $data) { id ${objectMetadataItem.fields .map((field) => mapFieldMetadataToGraphQLQuery(field)) diff --git a/packages/twenty-front/src/modules/object-record/hooks/useGenerateCreateOneRecordMutation.ts b/packages/twenty-front/src/modules/object-record/hooks/useGenerateCreateOneRecordMutation.ts index 507e17d46..6f3a1a011 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/useGenerateCreateOneRecordMutation.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/useGenerateCreateOneRecordMutation.ts @@ -5,6 +5,10 @@ import { EMPTY_MUTATION } from '@/object-metadata/hooks/useObjectMetadataItem'; import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; import { capitalize } from '~/utils/string/capitalize'; +export const getCreateOneRecordMutationResponseField = ( + objectNameSingular: string, +) => `create${capitalize(objectNameSingular)}`; + export const useGenerateCreateOneRecordMutation = ({ objectMetadataItem, }: { @@ -18,9 +22,13 @@ export const useGenerateCreateOneRecordMutation = ({ const capitalizedObjectName = capitalize(objectMetadataItem.nameSingular); + const mutationResponseField = getCreateOneRecordMutationResponseField( + objectMetadataItem.nameSingular, + ); + return gql` mutation CreateOne${capitalizedObjectName}($input: ${capitalizedObjectName}CreateInput!) { - create${capitalizedObjectName}(data: $input) { + ${mutationResponseField}(data: $input) { id ${objectMetadataItem.fields .map((field) => mapFieldMetadataToGraphQLQuery(field)) diff --git a/packages/twenty-front/src/modules/object-record/hooks/useGenerateDeleteManyRecordMutation.ts b/packages/twenty-front/src/modules/object-record/hooks/useGenerateDeleteManyRecordMutation.ts index 9efc5464b..af80fd645 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/useGenerateDeleteManyRecordMutation.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/useGenerateDeleteManyRecordMutation.ts @@ -4,6 +4,10 @@ import { EMPTY_MUTATION } from '@/object-metadata/hooks/useObjectMetadataItem'; import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; import { capitalize } from '~/utils/string/capitalize'; +export const getDeleteManyRecordsMutationResponseField = ( + objectNamePlural: string, +) => `delete${capitalize(objectNamePlural)}`; + export const useGenerateDeleteManyRecordMutation = ({ objectMetadataItem, }: { @@ -15,11 +19,15 @@ export const useGenerateDeleteManyRecordMutation = ({ const capitalizedObjectName = capitalize(objectMetadataItem.namePlural); + const mutationResponseField = getDeleteManyRecordsMutationResponseField( + objectMetadataItem.namePlural, + ); + return gql` mutation DeleteMany${capitalizedObjectName}($filter: ${capitalize( objectMetadataItem.nameSingular, )}FilterInput!) { - delete${capitalizedObjectName}(filter: $filter) { + ${mutationResponseField}(filter: $filter) { id } } diff --git a/packages/twenty-front/src/modules/object-record/hooks/useGenerateEmptyRecord.ts b/packages/twenty-front/src/modules/object-record/hooks/useGenerateEmptyRecord.ts deleted file mode 100644 index df33935b4..000000000 --- a/packages/twenty-front/src/modules/object-record/hooks/useGenerateEmptyRecord.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; -import { ObjectRecord } from '@/object-record/types/ObjectRecord'; -import { generateEmptyFieldValue } from '@/object-record/utils/generateEmptyFieldValue'; - -export const useGenerateEmptyRecord = ({ - objectMetadataItem, -}: { - objectMetadataItem: ObjectMetadataItem; -}) => { - // Todo fix typing once we generate the return base on Metadata - const generateEmptyRecord = (input: T) => { - // Todo replace this by runtime typing - const validatedInput = input as T; - - const emptyRecord = {} as any; - - for (const fieldMetadataItem of objectMetadataItem.fields) { - emptyRecord[fieldMetadataItem.name] = - validatedInput[fieldMetadataItem.name] ?? - generateEmptyFieldValue(fieldMetadataItem); - } - - return emptyRecord as T; - }; - - return { - generateEmptyRecord, - }; -}; diff --git a/packages/twenty-front/src/modules/object-record/hooks/useGenerateUpdateOneRecordMutation.ts b/packages/twenty-front/src/modules/object-record/hooks/useGenerateUpdateOneRecordMutation.ts index 19343a593..ecb38188b 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/useGenerateUpdateOneRecordMutation.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/useGenerateUpdateOneRecordMutation.ts @@ -5,13 +5,9 @@ import { EMPTY_MUTATION } from '@/object-metadata/hooks/useObjectMetadataItem'; import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; import { capitalize } from '~/utils/string/capitalize'; -export const getUpdateOneRecordMutationGraphQLField = ({ - objectNameSingular, -}: { - objectNameSingular: string; -}) => { - return `update${capitalize(objectNameSingular)}`; -}; +export const getUpdateOneRecordMutationResponseField = ( + objectNameSingular: string, +) => `update${capitalize(objectNameSingular)}`; export const useGenerateUpdateOneRecordMutation = ({ objectMetadataItem, @@ -26,14 +22,13 @@ export const useGenerateUpdateOneRecordMutation = ({ const capitalizedObjectName = capitalize(objectMetadataItem.nameSingular); - const graphQLFieldForUpdateOneRecordMutation = - getUpdateOneRecordMutationGraphQLField({ - objectNameSingular: objectMetadataItem.nameSingular, - }); + const mutationResponseField = getUpdateOneRecordMutationResponseField( + objectMetadataItem.nameSingular, + ); return gql` mutation UpdateOne${capitalizedObjectName}($idToUpdate: ID!, $input: ${capitalizedObjectName}UpdateInput!) { - ${graphQLFieldForUpdateOneRecordMutation}(id: $idToUpdate, data: $input) { + ${mutationResponseField}(id: $idToUpdate, data: $input) { id ${objectMetadataItem.fields .map((field) => mapFieldMetadataToGraphQLQuery(field)) diff --git a/packages/twenty-front/src/modules/object-record/hooks/useGetRecordFromCache.ts b/packages/twenty-front/src/modules/object-record/hooks/useGetRecordFromCache.ts index 49c8085b7..5cc1bb654 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/useGetRecordFromCache.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/useGetRecordFromCache.ts @@ -1,8 +1,8 @@ import { gql, useApolloClient } from '@apollo/client'; import { useMapFieldMetadataToGraphQLQuery } from '@/object-metadata/hooks/useMapFieldMetadataToGraphQLQuery'; -import { EMPTY_MUTATION } from '@/object-metadata/hooks/useObjectMetadataItem'; import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { ObjectRecord } from '@/object-record/types/ObjectRecord'; import { capitalize } from '~/utils/string/capitalize'; export const useGetRecordFromCache = ({ @@ -13,9 +13,11 @@ export const useGetRecordFromCache = ({ const mapFieldMetadataToGraphQLQuery = useMapFieldMetadataToGraphQLQuery(); const apolloClient = useApolloClient(); - return (recordId: string) => { + return ( + recordId: string, + ) => { if (!objectMetadataItem) { - return EMPTY_MUTATION; + return null; } const capitalizedObjectName = capitalize(objectMetadataItem.nameSingular); @@ -35,7 +37,7 @@ export const useGetRecordFromCache = ({ id: recordId, }); - return cache.readFragment({ + return cache.readFragment({ id: cachedRecordId, fragment: cacheReadFragment, }); diff --git a/packages/twenty-front/src/modules/object-record/hooks/useModifyRecordFromCache.ts b/packages/twenty-front/src/modules/object-record/hooks/useModifyRecordFromCache.ts index 43850e494..8ae4a0f6f 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/useModifyRecordFromCache.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/useModifyRecordFromCache.ts @@ -1,8 +1,8 @@ import { useApolloClient } from '@apollo/client'; -import { Modifier, Reference } from '@apollo/client/cache'; +import { Modifiers } from '@apollo/client/cache'; -import { EMPTY_MUTATION } from '@/object-metadata/hooks/useObjectMetadataItem'; import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { ObjectRecord } from '@/object-record/types/ObjectRecord'; import { capitalize } from '~/utils/string/capitalize'; export const useModifyRecordFromCache = ({ @@ -10,23 +10,20 @@ export const useModifyRecordFromCache = ({ }: { objectMetadataItem: ObjectMetadataItem; }) => { - const apolloClient = useApolloClient(); + const { cache } = useApolloClient(); - return ( + return ( recordId: string, - fieldModifiers: Record>, + fieldModifiers: Modifiers, ) => { - if (!objectMetadataItem) { - return EMPTY_MUTATION; - } + if (!objectMetadataItem) return; - const cache = apolloClient.cache; const cachedRecordId = cache.identify({ __typename: capitalize(objectMetadataItem.nameSingular), id: recordId, }); - cache.modify>({ + cache.modify({ id: cachedRecordId, fields: fieldModifiers, }); diff --git a/packages/twenty-front/src/modules/object-record/hooks/useObjectRecordBoard.ts b/packages/twenty-front/src/modules/object-record/hooks/useObjectRecordBoard.ts index f88566aa6..98532b521 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/useObjectRecordBoard.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/useObjectRecordBoard.ts @@ -55,7 +55,7 @@ export const useObjectRecordBoard = () => { useFindManyRecords({ objectNameSingular: CoreObjectNameSingular.PipelineStep, - filter: {}, + filter, onCompleted: useCallback( (data: ObjectRecordConnection) => { setSavedPipelineSteps(data.edges.map((edge) => edge.node)); 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 b8f9b6724..6abe23682 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/useUpdateOneRecord.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/useUpdateOneRecord.ts @@ -1,27 +1,23 @@ -import { Reference, useApolloClient } from '@apollo/client'; +import { useApolloClient } from '@apollo/client'; -import { useOptimisticEffect } from '@/apollo/optimistic-effect/hooks/useOptimisticEffect'; +import { triggerUpdateRecordOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerUpdateRecordOptimisticEffect'; import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; -import { isRecordMatchingFilter } from '@/object-record/record-filter/utils/isRecordMatchingFilter'; +import { getUpdateOneRecordMutationResponseField } from '@/object-record/hooks/useGenerateUpdateOneRecordMutation'; +import { ObjectRecord } from '@/object-record/types/ObjectRecord'; import { sanitizeRecordInput } from '@/object-record/utils/sanitizeRecordInput'; -import { parseApolloStoreFieldName } from '~/utils/parseApolloStoreFieldName'; import { capitalize } from '~/utils/string/capitalize'; type useUpdateOneRecordProps = { objectNameSingular: string; }; -export const useUpdateOneRecord = ({ +export const useUpdateOneRecord = < + UpdatedObjectRecord extends ObjectRecord = ObjectRecord, +>({ objectNameSingular, }: useUpdateOneRecordProps) => { const { objectMetadataItem, updateOneRecordMutation, getRecordFromCache } = - useObjectMetadataItem({ - objectNameSingular, - }); - - const { triggerOptimisticEffects } = useOptimisticEffect({ - objectNameSingular, - }); + useObjectMetadataItem({ objectNameSingular }); const apolloClient = useApolloClient(); @@ -30,13 +26,15 @@ export const useUpdateOneRecord = ({ updateOneRecordInput, }: { idToUpdate: string; - updateOneRecordInput: Record; + updateOneRecordInput: Partial>; }) => { - const cachedRecord = getRecordFromCache(idToUpdate); + const cachedRecord = getRecordFromCache(idToUpdate); - const optimisticallyUpdatedRecord: Record = { + const optimisticallyUpdatedRecord = { ...(cachedRecord ?? {}), ...updateOneRecordInput, + __typename: capitalize(objectNameSingular), + id: idToUpdate, }; const sanitizedUpdateOneRecordInput = sanitizeRecordInput({ @@ -44,82 +42,32 @@ export const useUpdateOneRecord = ({ recordInput: updateOneRecordInput, }); - triggerOptimisticEffects({ - typename: `${capitalize(objectMetadataItem.nameSingular)}Edge`, - updatedRecords: [optimisticallyUpdatedRecord], - }); + const mutationResponseField = + getUpdateOneRecordMutationResponseField(objectNameSingular); const updatedRecord = await apolloClient.mutate({ mutation: updateOneRecordMutation, variables: { idToUpdate, - input: { - ...sanitizedUpdateOneRecordInput, - }, + input: sanitizedUpdateOneRecordInput, }, optimisticResponse: { - [`update${capitalize(objectMetadataItem.nameSingular)}`]: - optimisticallyUpdatedRecord, + [mutationResponseField]: optimisticallyUpdatedRecord, }, update: (cache, { data }) => { - const response = - data?.[`update${capitalize(objectMetadataItem.nameSingular)}`]; + const record = data?.[mutationResponseField]; - if (!response) return; + if (!record) return; - cache.modify>({ - fields: { - [objectMetadataItem.namePlural]: ( - existingConnectionRef, - { readField, storeFieldName }, - ) => { - if ( - readField('__typename', existingConnectionRef) !== - `${capitalize(objectMetadataItem.nameSingular)}Connection` - ) - return existingConnectionRef; - - const { variables } = parseApolloStoreFieldName(storeFieldName); - - const edges = readField<{ node: Reference }[]>( - 'edges', - existingConnectionRef, - ); - - if ( - variables?.filter && - !isRecordMatchingFilter({ - record: response, - filter: variables.filter, - objectMetadataItem, - }) && - edges?.length - ) { - return { - ...existingConnectionRef, - edges: edges.filter( - (edge) => - readField('id', readField('node', edge)) !== response.id, - ), - }; - } - - return existingConnectionRef; - }, - }, + triggerUpdateRecordOptimisticEffect({ + cache, + objectMetadataItem, + record, }); }, }); - if (!updatedRecord?.data) { - return null; - } - - const updatedData = updatedRecord.data[ - `update${capitalize(objectMetadataItem.nameSingular)}` - ] as T; - - return updatedData; + return updatedRecord?.data?.[mutationResponseField] ?? null; }; return { diff --git a/packages/twenty-front/src/modules/object-record/record-board/hooks/internal/useDeleteSelectedRecordBoardCardsInternal.ts b/packages/twenty-front/src/modules/object-record/record-board/hooks/internal/useDeleteSelectedRecordBoardCardsInternal.ts index a87644844..b4c6f6a80 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/hooks/internal/useDeleteSelectedRecordBoardCardsInternal.ts +++ b/packages/twenty-front/src/modules/object-record/record-board/hooks/internal/useDeleteSelectedRecordBoardCardsInternal.ts @@ -4,7 +4,6 @@ import { useRecoilCallback } from 'recoil'; import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; import { useDeleteManyRecords } from '@/object-record/hooks/useDeleteManyRecords'; import { useRecordBoardScopedStates } from '@/object-record/record-board/hooks/internal/useRecordBoardScopedStates'; -import { Opportunity } from '@/pipeline/types/Opportunity'; import { useRemoveRecordBoardCardIdsInternal } from './useRemoveRecordBoardCardIdsInternal'; @@ -12,10 +11,9 @@ export const useDeleteSelectedRecordBoardCardsInternal = () => { const removeCardIds = useRemoveRecordBoardCardIdsInternal(); const apolloClient = useApolloClient(); - const { deleteManyRecords: deleteManyOpportunities } = - useDeleteManyRecords({ - objectNameSingular: CoreObjectNameSingular.Opportunity, - }); + const { deleteManyRecords: deleteManyOpportunities } = useDeleteManyRecords({ + objectNameSingular: CoreObjectNameSingular.Opportunity, + }); const { selectedCardIdsSelector } = useRecordBoardScopedStates(); diff --git a/packages/twenty-front/src/modules/object-record/record-filter/utils/turnObjectDropdownFilterIntoQueryFilter.ts b/packages/twenty-front/src/modules/object-record/record-filter/utils/turnObjectDropdownFilterIntoQueryFilter.ts index 249c09a39..d7084f363 100644 --- a/packages/twenty-front/src/modules/object-record/record-filter/utils/turnObjectDropdownFilterIntoQueryFilter.ts +++ b/packages/twenty-front/src/modules/object-record/record-filter/utils/turnObjectDropdownFilterIntoQueryFilter.ts @@ -10,6 +10,7 @@ import { URLFilter, UUIDFilter, } from '@/object-record/record-filter/types/ObjectRecordQueryFilter'; +import { andFilterVariables } from '@/object-record/utils/andFilterVariables'; import { ViewFilterOperand } from '@/views/types/ViewFilterOperand'; import { Field } from '~/generated/graphql'; @@ -24,7 +25,7 @@ export type ObjectDropdownFilter = Omit & { export const turnObjectDropdownFilterIntoQueryFilter = ( rawUIFilters: ObjectDropdownFilter[], fields: Pick[], -): ObjectRecordQueryFilter => { +): ObjectRecordQueryFilter | undefined => { const objectRecordFilters: ObjectRecordQueryFilter[] = []; for (const rawUIFilter of rawUIFilters) { @@ -134,13 +135,15 @@ export const turnObjectDropdownFilterIntoQueryFilter = ( }); break; case ViewFilterOperand.IsNot: - objectRecordFilters.push({ - not: { - [correspondingField.name + 'Id']: { - in: parsedRecordIds, - } as UUIDFilter, - }, - }); + if (parsedRecordIds.length) { + objectRecordFilters.push({ + not: { + [correspondingField.name + 'Id']: { + in: parsedRecordIds, + } as UUIDFilter, + }, + }); + } break; default: throw new Error( @@ -257,5 +260,5 @@ export const turnObjectDropdownFilterIntoQueryFilter = ( } } - return { and: objectRecordFilters }; + return andFilterVariables(objectRecordFilters); }; diff --git a/packages/twenty-front/src/modules/object-record/record-relation-card/components/RecordRelationFieldCardContent.tsx b/packages/twenty-front/src/modules/object-record/record-relation-card/components/RecordRelationFieldCardContent.tsx index 2048e9e56..eae224e18 100644 --- a/packages/twenty-front/src/modules/object-record/record-relation-card/components/RecordRelationFieldCardContent.tsx +++ b/packages/twenty-front/src/modules/object-record/record-relation-card/components/RecordRelationFieldCardContent.tsx @@ -1,10 +1,10 @@ import { useContext, useEffect } from 'react'; -import { Reference } from '@apollo/client'; import { css } from '@emotion/react'; import styled from '@emotion/styled'; import { useSetRecoilState } from 'recoil'; import { LightIconButton, MenuItem } from 'tsup.ui.index'; +import { CachedObjectRecordEdge } from '@/apollo/types/CachedObjectRecordEdge'; import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; import { FieldDisplay } from '@/object-record/field/components/FieldDisplay'; @@ -13,7 +13,6 @@ import { usePersistField } from '@/object-record/field/hooks/usePersistField'; import { entityFieldsFamilyState } from '@/object-record/field/states/entityFieldsFamilyState'; import { FieldRelationMetadata } from '@/object-record/field/types/FieldMetadata'; import { useFieldContext } from '@/object-record/hooks/useFieldContext'; -import { useModifyRecordFromCache } from '@/object-record/hooks/useModifyRecordFromCache'; import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord'; import { ObjectRecord } from '@/object-record/types/ObjectRecord'; import { IconDotsVertical, IconUnlink } from '@/ui/display/icon'; @@ -71,14 +70,10 @@ export const RecordRelationFieldCardContent = ({ objectMetadataNameSingular, } = fieldDefinition.metadata as FieldRelationMetadata; - const { objectMetadataItem } = useObjectMetadataItem({ + const { modifyRecordFromCache } = useObjectMetadataItem({ objectNameSingular: objectMetadataNameSingular ?? '', }); - const modifyRecordFromCache = useModifyRecordFromCache({ - objectMetadataItem, - }); - const isToOneObject = relationType === 'TO_ONE_OBJECT'; const { labelIdentifierFieldMetadata: relationLabelIdentifierFieldMetadata, @@ -104,13 +99,13 @@ export const RecordRelationFieldCardContent = ({ const { closeDropdown, isDropdownOpen } = useDropdown(dropdownScopeId); // TODO: temporary as ChipDisplay expect to find the entity in the entityFieldsFamilyState - const setEntityFields = useSetRecoilState( + const setRelationEntityFields = useSetRecoilState( entityFieldsFamilyState(relationRecord.id), ); useEffect(() => { - setEntityFields(relationRecord); - }, [relationRecord, setEntityFields]); + setRelationEntityFields(relationRecord); + }, [relationRecord, setRelationEntityFields]); if (!FieldContextProvider) return null; @@ -137,15 +132,18 @@ export const RecordRelationFieldCardContent = ({ }); modifyRecordFromCache(entityId, { - [fieldName]: (relationRef, { readField }) => { - const edges = readField<{ node: Reference }[]>('edges', relationRef); + [fieldName]: (cachedRelationConnection, { readField }) => { + const edges = readField( + 'edges', + cachedRelationConnection, + ); if (!edges) { - return relationRef; + return cachedRelationConnection; } return { - ...relationRef, + ...cachedRelationConnection, edges: edges.filter(({ node }) => { const id = readField('id', node); return id !== relationRecord.id; diff --git a/packages/twenty-front/src/modules/object-record/record-relation-card/components/RecordRelationFieldCardSection.tsx b/packages/twenty-front/src/modules/object-record/record-relation-card/components/RecordRelationFieldCardSection.tsx index 54633ecc4..759836875 100644 --- a/packages/twenty-front/src/modules/object-record/record-relation-card/components/RecordRelationFieldCardSection.tsx +++ b/packages/twenty-front/src/modules/object-record/record-relation-card/components/RecordRelationFieldCardSection.tsx @@ -139,7 +139,6 @@ export const RecordRelationFieldCardSection = () => { ], orderByField: 'createdAt', selectedIds: relationRecordIds, - excludeEntityIds: relationRecordIds, objectNameSingular: relationObjectMetadataNameSingular, }); diff --git a/packages/twenty-front/src/modules/object-record/relation-picker/hooks/__tests__/useMultiObjectSearch.test.tsx b/packages/twenty-front/src/modules/object-record/relation-picker/hooks/__tests__/useMultiObjectSearch.test.tsx index 280f5017e..3ece5130c 100644 --- a/packages/twenty-front/src/modules/object-record/relation-picker/hooks/__tests__/useMultiObjectSearch.test.tsx +++ b/packages/twenty-front/src/modules/object-record/relation-picker/hooks/__tests__/useMultiObjectSearch.test.tsx @@ -42,19 +42,6 @@ const response = { }; const mocks = [ - { - request: { - query, - variables: { - filterNameSingular: { and: [{}, { id: { in: ['1'] } }] }, - orderByNameSingular: { createdAt: 'DescNullsLast' }, - limitNameSingular: 60, - }, - }, - result: jest.fn(() => ({ - data: response, - })), - }, { request: { query, @@ -72,8 +59,20 @@ const mocks = [ request: { query, variables: { + orderByNameSingular: { createdAt: 'DescNullsLast' }, limitNameSingular: 60, - filterNameSingular: { and: [{}, { not: { id: { in: ['1'] } } }] }, + }, + }, + result: jest.fn(() => ({ + data: response, + })), + }, + { + request: { + query, + variables: { + limitNameSingular: 60, + filterNameSingular: { not: { id: { in: ['1'] } } }, orderByNameSingular: { createdAt: 'DescNullsLast' }, }, }, diff --git a/packages/twenty-front/src/modules/object-record/relation-picker/hooks/useMultiObjectSearchMatchesSearchFilterAndSelectedItemsQuery.ts b/packages/twenty-front/src/modules/object-record/relation-picker/hooks/useMultiObjectSearchMatchesSearchFilterAndSelectedItemsQuery.ts index 98b48f2aa..a7edbe093 100644 --- a/packages/twenty-front/src/modules/object-record/relation-picker/hooks/useMultiObjectSearchMatchesSearchFilterAndSelectedItemsQuery.ts +++ b/packages/twenty-front/src/modules/object-record/relation-picker/hooks/useMultiObjectSearchMatchesSearchFilterAndSelectedItemsQuery.ts @@ -53,23 +53,9 @@ export const useMultiObjectSearchMatchesSearchFilterAndSelectedItemsQuery = ({ if (!isNonEmptyArray(selectedIds)) return null; - const searchFilter = - searchFilterPerMetadataItemNameSingular[nameSingular] ?? {}; - return [ `filter${capitalize(nameSingular)}`, - { - and: [ - { - ...searchFilter, - }, - { - id: { - in: selectedIds, - }, - }, - ], - }, + searchFilterPerMetadataItemNameSingular[nameSingular], ]; }) .filter(isDefined), diff --git a/packages/twenty-front/src/modules/object-record/relation-picker/hooks/useMultiObjectSearchMatchesSearchFilterAndToSelectQuery.ts b/packages/twenty-front/src/modules/object-record/relation-picker/hooks/useMultiObjectSearchMatchesSearchFilterAndToSelectQuery.ts index b61437a50..79ac8483f 100644 --- a/packages/twenty-front/src/modules/object-record/relation-picker/hooks/useMultiObjectSearchMatchesSearchFilterAndToSelectQuery.ts +++ b/packages/twenty-front/src/modules/object-record/relation-picker/hooks/useMultiObjectSearchMatchesSearchFilterAndToSelectQuery.ts @@ -1,5 +1,4 @@ import { useQuery } from '@apollo/client'; -import { isNonEmptyArray } from '@sniptt/guards'; import { useRecoilValue } from 'recoil'; import { EMPTY_QUERY } from '@/object-metadata/hooks/useObjectMetadataItem'; @@ -14,7 +13,7 @@ import { import { SelectedObjectRecordId } from '@/object-record/relation-picker/hooks/useMultiObjectSearch'; import { useOrderByFieldPerMetadataItem } from '@/object-record/relation-picker/hooks/useOrderByFieldPerMetadataItem'; import { useSearchFilterPerMetadataItem } from '@/object-record/relation-picker/hooks/useSearchFilterPerMetadataItem'; -import { isDeeplyEqual } from '~/utils/isDeeplyEqual'; +import { andFilterVariables } from '@/object-record/utils/andFilterVariables'; import { isDefined } from '~/utils/isDefined'; import { capitalize } from '~/utils/string/capitalize'; @@ -58,35 +57,19 @@ export const useMultiObjectSearchMatchesSearchFilterAndToSelectQuery = ({ ) .map(({ id }) => id); - const searchFilter = - searchFilterPerMetadataItemNameSingular[nameSingular] ?? {}; - const excludedIdsUnion = [...selectedIds, ...excludedIds]; + const excludedIdsFilter = excludedIdsUnion.length + ? { not: { id: { in: excludedIdsUnion } } } + : undefined; - const noFilter = - !isNonEmptyArray(excludedIdsUnion) && - isDeeplyEqual(searchFilter, {}); + const searchFilters = [ + searchFilterPerMetadataItemNameSingular[nameSingular], + excludedIdsFilter, + ]; return [ `filter${capitalize(nameSingular)}`, - !noFilter - ? { - and: [ - { - ...searchFilter, - }, - isNonEmptyArray(excludedIdsUnion) - ? { - not: { - id: { - in: [...selectedIds, ...excludedIds], - }, - }, - } - : {}, - ], - } - : {}, + andFilterVariables(searchFilters), ]; }) .filter(isDefined), diff --git a/packages/twenty-front/src/modules/object-record/select/hooks/useRecordsForSelect.ts b/packages/twenty-front/src/modules/object-record/select/hooks/useRecordsForSelect.ts index b0bfb04f5..7750faaf6 100644 --- a/packages/twenty-front/src/modules/object-record/select/hooks/useRecordsForSelect.ts +++ b/packages/twenty-front/src/modules/object-record/select/hooks/useRecordsForSelect.ts @@ -5,7 +5,8 @@ import { OrderBy } from '@/object-metadata/types/OrderBy'; import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords'; import { SelectableRecord } from '@/object-record/select/types/SelectableRecord'; import { getObjectFilterFields } from '@/object-record/select/utils/getObjectFilterFields'; -import { isDefined } from '~/utils/isDefined'; +import { andFilterVariables } from '@/object-record/utils/andFilterVariables'; +import { orFilterVariables } from '@/object-record/utils/orFilterVariables'; export const DEFAULT_SEARCH_REQUEST_LIMIT = 60; @@ -37,85 +38,62 @@ export const useRecordsForSelect = ({ ]; const orderByField = getObjectOrderByField(sortOrder); + const selectedIdsFilter = { id: { in: selectedIds } }; const { loading: selectedRecordsLoading, records: selectedRecordsData } = useFindManyRecords({ - filter: { - id: { - in: selectedIds, - }, - }, + filter: selectedIdsFilter, orderBy: orderByField, objectNameSingular, + skip: !selectedIds.length, }); - const searchFilter = filters - .map(({ fieldNames, filter }) => { - if (!isNonEmptyString(filter)) { - return undefined; - } + const searchFilters = filters.map(({ fieldNames, filter }) => { + if (!isNonEmptyString(filter)) { + return undefined; + } - return { - or: fieldNames.map((fieldName) => { - const fieldNameParts = fieldName.split('.'); + return orFilterVariables( + fieldNames.map((fieldName) => { + const [parentFieldName, subFieldName] = fieldName.split('.'); - if (fieldNameParts.length > 1) { - // Composite field - - return { - [fieldNameParts[0]]: { - [fieldNameParts[1]]: { - ilike: `%${filter}%`, - }, - }, - }; - } + if (subFieldName) { + // Composite field return { - [fieldName]: { - ilike: `%${filter}%`, + [parentFieldName]: { + [subFieldName]: { + ilike: `%${filter}%`, + }, }, }; - }), - }; - }) - .filter(isDefined); + } + + return { + [fieldName]: { + ilike: `%${filter}%`, + }, + }; + }), + ); + }); const { loading: filteredSelectedRecordsLoading, records: filteredSelectedRecordsData, } = useFindManyRecords({ - filter: { - and: [ - { - and: searchFilter, - }, - { - id: { - in: selectedIds, - }, - }, - ], - }, + filter: andFilterVariables([...searchFilters, selectedIdsFilter]), orderBy: orderByField, objectNameSingular, + skip: !selectedIds.length, }); + const notFilterIds = [...selectedIds, ...excludeEntityIds]; + const notFilter = notFilterIds.length + ? { not: { id: { in: notFilterIds } } } + : undefined; const { loading: recordsToSelectLoading, records: recordsToSelectData } = useFindManyRecords({ - filter: { - and: [ - { - and: searchFilter, - }, - { - not: { - id: { - in: [...selectedIds, ...excludeEntityIds], - }, - }, - }, - ], - }, + filter: andFilterVariables([...searchFilters, notFilter]), limit: limit ?? DEFAULT_SEARCH_REQUEST_LIMIT, orderBy: orderByField, objectNameSingular, diff --git a/packages/twenty-front/src/modules/object-record/types/ObjectRecordEdge.ts b/packages/twenty-front/src/modules/object-record/types/ObjectRecordEdge.ts index 51d815d41..e0e0154bf 100644 --- a/packages/twenty-front/src/modules/object-record/types/ObjectRecordEdge.ts +++ b/packages/twenty-front/src/modules/object-record/types/ObjectRecordEdge.ts @@ -1,6 +1,6 @@ import { ObjectRecord } from '@/object-record/types/ObjectRecord'; -export type ObjectRecordEdge = { +export type ObjectRecordEdge = { __typename?: string; node: T; cursor: string; diff --git a/packages/twenty-front/src/modules/object-record/utils/andFilterVariables.ts b/packages/twenty-front/src/modules/object-record/utils/andFilterVariables.ts new file mode 100644 index 000000000..6e985f224 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/utils/andFilterVariables.ts @@ -0,0 +1,14 @@ +import { ObjectRecordQueryFilter } from '@/object-record/record-filter/types/ObjectRecordQueryFilter'; +import { isDefined } from '~/utils/isDefined'; + +export const andFilterVariables = ( + filters: (ObjectRecordQueryFilter | undefined)[], +): ObjectRecordQueryFilter | undefined => { + const definedFilters = filters.filter(isDefined); + + if (!definedFilters.length) return undefined; + + return definedFilters.length === 1 + ? definedFilters[0] + : { and: definedFilters }; +}; diff --git a/packages/twenty-front/src/modules/object-record/utils/generateDeleteOneRecordMutation.ts b/packages/twenty-front/src/modules/object-record/utils/generateDeleteOneRecordMutation.ts index 1ca8bd6d5..ad0a2840b 100644 --- a/packages/twenty-front/src/modules/object-record/utils/generateDeleteOneRecordMutation.ts +++ b/packages/twenty-front/src/modules/object-record/utils/generateDeleteOneRecordMutation.ts @@ -4,6 +4,10 @@ import { EMPTY_MUTATION } from '@/object-metadata/hooks/useObjectMetadataItem'; import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; import { capitalize } from '~/utils/string/capitalize'; +export const getDeleteOneRecordMutationResponseField = ( + objectNameSingular: string, +) => `delete${capitalize(objectNameSingular)}`; + export const generateDeleteOneRecordMutation = ({ objectMetadataItem, }: { @@ -15,9 +19,13 @@ export const generateDeleteOneRecordMutation = ({ const capitalizedObjectName = capitalize(objectMetadataItem.nameSingular); + const mutationResponseField = getDeleteOneRecordMutationResponseField( + objectMetadataItem.nameSingular, + ); + return gql` mutation DeleteOne${capitalizedObjectName}($idToDelete: ID!) { - delete${capitalizedObjectName}(id: $idToDelete) { + ${mutationResponseField}(id: $idToDelete) { id } } diff --git a/packages/twenty-front/src/modules/object-record/utils/orFilterVariables.ts b/packages/twenty-front/src/modules/object-record/utils/orFilterVariables.ts new file mode 100644 index 000000000..e8c4435fd --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/utils/orFilterVariables.ts @@ -0,0 +1,14 @@ +import { ObjectRecordQueryFilter } from '@/object-record/record-filter/types/ObjectRecordQueryFilter'; +import { isDefined } from '~/utils/isDefined'; + +export const orFilterVariables = ( + filters: (ObjectRecordQueryFilter | undefined)[], +): ObjectRecordQueryFilter | undefined => { + const definedFilters = filters.filter(isDefined); + + if (!definedFilters.length) return undefined; + + return definedFilters.length === 1 + ? definedFilters[0] + : { or: definedFilters }; +}; diff --git a/packages/twenty-front/src/modules/pipeline/hooks/__mocks__/usePipelineSteps.ts b/packages/twenty-front/src/modules/pipeline/hooks/__mocks__/usePipelineSteps.ts index 0b4aa621d..7035b73ae 100644 --- a/packages/twenty-front/src/modules/pipeline/hooks/__mocks__/usePipelineSteps.ts +++ b/packages/twenty-front/src/modules/pipeline/hooks/__mocks__/usePipelineSteps.ts @@ -37,17 +37,10 @@ const data = { id: 'columnId', position: 1, name: 'Column Title', - pipeline: { connect: { id: currentPipelineId } }, - type: 'ongoing', }; export const variables = { - input: { - id: mockId, - variables: { - data, - }, - }, + input: data, }; export const deleteVariables = { idToDelete: 'columnId' }; diff --git a/packages/twenty-front/src/modules/pipeline/hooks/usePipelineSteps.ts b/packages/twenty-front/src/modules/pipeline/hooks/usePipelineSteps.ts index e190c1657..e826cdc61 100644 --- a/packages/twenty-front/src/modules/pipeline/hooks/usePipelineSteps.ts +++ b/packages/twenty-front/src/modules/pipeline/hooks/usePipelineSteps.ts @@ -13,10 +13,9 @@ export const usePipelineSteps = () => { objectNameSingular: CoreObjectNameSingular.PipelineStep, }); - const { deleteOneRecord: deleteOnePipelineStep } = - useDeleteOneRecord({ - objectNameSingular: CoreObjectNameSingular.PipelineStep, - }); + const { deleteOneRecord: deleteOnePipelineStep } = useDeleteOneRecord({ + objectNameSingular: CoreObjectNameSingular.PipelineStep, + }); const handlePipelineStepAdd = useRecoilCallback( ({ snapshot }) => @@ -25,16 +24,10 @@ export const usePipelineSteps = () => { if (!currentPipeline?.id) return; return createOnePipelineStep?.({ - variables: { - data: { - color: boardColumn.colorCode ?? 'gray', - id: boardColumn.id, - position: boardColumn.position, - name: boardColumn.title, - pipeline: { connect: { id: currentPipeline.id } }, - type: 'ongoing', - }, - }, + color: boardColumn.colorCode ?? 'gray', + id: boardColumn.id, + position: boardColumn.position, + name: boardColumn.title, }); }, [createOnePipelineStep], diff --git a/packages/twenty-front/src/modules/search/hooks/useFilteredSearchEntityQuery.ts b/packages/twenty-front/src/modules/search/hooks/useFilteredSearchEntityQuery.ts index e2f294fa4..46319d744 100644 --- a/packages/twenty-front/src/modules/search/hooks/useFilteredSearchEntityQuery.ts +++ b/packages/twenty-front/src/modules/search/hooks/useFilteredSearchEntityQuery.ts @@ -6,8 +6,9 @@ import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords'; import { EntitiesForMultipleEntitySelect } from '@/object-record/relation-picker/types/EntitiesForMultipleEntitySelect'; import { EntityForSelect } from '@/object-record/relation-picker/types/EntityForSelect'; import { ObjectRecord } from '@/object-record/types/ObjectRecord'; +import { andFilterVariables } from '@/object-record/utils/andFilterVariables'; +import { orFilterVariables } from '@/object-record/utils/orFilterVariables'; import { assertNotNull } from '~/utils/assert'; -import { isDefined } from '~/utils/isDefined'; type SearchFilter = { fieldNames: string[]; filter: string | number }; @@ -40,63 +41,63 @@ export const useFilteredSearchEntityQuery = ({ ...mapToObjectRecordIdentifier(record), record, }); + const selectedIdsFilter = { id: { in: selectedIds } }; const { loading: selectedRecordsLoading, records: selectedRecords } = useFindManyRecords({ objectNameSingular, - filter: { id: { in: selectedIds } }, + filter: selectedIdsFilter, orderBy: { [orderByField]: sortOrder }, + skip: !selectedIds.length, }); - const searchFilter = filters - .map(({ fieldNames, filter }) => { - if (!isNonEmptyString(filter)) { - return undefined; - } + const searchFilters = filters.map(({ fieldNames, filter }) => { + if (!isNonEmptyString(filter)) { + return undefined; + } - return { - or: fieldNames.map((fieldName) => { - const fieldNameParts = fieldName.split('.'); + return orFilterVariables( + fieldNames.map((fieldName) => { + const [parentFieldName, subFieldName] = fieldName.split('.'); - if (fieldNameParts.length > 1) { - // Composite field - - return { - [fieldNameParts[0]]: { - [fieldNameParts[1]]: { - ilike: `%${filter}%`, - }, - }, - }; - } + if (subFieldName) { + // Composite field return { - [fieldName]: { - ilike: `%${filter}%`, + [parentFieldName]: { + [subFieldName]: { + ilike: `%${filter}%`, + }, }, }; - }), - }; - }) - .filter(isDefined); + } + + return { + [fieldName]: { + ilike: `%${filter}%`, + }, + }; + }), + ); + }); const { loading: filteredSelectedRecordsLoading, records: filteredSelectedRecords, } = useFindManyRecords({ objectNameSingular, - filter: { and: [{ and: searchFilter }, { id: { in: selectedIds } }] }, + filter: andFilterVariables([...searchFilters, selectedIdsFilter]), orderBy: { [orderByField]: sortOrder }, + skip: !selectedIds.length, }); + const notFilterIds = [...selectedIds, ...excludeEntityIds]; + const notFilter = notFilterIds.length + ? { not: { id: { in: notFilterIds } } } + : undefined; const { loading: recordsToSelectLoading, records: recordsToSelect } = useFindManyRecords({ objectNameSingular, - filter: { - and: [ - { and: searchFilter }, - { not: { id: { in: [...selectedIds, ...excludeEntityIds] } } }, - ], - }, + filter: andFilterVariables([...searchFilters, notFilter]), limit: limit ?? DEFAULT_SEARCH_REQUEST_LIMIT, orderBy: { [orderByField]: sortOrder }, }); diff --git a/packages/twenty-front/src/modules/settings/developers/types/ApiKey.ts b/packages/twenty-front/src/modules/settings/developers/types/ApiKey.ts index f6f525f42..7f21f804c 100644 --- a/packages/twenty-front/src/modules/settings/developers/types/ApiKey.ts +++ b/packages/twenty-front/src/modules/settings/developers/types/ApiKey.ts @@ -5,4 +5,5 @@ export type ApiKey = { deletedAt: string | null; name: string; expiresAt: string; + revokedAt: string | null; }; diff --git a/packages/twenty-front/src/pages/settings/SettingsWorkspaceMembers.tsx b/packages/twenty-front/src/pages/settings/SettingsWorkspaceMembers.tsx index 3fa972aaf..bd63e8978 100644 --- a/packages/twenty-front/src/pages/settings/SettingsWorkspaceMembers.tsx +++ b/packages/twenty-front/src/pages/settings/SettingsWorkspaceMembers.tsx @@ -39,10 +39,9 @@ export const SettingsWorkspaceMembers = () => { const { records: workspaceMembers } = useFindManyRecords({ objectNameSingular: CoreObjectNameSingular.WorkspaceMember, }); - const { deleteOneRecord: deleteOneWorkspaceMember } = - useDeleteOneRecord({ - objectNameSingular: CoreObjectNameSingular.WorkspaceMember, - }); + const { deleteOneRecord: deleteOneWorkspaceMember } = useDeleteOneRecord({ + objectNameSingular: CoreObjectNameSingular.WorkspaceMember, + }); const currentWorkspace = useRecoilValue(currentWorkspaceState); const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState); diff --git a/packages/twenty-front/src/pages/settings/data-model/SettingsObjectNewField/SettingsObjectNewFieldStep2.tsx b/packages/twenty-front/src/pages/settings/data-model/SettingsObjectNewField/SettingsObjectNewFieldStep2.tsx index 7c87f1f95..463249886 100644 --- a/packages/twenty-front/src/pages/settings/data-model/SettingsObjectNewField/SettingsObjectNewFieldStep2.tsx +++ b/packages/twenty-front/src/pages/settings/data-model/SettingsObjectNewField/SettingsObjectNewFieldStep2.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import { Reference } from '@apollo/client'; +import { CachedObjectRecordEdge } from '@/apollo/types/CachedObjectRecordEdge'; import { useCreateOneRelationMetadataItem } from '@/object-metadata/hooks/useCreateOneRelationMetadataItem'; import { useFieldMetadataItem } from '@/object-metadata/hooks/useFieldMetadataItem'; import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; @@ -227,16 +228,16 @@ export const SettingsObjectNewFieldStep2 = () => { }; modifyViewFromCache(view.id, { - viewFields: (viewFieldsRef, { readField }) => { - const edges = readField<{ node: Reference }[]>( + viewFields: (cachedViewFieldsConnection, { readField }) => { + const edges = readField( 'edges', - viewFieldsRef, + cachedViewFieldsConnection, ); - if (!edges) return viewFieldsRef; + if (!edges) return cachedViewFieldsConnection; return { - ...viewFieldsRef, + ...cachedViewFieldsConnection, edges: [...edges, { node: viewFieldToCreate }], }; }, diff --git a/packages/twenty-front/src/pages/settings/developers/api-keys/SettingsDevelopersApiKeyDetail.tsx b/packages/twenty-front/src/pages/settings/developers/api-keys/SettingsDevelopersApiKeyDetail.tsx index 828cbe25a..d647821c9 100644 --- a/packages/twenty-front/src/pages/settings/developers/api-keys/SettingsDevelopersApiKeyDetail.tsx +++ b/packages/twenty-front/src/pages/settings/developers/api-keys/SettingsDevelopersApiKeyDetail.tsx @@ -80,7 +80,7 @@ export const SettingsDevelopersApiKeyDetail = () => { ) => { const newApiKey = await createOneApiKey?.({ name: name, - expiresAt: newExpiresAt, + expiresAt: newExpiresAt ?? '', }); if (!newApiKey) { diff --git a/packages/twenty-front/src/utils/parseApolloStoreFieldName.ts b/packages/twenty-front/src/utils/parseApolloStoreFieldName.ts index 004c9f4f1..16bd36b04 100644 --- a/packages/twenty-front/src/utils/parseApolloStoreFieldName.ts +++ b/packages/twenty-front/src/utils/parseApolloStoreFieldName.ts @@ -1,17 +1,23 @@ // There is a feature request for receiving variables in `cache.modify`: // @see https://github.com/apollographql/apollo-feature-requests/issues/259 // @see https://github.com/apollographql/apollo-client/issues/7129 + // For now we need to parse `storeFieldName` to retrieve the variables. -export const parseApolloStoreFieldName = (storeFieldName: string) => { +export const parseApolloStoreFieldName = < + Variables extends Record, +>( + storeFieldName: string, +) => { const matches = storeFieldName.match(/([a-zA-Z][a-zA-Z0-9 ]*)\((.*)\)/); if (!matches?.[1]) return {}; - const [, fieldName, stringifiedVariables] = matches; + const [, , stringifiedVariables] = matches; + const fieldName = matches[1] as string; try { const variables = stringifiedVariables - ? (JSON.parse(stringifiedVariables) as Record) + ? (JSON.parse(stringifiedVariables) as Variables) : undefined; return { fieldName, variables }; diff --git a/packages/twenty-front/src/utils/sort.ts b/packages/twenty-front/src/utils/sort.ts new file mode 100644 index 000000000..0eec8861a --- /dev/null +++ b/packages/twenty-front/src/utils/sort.ts @@ -0,0 +1,21 @@ +import { Maybe } from '~/generated/graphql'; + +export const sortNullsFirst = ( + fieldValueA: Maybe, + fieldValueB: Maybe, +) => (fieldValueA === null ? -1 : fieldValueB === null ? 1 : 0); + +export const sortNullsLast = ( + fieldValueA: Maybe, + fieldValueB: Maybe, +) => sortNullsFirst(fieldValueB, fieldValueA); + +export const sortAsc = ( + fieldValueA: string | number, + fieldValueB: string | number, +) => (fieldValueA < fieldValueB ? -1 : 1); + +export const sortDesc = ( + fieldValueA: string | number, + fieldValueB: string | number, +) => sortAsc(fieldValueB, fieldValueA);