diff --git a/packages/twenty-front/src/modules/activities/tasks/hooks/useCompleteTask.ts b/packages/twenty-front/src/modules/activities/tasks/hooks/useCompleteTask.ts index 36bc95b8d..9931f0a90 100644 --- a/packages/twenty-front/src/modules/activities/tasks/hooks/useCompleteTask.ts +++ b/packages/twenty-front/src/modules/activities/tasks/hooks/useCompleteTask.ts @@ -6,20 +6,18 @@ import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord'; type Task = Pick; export const useCompleteTask = (task: Task) => { - const { updateOneRecord: updateOneActivity } = useUpdateOneRecord({ + const { updateOneRecord: updateOneActivity } = useUpdateOneRecord({ objectNameSingular: 'activity', - refetchFindManyQuery: true, }); const completeTask = useCallback( - (value: boolean) => { + async (value: boolean) => { const completedAt = value ? new Date().toISOString() : null; - updateOneActivity?.({ + await updateOneActivity?.({ idToUpdate: task.id, input: { completedAt, }, - forceRefetch: true, }); }, [task.id, updateOneActivity], 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 index 7bbc90bd8..56991d069 100644 --- a/packages/twenty-front/src/modules/apollo/optimistic-effect/hooks/useOptimisticEffect.ts +++ b/packages/twenty-front/src/modules/apollo/optimistic-effect/hooks/useOptimisticEffect.ts @@ -1,21 +1,20 @@ -import { - ApolloCache, - DocumentNode, - OperationVariables, - useApolloClient, -} from '@apollo/client'; +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 { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; import { ObjectMetadataItemIdentifier } from '@/object-metadata/types/ObjectMetadataItemIdentifier'; +import { ObjectRecordQueryVariables } from '@/object-record/types/ObjectRecordQueryVariables'; import { optimisticEffectState } from '../states/optimisticEffectState'; -import { OptimisticEffect } from '../types/internal/OptimisticEffect'; +import { + OptimisticEffect, + OptimisticEffectWriter, +} from '../types/internal/OptimisticEffect'; import { OptimisticEffectDefinition } from '../types/OptimisticEffectDefinition'; export const useOptimisticEffect = ({ @@ -23,17 +22,41 @@ export const useOptimisticEffect = ({ }: ObjectMetadataItemIdentifier) => { const apolloClient = useApolloClient(); - const { findManyRecordsQuery } = useObjectMetadataItem({ + const { findManyRecordsQuery, objectMetadataItem } = useObjectMetadataItem({ objectNameSingular, }); - const registerOptimisticEffect = useRecoilCallback( + const unregisterOptimisticEffect = useRecoilCallback( ({ snapshot, set }) => - ({ + ({ variables, definition, }: { - variables: OperationVariables; + 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) { @@ -46,21 +69,14 @@ export const useOptimisticEffect = ({ .getLoadable(optimisticEffectState) .getValue(); - const optimisticEffectWriter = ({ + const optimisticEffectWriter: OptimisticEffectWriter = ({ cache, - newData, + createdRecords, + updatedRecords, deletedRecordIds, query, variables, objectMetadataItem, - }: { - cache: ApolloCache; - newData: unknown; - deletedRecordIds?: string[]; - variables: OperationVariables; - query: DocumentNode; - isUsingFlexibleBackend?: boolean; - objectMetadataItem?: ObjectMetadataItem; }) => { if (objectMetadataItem) { const existingData = cache.readQuery({ @@ -77,10 +93,11 @@ export const useOptimisticEffect = ({ variables, data: { [objectMetadataItem.namePlural]: definition.resolver({ - currentData: (existingData as any)?.[ + currentCacheData: (existingData as any)?.[ objectMetadataItem.namePlural ], - newData, + updatedRecords, + createdRecords, deletedRecordIds, variables, }), @@ -91,7 +108,7 @@ export const useOptimisticEffect = ({ } const existingData = cache.readQuery({ - query, + query: query ?? findManyRecordsQuery, variables, }); @@ -100,26 +117,40 @@ export const useOptimisticEffect = ({ } }; + const computedKey = computeOptimisticEffectKey({ + variables, + definition, + }); + const optimisticEffect = { - key: definition.key, variables, typename: definition.typename, query: definition.query, writer: optimisticEffectWriter, - objectMetadataItem: definition.objectMetadataItem, - isUsingFlexibleBackend: definition.isUsingFlexibleBackend, - } satisfies OptimisticEffect; + objectMetadataItem, + } satisfies OptimisticEffect; set(optimisticEffectState, { ...optimisticEffects, - [definition.key]: optimisticEffect, + [computedKey]: optimisticEffect, }); }, + [findManyRecordsQuery, objectNameSingular, objectMetadataItem], ); const triggerOptimisticEffects = useRecoilCallback( ({ snapshot }) => - (typename: string, newData: unknown, deletedRecordIds?: string[]) => { + ({ + typename, + createdRecords, + updatedRecords, + deletedRecordIds, + }: { + typename: string; + createdRecords?: Record[]; + updatedRecords?: Record[]; + deletedRecordIds?: string[]; + }) => { const optimisticEffects = snapshot .getLoadable(optimisticEffectState) .getValue(); @@ -127,20 +158,26 @@ export const useOptimisticEffect = ({ 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 formattedNewData = isNonEmptyArray(newData) - ? newData.map((data: any) => { + const formattedCreatedRecords = isNonEmptyArray(createdRecords) + ? createdRecords.map((data: any) => { return { ...data, __typename: typename }; }) - : newData; + : []; + + const formattedUpdatedRecords = isNonEmptyArray(updatedRecords) + ? updatedRecords.map((data: any) => { + return { ...data, __typename: typename }; + }) + : []; if (optimisticEffect.typename === typename) { optimisticEffect.writer({ cache: apolloClient.cache, - query: optimisticEffect.query ?? ({} as DocumentNode), - newData: formattedNewData, + query: optimisticEffect.query, + createdRecords: formattedCreatedRecords, + updatedRecords: formattedUpdatedRecords, deletedRecordIds, variables: optimisticEffect.variables, - isUsingFlexibleBackend: optimisticEffect.isUsingFlexibleBackend, objectMetadataItem: optimisticEffect.objectMetadataItem, }); } @@ -152,5 +189,6 @@ export const useOptimisticEffect = ({ 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 index a0eed30ee..7d91f5e7d 100644 --- a/packages/twenty-front/src/modules/apollo/optimistic-effect/states/optimisticEffectState.ts +++ b/packages/twenty-front/src/modules/apollo/optimistic-effect/states/optimisticEffectState.ts @@ -2,9 +2,7 @@ import { atom } from 'recoil'; import { OptimisticEffect } from '../types/internal/OptimisticEffect'; -export const optimisticEffectState = atom< - Record> ->({ +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 index 166b74fa5..93ca668c4 100644 --- a/packages/twenty-front/src/modules/apollo/optimistic-effect/types/OptimisticEffectDefinition.ts +++ b/packages/twenty-front/src/modules/apollo/optimistic-effect/types/OptimisticEffectDefinition.ts @@ -5,10 +5,8 @@ import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; import { OptimisticEffectResolver } from './OptimisticEffectResolver'; export type OptimisticEffectDefinition = { - key: string; query?: DocumentNode; typename: string; resolver: OptimisticEffectResolver; objectMetadataItem?: ObjectMetadataItem; - isUsingFlexibleBackend?: boolean; }; 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 index d57d090cc..91de6ca05 100644 --- a/packages/twenty-front/src/modules/apollo/optimistic-effect/types/OptimisticEffectResolver.ts +++ b/packages/twenty-front/src/modules/apollo/optimistic-effect/types/OptimisticEffectResolver.ts @@ -1,13 +1,15 @@ import { OperationVariables } from '@apollo/client'; export type OptimisticEffectResolver = ({ - currentData, - newData, + currentCacheData, + createdRecords, + updatedRecords, deletedRecordIds, variables, }: { - currentData: any; //TODO: Change when decommissioning v1 - newData: any; //TODO: Change when decommissioning v1 + 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 index 8cee693fe..d59125c32 100644 --- 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 @@ -1,28 +1,30 @@ -import { ApolloCache, DocumentNode, OperationVariables } from '@apollo/client'; +import { ApolloCache, DocumentNode } from '@apollo/client'; import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { ObjectRecordQueryVariables } from '@/object-record/types/ObjectRecordQueryVariables'; -type OptimisticEffectWriter = ({ +export type OptimisticEffectWriter = ({ cache, - newData, - variables, query, + createdRecords, + updatedRecords, + deletedRecordIds, + variables, + objectMetadataItem, }: { - cache: ApolloCache; - query: DocumentNode; - newData: T; + cache: ApolloCache; + query?: DocumentNode; + createdRecords?: Record[]; + updatedRecords?: Record[]; deletedRecordIds?: string[]; - variables: OperationVariables; - objectMetadataItem?: ObjectMetadataItem; - isUsingFlexibleBackend?: boolean; + variables: ObjectRecordQueryVariables; + objectMetadataItem: ObjectMetadataItem; }) => void; -export type OptimisticEffect = { - key: string; +export type OptimisticEffect = { query?: DocumentNode; typename: string; - variables: OperationVariables; - writer: OptimisticEffectWriter; - objectMetadataItem?: ObjectMetadataItem; - isUsingFlexibleBackend?: boolean; + 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 new file mode 100644 index 000000000..680c7d474 --- /dev/null +++ b/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/computeOptimisticEffectKey.ts @@ -0,0 +1,17 @@ +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/favorites/hooks/useFavorites.ts b/packages/twenty-front/src/modules/favorites/hooks/useFavorites.ts index 5092f632e..48d7ff7b1 100644 --- a/packages/twenty-front/src/modules/favorites/hooks/useFavorites.ts +++ b/packages/twenty-front/src/modules/favorites/hooks/useFavorites.ts @@ -9,7 +9,6 @@ import { Favorite } from '@/favorites/types/Favorite'; import { mapFavorites } from '@/favorites/utils/mapFavorites'; import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; import { useObjectNameSingularFromPlural } from '@/object-metadata/hooks/useObjectNameSingularFromPlural'; -import { getRecordOptimisticEffectDefinition } from '@/object-record/graphql/optimistic-effect-definition/getRecordOptimisticEffectDefinition'; import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords'; import { PaginatedRecordTypeResults } from '@/object-record/types/PaginatedRecordTypeResults'; import { isDeeplyEqual } from '~/utils/isDeeplyEqual'; @@ -29,15 +28,13 @@ export const useFavorites = ({ updateOneRecordMutation: updateOneFavoriteMutation, createOneRecordMutation: createOneFavoriteMutation, deleteOneRecordMutation: deleteOneFavoriteMutation, - objectMetadataItem: favoriteObjectMetadataItem, } = useObjectMetadataItem({ objectNameSingular: 'favorite', }); - const { registerOptimisticEffect, triggerOptimisticEffects } = - useOptimisticEffect({ - objectNameSingular: 'favorite', - }); + const { triggerOptimisticEffects } = useOptimisticEffect({ + objectNameSingular: 'favorite', + }); const { performOptimisticEvict } = useOptimisticEvict(); const { objectNameSingular } = useObjectNameSingularFromPlural({ @@ -65,19 +62,8 @@ export const useFavorites = ({ if (!isDeeplyEqual(favorites, queriedFavorites)) { set(favoritesState, queriedFavorites); } - - if (!favoriteObjectMetadataItem) { - return; - } - - registerOptimisticEffect({ - variables: { filter: {}, orderBy: {} }, - definition: getRecordOptimisticEffectDefinition({ - objectMetadataItem: favoriteObjectMetadataItem, - }), - }); }, - [favoriteObjectMetadataItem, registerOptimisticEffect], + [], ), }); @@ -102,7 +88,10 @@ export const useFavorites = ({ }, }); - triggerOptimisticEffects(`FavoriteEdge`, result.data[`createFavorite`]); + triggerOptimisticEffects({ + typename: `FavoriteEdge`, + createdRecords: [result.data[`createFavorite`]], + }); const createdFavorite = result?.data?.createFavorite; diff --git a/packages/twenty-front/src/modules/object-metadata/hooks/useRecordOptimisticEffect.ts b/packages/twenty-front/src/modules/object-metadata/hooks/useRecordOptimisticEffect.ts new file mode 100644 index 000000000..bf00e3c08 --- /dev/null +++ b/packages/twenty-front/src/modules/object-metadata/hooks/useRecordOptimisticEffect.ts @@ -0,0 +1,57 @@ +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 index 6e79e596a..447c8b4fa 100644 --- 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 @@ -1,68 +1,109 @@ +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 { PaginatedRecordTypeResults } from '@/object-record/types/PaginatedRecordTypeResults'; +import { isDefined } from '~/utils/isDefined'; import { capitalize } from '~/utils/string/capitalize'; export const getRecordOptimisticEffectDefinition = ({ objectMetadataItem, }: { objectMetadataItem: ObjectMetadataItem; -}) => - ({ - key: `record-create-optimistic-effect-definition-${objectMetadataItem.nameSingular}`, - typename: `${capitalize(objectMetadataItem.nameSingular)}Edge`, - resolver: ({ - currentData, - newData, - deletedRecordIds, - }: { - currentData: unknown; - newData: { id: string } & Record; - deletedRecordIds?: string[]; - }) => { - const newRecordPaginatedCacheField = produce< - PaginatedRecordTypeResults - >(currentData as PaginatedRecordTypeResults, (draft) => { - if (newData) { - if (!draft) { - return { - __typename: `${capitalize(objectMetadataItem.nameSingular)}Edge`, - edges: [{ node: newData, cursor: '' }], - pageInfo: { - endCursor: '', - hasNextPage: false, - hasPreviousPage: false, - startCursor: '', - }, - }; - } +}): OptimisticEffectDefinition => ({ + typename: `${capitalize(objectMetadataItem.nameSingular)}Edge`, + resolver: ({ + currentCacheData: currentData, + createdRecords, + updatedRecords, + deletedRecordIds, + variables, + }) => { + const newRecordPaginatedCacheField = produce< + PaginatedRecordTypeResults + >(currentData as PaginatedRecordTypeResults, (draft) => { + const existingDataIsEmpty = !draft || !draft.edges || !draft.edges[0]; - const existingRecord = draft.edges.find( - (edge) => edge.node.id === newData.id, - ); - if (existingRecord) { - existingRecord.node = newData; - return; - } - - draft.edges.unshift({ - node: newData, - cursor: '', + if (isNonEmptyArray(createdRecords)) { + if (existingDataIsEmpty) { + return { __typename: `${capitalize(objectMetadataItem.nameSingular)}Edge`, - }); - } + edges: createdRecords.map((createdRecord) => ({ + 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 (deletedRecordIds) { - draft.edges = draft.edges.filter( - (edge) => !deletedRecordIds.includes(edge.node.id), - ); - } - }); + if (existingRecord) { + existingRecord.node = createdRecord; + continue; + } - return newRecordPaginatedCacheField; - }, - isUsingFlexibleBackend: true, - objectMetadataItem, - }) satisfies OptimisticEffectDefinition; + draft.edges.unshift({ + node: createdRecord, + cursor: '', + __typename: `${capitalize(objectMetadataItem.nameSingular)}Edge`, + }); + } + } + } + + if (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 cd0aaed00..1a67d1fb0 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/useCreateManyRecords.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/useCreateManyRecords.ts @@ -7,7 +7,7 @@ import { ObjectMetadataItemIdentifier } from '@/object-metadata/types/ObjectMeta import { useGenerateEmptyRecord } from '@/object-record/hooks/useGenerateEmptyRecord'; import { capitalize } from '~/utils/string/capitalize'; -export const useCreateManyRecords = ({ +export const useCreateManyRecords = >({ objectNameSingular, }: ObjectMetadataItemIdentifier) => { const { triggerOptimisticEffects } = useOptimisticEffect({ @@ -32,10 +32,17 @@ export const useCreateManyRecords = ({ })); withIds.forEach((record) => { - triggerOptimisticEffects( - `${capitalize(objectMetadataItem.nameSingular)}Edge`, - generateEmptyRecord({ id: record.id }), - ); + const emptyRecord: Record | undefined = + generateEmptyRecord({ + id: record.id, + }); + + if (emptyRecord) { + triggerOptimisticEffects({ + typename: `${capitalize(objectMetadataItem.nameSingular)}Edge`, + createdRecords: [emptyRecord], + }); + } }); const createdObjects = await apolloClient.mutate({ @@ -59,11 +66,9 @@ export const useCreateManyRecords = ({ `create${capitalize(objectMetadataItem.namePlural)}` ] as T[]) ?? []; - createdRecords.forEach((record) => { - triggerOptimisticEffects( - `${capitalize(objectMetadataItem.nameSingular)}Edge`, - record, - ); + triggerOptimisticEffects({ + typename: `${capitalize(objectMetadataItem.nameSingular)}Edge`, + createdRecords, }); return createdRecords; 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 92d88c461..d08c69027 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/useCreateOneRecord.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/useCreateOneRecord.ts @@ -1,5 +1,4 @@ import { useApolloClient } from '@apollo/client'; -import { getOperationName } from '@apollo/client/utilities'; import { v4 } from 'uuid'; import { useOptimisticEffect } from '@/apollo/optimistic-effect/hooks/useOptimisticEffect'; @@ -14,16 +13,16 @@ type useCreateOneRecordProps = { export const useCreateOneRecord = ({ objectNameSingular, - refetchFindManyQuery = false, }: useCreateOneRecordProps) => { const { triggerOptimisticEffects } = useOptimisticEffect({ objectNameSingular, }); - const { objectMetadataItem, createOneRecordMutation, findManyRecordsQuery } = - useObjectMetadataItem({ + const { objectMetadataItem, createOneRecordMutation } = useObjectMetadataItem( + { objectNameSingular, - }); + }, + ); // TODO: type this with a minimal type at least with Record const apolloClient = useApolloClient(); @@ -35,16 +34,16 @@ export const useCreateOneRecord = ({ const createOneRecord = async (input: Record) => { const recordId = v4(); - const generatedEmptyRecord = generateEmptyRecord({ + const generatedEmptyRecord = generateEmptyRecord>({ id: recordId, ...input, }); if (generatedEmptyRecord) { - triggerOptimisticEffects( - `${capitalize(objectMetadataItem.nameSingular)}Edge`, - generatedEmptyRecord, - ); + triggerOptimisticEffects({ + typename: `${capitalize(objectMetadataItem.nameSingular)}Edge`, + createdRecords: [generatedEmptyRecord], + }); } const createdObject = await apolloClient.mutate({ @@ -56,22 +55,12 @@ export const useCreateOneRecord = ({ [`create${capitalize(objectMetadataItem.nameSingular)}`]: generateEmptyRecord({ id: recordId, ...input }), }, - refetchQueries: refetchFindManyQuery - ? [getOperationName(findManyRecordsQuery) ?? ''] - : [], }); if (!createdObject.data) { return null; } - triggerOptimisticEffects( - `${capitalize(objectMetadataItem.nameSingular)}Edge`, - createdObject.data[ - `create${capitalize(objectMetadataItem.nameSingular)}` - ], - ); - return createdObject.data[ `create${capitalize(objectMetadataItem.nameSingular)}` ] as T; 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 839e2edd1..cc3e3b473 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/useDeleteOneRecord.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/useDeleteOneRecord.ts @@ -30,11 +30,10 @@ export const useDeleteOneRecord = ({ const deleteOneRecord = useCallback( async (idToDelete: string) => { - triggerOptimisticEffects( - `${capitalize(objectMetadataItem.nameSingular)}Edge`, - undefined, - [idToDelete], - ); + triggerOptimisticEffects({ + typename: `${capitalize(objectMetadataItem.nameSingular)}Edge`, + deletedRecordIds: [idToDelete], + }); performOptimisticEvict( capitalize(objectMetadataItem.nameSingular), 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 54f7e3d6e..18d472aba 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/useFindManyRecords.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/useFindManyRecords.ts @@ -4,13 +4,12 @@ import { isNonEmptyArray } from '@apollo/client/utilities'; import { isNonEmptyString } from '@sniptt/guards'; import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil'; -import { useOptimisticEffect } from '@/apollo/optimistic-effect/hooks/useOptimisticEffect'; 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 { getRecordOptimisticEffectDefinition } from '@/object-record/graphql/optimistic-effect-definition/getRecordOptimisticEffectDefinition'; -import { ObjectRecordFilter } from '@/object-record/types/ObjectRecordFilter'; +import { ObjectRecordQueryFilter } from '@/object-record/record-filter/types/ObjectRecordQueryFilter'; 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'; @@ -37,7 +36,7 @@ export const useFindManyRecords = < onCompleted, skip, }: ObjectMetadataItemIdentifier & { - filter?: ObjectRecordFilter; + filter?: ObjectRecordQueryFilter; orderBy?: OrderByField; limit?: number; onCompleted?: (data: PaginatedRecordTypeResults) => void; @@ -65,8 +64,11 @@ export const useFindManyRecords = < objectNameSingular, }); - const { registerOptimisticEffect } = useOptimisticEffect({ - objectNameSingular, + useRecordOptimisticEffect({ + objectMetadataItem, + filter, + orderBy, + limit, }); const { enqueueSnackBar } = useSnackBar(); @@ -82,19 +84,6 @@ export const useFindManyRecords = < orderBy: orderBy ?? {}, }, onCompleted: (data) => { - if (objectMetadataItem) { - registerOptimisticEffect({ - variables: { - filter: filter ?? {}, - orderBy: orderBy ?? {}, - limit: limit, - }, - definition: getRecordOptimisticEffectDefinition({ - objectMetadataItem, - }), - }); - } - onCompleted?.(data[objectMetadataItem.namePlural]); if (data?.[objectMetadataItem.namePlural]) { diff --git a/packages/twenty-front/src/modules/object-record/hooks/useGenerateEmptyRecord.ts b/packages/twenty-front/src/modules/object-record/hooks/useGenerateEmptyRecord.ts index 23350b18a..17d994ac7 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/useGenerateEmptyRecord.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/useGenerateEmptyRecord.ts @@ -164,6 +164,6 @@ export const useGenerateEmptyRecord = ({ }; return { - generateEmptyRecord: generateEmptyRecord, + generateEmptyRecord, }; }; 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 068be57f3..ae6acaf0d 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/useObjectRecordBoard.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/useObjectRecordBoard.ts @@ -5,8 +5,8 @@ import { Company } from '@/companies/types/Company'; import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; import { turnSortsIntoOrderBy } from '@/object-record/object-sort-dropdown/utils/turnSortsIntoOrderBy'; import { useRecordBoardScopedStates } from '@/object-record/record-board/hooks/internal/useRecordBoardScopedStates'; +import { turnObjectDropdownFilterIntoQueryFilter } from '@/object-record/record-filter/utils/turnObjectDropdownFilterIntoQueryFilter'; import { PaginatedRecordTypeResults } from '@/object-record/types/PaginatedRecordTypeResults'; -import { turnFiltersIntoObjectRecordFilters } from '@/object-record/utils/turnFiltersIntoWhereClause'; import { Opportunity } from '@/pipeline/types/Opportunity'; import { PipelineStep } from '@/pipeline/types/PipelineStep'; @@ -43,7 +43,7 @@ export const useObjectRecordBoard = () => { savedPipelineStepsState, ); - const filter = turnFiltersIntoObjectRecordFilters( + const filter = turnObjectDropdownFilterIntoQueryFilter( boardFilters, foundObjectMetadataItem?.fields ?? [], ); diff --git a/packages/twenty-front/src/modules/object-record/hooks/useObjectRecordTable.ts b/packages/twenty-front/src/modules/object-record/hooks/useObjectRecordTable.ts index c0928218c..900a6f28c 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/useObjectRecordTable.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/useObjectRecordTable.ts @@ -4,10 +4,10 @@ import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; import { useObjectNameSingularFromPlural } from '@/object-metadata/hooks/useObjectNameSingularFromPlural'; import { turnSortsIntoOrderBy } from '@/object-record/object-sort-dropdown/utils/turnSortsIntoOrderBy'; +import { turnObjectDropdownFilterIntoQueryFilter } from '@/object-record/record-filter/utils/turnObjectDropdownFilterIntoQueryFilter'; import { useRecordTableScopedStates } from '@/object-record/record-table/hooks/internal/useRecordTableScopedStates'; import { useRecordTable } from '@/object-record/record-table/hooks/useRecordTable'; import { isRecordTableInitialLoadingState } from '@/object-record/record-table/states/isRecordTableInitialLoadingState'; -import { turnFiltersIntoObjectRecordFilters } from '@/object-record/utils/turnFiltersIntoWhereClause'; import { signInBackgroundMockCompanies } from '@/sign-in-background-mock/constants/signInBackgroundMockCompanies'; import { useFindManyRecords } from './useFindManyRecords'; @@ -32,7 +32,7 @@ export const useObjectRecordTable = () => { const tableSorts = useRecoilValue(tableSortsState); const setLastRowVisible = useSetRecoilState(tableLastRowVisibleState); - const requestFilters = turnFiltersIntoObjectRecordFilters( + const requestFilters = turnObjectDropdownFilterIntoQueryFilter( tableFilters, foundObjectMetadataItem?.fields ?? [], ); 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 a9543d278..4779527da 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/useUpdateOneRecord.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/useUpdateOneRecord.ts @@ -1,6 +1,6 @@ import { useApolloClient } from '@apollo/client'; -import { getOperationName } from '@apollo/client/utilities'; +import { useOptimisticEffect } from '@/apollo/optimistic-effect/hooks/useOptimisticEffect'; import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; import { capitalize } from '~/utils/string/capitalize'; @@ -11,14 +11,13 @@ type useUpdateOneRecordProps = { export const useUpdateOneRecord = ({ objectNameSingular, - refetchFindManyQuery = false, }: useUpdateOneRecordProps) => { - const { - objectMetadataItem, - updateOneRecordMutation, - getRecordFromCache, - findManyRecordsQuery, - } = useObjectMetadataItem({ + const { objectMetadataItem, updateOneRecordMutation, getRecordFromCache } = + useObjectMetadataItem({ + objectNameSingular, + }); + + const { triggerOptimisticEffects } = useOptimisticEffect({ objectNameSingular, }); @@ -34,6 +33,16 @@ export const useUpdateOneRecord = ({ }) => { const cachedRecord = getRecordFromCache(idToUpdate); + triggerOptimisticEffects({ + typename: `${capitalize(objectMetadataItem.nameSingular)}Edge`, + updatedRecords: [ + { + ...(cachedRecord ?? {}), + ...input, + }, + ], + }); + const updatedRecord = await apolloClient.mutate({ mutation: updateOneRecordMutation, variables: { @@ -48,18 +57,17 @@ export const useUpdateOneRecord = ({ ...input, }, }, - refetchQueries: refetchFindManyQuery - ? [getOperationName(findManyRecordsQuery) ?? ''] - : [], }); if (!updatedRecord?.data) { return null; } - return updatedRecord.data[ + const updatedData = updatedRecord.data[ `update${capitalize(objectMetadataItem.nameSingular)}` ] as T; + + return updatedData; }; return { diff --git a/packages/twenty-front/src/modules/object-record/types/ObjectRecordFilter.ts b/packages/twenty-front/src/modules/object-record/record-filter/types/ObjectRecordQueryFilter.ts similarity index 58% rename from packages/twenty-front/src/modules/object-record/types/ObjectRecordFilter.ts rename to packages/twenty-front/src/modules/object-record/record-filter/types/ObjectRecordQueryFilter.ts index 81a9db243..19c40d85b 100644 --- a/packages/twenty-front/src/modules/object-record/types/ObjectRecordFilter.ts +++ b/packages/twenty-front/src/modules/object-record/record-filter/types/ObjectRecordQueryFilter.ts @@ -9,6 +9,11 @@ export type UUIDFilter = { is?: IsFilter; }; +export type BooleanFilter = { + eq?: boolean; + is?: IsFilter; +}; + export type StringFilter = { eq?: string; gt?: string; @@ -36,6 +41,11 @@ export type FloatFilter = { is?: IsFilter; }; +/** + * Always use a DateFilter in the variables of a query, and never directly in the query. + * + * Because pg_graphql only works with ISO strings if it is passed to variables. + */ export type DateFilter = { eq?: string; gt?: string; @@ -53,6 +63,7 @@ export type CurrencyFilter = { export type URLFilter = { url?: StringFilter; + label?: StringFilter; }; export type FullNameFilter = { @@ -67,14 +78,27 @@ export type LeafFilter = | DateFilter | CurrencyFilter | URLFilter - | FullNameFilter; + | FullNameFilter + | BooleanFilter; -export type ObjectRecordFilter = - | { - and?: ObjectRecordFilter[]; - or?: ObjectRecordFilter[]; - not?: ObjectRecordFilter; - } - | { - [fieldName: string]: LeafFilter; - }; +export type AndObjectRecordFilter = { + and?: ObjectRecordQueryFilter[]; +}; + +export type OrObjectRecordFilter = { + or?: ObjectRecordQueryFilter[] | ObjectRecordQueryFilter; +}; + +export type NotObjectRecordFilter = { + not?: ObjectRecordQueryFilter; +}; + +export type LeafObjectRecordFilter = { + [fieldName: string]: LeafFilter; +}; + +export type ObjectRecordQueryFilter = + | LeafObjectRecordFilter + | AndObjectRecordFilter + | OrObjectRecordFilter + | NotObjectRecordFilter; diff --git a/packages/twenty-front/src/modules/object-record/record-filter/utils/isMatchingBooleanFilter.spec.ts b/packages/twenty-front/src/modules/object-record/record-filter/utils/isMatchingBooleanFilter.spec.ts new file mode 100644 index 000000000..c6ad87158 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-filter/utils/isMatchingBooleanFilter.spec.ts @@ -0,0 +1,46 @@ +import { isMatchingBooleanFilter } from '@/object-record/record-filter/utils/isMatchingBooleanFilter'; + +describe('isMatchingBooleanFilter', () => { + describe('eq', () => { + it('value equals eq filter', () => { + expect( + isMatchingBooleanFilter({ booleanFilter: { eq: true }, value: true }), + ).toBe(true); + }); + + it('value does not equal eq filter', () => { + expect( + isMatchingBooleanFilter({ booleanFilter: { eq: true }, value: false }), + ).toBe(false); + }); + }); + + describe('is', () => { + it('value is NULL', () => { + expect( + isMatchingBooleanFilter({ + booleanFilter: { is: 'NULL' }, + value: null as any, + }), + ).toBe(true); + }); + + it('value is NOT_NULL', () => { + expect( + isMatchingBooleanFilter({ + booleanFilter: { is: 'NOT_NULL' }, + value: true, + }), + ).toBe(true); + }); + + it('value is NOT_NULL and false', () => { + expect( + isMatchingBooleanFilter({ + booleanFilter: { is: 'NOT_NULL' }, + value: false, + }), + ).toBe(true); + }); + }); +}); diff --git a/packages/twenty-front/src/modules/object-record/record-filter/utils/isMatchingBooleanFilter.ts b/packages/twenty-front/src/modules/object-record/record-filter/utils/isMatchingBooleanFilter.ts new file mode 100644 index 000000000..87ce14302 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-filter/utils/isMatchingBooleanFilter.ts @@ -0,0 +1,23 @@ +import { BooleanFilter } from '@/object-record/record-filter/types/ObjectRecordQueryFilter'; + +export const isMatchingBooleanFilter = ({ + booleanFilter, + value, +}: { + booleanFilter: BooleanFilter; + value: boolean; +}) => { + if (booleanFilter.eq !== undefined) { + return value === booleanFilter.eq; + } else if (booleanFilter.is !== undefined) { + if (booleanFilter.is === 'NULL') { + return value === null; + } else { + return value !== null; + } + } else { + throw new Error( + `Unexpected value for string filter : ${JSON.stringify(booleanFilter)}`, + ); + } +}; diff --git a/packages/twenty-front/src/modules/object-record/record-filter/utils/isMatchingDateFilter.spec.ts b/packages/twenty-front/src/modules/object-record/record-filter/utils/isMatchingDateFilter.spec.ts new file mode 100644 index 000000000..9cd4b07d1 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-filter/utils/isMatchingDateFilter.spec.ts @@ -0,0 +1,161 @@ +import { isMatchingDateFilter } from '@/object-record/record-filter/utils/isMatchingDateFilter'; + +describe('isMatchingDateFilter', () => { + const testDate = '2023-12-19T12:15:29.810Z'; + + describe('eq', () => { + it('value equals eq filter', () => { + expect( + isMatchingDateFilter({ dateFilter: { eq: testDate }, value: testDate }), + ).toBe(true); + }); + + it('value does not equal eq filter', () => { + expect( + isMatchingDateFilter({ + dateFilter: { eq: testDate }, + value: '2023-12-18T12:15:29.810Z', + }), + ).toBe(false); + }); + }); + + describe('neq', () => { + it('value does not equal neq filter', () => { + expect( + isMatchingDateFilter({ + dateFilter: { neq: testDate }, + value: '2023-12-18T12:15:29.810Z', + }), + ).toBe(true); + }); + + it('value equals neq filter', () => { + expect( + isMatchingDateFilter({ + dateFilter: { neq: testDate }, + value: testDate, + }), + ).toBe(false); + }); + }); + + describe('in', () => { + it('value is in the array', () => { + expect( + isMatchingDateFilter({ + dateFilter: { in: [testDate, '2023-12-20T12:15:29.810Z'] }, + value: testDate, + }), + ).toBe(true); + }); + + it('value is not in the array', () => { + expect( + isMatchingDateFilter({ + dateFilter: { + in: ['2023-12-20T12:15:29.810Z', '2023-12-21T12:15:29.810Z'], + }, + value: testDate, + }), + ).toBe(false); + }); + }); + + describe('is', () => { + it('value is NULL', () => { + expect( + isMatchingDateFilter({ + dateFilter: { is: 'NULL' }, + value: null as any, + }), + ).toBe(true); + }); + + it('value is NOT_NULL', () => { + expect( + isMatchingDateFilter({ + dateFilter: { is: 'NOT_NULL' }, + value: testDate, + }), + ).toBe(true); + }); + }); + + describe('gt', () => { + it('value is greater than gt filter', () => { + expect( + isMatchingDateFilter({ + dateFilter: { gt: '2023-12-18T12:15:29.810Z' }, + value: testDate, + }), + ).toBe(true); + }); + + it('value is not greater than gt filter', () => { + expect( + isMatchingDateFilter({ + dateFilter: { gt: '2023-12-20T12:15:29.810Z' }, + value: testDate, + }), + ).toBe(false); + }); + }); + + describe('gte', () => { + it('value is greater than or equal to gte filter', () => { + expect( + isMatchingDateFilter({ + dateFilter: { gte: testDate }, + value: testDate, + }), + ).toBe(true); + }); + + it('value is not greater than or equal to gte filter', () => { + expect( + isMatchingDateFilter({ + dateFilter: { gte: '2023-12-20T12:15:29.810Z' }, + value: testDate, + }), + ).toBe(false); + }); + }); + + describe('lt', () => { + it('value is less than lt filter', () => { + expect( + isMatchingDateFilter({ + dateFilter: { lt: '2023-12-20T12:15:29.810Z' }, + value: testDate, + }), + ).toBe(true); + }); + + it('value is not less than lt filter', () => { + expect( + isMatchingDateFilter({ dateFilter: { lt: testDate }, value: testDate }), + ).toBe(false); + }); + }); + + describe('lte', () => { + it('value is less than or equal to lte filter', () => { + expect( + isMatchingDateFilter({ + dateFilter: { lte: testDate }, + value: testDate, + }), + ).toBe(true); + }); + + it('value is not less than or equal to lte filter', () => { + expect( + isMatchingDateFilter({ + dateFilter: { lte: '2023-12-18T12:15:29.810Z' }, + value: testDate, + }), + ).toBe(false); + }); + }); +}); diff --git a/packages/twenty-front/src/modules/object-record/record-filter/utils/isMatchingDateFilter.ts b/packages/twenty-front/src/modules/object-record/record-filter/utils/isMatchingDateFilter.ts new file mode 100644 index 000000000..c769730de --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-filter/utils/isMatchingDateFilter.ts @@ -0,0 +1,47 @@ +import { DateTime } from 'luxon'; + +import { DateFilter } from '@/object-record/record-filter/types/ObjectRecordQueryFilter'; + +export const isMatchingDateFilter = ({ + dateFilter, + value, +}: { + dateFilter: DateFilter; + value: string; +}) => { + switch (true) { + case dateFilter.eq !== undefined: { + return DateTime.fromISO(value).equals(DateTime.fromISO(dateFilter.eq)); + } + case dateFilter.neq !== undefined: { + return !DateTime.fromISO(value).equals(DateTime.fromISO(dateFilter.neq)); + } + case dateFilter.in !== undefined: { + return dateFilter.in.includes(value); + } + case dateFilter.is !== undefined: { + if (dateFilter.is === 'NULL') { + return value === null; + } else { + return value !== null; + } + } + case dateFilter.gt !== undefined: { + return DateTime.fromISO(value) > DateTime.fromISO(dateFilter.gt); + } + case dateFilter.gte !== undefined: { + return DateTime.fromISO(value) >= DateTime.fromISO(dateFilter.gte); + } + case dateFilter.lt !== undefined: { + return DateTime.fromISO(value) < DateTime.fromISO(dateFilter.lt); + } + case dateFilter.lte !== undefined: { + return DateTime.fromISO(value) <= DateTime.fromISO(dateFilter.lte); + } + default: { + throw new Error( + `Unexpected value for string filter : ${JSON.stringify(dateFilter)}`, + ); + } + } +}; diff --git a/packages/twenty-front/src/modules/object-record/record-filter/utils/isMatchingFloatFilter.spec.ts b/packages/twenty-front/src/modules/object-record/record-filter/utils/isMatchingFloatFilter.spec.ts new file mode 100644 index 000000000..98a6e7c5d --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-filter/utils/isMatchingFloatFilter.spec.ts @@ -0,0 +1,118 @@ +import { isMatchingFloatFilter } from '@/object-record/record-filter/utils/isMatchingFloatFilter'; + +describe('isMatchingFloatFilter', () => { + describe('eq', () => { + it('value equals eq filter', () => { + expect( + isMatchingFloatFilter({ floatFilter: { eq: 10 }, value: 10 }), + ).toBe(true); + }); + + it('value does not equal eq filter', () => { + expect( + isMatchingFloatFilter({ floatFilter: { eq: 10 }, value: 20 }), + ).toBe(false); + }); + }); + + describe('neq', () => { + it('value does not equal neq filter', () => { + expect( + isMatchingFloatFilter({ floatFilter: { neq: 10 }, value: 20 }), + ).toBe(true); + }); + + it('value equals neq filter', () => { + expect( + isMatchingFloatFilter({ floatFilter: { neq: 10 }, value: 10 }), + ).toBe(false); + }); + }); + + describe('gt', () => { + it('value is greater than gt filter', () => { + expect( + isMatchingFloatFilter({ floatFilter: { gt: 10 }, value: 20 }), + ).toBe(true); + }); + + it('value is not greater than gt filter', () => { + expect( + isMatchingFloatFilter({ floatFilter: { gt: 20 }, value: 10 }), + ).toBe(false); + }); + }); + + describe('gte', () => { + it('value is greater than or equal to gte filter', () => { + expect( + isMatchingFloatFilter({ floatFilter: { gte: 10 }, value: 10 }), + ).toBe(true); + }); + + it('value is not greater than or equal to gte filter', () => { + expect( + isMatchingFloatFilter({ floatFilter: { gte: 20 }, value: 10 }), + ).toBe(false); + }); + }); + + describe('lt', () => { + it('value is less than lt filter', () => { + expect( + isMatchingFloatFilter({ floatFilter: { lt: 20 }, value: 10 }), + ).toBe(true); + }); + + it('value is not less than lt filter', () => { + expect( + isMatchingFloatFilter({ floatFilter: { lt: 10 }, value: 20 }), + ).toBe(false); + }); + }); + + describe('lte', () => { + it('value is less than or equal to lte filter', () => { + expect( + isMatchingFloatFilter({ floatFilter: { lte: 10 }, value: 10 }), + ).toBe(true); + }); + + it('value is not less than or equal to lte filter', () => { + expect( + isMatchingFloatFilter({ floatFilter: { lte: 10 }, value: 20 }), + ).toBe(false); + }); + }); + + describe('in', () => { + it('value is in the array', () => { + expect( + isMatchingFloatFilter({ floatFilter: { in: [10, 20, 30] }, value: 20 }), + ).toBe(true); + }); + + it('value is not in the array', () => { + expect( + isMatchingFloatFilter({ floatFilter: { in: [10, 30, 40] }, value: 20 }), + ).toBe(false); + }); + }); + + describe('is', () => { + it('value is NULL', () => { + expect( + isMatchingFloatFilter({ + floatFilter: { is: 'NULL' }, + value: null as any, + }), + ).toBe(true); + }); + + it('value is NOT_NULL', () => { + expect( + isMatchingFloatFilter({ floatFilter: { is: 'NOT_NULL' }, value: 10 }), + ).toBe(true); + }); + }); +}); diff --git a/packages/twenty-front/src/modules/object-record/record-filter/utils/isMatchingFloatFilter.ts b/packages/twenty-front/src/modules/object-record/record-filter/utils/isMatchingFloatFilter.ts new file mode 100644 index 000000000..ddf6c2fb8 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-filter/utils/isMatchingFloatFilter.ts @@ -0,0 +1,45 @@ +import { FloatFilter } from '@/object-record/record-filter/types/ObjectRecordQueryFilter'; + +export const isMatchingFloatFilter = ({ + floatFilter, + value, +}: { + floatFilter: FloatFilter; + value: number; +}) => { + switch (true) { + case floatFilter.eq !== undefined: { + return value === floatFilter.eq; + } + case floatFilter.neq !== undefined: { + return value !== floatFilter.neq; + } + case floatFilter.gt !== undefined: { + return value > floatFilter.gt; + } + case floatFilter.gte !== undefined: { + return value >= floatFilter.gte; + } + case floatFilter.lt !== undefined: { + return value < floatFilter.lt; + } + case floatFilter.lte !== undefined: { + return value <= floatFilter.lte; + } + case floatFilter.in !== undefined: { + return floatFilter.in.includes(value); + } + case floatFilter.is !== undefined: { + if (floatFilter.is === 'NULL') { + return value === null; + } else { + return value !== null; + } + } + default: { + throw new Error( + `Unexpected value for float filter : ${JSON.stringify(floatFilter)}`, + ); + } + } +}; diff --git a/packages/twenty-front/src/modules/object-record/record-filter/utils/isMatchingStringFilter.spec.ts b/packages/twenty-front/src/modules/object-record/record-filter/utils/isMatchingStringFilter.spec.ts new file mode 100644 index 000000000..b6949ac98 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-filter/utils/isMatchingStringFilter.spec.ts @@ -0,0 +1,236 @@ +import { isMatchingStringFilter } from '@/object-record/record-filter/utils/isMatchingStringFilter'; + +describe('isMatchingStringFilter', () => { + describe('eq', () => { + it('value equals eq filter', () => { + expect( + isMatchingStringFilter({ stringFilter: { eq: 'test' }, value: 'test' }), + ).toBe(true); + }); + + it('value does not equals eq filter', () => { + expect( + isMatchingStringFilter({ + stringFilter: { eq: 'test' }, + value: 'other', + }), + ).toBe(false); + }); + }); + + describe('neq', () => { + it('value does not equal neq filter', () => { + expect( + isMatchingStringFilter({ + stringFilter: { neq: 'test' }, + value: 'other', + }), + ).toBe(true); + }); + + it('value equals neq filter', () => { + expect( + isMatchingStringFilter({ + stringFilter: { neq: 'test' }, + value: 'test', + }), + ).toBe(false); + }); + }); + + describe('like', () => { + it('value matches like pattern', () => { + expect( + isMatchingStringFilter({ + stringFilter: { like: 'te%' }, + value: 'test', + }), + ).toBe(true); + }); + + it('value does not match like pattern', () => { + expect( + isMatchingStringFilter({ + stringFilter: { like: 'ab%' }, + value: 'test', + }), + ).toBe(false); + }); + }); + + describe('ilike', () => { + it('value matches ilike pattern case insensitively', () => { + expect( + isMatchingStringFilter({ + stringFilter: { ilike: 'TE%' }, + value: 'test', + }), + ).toBe(true); + }); + + it('value does not match ilike pattern', () => { + expect( + isMatchingStringFilter({ + stringFilter: { ilike: 'AB%' }, + value: 'test', + }), + ).toBe(false); + }); + }); + + describe('in', () => { + it('value is in the array', () => { + expect( + isMatchingStringFilter({ + stringFilter: { in: ['test', 'example'] }, + value: 'test', + }), + ).toBe(true); + }); + + it('value is not in the array', () => { + expect( + isMatchingStringFilter({ + stringFilter: { in: ['example', 'sample'] }, + value: 'test', + }), + ).toBe(false); + }); + }); + + describe('is', () => { + it('value is NULL', () => { + expect( + isMatchingStringFilter({ + stringFilter: { is: 'NULL' }, + value: null as any, + }), + ).toBe(true); + }); + + it('value is NOT_NULL', () => { + expect( + isMatchingStringFilter({ + stringFilter: { is: 'NOT_NULL' }, + value: 'test', + }), + ).toBe(true); + }); + }); + + describe('regex', () => { + it('value matches regex pattern', () => { + expect( + isMatchingStringFilter({ + stringFilter: { regex: '^test$' }, + value: 'test', + }), + ).toBe(true); + }); + + it('value does not match regex pattern', () => { + expect( + isMatchingStringFilter({ + stringFilter: { regex: '^test$' }, + value: 'testing', + }), + ).toBe(false); + }); + }); + + describe('iregex', () => { + it('value matches iregex pattern case insensitively', () => { + expect( + isMatchingStringFilter({ + stringFilter: { iregex: '^test$' }, + value: 'Test', + }), + ).toBe(true); + }); + + it('value does not match iregex pattern', () => { + expect( + isMatchingStringFilter({ + stringFilter: { iregex: '^test$' }, + value: 'testing', + }), + ).toBe(false); + }); + }); + + describe('gt', () => { + it('value is greater than gt filter', () => { + expect( + isMatchingStringFilter({ stringFilter: { gt: 'a' }, value: 'b' }), + ).toBe(true); + }); + + it('value is not greater than gt filter', () => { + expect( + isMatchingStringFilter({ stringFilter: { gt: 'b' }, value: 'a' }), + ).toBe(false); + }); + }); + + describe('gte', () => { + it('value is greater than or equal to gte filter', () => { + expect( + isMatchingStringFilter({ stringFilter: { gte: 'a' }, value: 'a' }), + ).toBe(true); + }); + + it('value is not greater than or equal to gte filter', () => { + expect( + isMatchingStringFilter({ stringFilter: { gte: 'b' }, value: 'a' }), + ).toBe(false); + }); + }); + + describe('lt', () => { + it('value is less than lt filter', () => { + expect( + isMatchingStringFilter({ stringFilter: { lt: 'b' }, value: 'a' }), + ).toBe(true); + }); + + it('value is not less than lt filter', () => { + expect( + isMatchingStringFilter({ stringFilter: { lt: 'a' }, value: 'b' }), + ).toBe(false); + }); + }); + + describe('lte', () => { + it('value is less than or equal to lte filter', () => { + expect( + isMatchingStringFilter({ stringFilter: { lte: 'a' }, value: 'a' }), + ).toBe(true); + }); + + it('value is not less than or equal to lte filter', () => { + expect( + isMatchingStringFilter({ stringFilter: { lte: 'a' }, value: 'b' }), + ).toBe(false); + }); + }); + + describe('startsWith', () => { + it('value starts with the startsWith filter', () => { + expect( + isMatchingStringFilter({ + stringFilter: { startsWith: 'te' }, + value: 'test', + }), + ).toBe(true); + }); + + it('value does not start with the startsWith filter', () => { + expect( + isMatchingStringFilter({ + stringFilter: { startsWith: 'st' }, + value: 'test', + }), + ).toBe(false); + }); + }); +}); diff --git a/packages/twenty-front/src/modules/object-record/record-filter/utils/isMatchingStringFilter.ts b/packages/twenty-front/src/modules/object-record/record-filter/utils/isMatchingStringFilter.ts new file mode 100644 index 000000000..164266d8e --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-filter/utils/isMatchingStringFilter.ts @@ -0,0 +1,72 @@ +import { StringFilter } from '@/object-record/record-filter/types/ObjectRecordQueryFilter'; + +export const isMatchingStringFilter = ({ + stringFilter, + value, +}: { + stringFilter: StringFilter; + value: string; +}) => { + switch (true) { + case stringFilter.eq !== undefined: { + return value === stringFilter.eq; + } + case stringFilter.neq !== undefined: { + return value !== stringFilter.neq; + } + case stringFilter.like !== undefined: { + const regexPattern = stringFilter.like.replace(/%/g, '.*'); + const regexCaseSensitive = new RegExp(`^${regexPattern}$`); + + return regexCaseSensitive.test(value); + } + case stringFilter.ilike !== undefined: { + const regexPattern = stringFilter.ilike.replace(/%/g, '.*'); + const regexCaseInsensitive = new RegExp(`^${regexPattern}$`, 'i'); + + return regexCaseInsensitive.test(value); + } + case stringFilter.in !== undefined: { + return stringFilter.in.includes(value); + } + case stringFilter.is !== undefined: { + if (stringFilter.is === 'NULL') { + return value === null; + } else { + return value !== null; + } + } + case stringFilter.regex !== undefined: { + const regexPattern = stringFilter.regex; + const regexCaseSensitive = new RegExp(regexPattern); + + return regexCaseSensitive.test(value); + } + case stringFilter.iregex !== undefined: { + const regexPattern = stringFilter.iregex; + const regexCaseInsensitive = new RegExp(regexPattern, 'i'); + + return regexCaseInsensitive.test(value); + } + case stringFilter.gt !== undefined: { + return value > stringFilter.gt; + } + case stringFilter.gte !== undefined: { + return value >= stringFilter.gte; + } + case stringFilter.lt !== undefined: { + return value < stringFilter.lt; + } + case stringFilter.lte !== undefined: { + return value <= stringFilter.lte; + } + case stringFilter.startsWith !== undefined: { + return value.startsWith(stringFilter.startsWith); + } + default: { + throw new Error( + `Unexpected value for string filter : ${JSON.stringify(stringFilter)}`, + ); + } + } +}; diff --git a/packages/twenty-front/src/modules/object-record/record-filter/utils/isMatchingUUIDFilter.spec.ts b/packages/twenty-front/src/modules/object-record/record-filter/utils/isMatchingUUIDFilter.spec.ts new file mode 100644 index 000000000..bbe18c1fb --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-filter/utils/isMatchingUUIDFilter.spec.ts @@ -0,0 +1,82 @@ +import { isMatchingUUIDFilter } from '@/object-record/record-filter/utils/isMatchingUUIDFilter'; + +describe('isMatchingUUIDFilter', () => { + const testUUID = '123e4567-e89b-12d3-a456-426655440000'; + + describe('eq', () => { + it('value equals eq filter', () => { + expect( + isMatchingUUIDFilter({ uuidFilter: { eq: testUUID }, value: testUUID }), + ).toBe(true); + }); + + it('value does not equal eq filter', () => { + expect( + isMatchingUUIDFilter({ + uuidFilter: { eq: testUUID }, + value: 'different-uuid', + }), + ).toBe(false); + }); + }); + + describe('neq', () => { + it('value does not equal neq filter', () => { + expect( + isMatchingUUIDFilter({ + uuidFilter: { neq: testUUID }, + value: 'different-uuid', + }), + ).toBe(true); + }); + + it('value equals neq filter', () => { + expect( + isMatchingUUIDFilter({ + uuidFilter: { neq: testUUID }, + value: testUUID, + }), + ).toBe(false); + }); + }); + + describe('in', () => { + it('value is in the array', () => { + expect( + isMatchingUUIDFilter({ + uuidFilter: { in: [testUUID, 'another-uuid'] }, + value: testUUID, + }), + ).toBe(true); + }); + + it('value is not in the array', () => { + expect( + isMatchingUUIDFilter({ + uuidFilter: { in: ['another-uuid', 'yet-another-uuid'] }, + value: testUUID, + }), + ).toBe(false); + }); + }); + + describe('is', () => { + it('value is NULL', () => { + expect( + isMatchingUUIDFilter({ + uuidFilter: { is: 'NULL' }, + value: null as any, + }), + ).toBe(true); + }); + + it('value is NOT_NULL', () => { + expect( + isMatchingUUIDFilter({ + uuidFilter: { is: 'NOT_NULL' }, + value: testUUID, + }), + ).toBe(true); + }); + }); +}); diff --git a/packages/twenty-front/src/modules/object-record/record-filter/utils/isMatchingUUIDFilter.ts b/packages/twenty-front/src/modules/object-record/record-filter/utils/isMatchingUUIDFilter.ts new file mode 100644 index 000000000..3c5cfb0bd --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-filter/utils/isMatchingUUIDFilter.ts @@ -0,0 +1,36 @@ +import { + UUIDFilter, + UUIDFilterValue, +} from '@/object-record/record-filter/types/ObjectRecordQueryFilter'; + +export const isMatchingUUIDFilter = ({ + uuidFilter, + value, +}: { + uuidFilter: UUIDFilter; + value: UUIDFilterValue; +}) => { + switch (true) { + case uuidFilter.eq !== undefined: { + return value === uuidFilter.eq; + } + case uuidFilter.neq !== undefined: { + return value !== uuidFilter.neq; + } + case uuidFilter.in !== undefined: { + return uuidFilter.in.includes(value); + } + case uuidFilter.is !== undefined: { + if (uuidFilter.is === 'NULL') { + return value === null; + } else { + return value !== null; + } + } + default: { + throw new Error( + `Unexpected value for string filter : ${JSON.stringify(uuidFilter)}`, + ); + } + } +}; diff --git a/packages/twenty-front/src/modules/object-record/record-filter/utils/isRecordMatchingFilter.spec.ts b/packages/twenty-front/src/modules/object-record/record-filter/utils/isRecordMatchingFilter.spec.ts new file mode 100644 index 000000000..bdfe17707 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-filter/utils/isRecordMatchingFilter.spec.ts @@ -0,0 +1,344 @@ +import { ObjectRecordQueryFilter } from '@/object-record/record-filter/types/ObjectRecordQueryFilter'; +import { mockedCompaniesData } from '~/testing/mock-data/companies'; +import { mockObjectMetadataItem } from '~/testing/mock-data/objectMetadataItems'; + +import { isRecordMatchingFilter } from './isRecordMatchingFilter'; + +describe('isRecordMatchingFilter', () => { + describe('Empty Filters', () => { + it('matches any record when no filter is provided', () => { + const emptyFilter = {}; + + mockedCompaniesData.forEach((company) => { + expect( + isRecordMatchingFilter({ + record: company, + filter: emptyFilter, + objectMetadataItem: mockObjectMetadataItem, + }), + ).toBe(true); + }); + }); + + it('matches any record when filter fields are empty', () => { + const filterWithEmptyFields = { + name: {}, + employees: {}, + }; + + mockedCompaniesData.forEach((company) => { + expect( + isRecordMatchingFilter({ + record: company, + filter: filterWithEmptyFields, + objectMetadataItem: mockObjectMetadataItem, + }), + ).toBe(true); + }); + }); + + it('matches any record with an empty and filter', () => { + const filter = { and: [] }; + + mockedCompaniesData.forEach((company) => { + expect( + isRecordMatchingFilter({ + record: company, + filter, + objectMetadataItem: mockObjectMetadataItem, + }), + ).toBe(true); + }); + }); + + it('matches any record with an empty or filter', () => { + const filter = { or: [] }; + + mockedCompaniesData.forEach((company) => { + expect( + isRecordMatchingFilter({ + record: company, + filter, + objectMetadataItem: mockObjectMetadataItem, + }), + ).toBe(true); + }); + }); + + it('matches any record with an empty not filter', () => { + const filter = { not: {} }; + + mockedCompaniesData.forEach((company) => { + expect( + isRecordMatchingFilter({ + record: company, + filter, + objectMetadataItem: mockObjectMetadataItem, + }), + ).toBe(true); + }); + }); + }); + + describe('Simple Filters', () => { + it('matches a record with a simple equality filter on name', () => { + const filter = { name: { eq: 'Airbnb' } }; + + expect( + isRecordMatchingFilter({ + record: mockedCompaniesData[0], + filter, + objectMetadataItem: mockObjectMetadataItem, + }), + ).toBe(true); + + expect( + isRecordMatchingFilter({ + record: mockedCompaniesData[1], + filter, + objectMetadataItem: mockObjectMetadataItem, + }), + ).toBe(false); + }); + + it('matches a record with a simple equality filter on domainName', () => { + const filter = { domainName: { eq: 'airbnb.com' } }; + + expect( + isRecordMatchingFilter({ + record: mockedCompaniesData[0], + filter, + objectMetadataItem: mockObjectMetadataItem, + }), + ).toBe(true); + expect( + isRecordMatchingFilter({ + record: mockedCompaniesData[1], + filter, + objectMetadataItem: mockObjectMetadataItem, + }), + ).toBe(false); + }); + + it('matches a record with a greater than filter on employees', () => { + const filter = { employees: { gt: 10 } }; + + expect( + isRecordMatchingFilter({ + record: mockedCompaniesData[0], + filter, + objectMetadataItem: mockObjectMetadataItem, + }), + ).toBe(true); + expect( + isRecordMatchingFilter({ + record: mockedCompaniesData[1], + filter, + objectMetadataItem: mockObjectMetadataItem, + }), + ).toBe(false); + }); + + it('matches a record with a boolean filter on idealCustomerProfile', () => { + const filter = { idealCustomerProfile: { eq: true } }; + + expect( + isRecordMatchingFilter({ + record: mockedCompaniesData[0], + filter, + objectMetadataItem: mockObjectMetadataItem, + }), + ).toBe(true); + expect( + isRecordMatchingFilter({ + record: mockedCompaniesData[4], // Assuming this record has idealCustomerProfile as false + filter, + objectMetadataItem: mockObjectMetadataItem, + }), + ).toBe(false); + }); + }); + + describe('Complex And/Or/Not Nesting', () => { + it('matches record with a combination of and + or filters', () => { + const filter: ObjectRecordQueryFilter = { + and: [ + { domainName: { eq: 'airbnb.com' } }, + { + or: [ + { employees: { gt: 10 } }, + { idealCustomerProfile: { eq: true } }, + ], + }, + ], + }; + + expect( + isRecordMatchingFilter({ + record: mockedCompaniesData[0], // Airbnb + filter, + objectMetadataItem: mockObjectMetadataItem, + }), + ).toBe(true); + + expect( + isRecordMatchingFilter({ + record: mockedCompaniesData[1], // Aircall + filter, + objectMetadataItem: mockObjectMetadataItem, + }), + ).toBe(false); + }); + + it('matches record with nested not filter', () => { + const filter: ObjectRecordQueryFilter = { + not: { + and: [ + { name: { eq: 'Airbnb' } }, + { idealCustomerProfile: { eq: true } }, + ], + }, + }; + + expect( + isRecordMatchingFilter({ + record: mockedCompaniesData[0], // Airbnb + filter, + objectMetadataItem: mockObjectMetadataItem, + }), + ).toBe(false); // Should not match as it's Airbnb with idealCustomerProfile true + + expect( + isRecordMatchingFilter({ + record: mockedCompaniesData[3], // Apple + filter, + objectMetadataItem: mockObjectMetadataItem, + }), + ).toBe(true); // Should match as it's not Airbnb + }); + + it('matches record with deep nesting of and, or, and not filters', () => { + const filter: ObjectRecordQueryFilter = { + and: [ + { domainName: { eq: 'apple.com' } }, + { + or: [{ employees: { eq: 10 } }, { not: { name: { eq: 'Apple' } } }], + }, + ], + }; + + expect( + isRecordMatchingFilter({ + record: mockedCompaniesData[3], // Apple + filter, + objectMetadataItem: mockObjectMetadataItem, + }), + ).toBe(true); + + expect( + isRecordMatchingFilter({ + record: mockedCompaniesData[4], // Qonto + filter, + objectMetadataItem: mockObjectMetadataItem, + }), + ).toBe(false); + }); + + it('matches record with and filter at root level', () => { + const filter: ObjectRecordQueryFilter = { + and: [ + { name: { eq: 'Facebook' } }, + { idealCustomerProfile: { eq: true } }, + ], + }; + + expect( + isRecordMatchingFilter({ + record: mockedCompaniesData[5], // Facebook + filter, + objectMetadataItem: mockObjectMetadataItem, + }), + ).toBe(true); + + expect( + isRecordMatchingFilter({ + record: mockedCompaniesData[0], // Airbnb + filter, + objectMetadataItem: mockObjectMetadataItem, + }), + ).toBe(false); + }); + + it('matches record with or filter at root level including a not condition', () => { + const filter: ObjectRecordQueryFilter = { + or: [{ name: { eq: 'Sequoia' } }, { not: { employees: { eq: 1 } } }], + }; + + expect( + isRecordMatchingFilter({ + record: mockedCompaniesData[6], // Sequoia + filter, + objectMetadataItem: mockObjectMetadataItem, + }), + ).toBe(true); + + expect( + isRecordMatchingFilter({ + record: mockedCompaniesData[1], // Aircall + filter, + objectMetadataItem: mockObjectMetadataItem, + }), + ).toBe(false); + }); + }); + + describe('Implicit And Conditions', () => { + it('matches record with implicit and of multiple operators within the same field', () => { + const filter = { + employees: { gt: 10, lt: 100000 }, + name: { eq: 'Airbnb' }, + }; + + expect( + isRecordMatchingFilter({ + record: mockedCompaniesData[0], // Airbnb + filter, + objectMetadataItem: mockObjectMetadataItem, + }), + ).toBe(true); // Matches as Airbnb's employee count is between 10 and 100000 + + expect( + isRecordMatchingFilter({ + record: mockedCompaniesData[1], // Aircall + filter, + objectMetadataItem: mockObjectMetadataItem, + }), + ).toBe(false); // Does not match as Aircall's employee count is not within the range + }); + + it('matches record with implicit and within an object passed to or', () => { + const filter = { + or: { + name: { eq: 'Airbnb' }, + domainName: { eq: 'airbnb.com' }, + }, + }; + + expect( + isRecordMatchingFilter({ + record: mockedCompaniesData[0], // Airbnb + filter, + objectMetadataItem: mockObjectMetadataItem, + }), + ).toBe(true); + + expect( + isRecordMatchingFilter({ + record: mockedCompaniesData[2], // Algolia + filter, + objectMetadataItem: mockObjectMetadataItem, + }), + ).toBe(false); + }); + }); +}); diff --git a/packages/twenty-front/src/modules/object-record/record-filter/utils/isRecordMatchingFilter.ts b/packages/twenty-front/src/modules/object-record/record-filter/utils/isRecordMatchingFilter.ts new file mode 100644 index 000000000..336297442 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-filter/utils/isRecordMatchingFilter.ts @@ -0,0 +1,269 @@ +import { isObject } from '@sniptt/guards'; + +import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { + AndObjectRecordFilter, + BooleanFilter, + DateFilter, + FloatFilter, + FullNameFilter, + LeafObjectRecordFilter, + NotObjectRecordFilter, + ObjectRecordQueryFilter, + OrObjectRecordFilter, + StringFilter, + URLFilter, + UUIDFilter, +} from '@/object-record/record-filter/types/ObjectRecordQueryFilter'; +import { isMatchingBooleanFilter } from '@/object-record/record-filter/utils/isMatchingBooleanFilter'; +import { isMatchingDateFilter } from '@/object-record/record-filter/utils/isMatchingDateFilter'; +import { isMatchingFloatFilter } from '@/object-record/record-filter/utils/isMatchingFloatFilter'; +import { isMatchingStringFilter } from '@/object-record/record-filter/utils/isMatchingStringFilter'; +import { isMatchingUUIDFilter } from '@/object-record/record-filter/utils/isMatchingUUIDFilter'; +import { FieldMetadataType } from '~/generated-metadata/graphql'; +import { isDefined } from '~/utils/isDefined'; +import { isEmptyObject } from '~/utils/isEmptyObject'; + +export const isRecordMatchingFilter = ({ + record, + filter, + objectMetadataItem, +}: { + record: any; + filter: ObjectRecordQueryFilter; + objectMetadataItem: ObjectMetadataItem; +}) => { + if (Object.keys(filter).length === 0) { + return true; + } + + const currentLevelFilterMatches: boolean[] = []; + + // We consider all the keys at the same level as an "and" + for (const filterKey in filter) { + if (filterKey === 'and') { + const filterValue = (filter as AndObjectRecordFilter).and; + + if (!Array.isArray(filterValue)) { + throw new Error( + 'Unexpected value for "and" filter : ' + JSON.stringify(filterValue), + ); + } + + if (filterValue.length === 0) { + currentLevelFilterMatches.push(true); + continue; + } + + const recordIsMatchingAndFilters = filterValue.every((andFilter) => + isRecordMatchingFilter({ + record, + filter: andFilter, + objectMetadataItem, + }), + ); + + currentLevelFilterMatches.push(recordIsMatchingAndFilters); + } else if (filterKey === 'or') { + const filterValue = (filter as OrObjectRecordFilter).or; + + if (Array.isArray(filterValue)) { + if (filterValue.length === 0) { + currentLevelFilterMatches.push(true); + continue; + } + + const recordIsMatchingOrFilters = filterValue.some((orFilter) => + isRecordMatchingFilter({ + record, + filter: orFilter, + objectMetadataItem, + }), + ); + + currentLevelFilterMatches.push(recordIsMatchingOrFilters); + } else if (isObject(filterValue)) { + // The API considers "or" with an object as an "and" + const recordIsMatchingOrFilters = isRecordMatchingFilter({ + record, + filter: filterValue, + objectMetadataItem, + }); + + currentLevelFilterMatches.push(recordIsMatchingOrFilters); + } else { + throw new Error('Unexpected value for "or" filter : ' + filterValue); + } + } else if (filterKey === 'not') { + const filterValue = (filter as NotObjectRecordFilter).not; + + if (!isDefined(filterValue)) { + throw new Error('Unexpected value for "not" filter : ' + filterValue); + } + + if (isEmptyObject(filterValue)) { + currentLevelFilterMatches.push(true); + continue; + } + + const recordIsMatchingNotFilters = !isRecordMatchingFilter({ + record, + filter: filterValue, + objectMetadataItem, + }); + + currentLevelFilterMatches.push(recordIsMatchingNotFilters); + } else { + const filterValue = (filter as LeafObjectRecordFilter)[filterKey]; + + if (!isDefined(filterValue)) { + throw new Error( + 'Unexpected value for filter key "' + + filterKey + + '" : ' + + filterValue, + ); + } + + if (isEmptyObject(filterValue)) { + currentLevelFilterMatches.push(true); + + continue; + } + + const objectMetadataField = objectMetadataItem.fields.find( + (field) => field.name === filterKey, + ); + + if (!isDefined(objectMetadataField)) { + throw new Error( + 'Field metadata item "' + + filterKey + + '" not found for object metadata item ' + + objectMetadataItem.nameSingular, + ); + } + + switch (objectMetadataField.type) { + case FieldMetadataType.Email: + case FieldMetadataType.Phone: + case FieldMetadataType.Text: { + const stringFilter = filterValue as StringFilter; + + currentLevelFilterMatches.push( + isMatchingStringFilter({ + stringFilter, + value: record[filterKey], + }), + ); + break; + } + case FieldMetadataType.Link: { + const urlFilter = filterValue as URLFilter; + + if (urlFilter.url !== undefined) { + currentLevelFilterMatches.push( + isMatchingStringFilter({ + stringFilter: urlFilter.url, + value: record[filterKey].url, + }), + ); + } + + if (urlFilter.label !== undefined) { + currentLevelFilterMatches.push( + isMatchingStringFilter({ + stringFilter: urlFilter.label, + value: record[filterKey].label, + }), + ); + } + break; + } + case FieldMetadataType.FullName: { + const fullNameFilter = filterValue as FullNameFilter; + + if (fullNameFilter.firstName !== undefined) { + currentLevelFilterMatches.push( + isMatchingStringFilter({ + stringFilter: fullNameFilter.firstName, + value: record[filterKey].firstName, + }), + ); + } + + if (fullNameFilter.lastName !== undefined) { + currentLevelFilterMatches.push( + isMatchingStringFilter({ + stringFilter: fullNameFilter.lastName, + value: record[filterKey].lastName, + }), + ); + } + break; + } + case FieldMetadataType.DateTime: { + const dateFilter = filterValue as DateFilter; + + currentLevelFilterMatches.push( + isMatchingDateFilter({ + dateFilter, + value: record[filterKey], + }), + ); + break; + } + case FieldMetadataType.Number: + case FieldMetadataType.Numeric: { + const numberFilter = filterValue as FloatFilter; + + currentLevelFilterMatches.push( + isMatchingFloatFilter({ + floatFilter: numberFilter, + value: record[filterKey], + }), + ); + break; + } + case FieldMetadataType.Uuid: { + const uuidFilter = filterValue as UUIDFilter; + + currentLevelFilterMatches.push( + isMatchingUUIDFilter({ + uuidFilter, + value: record[filterKey], + }), + ); + break; + } + case FieldMetadataType.Boolean: { + const booleanFilter = filterValue as BooleanFilter; + + currentLevelFilterMatches.push( + isMatchingBooleanFilter({ + booleanFilter, + value: record[filterKey], + }), + ); + break; + } + case FieldMetadataType.Relation: { + throw new Error( + `Not implemented yet, use UUID filter instead on the corredponding "${filterKey}Id" field`, + ); + } + case FieldMetadataType.Currency: + case FieldMetadataType.MultiSelect: + case FieldMetadataType.Select: + case FieldMetadataType.Probability: + case FieldMetadataType.Rating: { + throw new Error('Not implemented yet'); + } + } + } + } + + return currentLevelFilterMatches.length > 0 + ? currentLevelFilterMatches.every((match) => !!match) + : false; +}; diff --git a/packages/twenty-front/src/modules/object-record/utils/turnFiltersIntoWhereClause.ts b/packages/twenty-front/src/modules/object-record/record-filter/utils/turnObjectDropdownFilterIntoQueryFilter.ts similarity index 92% rename from packages/twenty-front/src/modules/object-record/utils/turnFiltersIntoWhereClause.ts rename to packages/twenty-front/src/modules/object-record/record-filter/utils/turnObjectDropdownFilterIntoQueryFilter.ts index 6bce33af2..249c09a39 100644 --- a/packages/twenty-front/src/modules/object-record/utils/turnFiltersIntoWhereClause.ts +++ b/packages/twenty-front/src/modules/object-record/record-filter/utils/turnObjectDropdownFilterIntoQueryFilter.ts @@ -1,28 +1,31 @@ +import { isNonEmptyString } from '@sniptt/guards'; + import { CurrencyFilter, DateFilter, FloatFilter, FullNameFilter, - ObjectRecordFilter, + ObjectRecordQueryFilter, StringFilter, URLFilter, -} from '@/object-record/types/ObjectRecordFilter'; + UUIDFilter, +} from '@/object-record/record-filter/types/ObjectRecordQueryFilter'; import { ViewFilterOperand } from '@/views/types/ViewFilterOperand'; import { Field } from '~/generated/graphql'; -import { Filter } from '../object-filter-dropdown/types/Filter'; +import { Filter } from '../../object-filter-dropdown/types/Filter'; -export type RawUIFilter = Omit & { +export type ObjectDropdownFilter = Omit & { definition: { type: Filter['definition']['type']; }; }; -export const turnFiltersIntoObjectRecordFilters = ( - rawUIFilters: RawUIFilter[], +export const turnObjectDropdownFilterIntoQueryFilter = ( + rawUIFilters: ObjectDropdownFilter[], fields: Pick[], -): ObjectRecordFilter => { - const objectRecordFilters: ObjectRecordFilter[] = []; +): ObjectRecordQueryFilter => { + const objectRecordFilters: ObjectRecordQueryFilter[] = []; for (const rawUIFilter of rawUIFilters) { const correspondingField = fields.find( @@ -107,6 +110,10 @@ export const turnFiltersIntoObjectRecordFilters = ( } break; case 'RELATION': { + if (!isNonEmptyString(rawUIFilter.value)) { + break; + } + try { JSON.parse(rawUIFilter.value); } catch (e) { @@ -123,7 +130,7 @@ export const turnFiltersIntoObjectRecordFilters = ( objectRecordFilters.push({ [correspondingField.name + 'Id']: { in: parsedRecordIds, - } as StringFilter, + } as UUIDFilter, }); break; case ViewFilterOperand.IsNot: @@ -131,7 +138,7 @@ export const turnFiltersIntoObjectRecordFilters = ( not: { [correspondingField.name + 'Id']: { in: parsedRecordIds, - } as StringFilter, + } as UUIDFilter, }, }); break; diff --git a/packages/twenty-front/src/modules/object-record/types/ObjectRecordQueryVariables.ts b/packages/twenty-front/src/modules/object-record/types/ObjectRecordQueryVariables.ts new file mode 100644 index 000000000..99e4956ae --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/types/ObjectRecordQueryVariables.ts @@ -0,0 +1,8 @@ +import { OrderByField } from '@/object-metadata/types/OrderByField'; +import { ObjectRecordQueryFilter } from '@/object-record/record-filter/types/ObjectRecordQueryFilter'; + +export type ObjectRecordQueryVariables = { + filter?: ObjectRecordQueryFilter; + orderBy?: OrderByField; + limit?: number; +}; diff --git a/packages/twenty-front/src/modules/views/components/ViewBarFilterEffect.tsx b/packages/twenty-front/src/modules/views/components/ViewBarFilterEffect.tsx index 5733d33e4..1686762c3 100644 --- a/packages/twenty-front/src/modules/views/components/ViewBarFilterEffect.tsx +++ b/packages/twenty-front/src/modules/views/components/ViewBarFilterEffect.tsx @@ -1,4 +1,5 @@ import { useEffect } from 'react'; +import { isNonEmptyString } from '@sniptt/guards'; import { useRecoilValue } from 'recoil'; import { useFilterDropdown } from '@/object-record/object-filter-dropdown/hooks/useFilterDropdown'; @@ -53,9 +54,11 @@ export const ViewBarFilterEffect = ({ filterDefinitionUsedInDropdown.fieldMetadataId, ); - const viewFilterSelectedRecordIds = JSON.parse( - viewFilterUsedInDropdown?.value ?? '[]', - ); + const viewFilterSelectedRecordIds = isNonEmptyString( + viewFilterUsedInDropdown?.value, + ) + ? JSON.parse(viewFilterUsedInDropdown.value) + : []; setObjectFilterDropdownSelectedRecordIds(viewFilterSelectedRecordIds); } diff --git a/packages/twenty-front/src/testing/mock-data/objectMetadataItems.ts b/packages/twenty-front/src/testing/mock-data/objectMetadataItems.ts new file mode 100644 index 000000000..203f48149 --- /dev/null +++ b/packages/twenty-front/src/testing/mock-data/objectMetadataItems.ts @@ -0,0 +1,417 @@ +import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { FieldMetadataType, RelationMetadataType } from '~/generated/graphql'; + +export const mockObjectMetadataItem: ObjectMetadataItem = { + __typename: 'object', + id: 'b79a038c-b06b-4a5a-b7ee-f8ba412aa1c0', + nameSingular: 'company', + namePlural: 'companies', + labelSingular: 'Company', + labelPlural: 'Companies', + description: 'A company', + icon: 'IconBuildingSkyscraper', + isCustom: false, + isActive: true, + isSystem: false, + createdAt: '2023-12-19T12:15:28.459Z', + updatedAt: '2023-12-19T12:15:28.459Z', + labelIdentifierFieldMetadataId: null, + imageIdentifierFieldMetadataId: null, + fields: [ + { + __typename: 'field', + id: '390eb5e5-d8d1-4064-bf75-3461251eb142', + type: FieldMetadataType.Boolean, + name: 'idealCustomerProfile', + label: 'ICP', + description: + 'Ideal Customer Profile: Indicates whether the company is the most suitable and valuable customer for you', + icon: 'IconTarget', + isCustom: false, + isActive: true, + isSystem: false, + isNullable: true, + createdAt: '2023-12-19T12:15:28.459Z', + updatedAt: '2023-12-19T12:15:28.459Z', + fromRelationMetadata: null, + toRelationMetadata: null, + defaultValue: null, + }, + { + __typename: 'field', + id: '72a43010-f236-4fa2-8ac4-a31e6b37d692', + type: FieldMetadataType.Relation, + name: 'people', + label: 'People', + description: 'People linked to the company.', + icon: 'IconUsers', + isCustom: false, + isActive: true, + isSystem: false, + isNullable: true, + createdAt: '2023-12-19T12:15:28.459Z', + updatedAt: '2023-12-19T12:15:28.459Z', + fromRelationMetadata: { + id: 'f08943fe-e8a0-4747-951c-c3b391842453', + relationType: RelationMetadataType.OneToMany, + toObjectMetadata: { + id: 'fcccc985-5edf-405c-aa2b-80c82b230f35', + nameSingular: 'person', + namePlural: 'people', + }, + toFieldMetadataId: 'c756f6ff-8c00-4fe5-a923-c6cfc7b1ac4a', + }, + toRelationMetadata: null, + defaultValue: null, + }, + { + __typename: 'field', + id: '51636fba-1bd9-4344-bba8-9639cbc8e134', + type: FieldMetadataType.Relation, + name: 'opportunities', + label: 'Opportunities', + description: 'Opportunities linked to the company.', + icon: 'IconTargetArrow', + isCustom: false, + isActive: true, + isSystem: false, + isNullable: true, + createdAt: '2023-12-19T12:15:28.459Z', + updatedAt: '2023-12-19T12:15:28.459Z', + fromRelationMetadata: { + id: '7ffae8bb-b12b-4ad9-8922-da0d517b5612', + relationType: RelationMetadataType.OneToMany, + toObjectMetadata: { + id: '169e5b21-dc95-44a8-acd0-5e9447dd0784', + nameSingular: 'opportunity', + namePlural: 'opportunities', + }, + toFieldMetadataId: '00468e2a-a601-4635-ae9c-a9bb826cc860', + }, + toRelationMetadata: null, + defaultValue: null, + }, + { + __typename: 'field', + id: 'd541f76b-d327-4dda-8ef8-81b60e5ad01e', + type: FieldMetadataType.Relation, + name: 'activityTargets', + label: 'Activities', + description: 'Activities tied to the company', + icon: 'IconCheckbox', + isCustom: false, + isActive: true, + isSystem: false, + isNullable: true, + createdAt: '2023-12-19T12:15:28.459Z', + updatedAt: '2023-12-19T12:15:28.459Z', + fromRelationMetadata: { + id: 'bc42672b-350f-45c3-bd1f-4debb536ccd1', + relationType: RelationMetadataType.OneToMany, + toObjectMetadata: { + id: 'b87c6cac-a8e7-4156-a525-30ec536acd75', + nameSingular: 'activityTarget', + namePlural: 'activityTargets', + }, + toFieldMetadataId: 'bba19feb-c248-487b-92d7-98df54c51e44', + }, + toRelationMetadata: null, + defaultValue: null, + }, + { + __typename: 'field', + id: 'dacb7562-497e-4080-8ef5-746d6786ed49', + type: FieldMetadataType.DateTime, + name: 'createdAt', + label: 'Creation date', + description: null, + icon: 'IconCalendar', + isCustom: false, + isActive: true, + isSystem: false, + isNullable: false, + createdAt: '2023-12-19T12:15:28.459Z', + updatedAt: '2023-12-19T12:15:28.459Z', + fromRelationMetadata: null, + toRelationMetadata: null, + defaultValue: { + type: 'now', + }, + }, + { + __typename: 'field', + id: 'f3b4ff22-800b-4f13-8262-8003da8eed5b', + type: FieldMetadataType.Number, + name: 'employees', + label: 'Employees', + description: 'Number of employees in the company', + icon: 'IconUsers', + isCustom: false, + isActive: true, + isSystem: false, + isNullable: true, + createdAt: '2023-12-19T12:15:28.459Z', + updatedAt: '2023-12-19T12:15:28.459Z', + fromRelationMetadata: null, + toRelationMetadata: null, + defaultValue: null, + }, + { + __typename: 'field', + id: 'c3e64012-32cc-43f1-af2f-33b37cc4e59d', + type: FieldMetadataType.Link, + name: 'linkedinLink', + label: 'Linkedin', + description: 'The company Linkedin account', + icon: 'IconBrandLinkedin', + isCustom: false, + isActive: true, + isSystem: false, + isNullable: true, + createdAt: '2023-12-19T12:15:28.459Z', + updatedAt: '2023-12-19T12:15:28.459Z', + fromRelationMetadata: null, + toRelationMetadata: null, + defaultValue: null, + }, + { + __typename: 'field', + id: 'fced9acc-0374-487d-9da4-579a17435df0', + type: FieldMetadataType.Link, + name: 'xLink', + label: 'X', + description: 'The company Twitter/X account', + icon: 'IconBrandX', + isCustom: false, + isActive: true, + isSystem: false, + isNullable: true, + createdAt: '2023-12-19T12:15:28.459Z', + updatedAt: '2023-12-19T12:15:28.459Z', + fromRelationMetadata: null, + toRelationMetadata: null, + defaultValue: null, + }, + { + __typename: 'field', + id: '63db0a2f-ffb4-4ea1-98c7-f7e13ce75c38', + type: FieldMetadataType.Relation, + name: 'attachments', + label: 'Attachments', + description: 'Attachments linked to the company.', + icon: 'IconFileImport', + isCustom: false, + isActive: true, + isSystem: false, + isNullable: true, + createdAt: '2023-12-19T12:15:28.459Z', + updatedAt: '2023-12-19T12:15:28.459Z', + fromRelationMetadata: { + id: '901fd405-c6bf-4559-9d1f-d0937b6f16d9', + relationType: RelationMetadataType.OneToMany, + toObjectMetadata: { + id: '77240b4b-6bcf-454d-a102-19bbba181716', + nameSingular: 'attachment', + namePlural: 'attachments', + }, + toFieldMetadataId: '0880dac5-37d2-43a6-b143-722126d4923f', + }, + toRelationMetadata: null, + defaultValue: null, + }, + { + __typename: 'field', + id: 'e775ce12-87c0-4feb-bcfe-9af3d8ca117b', + type: FieldMetadataType.Uuid, + name: 'id', + label: 'Id', + description: null, + icon: null, + isCustom: false, + isActive: true, + isSystem: true, + isNullable: false, + createdAt: '2023-12-19T12:15:28.459Z', + updatedAt: '2023-12-19T12:15:28.459Z', + fromRelationMetadata: null, + toRelationMetadata: null, + defaultValue: { + type: 'uuid', + }, + }, + { + __typename: 'field', + id: '2278ef91-3d6a-45cf-86f5-76b7bfa2bf32', + type: FieldMetadataType.Text, + name: 'domainName', + label: 'Domain Name', + description: + 'The company website URL. We use this url to fetch the company icon', + icon: 'IconLink', + isCustom: false, + isActive: true, + isSystem: false, + isNullable: true, + createdAt: '2023-12-19T12:15:28.459Z', + updatedAt: '2023-12-19T12:15:28.459Z', + fromRelationMetadata: null, + toRelationMetadata: null, + defaultValue: { + value: '', + }, + }, + { + __typename: 'field', + id: '438291d7-18f4-48cf-8dca-05e96c5a0765', + type: FieldMetadataType.Currency, + name: 'annualRecurringRevenue', + label: 'ARR', + description: + 'Annual Recurring Revenue: The actual or estimated annual revenue of the company', + icon: 'IconMoneybag', + isCustom: false, + isActive: true, + isSystem: false, + isNullable: true, + createdAt: '2023-12-19T12:15:28.459Z', + updatedAt: '2023-12-19T12:15:28.459Z', + fromRelationMetadata: null, + toRelationMetadata: null, + defaultValue: null, + }, + { + __typename: 'field', + id: 'edb8475f-03fc-4ac1-9305-e9d4e2dacd11', + type: FieldMetadataType.DateTime, + name: 'updatedAt', + label: 'Update date', + description: null, + icon: 'IconCalendar', + isCustom: false, + isActive: true, + isSystem: true, + isNullable: false, + createdAt: '2023-12-19T12:15:28.459Z', + updatedAt: '2023-12-19T12:15:28.459Z', + fromRelationMetadata: null, + toRelationMetadata: null, + defaultValue: { + type: 'now', + }, + }, + { + __typename: 'field', + id: 'e3c9ba7f-cecf-4ac6-a7b9-7a9987be0253', + type: FieldMetadataType.Relation, + name: 'accountOwner', + label: 'Account Owner', + description: + 'Your team member responsible for managing the company account', + icon: 'IconUserCircle', + isCustom: false, + isActive: true, + isSystem: false, + isNullable: true, + createdAt: '2023-12-19T12:15:28.459Z', + updatedAt: '2023-12-19T12:15:28.459Z', + fromRelationMetadata: null, + toRelationMetadata: { + id: '0317d74c-5187-491f-9e1d-d22f06ca2a38', + relationType: RelationMetadataType.OneToMany, + fromObjectMetadata: { + id: '92c306ce-ad06-4712-99d2-5d0daf13c95f', + nameSingular: 'workspaceMember', + namePlural: 'workspaceMembers', + }, + fromFieldMetadataId: '0f3e456f-3bb4-4261-a436-95246dc0e159', + }, + defaultValue: null, + }, + { + __typename: 'field', + id: 'a34bd3b3-6949-4793-bac6-d2c054639c7f', + type: FieldMetadataType.Text, + name: 'address', + label: 'Address', + description: 'The company address', + icon: 'IconMap', + isCustom: false, + isActive: true, + isSystem: false, + isNullable: true, + createdAt: '2023-12-19T12:15:28.459Z', + updatedAt: '2023-12-19T12:15:28.459Z', + fromRelationMetadata: null, + toRelationMetadata: null, + defaultValue: { + value: '', + }, + }, + { + __typename: 'field', + id: '4b204845-f1fc-4fd8-8fdd-f4caeaab749f', + type: FieldMetadataType.Relation, + name: 'favorites', + label: 'Favorites', + description: 'Favorites linked to the company', + icon: 'IconHeart', + isCustom: false, + isActive: true, + isSystem: false, + isNullable: true, + createdAt: '2023-12-19T12:15:28.459Z', + updatedAt: '2023-12-19T12:15:28.459Z', + fromRelationMetadata: { + id: '8e0d3aa1-6135-4d65-aa28-15a5b6d1619c', + relationType: RelationMetadataType.OneToMany, + toObjectMetadata: { + id: '1415392e-0ecb-462e-aa67-001e424e6a37', + nameSingular: 'favorite', + namePlural: 'favorites', + }, + toFieldMetadataId: '8fd8965b-bd4e-4a9b-90e9-c75652dadda1', + }, + toRelationMetadata: null, + defaultValue: null, + }, + { + __typename: 'field', + id: 'a795e81e-0bcf-4fd6-8f2f-b3764b990d2d', + type: FieldMetadataType.Uuid, + name: 'accountOwnerId', + label: 'Account Owner id (foreign key)', + description: + 'Your team member responsible for managing the company account id foreign key', + icon: 'IconUserCircle', + isCustom: false, + isActive: true, + isSystem: true, + isNullable: true, + createdAt: '2023-12-19T12:15:28.459Z', + updatedAt: '2023-12-19T12:15:28.459Z', + fromRelationMetadata: null, + toRelationMetadata: null, + defaultValue: null, + }, + { + __typename: 'field', + id: '87887d23-f632-4d3e-840a-02fcee960660', + type: FieldMetadataType.Text, + name: 'name', + label: 'Name', + description: 'The company name', + icon: 'IconBuildingSkyscraper', + isCustom: false, + isActive: true, + isSystem: false, + isNullable: false, + createdAt: '2023-12-19T12:15:28.459Z', + updatedAt: '2023-12-19T12:15:28.459Z', + fromRelationMetadata: null, + toRelationMetadata: null, + defaultValue: { + value: '', + }, + }, + ], +}; diff --git a/packages/twenty-front/src/utils/isAnObject.ts b/packages/twenty-front/src/utils/isAnObject.ts new file mode 100644 index 000000000..019e7ca20 --- /dev/null +++ b/packages/twenty-front/src/utils/isAnObject.ts @@ -0,0 +1,3 @@ +export const isAnObject = (obj: any): obj is object => { + return typeof obj === 'object' && obj !== null && Object.keys(obj).length > 0; +}; diff --git a/packages/twenty-front/src/utils/isEmptyObject.ts b/packages/twenty-front/src/utils/isEmptyObject.ts new file mode 100644 index 000000000..c263b79b9 --- /dev/null +++ b/packages/twenty-front/src/utils/isEmptyObject.ts @@ -0,0 +1,5 @@ +import { isObject } from '@sniptt/guards'; + +export const isEmptyObject = (obj: any): obj is object => { + return isObject(obj) && Object.keys(obj).length === 0; +};