mirror of
				https://github.com/lingble/twenty.git
				synced 2025-10-31 20:57:55 +00:00 
			
		
		
		
	Add custom objects to command menu search + use ilike for notes search (#8564)
In this PR - Re-introduce previously used search based on "ILIKE" queries for search on notes since the tsvector search with json text is not working correctly (@charlesBochet) - Add search on custom objects in Command Menu bar (closes https://github.com/twentyhq/twenty/issues/8522) https://github.com/user-attachments/assets/0cc064cf-889d-4f2c-8747-6d8670f35a39
This commit is contained in:
		| @@ -2,6 +2,7 @@ import { useOpenCopilotRightDrawer } from '@/activities/copilot/right-drawer/hoo | ||||
| import { copilotQueryState } from '@/activities/copilot/right-drawer/states/copilotQueryState'; | ||||
| import { useOpenActivityRightDrawer } from '@/activities/hooks/useOpenActivityRightDrawer'; | ||||
| import { Note } from '@/activities/types/Note'; | ||||
| import { Task } from '@/activities/types/Task'; | ||||
| import { CommandGroup } from '@/command-menu/components/CommandGroup'; | ||||
| import { CommandMenuItem } from '@/command-menu/components/CommandMenuItem'; | ||||
| import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu'; | ||||
| @@ -12,11 +13,13 @@ import { Command, CommandType } from '@/command-menu/types/Command'; | ||||
| import { Company } from '@/companies/types/Company'; | ||||
| import { mainContextStoreComponentInstanceIdState } from '@/context-store/states/mainContextStoreComponentInstanceId'; | ||||
| import { useKeyboardShortcutMenu } from '@/keyboard-shortcut-menu/hooks/useKeyboardShortcutMenu'; | ||||
| import { CoreObjectNamePlural } from '@/object-metadata/types/CoreObjectNamePlural'; | ||||
| import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; | ||||
| import { getCompanyDomainName } from '@/object-metadata/utils/getCompanyDomainName'; | ||||
| import { useSearchRecords } from '@/object-record/hooks/useSearchRecords'; | ||||
| import { Opportunity } from '@/opportunities/types/Opportunity'; | ||||
| import { Person } from '@/people/types/Person'; | ||||
| import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords'; | ||||
| import { useMultiObjectSearch } from '@/object-record/relation-picker/hooks/useMultiObjectSearch'; | ||||
| import { useMultiObjectSearchQueryResultFormattedAsObjectRecordsMap } from '@/object-record/relation-picker/hooks/useMultiObjectSearchQueryResultFormattedAsObjectRecordsMap'; | ||||
| import { makeOrFilterVariables } from '@/object-record/utils/makeOrFilterVariables'; | ||||
| import { SelectableItem } from '@/ui/layout/selectable-list/components/SelectableItem'; | ||||
| import { SelectableList } from '@/ui/layout/selectable-list/components/SelectableList'; | ||||
| import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys'; | ||||
| @@ -27,11 +30,14 @@ import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper'; | ||||
| import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; | ||||
| import styled from '@emotion/styled'; | ||||
| import { isNonEmptyString } from '@sniptt/guards'; | ||||
| import isEmpty from 'lodash.isempty'; | ||||
| import { useMemo, useRef } from 'react'; | ||||
| import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil'; | ||||
| import { Key } from 'ts-key-enum'; | ||||
| import { | ||||
|   Avatar, | ||||
|   IconCheckbox, | ||||
|   IconComponent, | ||||
|   IconNotes, | ||||
|   IconSparkles, | ||||
|   IconX, | ||||
| @@ -40,11 +46,25 @@ import { | ||||
| } from 'twenty-ui'; | ||||
| import { useDebounce } from 'use-debounce'; | ||||
| import { getLogoUrlFromDomainName } from '~/utils'; | ||||
| import { capitalize } from '~/utils/string/capitalize'; | ||||
|  | ||||
| const SEARCH_BAR_HEIGHT = 56; | ||||
| const SEARCH_BAR_PADDING = 3; | ||||
| const MOBILE_NAVIGATION_BAR_HEIGHT = 64; | ||||
|  | ||||
| type CommandGroupConfig = { | ||||
|   heading: string; | ||||
|   items?: any[]; | ||||
|   renderItem: (item: any) => { | ||||
|     id: string; | ||||
|     Icon?: IconComponent; | ||||
|     label: string; | ||||
|     to?: string; | ||||
|     onClick?: () => void; | ||||
|     key?: string; | ||||
|   }; | ||||
| }; | ||||
|  | ||||
| const StyledCommandMenu = styled.div` | ||||
|   background: ${({ theme }) => theme.background.secondary}; | ||||
|   border-left: 1px solid ${({ theme }) => theme.border.color.medium}; | ||||
| @@ -170,37 +190,68 @@ export const CommandMenu = () => { | ||||
|     [closeCommandMenu], | ||||
|   ); | ||||
|  | ||||
|   const { loading: isPeopleLoading, records: people } = | ||||
|     useSearchRecords<Person>({ | ||||
|       skip: !isCommandMenuOpened, | ||||
|       objectNameSingular: CoreObjectNameSingular.Person, | ||||
|   const { | ||||
|     matchesSearchFilterObjectRecordsQueryResult, | ||||
|     matchesSearchFilterObjectRecordsLoading: loading, | ||||
|   } = useMultiObjectSearch({ | ||||
|     excludedObjects: [CoreObjectNameSingular.Task, CoreObjectNameSingular.Note], | ||||
|     searchFilterValue: deferredCommandMenuSearch ?? undefined, | ||||
|     limit: 3, | ||||
|       searchInput: deferredCommandMenuSearch ?? undefined, | ||||
|   }); | ||||
|  | ||||
|   const { loading: isCompaniesLoading, records: companies } = | ||||
|     useSearchRecords<Company>({ | ||||
|       skip: !isCommandMenuOpened, | ||||
|       objectNameSingular: CoreObjectNameSingular.Company, | ||||
|       limit: 3, | ||||
|       searchInput: deferredCommandMenuSearch ?? undefined, | ||||
|   const { objectRecordsMap: matchesSearchFilterObjectRecords } = | ||||
|     useMultiObjectSearchQueryResultFormattedAsObjectRecordsMap({ | ||||
|       multiObjectRecordsQueryResult: | ||||
|         matchesSearchFilterObjectRecordsQueryResult, | ||||
|     }); | ||||
|  | ||||
|   const { loading: isNotesLoading, records: notes } = useSearchRecords<Note>({ | ||||
|   const { loading: isNotesLoading, records: notes } = useFindManyRecords<Note>({ | ||||
|     skip: !isCommandMenuOpened, | ||||
|     objectNameSingular: CoreObjectNameSingular.Note, | ||||
|     filter: deferredCommandMenuSearch | ||||
|       ? makeOrFilterVariables([ | ||||
|           { title: { ilike: `%${deferredCommandMenuSearch}%` } }, | ||||
|           { body: { ilike: `%${deferredCommandMenuSearch}%` } }, | ||||
|         ]) | ||||
|       : undefined, | ||||
|     limit: 3, | ||||
|     searchInput: deferredCommandMenuSearch ?? undefined, | ||||
|   }); | ||||
|  | ||||
|   const { loading: isOpportunitiesLoading, records: opportunities } = | ||||
|     useSearchRecords<Opportunity>({ | ||||
|   const { loading: isTasksLoading, records: tasks } = useFindManyRecords<Task>({ | ||||
|     skip: !isCommandMenuOpened, | ||||
|       objectNameSingular: CoreObjectNameSingular.Opportunity, | ||||
|     objectNameSingular: CoreObjectNameSingular.Task, | ||||
|     filter: deferredCommandMenuSearch | ||||
|       ? makeOrFilterVariables([ | ||||
|           { title: { ilike: `%${deferredCommandMenuSearch}%` } }, | ||||
|           { body: { ilike: `%${deferredCommandMenuSearch}%` } }, | ||||
|         ]) | ||||
|       : undefined, | ||||
|     limit: 3, | ||||
|       searchInput: deferredCommandMenuSearch ?? undefined, | ||||
|   }); | ||||
|  | ||||
|   const people = matchesSearchFilterObjectRecords.people?.map( | ||||
|     (people) => people.record, | ||||
|   ); | ||||
|   const companies = matchesSearchFilterObjectRecords.companies?.map( | ||||
|     (companies) => companies.record, | ||||
|   ); | ||||
|   const opportunities = matchesSearchFilterObjectRecords.opportunities?.map( | ||||
|     (opportunities) => opportunities.record, | ||||
|   ); | ||||
|  | ||||
|   const customObjectRecordsMap = useMemo(() => { | ||||
|     return Object.fromEntries( | ||||
|       Object.entries(matchesSearchFilterObjectRecords).filter( | ||||
|         ([namePlural, records]) => | ||||
|           ![ | ||||
|             CoreObjectNamePlural.Person, | ||||
|             CoreObjectNamePlural.Opportunity, | ||||
|             CoreObjectNamePlural.Company, | ||||
|           ].includes(namePlural as CoreObjectNamePlural) && !isEmpty(records), | ||||
|       ), | ||||
|     ); | ||||
|   }, [matchesSearchFilterObjectRecords]); | ||||
|  | ||||
|   const peopleCommands = useMemo( | ||||
|     () => | ||||
|       people?.map(({ id, name: { firstName, lastName } }) => ({ | ||||
| @@ -242,6 +293,32 @@ export const CommandMenu = () => { | ||||
|     [notes, openActivityRightDrawer], | ||||
|   ); | ||||
|  | ||||
|   const tasksCommands = useMemo( | ||||
|     () => | ||||
|       tasks?.map((task) => ({ | ||||
|         id: task.id, | ||||
|         label: task.title ?? '', | ||||
|         to: '', | ||||
|         onCommandClick: () => openActivityRightDrawer(task.id), | ||||
|       })), | ||||
|     [tasks, openActivityRightDrawer], | ||||
|   ); | ||||
|  | ||||
|   const customObjectCommands = useMemo(() => { | ||||
|     const customObjectCommandsArray: Command[] = []; | ||||
|     Object.values(customObjectRecordsMap).forEach((objectRecords) => { | ||||
|       customObjectCommandsArray.push( | ||||
|         ...objectRecords.map((objectRecord) => ({ | ||||
|           id: objectRecord.record.id, | ||||
|           label: objectRecord.recordIdentifier.name, | ||||
|           to: `object/${objectRecord.objectMetadataItem.nameSingular}/${objectRecord.record.id}`, | ||||
|         })), | ||||
|       ); | ||||
|     }); | ||||
|  | ||||
|     return customObjectCommandsArray; | ||||
|   }, [customObjectRecordsMap]); | ||||
|  | ||||
|   const otherCommands = useMemo(() => { | ||||
|     const commandsArray: Command[] = []; | ||||
|     if (peopleCommands?.length > 0) { | ||||
| @@ -256,8 +333,21 @@ export const CommandMenu = () => { | ||||
|     if (noteCommands?.length > 0) { | ||||
|       commandsArray.push(...(noteCommands as Command[])); | ||||
|     } | ||||
|     if (tasksCommands?.length > 0) { | ||||
|       commandsArray.push(...(tasksCommands as Command[])); | ||||
|     } | ||||
|     if (customObjectCommands?.length > 0) { | ||||
|       commandsArray.push(...(customObjectCommands as Command[])); | ||||
|     } | ||||
|     return commandsArray; | ||||
|   }, [peopleCommands, companyCommands, noteCommands, opportunityCommands]); | ||||
|   }, [ | ||||
|     peopleCommands, | ||||
|     companyCommands, | ||||
|     opportunityCommands, | ||||
|     noteCommands, | ||||
|     customObjectCommands, | ||||
|     tasksCommands, | ||||
|   ]); | ||||
|  | ||||
|   const checkInShortcuts = (cmd: Command, search: string) => { | ||||
|     return (cmd.firstHotKey + (cmd.secondHotKey ?? '')) | ||||
| @@ -335,7 +425,15 @@ export const CommandMenu = () => { | ||||
|     .concat(people?.map((person) => person.id)) | ||||
|     .concat(companies?.map((company) => company.id)) | ||||
|     .concat(opportunities?.map((opportunity) => opportunity.id)) | ||||
|     .concat(notes?.map((note) => note.id)); | ||||
|     .concat(notes?.map((note) => note.id)) | ||||
|     .concat(tasks?.map((task) => task.id)) | ||||
|     .concat( | ||||
|       Object.values(customObjectRecordsMap) | ||||
|         ?.map((objectRecords) => | ||||
|           objectRecords.map((objectRecord) => objectRecord.record.id), | ||||
|         ) | ||||
|         .flat() ?? [], | ||||
|     ); | ||||
|  | ||||
|   const isNoResults = | ||||
|     !matchingStandardActionCommands.length && | ||||
| @@ -345,18 +443,133 @@ export const CommandMenu = () => { | ||||
|     !people?.length && | ||||
|     !companies?.length && | ||||
|     !notes?.length && | ||||
|     !opportunities?.length; | ||||
|     !tasks?.length && | ||||
|     !opportunities?.length && | ||||
|     isEmpty(customObjectRecordsMap); | ||||
|  | ||||
|   const isLoading = | ||||
|     isPeopleLoading || | ||||
|     isNotesLoading || | ||||
|     isOpportunitiesLoading || | ||||
|     isCompaniesLoading; | ||||
|   const isLoading = loading || isNotesLoading || isTasksLoading; | ||||
|  | ||||
|   const mainContextStoreComponentInstanceId = useRecoilValue( | ||||
|     mainContextStoreComponentInstanceIdState, | ||||
|   ); | ||||
|  | ||||
|   const commandGroups: CommandGroupConfig[] = [ | ||||
|     { | ||||
|       heading: 'Navigate', | ||||
|       items: matchingNavigateCommand, | ||||
|       renderItem: (command) => ({ | ||||
|         id: command.id, | ||||
|         Icon: command.Icon, | ||||
|         label: command.label, | ||||
|         to: command.to, | ||||
|         onClick: command.onCommandClick, | ||||
|       }), | ||||
|     }, | ||||
|     { | ||||
|       heading: 'Other', | ||||
|       items: matchingCreateCommand, | ||||
|       renderItem: (command) => ({ | ||||
|         id: command.id, | ||||
|         Icon: command.Icon, | ||||
|         label: command.label, | ||||
|         to: command.to, | ||||
|         onClick: command.onCommandClick, | ||||
|       }), | ||||
|     }, | ||||
|     { | ||||
|       heading: 'People', | ||||
|       items: people, | ||||
|       renderItem: (person) => ({ | ||||
|         id: person.id, | ||||
|         label: `${person.name.firstName} ${person.name.lastName}`, | ||||
|         to: `object/person/${person.id}`, | ||||
|         Icon: () => ( | ||||
|           <Avatar | ||||
|             type="rounded" | ||||
|             avatarUrl={null} | ||||
|             placeholderColorSeed={person.id} | ||||
|             placeholder={`${person.name.firstName} ${person.name.lastName}`} | ||||
|           /> | ||||
|         ), | ||||
|       }), | ||||
|     }, | ||||
|     { | ||||
|       heading: 'Companies', | ||||
|       items: companies, | ||||
|       renderItem: (company) => ({ | ||||
|         id: company.id, | ||||
|         label: company.name, | ||||
|         to: `object/company/${company.id}`, | ||||
|         Icon: () => ( | ||||
|           <Avatar | ||||
|             placeholderColorSeed={company.id} | ||||
|             placeholder={company.name} | ||||
|             avatarUrl={getLogoUrlFromDomainName( | ||||
|               getCompanyDomainName(company as Company), | ||||
|             )} | ||||
|           /> | ||||
|         ), | ||||
|       }), | ||||
|     }, | ||||
|     { | ||||
|       heading: 'Opportunities', | ||||
|       items: opportunities, | ||||
|       renderItem: (opportunity) => ({ | ||||
|         id: opportunity.id, | ||||
|         label: opportunity.name ?? '', | ||||
|         to: `object/opportunity/${opportunity.id}`, | ||||
|         Icon: () => ( | ||||
|           <Avatar | ||||
|             type="rounded" | ||||
|             avatarUrl={null} | ||||
|             placeholderColorSeed={opportunity.id} | ||||
|             placeholder={opportunity.name ?? ''} | ||||
|           /> | ||||
|         ), | ||||
|       }), | ||||
|     }, | ||||
|     { | ||||
|       heading: 'Notes', | ||||
|       items: notes, | ||||
|       renderItem: (note) => ({ | ||||
|         id: note.id, | ||||
|         Icon: IconNotes, | ||||
|         label: note.title ?? '', | ||||
|         onClick: () => openActivityRightDrawer(note.id), | ||||
|       }), | ||||
|     }, | ||||
|     { | ||||
|       heading: 'Tasks', | ||||
|       items: tasks, | ||||
|       renderItem: (task) => ({ | ||||
|         id: task.id, | ||||
|         Icon: IconCheckbox, | ||||
|         label: task.title ?? '', | ||||
|         onClick: () => openActivityRightDrawer(task.id), | ||||
|       }), | ||||
|     }, | ||||
|     ...Object.entries(customObjectRecordsMap).map( | ||||
|       ([customObjectNamePlural, objectRecords]): CommandGroupConfig => ({ | ||||
|         heading: capitalize(customObjectNamePlural), | ||||
|         items: objectRecords, | ||||
|         renderItem: (objectRecord) => ({ | ||||
|           key: objectRecord.record.id, | ||||
|           id: objectRecord.record.id, | ||||
|           label: objectRecord.recordIdentifier.name, | ||||
|           to: `object/${objectRecord.objectMetadataItem.nameSingular}/${objectRecord.record.id}`, | ||||
|           Icon: () => ( | ||||
|             <Avatar | ||||
|               type="rounded" | ||||
|               avatarUrl={null} | ||||
|               placeholderColorSeed={objectRecord.id} | ||||
|               placeholder={objectRecord.recordIdentifier.name ?? ''} | ||||
|             /> | ||||
|           ), | ||||
|         }), | ||||
|       }), | ||||
|     ), | ||||
|   ]; | ||||
|  | ||||
|   return ( | ||||
|     <> | ||||
|       {isCommandMenuOpened && ( | ||||
| @@ -457,121 +670,28 @@ export const CommandMenu = () => { | ||||
|                       </CommandGroup> | ||||
|                     </> | ||||
|                   )} | ||||
|                   <CommandGroup heading="Navigate"> | ||||
|                     {matchingNavigateCommand.map((cmd) => ( | ||||
|                       <SelectableItem itemId={cmd.id} key={cmd.id}> | ||||
|                   {commandGroups.map(({ heading, items, renderItem }) => | ||||
|                     items?.length ? ( | ||||
|                       <CommandGroup heading={heading} key={heading}> | ||||
|                         {items.map((item) => { | ||||
|                           const { id, Icon, label, to, onClick, key } = | ||||
|                             renderItem(item); | ||||
|                           return ( | ||||
|                             <SelectableItem itemId={id} key={id}> | ||||
|                               <CommandMenuItem | ||||
|                           id={cmd.id} | ||||
|                           to={cmd.to} | ||||
|                           key={cmd.id} | ||||
|                           label={cmd.label} | ||||
|                           Icon={cmd.Icon} | ||||
|                           onClick={cmd.onCommandClick} | ||||
|                           firstHotKey={cmd.firstHotKey} | ||||
|                           secondHotKey={cmd.secondHotKey} | ||||
|                                 key={key} | ||||
|                                 id={id} | ||||
|                                 Icon={Icon} | ||||
|                                 label={label} | ||||
|                                 to={to} | ||||
|                                 onClick={onClick} | ||||
|                               /> | ||||
|                             </SelectableItem> | ||||
|                     ))} | ||||
|                           ); | ||||
|                         })} | ||||
|                       </CommandGroup> | ||||
|                   <CommandGroup heading="Other"> | ||||
|                     {matchingCreateCommand.map((cmd) => ( | ||||
|                       <SelectableItem itemId={cmd.id} key={cmd.id}> | ||||
|                         <CommandMenuItem | ||||
|                           id={cmd.id} | ||||
|                           to={cmd.to} | ||||
|                           key={cmd.id} | ||||
|                           Icon={cmd.Icon} | ||||
|                           label={cmd.label} | ||||
|                           onClick={cmd.onCommandClick} | ||||
|                           firstHotKey={cmd.firstHotKey} | ||||
|                           secondHotKey={cmd.secondHotKey} | ||||
|                         /> | ||||
|                       </SelectableItem> | ||||
|                     ))} | ||||
|                   </CommandGroup> | ||||
|                   <CommandGroup heading="People"> | ||||
|                     {people?.map((person) => ( | ||||
|                       <SelectableItem itemId={person.id} key={person.id}> | ||||
|                         <CommandMenuItem | ||||
|                           id={person.id} | ||||
|                           key={person.id} | ||||
|                           to={`object/person/${person.id}`} | ||||
|                           label={ | ||||
|                             person.name.firstName + ' ' + person.name.lastName | ||||
|                           } | ||||
|                           Icon={() => ( | ||||
|                             <Avatar | ||||
|                               type="rounded" | ||||
|                               avatarUrl={null} | ||||
|                               placeholderColorSeed={person.id} | ||||
|                               placeholder={ | ||||
|                                 person.name.firstName + | ||||
|                                 ' ' + | ||||
|                                 person.name.lastName | ||||
|                               } | ||||
|                             /> | ||||
|                     ) : null, | ||||
|                   )} | ||||
|                         /> | ||||
|                       </SelectableItem> | ||||
|                     ))} | ||||
|                   </CommandGroup> | ||||
|                   <CommandGroup heading="Companies"> | ||||
|                     {companies?.map((company) => ( | ||||
|                       <SelectableItem itemId={company.id} key={company.id}> | ||||
|                         <CommandMenuItem | ||||
|                           id={company.id} | ||||
|                           key={company.id} | ||||
|                           label={company.name} | ||||
|                           to={`object/company/${company.id}`} | ||||
|                           Icon={() => ( | ||||
|                             <Avatar | ||||
|                               placeholderColorSeed={company.id} | ||||
|                               placeholder={company.name} | ||||
|                               avatarUrl={getLogoUrlFromDomainName( | ||||
|                                 getCompanyDomainName(company), | ||||
|                               )} | ||||
|                             /> | ||||
|                           )} | ||||
|                         /> | ||||
|                       </SelectableItem> | ||||
|                     ))} | ||||
|                   </CommandGroup> | ||||
|                   <CommandGroup heading="Opportunities"> | ||||
|                     {opportunities?.map((opportunity) => ( | ||||
|                       <SelectableItem | ||||
|                         itemId={opportunity.id} | ||||
|                         key={opportunity.id} | ||||
|                       > | ||||
|                         <CommandMenuItem | ||||
|                           id={opportunity.id} | ||||
|                           key={opportunity.id} | ||||
|                           label={opportunity.name ?? ''} | ||||
|                           to={`object/opportunity/${opportunity.id}`} | ||||
|                           Icon={() => ( | ||||
|                             <Avatar | ||||
|                               type="rounded" | ||||
|                               avatarUrl={null} | ||||
|                               placeholderColorSeed={opportunity.id} | ||||
|                               placeholder={opportunity.name ?? ''} | ||||
|                             /> | ||||
|                           )} | ||||
|                         /> | ||||
|                       </SelectableItem> | ||||
|                     ))} | ||||
|                   </CommandGroup> | ||||
|                   <CommandGroup heading="Notes"> | ||||
|                     {notes?.map((note) => ( | ||||
|                       <SelectableItem itemId={note.id} key={note.id}> | ||||
|                         <CommandMenuItem | ||||
|                           id={note.id} | ||||
|                           Icon={IconNotes} | ||||
|                           key={note.id} | ||||
|                           label={note.title ?? ''} | ||||
|                           onClick={() => openActivityRightDrawer(note.id)} | ||||
|                         /> | ||||
|                       </SelectableItem> | ||||
|                     ))} | ||||
|                   </CommandGroup> | ||||
|                 </SelectableList> | ||||
|               </StyledInnerList> | ||||
|             </ScrollWrapper> | ||||
|   | ||||
| @@ -11,7 +11,7 @@ export type Command = { | ||||
|   id: string; | ||||
|   to?: string; | ||||
|   label: string; | ||||
|   type: | ||||
|   type?: | ||||
|     | CommandType.Navigate | ||||
|     | CommandType.Create | ||||
|     | CommandType.StandardAction | ||||
|   | ||||
| @@ -0,0 +1,37 @@ | ||||
| export enum CoreObjectNamePlural { | ||||
|   Activity = 'activities', | ||||
|   ActivityTarget = 'activityTargets', | ||||
|   ApiKey = 'apiKeys', | ||||
|   Attachment = 'attachments', | ||||
|   Blocklist = 'blocklists', | ||||
|   CalendarChannel = 'calendarChannels', | ||||
|   CalendarEvent = 'calendarEvents', | ||||
|   Comment = 'comments', | ||||
|   Company = 'companies', | ||||
|   ConnectedAccount = 'connectedAccounts', | ||||
|   TimelineActivity = 'timelineActivities', | ||||
|   Favorite = 'favorites', | ||||
|   Message = 'messages', | ||||
|   MessageChannel = 'messageChannels', | ||||
|   MessageParticipant = 'messageParticipants', | ||||
|   MessageThread = 'messageThreads', | ||||
|   Note = 'notes', | ||||
|   NoteTarget = 'noteTargets', | ||||
|   Opportunity = 'opportunities', | ||||
|   Person = 'people', | ||||
|   Task = 'tasks', | ||||
|   TaskTarget = 'taskTargets', | ||||
|   View = 'views', | ||||
|   ViewField = 'viewFields', | ||||
|   ViewFilter = 'viewFilters', | ||||
|   ViewFilterGroup = 'viewFilterGroups', | ||||
|   ViewSort = 'viewSorts', | ||||
|   ViewGroup = 'viewGroups', | ||||
|   Webhook = 'webhooks', | ||||
|   WorkspaceMember = 'workspaceMembers', | ||||
|   MessageThreadSubscriber = 'messageThreadSubscribers', | ||||
|   Workflow = 'workflows', | ||||
|   MessageChannelMessageAssociation = 'messageChannelMessageAssociations', | ||||
|   WorkflowVersion = 'workflowVersions', | ||||
|   WorkflowRun = 'workflowRuns', | ||||
| } | ||||
| @@ -4,7 +4,8 @@ import { useRecoilValue, useSetRecoilState } from 'recoil'; | ||||
| import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; | ||||
| import { objectRecordMultiSelectMatchesFilterRecordsIdsComponentState } from '@/object-record/record-field/states/objectRecordMultiSelectMatchesFilterRecordsIdsComponentState'; | ||||
| import { useRelationPickerScopedStates } from '@/object-record/relation-picker/hooks/internal/useRelationPickerScopedStates'; | ||||
| import { useMultiObjectSearchMatchesSearchFilterQuery } from '@/object-record/relation-picker/hooks/useMultiObjectSearchMatchesSearchFilterQuery'; | ||||
| import { useMultiObjectRecordsQueryResultFormattedAsObjectRecordForSelectArray } from '@/object-record/relation-picker/hooks/useMultiObjectRecordsQueryResultFormattedAsObjectRecordForSelectArray'; | ||||
| import { useMultiObjectSearch } from '@/object-record/relation-picker/hooks/useMultiObjectSearch'; | ||||
| import { RelationPickerScopeInternalContext } from '@/object-record/relation-picker/scopes/scope-internal-context/RelationPickerScopeInternalContext'; | ||||
| import { useAvailableScopeIdOrThrow } from '@/ui/utilities/recoil-scope/scopes-internal/hooks/useAvailableScopeId'; | ||||
|  | ||||
| @@ -31,8 +32,8 @@ export const ActivityTargetInlineCellEditModeMultiRecordsSearchFilterEffect = | ||||
|       relationPickerSearchFilterState, | ||||
|     ); | ||||
|  | ||||
|     const { matchesSearchFilterObjectRecords } = | ||||
|       useMultiObjectSearchMatchesSearchFilterQuery({ | ||||
|     const { matchesSearchFilterObjectRecordsQueryResult } = | ||||
|       useMultiObjectSearch({ | ||||
|         excludedObjects: [ | ||||
|           CoreObjectNameSingular.Task, | ||||
|           CoreObjectNameSingular.Note, | ||||
| @@ -41,14 +42,15 @@ export const ActivityTargetInlineCellEditModeMultiRecordsSearchFilterEffect = | ||||
|         limit: 10, | ||||
|       }); | ||||
|  | ||||
|     const { objectRecordForSelectArray } = | ||||
|       useMultiObjectRecordsQueryResultFormattedAsObjectRecordForSelectArray({ | ||||
|         multiObjectRecordsQueryResult: | ||||
|           matchesSearchFilterObjectRecordsQueryResult, | ||||
|       }); | ||||
|  | ||||
|     useEffect(() => { | ||||
|       setRecordMultiSelectMatchesFilterRecords( | ||||
|         matchesSearchFilterObjectRecords, | ||||
|       ); | ||||
|     }, [ | ||||
|       setRecordMultiSelectMatchesFilterRecords, | ||||
|       matchesSearchFilterObjectRecords, | ||||
|     ]); | ||||
|       setRecordMultiSelectMatchesFilterRecords(objectRecordForSelectArray); | ||||
|     }, [setRecordMultiSelectMatchesFilterRecords, objectRecordForSelectArray]); | ||||
|  | ||||
|     return <></>; | ||||
|   }; | ||||
|   | ||||
| @@ -0,0 +1,97 @@ | ||||
| import { act, renderHook } from '@testing-library/react'; | ||||
| import { RecoilRoot, useSetRecoilState } from 'recoil'; | ||||
|  | ||||
| import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState'; | ||||
| import { useMultiObjectSearchQueryResultFormattedAsObjectRecordsMap } from '@/object-record/relation-picker/hooks/useMultiObjectSearchQueryResultFormattedAsObjectRecordsMap'; | ||||
| import { RelationPickerScopeInternalContext } from '@/object-record/relation-picker/scopes/scope-internal-context/RelationPickerScopeInternalContext'; | ||||
| import { generatedMockObjectMetadataItems } from '~/testing/mock-data/generatedMockObjectMetadataItems'; | ||||
|  | ||||
| const scopeId = 'scopeId'; | ||||
| const Wrapper = ({ children }: { children: React.ReactNode }) => ( | ||||
|   <RelationPickerScopeInternalContext.Provider value={{ scopeId }}> | ||||
|     <RecoilRoot>{children}</RecoilRoot> | ||||
|   </RelationPickerScopeInternalContext.Provider> | ||||
| ); | ||||
|  | ||||
| const opportunityId = 'cb702502-4b1d-488e-9461-df3fb096ebf6'; | ||||
| const personId = 'ab091fd9-1b81-4dfd-bfdb-564ffee032a2'; | ||||
|  | ||||
| describe('useMultiObjectRecordsQueryResultFormattedAsObjectRecordsMap', () => { | ||||
|   it('should return object formatted from objectMetadataItemsState', async () => { | ||||
|     const { result } = renderHook( | ||||
|       () => { | ||||
|         return { | ||||
|           formattedRecord: | ||||
|             useMultiObjectSearchQueryResultFormattedAsObjectRecordsMap({ | ||||
|               multiObjectRecordsQueryResult: { | ||||
|                 opportunities: { | ||||
|                   edges: [ | ||||
|                     { | ||||
|                       node: { | ||||
|                         id: opportunityId, | ||||
|                         pointOfContactId: | ||||
|                           'e992bda7-d797-4e12-af04-9b427f42244c', | ||||
|                         updatedAt: '2023-11-30T11:13:15.308Z', | ||||
|                         createdAt: '2023-11-30T11:13:15.308Z', | ||||
|                         __typename: 'Opportunity', | ||||
|                       }, | ||||
|                       cursor: 'cursor', | ||||
|                       __typename: 'OpportunityEdge', | ||||
|                     }, | ||||
|                   ], | ||||
|                   pageInfo: {}, | ||||
|                 }, | ||||
|                 people: { | ||||
|                   edges: [ | ||||
|                     { | ||||
|                       node: { | ||||
|                         id: personId, | ||||
|                         updatedAt: '2023-11-30T11:13:15.308Z', | ||||
|                         createdAt: '2023-11-30T11:13:15.308Z', | ||||
|                         __typename: 'Person', | ||||
|                       }, | ||||
|                       cursor: 'cursor', | ||||
|                       __typename: 'PersonEdge', | ||||
|                     }, | ||||
|                   ], | ||||
|                   pageInfo: {}, | ||||
|                 }, | ||||
|               }, | ||||
|             }), | ||||
|           setObjectMetadata: useSetRecoilState(objectMetadataItemsState), | ||||
|         }; | ||||
|       }, | ||||
|       { | ||||
|         wrapper: Wrapper, | ||||
|       }, | ||||
|     ); | ||||
|     act(() => { | ||||
|       result.current.setObjectMetadata(generatedMockObjectMetadataItems); | ||||
|     }); | ||||
|  | ||||
|     expect( | ||||
|       Object.values(result.current.formattedRecord.objectRecordsMap).flat() | ||||
|         .length, | ||||
|     ).toBe(2); | ||||
|  | ||||
|     const opportunityObjectRecords = | ||||
|       result.current.formattedRecord.objectRecordsMap.opportunities; | ||||
|  | ||||
|     const personObjectRecords = | ||||
|       result.current.formattedRecord.objectRecordsMap.people; | ||||
|  | ||||
|     expect(opportunityObjectRecords[0].objectMetadataItem.namePlural).toBe( | ||||
|       'opportunities', | ||||
|     ); | ||||
|     expect(opportunityObjectRecords[0].record.id).toBe(opportunityId); | ||||
|     expect(opportunityObjectRecords[0].recordIdentifier.linkToShowPage).toBe( | ||||
|       `/object/opportunity/${opportunityId}`, | ||||
|     ); | ||||
|  | ||||
|     expect(personObjectRecords[0].objectMetadataItem.namePlural).toBe('people'); | ||||
|     expect(personObjectRecords[0].record.id).toBe(personId); | ||||
|     expect(personObjectRecords[0].recordIdentifier.linkToShowPage).toBe( | ||||
|       `/object/person/${personId}`, | ||||
|     ); | ||||
|   }); | ||||
| }); | ||||
| @@ -6,14 +6,11 @@ import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSi | ||||
| import { EMPTY_QUERY } from '@/object-record/constants/EmptyQuery'; | ||||
| import { useGenerateCombinedSearchRecordsQuery } from '@/object-record/multiple-objects/hooks/useGenerateCombinedSearchRecordsQuery'; | ||||
| import { useLimitPerMetadataItem } from '@/object-record/relation-picker/hooks/useLimitPerMetadataItem'; | ||||
| import { | ||||
|   MultiObjectRecordQueryResult, | ||||
|   useMultiObjectRecordsQueryResultFormattedAsObjectRecordForSelectArray, | ||||
| } from '@/object-record/relation-picker/hooks/useMultiObjectRecordsQueryResultFormattedAsObjectRecordForSelectArray'; | ||||
| import { MultiObjectRecordQueryResult } from '@/object-record/relation-picker/hooks/useMultiObjectRecordsQueryResultFormattedAsObjectRecordForSelectArray'; | ||||
| import { isObjectMetadataItemSearchableInCombinedRequest } from '@/object-record/utils/isObjectMetadataItemSearchableInCombinedRequest'; | ||||
| import { isDefined } from '~/utils/isDefined'; | ||||
| 
 | ||||
| export const useMultiObjectSearchMatchesSearchFilterQuery = ({ | ||||
| export const useMultiObjectSearch = ({ | ||||
|   searchFilterValue, | ||||
|   limit, | ||||
|   excludedObjects, | ||||
| @@ -62,14 +59,8 @@ export const useMultiObjectSearchMatchesSearchFilterQuery = ({ | ||||
|     }, | ||||
|   ); | ||||
| 
 | ||||
|   const { objectRecordForSelectArray: matchesSearchFilterObjectRecords } = | ||||
|     useMultiObjectRecordsQueryResultFormattedAsObjectRecordForSelectArray({ | ||||
|       multiObjectRecordsQueryResult: | ||||
|         matchesSearchFilterObjectRecordsQueryResult, | ||||
|     }); | ||||
| 
 | ||||
|   return { | ||||
|     matchesSearchFilterObjectRecordsLoading, | ||||
|     matchesSearchFilterObjectRecords, | ||||
|     matchesSearchFilterObjectRecordsQueryResult, | ||||
|   }; | ||||
| }; | ||||
| @@ -0,0 +1,61 @@ | ||||
| import { useMemo } from 'react'; | ||||
| import { useRecoilValue } from 'recoil'; | ||||
|  | ||||
| import { objectMetadataItemsByNamePluralMapSelector } from '@/object-metadata/states/objectMetadataItemsByNamePluralMapSelector'; | ||||
| import { getObjectRecordIdentifier } from '@/object-metadata/utils/getObjectRecordIdentifier'; | ||||
| import { MultiObjectRecordQueryResult } from '@/object-record/relation-picker/hooks/useMultiObjectRecordsQueryResultFormattedAsObjectRecordForSelectArray'; | ||||
| import { formatMultiObjectRecordSearchResults } from '@/object-record/relation-picker/utils/formatMultiObjectRecordSearchResults'; | ||||
| import { ObjectRecordForSelect } from '@/object-record/types/ObjectRecordForSelect'; | ||||
| import { isDefined } from '~/utils/isDefined'; | ||||
|  | ||||
| export const useMultiObjectSearchQueryResultFormattedAsObjectRecordsMap = ({ | ||||
|   multiObjectRecordsQueryResult, | ||||
| }: { | ||||
|   multiObjectRecordsQueryResult: | ||||
|     | MultiObjectRecordQueryResult | ||||
|     | null | ||||
|     | undefined; | ||||
| }) => { | ||||
|   const objectMetadataItemsByNamePluralMap = useRecoilValue( | ||||
|     objectMetadataItemsByNamePluralMapSelector, | ||||
|   ); | ||||
|  | ||||
|   const formattedMultiObjectRecordsQueryResult = useMemo(() => { | ||||
|     return formatMultiObjectRecordSearchResults(multiObjectRecordsQueryResult); | ||||
|   }, [multiObjectRecordsQueryResult]); | ||||
|  | ||||
|   const objectRecordsMap = useMemo(() => { | ||||
|     const recordsByNamePlural: { [key: string]: ObjectRecordForSelect[] } = {}; | ||||
|     Object.entries(formattedMultiObjectRecordsQueryResult ?? {}).forEach( | ||||
|       ([namePlural, objectRecordConnection]) => { | ||||
|         const objectMetadataItem = | ||||
|           objectMetadataItemsByNamePluralMap.get(namePlural); | ||||
|  | ||||
|         if (!isDefined(objectMetadataItem)) return []; | ||||
|         if (!isDefined(recordsByNamePlural[namePlural])) { | ||||
|           recordsByNamePlural[namePlural] = []; | ||||
|         } | ||||
|  | ||||
|         objectRecordConnection.edges.forEach(({ node }) => { | ||||
|           const record = { | ||||
|             objectMetadataItem, | ||||
|             record: node, | ||||
|             recordIdentifier: getObjectRecordIdentifier({ | ||||
|               objectMetadataItem, | ||||
|               record: node, | ||||
|             }), | ||||
|           } as ObjectRecordForSelect; | ||||
|           recordsByNamePlural[namePlural].push(record); | ||||
|         }); | ||||
|       }, | ||||
|     ); | ||||
|     return recordsByNamePlural; | ||||
|   }, [ | ||||
|     formattedMultiObjectRecordsQueryResult, | ||||
|     objectMetadataItemsByNamePluralMap, | ||||
|   ]); | ||||
|  | ||||
|   return { | ||||
|     objectRecordsMap, | ||||
|   }; | ||||
| }; | ||||
| @@ -114,6 +114,45 @@ export const graphqlMocks = { | ||||
|         }, | ||||
|       }); | ||||
|     }), | ||||
|     graphql.query('CombinedSearchRecords', () => { | ||||
|       return HttpResponse.json({ | ||||
|         data: { | ||||
|           searchOpportunities: { | ||||
|             edges: [], | ||||
|             pageInfo: { | ||||
|               hasNextPage: false, | ||||
|               hasPreviousPage: false, | ||||
|               startCursor: null, | ||||
|               endCursor: null, | ||||
|             }, | ||||
|           }, | ||||
|           searchCompanies: { | ||||
|             edges: companiesMock.slice(0, 3).map((company) => ({ | ||||
|               node: company, | ||||
|               cursor: null, | ||||
|             })), | ||||
|             pageInfo: { | ||||
|               hasNextPage: false, | ||||
|               hasPreviousPage: false, | ||||
|               startCursor: null, | ||||
|               endCursor: null, | ||||
|             }, | ||||
|           }, | ||||
|           searchPeople: { | ||||
|             edges: peopleMock.slice(0, 3).map((person) => ({ | ||||
|               node: person, | ||||
|               cursor: null, | ||||
|             })), | ||||
|             pageInfo: { | ||||
|               hasNextPage: false, | ||||
|               hasPreviousPage: false, | ||||
|               startCursor: null, | ||||
|               endCursor: null, | ||||
|             }, | ||||
|           }, | ||||
|         }, | ||||
|       }); | ||||
|     }), | ||||
|     graphql.query('FindManyViews', ({ variables }) => { | ||||
|       const objectMetadataId = variables.filter?.objectMetadataId?.eq; | ||||
|       const viewType = variables.filter?.type?.eq; | ||||
|   | ||||
		Reference in New Issue
	
	Block a user
	 Marie
					Marie