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<{
searchInput: Scalars['String'];
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 GetClientConfigQueryResult = Apollo.QueryResult<GetClientConfigQuery, GetClientConfigQueryVariables>;
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(
searchInput: $searchInput
limit: $limit
excludedObjectNameSingulars: $excludedObjectNameSingulars
includedObjectNameSingulars: $includedObjectNameSingulars
filter: $filter
) {
recordId
objectSingularName
@ -4126,6 +4130,8 @@ export const GlobalSearchDocument = gql`
* searchInput: // value for 'searchInput'
* limit: // value for 'limit'
* 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 { useCreateOneRecord } from '@/object-record/hooks/useCreateOneRecord';
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 { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { isNull } from '@sniptt/guards';
@ -47,6 +48,7 @@ export const useUpdateActivityTargetFromInlineCell = ({
async ({
morphItem,
activityTargetWithTargetRecords,
recordPickerInstanceId,
}: UpdateActivityTargetFromInlineCellProps) => {
const targetObjectName =
activityObjectNameSingular === CoreObjectNameSingular.Task
@ -92,11 +94,16 @@ export const useUpdateActivityTargetFromInlineCell = ({
);
}
} else {
const targetRecord = snapshot
.getLoadable(recordStoreFamilyState(morphItem.recordId))
const searchRecord = snapshot
.getLoadable(
searchRecordStoreComponentFamilyState.atomFamily({
instanceId: recordPickerInstanceId,
familyKey: morphItem.recordId,
}),
)
.getValue();
if (!isDefined(targetRecord)) {
if (!isDefined(searchRecord) || !isDefined(searchRecord?.record)) {
return;
}
@ -104,6 +111,8 @@ export const useUpdateActivityTargetFromInlineCell = ({
return;
}
const targetRecord = searchRecord.record;
const activityTarget =
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(
$searchInput: String!
$limit: Int!
$excludedObjectNameSingulars: [String!]!
$excludedObjectNameSingulars: [String!]
$includedObjectNameSingulars: [String!]
$filter: ObjectRecordFilterInput
) {
globalSearch(
searchInput: $searchInput
limit: $limit
excludedObjectNameSingulars: $excludedObjectNameSingulars
includedObjectNameSingulars: $includedObjectNameSingulars
filter: $filter
) {
recordId
objectSingularName

View File

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

View File

@ -1,9 +1,9 @@
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';
export const CommandMenuSearchRecordsPage = () => {
const { commandGroups, loading, noResults } = useSearchRecords();
const { commandGroups, loading, noResults } = useCommandMenuSearchRecords();
const selectableItemIds = useMemo(() => {
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',
metadata: {
fieldName: 'Relation',
relationObjectMetadataNamePlural: 'workspaceMembers',
relationObjectMetadataNameSingular:
CoreObjectNameSingular.WorkspaceMember,
relationObjectMetadataNamePlural: 'companies',
relationObjectMetadataNameSingular: CoreObjectNameSingular.Company,
objectMetadataNameSingular: 'person',
relationFieldMetadataId: '20202020-8c37-4163-ba06-1dada334ce3e',
},
@ -149,7 +148,7 @@ export const Submit: Story = {
expect(submitJestFn).toHaveBeenCalledTimes(0);
const item = await canvas.findByText('John Wick', undefined, {
const item = await canvas.findByText('Linkedin', undefined, {
timeout: 3000,
});
@ -167,7 +166,7 @@ export const Cancel: Story = {
const canvas = within(getCanvasElementForDropdownTesting());
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');

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

View File

@ -3,17 +3,16 @@ import { useRecoilValue } from 'recoil';
import { Avatar, MenuItemMultiSelectAvatar } from 'twenty-ui';
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 { 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 { 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 { useSelectableList } from '@/ui/layout/selectable-list/hooks/useSelectableList';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
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)`
height: 100%;
@ -21,13 +20,13 @@ export const StyledSelectableItem = styled(SelectableItem)`
`;
type MultipleRecordPickerMenuItemContentProps = {
record: ObjectRecord;
searchRecord: GlobalSearchRecord;
objectMetadataItem: ObjectMetadataItem;
onChange: (morphItem: RecordPickerPickableMorphItem) => void;
};
export const MultipleRecordPickerMenuItemContent = ({
record,
searchRecord,
objectMetadataItem,
onChange,
}: MultipleRecordPickerMenuItemContentProps) => {
@ -43,49 +42,43 @@ export const MultipleRecordPickerMenuItemContent = ({
);
const isSelectedByKeyboard = useRecoilValue(
isSelectedItemIdSelector(record.id),
isSelectedItemIdSelector(searchRecord.recordId),
);
const isRecordSelectedWithObjectItem = useRecoilComponentFamilyValueV2(
multipleRecordPickerIsSelectedComponentFamilySelector,
record.id,
searchRecord.recordId,
componentInstanceId,
);
const handleSelectChange = (isSelected: boolean) => {
onChange({
recordId: record.id,
recordId: searchRecord.recordId,
objectMetadataId: objectMetadataItem.id,
isSelected,
isMatchingSearchFilter: true,
});
};
const recordIdentifier = getObjectRecordIdentifier({
objectMetadataItem,
record,
});
if (!isDefined(recordIdentifier)) {
return null;
}
return (
<StyledSelectableItem itemId={record.id} key={record.id}>
<StyledSelectableItem
itemId={searchRecord.recordId}
key={searchRecord.recordId}
>
<MenuItemMultiSelectAvatar
onSelectChange={(isSelected) => handleSelectChange(isSelected)}
isKeySelected={isSelectedByKeyboard}
selected={isRecordSelectedWithObjectItem}
avatar={
<Avatar
avatarUrl={recordIdentifier.avatarUrl}
placeholderColorSeed={record.id}
placeholder={recordIdentifier.name}
avatarUrl={searchRecord.imageUrl}
placeholderColorSeed={searchRecord.recordId}
placeholder={searchRecord.label}
size="md"
type={recordIdentifier.avatarType ?? 'rounded'}
type={getAvatarType(objectMetadataItem.nameSingular) ?? 'rounded'}
/>
}
text={recordIdentifier.name}
text={searchRecord.label}
/>
</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 { CombinedFindManyRecordsQueryResult } from '@/object-record/multiple-objects/types/CombinedFindManyRecordsQueryResult';
import { generateCombinedSearchRecordsQuery } from '@/object-record/multiple-objects/utils/generateCombinedSearchRecordsQuery';
import { getLimitPerMetadataItem } from '@/object-record/multiple-objects/utils/getLimitPerMetadataItem';
import { usePerformCombinedFindManyRecords } from '@/object-record/multiple-objects/hooks/usePerformCombinedFindManyRecords';
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 { 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 { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { ApolloClient, useApolloClient } from '@apollo/client';
import { isNonEmptyArray } from '@sniptt/guards';
import { useRecoilCallback } from 'recoil';
import { capitalize, isDefined } from 'twenty-shared';
import { GlobalSearchRecord } from '~/generated-metadata/graphql';
export const useMultipleRecordPickerPerformSearch = () => {
const client = useApolloClient();
const { performCombinedFindManyRecords } =
usePerformCombinedFindManyRecords();
const performSearch = useRecoilCallback(
({ snapshot, set }) =>
async ({
@ -29,57 +32,52 @@ export const useMultipleRecordPickerPerformSearch = () => {
forceSearchableObjectMetadataItems?: ObjectMetadataItem[];
forcePickableMorphItems?: RecordPickerPickableMorphItem[];
}) => {
const recordPickerSearchFilter = snapshot
.getLoadable(
multipleRecordPickerSearchFilterComponentState.atomFamily({
instanceId: multipleRecordPickerInstanceId,
}),
)
.getValue();
const { getLoadable } = snapshot;
const recordPickerSearchFilter = getLoadable(
multipleRecordPickerSearchFilterComponentState.atomFamily({
instanceId: multipleRecordPickerInstanceId,
}),
).getValue();
const searchFilter = forceSearchFilter ?? recordPickerSearchFilter;
const recordPickerSearchableObjectMetadataItems = snapshot
.getLoadable(
multipleRecordPickerSearchableObjectMetadataItemsComponentState.atomFamily(
{ instanceId: multipleRecordPickerInstanceId },
),
)
.getValue();
const recordPickerSearchableObjectMetadataItems = getLoadable(
multipleRecordPickerSearchableObjectMetadataItemsComponentState.atomFamily(
{ instanceId: multipleRecordPickerInstanceId },
),
).getValue();
const searchableObjectMetadataItems =
forceSearchableObjectMetadataItems.length > 0
? forceSearchableObjectMetadataItems
: recordPickerSearchableObjectMetadataItems;
const recordPickerPickableMorphItems = snapshot
.getLoadable(
multipleRecordPickerPickableMorphItemsComponentState.atomFamily({
instanceId: multipleRecordPickerInstanceId,
}),
)
.getValue();
const recordPickerPickableMorphItems = getLoadable(
multipleRecordPickerPickableMorphItemsComponentState.atomFamily({
instanceId: multipleRecordPickerInstanceId,
}),
).getValue();
const pickableMorphItems =
forcePickableMorphItems.length > 0
? forcePickableMorphItems
: recordPickerPickableMorphItems;
const selectedPickableMorphItems = pickableMorphItems.filter(
({ isSelected }) => isSelected,
);
const recordsWithObjectMetadataIdFilteredOnPickedRecords =
await performSearchForPickedRecords({
client,
searchFilter,
searchableObjectMetadataItems,
pickableMorphItems,
});
const recordsWithObjectMetadataIdExcludingPickedRecords =
await performSearchExcludingPickedRecords({
client,
searchFilter,
searchableObjectMetadataItems,
pickableMorphItems,
});
const [
searchRecordsFilteredOnPickedRecords,
searchRecordsExcludingPickedRecords,
] = await performSearchQueries({
client,
searchFilter,
searchableObjectMetadataItems,
pickedRecordIds: selectedPickableMorphItems.map(
({ recordId }) => recordId,
),
});
const pickedMorphItems = pickableMorphItems.filter(
({ isSelected }) => isSelected,
@ -87,10 +85,9 @@ export const useMultipleRecordPickerPerformSearch = () => {
// We update the existing pickedMorphItems to be matching the search filter
const updatedPickedMorphItems = pickedMorphItems.map((morphItem) => {
const record =
recordsWithObjectMetadataIdFilteredOnPickedRecords.find(
({ record }) => record.id === morphItem.recordId,
);
const record = searchRecordsFilteredOnPickedRecords.find(
({ recordId }) => recordId === morphItem.recordId,
);
return {
...morphItem,
@ -98,40 +95,47 @@ export const useMultipleRecordPickerPerformSearch = () => {
};
});
const recordsWithObjectMetadataIdFilteredOnPickedRecordsWithoutDuplicates =
recordsWithObjectMetadataIdFilteredOnPickedRecords.filter(
({ record }) =>
const searchRecordsFilteredOnPickedRecordsWithoutDuplicates =
searchRecordsFilteredOnPickedRecords.filter(
(searchRecord) =>
!updatedPickedMorphItems.some(
({ recordId }) => recordId === record.id,
({ recordId }) => recordId === searchRecord.recordId,
),
);
const recordsWithObjectMetadataIdExcludingPickedRecordsWithoutDuplicates =
recordsWithObjectMetadataIdExcludingPickedRecords.filter(
({ record }) =>
!recordsWithObjectMetadataIdFilteredOnPickedRecords.some(
({ record: recordFilteredOnPickedRecords }) =>
recordFilteredOnPickedRecords.id === record.id,
const searchRecordsExcludingPickedRecordsWithoutDuplicates =
searchRecordsExcludingPickedRecords.filter(
(searchRecord) =>
!searchRecordsFilteredOnPickedRecords.some(
({ recordId }) => recordId === searchRecord.recordId,
) &&
!pickedMorphItems.some(({ recordId }) => recordId === record.id),
!pickedMorphItems.some(
({ recordId }) => recordId === searchRecord.recordId,
),
);
const morphItems = [
...updatedPickedMorphItems,
...recordsWithObjectMetadataIdFilteredOnPickedRecordsWithoutDuplicates.map(
({ record, objectMetadataItem }) => ({
...searchRecordsFilteredOnPickedRecordsWithoutDuplicates.map(
({ recordId, objectSingularName }) => ({
isMatchingSearchFilter: true,
isSelected: true,
objectMetadataId: objectMetadataItem.id,
recordId: record.id,
objectMetadataId: searchableObjectMetadataItems.find(
(objectMetadata) =>
objectMetadata.nameSingular === objectSingularName,
)?.id,
recordId,
}),
),
...recordsWithObjectMetadataIdExcludingPickedRecordsWithoutDuplicates.map(
({ record, objectMetadataItem }) => ({
...searchRecordsExcludingPickedRecordsWithoutDuplicates.map(
({ recordId, objectSingularName }) => ({
isMatchingSearchFilter: true,
isSelected: false,
objectMetadataId: objectMetadataItem.id,
recordId: record.id,
objectMetadataId: searchableObjectMetadataItems.find(
(objectMetadata) =>
objectMetadata.nameSingular === objectSingularName,
)?.id,
recordId,
}),
),
];
@ -143,192 +147,153 @@ export const useMultipleRecordPickerPerformSearch = () => {
morphItems,
);
[
...recordsWithObjectMetadataIdFilteredOnPickedRecords,
...recordsWithObjectMetadataIdExcludingPickedRecordsWithoutDuplicates,
].forEach(({ record }) => {
set(recordStoreFamilyState(record.id), record);
const searchRecords = [
...searchRecordsFilteredOnPickedRecords,
...searchRecordsExcludingPickedRecordsWithoutDuplicates,
];
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 };
};
const performSearchForPickedRecords = async ({
const performSearchQueries = async ({
client,
searchFilter,
searchableObjectMetadataItems,
pickableMorphItems,
pickedRecordIds,
}: {
client: ApolloClient<object>;
searchFilter: string;
searchableObjectMetadataItems: ObjectMetadataItem[];
pickableMorphItems: RecordPickerPickableMorphItem[];
}) => {
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[];
}) => {
pickedRecordIds: string[];
}): Promise<[GlobalSearchRecord[], GlobalSearchRecord[]]> => {
if (searchableObjectMetadataItems.length === 0) {
return [];
return [[], []];
}
const pickedMorphItems = pickableMorphItems.filter(
({ isSelected }) => isSelected,
);
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,
const searchRecords = async (filter: any) => {
const { data } = await client.query({
query: globalSearch,
variables: {
search: searchFilter,
...limitPerMetadataItem,
...filterPerMetadataItemExcludingPickedRecordId,
searchInput: searchFilter,
includedObjectNameSingulars: searchableObjectMetadataItems.map(
({ nameSingular }) => nameSingular,
),
filter,
limit: MAX_SEARCH_RESULTS,
},
});
return data.globalSearch;
};
const {
recordsWithObjectMetadataId:
recordsWithObjectMetadataIdExcludingPickedRecords,
} = multipleRecordPickerformatQueryResultAsRecordsWithObjectMetadataId({
objectMetadataItems: searchableObjectMetadataItems,
searchQueryResult: combinedSearchRecordExcludingPickedRecordsQueryResult,
});
const searchRecordsExcludingPickedRecords = await searchRecords(
pickedRecordIds.length > 0
? {
not: {
id: {
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,
}}
>
<ActivityTargetsInlineCell
activityObjectNameSingular={
objectNameSingular as
| CoreObjectNameSingular.Note
| CoreObjectNameSingular.Task
}
activityRecordId={objectRecordId}
showLabel={true}
maxWidth={200}
/>
<RecordFieldComponentInstanceContext.Provider
value={{
instanceId: objectRecordId + fieldMetadataItem.id,
}}
>
<ActivityTargetsInlineCell
activityObjectNameSingular={
objectNameSingular as
| CoreObjectNameSingular.Note
| CoreObjectNameSingular.Task
}
activityRecordId={objectRecordId}
showLabel={true}
maxWidth={200}
/>
</RecordFieldComponentInstanceContext.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 { useSearchRecords } from '@/object-record/hooks/useSearchRecords';
import { MultipleRecordPickerRecords } from '@/object-record/record-picker/multiple-record-picker/types/MultipleRecordPickerRecords';
import { useObjectRecordSearchRecords } from '@/object-record/hooks/useObjectRecordSearchRecords';
import { SingleRecordPickerRecord } from '@/object-record/record-picker/single-record-picker/types/SingleRecordPickerRecord';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { isDefined } from 'twenty-shared';
export const useFilteredSearchRecordQuery = ({
@ -18,19 +16,16 @@ export const useFilteredSearchRecordQuery = ({
excludedRecordIds?: string[];
objectNameSingular: string;
searchFilter?: string;
}): MultipleRecordPickerRecords<SingleRecordPickerRecord> => {
const { mapToObjectRecordIdentifier } = useMapToObjectRecordIdentifier({
objectNameSingular,
});
const mappingFunction = (record: ObjectRecord) => ({
...mapToObjectRecordIdentifier(record),
record,
});
}): {
selectedRecords: SingleRecordPickerRecord[];
filteredSelectedRecords: SingleRecordPickerRecord[];
recordsToSelect: SingleRecordPickerRecord[];
loading: boolean;
} => {
const selectedIdsFilter = { id: { in: selectedIds } };
const { loading: selectedRecordsLoading, records: selectedRecords } =
useSearchRecords({
const { loading: selectedRecordsLoading, searchRecords: selectedRecords } =
useObjectRecordSearchRecords({
objectNameSingular,
filter: selectedIdsFilter,
skip: !selectedIds.length,
@ -39,8 +34,8 @@ export const useFilteredSearchRecordQuery = ({
const {
loading: filteredSelectedRecordsLoading,
records: filteredSelectedRecords,
} = useSearchRecords({
searchRecords: filteredSelectedRecords,
} = useObjectRecordSearchRecords({
objectNameSingular,
filter: selectedIdsFilter,
skip: !selectedIds.length,
@ -51,8 +46,8 @@ export const useFilteredSearchRecordQuery = ({
const notFilter = notFilterIds.length
? { not: { id: { in: notFilterIds } } }
: undefined;
const { loading: recordsToSelectLoading, records: recordsToSelect } =
useSearchRecords({
const { loading: recordsToSelectLoading, searchRecords: recordsToSelect } =
useObjectRecordSearchRecords({
objectNameSingular,
filter: notFilter,
limit: limit ?? DEFAULT_SEARCH_REQUEST_LIMIT,
@ -61,11 +56,15 @@ export const useFilteredSearchRecordQuery = ({
});
return {
selectedRecords: selectedRecords.map(mappingFunction).filter(isDefined),
filteredSelectedRecords: filteredSelectedRecords
.map(mappingFunction)
selectedRecords: selectedRecords
.map(formatGlobalSearchRecordAsSingleRecordPickerRecord)
.filter(isDefined),
filteredSelectedRecords: filteredSelectedRecords
.map(formatGlobalSearchRecordAsSingleRecordPickerRecord)
.filter(isDefined),
recordsToSelect: recordsToSelect
.map(formatGlobalSearchRecordAsSingleRecordPickerRecord)
.filter(isDefined),
recordsToSelect: recordsToSelect.map(mappingFunction).filter(isDefined),
loading:
recordsToSelectLoading ||
filteredSelectedRecordsLoading ||

View File

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

View File

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

View File

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