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
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
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 { 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<MultiObjectRecordQueryResult>(
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 {

View File

@ -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<MultiObjectRecordQueryResult>(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 {

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