diff --git a/packages/twenty-front/src/modules/object-record/multiple-objects/hooks/useGenerateCombinedSearchRecordsQuery.ts b/packages/twenty-front/src/modules/object-record/multiple-objects/hooks/useGenerateCombinedSearchRecordsQuery.ts new file mode 100644 index 000000000000..fc7725c3ae90 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/multiple-objects/hooks/useGenerateCombinedSearchRecordsQuery.ts @@ -0,0 +1,96 @@ +import { gql } from '@apollo/client'; +import { isUndefined } from '@sniptt/guards'; +import { useRecoilValue } from 'recoil'; + +import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState'; +import { mapObjectMetadataToGraphQLQuery } from '@/object-metadata/utils/mapObjectMetadataToGraphQLQuery'; +import { RecordGqlOperationSignature } from '@/object-record/graphql/types/RecordGqlOperationSignature'; +import { generateDepthOneRecordGqlFields } from '@/object-record/graphql/utils/generateDepthOneRecordGqlFields'; +import { getSearchRecordsQueryResponseField } from '@/object-record/utils/getSearchRecordsQueryResponseField'; +import { isObjectMetadataItemSearchable } from '@/object-record/utils/isObjectMetadataItemSearchable'; +import { isNonEmptyArray } from '~/utils/isNonEmptyArray'; +import { capitalize } from '~/utils/string/capitalize'; + +export const useGenerateCombinedSearchRecordsQuery = ({ + operationSignatures, +}: { + operationSignatures: RecordGqlOperationSignature[]; +}) => { + const objectMetadataItems = useRecoilValue(objectMetadataItemsState); + + if (!isNonEmptyArray(operationSignatures)) { + return null; + } + + const filterPerMetadataItemArray = operationSignatures + .map( + ({ objectNameSingular }) => + `$filter${capitalize(objectNameSingular)}: ${capitalize( + objectNameSingular, + )}FilterInput`, + ) + .join(', '); + + const limitPerMetadataItemArray = operationSignatures + .map( + ({ objectNameSingular }) => + `$limit${capitalize(objectNameSingular)}: Int`, + ) + .join(', '); + + const queryKeyWithObjectMetadataItemArray = operationSignatures.map( + (queryKey) => { + const objectMetadataItem = objectMetadataItems.find( + (objectMetadataItem) => + objectMetadataItem.nameSingular === queryKey.objectNameSingular, + ); + + if (isUndefined(objectMetadataItem)) { + throw new Error( + `Object metadata item not found for object name singular: ${queryKey.objectNameSingular}`, + ); + } + + return { ...queryKey, objectMetadataItem }; + }, + ); + + const filteredQueryKeyWithObjectMetadataItemArray = + queryKeyWithObjectMetadataItemArray.filter(({ objectMetadataItem }) => + isObjectMetadataItemSearchable(objectMetadataItem), + ); + + return gql` + query CombinedSearchRecords( + ${filterPerMetadataItemArray}, + ${limitPerMetadataItemArray}, + $search: String, + ) { + ${filteredQueryKeyWithObjectMetadataItemArray + .map( + ({ objectMetadataItem, fields }) => + `${getSearchRecordsQueryResponseField(objectMetadataItem.namePlural)}(filter: $filter${capitalize( + objectMetadataItem.nameSingular, + )}, + limit: $limit${capitalize(objectMetadataItem.nameSingular)}, + searchInput: $search + ){ + edges { + node ${mapObjectMetadataToGraphQLQuery({ + objectMetadataItems: objectMetadataItems, + objectMetadataItem, + recordGqlFields: + fields ?? + generateDepthOneRecordGqlFields({ + objectMetadataItem, + }), + })} + cursor + } + totalCount + }`, + ) + .join('\n')} + } + `; +}; 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 d42b2338b315..b69ef1f40c6d 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 @@ -4,18 +4,32 @@ import { useRecoilValue } from 'recoil'; import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState'; import { EMPTY_QUERY } from '@/object-record/constants/EmptyQuery'; -import { useGenerateCombinedFindManyRecordsQuery } from '@/object-record/multiple-objects/hooks/useGenerateCombinedFindManyRecordsQuery'; +import { useGenerateCombinedSearchRecordsQuery } from '@/object-record/multiple-objects/hooks/useGenerateCombinedSearchRecordsQuery'; import { useLimitPerMetadataItem } from '@/object-record/relation-picker/hooks/useLimitPerMetadataItem'; import { MultiObjectRecordQueryResult, useMultiObjectRecordsQueryResultFormattedAsObjectRecordForSelectArray, } from '@/object-record/relation-picker/hooks/useMultiObjectRecordsQueryResultFormattedAsObjectRecordForSelectArray'; 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 { useMemo } from 'react'; import { isDefined } from '~/utils/isDefined'; import { capitalize } from '~/utils/string/capitalize'; +export const formatSearchResults = ( + searchResults: MultiObjectRecordQueryResult | undefined, +): MultiObjectRecordQueryResult => { + if (!searchResults) { + return {}; + } + + return Object.entries(searchResults).reduce((acc, [key, value]) => { + let newKey = key.replace(/^search/, ''); + newKey = newKey.charAt(0).toLowerCase() + newKey.slice(1); + acc[newKey] = value; + return acc; + }, {} as MultiObjectRecordQueryResult); +}; + export const useMultiObjectSearchMatchesSearchFilterAndSelectedItemsQuery = ({ selectedObjectRecordIds, searchFilterValue, @@ -27,18 +41,14 @@ export const useMultiObjectSearchMatchesSearchFilterAndSelectedItemsQuery = ({ }) => { const objectMetadataItems = useRecoilValue(objectMetadataItemsState); - const { searchFilterPerMetadataItemNameSingular } = - useSearchFilterPerMetadataItem({ - objectMetadataItems, - searchFilterValue, - }); - - const objectMetadataItemsUsedInSelectedIdsQuery = objectMetadataItems.filter( - ({ nameSingular }) => { - return selectedObjectRecordIds.some(({ objectNameSingular }) => { - return objectNameSingular === nameSingular; - }); - }, + const objectMetadataItemsUsedInSelectedIdsQuery = useMemo( + () => + objectMetadataItems.filter(({ nameSingular }) => { + return selectedObjectRecordIds.some(({ objectNameSingular }) => { + return objectNameSingular === nameSingular; + }); + }), + [objectMetadataItems, selectedObjectRecordIds], ); const selectedAndMatchesSearchFilterTextFilterPerMetadataItem = @@ -53,38 +63,25 @@ export const useMultiObjectSearchMatchesSearchFilterAndSelectedItemsQuery = ({ if (!isNonEmptyArray(selectedIds)) return null; - const searchFilter = - searchFilterPerMetadataItemNameSingular[nameSingular] ?? {}; return [ `filter${capitalize(nameSingular)}`, { - and: [ - { - ...searchFilter, - }, - { - id: { - in: selectedIds, - }, - }, - ], + id: { + in: selectedIds, + }, }, ]; }) .filter(isDefined), ); - const { orderByFieldPerMetadataItem } = useOrderByFieldPerMetadataItem({ - objectMetadataItems: objectMetadataItemsUsedInSelectedIdsQuery, - }); - const { limitPerMetadataItem } = useLimitPerMetadataItem({ objectMetadataItems: objectMetadataItemsUsedInSelectedIdsQuery, limit, }); - const multiSelectQueryForSelectedIds = - useGenerateCombinedFindManyRecordsQuery({ + const multiSelectSearchQueryForSelectedIds = + useGenerateCombinedSearchRecordsQuery({ operationSignatures: objectMetadataItemsUsedInSelectedIdsQuery.map( (objectMetadataItem) => ({ objectNameSingular: objectMetadataItem.nameSingular, @@ -97,22 +94,23 @@ export const useMultiObjectSearchMatchesSearchFilterAndSelectedItemsQuery = ({ loading: selectedAndMatchesSearchFilterObjectRecordsLoading, data: selectedAndMatchesSearchFilterObjectRecordsQueryResult, } = useQuery( - multiSelectQueryForSelectedIds ?? EMPTY_QUERY, + multiSelectSearchQueryForSelectedIds ?? EMPTY_QUERY, { variables: { + search: searchFilterValue, ...selectedAndMatchesSearchFilterTextFilterPerMetadataItem, - ...orderByFieldPerMetadataItem, ...limitPerMetadataItem, }, - skip: !isDefined(multiSelectQueryForSelectedIds), + skip: !isDefined(multiSelectSearchQueryForSelectedIds), }, ); const { objectRecordForSelectArray: selectedAndMatchesSearchFilterObjectRecords, } = useMultiObjectRecordsQueryResultFormattedAsObjectRecordForSelectArray({ - multiObjectRecordsQueryResult: + multiObjectRecordsQueryResult: formatSearchResults( selectedAndMatchesSearchFilterObjectRecordsQueryResult, + ), }); return { 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 607eef1806de..c3150cd44ca8 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 @@ -4,15 +4,15 @@ import { useRecoilValue } from 'recoil'; import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState'; import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; import { EMPTY_QUERY } from '@/object-record/constants/EmptyQuery'; -import { useGenerateCombinedFindManyRecordsQuery } from '@/object-record/multiple-objects/hooks/useGenerateCombinedFindManyRecordsQuery'; +import { useGenerateCombinedSearchRecordsQuery } from '@/object-record/multiple-objects/hooks/useGenerateCombinedSearchRecordsQuery'; import { useLimitPerMetadataItem } from '@/object-record/relation-picker/hooks/useLimitPerMetadataItem'; import { MultiObjectRecordQueryResult, useMultiObjectRecordsQueryResultFormattedAsObjectRecordForSelectArray, } from '@/object-record/relation-picker/hooks/useMultiObjectRecordsQueryResultFormattedAsObjectRecordForSelectArray'; 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 { formatSearchResults } from '@/object-record/relation-picker/hooks/useMultiObjectSearchMatchesSearchFilterAndSelectedItemsQuery'; +import { isObjectMetadataItemSearchableInCombinedRequest } from '@/object-record/utils/isObjectMetadataItemSearchableInCombinedRequest'; import { makeAndFilterVariables } from '@/object-record/utils/makeAndFilterVariables'; import { isDefined } from '~/utils/isDefined'; import { capitalize } from '~/utils/string/capitalize'; @@ -36,13 +36,10 @@ export const useMultiObjectSearchMatchesSearchFilterAndToSelectQuery = ({ .filter(({ isSystem, isRemote }) => !isSystem && !isRemote) .filter(({ nameSingular }) => { return !excludedObjects?.includes(nameSingular as CoreObjectNameSingular); - }); - - const { searchFilterPerMetadataItemNameSingular } = - useSearchFilterPerMetadataItem({ - objectMetadataItems: selectableObjectMetadataItems, - searchFilterValue, - }); + }) + .filter((object) => + isObjectMetadataItemSearchableInCombinedRequest(object), + ); const objectRecordsToSelectAndMatchesSearchFilterTextFilterPerMetadataItem = Object.fromEntries( @@ -65,29 +62,19 @@ export const useMultiObjectSearchMatchesSearchFilterAndToSelectQuery = ({ ? { not: { id: { in: excludedIdsUnion } } } : undefined; - const searchFilters = [ - searchFilterPerMetadataItemNameSingular[nameSingular], - excludedIdsFilter, - ]; - return [ `filter${capitalize(nameSingular)}`, - makeAndFilterVariables(searchFilters), + makeAndFilterVariables([excludedIdsFilter]), ]; }) .filter(isDefined), ); - - const { orderByFieldPerMetadataItem } = useOrderByFieldPerMetadataItem({ - objectMetadataItems: selectableObjectMetadataItems, - }); - const { limitPerMetadataItem } = useLimitPerMetadataItem({ objectMetadataItems: selectableObjectMetadataItems, limit, }); - const multiSelectQuery = useGenerateCombinedFindManyRecordsQuery({ + const multiSelectQuery = useGenerateCombinedSearchRecordsQuery({ operationSignatures: selectableObjectMetadataItems.map( (objectMetadataItem) => ({ objectNameSingular: objectMetadataItem.nameSingular, @@ -101,8 +88,8 @@ export const useMultiObjectSearchMatchesSearchFilterAndToSelectQuery = ({ data: toSelectAndMatchesSearchFilterObjectRecordsQueryResult, } = useQuery(multiSelectQuery ?? EMPTY_QUERY, { variables: { + search: searchFilterValue, ...objectRecordsToSelectAndMatchesSearchFilterTextFilterPerMetadataItem, - ...orderByFieldPerMetadataItem, ...limitPerMetadataItem, }, skip: !isDefined(multiSelectQuery), @@ -111,8 +98,9 @@ export const useMultiObjectSearchMatchesSearchFilterAndToSelectQuery = ({ const { objectRecordForSelectArray: toSelectAndMatchesSearchFilterObjectRecords, } = useMultiObjectRecordsQueryResultFormattedAsObjectRecordForSelectArray({ - multiObjectRecordsQueryResult: + multiObjectRecordsQueryResult: formatSearchResults( toSelectAndMatchesSearchFilterObjectRecordsQueryResult, + ), }); return { diff --git a/packages/twenty-front/src/modules/object-record/relation-picker/hooks/useSearchFilterPerMetadataItem.ts b/packages/twenty-front/src/modules/object-record/relation-picker/hooks/useSearchFilterPerMetadataItem.ts deleted file mode 100644 index a4822dea14a6..000000000000 --- a/packages/twenty-front/src/modules/object-record/relation-picker/hooks/useSearchFilterPerMetadataItem.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { isNonEmptyString } from '@sniptt/guards'; - -import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; -import { getLabelIdentifierFieldMetadataItem } from '@/object-metadata/utils/getLabelIdentifierFieldMetadataItem'; -import { RecordGqlOperationFilter } from '@/object-record/graphql/types/RecordGqlOperationFilter'; -import { makeOrFilterVariables } from '@/object-record/utils/makeOrFilterVariables'; -import { FieldMetadataType } from '~/generated/graphql'; -import { generateILikeFiltersForCompositeFields } from '~/utils/array/generateILikeFiltersForCompositeFields'; -import { isDefined } from '~/utils/isDefined'; - -export const useSearchFilterPerMetadataItem = ({ - objectMetadataItems, - searchFilterValue, -}: { - objectMetadataItems: ObjectMetadataItem[]; - searchFilterValue: string; -}) => { - const searchFilterPerMetadataItemNameSingular = - Object.fromEntries( - objectMetadataItems - .map((objectMetadataItem) => { - if (searchFilterValue === '') return null; - - const labelIdentifierFieldMetadataItem = - getLabelIdentifierFieldMetadataItem(objectMetadataItem); - - let searchFilter: RecordGqlOperationFilter = {}; - - if (isDefined(labelIdentifierFieldMetadataItem)) { - switch (labelIdentifierFieldMetadataItem.type) { - case FieldMetadataType.FullName: { - if (isNonEmptyString(searchFilterValue)) { - const compositeFilter = makeOrFilterVariables( - generateILikeFiltersForCompositeFields( - searchFilterValue, - labelIdentifierFieldMetadataItem.name, - ['firstName', 'lastName'], - ), - ); - - if (isDefined(compositeFilter)) { - searchFilter = compositeFilter; - } - } - break; - } - default: { - if (isNonEmptyString(searchFilterValue)) { - searchFilter = { - [labelIdentifierFieldMetadataItem.name]: { - ilike: `%${searchFilterValue}%`, - }, - }; - } - } - } - } - - return [objectMetadataItem.nameSingular, searchFilter] as const; - }) - .filter(isDefined), - ); - - return { - searchFilterPerMetadataItemNameSingular, - }; -}; diff --git a/packages/twenty-front/src/modules/object-record/utils/isObjectMetadataItemSearchable.ts b/packages/twenty-front/src/modules/object-record/utils/isObjectMetadataItemSearchable.ts new file mode 100644 index 000000000000..21bb1b2510e4 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/utils/isObjectMetadataItemSearchable.ts @@ -0,0 +1,17 @@ +import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; + +const SEARCHABLE_STANDARD_OBJECTS_NAMES_PLURAL = [ + 'companies', + 'people', + 'opportunities', +]; +export const isObjectMetadataItemSearchable = ( + objectMetadataItem: ObjectMetadataItem, +) => { + return ( + objectMetadataItem.isCustom || + SEARCHABLE_STANDARD_OBJECTS_NAMES_PLURAL.includes( + objectMetadataItem.namePlural, + ) + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/utils/isObjectMetadataItemSearchableInCombinedRequest.ts b/packages/twenty-front/src/modules/object-record/utils/isObjectMetadataItemSearchableInCombinedRequest.ts new file mode 100644 index 000000000000..7b16a87ecbb5 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/utils/isObjectMetadataItemSearchableInCombinedRequest.ts @@ -0,0 +1,17 @@ +import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; + +const SEARCHABLE_STANDARD_OBJECTS_IN_COMBINED_REQUEST_NAMES_PLURAL = [ + 'companies', + 'people', + 'opportunities', +]; +export const isObjectMetadataItemSearchableInCombinedRequest = ( + objectMetadataItem: ObjectMetadataItem, +) => { + return ( + objectMetadataItem.isCustom || + SEARCHABLE_STANDARD_OBJECTS_IN_COMBINED_REQUEST_NAMES_PLURAL.includes( + objectMetadataItem.namePlural, + ) + ); +};