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

# Context

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

-----

- [x] Test to add - work in progress


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

View File

@ -27,13 +27,10 @@ import { IconDotsVertical } from 'twenty-ui';
import { FeatureFlagKey } from '~/generated/graphql';
import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator';
import { JestContextStoreSetter } from '~/testing/jest/JestContextStoreSetter';
import { getCompaniesMock } from '~/testing/mock-data/companies';
import { CommandMenu } from '../CommandMenu';
const openTimeout = 50;
const companiesMock = getCompaniesMock();
// Mock workspace with feature flag enabled
const mockWorkspaceWithFeatureFlag = {
...mockCurrentWorkspace,
@ -167,33 +164,18 @@ export const NoResultsSearchFallback: Story = {
const canvas = within(document.body);
const searchInput = await canvas.findByPlaceholderText('Type anything');
await sleep(openTimeout);
await userEvent.type(searchInput, 'Linkedin');
await userEvent.type(searchInput, 'input without results');
expect(await canvas.findByText('No results found')).toBeVisible();
const searchRecordsButton = await canvas.findByText('Search records');
expect(searchRecordsButton).toBeVisible();
await userEvent.click(searchRecordsButton);
expect(await canvas.findByText('Linkedin')).toBeVisible();
},
parameters: {
msw: {
handlers: [
graphql.query('CombinedSearchRecords', () => {
graphql.query('GlobalSearch', () => {
return HttpResponse.json({
data: {
searchCompanies: {
edges: [
{
node: companiesMock[0],
cursor: null,
},
],
pageInfo: {
hasNextPage: false,
hasPreviousPage: false,
startCursor: null,
endCursor: null,
},
},
globalSearch: [],
},
});
}),

View File

@ -0,0 +1,22 @@
import gql from 'graphql-tag';
export const globalSearch = gql`
query GlobalSearch(
$searchInput: String!
$limit: Int!
$excludedObjectNameSingulars: [String!]!
) {
globalSearch(
searchInput: $searchInput
limit: $limit
excludedObjectNameSingulars: $excludedObjectNameSingulars
) {
recordId
objectSingularName
label
imageUrl
tsRankCD
tsRank
}
}
`;

View File

@ -1,156 +1,29 @@
import { useOpenActivityRightDrawer } from '@/activities/hooks/useOpenActivityRightDrawer';
import { Note } from '@/activities/types/Note';
import { Task } from '@/activities/types/Task';
import { commandMenuSearchState } from '@/command-menu/states/commandMenuSearchState';
import { Company } from '@/companies/types/Company';
import { CoreObjectNamePlural } from '@/object-metadata/types/CoreObjectNamePlural';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { getCompanyDomainName } from '@/object-metadata/utils/getCompanyDomainName';
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
import { useMultiObjectSearch } from '@/object-record/record-picker/hooks/useMultiObjectSearch';
import { useMultiObjectSearchQueryResultFormattedAsObjectRecordsMap } from '@/object-record/record-picker/hooks/useMultiObjectSearchQueryResultFormattedAsObjectRecordsMap';
import { makeOrFilterVariables } from '@/object-record/utils/makeOrFilterVariables';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { t } from '@lingui/core/macro';
import isEmpty from 'lodash.isempty';
import { useMemo } from 'react';
import { useRecoilValue } from 'recoil';
import { Avatar, IconCheckbox, IconNotes } from 'twenty-ui';
import { capitalize } from 'twenty-shared';
import { Avatar } from 'twenty-ui';
import { useDebounce } from 'use-debounce';
import { FeatureFlagKey } from '~/generated-metadata/graphql';
import { getLogoUrlFromDomainName } from '~/utils';
import { useGlobalSearchQuery } from '~/generated/graphql';
const MAX_SEARCH_RESULTS_PER_OBJECT = 8;
const MAX_SEARCH_RESULTS = 30;
export const useSearchRecords = () => {
const commandMenuSearch = useRecoilValue(commandMenuSearchState);
const isRichTextV2Enabled = useIsFeatureEnabled(
FeatureFlagKey.IsRichTextV2Enabled,
);
const [deferredCommandMenuSearch] = useDebounce(commandMenuSearch, 300);
const {
matchesSearchFilterObjectRecordsQueryResult,
matchesSearchFilterObjectRecordsLoading: loading,
} = useMultiObjectSearch({
excludedObjects: [CoreObjectNameSingular.Task, CoreObjectNameSingular.Note],
searchFilterValue: deferredCommandMenuSearch ?? undefined,
limit: MAX_SEARCH_RESULTS_PER_OBJECT,
const { data: globalSearchData, loading } = useGlobalSearchQuery({
variables: {
searchInput: deferredCommandMenuSearch ?? '',
limit: MAX_SEARCH_RESULTS,
excludedObjectNameSingulars: [],
},
});
const { objectRecordsMap: matchesSearchFilterObjectRecords } =
useMultiObjectSearchQueryResultFormattedAsObjectRecordsMap({
multiObjectRecordsQueryResult:
matchesSearchFilterObjectRecordsQueryResult,
});
const { loading: isNotesLoading, records: notes } = useFindManyRecords<Note>({
objectNameSingular: CoreObjectNameSingular.Note,
filter: deferredCommandMenuSearch
? makeOrFilterVariables([
{ title: { ilike: `%${deferredCommandMenuSearch}%` } },
isRichTextV2Enabled
? {
bodyV2: {
markdown: { ilike: `%${deferredCommandMenuSearch}%` },
},
}
: { body: { ilike: `%${deferredCommandMenuSearch}%` } },
])
: undefined,
limit: MAX_SEARCH_RESULTS_PER_OBJECT,
});
const { loading: isTasksLoading, records: tasks } = useFindManyRecords<Task>({
objectNameSingular: CoreObjectNameSingular.Task,
filter: deferredCommandMenuSearch
? makeOrFilterVariables([
{ title: { ilike: `%${deferredCommandMenuSearch}%` } },
isRichTextV2Enabled
? {
bodyV2: {
markdown: { ilike: `%${deferredCommandMenuSearch}%` },
},
}
: { body: { ilike: `%${deferredCommandMenuSearch}%` } },
])
: undefined,
limit: MAX_SEARCH_RESULTS_PER_OBJECT,
});
const people = matchesSearchFilterObjectRecords.people?.map(
(people) => people.record,
);
const companies = matchesSearchFilterObjectRecords.companies?.map(
(companies) => companies.record,
);
const opportunities = matchesSearchFilterObjectRecords.opportunities?.map(
(opportunities) => opportunities.record,
);
const peopleCommands = useMemo(
() =>
people?.map(({ id, name: { firstName, lastName }, avatarUrl }) => ({
id,
label: `${firstName} ${lastName}`,
description: 'Person',
to: `object/person/${id}`,
shouldCloseCommandMenuOnClick: true,
Icon: () => (
<Avatar
type="rounded"
avatarUrl={avatarUrl}
placeholderColorSeed={id}
placeholder={`${firstName} ${lastName}`}
/>
),
})),
[people],
);
const companyCommands = useMemo(
() =>
companies?.map((company) => ({
id: company.id,
label: company.name ?? '',
description: 'Company',
to: `object/company/${company.id}`,
shouldCloseCommandMenuOnClick: true,
Icon: () => (
<Avatar
placeholderColorSeed={company.id}
placeholder={company.name}
avatarUrl={getLogoUrlFromDomainName(
getCompanyDomainName(company as Company),
)}
/>
),
})),
[companies],
);
const opportunityCommands = useMemo(
() =>
opportunities?.map(({ id, name }) => ({
id,
label: name ?? '',
description: 'Opportunity',
to: `object/opportunity/${id}`,
shouldCloseCommandMenuOnClick: true,
Icon: () => (
<Avatar
type="rounded"
avatarUrl={null}
placeholderColorSeed={id}
placeholder={name ?? ''}
/>
),
})),
[opportunities],
);
const openNoteRightDrawer = useOpenActivityRightDrawer({
objectNameSingular: CoreObjectNameSingular.Note,
});
@ -159,87 +32,49 @@ export const useSearchRecords = () => {
objectNameSingular: CoreObjectNameSingular.Task,
});
const noteCommands = useMemo(
() =>
notes?.map((note) => ({
id: note.id,
label: note.title ?? '',
description: 'Note',
to: '',
onCommandClick: () => openNoteRightDrawer(note.id),
shouldCloseCommandMenuOnClick: true,
Icon: IconNotes,
})),
[notes, openNoteRightDrawer],
);
const tasksCommands = useMemo(
() =>
tasks?.map((task) => ({
id: task.id,
label: task.title ?? '',
description: 'Task',
to: '',
onCommandClick: () => openTaskRightDrawer(task.id),
shouldCloseCommandMenuOnClick: true,
Icon: IconCheckbox,
})),
[tasks, openTaskRightDrawer],
);
const customObjectRecordsMap = useMemo(() => {
return Object.fromEntries(
Object.entries(matchesSearchFilterObjectRecords).filter(
([namePlural, records]) =>
![
CoreObjectNamePlural.Person,
CoreObjectNamePlural.Opportunity,
CoreObjectNamePlural.Company,
].includes(namePlural as CoreObjectNamePlural) && !isEmpty(records),
),
);
}, [matchesSearchFilterObjectRecords]);
const customObjectCommands = useMemo(() => {
return Object.values(customObjectRecordsMap).flatMap((objectRecords) =>
objectRecords.map((objectRecord) => ({
id: objectRecord.record.id,
label: objectRecord.recordIdentifier.name,
description: objectRecord.objectMetadataItem.labelSingular,
to: `object/${objectRecord.objectMetadataItem.nameSingular}/${objectRecord.record.id}`,
const commands = useMemo(() => {
return (globalSearchData?.globalSearch ?? []).map((searchRecord) => {
const command = {
id: searchRecord.recordId,
label: searchRecord.label,
description: capitalize(searchRecord.objectSingularName),
to: `object/${searchRecord.objectSingularName}/${searchRecord.recordId}`,
shouldCloseCommandMenuOnClick: true,
Icon: () => (
<Avatar
type="rounded"
avatarUrl={objectRecord.record.avatarUrl}
placeholderColorSeed={objectRecord.record.id}
placeholder={objectRecord.recordIdentifier.name ?? ''}
type={
searchRecord.objectSingularName === 'company'
? 'squared'
: 'rounded'
}
avatarUrl={searchRecord.imageUrl}
placeholderColorSeed={searchRecord.recordId}
placeholder={searchRecord.label}
/>
),
})),
);
}, [customObjectRecordsMap]);
const commands = [
...(peopleCommands ?? []),
...(companyCommands ?? []),
...(opportunityCommands ?? []),
...(noteCommands ?? []),
...(tasksCommands ?? []),
...(customObjectCommands ?? []),
];
const noResults =
!peopleCommands?.length &&
!companyCommands?.length &&
!opportunityCommands?.length &&
!noteCommands?.length &&
!tasksCommands?.length &&
!customObjectCommands?.length;
};
if (
[CoreObjectNameSingular.Task, CoreObjectNameSingular.Note].includes(
searchRecord.objectSingularName as CoreObjectNameSingular,
)
) {
return {
...command,
to: '',
onCommandClick: () => {
searchRecord.objectSingularName === 'task'
? openTaskRightDrawer(searchRecord.recordId)
: openNoteRightDrawer(searchRecord.recordId);
},
};
}
return command;
});
}, [globalSearchData, openTaskRightDrawer, openNoteRightDrawer]);
return {
loading: loading || isNotesLoading || isTasksLoading,
noResults,
loading,
noResults: !commands?.length,
commandGroups: [
{
heading: t`Results`,

View File

@ -3,9 +3,12 @@ import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSi
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { getCompanyDomainName } from '@/object-metadata/utils/getCompanyDomainName';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { getImageAbsoluteURI, isDefined } from 'twenty-shared';
import {
getImageAbsoluteURI,
getLogoUrlFromDomainName,
isDefined,
} from 'twenty-shared';
import { REACT_APP_SERVER_BASE_URL } from '~/config';
import { getLogoUrlFromDomainName } from '~/utils';
import { getImageIdentifierFieldValue } from './getImageIdentifierFieldValue';
export const getAvatarUrl = (