diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index 6b352773f..3a7b1d26e 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -704,6 +704,16 @@ export type GetServerlessFunctionSourceCodeInput = { version?: Scalars['String']['input']; }; +export type GlobalSearchRecord = { + __typename?: 'GlobalSearchRecord'; + imageUrl?: Maybe; + label: Scalars['String']['output']; + objectSingularName: Scalars['String']['output']; + recordId: Scalars['String']['output']; + tsRank: Scalars['Float']['output']; + tsRankCD: Scalars['Float']['output']; +}; + export enum HealthIndicatorId { connectedAccount = 'connectedAccount', database = 'database', @@ -1437,6 +1447,7 @@ export type Query = { getTimelineCalendarEventsFromPersonId: TimelineCalendarEventsWithTotal; getTimelineThreadsFromCompanyId: TimelineThreadsWithTotal; getTimelineThreadsFromPersonId: TimelineThreadsWithTotal; + globalSearch: Array; index: Index; indexMetadatas: IndexConnection; object: Object; @@ -1552,6 +1563,13 @@ export type QueryGetTimelineThreadsFromPersonIdArgs = { }; +export type QueryGlobalSearchArgs = { + excludedObjectNameSingulars?: InputMaybe>; + limit: Scalars['Int']['input']; + searchInput: Scalars['String']['input']; +}; + + export type QueryIndexArgs = { id: Scalars['UUID']['input']; }; diff --git a/packages/twenty-front/src/generated/graphql.tsx b/packages/twenty-front/src/generated/graphql.tsx index 62f1d58dc..409ce9dd5 100644 --- a/packages/twenty-front/src/generated/graphql.tsx +++ b/packages/twenty-front/src/generated/graphql.tsx @@ -629,6 +629,16 @@ export type GetServerlessFunctionSourceCodeInput = { version?: Scalars['String']; }; +export type GlobalSearchRecord = { + __typename?: 'GlobalSearchRecord'; + imageUrl?: Maybe; + label: Scalars['String']; + objectSingularName: Scalars['String']; + recordId: Scalars['String']; + tsRank: Scalars['Float']; + tsRankCD: Scalars['Float']; +}; + export enum HealthIndicatorId { connectedAccount = 'connectedAccount', database = 'database', @@ -1301,6 +1311,7 @@ export type Query = { getTimelineCalendarEventsFromPersonId: TimelineCalendarEventsWithTotal; getTimelineThreadsFromCompanyId: TimelineThreadsWithTotal; getTimelineThreadsFromPersonId: TimelineThreadsWithTotal; + globalSearch: Array; index: Index; indexMetadatas: IndexConnection; object: Object; @@ -1389,6 +1400,13 @@ export type QueryGetTimelineThreadsFromPersonIdArgs = { }; +export type QueryGlobalSearchArgs = { + excludedObjectNameSingulars?: InputMaybe>; + limit: Scalars['Int']; + searchInput: Scalars['String']; +}; + + export type QueryValidatePasswordResetTokenArgs = { passwordResetToken: Scalars['String']; }; @@ -2319,6 +2337,15 @@ export type GetClientConfigQueryVariables = Exact<{ [key: string]: never; }>; export type GetClientConfigQuery = { __typename?: 'Query', clientConfig: { __typename?: 'ClientConfig', signInPrefilled: boolean, isMultiWorkspaceEnabled: boolean, isEmailVerificationRequired: boolean, defaultSubdomain?: string | null, frontDomain: string, debugMode: boolean, analyticsEnabled: boolean, isAttachmentPreviewEnabled: boolean, chromeExtensionId?: string | null, canManageFeatureFlags: boolean, isMicrosoftMessagingEnabled: boolean, isMicrosoftCalendarEnabled: boolean, isGoogleMessagingEnabled: boolean, isGoogleCalendarEnabled: boolean, billing: { __typename?: 'Billing', isBillingEnabled: boolean, billingUrl?: string | null, trialPeriods: Array<{ __typename?: 'BillingTrialPeriodDTO', duration: number, isCreditCardRequired: boolean }> }, authProviders: { __typename?: 'AuthProviders', google: boolean, password: boolean, microsoft: boolean, sso: Array<{ __typename?: 'SSOIdentityProvider', id: string, name: string, type: IdentityProviderType, status: SsoIdentityProviderStatus, issuer: string }> }, support: { __typename?: 'Support', supportDriver: string, supportFrontChatId?: string | null }, sentry: { __typename?: 'Sentry', dsn?: string | null, environment?: string | null, release?: string | null }, captcha: { __typename?: 'Captcha', provider?: CaptchaDriverType | null, siteKey?: string | null }, api: { __typename?: 'ApiConfig', mutationMaximumAffectedRecords: number }, publicFeatureFlags: Array<{ __typename?: 'PublicFeatureFlag', key: FeatureFlagKey, metadata: { __typename?: 'PublicFeatureFlagMetadata', label: string, description: string, imagePath: string } }> } }; +export type GlobalSearchQueryVariables = Exact<{ + searchInput: Scalars['String']; + limit: Scalars['Int']; + excludedObjectNameSingulars: Array | Scalars['String']; +}>; + + +export type GlobalSearchQuery = { __typename?: 'Query', globalSearch: Array<{ __typename?: 'GlobalSearchRecord', recordId: string, objectSingularName: string, label: string, imageUrl?: string | null, tsRankCD: number, tsRank: number }> }; + export type SkipSyncEmailOnboardingStepMutationVariables = Exact<{ [key: string]: never; }>; @@ -3934,6 +3961,52 @@ export function useGetClientConfigLazyQuery(baseOptions?: Apollo.LazyQueryHookOp export type GetClientConfigQueryHookResult = ReturnType; export type GetClientConfigLazyQueryHookResult = ReturnType; export type GetClientConfigQueryResult = Apollo.QueryResult; +export const GlobalSearchDocument = gql` + query GlobalSearch($searchInput: String!, $limit: Int!, $excludedObjectNameSingulars: [String!]!) { + globalSearch( + searchInput: $searchInput + limit: $limit + excludedObjectNameSingulars: $excludedObjectNameSingulars + ) { + recordId + objectSingularName + label + imageUrl + tsRankCD + tsRank + } +} + `; + +/** + * __useGlobalSearchQuery__ + * + * To run a query within a React component, call `useGlobalSearchQuery` and pass it any options that fit your needs. + * When your component renders, `useGlobalSearchQuery` returns an object from Apollo Client that contains loading, error, and data properties + * you can use to render your UI. + * + * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; + * + * @example + * const { data, loading, error } = useGlobalSearchQuery({ + * variables: { + * searchInput: // value for 'searchInput' + * limit: // value for 'limit' + * excludedObjectNameSingulars: // value for 'excludedObjectNameSingulars' + * }, + * }); + */ +export function useGlobalSearchQuery(baseOptions: Apollo.QueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useQuery(GlobalSearchDocument, options); + } +export function useGlobalSearchLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useLazyQuery(GlobalSearchDocument, options); + } +export type GlobalSearchQueryHookResult = ReturnType; +export type GlobalSearchLazyQueryHookResult = ReturnType; +export type GlobalSearchQueryResult = Apollo.QueryResult; export const SkipSyncEmailOnboardingStepDocument = gql` mutation SkipSyncEmailOnboardingStep { skipSyncEmailOnboardingStep { diff --git a/packages/twenty-front/src/modules/command-menu/components/__stories__/CommandMenu.stories.tsx b/packages/twenty-front/src/modules/command-menu/components/__stories__/CommandMenu.stories.tsx index 164ef4da1..89ef96d71 100644 --- a/packages/twenty-front/src/modules/command-menu/components/__stories__/CommandMenu.stories.tsx +++ b/packages/twenty-front/src/modules/command-menu/components/__stories__/CommandMenu.stories.tsx @@ -27,13 +27,10 @@ import { IconDotsVertical } from 'twenty-ui'; import { FeatureFlagKey } from '~/generated/graphql'; import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator'; import { JestContextStoreSetter } from '~/testing/jest/JestContextStoreSetter'; -import { getCompaniesMock } from '~/testing/mock-data/companies'; import { CommandMenu } from '../CommandMenu'; const openTimeout = 50; -const companiesMock = getCompaniesMock(); - // Mock workspace with feature flag enabled const mockWorkspaceWithFeatureFlag = { ...mockCurrentWorkspace, @@ -167,33 +164,18 @@ export const NoResultsSearchFallback: Story = { const canvas = within(document.body); const searchInput = await canvas.findByPlaceholderText('Type anything'); await sleep(openTimeout); - await userEvent.type(searchInput, 'Linkedin'); + await userEvent.type(searchInput, 'input without results'); expect(await canvas.findByText('No results found')).toBeVisible(); const searchRecordsButton = await canvas.findByText('Search records'); expect(searchRecordsButton).toBeVisible(); - await userEvent.click(searchRecordsButton); - expect(await canvas.findByText('Linkedin')).toBeVisible(); }, parameters: { msw: { handlers: [ - graphql.query('CombinedSearchRecords', () => { + graphql.query('GlobalSearch', () => { return HttpResponse.json({ data: { - searchCompanies: { - edges: [ - { - node: companiesMock[0], - cursor: null, - }, - ], - pageInfo: { - hasNextPage: false, - hasPreviousPage: false, - startCursor: null, - endCursor: null, - }, - }, + globalSearch: [], }, }); }), diff --git a/packages/twenty-front/src/modules/command-menu/graphql/queries/globalSearch.ts b/packages/twenty-front/src/modules/command-menu/graphql/queries/globalSearch.ts new file mode 100644 index 000000000..66f79b4e3 --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu/graphql/queries/globalSearch.ts @@ -0,0 +1,22 @@ +import gql from 'graphql-tag'; + +export const globalSearch = gql` + query GlobalSearch( + $searchInput: String! + $limit: Int! + $excludedObjectNameSingulars: [String!]! + ) { + globalSearch( + searchInput: $searchInput + limit: $limit + excludedObjectNameSingulars: $excludedObjectNameSingulars + ) { + recordId + objectSingularName + label + imageUrl + tsRankCD + tsRank + } + } +`; diff --git a/packages/twenty-front/src/modules/command-menu/hooks/useSearchRecords.tsx b/packages/twenty-front/src/modules/command-menu/hooks/useSearchRecords.tsx index a7e5063b6..5bade7513 100644 --- a/packages/twenty-front/src/modules/command-menu/hooks/useSearchRecords.tsx +++ b/packages/twenty-front/src/modules/command-menu/hooks/useSearchRecords.tsx @@ -1,156 +1,29 @@ import { useOpenActivityRightDrawer } from '@/activities/hooks/useOpenActivityRightDrawer'; -import { Note } from '@/activities/types/Note'; -import { Task } from '@/activities/types/Task'; import { commandMenuSearchState } from '@/command-menu/states/commandMenuSearchState'; -import { Company } from '@/companies/types/Company'; -import { CoreObjectNamePlural } from '@/object-metadata/types/CoreObjectNamePlural'; import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; -import { getCompanyDomainName } from '@/object-metadata/utils/getCompanyDomainName'; -import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords'; -import { useMultiObjectSearch } from '@/object-record/record-picker/hooks/useMultiObjectSearch'; -import { useMultiObjectSearchQueryResultFormattedAsObjectRecordsMap } from '@/object-record/record-picker/hooks/useMultiObjectSearchQueryResultFormattedAsObjectRecordsMap'; -import { makeOrFilterVariables } from '@/object-record/utils/makeOrFilterVariables'; -import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; import { t } from '@lingui/core/macro'; -import isEmpty from 'lodash.isempty'; import { useMemo } from 'react'; import { useRecoilValue } from 'recoil'; -import { Avatar, IconCheckbox, IconNotes } from 'twenty-ui'; +import { capitalize } from 'twenty-shared'; +import { Avatar } from 'twenty-ui'; import { useDebounce } from 'use-debounce'; -import { FeatureFlagKey } from '~/generated-metadata/graphql'; -import { getLogoUrlFromDomainName } from '~/utils'; +import { useGlobalSearchQuery } from '~/generated/graphql'; -const MAX_SEARCH_RESULTS_PER_OBJECT = 8; +const MAX_SEARCH_RESULTS = 30; export const useSearchRecords = () => { const commandMenuSearch = useRecoilValue(commandMenuSearchState); - const isRichTextV2Enabled = useIsFeatureEnabled( - FeatureFlagKey.IsRichTextV2Enabled, - ); - const [deferredCommandMenuSearch] = useDebounce(commandMenuSearch, 300); - const { - matchesSearchFilterObjectRecordsQueryResult, - matchesSearchFilterObjectRecordsLoading: loading, - } = useMultiObjectSearch({ - excludedObjects: [CoreObjectNameSingular.Task, CoreObjectNameSingular.Note], - searchFilterValue: deferredCommandMenuSearch ?? undefined, - limit: MAX_SEARCH_RESULTS_PER_OBJECT, + const { data: globalSearchData, loading } = useGlobalSearchQuery({ + variables: { + searchInput: deferredCommandMenuSearch ?? '', + limit: MAX_SEARCH_RESULTS, + excludedObjectNameSingulars: [], + }, }); - const { objectRecordsMap: matchesSearchFilterObjectRecords } = - useMultiObjectSearchQueryResultFormattedAsObjectRecordsMap({ - multiObjectRecordsQueryResult: - matchesSearchFilterObjectRecordsQueryResult, - }); - - const { loading: isNotesLoading, records: notes } = useFindManyRecords({ - objectNameSingular: CoreObjectNameSingular.Note, - filter: deferredCommandMenuSearch - ? makeOrFilterVariables([ - { title: { ilike: `%${deferredCommandMenuSearch}%` } }, - isRichTextV2Enabled - ? { - bodyV2: { - markdown: { ilike: `%${deferredCommandMenuSearch}%` }, - }, - } - : { body: { ilike: `%${deferredCommandMenuSearch}%` } }, - ]) - : undefined, - limit: MAX_SEARCH_RESULTS_PER_OBJECT, - }); - - const { loading: isTasksLoading, records: tasks } = useFindManyRecords({ - objectNameSingular: CoreObjectNameSingular.Task, - filter: deferredCommandMenuSearch - ? makeOrFilterVariables([ - { title: { ilike: `%${deferredCommandMenuSearch}%` } }, - isRichTextV2Enabled - ? { - bodyV2: { - markdown: { ilike: `%${deferredCommandMenuSearch}%` }, - }, - } - : { body: { ilike: `%${deferredCommandMenuSearch}%` } }, - ]) - : undefined, - limit: MAX_SEARCH_RESULTS_PER_OBJECT, - }); - - 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 peopleCommands = useMemo( - () => - people?.map(({ id, name: { firstName, lastName }, avatarUrl }) => ({ - id, - label: `${firstName} ${lastName}`, - description: 'Person', - to: `object/person/${id}`, - shouldCloseCommandMenuOnClick: true, - Icon: () => ( - - ), - })), - [people], - ); - - const companyCommands = useMemo( - () => - companies?.map((company) => ({ - id: company.id, - label: company.name ?? '', - description: 'Company', - to: `object/company/${company.id}`, - shouldCloseCommandMenuOnClick: true, - Icon: () => ( - - ), - })), - [companies], - ); - - const opportunityCommands = useMemo( - () => - opportunities?.map(({ id, name }) => ({ - id, - label: name ?? '', - description: 'Opportunity', - to: `object/opportunity/${id}`, - shouldCloseCommandMenuOnClick: true, - Icon: () => ( - - ), - })), - [opportunities], - ); - const openNoteRightDrawer = useOpenActivityRightDrawer({ objectNameSingular: CoreObjectNameSingular.Note, }); @@ -159,87 +32,49 @@ export const useSearchRecords = () => { objectNameSingular: CoreObjectNameSingular.Task, }); - const noteCommands = useMemo( - () => - notes?.map((note) => ({ - id: note.id, - label: note.title ?? '', - description: 'Note', - to: '', - onCommandClick: () => openNoteRightDrawer(note.id), - shouldCloseCommandMenuOnClick: true, - Icon: IconNotes, - })), - [notes, openNoteRightDrawer], - ); - - const tasksCommands = useMemo( - () => - tasks?.map((task) => ({ - id: task.id, - label: task.title ?? '', - description: 'Task', - to: '', - onCommandClick: () => openTaskRightDrawer(task.id), - shouldCloseCommandMenuOnClick: true, - Icon: IconCheckbox, - })), - [tasks, openTaskRightDrawer], - ); - - 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 customObjectCommands = useMemo(() => { - return Object.values(customObjectRecordsMap).flatMap((objectRecords) => - objectRecords.map((objectRecord) => ({ - id: objectRecord.record.id, - label: objectRecord.recordIdentifier.name, - description: objectRecord.objectMetadataItem.labelSingular, - to: `object/${objectRecord.objectMetadataItem.nameSingular}/${objectRecord.record.id}`, + const commands = useMemo(() => { + return (globalSearchData?.globalSearch ?? []).map((searchRecord) => { + const command = { + id: searchRecord.recordId, + label: searchRecord.label, + description: capitalize(searchRecord.objectSingularName), + to: `object/${searchRecord.objectSingularName}/${searchRecord.recordId}`, shouldCloseCommandMenuOnClick: true, Icon: () => ( ), - })), - ); - }, [customObjectRecordsMap]); - - const commands = [ - ...(peopleCommands ?? []), - ...(companyCommands ?? []), - ...(opportunityCommands ?? []), - ...(noteCommands ?? []), - ...(tasksCommands ?? []), - ...(customObjectCommands ?? []), - ]; - - const noResults = - !peopleCommands?.length && - !companyCommands?.length && - !opportunityCommands?.length && - !noteCommands?.length && - !tasksCommands?.length && - !customObjectCommands?.length; + }; + if ( + [CoreObjectNameSingular.Task, CoreObjectNameSingular.Note].includes( + searchRecord.objectSingularName as CoreObjectNameSingular, + ) + ) { + return { + ...command, + to: '', + onCommandClick: () => { + searchRecord.objectSingularName === 'task' + ? openTaskRightDrawer(searchRecord.recordId) + : openNoteRightDrawer(searchRecord.recordId); + }, + }; + } + return command; + }); + }, [globalSearchData, openTaskRightDrawer, openNoteRightDrawer]); return { - loading: loading || isNotesLoading || isTasksLoading, - noResults, + loading, + noResults: !commands?.length, commandGroups: [ { heading: t`Results`, diff --git a/packages/twenty-front/src/modules/object-metadata/utils/getAvatarUrl.ts b/packages/twenty-front/src/modules/object-metadata/utils/getAvatarUrl.ts index 69950aea0..e66456754 100644 --- a/packages/twenty-front/src/modules/object-metadata/utils/getAvatarUrl.ts +++ b/packages/twenty-front/src/modules/object-metadata/utils/getAvatarUrl.ts @@ -3,9 +3,12 @@ import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSi import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; import { getCompanyDomainName } from '@/object-metadata/utils/getCompanyDomainName'; import { ObjectRecord } from '@/object-record/types/ObjectRecord'; -import { getImageAbsoluteURI, isDefined } from 'twenty-shared'; +import { + getImageAbsoluteURI, + getLogoUrlFromDomainName, + isDefined, +} from 'twenty-shared'; import { REACT_APP_SERVER_BASE_URL } from '~/config'; -import { getLogoUrlFromDomainName } from '~/utils'; import { getImageIdentifierFieldValue } from './getImageIdentifierFieldValue'; export const getAvatarUrl = ( diff --git a/packages/twenty-front/src/testing/graphqlMocks.ts b/packages/twenty-front/src/testing/graphqlMocks.ts index f94bc8467..aca9a7e01 100644 --- a/packages/twenty-front/src/testing/graphqlMocks.ts +++ b/packages/twenty-front/src/testing/graphqlMocks.ts @@ -146,6 +146,50 @@ export const graphqlMocks = { }, }); }), + graphql.query('GlobalSearch', () => { + return HttpResponse.json({ + data: { + globalSearch: [ + { + __typename: 'GlobalSearchRecordDTO', + recordId: '20202020-2d40-4e49-8df4-9c6a049191de', + objectSingularName: 'person', + label: 'Louis Duss', + imageUrl: '', + tsRankCD: 0.2, + tsRank: 0.12158542, + }, + { + __typename: 'GlobalSearchRecordDTO', + recordId: '20202020-3ec3-4fe3-8997-b76aa0bfa408', + objectSingularName: 'company', + label: 'Linkedin', + imageUrl: 'https://twenty-icons.com/linkedin.com', + tsRankCD: 0.2, + tsRank: 0.12158542, + }, + { + __typename: 'GlobalSearchRecordDTO', + recordId: '20202020-3f74-492d-a101-2a70f50a1645', + objectSingularName: 'company', + label: 'Libeo', + imageUrl: 'https://twenty-icons.com/libeo.io', + tsRankCD: 0.2, + tsRank: 0.12158542, + }, + { + __typename: 'GlobalSearchRecordDTO', + recordId: '20202020-ac73-4797-824e-87a1f5aea9e0', + objectSingularName: 'person', + label: 'Sylvie Palmer', + imageUrl: '', + tsRankCD: 0.1, + tsRank: 0.06079271, + }, + ], + }, + }); + }), graphql.query('CombinedSearchRecords', () => { return HttpResponse.json({ data: { diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-search-resolver.service.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-search-resolver.service.ts index 89c20426f..ec66e0e5f 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-search-resolver.service.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-search-resolver.service.ts @@ -19,6 +19,7 @@ import { SearchResolverArgs } from 'src/engine/api/graphql/workspace-resolver-bu import { QUERY_MAX_RECORDS } from 'src/engine/api/graphql/graphql-query-runner/constants/query-max-records.constant'; import { ObjectRecordsToGraphqlConnectionHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/object-records-to-graphql-connection.helper'; import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum'; +import { formatSearchTerms } from 'src/engine/core-modules/global-search/utils/format-search-terms'; import { SEARCH_VECTOR_FIELD } from 'src/engine/metadata-modules/constants/search-vector-field.constants'; import { formatResult } from 'src/engine/twenty-orm/utils/format-result.util'; @@ -52,11 +53,11 @@ export class GraphqlQuerySearchResolverService extends GraphqlQueryBaseResolverS }); } - const searchTerms = this.formatSearchTerms( + const searchTerms = formatSearchTerms( executionArgs.args.searchInput, 'and', ); - const searchTermsOr = this.formatSearchTerms( + const searchTermsOr = formatSearchTerms( executionArgs.args.searchInput, 'or', ); @@ -150,23 +151,6 @@ export class GraphqlQuerySearchResolverService extends GraphqlQueryBaseResolverS }); } - private formatSearchTerms( - searchTerm: string, - operator: 'and' | 'or' = 'and', - ) { - if (searchTerm === '') { - return ''; - } - const words = searchTerm.trim().split(/\s+/); - const formattedWords = words.map((word) => { - const escapedWord = word.replace(/[\\:'&|!()]/g, '\\$&'); - - return `${escapedWord}:*`; - }); - - return formattedWords.join(` ${operator === 'and' ? '&' : '|'} `); - } - async validate( _args: SearchResolverArgs, _options: WorkspaceQueryRunnerOptions, diff --git a/packages/twenty-server/src/engine/core-modules/core-engine.module.ts b/packages/twenty-server/src/engine/core-modules/core-engine.module.ts index 6926e75a3..fdb8aac9d 100644 --- a/packages/twenty-server/src/engine/core-modules/core-engine.module.ts +++ b/packages/twenty-server/src/engine/core-modules/core-engine.module.ts @@ -5,6 +5,7 @@ import { EventEmitterModule } from '@nestjs/event-emitter'; import { ActorModule } from 'src/engine/core-modules/actor/actor.module'; import { AdminPanelModule } from 'src/engine/core-modules/admin-panel/admin-panel.module'; import { AppTokenModule } from 'src/engine/core-modules/app-token/app-token.module'; +import { ApprovedAccessDomainModule } from 'src/engine/core-modules/approved-access-domain/approved-access-domain.module'; import { AuthModule } from 'src/engine/core-modules/auth/auth.module'; import { BillingModule } from 'src/engine/core-modules/billing/billing.module'; import { CacheStorageModule } from 'src/engine/core-modules/cache-storage/cache-storage.module'; @@ -21,6 +22,7 @@ import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature- import { FileStorageModule } from 'src/engine/core-modules/file-storage/file-storage.module'; import { fileStorageModuleFactory } from 'src/engine/core-modules/file-storage/file-storage.module-factory'; import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service'; +import { GlobalSearchModule } from 'src/engine/core-modules/global-search/global-search.module'; import { HealthModule } from 'src/engine/core-modules/health/health.module'; import { LabModule } from 'src/engine/core-modules/lab/lab.module'; import { LLMChatModelModule } from 'src/engine/core-modules/llm-chat-model/llm-chat-model.module'; @@ -46,7 +48,6 @@ import { WorkspaceInvitationModule } from 'src/engine/core-modules/workspace-inv import { WorkspaceModule } from 'src/engine/core-modules/workspace/workspace.module'; import { RoleModule } from 'src/engine/metadata-modules/role/role.module'; import { WorkspaceEventEmitterModule } from 'src/engine/workspace-event-emitter/workspace-event-emitter.module'; -import { ApprovedAccessDomainModule } from 'src/engine/core-modules/approved-access-domain/approved-access-domain.module'; import { AnalyticsModule } from './analytics/analytics.module'; import { ClientConfigModule } from './client-config/client-config.module'; @@ -120,6 +121,7 @@ import { FileModule } from './file/file.module'; useFactory: serverlessModuleFactory, inject: [EnvironmentService, FileStorageService], }), + GlobalSearchModule, ], exports: [ AnalyticsModule, diff --git a/packages/twenty-server/src/engine/core-modules/global-search/__mocks__/mockObjectMetadataItemsWithFieldMaps.ts b/packages/twenty-server/src/engine/core-modules/global-search/__mocks__/mockObjectMetadataItemsWithFieldMaps.ts new file mode 100644 index 000000000..05be6c346 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/global-search/__mocks__/mockObjectMetadataItemsWithFieldMaps.ts @@ -0,0 +1,243 @@ +import { FieldMetadataType } from 'twenty-shared'; + +import { ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps'; + +export const mockObjectMetadataItemsWithFieldMaps: ObjectMetadataItemWithFieldMaps[] = + [ + { + id: '', + standardId: '', + nameSingular: 'person', + namePlural: 'people', + labelSingular: 'Person', + labelPlural: 'People', + description: 'A person', + targetTableName: 'DEPRECATED', + isCustom: false, + isRemote: false, + isActive: true, + isSystem: false, + isAuditLogged: true, + fromRelations: [], + toRelations: [], + labelIdentifierFieldMetadataId: 'nameFieldMetadataId', + imageIdentifierFieldMetadataId: '', + workspaceId: '', + fields: [], + indexMetadatas: [], + fieldsById: { + nameFieldMetadataId: { + id: 'nameFieldMetadataId', + objectMetadataId: '', + type: FieldMetadataType.FULL_NAME, + name: 'name', + label: 'Name', + defaultValue: { + lastName: "''", + firstName: "''", + }, + description: 'Contact’s name', + isCustom: false, + isNullable: true, + isUnique: false, + workspaceId: '', + }, + }, + fieldsByName: { + name: { + id: 'nameFieldMetadataId', + objectMetadataId: '', + type: FieldMetadataType.FULL_NAME, + name: 'name', + label: 'Name', + defaultValue: { + lastName: "''", + firstName: "''", + }, + description: 'Contact’s name', + isCustom: false, + isNullable: true, + isUnique: false, + workspaceId: '', + }, + }, + }, + { + id: '', + standardId: '', + nameSingular: 'company', + namePlural: 'companies', + labelSingular: 'Company', + labelPlural: 'Companies', + description: 'A company', + targetTableName: 'DEPRECATED', + isCustom: false, + isRemote: false, + isActive: true, + isSystem: false, + isAuditLogged: true, + fromRelations: [], + toRelations: [], + labelIdentifierFieldMetadataId: 'nameFieldMetadataId', + imageIdentifierFieldMetadataId: '', + workspaceId: '', + fields: [], + indexMetadatas: [], + fieldsById: { + nameFieldMetadataId: { + id: 'nameFieldMetadataId', + objectMetadataId: '', + type: FieldMetadataType.TEXT, + name: 'name', + label: 'Name', + defaultValue: '', + isCustom: false, + isNullable: true, + isUnique: false, + workspaceId: '', + }, + domainNameFieldMetadataId: { + id: 'domainNameFieldMetadataId', + objectMetadataId: '', + type: FieldMetadataType.LINKS, + name: 'domainName', + label: 'Domain Name', + defaultValue: '', + isCustom: false, + isNullable: true, + isUnique: false, + workspaceId: '', + }, + }, + fieldsByName: { + name: { + id: 'nameFieldMetadataId', + objectMetadataId: '', + type: FieldMetadataType.TEXT, + name: 'name', + label: 'Name', + defaultValue: { + lastName: "''", + firstName: "''", + }, + isCustom: false, + isNullable: true, + isUnique: false, + workspaceId: '', + }, + domainName: { + id: 'domainNameFieldMetadataId', + objectMetadataId: '', + type: FieldMetadataType.LINKS, + name: 'domainName', + label: 'Domain Name', + defaultValue: '', + isCustom: false, + isNullable: true, + isUnique: false, + workspaceId: '', + }, + }, + }, + { + id: '', + standardId: '', + nameSingular: 'regular-custom-object', + namePlural: 'regular-custom-objects', + labelSingular: 'Regular Custom Object', + labelPlural: 'Regular Custom Objects', + description: 'A regular custom object', + targetTableName: 'DEPRECATED', + isCustom: true, + isRemote: false, + isActive: true, + isSystem: false, + isAuditLogged: true, + fromRelations: [], + toRelations: [], + labelIdentifierFieldMetadataId: 'nameFieldMetadataId', + imageIdentifierFieldMetadataId: 'imageIdentifierFieldMetadataId', + workspaceId: '', + fields: [], + indexMetadatas: [], + fieldsById: { + nameFieldMetadataId: { + id: 'nameFieldMetadataId', + objectMetadataId: '', + type: FieldMetadataType.TEXT, + name: 'name', + label: 'Name', + defaultValue: '', + isCustom: false, + isNullable: true, + isUnique: false, + workspaceId: '', + }, + imageIdentifierFieldMetadataId: { + id: 'imageIdentifierFieldMetadataId', + objectMetadataId: '', + type: FieldMetadataType.TEXT, + name: 'imageIdentifierFieldName', + label: 'Image Identifier Field Name', + defaultValue: '', + isCustom: false, + isNullable: true, + isUnique: false, + workspaceId: '', + }, + }, + fieldsByName: { + name: { + id: 'nameFieldMetadataId', + objectMetadataId: '', + type: FieldMetadataType.TEXT, + name: 'name', + label: 'Name', + defaultValue: { + lastName: "''", + firstName: "''", + }, + isCustom: false, + isNullable: true, + isUnique: false, + workspaceId: '', + }, + imageIdentifierFieldName: { + id: 'imageIdentifierFieldMetadataId', + objectMetadataId: '', + type: FieldMetadataType.TEXT, + name: 'imageIdentifierFieldName', + label: 'Image Identifier Field Name', + defaultValue: '', + isCustom: false, + isNullable: true, + isUnique: false, + workspaceId: '', + }, + }, + }, + { + id: '', + standardId: '', + nameSingular: 'non-searchable-object', + namePlural: 'non-searchable-objects', + labelSingular: '', + labelPlural: '', + description: '', + targetTableName: 'DEPRECATED', + isCustom: false, + isRemote: false, + isActive: true, + isSystem: true, + isAuditLogged: true, + fromRelations: [], + toRelations: [], + labelIdentifierFieldMetadataId: '', + imageIdentifierFieldMetadataId: '', + workspaceId: '', + fields: [], + indexMetadatas: [], + fieldsById: {}, + fieldsByName: {}, + }, + ]; diff --git a/packages/twenty-server/src/engine/core-modules/global-search/__tests__/global-search.service.spec.ts b/packages/twenty-server/src/engine/core-modules/global-search/__tests__/global-search.service.spec.ts new file mode 100644 index 000000000..cda6ba3cf --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/global-search/__tests__/global-search.service.spec.ts @@ -0,0 +1,195 @@ +import { Test, TestingModule } from '@nestjs/testing'; + +import { mockObjectMetadataItemsWithFieldMaps } from 'src/engine/core-modules/global-search/__mocks__/mockObjectMetadataItemsWithFieldMaps'; +import { GlobalSearchService } from 'src/engine/core-modules/global-search/services/global-search.service'; + +describe('GlobalSearchService', () => { + let service: GlobalSearchService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [GlobalSearchService], + }).compile(); + + service = module.get(GlobalSearchService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('filterObjectMetadataItems', () => { + it('should return searchable object metadata items -- TODO isSearchable only', () => { + const objectMetadataItems = service.filterObjectMetadataItems( + mockObjectMetadataItemsWithFieldMaps, + [], + ); + + expect(objectMetadataItems).toEqual([ + mockObjectMetadataItemsWithFieldMaps[0], + mockObjectMetadataItemsWithFieldMaps[1], + mockObjectMetadataItemsWithFieldMaps[2], + ]); + }); + it('should return searchable object metadata items without excluded ones', () => { + const objectMetadataItems = service.filterObjectMetadataItems( + mockObjectMetadataItemsWithFieldMaps, + ['company'], + ); + + expect(objectMetadataItems).toEqual([ + mockObjectMetadataItemsWithFieldMaps[0], + mockObjectMetadataItemsWithFieldMaps[2], + ]); + }); + }); + + describe('getLabelIdentifierColumns', () => { + it('should return the two label identifier columns for a person object metadata item', () => { + const labelIdentifierColumns = service.getLabelIdentifierColumns( + mockObjectMetadataItemsWithFieldMaps[0], + ); + + expect(labelIdentifierColumns).toEqual(['nameFirstName', 'nameLastName']); + }); + it('should return the label identifier column for a regular object metadata item', () => { + const labelIdentifierColumns = service.getLabelIdentifierColumns( + mockObjectMetadataItemsWithFieldMaps[1], + ); + + expect(labelIdentifierColumns).toEqual(['name']); + }); + }); + + describe('getImageIdentifierColumn', () => { + it('should return null if the object metadata item does not have an image identifier', () => { + const imageIdentifierColumn = service.getImageIdentifierColumn( + mockObjectMetadataItemsWithFieldMaps[0], + ); + + expect(imageIdentifierColumn).toBeNull(); + }); + it('should return `domainNamePrimaryLinkUrl` column for a company object metadata item', () => { + const imageIdentifierColumn = service.getImageIdentifierColumn( + mockObjectMetadataItemsWithFieldMaps[1], + ); + + expect(imageIdentifierColumn).toEqual('domainNamePrimaryLinkUrl'); + }); + + it('should return the image identifier column', () => { + const imageIdentifierColumn = service.getImageIdentifierColumn( + mockObjectMetadataItemsWithFieldMaps[2], + ); + + expect(imageIdentifierColumn).toEqual('imageIdentifierFieldName'); + }); + }); + + describe('sortSearchObjectResults', () => { + it('should sort the search object results by tsRankCD', () => { + const objectResults = [ + { + objectSingularName: 'person', + tsRankCD: 2, + tsRank: 1, + recordId: '', + label: '', + imageUrl: '', + }, + { + objectSingularName: 'company', + tsRankCD: 1, + tsRank: 1, + recordId: '', + label: '', + imageUrl: '', + }, + { + objectSingularName: 'regular-custom-object', + tsRankCD: 3, + tsRank: 1, + recordId: '', + label: '', + imageUrl: '', + }, + ]; + + expect(service.sortSearchObjectResults([...objectResults])).toEqual([ + objectResults[2], + objectResults[0], + objectResults[1], + ]); + }); + + it('should sort the search object results by tsRank, if tsRankCD is the same', () => { + const objectResults = [ + { + objectSingularName: 'person', + tsRankCD: 1, + tsRank: 1, + recordId: '', + label: '', + imageUrl: '', + }, + { + objectSingularName: 'company', + tsRankCD: 1, + tsRank: 2, + recordId: '', + label: '', + imageUrl: '', + }, + { + objectSingularName: 'regular-custom-object', + tsRankCD: 1, + tsRank: 3, + recordId: '', + label: '', + imageUrl: '', + }, + ]; + + expect(service.sortSearchObjectResults([...objectResults])).toEqual([ + objectResults[2], + objectResults[1], + objectResults[0], + ]); + }); + + it('should sort the search object results by priority rank, if tsRankCD and tsRank are the same', () => { + const objectResults = [ + { + objectSingularName: 'company', + tsRankCD: 1, + tsRank: 1, + recordId: '', + label: '', + imageUrl: '', + }, + { + objectSingularName: 'person', + tsRankCD: 1, + tsRank: 1, + recordId: '', + label: '', + imageUrl: '', + }, + { + objectSingularName: 'regular-custom-object', + tsRankCD: 1, + tsRank: 1, + recordId: '', + label: '', + imageUrl: '', + }, + ]; + + expect(service.sortSearchObjectResults([...objectResults])).toEqual([ + objectResults[1], + objectResults[0], + objectResults[2], + ]); + }); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/global-search/constants/results-limit-by-object-without-search-terms.ts b/packages/twenty-server/src/engine/core-modules/global-search/constants/results-limit-by-object-without-search-terms.ts new file mode 100644 index 000000000..b99ae4e5c --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/global-search/constants/results-limit-by-object-without-search-terms.ts @@ -0,0 +1 @@ +export const RESULTS_LIMIT_BY_OBJECT_WITHOUT_SEARCH_TERMS = 8; diff --git a/packages/twenty-server/src/engine/core-modules/global-search/constants/standard-objects-by-priority-rank.ts b/packages/twenty-server/src/engine/core-modules/global-search/constants/standard-objects-by-priority-rank.ts new file mode 100644 index 000000000..8e1691122 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/global-search/constants/standard-objects-by-priority-rank.ts @@ -0,0 +1,8 @@ +//the higher the number, the higher the priority +export const STANDARD_OBJECTS_BY_PRIORITY_RANK = { + person: 5, + company: 4, + opportunity: 3, + note: 2, + task: 1, +}; diff --git a/packages/twenty-server/src/engine/core-modules/global-search/dtos/global-search-args.ts b/packages/twenty-server/src/engine/core-modules/global-search/dtos/global-search-args.ts new file mode 100644 index 000000000..3f03c32c7 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/global-search/dtos/global-search-args.ts @@ -0,0 +1,19 @@ +import { ArgsType, Field, Int } from '@nestjs/graphql'; + +import { IsArray, IsInt, IsOptional, IsString } from 'class-validator'; + +@ArgsType() +export class GlobalSearchArgs { + @Field(() => String) + @IsString() + searchInput: string; + + @Field(() => Int) + @IsInt() + limit: number; + + @IsArray() + @Field(() => [String], { nullable: true }) + @IsOptional() + excludedObjectNameSingulars?: string[]; +} diff --git a/packages/twenty-server/src/engine/core-modules/global-search/dtos/global-search-record-dto.ts b/packages/twenty-server/src/engine/core-modules/global-search/dtos/global-search-record-dto.ts new file mode 100644 index 000000000..9e642bfa5 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/global-search/dtos/global-search-record-dto.ts @@ -0,0 +1,32 @@ +import { Field, ObjectType } from '@nestjs/graphql'; + +import { IsNotEmpty, IsNumber, IsString } from 'class-validator'; + +@ObjectType('GlobalSearchRecord') +export class GlobalSearchRecordDTO { + @Field(() => String) + @IsString() + @IsNotEmpty() + recordId: string; + + @Field(() => String) + @IsString() + @IsNotEmpty() + objectSingularName: string; + + @Field(() => String) + @IsString() + @IsNotEmpty() + label: string; + + @Field(() => String, { nullable: true }) + imageUrl: string; + + @Field(() => Number) + @IsNumber() + tsRankCD: number; + + @Field(() => Number) + @IsNumber() + tsRank: number; +} diff --git a/packages/twenty-server/src/engine/core-modules/global-search/exceptions/global-search.exception.ts b/packages/twenty-server/src/engine/core-modules/global-search/exceptions/global-search.exception.ts new file mode 100644 index 000000000..6bac7cc84 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/global-search/exceptions/global-search.exception.ts @@ -0,0 +1,13 @@ +import { CustomException } from 'src/utils/custom-exception'; + +export class GlobalSearchException extends CustomException { + constructor(message: string, code: GlobalSearchExceptionCode) { + super(message, code); + } +} + +export enum GlobalSearchExceptionCode { + METADATA_CACHE_VERSION_NOT_FOUND = 'METADATA_CACHE_VERSION_NOT_FOUND', + LABEL_IDENTIFIER_FIELD_NOT_FOUND = 'LABEL_IDENTIFIER_FIELD_NOT_FOUND', + OBJECT_METADATA_MAP_NOT_FOUND = 'OBJECT_METADATA_MAP_NOT_FOUND', +} diff --git a/packages/twenty-server/src/engine/core-modules/global-search/filters/global-search-api-exception.filter.ts b/packages/twenty-server/src/engine/core-modules/global-search/filters/global-search-api-exception.filter.ts new file mode 100644 index 000000000..275c09e9d --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/global-search/filters/global-search-api-exception.filter.ts @@ -0,0 +1,16 @@ +import { Catch, ExceptionFilter } from '@nestjs/common'; + +import { GlobalSearchException } from 'src/engine/core-modules/global-search/exceptions/global-search.exception'; +import { InternalServerError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util'; + +@Catch(GlobalSearchException) +export class GlobalSearchApiExceptionFilter implements ExceptionFilter { + constructor() {} + + catch(exception: GlobalSearchException) { + switch (exception.code) { + default: + throw new InternalServerError(exception.message); + } + } +} diff --git a/packages/twenty-server/src/engine/core-modules/global-search/global-search.module.ts b/packages/twenty-server/src/engine/core-modules/global-search/global-search.module.ts new file mode 100644 index 000000000..0fe79a916 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/global-search/global-search.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; + +import { GlobalSearchResolver } from 'src/engine/core-modules/global-search/global-search.resolver'; +import { GlobalSearchService } from 'src/engine/core-modules/global-search/services/global-search.service'; +import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module'; + +@Module({ + imports: [WorkspaceCacheStorageModule], + providers: [GlobalSearchResolver, GlobalSearchService], +}) +export class GlobalSearchModule {} diff --git a/packages/twenty-server/src/engine/core-modules/global-search/global-search.resolver.ts b/packages/twenty-server/src/engine/core-modules/global-search/global-search.resolver.ts new file mode 100644 index 000000000..5a7039c85 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/global-search/global-search.resolver.ts @@ -0,0 +1,110 @@ +import { UseFilters } from '@nestjs/common'; +import { Args, Query, Resolver } from '@nestjs/graphql'; + +import chunk from 'lodash.chunk'; + +import { GlobalSearchArgs } from 'src/engine/core-modules/global-search/dtos/global-search-args'; +import { GlobalSearchRecordDTO } from 'src/engine/core-modules/global-search/dtos/global-search-record-dto'; +import { + GlobalSearchException, + GlobalSearchExceptionCode, +} from 'src/engine/core-modules/global-search/exceptions/global-search.exception'; +import { GlobalSearchApiExceptionFilter } from 'src/engine/core-modules/global-search/filters/global-search-api-exception.filter'; +import { GlobalSearchService } from 'src/engine/core-modules/global-search/services/global-search.service'; +import { RecordsWithObjectMetadataItem } from 'src/engine/core-modules/global-search/types/records-with-object-metadata-item'; +import { formatSearchTerms } from 'src/engine/core-modules/global-search/utils/format-search-terms'; +import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; +import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator'; +import { TwentyORMManager } from 'src/engine/twenty-orm/twenty-orm.manager'; +import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service'; + +const OBJECT_METADATA_ITEMS_CHUNK_SIZE = 5; + +@Resolver(() => [GlobalSearchRecordDTO]) +@UseFilters(GlobalSearchApiExceptionFilter) +export class GlobalSearchResolver { + constructor( + private readonly workspaceCacheStorageService: WorkspaceCacheStorageService, + private readonly twentyORMManager: TwentyORMManager, + private readonly globalSearchService: GlobalSearchService, + ) {} + + @Query(() => [GlobalSearchRecordDTO]) + async globalSearch( + @AuthWorkspace() workspace: Workspace, + @Args() + { searchInput, limit, excludedObjectNameSingulars }: GlobalSearchArgs, + ) { + const currentCacheVersion = + await this.workspaceCacheStorageService.getMetadataVersion(workspace.id); + + if (currentCacheVersion === undefined) { + throw new GlobalSearchException( + 'Metadata cache version not found', + GlobalSearchExceptionCode.METADATA_CACHE_VERSION_NOT_FOUND, + ); + } + + const objectMetadataMaps = + await this.workspaceCacheStorageService.getObjectMetadataMaps( + workspace.id, + currentCacheVersion, + ); + + if (!objectMetadataMaps) { + throw new GlobalSearchException( + `Object metadata map not found for workspace ${workspace.id} and metadata version ${currentCacheVersion}`, + GlobalSearchExceptionCode.OBJECT_METADATA_MAP_NOT_FOUND, + ); + } + + const objectMetadataItemWithFieldMaps = Object.values( + objectMetadataMaps.byId, + ); + + const filteredObjectMetadataItems = + this.globalSearchService.filterObjectMetadataItems( + objectMetadataItemWithFieldMaps, + excludedObjectNameSingulars, + ); + + const allRecordsWithObjectMetadataItems: RecordsWithObjectMetadataItem[] = + []; + + const filteredObjectMetadataItemsChunks = chunk( + filteredObjectMetadataItems, + OBJECT_METADATA_ITEMS_CHUNK_SIZE, + ); + + for (const objectMetadataItemChunk of filteredObjectMetadataItemsChunks) { + const recordsWithObjectMetadataItems = await Promise.all( + objectMetadataItemChunk.map(async (objectMetadataItem) => { + const repository = await this.twentyORMManager.getRepository( + objectMetadataItem.nameSingular, + ); + + repository.createQueryBuilder(); + + return { + objectMetadataItem, + records: + await this.globalSearchService.buildSearchQueryAndGetRecords( + repository, + objectMetadataItem, + formatSearchTerms(searchInput, 'and'), + formatSearchTerms(searchInput, 'or'), + limit, + ), + }; + }), + ); + + allRecordsWithObjectMetadataItems.push(...recordsWithObjectMetadataItems); + } + + return this.globalSearchService.computeSearchObjectResults( + allRecordsWithObjectMetadataItems, + limit, + ); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/global-search/services/global-search.service.ts b/packages/twenty-server/src/engine/core-modules/global-search/services/global-search.service.ts new file mode 100644 index 000000000..5a4295d27 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/global-search/services/global-search.service.ts @@ -0,0 +1,217 @@ +import { Entity } from '@microsoft/microsoft-graph-types'; +import { getLogoUrlFromDomainName } from 'twenty-shared'; +import { Brackets } from 'typeorm'; + +import { ObjectRecord } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface'; + +import { RESULTS_LIMIT_BY_OBJECT_WITHOUT_SEARCH_TERMS } from 'src/engine/core-modules/global-search/constants/results-limit-by-object-without-search-terms'; +import { STANDARD_OBJECTS_BY_PRIORITY_RANK } from 'src/engine/core-modules/global-search/constants/standard-objects-by-priority-rank'; +import { GlobalSearchRecordDTO } from 'src/engine/core-modules/global-search/dtos/global-search-record-dto'; +import { + GlobalSearchException, + GlobalSearchExceptionCode, +} from 'src/engine/core-modules/global-search/exceptions/global-search.exception'; +import { RecordsWithObjectMetadataItem } from 'src/engine/core-modules/global-search/types/records-with-object-metadata-item'; +import { SEARCH_VECTOR_FIELD } from 'src/engine/metadata-modules/constants/search-vector-field.constants'; +import { ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps'; +import { WorkspaceRepository } from 'src/engine/twenty-orm/repository/workspace.repository'; + +export class GlobalSearchService { + filterObjectMetadataItems( + objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps[], + excludedObjectNameSingulars: string[] | undefined, + ) { + return objectMetadataItemWithFieldMaps.filter( + ({ nameSingular, isSystem, isRemote, isCustom }) => { + if (excludedObjectNameSingulars?.includes(nameSingular)) { + return false; + } + //TODO - #345 issue - IsSearchable decorator + if (isSystem || isRemote) { + return false; + } + + return ( + isCustom || + ['company', 'person', 'opportunity', 'note', 'task'].includes( + nameSingular, + ) + ); + }, + ); + } + + async buildSearchQueryAndGetRecords( + entityManager: WorkspaceRepository, + objectMetadataItem: ObjectMetadataItemWithFieldMaps, + searchTerms: string, + searchTermsOr: string, + limit: number, + ) { + const queryBuilder = entityManager.createQueryBuilder(); + const imageIdentifierField = + this.getImageIdentifierColumn(objectMetadataItem); + + const fieldsToSelect = [ + 'id', + ...this.getLabelIdentifierColumns(objectMetadataItem), + ...(imageIdentifierField ? [imageIdentifierField] : []), + ].map((field) => `"${field}"`); + + const searchQuery = searchTerms + ? queryBuilder + .select(fieldsToSelect) + .addSelect( + `ts_rank_cd("${SEARCH_VECTOR_FIELD.name}", to_tsquery(:searchTerms))`, + 'tsRankCD', + ) + .addSelect( + `ts_rank("${SEARCH_VECTOR_FIELD.name}", to_tsquery(:searchTerms))`, + 'tsRank', + ) + .andWhere( + new Brackets((qb) => { + qb.where( + `"${SEARCH_VECTOR_FIELD.name}" @@ to_tsquery('simple', :searchTerms)`, + { searchTerms }, + ).orWhere( + `"${SEARCH_VECTOR_FIELD.name}" @@ to_tsquery('simple', :searchTermsOr)`, + { searchTermsOr }, + ); + }), + ) + .orderBy( + `ts_rank_cd("${SEARCH_VECTOR_FIELD.name}", to_tsquery(:searchTerms))`, + 'DESC', + ) + .addOrderBy( + `ts_rank("${SEARCH_VECTOR_FIELD.name}", to_tsquery(:searchTermsOr))`, + 'DESC', + ) + .setParameter('searchTerms', searchTerms) + .setParameter('searchTermsOr', searchTermsOr) + .take(limit) + : queryBuilder + .select(fieldsToSelect) + .addSelect('0', 'tsRankCD') + .addSelect('0', 'tsRank') + .andWhere( + new Brackets((qb) => { + qb.where(`"${SEARCH_VECTOR_FIELD.name}" IS NOT NULL`); + }), + ) + .take(RESULTS_LIMIT_BY_OBJECT_WITHOUT_SEARCH_TERMS); + + return await searchQuery.getRawMany(); + } + + getLabelIdentifierColumns( + objectMetadataItem: ObjectMetadataItemWithFieldMaps, + ) { + if (!objectMetadataItem.labelIdentifierFieldMetadataId) { + throw new GlobalSearchException( + 'Label identifier field not found', + GlobalSearchExceptionCode.LABEL_IDENTIFIER_FIELD_NOT_FOUND, + ); + } + + const labelIdentifierField = + objectMetadataItem.fieldsById[ + objectMetadataItem.labelIdentifierFieldMetadataId + ]; + + if (objectMetadataItem.nameSingular === 'person') { + return [ + `${labelIdentifierField.name}FirstName`, + `${labelIdentifierField.name}LastName`, + ]; + } + + return [ + objectMetadataItem.fieldsById[ + objectMetadataItem.labelIdentifierFieldMetadataId + ].name, + ]; + } + + getLabelIdentifierValue( + record: ObjectRecord, + objectMetadataItem: ObjectMetadataItemWithFieldMaps, + ): string { + const labelIdentifierFields = + this.getLabelIdentifierColumns(objectMetadataItem); + + return labelIdentifierFields.map((field) => record[field]).join(' '); + } + + getImageIdentifierColumn( + objectMetadataItem: ObjectMetadataItemWithFieldMaps, + ) { + if (objectMetadataItem.nameSingular === 'company') { + return 'domainNamePrimaryLinkUrl'; + } + + if (!objectMetadataItem.imageIdentifierFieldMetadataId) { + return null; + } + + return objectMetadataItem.fieldsById[ + objectMetadataItem.imageIdentifierFieldMetadataId + ].name; + } + + getImageIdentifierValue( + record: ObjectRecord, + objectMetadataItem: ObjectMetadataItemWithFieldMaps, + ): string { + const imageIdentifierField = + this.getImageIdentifierColumn(objectMetadataItem); + + if (objectMetadataItem.nameSingular === 'company') { + return getLogoUrlFromDomainName(record.domainNamePrimaryLinkUrl) || ''; + } + + return imageIdentifierField ? record[imageIdentifierField] : ''; + } + + computeSearchObjectResults( + recordsWithObjectMetadataItems: RecordsWithObjectMetadataItem[], + limit: number, + ) { + const searchRecords = recordsWithObjectMetadataItems.flatMap( + ({ objectMetadataItem, records }) => { + return records.map((record) => { + return { + recordId: record.id, + objectSingularName: objectMetadataItem.nameSingular, + label: this.getLabelIdentifierValue(record, objectMetadataItem), + imageUrl: this.getImageIdentifierValue(record, objectMetadataItem), + tsRankCD: record.tsRankCD, + tsRank: record.tsRank, + }; + }); + }, + ); + + return this.sortSearchObjectResults(searchRecords).slice(0, limit); + } + + sortSearchObjectResults( + searchObjectResultsWithRank: GlobalSearchRecordDTO[], + ) { + return searchObjectResultsWithRank.sort((a, b) => { + if (a.tsRankCD !== b.tsRankCD) { + return b.tsRankCD - a.tsRankCD; + } + + if (a.tsRank !== b.tsRank) { + return b.tsRank - a.tsRank; + } + + return ( + (STANDARD_OBJECTS_BY_PRIORITY_RANK[b.objectSingularName] || 0) - + (STANDARD_OBJECTS_BY_PRIORITY_RANK[a.objectSingularName] || 0) + ); + }); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/global-search/types/records-with-object-metadata-item.ts b/packages/twenty-server/src/engine/core-modules/global-search/types/records-with-object-metadata-item.ts new file mode 100644 index 000000000..b7b298ebe --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/global-search/types/records-with-object-metadata-item.ts @@ -0,0 +1,8 @@ +import { ObjectRecord } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface'; + +import { ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps'; + +export type RecordsWithObjectMetadataItem = { + objectMetadataItem: ObjectMetadataItemWithFieldMaps; + records: ObjectRecord[]; +}; diff --git a/packages/twenty-server/src/engine/core-modules/global-search/utils/__tests__/format-search-terms.spec.ts b/packages/twenty-server/src/engine/core-modules/global-search/utils/__tests__/format-search-terms.spec.ts new file mode 100644 index 000000000..6ae8c09b5 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/global-search/utils/__tests__/format-search-terms.spec.ts @@ -0,0 +1,15 @@ +import { formatSearchTerms } from 'src/engine/core-modules/global-search/utils/format-search-terms'; + +describe('formatSearchTerms', () => { + it('should format the search terms', () => { + const formattedTerms = formatSearchTerms('my search input', 'and'); + + expect(formattedTerms).toBe('my:* & search:* & input:*'); + }); + + it('should format the search terms with or', () => { + const formattedTerms = formatSearchTerms('my search input', 'or'); + + expect(formattedTerms).toBe('my:* | search:* | input:*'); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/global-search/utils/format-search-terms.ts b/packages/twenty-server/src/engine/core-modules/global-search/utils/format-search-terms.ts new file mode 100644 index 000000000..af57b7126 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/global-search/utils/format-search-terms.ts @@ -0,0 +1,16 @@ +export const formatSearchTerms = ( + searchTerm: string, + operator: 'and' | 'or' = 'and', +) => { + if (searchTerm === '') { + return ''; + } + const words = searchTerm.trim().split(/\s+/); + const formattedWords = words.map((word) => { + const escapedWord = word.replace(/[\\:'&|!()]/g, '\\$&'); + + return `${escapedWord}:*`; + }); + + return formattedWords.join(` ${operator === 'and' ? '&' : '|'} `); +}; diff --git a/packages/twenty-shared/src/i18n/index.ts b/packages/twenty-shared/src/i18n/index.ts index 4e14cd142..a3e0fea72 100644 --- a/packages/twenty-shared/src/i18n/index.ts +++ b/packages/twenty-shared/src/i18n/index.ts @@ -1,3 +1,2 @@ export * from './constants/AppLocales'; export * from './constants/SourceLocale'; - diff --git a/packages/twenty-front/src/utils/__tests__/utils.test.ts b/packages/twenty-shared/src/utils/image/__tests__/getLogoUrlFromDomainName.test.ts similarity index 94% rename from packages/twenty-front/src/utils/__tests__/utils.test.ts rename to packages/twenty-shared/src/utils/image/__tests__/getLogoUrlFromDomainName.test.ts index f21a68b81..f3ddc7693 100644 --- a/packages/twenty-front/src/utils/__tests__/utils.test.ts +++ b/packages/twenty-shared/src/utils/image/__tests__/getLogoUrlFromDomainName.test.ts @@ -1,4 +1,7 @@ -import { getLogoUrlFromDomainName, sanitizeURL } from '..'; +import { + getLogoUrlFromDomainName, + sanitizeURL, +} from '../getLogoUrlFromDomainName'; describe('sanitizeURL', () => { test('should sanitize the URL correctly', () => { diff --git a/packages/twenty-front/src/utils/index.ts b/packages/twenty-shared/src/utils/image/getLogoUrlFromDomainName.ts similarity index 100% rename from packages/twenty-front/src/utils/index.ts rename to packages/twenty-shared/src/utils/image/getLogoUrlFromDomainName.ts diff --git a/packages/twenty-shared/src/utils/image/index.ts b/packages/twenty-shared/src/utils/image/index.ts index 937d6ea94..2cd3da401 100644 --- a/packages/twenty-shared/src/utils/image/index.ts +++ b/packages/twenty-shared/src/utils/image/index.ts @@ -1 +1,2 @@ export * from './getImageAbsoluteURI'; +export * from './getLogoUrlFromDomainName';