add new globalSearch resolver + update useSearchRecords hook (#10457)

# Context

To enable search records sorting by ts_rank_cd / ts_rank, we have
decided to add a new search resolver serving `GlobalSearchRecordDTO`.

-----

- [x] Test to add - work in progress


closes https://github.com/twentyhq/core-team-issues/issues/357
This commit is contained in:
Etienne
2025-02-25 17:43:35 +01:00
committed by GitHub
parent 3f25d13999
commit 90a390ee33
27 changed files with 1126 additions and 256 deletions

View File

@ -704,6 +704,16 @@ export type GetServerlessFunctionSourceCodeInput = {
version?: Scalars['String']['input'];
};
export type GlobalSearchRecord = {
__typename?: 'GlobalSearchRecord';
imageUrl?: Maybe<Scalars['String']['output']>;
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<GlobalSearchRecord>;
index: Index;
indexMetadatas: IndexConnection;
object: Object;
@ -1552,6 +1563,13 @@ export type QueryGetTimelineThreadsFromPersonIdArgs = {
};
export type QueryGlobalSearchArgs = {
excludedObjectNameSingulars?: InputMaybe<Array<Scalars['String']['input']>>;
limit: Scalars['Int']['input'];
searchInput: Scalars['String']['input'];
};
export type QueryIndexArgs = {
id: Scalars['UUID']['input'];
};

View File

@ -629,6 +629,16 @@ export type GetServerlessFunctionSourceCodeInput = {
version?: Scalars['String'];
};
export type GlobalSearchRecord = {
__typename?: 'GlobalSearchRecord';
imageUrl?: Maybe<Scalars['String']>;
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<GlobalSearchRecord>;
index: Index;
indexMetadatas: IndexConnection;
object: Object;
@ -1389,6 +1400,13 @@ export type QueryGetTimelineThreadsFromPersonIdArgs = {
};
export type QueryGlobalSearchArgs = {
excludedObjectNameSingulars?: InputMaybe<Array<Scalars['String']>>;
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']> | 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<typeof useGetClientConfigQuery>;
export type GetClientConfigLazyQueryHookResult = ReturnType<typeof useGetClientConfigLazyQuery>;
export type GetClientConfigQueryResult = Apollo.QueryResult<GetClientConfigQuery, GetClientConfigQueryVariables>;
export const GlobalSearchDocument = gql`
query GlobalSearch($searchInput: String!, $limit: Int!, $excludedObjectNameSingulars: [String!]!) {
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<GlobalSearchQuery, GlobalSearchQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useQuery<GlobalSearchQuery, GlobalSearchQueryVariables>(GlobalSearchDocument, options);
}
export function useGlobalSearchLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<GlobalSearchQuery, GlobalSearchQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useLazyQuery<GlobalSearchQuery, GlobalSearchQueryVariables>(GlobalSearchDocument, options);
}
export type GlobalSearchQueryHookResult = ReturnType<typeof useGlobalSearchQuery>;
export type GlobalSearchLazyQueryHookResult = ReturnType<typeof useGlobalSearchLazyQuery>;
export type GlobalSearchQueryResult = Apollo.QueryResult<GlobalSearchQuery, GlobalSearchQueryVariables>;
export const SkipSyncEmailOnboardingStepDocument = gql`
mutation SkipSyncEmailOnboardingStep {
skipSyncEmailOnboardingStep {

View File

@ -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: [],
},
});
}),

View File

@ -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
}
}
`;

View File

@ -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<Note>({
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<Task>({
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: () => (
<Avatar
type="rounded"
avatarUrl={avatarUrl}
placeholderColorSeed={id}
placeholder={`${firstName} ${lastName}`}
/>
),
})),
[people],
);
const companyCommands = useMemo(
() =>
companies?.map((company) => ({
id: company.id,
label: company.name ?? '',
description: 'Company',
to: `object/company/${company.id}`,
shouldCloseCommandMenuOnClick: true,
Icon: () => (
<Avatar
placeholderColorSeed={company.id}
placeholder={company.name}
avatarUrl={getLogoUrlFromDomainName(
getCompanyDomainName(company as Company),
)}
/>
),
})),
[companies],
);
const opportunityCommands = useMemo(
() =>
opportunities?.map(({ id, name }) => ({
id,
label: name ?? '',
description: 'Opportunity',
to: `object/opportunity/${id}`,
shouldCloseCommandMenuOnClick: true,
Icon: () => (
<Avatar
type="rounded"
avatarUrl={null}
placeholderColorSeed={id}
placeholder={name ?? ''}
/>
),
})),
[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: () => (
<Avatar
type="rounded"
avatarUrl={objectRecord.record.avatarUrl}
placeholderColorSeed={objectRecord.record.id}
placeholder={objectRecord.recordIdentifier.name ?? ''}
type={
searchRecord.objectSingularName === 'company'
? 'squared'
: 'rounded'
}
avatarUrl={searchRecord.imageUrl}
placeholderColorSeed={searchRecord.recordId}
placeholder={searchRecord.label}
/>
),
})),
);
}, [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`,

View File

@ -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 = (

View File

@ -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: {

View File

@ -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,

View File

@ -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,

View File

@ -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: 'Contacts 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: 'Contacts 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: {},
},
];

View File

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

View File

@ -0,0 +1 @@
export const RESULTS_LIMIT_BY_OBJECT_WITHOUT_SEARCH_TERMS = 8;

View File

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

View File

@ -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[];
}

View File

@ -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;
}

View File

@ -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',
}

View File

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

View File

@ -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 {}

View File

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

View File

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

View File

@ -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[];
};

View File

@ -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:*');
});
});

View File

@ -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' ? '&' : '|'} `);
};

View File

@ -1,3 +1,2 @@
export * from './constants/AppLocales';
export * from './constants/SourceLocale';

View File

@ -1,4 +1,7 @@
import { getLogoUrlFromDomainName, sanitizeURL } from '..';
import {
getLogoUrlFromDomainName,
sanitizeURL,
} from '../getLogoUrlFromDomainName';
describe('sanitizeURL', () => {
test('should sanitize the URL correctly', () => {

View File

@ -1 +1,2 @@
export * from './getImageAbsoluteURI';
export * from './getLogoUrlFromDomainName';