Use search in multi object pickers (#7909)

Fixes https://github.com/twentyhq/twenty/issues/3298.
We still have some existing glitches in the picker yet to fix.

---------

Co-authored-by: Weiko <corentin@twenty.com>
This commit is contained in:
Marie
2024-10-24 13:43:57 +02:00
committed by GitHub
parent 67fb750ef6
commit c7bc301dba
6 changed files with 177 additions and 128 deletions

View File

@ -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')}
}
`;
};

View File

@ -4,18 +4,32 @@ import { useRecoilValue } from 'recoil';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState'; import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { EMPTY_QUERY } from '@/object-record/constants/EmptyQuery'; 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 { useLimitPerMetadataItem } from '@/object-record/relation-picker/hooks/useLimitPerMetadataItem';
import { import {
MultiObjectRecordQueryResult, MultiObjectRecordQueryResult,
useMultiObjectRecordsQueryResultFormattedAsObjectRecordForSelectArray, useMultiObjectRecordsQueryResultFormattedAsObjectRecordForSelectArray,
} from '@/object-record/relation-picker/hooks/useMultiObjectRecordsQueryResultFormattedAsObjectRecordForSelectArray'; } from '@/object-record/relation-picker/hooks/useMultiObjectRecordsQueryResultFormattedAsObjectRecordForSelectArray';
import { SelectedObjectRecordId } from '@/object-record/relation-picker/hooks/useMultiObjectSearch'; import { SelectedObjectRecordId } from '@/object-record/relation-picker/hooks/useMultiObjectSearch';
import { useOrderByFieldPerMetadataItem } from '@/object-record/relation-picker/hooks/useOrderByFieldPerMetadataItem'; import { useMemo } from 'react';
import { useSearchFilterPerMetadataItem } from '@/object-record/relation-picker/hooks/useSearchFilterPerMetadataItem';
import { isDefined } from '~/utils/isDefined'; import { isDefined } from '~/utils/isDefined';
import { capitalize } from '~/utils/string/capitalize'; 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 = ({ export const useMultiObjectSearchMatchesSearchFilterAndSelectedItemsQuery = ({
selectedObjectRecordIds, selectedObjectRecordIds,
searchFilterValue, searchFilterValue,
@ -27,18 +41,14 @@ export const useMultiObjectSearchMatchesSearchFilterAndSelectedItemsQuery = ({
}) => { }) => {
const objectMetadataItems = useRecoilValue(objectMetadataItemsState); const objectMetadataItems = useRecoilValue(objectMetadataItemsState);
const { searchFilterPerMetadataItemNameSingular } = const objectMetadataItemsUsedInSelectedIdsQuery = useMemo(
useSearchFilterPerMetadataItem({ () =>
objectMetadataItems, objectMetadataItems.filter(({ nameSingular }) => {
searchFilterValue, return selectedObjectRecordIds.some(({ objectNameSingular }) => {
}); return objectNameSingular === nameSingular;
});
const objectMetadataItemsUsedInSelectedIdsQuery = objectMetadataItems.filter( }),
({ nameSingular }) => { [objectMetadataItems, selectedObjectRecordIds],
return selectedObjectRecordIds.some(({ objectNameSingular }) => {
return objectNameSingular === nameSingular;
});
},
); );
const selectedAndMatchesSearchFilterTextFilterPerMetadataItem = const selectedAndMatchesSearchFilterTextFilterPerMetadataItem =
@ -53,38 +63,25 @@ export const useMultiObjectSearchMatchesSearchFilterAndSelectedItemsQuery = ({
if (!isNonEmptyArray(selectedIds)) return null; if (!isNonEmptyArray(selectedIds)) return null;
const searchFilter =
searchFilterPerMetadataItemNameSingular[nameSingular] ?? {};
return [ return [
`filter${capitalize(nameSingular)}`, `filter${capitalize(nameSingular)}`,
{ {
and: [ id: {
{ in: selectedIds,
...searchFilter, },
},
{
id: {
in: selectedIds,
},
},
],
}, },
]; ];
}) })
.filter(isDefined), .filter(isDefined),
); );
const { orderByFieldPerMetadataItem } = useOrderByFieldPerMetadataItem({
objectMetadataItems: objectMetadataItemsUsedInSelectedIdsQuery,
});
const { limitPerMetadataItem } = useLimitPerMetadataItem({ const { limitPerMetadataItem } = useLimitPerMetadataItem({
objectMetadataItems: objectMetadataItemsUsedInSelectedIdsQuery, objectMetadataItems: objectMetadataItemsUsedInSelectedIdsQuery,
limit, limit,
}); });
const multiSelectQueryForSelectedIds = const multiSelectSearchQueryForSelectedIds =
useGenerateCombinedFindManyRecordsQuery({ useGenerateCombinedSearchRecordsQuery({
operationSignatures: objectMetadataItemsUsedInSelectedIdsQuery.map( operationSignatures: objectMetadataItemsUsedInSelectedIdsQuery.map(
(objectMetadataItem) => ({ (objectMetadataItem) => ({
objectNameSingular: objectMetadataItem.nameSingular, objectNameSingular: objectMetadataItem.nameSingular,
@ -97,22 +94,23 @@ export const useMultiObjectSearchMatchesSearchFilterAndSelectedItemsQuery = ({
loading: selectedAndMatchesSearchFilterObjectRecordsLoading, loading: selectedAndMatchesSearchFilterObjectRecordsLoading,
data: selectedAndMatchesSearchFilterObjectRecordsQueryResult, data: selectedAndMatchesSearchFilterObjectRecordsQueryResult,
} = useQuery<MultiObjectRecordQueryResult>( } = useQuery<MultiObjectRecordQueryResult>(
multiSelectQueryForSelectedIds ?? EMPTY_QUERY, multiSelectSearchQueryForSelectedIds ?? EMPTY_QUERY,
{ {
variables: { variables: {
search: searchFilterValue,
...selectedAndMatchesSearchFilterTextFilterPerMetadataItem, ...selectedAndMatchesSearchFilterTextFilterPerMetadataItem,
...orderByFieldPerMetadataItem,
...limitPerMetadataItem, ...limitPerMetadataItem,
}, },
skip: !isDefined(multiSelectQueryForSelectedIds), skip: !isDefined(multiSelectSearchQueryForSelectedIds),
}, },
); );
const { const {
objectRecordForSelectArray: selectedAndMatchesSearchFilterObjectRecords, objectRecordForSelectArray: selectedAndMatchesSearchFilterObjectRecords,
} = useMultiObjectRecordsQueryResultFormattedAsObjectRecordForSelectArray({ } = useMultiObjectRecordsQueryResultFormattedAsObjectRecordForSelectArray({
multiObjectRecordsQueryResult: multiObjectRecordsQueryResult: formatSearchResults(
selectedAndMatchesSearchFilterObjectRecordsQueryResult, selectedAndMatchesSearchFilterObjectRecordsQueryResult,
),
}); });
return { return {

View File

@ -4,15 +4,15 @@ import { useRecoilValue } from 'recoil';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState'; import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { EMPTY_QUERY } from '@/object-record/constants/EmptyQuery'; 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 { useLimitPerMetadataItem } from '@/object-record/relation-picker/hooks/useLimitPerMetadataItem';
import { import {
MultiObjectRecordQueryResult, MultiObjectRecordQueryResult,
useMultiObjectRecordsQueryResultFormattedAsObjectRecordForSelectArray, useMultiObjectRecordsQueryResultFormattedAsObjectRecordForSelectArray,
} from '@/object-record/relation-picker/hooks/useMultiObjectRecordsQueryResultFormattedAsObjectRecordForSelectArray'; } from '@/object-record/relation-picker/hooks/useMultiObjectRecordsQueryResultFormattedAsObjectRecordForSelectArray';
import { SelectedObjectRecordId } from '@/object-record/relation-picker/hooks/useMultiObjectSearch'; import { SelectedObjectRecordId } from '@/object-record/relation-picker/hooks/useMultiObjectSearch';
import { useOrderByFieldPerMetadataItem } from '@/object-record/relation-picker/hooks/useOrderByFieldPerMetadataItem'; import { formatSearchResults } from '@/object-record/relation-picker/hooks/useMultiObjectSearchMatchesSearchFilterAndSelectedItemsQuery';
import { useSearchFilterPerMetadataItem } from '@/object-record/relation-picker/hooks/useSearchFilterPerMetadataItem'; import { isObjectMetadataItemSearchableInCombinedRequest } from '@/object-record/utils/isObjectMetadataItemSearchableInCombinedRequest';
import { makeAndFilterVariables } from '@/object-record/utils/makeAndFilterVariables'; import { makeAndFilterVariables } from '@/object-record/utils/makeAndFilterVariables';
import { isDefined } from '~/utils/isDefined'; import { isDefined } from '~/utils/isDefined';
import { capitalize } from '~/utils/string/capitalize'; import { capitalize } from '~/utils/string/capitalize';
@ -36,13 +36,10 @@ export const useMultiObjectSearchMatchesSearchFilterAndToSelectQuery = ({
.filter(({ isSystem, isRemote }) => !isSystem && !isRemote) .filter(({ isSystem, isRemote }) => !isSystem && !isRemote)
.filter(({ nameSingular }) => { .filter(({ nameSingular }) => {
return !excludedObjects?.includes(nameSingular as CoreObjectNameSingular); return !excludedObjects?.includes(nameSingular as CoreObjectNameSingular);
}); })
.filter((object) =>
const { searchFilterPerMetadataItemNameSingular } = isObjectMetadataItemSearchableInCombinedRequest(object),
useSearchFilterPerMetadataItem({ );
objectMetadataItems: selectableObjectMetadataItems,
searchFilterValue,
});
const objectRecordsToSelectAndMatchesSearchFilterTextFilterPerMetadataItem = const objectRecordsToSelectAndMatchesSearchFilterTextFilterPerMetadataItem =
Object.fromEntries( Object.fromEntries(
@ -65,29 +62,19 @@ export const useMultiObjectSearchMatchesSearchFilterAndToSelectQuery = ({
? { not: { id: { in: excludedIdsUnion } } } ? { not: { id: { in: excludedIdsUnion } } }
: undefined; : undefined;
const searchFilters = [
searchFilterPerMetadataItemNameSingular[nameSingular],
excludedIdsFilter,
];
return [ return [
`filter${capitalize(nameSingular)}`, `filter${capitalize(nameSingular)}`,
makeAndFilterVariables(searchFilters), makeAndFilterVariables([excludedIdsFilter]),
]; ];
}) })
.filter(isDefined), .filter(isDefined),
); );
const { orderByFieldPerMetadataItem } = useOrderByFieldPerMetadataItem({
objectMetadataItems: selectableObjectMetadataItems,
});
const { limitPerMetadataItem } = useLimitPerMetadataItem({ const { limitPerMetadataItem } = useLimitPerMetadataItem({
objectMetadataItems: selectableObjectMetadataItems, objectMetadataItems: selectableObjectMetadataItems,
limit, limit,
}); });
const multiSelectQuery = useGenerateCombinedFindManyRecordsQuery({ const multiSelectQuery = useGenerateCombinedSearchRecordsQuery({
operationSignatures: selectableObjectMetadataItems.map( operationSignatures: selectableObjectMetadataItems.map(
(objectMetadataItem) => ({ (objectMetadataItem) => ({
objectNameSingular: objectMetadataItem.nameSingular, objectNameSingular: objectMetadataItem.nameSingular,
@ -101,8 +88,8 @@ export const useMultiObjectSearchMatchesSearchFilterAndToSelectQuery = ({
data: toSelectAndMatchesSearchFilterObjectRecordsQueryResult, data: toSelectAndMatchesSearchFilterObjectRecordsQueryResult,
} = useQuery<MultiObjectRecordQueryResult>(multiSelectQuery ?? EMPTY_QUERY, { } = useQuery<MultiObjectRecordQueryResult>(multiSelectQuery ?? EMPTY_QUERY, {
variables: { variables: {
search: searchFilterValue,
...objectRecordsToSelectAndMatchesSearchFilterTextFilterPerMetadataItem, ...objectRecordsToSelectAndMatchesSearchFilterTextFilterPerMetadataItem,
...orderByFieldPerMetadataItem,
...limitPerMetadataItem, ...limitPerMetadataItem,
}, },
skip: !isDefined(multiSelectQuery), skip: !isDefined(multiSelectQuery),
@ -111,8 +98,9 @@ export const useMultiObjectSearchMatchesSearchFilterAndToSelectQuery = ({
const { const {
objectRecordForSelectArray: toSelectAndMatchesSearchFilterObjectRecords, objectRecordForSelectArray: toSelectAndMatchesSearchFilterObjectRecords,
} = useMultiObjectRecordsQueryResultFormattedAsObjectRecordForSelectArray({ } = useMultiObjectRecordsQueryResultFormattedAsObjectRecordForSelectArray({
multiObjectRecordsQueryResult: multiObjectRecordsQueryResult: formatSearchResults(
toSelectAndMatchesSearchFilterObjectRecordsQueryResult, toSelectAndMatchesSearchFilterObjectRecordsQueryResult,
),
}); });
return { return {

View File

@ -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<RecordGqlOperationFilter>(
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,
};
};

View File

@ -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,
)
);
};

View File

@ -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,
)
);
};