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:
@ -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'];
|
||||
};
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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: [],
|
||||
},
|
||||
});
|
||||
}),
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
`;
|
||||
@ -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 { 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}%` },
|
||||
const { data: globalSearchData, loading } = useGlobalSearchQuery({
|
||||
variables: {
|
||||
searchInput: deferredCommandMenuSearch ?? '',
|
||||
limit: MAX_SEARCH_RESULTS,
|
||||
excludedObjectNameSingulars: [],
|
||||
},
|
||||
}
|
||||
: { 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`,
|
||||
|
||||
@ -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 = (
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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: {},
|
||||
},
|
||||
];
|
||||
@ -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],
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -0,0 +1 @@
|
||||
export const RESULTS_LIMIT_BY_OBJECT_WITHOUT_SEARCH_TERMS = 8;
|
||||
@ -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,
|
||||
};
|
||||
@ -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[];
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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',
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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 {}
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -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[];
|
||||
};
|
||||
@ -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:*');
|
||||
});
|
||||
});
|
||||
@ -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' ? '&' : '|'} `);
|
||||
};
|
||||
@ -1,3 +1,2 @@
|
||||
export * from './constants/AppLocales';
|
||||
export * from './constants/SourceLocale';
|
||||
|
||||
|
||||
@ -1,4 +1,7 @@
|
||||
import { getLogoUrlFromDomainName, sanitizeURL } from '..';
|
||||
import {
|
||||
getLogoUrlFromDomainName,
|
||||
sanitizeURL,
|
||||
} from '../getLogoUrlFromDomainName';
|
||||
|
||||
describe('sanitizeURL', () => {
|
||||
test('should sanitize the URL correctly', () => {
|
||||
@ -1 +1,2 @@
|
||||
export * from './getImageAbsoluteURI';
|
||||
export * from './getLogoUrlFromDomainName';
|
||||
|
||||
Reference in New Issue
Block a user