replace search resolvers by global search in front (#11086)

Done
- Replace global search in multi record picker and single record picker

To do
- refactor SingleRecordPicker to match MultipleRecordPicker - next 1:1
- items in this issue
https://github.com/twentyhq/core-team-issues/issues/643



closes https://github.com/twentyhq/core-team-issues/issues/535
This commit is contained in:
Etienne
2025-03-21 17:25:00 +01:00
committed by GitHub
parent da527f1780
commit e624e8deee
26 changed files with 481 additions and 651 deletions

View File

@ -2418,7 +2418,9 @@ export type GetClientConfigQuery = { __typename?: 'Query', clientConfig: { __typ
export type GlobalSearchQueryVariables = Exact<{ export type GlobalSearchQueryVariables = Exact<{
searchInput: Scalars['String']; searchInput: Scalars['String'];
limit: Scalars['Int']; limit: Scalars['Int'];
excludedObjectNameSingulars: Array<Scalars['String']> | Scalars['String']; excludedObjectNameSingulars?: InputMaybe<Array<Scalars['String']> | Scalars['String']>;
includedObjectNameSingulars?: InputMaybe<Array<Scalars['String']> | Scalars['String']>;
filter?: InputMaybe<ObjectRecordFilterInput>;
}>; }>;
@ -4095,11 +4097,13 @@ export type GetClientConfigQueryHookResult = ReturnType<typeof useGetClientConfi
export type GetClientConfigLazyQueryHookResult = ReturnType<typeof useGetClientConfigLazyQuery>; export type GetClientConfigLazyQueryHookResult = ReturnType<typeof useGetClientConfigLazyQuery>;
export type GetClientConfigQueryResult = Apollo.QueryResult<GetClientConfigQuery, GetClientConfigQueryVariables>; export type GetClientConfigQueryResult = Apollo.QueryResult<GetClientConfigQuery, GetClientConfigQueryVariables>;
export const GlobalSearchDocument = gql` export const GlobalSearchDocument = gql`
query GlobalSearch($searchInput: String!, $limit: Int!, $excludedObjectNameSingulars: [String!]!) { query GlobalSearch($searchInput: String!, $limit: Int!, $excludedObjectNameSingulars: [String!], $includedObjectNameSingulars: [String!], $filter: ObjectRecordFilterInput) {
globalSearch( globalSearch(
searchInput: $searchInput searchInput: $searchInput
limit: $limit limit: $limit
excludedObjectNameSingulars: $excludedObjectNameSingulars excludedObjectNameSingulars: $excludedObjectNameSingulars
includedObjectNameSingulars: $includedObjectNameSingulars
filter: $filter
) { ) {
recordId recordId
objectSingularName objectSingularName
@ -4126,6 +4130,8 @@ export const GlobalSearchDocument = gql`
* searchInput: // value for 'searchInput' * searchInput: // value for 'searchInput'
* limit: // value for 'limit' * limit: // value for 'limit'
* excludedObjectNameSingulars: // value for 'excludedObjectNameSingulars' * excludedObjectNameSingulars: // value for 'excludedObjectNameSingulars'
* includedObjectNameSingulars: // value for 'includedObjectNameSingulars'
* filter: // value for 'filter'
* }, * },
* }); * });
*/ */

View File

@ -6,6 +6,7 @@ import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadat
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useCreateOneRecord } from '@/object-record/hooks/useCreateOneRecord'; import { useCreateOneRecord } from '@/object-record/hooks/useCreateOneRecord';
import { useDeleteOneRecord } from '@/object-record/hooks/useDeleteOneRecord'; import { useDeleteOneRecord } from '@/object-record/hooks/useDeleteOneRecord';
import { searchRecordStoreComponentFamilyState } from '@/object-record/record-picker/multiple-record-picker/states/searchRecordStoreComponentFamilyState';
import { RecordPickerPickableMorphItem } from '@/object-record/record-picker/types/RecordPickerPickableMorphItem'; import { RecordPickerPickableMorphItem } from '@/object-record/record-picker/types/RecordPickerPickableMorphItem';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState'; import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { isNull } from '@sniptt/guards'; import { isNull } from '@sniptt/guards';
@ -47,6 +48,7 @@ export const useUpdateActivityTargetFromInlineCell = ({
async ({ async ({
morphItem, morphItem,
activityTargetWithTargetRecords, activityTargetWithTargetRecords,
recordPickerInstanceId,
}: UpdateActivityTargetFromInlineCellProps) => { }: UpdateActivityTargetFromInlineCellProps) => {
const targetObjectName = const targetObjectName =
activityObjectNameSingular === CoreObjectNameSingular.Task activityObjectNameSingular === CoreObjectNameSingular.Task
@ -92,11 +94,16 @@ export const useUpdateActivityTargetFromInlineCell = ({
); );
} }
} else { } else {
const targetRecord = snapshot const searchRecord = snapshot
.getLoadable(recordStoreFamilyState(morphItem.recordId)) .getLoadable(
searchRecordStoreComponentFamilyState.atomFamily({
instanceId: recordPickerInstanceId,
familyKey: morphItem.recordId,
}),
)
.getValue(); .getValue();
if (!isDefined(targetRecord)) { if (!isDefined(searchRecord) || !isDefined(searchRecord?.record)) {
return; return;
} }
@ -104,6 +111,8 @@ export const useUpdateActivityTargetFromInlineCell = ({
return; return;
} }
const targetRecord = searchRecord.record;
const activityTarget = const activityTarget =
activityObjectNameSingular === CoreObjectNameSingular.Task activityObjectNameSingular === CoreObjectNameSingular.Task
? { ? {

View File

@ -0,0 +1 @@
export const MAX_SEARCH_RESULTS = 30;

View File

@ -4,12 +4,16 @@ export const globalSearch = gql`
query GlobalSearch( query GlobalSearch(
$searchInput: String! $searchInput: String!
$limit: Int! $limit: Int!
$excludedObjectNameSingulars: [String!]! $excludedObjectNameSingulars: [String!]
$includedObjectNameSingulars: [String!]
$filter: ObjectRecordFilterInput
) { ) {
globalSearch( globalSearch(
searchInput: $searchInput searchInput: $searchInput
limit: $limit limit: $limit
excludedObjectNameSingulars: $excludedObjectNameSingulars excludedObjectNameSingulars: $excludedObjectNameSingulars
includedObjectNameSingulars: $includedObjectNameSingulars
filter: $filter
) { ) {
recordId recordId
objectSingularName objectSingularName

View File

@ -1,3 +1,4 @@
import { MAX_SEARCH_RESULTS } from '@/command-menu/constants/MaxSearchResults';
import { useOpenRecordInCommandMenu } from '@/command-menu/hooks/useOpenRecordInCommandMenu'; import { useOpenRecordInCommandMenu } from '@/command-menu/hooks/useOpenRecordInCommandMenu';
import { commandMenuSearchState } from '@/command-menu/states/commandMenuSearchState'; import { commandMenuSearchState } from '@/command-menu/states/commandMenuSearchState';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
@ -9,9 +10,7 @@ import { Avatar } from 'twenty-ui';
import { useDebounce } from 'use-debounce'; import { useDebounce } from 'use-debounce';
import { useGlobalSearchQuery } from '~/generated/graphql'; import { useGlobalSearchQuery } from '~/generated/graphql';
const MAX_SEARCH_RESULTS = 30; export const useCommandMenuSearchRecords = () => {
export const useSearchRecords = () => {
const commandMenuSearch = useRecoilValue(commandMenuSearchState); const commandMenuSearch = useRecoilValue(commandMenuSearchState);
const [deferredCommandMenuSearch] = useDebounce(commandMenuSearch, 300); const [deferredCommandMenuSearch] = useDebounce(commandMenuSearch, 300);

View File

@ -1,9 +1,9 @@
import { CommandMenuList } from '@/command-menu/components/CommandMenuList'; import { CommandMenuList } from '@/command-menu/components/CommandMenuList';
import { useSearchRecords } from '@/command-menu/hooks/useSearchRecords'; import { useCommandMenuSearchRecords } from '@/command-menu/hooks/useCommandMenuSearchRecords';
import { useMemo } from 'react'; import { useMemo } from 'react';
export const CommandMenuSearchRecordsPage = () => { export const CommandMenuSearchRecordsPage = () => {
const { commandGroups, loading, noResults } = useSearchRecords(); const { commandGroups, loading, noResults } = useCommandMenuSearchRecords();
const selectableItemIds = useMemo(() => { const selectableItemIds = useMemo(() => {
return commandGroups.flatMap((group) => group.items).map((item) => item.id); return commandGroups.flatMap((group) => group.items).map((item) => item.id);

View File

@ -0,0 +1,23 @@
import { getAvatarType } from '@/object-metadata/utils/getAvatarType';
import { getBasePathToShowPage } from '@/object-metadata/utils/getBasePathToShowPage';
import { SingleRecordPickerRecord } from '@/object-record/record-picker/single-record-picker/types/SingleRecordPickerRecord';
import { GlobalSearchRecord } from '~/generated/graphql';
export const formatGlobalSearchRecordAsSingleRecordPickerRecord = (
searchRecord: GlobalSearchRecord,
): SingleRecordPickerRecord => {
return {
id: searchRecord.recordId,
name: searchRecord.label,
avatarUrl: searchRecord.imageUrl ?? undefined,
avatarType: getAvatarType(searchRecord.objectSingularName),
linkToShowPage:
getBasePathToShowPage({
objectNameSingular: searchRecord.objectSingularName,
}) + searchRecord.recordId,
record: {
id: searchRecord.recordId,
__typename: searchRecord.objectSingularName,
},
};
};

View File

@ -0,0 +1,81 @@
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { MAX_SEARCH_RESULTS } from '@/command-menu/constants/MaxSearchResults';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { ObjectMetadataItemIdentifier } from '@/object-metadata/types/ObjectMetadataItemIdentifier';
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { WatchQueryFetchPolicy } from '@apollo/client';
import { useMemo } from 'react';
import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-shared';
import {
ObjectRecordFilterInput,
useGlobalSearchQuery,
} from '~/generated/graphql';
import { logError } from '~/utils/logError';
export type UseSearchRecordsParams = ObjectMetadataItemIdentifier & {
limit?: number;
onError?: (error?: Error) => void;
skip?: boolean;
fetchPolicy?: WatchQueryFetchPolicy;
searchInput?: string;
filter?: ObjectRecordFilterInput;
};
export const useObjectRecordSearchRecords = ({
objectNameSingular,
searchInput,
limit,
skip,
filter,
fetchPolicy,
}: UseSearchRecordsParams) => {
const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState);
const { objectMetadataItem } = useObjectMetadataItem({
objectNameSingular,
});
const { enqueueSnackBar } = useSnackBar();
const { data, loading, error, previousData } = useGlobalSearchQuery({
skip:
skip ||
!objectMetadataItem ||
!currentWorkspaceMember ||
!isDefined(searchInput),
variables: {
searchInput: searchInput ?? '',
limit: limit ?? MAX_SEARCH_RESULTS,
filter: filter ?? {},
includedObjectNameSingulars: [objectNameSingular],
},
fetchPolicy: fetchPolicy,
onError: (error) => {
logError(
`useGlobalSearchRecords for "${objectMetadataItem.namePlural}" error : ` +
error,
);
enqueueSnackBar(
`Error during useGlobalSearchRecords for "${objectMetadataItem.namePlural}", ${error.message}`,
{
variant: SnackBarVariant.Error,
},
);
},
});
const effectiveData = loading ? previousData : data;
const searchRecords = useMemo(
() => effectiveData?.globalSearch || [],
[effectiveData],
);
return {
objectMetadataItem,
searchRecords,
loading,
error,
};
};

View File

@ -1,98 +0,0 @@
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { ObjectMetadataItemIdentifier } from '@/object-metadata/types/ObjectMetadataItemIdentifier';
import { getRecordsFromRecordConnection } from '@/object-record/cache/utils/getRecordsFromRecordConnection';
import { RecordGqlOperationGqlRecordFields } from '@/object-record/graphql/types/RecordGqlOperationGqlRecordFields';
import { RecordGqlOperationSearchResult } from '@/object-record/graphql/types/RecordGqlOperationSearchResult';
import { RecordGqlOperationVariables } from '@/object-record/graphql/types/RecordGqlOperationVariables';
import { useSearchRecordsQuery } from '@/object-record/hooks/useSearchRecordsQuery';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { getSearchRecordsQueryResponseField } from '@/object-record/utils/getSearchRecordsQueryResponseField';
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useQuery, WatchQueryFetchPolicy } from '@apollo/client';
import { useMemo } from 'react';
import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-shared';
import { logError } from '~/utils/logError';
export type UseSearchRecordsParams = ObjectMetadataItemIdentifier &
Pick<RecordGqlOperationVariables, 'filter' | 'limit'> & {
onError?: (error?: Error) => void;
skip?: boolean;
recordGqlFields?: RecordGqlOperationGqlRecordFields;
fetchPolicy?: WatchQueryFetchPolicy;
searchInput?: string;
};
export const useSearchRecords = <T extends ObjectRecord = ObjectRecord>({
objectNameSingular,
searchInput,
limit,
skip,
filter,
recordGqlFields,
fetchPolicy,
}: UseSearchRecordsParams) => {
const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState);
const { objectMetadataItem } = useObjectMetadataItem({
objectNameSingular,
});
const { searchRecordsQuery } = useSearchRecordsQuery({
objectNameSingular,
recordGqlFields,
});
const { enqueueSnackBar } = useSnackBar();
const { data, loading, error, previousData } =
useQuery<RecordGqlOperationSearchResult>(searchRecordsQuery, {
skip:
skip ||
!objectMetadataItem ||
!currentWorkspaceMember ||
!isDefined(searchInput),
variables: {
search: searchInput,
limit: limit,
filter: filter,
},
fetchPolicy: fetchPolicy,
onError: (error) => {
logError(
`useSearchRecords for "${objectMetadataItem.namePlural}" error : ` +
error,
);
enqueueSnackBar(
`Error during useSearchRecords for "${objectMetadataItem.namePlural}", ${error.message}`,
{
variant: SnackBarVariant.Error,
},
);
},
});
const effectiveData = loading ? previousData : data;
const queryResponseField = getSearchRecordsQueryResponseField(
objectMetadataItem.namePlural,
);
const result = effectiveData?.[queryResponseField];
const records = useMemo(
() =>
result
? (getRecordsFromRecordConnection({
recordConnection: result,
}) as T[])
: [],
[result],
);
return {
objectMetadataItem,
records: records,
loading,
error,
};
};

View File

@ -1,33 +0,0 @@
import { useRecoilValue } from 'recoil';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { RecordGqlOperationGqlRecordFields } from '@/object-record/graphql/types/RecordGqlOperationGqlRecordFields';
import { generateSearchRecordsQuery } from '@/object-record/utils/generateSearchRecordsQuery';
export const useSearchRecordsQuery = ({
objectNameSingular,
recordGqlFields,
computeReferences,
}: {
objectNameSingular: string;
recordGqlFields?: RecordGqlOperationGqlRecordFields;
computeReferences?: boolean;
}) => {
const { objectMetadataItem } = useObjectMetadataItem({
objectNameSingular,
});
const objectMetadataItems = useRecoilValue(objectMetadataItemsState);
const searchRecordsQuery = generateSearchRecordsQuery({
objectMetadataItem,
objectMetadataItems,
recordGqlFields,
computeReferences,
});
return {
searchRecordsQuery,
};
};

View File

@ -1,23 +0,0 @@
import { useRecoilValue } from 'recoil';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { RecordGqlOperationSignature } from '@/object-record/graphql/types/RecordGqlOperationSignature';
import { generateCombinedSearchRecordsQuery } from '@/object-record/multiple-objects/utils/generateCombinedSearchRecordsQuery';
import { isNonEmptyArray } from '~/utils/isNonEmptyArray';
export const useGenerateCombinedSearchRecordsQuery = ({
operationSignatures,
}: {
operationSignatures: RecordGqlOperationSignature[];
}) => {
const objectMetadataItems = useRecoilValue(objectMetadataItemsState);
if (!isNonEmptyArray(operationSignatures)) {
return null;
}
return generateCombinedSearchRecordsQuery({
objectMetadataItems,
operationSignatures,
});
};

View File

@ -1,82 +0,0 @@
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { mapObjectMetadataToGraphQLQuery } from '@/object-metadata/utils/mapObjectMetadataToGraphQLQuery';
import { RecordGqlOperationSignature } from '@/object-record/graphql/types/RecordGqlOperationSignature';
import { getSearchRecordsQueryResponseField } from '@/object-record/utils/getSearchRecordsQueryResponseField';
import { isUndefined } from '@sniptt/guards';
import gql from 'graphql-tag';
import { capitalize } from 'twenty-shared';
export const generateCombinedSearchRecordsQuery = ({
objectMetadataItems,
operationSignatures,
}: {
objectMetadataItems: ObjectMetadataItem[];
operationSignatures: RecordGqlOperationSignature[];
}) => {
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 }) => objectMetadataItem.isSearchable,
);
return gql`
query CombinedSearchRecords(
${filterPerMetadataItemArray},
${limitPerMetadataItemArray},
$search: String,
) {
${filteredQueryKeyWithObjectMetadataItemArray
.map(
({ objectMetadataItem }) =>
`${getSearchRecordsQueryResponseField(objectMetadataItem.namePlural)}(filter: $filter${capitalize(
objectMetadataItem.nameSingular,
)},
limit: $limit${capitalize(objectMetadataItem.nameSingular)},
searchInput: $search
){
edges {
node ${mapObjectMetadataToGraphQLQuery({
objectMetadataItems: objectMetadataItems,
objectMetadataItem,
})}
cursor
}
totalCount
}`,
)
.join('\n')}
}
`;
};

View File

@ -77,9 +77,8 @@ const RelationToOneFieldInputWithContext = ({
iconName: 'IconLink', iconName: 'IconLink',
metadata: { metadata: {
fieldName: 'Relation', fieldName: 'Relation',
relationObjectMetadataNamePlural: 'workspaceMembers', relationObjectMetadataNamePlural: 'companies',
relationObjectMetadataNameSingular: relationObjectMetadataNameSingular: CoreObjectNameSingular.Company,
CoreObjectNameSingular.WorkspaceMember,
objectMetadataNameSingular: 'person', objectMetadataNameSingular: 'person',
relationFieldMetadataId: '20202020-8c37-4163-ba06-1dada334ce3e', relationFieldMetadataId: '20202020-8c37-4163-ba06-1dada334ce3e',
}, },
@ -149,7 +148,7 @@ export const Submit: Story = {
expect(submitJestFn).toHaveBeenCalledTimes(0); expect(submitJestFn).toHaveBeenCalledTimes(0);
const item = await canvas.findByText('John Wick', undefined, { const item = await canvas.findByText('Linkedin', undefined, {
timeout: 3000, timeout: 3000,
}); });
@ -167,7 +166,7 @@ export const Cancel: Story = {
const canvas = within(getCanvasElementForDropdownTesting()); const canvas = within(getCanvasElementForDropdownTesting());
expect(cancelJestFn).toHaveBeenCalledTimes(0); expect(cancelJestFn).toHaveBeenCalledTimes(0);
await canvas.findByText('John Wick', undefined, { timeout: 3000 }); await canvas.findByText('Linkedin', undefined, { timeout: 3000 });
const emptyDiv = canvas.getByTestId('data-field-input-click-outside-div'); const emptyDiv = canvas.getByTestId('data-field-input-click-outside-div');

View File

@ -1,38 +0,0 @@
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { multipleRecordPickerSinglePickableMorphItemComponentFamilySelector } from '@/object-record/record-picker/multiple-record-picker/states/selectors/multipleRecordPickerSinglePickableMorphItemComponentFamilySelector';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { useRecoilComponentFamilyValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentFamilyValueV2';
import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-shared';
type UseRecordPickerGetRecordAndObjectMetadataItemFromRecordIdProps = {
recordId: string;
};
export const useRecordPickerGetRecordAndObjectMetadataItemFromRecordId = ({
recordId,
}: UseRecordPickerGetRecordAndObjectMetadataItemFromRecordIdProps) => {
const { objectMetadataItems } = useObjectMetadataItems();
const pickableMorphItem = useRecoilComponentFamilyValueV2(
multipleRecordPickerSinglePickableMorphItemComponentFamilySelector,
recordId,
);
const record = useRecoilValue(recordStoreFamilyState(recordId));
if (!isDefined(pickableMorphItem)) {
return { record: null, objectMetadataItem: null };
}
const objectMetadataItem = objectMetadataItems.find(
(objectMetadataItem) =>
objectMetadataItem.id === pickableMorphItem.objectMetadataId,
);
if (!isDefined(objectMetadataItem)) {
return { record: null, objectMetadataItem: null };
}
return { record, objectMetadataItem };
};

View File

@ -0,0 +1,41 @@
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { searchRecordStoreComponentFamilyState } from '@/object-record/record-picker/multiple-record-picker/states/searchRecordStoreComponentFamilyState';
import { multipleRecordPickerSinglePickableMorphItemComponentFamilySelector } from '@/object-record/record-picker/multiple-record-picker/states/selectors/multipleRecordPickerSinglePickableMorphItemComponentFamilySelector';
import { useRecoilComponentFamilyValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentFamilyValueV2';
import { isDefined } from 'twenty-shared';
type UseRecordPickerGetRecordAndObjectMetadataItemFromRecordIdProps = {
recordId: string;
};
export const useRecordPickerGetSearchRecordAndObjectMetadataItemFromRecordId =
({
recordId,
}: UseRecordPickerGetRecordAndObjectMetadataItemFromRecordIdProps) => {
const { objectMetadataItems } = useObjectMetadataItems();
const pickableMorphItem = useRecoilComponentFamilyValueV2(
multipleRecordPickerSinglePickableMorphItemComponentFamilySelector,
recordId,
);
const searchRecord = useRecoilComponentFamilyValueV2(
searchRecordStoreComponentFamilyState,
recordId,
);
if (!isDefined(pickableMorphItem) || !isDefined(searchRecord)) {
return { searchRecord: null, objectMetadataItem: null };
}
const objectMetadataItem = objectMetadataItems.find(
(objectMetadataItem) =>
objectMetadataItem.id === pickableMorphItem.objectMetadataId,
);
if (!isDefined(objectMetadataItem)) {
return { searchRecord: null, objectMetadataItem: null };
}
return { searchRecord, objectMetadataItem };
};

View File

@ -1,6 +1,6 @@
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { useRecordPickerGetRecordAndObjectMetadataItemFromRecordId } from '@/object-record/record-picker/hooks/useRecordPickerGetRecordAndObjectMetadataItemFromRecordId'; import { useRecordPickerGetSearchRecordAndObjectMetadataItemFromRecordId } from '@/object-record/record-picker/hooks/useRecordPickerGetSearchRecordAndObjectMetadataItemFromRecordId';
import { MultipleRecordPickerMenuItemContent } from '@/object-record/record-picker/multiple-record-picker/components/MultipleRecordPickerMenuItemContent'; import { MultipleRecordPickerMenuItemContent } from '@/object-record/record-picker/multiple-record-picker/components/MultipleRecordPickerMenuItemContent';
import { RecordPickerPickableMorphItem } from '@/object-record/record-picker/types/RecordPickerPickableMorphItem'; import { RecordPickerPickableMorphItem } from '@/object-record/record-picker/types/RecordPickerPickableMorphItem';
import { SelectableItem } from '@/ui/layout/selectable-list/components/SelectableItem'; import { SelectableItem } from '@/ui/layout/selectable-list/components/SelectableItem';
@ -20,18 +20,18 @@ export const MultipleRecordPickerMenuItem = ({
recordId, recordId,
onChange, onChange,
}: MultipleRecordPickerMenuItemProps) => { }: MultipleRecordPickerMenuItemProps) => {
const { record, objectMetadataItem } = const { searchRecord, objectMetadataItem } =
useRecordPickerGetRecordAndObjectMetadataItemFromRecordId({ useRecordPickerGetSearchRecordAndObjectMetadataItemFromRecordId({
recordId, recordId,
}); });
if (!isDefined(record) || !isDefined(objectMetadataItem)) { if (!isDefined(searchRecord) || !isDefined(objectMetadataItem)) {
return null; return null;
} }
return ( return (
<MultipleRecordPickerMenuItemContent <MultipleRecordPickerMenuItemContent
record={record} searchRecord={searchRecord}
objectMetadataItem={objectMetadataItem} objectMetadataItem={objectMetadataItem}
onChange={onChange} onChange={onChange}
/> />

View File

@ -3,17 +3,16 @@ import { useRecoilValue } from 'recoil';
import { Avatar, MenuItemMultiSelectAvatar } from 'twenty-ui'; import { Avatar, MenuItemMultiSelectAvatar } from 'twenty-ui';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { getObjectRecordIdentifier } from '@/object-metadata/utils/getObjectRecordIdentifier'; import { getAvatarType } from '@/object-metadata/utils/getAvatarType';
import { MultipleRecordPickerComponentInstanceContext } from '@/object-record/record-picker/multiple-record-picker/states/contexts/MultipleRecordPickerComponentInstanceContext'; import { MultipleRecordPickerComponentInstanceContext } from '@/object-record/record-picker/multiple-record-picker/states/contexts/MultipleRecordPickerComponentInstanceContext';
import { multipleRecordPickerIsSelectedComponentFamilySelector } from '@/object-record/record-picker/multiple-record-picker/states/selectors/multipleRecordPickerIsSelectedComponentFamilySelector'; import { multipleRecordPickerIsSelectedComponentFamilySelector } from '@/object-record/record-picker/multiple-record-picker/states/selectors/multipleRecordPickerIsSelectedComponentFamilySelector';
import { getMultipleRecordPickerSelectableListId } from '@/object-record/record-picker/multiple-record-picker/utils/getMultipleRecordPickerSelectableListId'; import { getMultipleRecordPickerSelectableListId } from '@/object-record/record-picker/multiple-record-picker/utils/getMultipleRecordPickerSelectableListId';
import { RecordPickerPickableMorphItem } from '@/object-record/record-picker/types/RecordPickerPickableMorphItem'; import { RecordPickerPickableMorphItem } from '@/object-record/record-picker/types/RecordPickerPickableMorphItem';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { SelectableItem } from '@/ui/layout/selectable-list/components/SelectableItem'; import { SelectableItem } from '@/ui/layout/selectable-list/components/SelectableItem';
import { useSelectableList } from '@/ui/layout/selectable-list/hooks/useSelectableList'; import { useSelectableList } from '@/ui/layout/selectable-list/hooks/useSelectableList';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow'; import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useRecoilComponentFamilyValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentFamilyValueV2'; import { useRecoilComponentFamilyValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentFamilyValueV2';
import { isDefined } from 'twenty-shared'; import { GlobalSearchRecord } from '~/generated-metadata/graphql';
export const StyledSelectableItem = styled(SelectableItem)` export const StyledSelectableItem = styled(SelectableItem)`
height: 100%; height: 100%;
@ -21,13 +20,13 @@ export const StyledSelectableItem = styled(SelectableItem)`
`; `;
type MultipleRecordPickerMenuItemContentProps = { type MultipleRecordPickerMenuItemContentProps = {
record: ObjectRecord; searchRecord: GlobalSearchRecord;
objectMetadataItem: ObjectMetadataItem; objectMetadataItem: ObjectMetadataItem;
onChange: (morphItem: RecordPickerPickableMorphItem) => void; onChange: (morphItem: RecordPickerPickableMorphItem) => void;
}; };
export const MultipleRecordPickerMenuItemContent = ({ export const MultipleRecordPickerMenuItemContent = ({
record, searchRecord,
objectMetadataItem, objectMetadataItem,
onChange, onChange,
}: MultipleRecordPickerMenuItemContentProps) => { }: MultipleRecordPickerMenuItemContentProps) => {
@ -43,49 +42,43 @@ export const MultipleRecordPickerMenuItemContent = ({
); );
const isSelectedByKeyboard = useRecoilValue( const isSelectedByKeyboard = useRecoilValue(
isSelectedItemIdSelector(record.id), isSelectedItemIdSelector(searchRecord.recordId),
); );
const isRecordSelectedWithObjectItem = useRecoilComponentFamilyValueV2( const isRecordSelectedWithObjectItem = useRecoilComponentFamilyValueV2(
multipleRecordPickerIsSelectedComponentFamilySelector, multipleRecordPickerIsSelectedComponentFamilySelector,
record.id, searchRecord.recordId,
componentInstanceId, componentInstanceId,
); );
const handleSelectChange = (isSelected: boolean) => { const handleSelectChange = (isSelected: boolean) => {
onChange({ onChange({
recordId: record.id, recordId: searchRecord.recordId,
objectMetadataId: objectMetadataItem.id, objectMetadataId: objectMetadataItem.id,
isSelected, isSelected,
isMatchingSearchFilter: true, isMatchingSearchFilter: true,
}); });
}; };
const recordIdentifier = getObjectRecordIdentifier({
objectMetadataItem,
record,
});
if (!isDefined(recordIdentifier)) {
return null;
}
return ( return (
<StyledSelectableItem itemId={record.id} key={record.id}> <StyledSelectableItem
itemId={searchRecord.recordId}
key={searchRecord.recordId}
>
<MenuItemMultiSelectAvatar <MenuItemMultiSelectAvatar
onSelectChange={(isSelected) => handleSelectChange(isSelected)} onSelectChange={(isSelected) => handleSelectChange(isSelected)}
isKeySelected={isSelectedByKeyboard} isKeySelected={isSelectedByKeyboard}
selected={isRecordSelectedWithObjectItem} selected={isRecordSelectedWithObjectItem}
avatar={ avatar={
<Avatar <Avatar
avatarUrl={recordIdentifier.avatarUrl} avatarUrl={searchRecord.imageUrl}
placeholderColorSeed={record.id} placeholderColorSeed={searchRecord.recordId}
placeholder={recordIdentifier.name} placeholder={searchRecord.label}
size="md" size="md"
type={recordIdentifier.avatarType ?? 'rounded'} type={getAvatarType(objectMetadataItem.nameSingular) ?? 'rounded'}
/> />
} }
text={recordIdentifier.name} text={searchRecord.label}
/> />
</StyledSelectableItem> </StyledSelectableItem>
); );

View File

@ -1,21 +1,24 @@
import { MAX_SEARCH_RESULTS } from '@/command-menu/constants/MaxSearchResults';
import { globalSearch } from '@/command-menu/graphql/queries/globalSearch';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { CombinedFindManyRecordsQueryResult } from '@/object-record/multiple-objects/types/CombinedFindManyRecordsQueryResult'; import { usePerformCombinedFindManyRecords } from '@/object-record/multiple-objects/hooks/usePerformCombinedFindManyRecords';
import { generateCombinedSearchRecordsQuery } from '@/object-record/multiple-objects/utils/generateCombinedSearchRecordsQuery';
import { getLimitPerMetadataItem } from '@/object-record/multiple-objects/utils/getLimitPerMetadataItem';
import { multipleRecordPickerPickableMorphItemsComponentState } from '@/object-record/record-picker/multiple-record-picker/states/multipleRecordPickerPickableMorphItemsComponentState'; import { multipleRecordPickerPickableMorphItemsComponentState } from '@/object-record/record-picker/multiple-record-picker/states/multipleRecordPickerPickableMorphItemsComponentState';
import { multipleRecordPickerSearchFilterComponentState } from '@/object-record/record-picker/multiple-record-picker/states/multipleRecordPickerSearchFilterComponentState'; import { multipleRecordPickerSearchFilterComponentState } from '@/object-record/record-picker/multiple-record-picker/states/multipleRecordPickerSearchFilterComponentState';
import { multipleRecordPickerSearchableObjectMetadataItemsComponentState } from '@/object-record/record-picker/multiple-record-picker/states/multipleRecordPickerSearchableObjectMetadataItemsComponentState'; import { multipleRecordPickerSearchableObjectMetadataItemsComponentState } from '@/object-record/record-picker/multiple-record-picker/states/multipleRecordPickerSearchableObjectMetadataItemsComponentState';
import { multipleRecordPickerformatQueryResultAsRecordsWithObjectMetadataId } from '@/object-record/record-picker/multiple-record-picker/utils/multipleRecordPickerformatQueryResultAsRecordWithObjectMetadataId'; import { searchRecordStoreComponentFamilyState } from '@/object-record/record-picker/multiple-record-picker/states/searchRecordStoreComponentFamilyState';
import { RecordPickerPickableMorphItem } from '@/object-record/record-picker/types/RecordPickerPickableMorphItem'; import { RecordPickerPickableMorphItem } from '@/object-record/record-picker/types/RecordPickerPickableMorphItem';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { ApolloClient, useApolloClient } from '@apollo/client'; import { ApolloClient, useApolloClient } from '@apollo/client';
import { isNonEmptyArray } from '@sniptt/guards'; import { isNonEmptyArray } from '@sniptt/guards';
import { useRecoilCallback } from 'recoil'; import { useRecoilCallback } from 'recoil';
import { capitalize, isDefined } from 'twenty-shared'; import { capitalize, isDefined } from 'twenty-shared';
import { GlobalSearchRecord } from '~/generated-metadata/graphql';
export const useMultipleRecordPickerPerformSearch = () => { export const useMultipleRecordPickerPerformSearch = () => {
const client = useApolloClient(); const client = useApolloClient();
const { performCombinedFindManyRecords } =
usePerformCombinedFindManyRecords();
const performSearch = useRecoilCallback( const performSearch = useRecoilCallback(
({ snapshot, set }) => ({ snapshot, set }) =>
async ({ async ({
@ -29,57 +32,52 @@ export const useMultipleRecordPickerPerformSearch = () => {
forceSearchableObjectMetadataItems?: ObjectMetadataItem[]; forceSearchableObjectMetadataItems?: ObjectMetadataItem[];
forcePickableMorphItems?: RecordPickerPickableMorphItem[]; forcePickableMorphItems?: RecordPickerPickableMorphItem[];
}) => { }) => {
const recordPickerSearchFilter = snapshot const { getLoadable } = snapshot;
.getLoadable(
multipleRecordPickerSearchFilterComponentState.atomFamily({ const recordPickerSearchFilter = getLoadable(
instanceId: multipleRecordPickerInstanceId, multipleRecordPickerSearchFilterComponentState.atomFamily({
}), instanceId: multipleRecordPickerInstanceId,
) }),
.getValue(); ).getValue();
const searchFilter = forceSearchFilter ?? recordPickerSearchFilter; const searchFilter = forceSearchFilter ?? recordPickerSearchFilter;
const recordPickerSearchableObjectMetadataItems = snapshot const recordPickerSearchableObjectMetadataItems = getLoadable(
.getLoadable( multipleRecordPickerSearchableObjectMetadataItemsComponentState.atomFamily(
multipleRecordPickerSearchableObjectMetadataItemsComponentState.atomFamily( { instanceId: multipleRecordPickerInstanceId },
{ instanceId: multipleRecordPickerInstanceId }, ),
), ).getValue();
)
.getValue();
const searchableObjectMetadataItems = const searchableObjectMetadataItems =
forceSearchableObjectMetadataItems.length > 0 forceSearchableObjectMetadataItems.length > 0
? forceSearchableObjectMetadataItems ? forceSearchableObjectMetadataItems
: recordPickerSearchableObjectMetadataItems; : recordPickerSearchableObjectMetadataItems;
const recordPickerPickableMorphItems = snapshot const recordPickerPickableMorphItems = getLoadable(
.getLoadable( multipleRecordPickerPickableMorphItemsComponentState.atomFamily({
multipleRecordPickerPickableMorphItemsComponentState.atomFamily({ instanceId: multipleRecordPickerInstanceId,
instanceId: multipleRecordPickerInstanceId, }),
}), ).getValue();
)
.getValue();
const pickableMorphItems = const pickableMorphItems =
forcePickableMorphItems.length > 0 forcePickableMorphItems.length > 0
? forcePickableMorphItems ? forcePickableMorphItems
: recordPickerPickableMorphItems; : recordPickerPickableMorphItems;
const selectedPickableMorphItems = pickableMorphItems.filter(
({ isSelected }) => isSelected,
);
const recordsWithObjectMetadataIdFilteredOnPickedRecords = const [
await performSearchForPickedRecords({ searchRecordsFilteredOnPickedRecords,
client, searchRecordsExcludingPickedRecords,
searchFilter, ] = await performSearchQueries({
searchableObjectMetadataItems, client,
pickableMorphItems, searchFilter,
}); searchableObjectMetadataItems,
pickedRecordIds: selectedPickableMorphItems.map(
const recordsWithObjectMetadataIdExcludingPickedRecords = ({ recordId }) => recordId,
await performSearchExcludingPickedRecords({ ),
client, });
searchFilter,
searchableObjectMetadataItems,
pickableMorphItems,
});
const pickedMorphItems = pickableMorphItems.filter( const pickedMorphItems = pickableMorphItems.filter(
({ isSelected }) => isSelected, ({ isSelected }) => isSelected,
@ -87,10 +85,9 @@ export const useMultipleRecordPickerPerformSearch = () => {
// We update the existing pickedMorphItems to be matching the search filter // We update the existing pickedMorphItems to be matching the search filter
const updatedPickedMorphItems = pickedMorphItems.map((morphItem) => { const updatedPickedMorphItems = pickedMorphItems.map((morphItem) => {
const record = const record = searchRecordsFilteredOnPickedRecords.find(
recordsWithObjectMetadataIdFilteredOnPickedRecords.find( ({ recordId }) => recordId === morphItem.recordId,
({ record }) => record.id === morphItem.recordId, );
);
return { return {
...morphItem, ...morphItem,
@ -98,40 +95,47 @@ export const useMultipleRecordPickerPerformSearch = () => {
}; };
}); });
const recordsWithObjectMetadataIdFilteredOnPickedRecordsWithoutDuplicates = const searchRecordsFilteredOnPickedRecordsWithoutDuplicates =
recordsWithObjectMetadataIdFilteredOnPickedRecords.filter( searchRecordsFilteredOnPickedRecords.filter(
({ record }) => (searchRecord) =>
!updatedPickedMorphItems.some( !updatedPickedMorphItems.some(
({ recordId }) => recordId === record.id, ({ recordId }) => recordId === searchRecord.recordId,
), ),
); );
const recordsWithObjectMetadataIdExcludingPickedRecordsWithoutDuplicates = const searchRecordsExcludingPickedRecordsWithoutDuplicates =
recordsWithObjectMetadataIdExcludingPickedRecords.filter( searchRecordsExcludingPickedRecords.filter(
({ record }) => (searchRecord) =>
!recordsWithObjectMetadataIdFilteredOnPickedRecords.some( !searchRecordsFilteredOnPickedRecords.some(
({ record: recordFilteredOnPickedRecords }) => ({ recordId }) => recordId === searchRecord.recordId,
recordFilteredOnPickedRecords.id === record.id,
) && ) &&
!pickedMorphItems.some(({ recordId }) => recordId === record.id), !pickedMorphItems.some(
({ recordId }) => recordId === searchRecord.recordId,
),
); );
const morphItems = [ const morphItems = [
...updatedPickedMorphItems, ...updatedPickedMorphItems,
...recordsWithObjectMetadataIdFilteredOnPickedRecordsWithoutDuplicates.map( ...searchRecordsFilteredOnPickedRecordsWithoutDuplicates.map(
({ record, objectMetadataItem }) => ({ ({ recordId, objectSingularName }) => ({
isMatchingSearchFilter: true, isMatchingSearchFilter: true,
isSelected: true, isSelected: true,
objectMetadataId: objectMetadataItem.id, objectMetadataId: searchableObjectMetadataItems.find(
recordId: record.id, (objectMetadata) =>
objectMetadata.nameSingular === objectSingularName,
)?.id,
recordId,
}), }),
), ),
...recordsWithObjectMetadataIdExcludingPickedRecordsWithoutDuplicates.map( ...searchRecordsExcludingPickedRecordsWithoutDuplicates.map(
({ record, objectMetadataItem }) => ({ ({ recordId, objectSingularName }) => ({
isMatchingSearchFilter: true, isMatchingSearchFilter: true,
isSelected: false, isSelected: false,
objectMetadataId: objectMetadataItem.id, objectMetadataId: searchableObjectMetadataItems.find(
recordId: record.id, (objectMetadata) =>
objectMetadata.nameSingular === objectSingularName,
)?.id,
recordId,
}), }),
), ),
]; ];
@ -143,192 +147,153 @@ export const useMultipleRecordPickerPerformSearch = () => {
morphItems, morphItems,
); );
[ const searchRecords = [
...recordsWithObjectMetadataIdFilteredOnPickedRecords, ...searchRecordsFilteredOnPickedRecords,
...recordsWithObjectMetadataIdExcludingPickedRecordsWithoutDuplicates, ...searchRecordsExcludingPickedRecordsWithoutDuplicates,
].forEach(({ record }) => { ];
set(recordStoreFamilyState(record.id), record);
searchRecords.forEach((searchRecord) => {
set(
searchRecordStoreComponentFamilyState.atomFamily({
instanceId: multipleRecordPickerInstanceId,
familyKey: searchRecord.recordId,
}),
searchRecord,
);
}); });
if (searchRecords.length > 0) {
const filterPerMetadataItemFilteredOnRecordId = Object.fromEntries(
searchableObjectMetadataItems
.map(({ nameSingular }) => {
const recordIdsForMetadataItem = searchRecords
.filter(
({ objectSingularName }) =>
objectSingularName === nameSingular,
)
.map(({ recordId }) => recordId);
if (!isNonEmptyArray(recordIdsForMetadataItem)) {
return null;
}
return [
`filter${capitalize(nameSingular)}`,
{
id: {
in: recordIdsForMetadataItem,
},
},
];
})
.filter(isDefined),
);
const operationSignatures = searchableObjectMetadataItems
.filter(({ nameSingular }) =>
isDefined(
filterPerMetadataItemFilteredOnRecordId[
`filter${capitalize(nameSingular)}`
],
),
)
.map((objectMetadataItem) => ({
objectNameSingular: objectMetadataItem.nameSingular,
variables: {
filter:
filterPerMetadataItemFilteredOnRecordId[
`filter${capitalize(objectMetadataItem.nameSingular)}`
],
},
}));
performCombinedFindManyRecords({ operationSignatures }).then(
({ result }) => {
Object.values(result)
.flat()
.forEach((objectRecord) => {
const searchRecord = searchRecords.find(
({ recordId }) => recordId === objectRecord.id,
);
if (!searchRecord) {
return;
}
set(
searchRecordStoreComponentFamilyState.atomFamily({
instanceId: multipleRecordPickerInstanceId,
familyKey: objectRecord.id,
}),
{
...searchRecord,
record: objectRecord,
},
);
});
},
);
}
}, },
[client], [client, performCombinedFindManyRecords],
); );
return { performSearch }; return { performSearch };
}; };
const performSearchForPickedRecords = async ({ const performSearchQueries = async ({
client, client,
searchFilter, searchFilter,
searchableObjectMetadataItems, searchableObjectMetadataItems,
pickableMorphItems, pickedRecordIds,
}: { }: {
client: ApolloClient<object>; client: ApolloClient<object>;
searchFilter: string; searchFilter: string;
searchableObjectMetadataItems: ObjectMetadataItem[]; searchableObjectMetadataItems: ObjectMetadataItem[];
pickableMorphItems: RecordPickerPickableMorphItem[]; pickedRecordIds: string[];
}) => { }): Promise<[GlobalSearchRecord[], GlobalSearchRecord[]]> => {
const pickedMorphItems = pickableMorphItems.filter(
({ isSelected }) => isSelected,
);
const filterPerMetadataItemFilteredOnPickedRecordId = Object.fromEntries(
searchableObjectMetadataItems
.map(({ id, nameSingular }) => {
const pickedRecordIdsForMetadataItem = pickedMorphItems
.filter(
({ objectMetadataId, isSelected }) =>
objectMetadataId === id && isSelected,
)
.map(({ recordId }) => recordId);
if (!isNonEmptyArray(pickedRecordIdsForMetadataItem)) {
return null;
}
return [
`filter${capitalize(nameSingular)}`,
{
id: {
in: pickedRecordIdsForMetadataItem,
},
},
];
})
.filter(isDefined),
);
const searchableObjectMetadataItemsFilteredOnPickedRecordId =
searchableObjectMetadataItems.filter(({ nameSingular }) =>
isDefined(
filterPerMetadataItemFilteredOnPickedRecordId[
`filter${capitalize(nameSingular)}`
],
),
);
if (!isNonEmptyArray(searchableObjectMetadataItemsFilteredOnPickedRecordId)) {
return [];
}
const combinedSearchRecordsQueryFilteredOnPickedRecords =
generateCombinedSearchRecordsQuery({
objectMetadataItems:
searchableObjectMetadataItemsFilteredOnPickedRecordId,
operationSignatures:
searchableObjectMetadataItemsFilteredOnPickedRecordId.map(
(objectMetadataItem) => ({
objectNameSingular: objectMetadataItem.nameSingular,
variables: {},
}),
),
});
const limitPerMetadataItem = getLimitPerMetadataItem(
searchableObjectMetadataItemsFilteredOnPickedRecordId,
10,
);
const { data: combinedSearchRecordFilteredOnPickedRecordsQueryResult } =
await client.query<CombinedFindManyRecordsQueryResult>({
query: combinedSearchRecordsQueryFilteredOnPickedRecords,
variables: {
search: searchFilter,
...limitPerMetadataItem,
...filterPerMetadataItemFilteredOnPickedRecordId,
},
});
const {
recordsWithObjectMetadataId:
recordsWithObjectMetadataIdFilteredOnPickedRecords,
} = multipleRecordPickerformatQueryResultAsRecordsWithObjectMetadataId({
objectMetadataItems: searchableObjectMetadataItems,
searchQueryResult: combinedSearchRecordFilteredOnPickedRecordsQueryResult,
});
return recordsWithObjectMetadataIdFilteredOnPickedRecords;
};
const performSearchExcludingPickedRecords = async ({
client,
searchFilter,
searchableObjectMetadataItems,
pickableMorphItems,
}: {
client: ApolloClient<object>;
searchFilter: string;
searchableObjectMetadataItems: ObjectMetadataItem[];
pickableMorphItems: RecordPickerPickableMorphItem[];
}) => {
if (searchableObjectMetadataItems.length === 0) { if (searchableObjectMetadataItems.length === 0) {
return []; return [[], []];
} }
const pickedMorphItems = pickableMorphItems.filter( const searchRecords = async (filter: any) => {
({ isSelected }) => isSelected, const { data } = await client.query({
); query: globalSearch,
const filterPerMetadataItemExcludingPickedRecordId = Object.fromEntries(
searchableObjectMetadataItems
.map(({ id, nameSingular }) => {
const pickedRecordIdsForMetadataItem = pickedMorphItems
.filter(
({ objectMetadataId, isSelected }) =>
objectMetadataId === id && isSelected,
)
.map(({ recordId }) => recordId);
if (!isNonEmptyArray(pickedRecordIdsForMetadataItem)) {
return null;
}
return [
`filter${capitalize(nameSingular)}`,
{
not: {
id: {
in: pickedRecordIdsForMetadataItem,
},
},
},
];
})
.filter(isDefined),
);
const combinedSearchRecordsQueryExcludingPickedRecords =
generateCombinedSearchRecordsQuery({
objectMetadataItems: searchableObjectMetadataItems,
operationSignatures: searchableObjectMetadataItems.map(
(objectMetadataItem) => ({
objectNameSingular: objectMetadataItem.nameSingular,
variables: {},
}),
),
});
const limitPerMetadataItem = getLimitPerMetadataItem(
searchableObjectMetadataItems,
10,
);
const { data: combinedSearchRecordExcludingPickedRecordsQueryResult } =
await client.query<CombinedFindManyRecordsQueryResult>({
query: combinedSearchRecordsQueryExcludingPickedRecords,
variables: { variables: {
search: searchFilter, searchInput: searchFilter,
...limitPerMetadataItem, includedObjectNameSingulars: searchableObjectMetadataItems.map(
...filterPerMetadataItemExcludingPickedRecordId, ({ nameSingular }) => nameSingular,
),
filter,
limit: MAX_SEARCH_RESULTS,
}, },
}); });
return data.globalSearch;
};
const { const searchRecordsExcludingPickedRecords = await searchRecords(
recordsWithObjectMetadataId: pickedRecordIds.length > 0
recordsWithObjectMetadataIdExcludingPickedRecords, ? {
} = multipleRecordPickerformatQueryResultAsRecordsWithObjectMetadataId({ not: {
objectMetadataItems: searchableObjectMetadataItems, id: {
searchQueryResult: combinedSearchRecordExcludingPickedRecordsQueryResult, in: pickedRecordIds,
}); },
},
}
: undefined,
);
return recordsWithObjectMetadataIdExcludingPickedRecords; const searchRecordsIncludingPickedRecords =
pickedRecordIds.length > 0
? await searchRecords({
id: {
in: pickedRecordIds,
},
})
: [];
return [
searchRecordsIncludingPickedRecords,
searchRecordsExcludingPickedRecords,
];
}; };

View File

@ -0,0 +1,14 @@
import { MultipleRecordPickerComponentInstanceContext } from '@/object-record/record-picker/multiple-record-picker/states/contexts/MultipleRecordPickerComponentInstanceContext';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { createComponentFamilyStateV2 } from '@/ui/utilities/state/component-state/utils/createComponentFamilyStateV2';
import { GlobalSearchRecord } from '~/generated-metadata/graphql';
export const searchRecordStoreComponentFamilyState =
createComponentFamilyStateV2<
(GlobalSearchRecord & { record?: ObjectRecord }) | undefined,
string
>({
key: 'searchRecordStoreComponentFamilyState',
defaultValue: undefined,
componentInstanceContext: MultipleRecordPickerComponentInstanceContext,
});

View File

@ -110,16 +110,22 @@ export const FieldsCard = ({
hotkeyScope: InlineCellHotkeyScope.InlineCell, hotkeyScope: InlineCellHotkeyScope.InlineCell,
}} }}
> >
<ActivityTargetsInlineCell <RecordFieldComponentInstanceContext.Provider
activityObjectNameSingular={ value={{
objectNameSingular as instanceId: objectRecordId + fieldMetadataItem.id,
| CoreObjectNameSingular.Note }}
| CoreObjectNameSingular.Task >
} <ActivityTargetsInlineCell
activityRecordId={objectRecordId} activityObjectNameSingular={
showLabel={true} objectNameSingular as
maxWidth={200} | CoreObjectNameSingular.Note
/> | CoreObjectNameSingular.Task
}
activityRecordId={objectRecordId}
showLabel={true}
maxWidth={200}
/>
</RecordFieldComponentInstanceContext.Provider>
</FieldContext.Provider> </FieldContext.Provider>
), ),
)} )}

View File

@ -1,42 +0,0 @@
import gql from 'graphql-tag';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { mapObjectMetadataToGraphQLQuery } from '@/object-metadata/utils/mapObjectMetadataToGraphQLQuery';
import { RecordGqlOperationGqlRecordFields } from '@/object-record/graphql/types/RecordGqlOperationGqlRecordFields';
import { getSearchRecordsQueryResponseField } from '@/object-record/utils/getSearchRecordsQueryResponseField';
import { capitalize } from 'twenty-shared';
export type QueryCursorDirection = 'before' | 'after';
export const generateSearchRecordsQuery = ({
objectMetadataItem,
objectMetadataItems,
recordGqlFields,
computeReferences,
}: {
objectMetadataItem: ObjectMetadataItem;
objectMetadataItems: ObjectMetadataItem[];
recordGqlFields?: RecordGqlOperationGqlRecordFields;
computeReferences?: boolean;
}) => gql`
query Search${capitalize(objectMetadataItem.namePlural)}($search: String, $limit: Int, $filter: ${capitalize(
objectMetadataItem.nameSingular,
)}FilterInput) {
${getSearchRecordsQueryResponseField(objectMetadataItem.namePlural)}(searchInput: $search, limit: $limit, filter: $filter){
edges {
node ${mapObjectMetadataToGraphQLQuery({
objectMetadataItems,
objectMetadataItem,
recordGqlFields,
computeReferences,
})}
}
pageInfo {
hasNextPage
hasPreviousPage
startCursor
endCursor
}
}
}
`;

View File

@ -1,4 +0,0 @@
import { capitalize } from 'twenty-shared';
export const getSearchRecordsQueryResponseField = (objectNamePlural: string) =>
`search${capitalize(objectNamePlural)}`;

View File

@ -1,9 +1,7 @@
import { useMapToObjectRecordIdentifier } from '@/object-metadata/hooks/useMapToObjectRecordIdentifier'; import { formatGlobalSearchRecordAsSingleRecordPickerRecord } from '@/object-metadata/utils/formatGlobalSearchRecordAsSingleRecordPickerRecord';
import { DEFAULT_SEARCH_REQUEST_LIMIT } from '@/object-record/constants/DefaultSearchRequestLimit'; import { DEFAULT_SEARCH_REQUEST_LIMIT } from '@/object-record/constants/DefaultSearchRequestLimit';
import { useSearchRecords } from '@/object-record/hooks/useSearchRecords'; import { useObjectRecordSearchRecords } from '@/object-record/hooks/useObjectRecordSearchRecords';
import { MultipleRecordPickerRecords } from '@/object-record/record-picker/multiple-record-picker/types/MultipleRecordPickerRecords';
import { SingleRecordPickerRecord } from '@/object-record/record-picker/single-record-picker/types/SingleRecordPickerRecord'; import { SingleRecordPickerRecord } from '@/object-record/record-picker/single-record-picker/types/SingleRecordPickerRecord';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { isDefined } from 'twenty-shared'; import { isDefined } from 'twenty-shared';
export const useFilteredSearchRecordQuery = ({ export const useFilteredSearchRecordQuery = ({
@ -18,19 +16,16 @@ export const useFilteredSearchRecordQuery = ({
excludedRecordIds?: string[]; excludedRecordIds?: string[];
objectNameSingular: string; objectNameSingular: string;
searchFilter?: string; searchFilter?: string;
}): MultipleRecordPickerRecords<SingleRecordPickerRecord> => { }): {
const { mapToObjectRecordIdentifier } = useMapToObjectRecordIdentifier({ selectedRecords: SingleRecordPickerRecord[];
objectNameSingular, filteredSelectedRecords: SingleRecordPickerRecord[];
}); recordsToSelect: SingleRecordPickerRecord[];
loading: boolean;
const mappingFunction = (record: ObjectRecord) => ({ } => {
...mapToObjectRecordIdentifier(record),
record,
});
const selectedIdsFilter = { id: { in: selectedIds } }; const selectedIdsFilter = { id: { in: selectedIds } };
const { loading: selectedRecordsLoading, records: selectedRecords } = const { loading: selectedRecordsLoading, searchRecords: selectedRecords } =
useSearchRecords({ useObjectRecordSearchRecords({
objectNameSingular, objectNameSingular,
filter: selectedIdsFilter, filter: selectedIdsFilter,
skip: !selectedIds.length, skip: !selectedIds.length,
@ -39,8 +34,8 @@ export const useFilteredSearchRecordQuery = ({
const { const {
loading: filteredSelectedRecordsLoading, loading: filteredSelectedRecordsLoading,
records: filteredSelectedRecords, searchRecords: filteredSelectedRecords,
} = useSearchRecords({ } = useObjectRecordSearchRecords({
objectNameSingular, objectNameSingular,
filter: selectedIdsFilter, filter: selectedIdsFilter,
skip: !selectedIds.length, skip: !selectedIds.length,
@ -51,8 +46,8 @@ export const useFilteredSearchRecordQuery = ({
const notFilter = notFilterIds.length const notFilter = notFilterIds.length
? { not: { id: { in: notFilterIds } } } ? { not: { id: { in: notFilterIds } } }
: undefined; : undefined;
const { loading: recordsToSelectLoading, records: recordsToSelect } = const { loading: recordsToSelectLoading, searchRecords: recordsToSelect } =
useSearchRecords({ useObjectRecordSearchRecords({
objectNameSingular, objectNameSingular,
filter: notFilter, filter: notFilter,
limit: limit ?? DEFAULT_SEARCH_REQUEST_LIMIT, limit: limit ?? DEFAULT_SEARCH_REQUEST_LIMIT,
@ -61,11 +56,15 @@ export const useFilteredSearchRecordQuery = ({
}); });
return { return {
selectedRecords: selectedRecords.map(mappingFunction).filter(isDefined), selectedRecords: selectedRecords
filteredSelectedRecords: filteredSelectedRecords .map(formatGlobalSearchRecordAsSingleRecordPickerRecord)
.map(mappingFunction) .filter(isDefined),
filteredSelectedRecords: filteredSelectedRecords
.map(formatGlobalSearchRecordAsSingleRecordPickerRecord)
.filter(isDefined),
recordsToSelect: recordsToSelect
.map(formatGlobalSearchRecordAsSingleRecordPickerRecord)
.filter(isDefined), .filter(isDefined),
recordsToSelect: recordsToSelect.map(mappingFunction).filter(isDefined),
loading: loading:
recordsToSelectLoading || recordsToSelectLoading ||
filteredSelectedRecordsLoading || filteredSelectedRecordsLoading ||

View File

@ -21,7 +21,11 @@ import {
Section, Section,
TooltipDelay, TooltipDelay,
} from 'twenty-ui'; } from 'twenty-ui';
import { Role, WorkspaceMember } from '~/generated-metadata/graphql'; import {
GlobalSearchRecord,
Role,
WorkspaceMember,
} from '~/generated-metadata/graphql';
import { import {
GetRolesDocument, GetRolesDocument,
useGetRolesQuery, useGetRolesQuery,
@ -129,14 +133,18 @@ export const RoleAssignment = ({ role }: RoleAssignmentProps) => {
setSelectedWorkspaceMember(null); setSelectedWorkspaceMember(null);
}; };
const handleSelectWorkspaceMember = (workspaceMember: WorkspaceMember) => { const handleSelectWorkspaceMember = (
const existingRole = workspaceMemberRoleMap.get(workspaceMember.id); workspaceMemberSearchRecord: GlobalSearchRecord,
) => {
const existingRole = workspaceMemberRoleMap.get(
workspaceMemberSearchRecord.recordId,
);
setSelectedWorkspaceMember({ setSelectedWorkspaceMember({
id: workspaceMember.id, id: workspaceMemberSearchRecord.recordId,
name: `${workspaceMember.name.firstName} ${workspaceMember.name.lastName}`, name: `${workspaceMemberSearchRecord.label}`,
role: existingRole, role: existingRole,
avatarUrl: workspaceMember.avatarUrl, avatarUrl: workspaceMemberSearchRecord.imageUrl,
}); });
setConfirmationModalIsOpen(true); setConfirmationModalIsOpen(true);
closeDropdown(); closeDropdown();

View File

@ -1,16 +1,16 @@
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useSearchRecords } from '@/object-record/hooks/useSearchRecords'; import { useObjectRecordSearchRecords } from '@/object-record/hooks/useObjectRecordSearchRecords';
import { RoleAssignmentWorkspaceMemberPickerDropdownContent } from '@/settings/roles/role-assignment/components/RoleAssignmentWorkspaceMemberPickerDropdownContent'; import { RoleAssignmentWorkspaceMemberPickerDropdownContent } from '@/settings/roles/role-assignment/components/RoleAssignmentWorkspaceMemberPickerDropdownContent';
import { DropdownMenu } from '@/ui/layout/dropdown/components/DropdownMenu'; import { DropdownMenu } from '@/ui/layout/dropdown/components/DropdownMenu';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { DropdownMenuSearchInput } from '@/ui/layout/dropdown/components/DropdownMenuSearchInput'; import { DropdownMenuSearchInput } from '@/ui/layout/dropdown/components/DropdownMenuSearchInput';
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator'; import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
import { ChangeEvent, useState } from 'react'; import { ChangeEvent, useState } from 'react';
import { WorkspaceMember } from '~/generated-metadata/graphql'; import { GlobalSearchRecord } from '~/generated-metadata/graphql';
type RoleAssignmentWorkspaceMemberPickerDropdownProps = { type RoleAssignmentWorkspaceMemberPickerDropdownProps = {
excludedWorkspaceMemberIds: string[]; excludedWorkspaceMemberIds: string[];
onSelect: (workspaceMember: WorkspaceMember) => void; onSelect: (workspaceMemberSearchRecord: GlobalSearchRecord) => void;
}; };
export const RoleAssignmentWorkspaceMemberPickerDropdown = ({ export const RoleAssignmentWorkspaceMemberPickerDropdown = ({
@ -19,15 +19,17 @@ export const RoleAssignmentWorkspaceMemberPickerDropdown = ({
}: RoleAssignmentWorkspaceMemberPickerDropdownProps) => { }: RoleAssignmentWorkspaceMemberPickerDropdownProps) => {
const [searchFilter, setSearchFilter] = useState(''); const [searchFilter, setSearchFilter] = useState('');
const { loading, records: workspaceMembers } = useSearchRecords({ const { loading, searchRecords: workspaceMembers } =
objectNameSingular: CoreObjectNameSingular.WorkspaceMember, useObjectRecordSearchRecords({
searchInput: searchFilter, objectNameSingular: CoreObjectNameSingular.WorkspaceMember,
}); searchInput: searchFilter,
});
const filteredWorkspaceMembers = (workspaceMembers?.filter( const filteredWorkspaceMembers =
(workspaceMember) => workspaceMembers?.filter(
!excludedWorkspaceMemberIds.includes(workspaceMember.id), (workspaceMember) =>
) ?? []) as WorkspaceMember[]; !excludedWorkspaceMemberIds.includes(workspaceMember.recordId),
) ?? [];
const handleSearchFilterChange = (event: ChangeEvent<HTMLInputElement>) => { const handleSearchFilterChange = (event: ChangeEvent<HTMLInputElement>) => {
setSearchFilter(event.target.value); setSearchFilter(event.target.value);

View File

@ -1,12 +1,12 @@
import { t } from '@lingui/core/macro'; import { t } from '@lingui/core/macro';
import { MenuItem, MenuItemAvatar } from 'twenty-ui'; import { MenuItem, MenuItemAvatar } from 'twenty-ui';
import { WorkspaceMember } from '~/generated-metadata/graphql'; import { GlobalSearchRecord } from '~/generated-metadata/graphql';
type RoleAssignmentWorkspaceMemberPickerDropdownContentProps = { type RoleAssignmentWorkspaceMemberPickerDropdownContentProps = {
loading: boolean; loading: boolean;
searchFilter: string; searchFilter: string;
filteredWorkspaceMembers: WorkspaceMember[]; filteredWorkspaceMembers: GlobalSearchRecord[];
onSelect: (workspaceMember: WorkspaceMember) => void; onSelect: (workspaceMemberSearchRecord: GlobalSearchRecord) => void;
}; };
export const RoleAssignmentWorkspaceMemberPickerDropdownContent = ({ export const RoleAssignmentWorkspaceMemberPickerDropdownContent = ({
@ -27,15 +27,15 @@ export const RoleAssignmentWorkspaceMemberPickerDropdownContent = ({
<> <>
{filteredWorkspaceMembers.map((workspaceMember) => ( {filteredWorkspaceMembers.map((workspaceMember) => (
<MenuItemAvatar <MenuItemAvatar
key={workspaceMember.id} key={workspaceMember.recordId}
onClick={() => onSelect(workspaceMember)} onClick={() => onSelect(workspaceMember)}
avatar={{ avatar={{
type: 'rounded', type: 'rounded',
size: 'md', size: 'md',
placeholder: workspaceMember.name.firstName ?? '', placeholder: workspaceMember.label ?? '',
placeholderColorSeed: workspaceMember.id, placeholderColorSeed: workspaceMember.recordId,
}} }}
text={`${workspaceMember.name.firstName} ${workspaceMember.name.lastName}`} text={workspaceMember.label}
/> />
))} ))}
</> </>