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'];
|
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 {
|
export enum HealthIndicatorId {
|
||||||
connectedAccount = 'connectedAccount',
|
connectedAccount = 'connectedAccount',
|
||||||
database = 'database',
|
database = 'database',
|
||||||
@ -1437,6 +1447,7 @@ export type Query = {
|
|||||||
getTimelineCalendarEventsFromPersonId: TimelineCalendarEventsWithTotal;
|
getTimelineCalendarEventsFromPersonId: TimelineCalendarEventsWithTotal;
|
||||||
getTimelineThreadsFromCompanyId: TimelineThreadsWithTotal;
|
getTimelineThreadsFromCompanyId: TimelineThreadsWithTotal;
|
||||||
getTimelineThreadsFromPersonId: TimelineThreadsWithTotal;
|
getTimelineThreadsFromPersonId: TimelineThreadsWithTotal;
|
||||||
|
globalSearch: Array<GlobalSearchRecord>;
|
||||||
index: Index;
|
index: Index;
|
||||||
indexMetadatas: IndexConnection;
|
indexMetadatas: IndexConnection;
|
||||||
object: Object;
|
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 = {
|
export type QueryIndexArgs = {
|
||||||
id: Scalars['UUID']['input'];
|
id: Scalars['UUID']['input'];
|
||||||
};
|
};
|
||||||
|
|||||||
@ -629,6 +629,16 @@ export type GetServerlessFunctionSourceCodeInput = {
|
|||||||
version?: Scalars['String'];
|
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 {
|
export enum HealthIndicatorId {
|
||||||
connectedAccount = 'connectedAccount',
|
connectedAccount = 'connectedAccount',
|
||||||
database = 'database',
|
database = 'database',
|
||||||
@ -1301,6 +1311,7 @@ export type Query = {
|
|||||||
getTimelineCalendarEventsFromPersonId: TimelineCalendarEventsWithTotal;
|
getTimelineCalendarEventsFromPersonId: TimelineCalendarEventsWithTotal;
|
||||||
getTimelineThreadsFromCompanyId: TimelineThreadsWithTotal;
|
getTimelineThreadsFromCompanyId: TimelineThreadsWithTotal;
|
||||||
getTimelineThreadsFromPersonId: TimelineThreadsWithTotal;
|
getTimelineThreadsFromPersonId: TimelineThreadsWithTotal;
|
||||||
|
globalSearch: Array<GlobalSearchRecord>;
|
||||||
index: Index;
|
index: Index;
|
||||||
indexMetadatas: IndexConnection;
|
indexMetadatas: IndexConnection;
|
||||||
object: Object;
|
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 = {
|
export type QueryValidatePasswordResetTokenArgs = {
|
||||||
passwordResetToken: Scalars['String'];
|
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 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; }>;
|
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 GetClientConfigQueryHookResult = ReturnType<typeof useGetClientConfigQuery>;
|
||||||
export type GetClientConfigLazyQueryHookResult = ReturnType<typeof useGetClientConfigLazyQuery>;
|
export type GetClientConfigLazyQueryHookResult = ReturnType<typeof useGetClientConfigLazyQuery>;
|
||||||
export type GetClientConfigQueryResult = Apollo.QueryResult<GetClientConfigQuery, GetClientConfigQueryVariables>;
|
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`
|
export const SkipSyncEmailOnboardingStepDocument = gql`
|
||||||
mutation SkipSyncEmailOnboardingStep {
|
mutation SkipSyncEmailOnboardingStep {
|
||||||
skipSyncEmailOnboardingStep {
|
skipSyncEmailOnboardingStep {
|
||||||
|
|||||||
@ -27,13 +27,10 @@ import { IconDotsVertical } from 'twenty-ui';
|
|||||||
import { FeatureFlagKey } from '~/generated/graphql';
|
import { FeatureFlagKey } from '~/generated/graphql';
|
||||||
import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator';
|
import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator';
|
||||||
import { JestContextStoreSetter } from '~/testing/jest/JestContextStoreSetter';
|
import { JestContextStoreSetter } from '~/testing/jest/JestContextStoreSetter';
|
||||||
import { getCompaniesMock } from '~/testing/mock-data/companies';
|
|
||||||
import { CommandMenu } from '../CommandMenu';
|
import { CommandMenu } from '../CommandMenu';
|
||||||
|
|
||||||
const openTimeout = 50;
|
const openTimeout = 50;
|
||||||
|
|
||||||
const companiesMock = getCompaniesMock();
|
|
||||||
|
|
||||||
// Mock workspace with feature flag enabled
|
// Mock workspace with feature flag enabled
|
||||||
const mockWorkspaceWithFeatureFlag = {
|
const mockWorkspaceWithFeatureFlag = {
|
||||||
...mockCurrentWorkspace,
|
...mockCurrentWorkspace,
|
||||||
@ -167,33 +164,18 @@ export const NoResultsSearchFallback: Story = {
|
|||||||
const canvas = within(document.body);
|
const canvas = within(document.body);
|
||||||
const searchInput = await canvas.findByPlaceholderText('Type anything');
|
const searchInput = await canvas.findByPlaceholderText('Type anything');
|
||||||
await sleep(openTimeout);
|
await sleep(openTimeout);
|
||||||
await userEvent.type(searchInput, 'Linkedin');
|
await userEvent.type(searchInput, 'input without results');
|
||||||
expect(await canvas.findByText('No results found')).toBeVisible();
|
expect(await canvas.findByText('No results found')).toBeVisible();
|
||||||
const searchRecordsButton = await canvas.findByText('Search records');
|
const searchRecordsButton = await canvas.findByText('Search records');
|
||||||
expect(searchRecordsButton).toBeVisible();
|
expect(searchRecordsButton).toBeVisible();
|
||||||
await userEvent.click(searchRecordsButton);
|
|
||||||
expect(await canvas.findByText('Linkedin')).toBeVisible();
|
|
||||||
},
|
},
|
||||||
parameters: {
|
parameters: {
|
||||||
msw: {
|
msw: {
|
||||||
handlers: [
|
handlers: [
|
||||||
graphql.query('CombinedSearchRecords', () => {
|
graphql.query('GlobalSearch', () => {
|
||||||
return HttpResponse.json({
|
return HttpResponse.json({
|
||||||
data: {
|
data: {
|
||||||
searchCompanies: {
|
globalSearch: [],
|
||||||
edges: [
|
|
||||||
{
|
|
||||||
node: companiesMock[0],
|
|
||||||
cursor: null,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
pageInfo: {
|
|
||||||
hasNextPage: false,
|
|
||||||
hasPreviousPage: false,
|
|
||||||
startCursor: null,
|
|
||||||
endCursor: null,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
|
|||||||
@ -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 { 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 { 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 { 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 { t } from '@lingui/core/macro';
|
||||||
import isEmpty from 'lodash.isempty';
|
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { useRecoilValue } from 'recoil';
|
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 { useDebounce } from 'use-debounce';
|
||||||
import { FeatureFlagKey } from '~/generated-metadata/graphql';
|
import { useGlobalSearchQuery } from '~/generated/graphql';
|
||||||
import { getLogoUrlFromDomainName } from '~/utils';
|
|
||||||
|
|
||||||
const MAX_SEARCH_RESULTS_PER_OBJECT = 8;
|
const MAX_SEARCH_RESULTS = 30;
|
||||||
|
|
||||||
export const useSearchRecords = () => {
|
export const useSearchRecords = () => {
|
||||||
const commandMenuSearch = useRecoilValue(commandMenuSearchState);
|
const commandMenuSearch = useRecoilValue(commandMenuSearchState);
|
||||||
|
|
||||||
const isRichTextV2Enabled = useIsFeatureEnabled(
|
|
||||||
FeatureFlagKey.IsRichTextV2Enabled,
|
|
||||||
);
|
|
||||||
|
|
||||||
const [deferredCommandMenuSearch] = useDebounce(commandMenuSearch, 300);
|
const [deferredCommandMenuSearch] = useDebounce(commandMenuSearch, 300);
|
||||||
|
|
||||||
const {
|
const { data: globalSearchData, loading } = useGlobalSearchQuery({
|
||||||
matchesSearchFilterObjectRecordsQueryResult,
|
variables: {
|
||||||
matchesSearchFilterObjectRecordsLoading: loading,
|
searchInput: deferredCommandMenuSearch ?? '',
|
||||||
} = useMultiObjectSearch({
|
limit: MAX_SEARCH_RESULTS,
|
||||||
excludedObjects: [CoreObjectNameSingular.Task, CoreObjectNameSingular.Note],
|
excludedObjectNameSingulars: [],
|
||||||
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}%` },
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: { 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({
|
const openNoteRightDrawer = useOpenActivityRightDrawer({
|
||||||
objectNameSingular: CoreObjectNameSingular.Note,
|
objectNameSingular: CoreObjectNameSingular.Note,
|
||||||
});
|
});
|
||||||
@ -159,87 +32,49 @@ export const useSearchRecords = () => {
|
|||||||
objectNameSingular: CoreObjectNameSingular.Task,
|
objectNameSingular: CoreObjectNameSingular.Task,
|
||||||
});
|
});
|
||||||
|
|
||||||
const noteCommands = useMemo(
|
const commands = useMemo(() => {
|
||||||
() =>
|
return (globalSearchData?.globalSearch ?? []).map((searchRecord) => {
|
||||||
notes?.map((note) => ({
|
const command = {
|
||||||
id: note.id,
|
id: searchRecord.recordId,
|
||||||
label: note.title ?? '',
|
label: searchRecord.label,
|
||||||
description: 'Note',
|
description: capitalize(searchRecord.objectSingularName),
|
||||||
to: '',
|
to: `object/${searchRecord.objectSingularName}/${searchRecord.recordId}`,
|
||||||
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}`,
|
|
||||||
shouldCloseCommandMenuOnClick: true,
|
shouldCloseCommandMenuOnClick: true,
|
||||||
Icon: () => (
|
Icon: () => (
|
||||||
<Avatar
|
<Avatar
|
||||||
type="rounded"
|
type={
|
||||||
avatarUrl={objectRecord.record.avatarUrl}
|
searchRecord.objectSingularName === 'company'
|
||||||
placeholderColorSeed={objectRecord.record.id}
|
? 'squared'
|
||||||
placeholder={objectRecord.recordIdentifier.name ?? ''}
|
: 'rounded'
|
||||||
|
}
|
||||||
|
avatarUrl={searchRecord.imageUrl}
|
||||||
|
placeholderColorSeed={searchRecord.recordId}
|
||||||
|
placeholder={searchRecord.label}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
})),
|
};
|
||||||
);
|
if (
|
||||||
}, [customObjectRecordsMap]);
|
[CoreObjectNameSingular.Task, CoreObjectNameSingular.Note].includes(
|
||||||
|
searchRecord.objectSingularName as CoreObjectNameSingular,
|
||||||
const commands = [
|
)
|
||||||
...(peopleCommands ?? []),
|
) {
|
||||||
...(companyCommands ?? []),
|
return {
|
||||||
...(opportunityCommands ?? []),
|
...command,
|
||||||
...(noteCommands ?? []),
|
to: '',
|
||||||
...(tasksCommands ?? []),
|
onCommandClick: () => {
|
||||||
...(customObjectCommands ?? []),
|
searchRecord.objectSingularName === 'task'
|
||||||
];
|
? openTaskRightDrawer(searchRecord.recordId)
|
||||||
|
: openNoteRightDrawer(searchRecord.recordId);
|
||||||
const noResults =
|
},
|
||||||
!peopleCommands?.length &&
|
};
|
||||||
!companyCommands?.length &&
|
}
|
||||||
!opportunityCommands?.length &&
|
return command;
|
||||||
!noteCommands?.length &&
|
});
|
||||||
!tasksCommands?.length &&
|
}, [globalSearchData, openTaskRightDrawer, openNoteRightDrawer]);
|
||||||
!customObjectCommands?.length;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
loading: loading || isNotesLoading || isTasksLoading,
|
loading,
|
||||||
noResults,
|
noResults: !commands?.length,
|
||||||
commandGroups: [
|
commandGroups: [
|
||||||
{
|
{
|
||||||
heading: t`Results`,
|
heading: t`Results`,
|
||||||
|
|||||||
@ -3,9 +3,12 @@ import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSi
|
|||||||
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
|
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
|
||||||
import { getCompanyDomainName } from '@/object-metadata/utils/getCompanyDomainName';
|
import { getCompanyDomainName } from '@/object-metadata/utils/getCompanyDomainName';
|
||||||
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
|
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 { REACT_APP_SERVER_BASE_URL } from '~/config';
|
||||||
import { getLogoUrlFromDomainName } from '~/utils';
|
|
||||||
import { getImageIdentifierFieldValue } from './getImageIdentifierFieldValue';
|
import { getImageIdentifierFieldValue } from './getImageIdentifierFieldValue';
|
||||||
|
|
||||||
export const getAvatarUrl = (
|
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', () => {
|
graphql.query('CombinedSearchRecords', () => {
|
||||||
return HttpResponse.json({
|
return HttpResponse.json({
|
||||||
data: {
|
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 { 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 { 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 { 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 { SEARCH_VECTOR_FIELD } from 'src/engine/metadata-modules/constants/search-vector-field.constants';
|
||||||
import { formatResult } from 'src/engine/twenty-orm/utils/format-result.util';
|
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,
|
executionArgs.args.searchInput,
|
||||||
'and',
|
'and',
|
||||||
);
|
);
|
||||||
const searchTermsOr = this.formatSearchTerms(
|
const searchTermsOr = formatSearchTerms(
|
||||||
executionArgs.args.searchInput,
|
executionArgs.args.searchInput,
|
||||||
'or',
|
'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(
|
async validate(
|
||||||
_args: SearchResolverArgs,
|
_args: SearchResolverArgs,
|
||||||
_options: WorkspaceQueryRunnerOptions,
|
_options: WorkspaceQueryRunnerOptions,
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import { EventEmitterModule } from '@nestjs/event-emitter';
|
|||||||
import { ActorModule } from 'src/engine/core-modules/actor/actor.module';
|
import { ActorModule } from 'src/engine/core-modules/actor/actor.module';
|
||||||
import { AdminPanelModule } from 'src/engine/core-modules/admin-panel/admin-panel.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 { 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 { AuthModule } from 'src/engine/core-modules/auth/auth.module';
|
||||||
import { BillingModule } from 'src/engine/core-modules/billing/billing.module';
|
import { BillingModule } from 'src/engine/core-modules/billing/billing.module';
|
||||||
import { CacheStorageModule } from 'src/engine/core-modules/cache-storage/cache-storage.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 { 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 { 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 { 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 { HealthModule } from 'src/engine/core-modules/health/health.module';
|
||||||
import { LabModule } from 'src/engine/core-modules/lab/lab.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';
|
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 { WorkspaceModule } from 'src/engine/core-modules/workspace/workspace.module';
|
||||||
import { RoleModule } from 'src/engine/metadata-modules/role/role.module';
|
import { RoleModule } from 'src/engine/metadata-modules/role/role.module';
|
||||||
import { WorkspaceEventEmitterModule } from 'src/engine/workspace-event-emitter/workspace-event-emitter.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 { AnalyticsModule } from './analytics/analytics.module';
|
||||||
import { ClientConfigModule } from './client-config/client-config.module';
|
import { ClientConfigModule } from './client-config/client-config.module';
|
||||||
@ -120,6 +121,7 @@ import { FileModule } from './file/file.module';
|
|||||||
useFactory: serverlessModuleFactory,
|
useFactory: serverlessModuleFactory,
|
||||||
inject: [EnvironmentService, FileStorageService],
|
inject: [EnvironmentService, FileStorageService],
|
||||||
}),
|
}),
|
||||||
|
GlobalSearchModule,
|
||||||
],
|
],
|
||||||
exports: [
|
exports: [
|
||||||
AnalyticsModule,
|
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/AppLocales';
|
||||||
export * from './constants/SourceLocale';
|
export * from './constants/SourceLocale';
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,7 @@
|
|||||||
import { getLogoUrlFromDomainName, sanitizeURL } from '..';
|
import {
|
||||||
|
getLogoUrlFromDomainName,
|
||||||
|
sanitizeURL,
|
||||||
|
} from '../getLogoUrlFromDomainName';
|
||||||
|
|
||||||
describe('sanitizeURL', () => {
|
describe('sanitizeURL', () => {
|
||||||
test('should sanitize the URL correctly', () => {
|
test('should sanitize the URL correctly', () => {
|
||||||
@ -1 +1,2 @@
|
|||||||
export * from './getImageAbsoluteURI';
|
export * from './getImageAbsoluteURI';
|
||||||
|
export * from './getLogoUrlFromDomainName';
|
||||||
|
|||||||
Reference in New Issue
Block a user