fix: prevent flashing of search results during input (#7592)

solves #7511

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Vardhaman Bhandari
2024-10-14 11:59:54 +05:30
committed by GitHub
parent 653085f1e6
commit 1a57dd654f
2 changed files with 89 additions and 131 deletions

View File

@ -1,5 +1,3 @@
import styled from '@emotion/styled';
import { useOpenCopilotRightDrawer } from '@/activities/copilot/right-drawer/hooks/useOpenCopilotRightDrawer'; import { useOpenCopilotRightDrawer } from '@/activities/copilot/right-drawer/hooks/useOpenCopilotRightDrawer';
import { copilotQueryState } from '@/activities/copilot/right-drawer/states/copilotQueryState'; import { copilotQueryState } from '@/activities/copilot/right-drawer/states/copilotQueryState';
import { useOpenActivityRightDrawer } from '@/activities/hooks/useOpenActivityRightDrawer'; import { useOpenActivityRightDrawer } from '@/activities/hooks/useOpenActivityRightDrawer';
@ -29,13 +27,14 @@ import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useLis
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile'; import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper'; import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import styled from '@emotion/styled';
import { isNonEmptyString } from '@sniptt/guards'; import { isNonEmptyString } from '@sniptt/guards';
import { useMemo, useRef } from 'react'; import { useMemo, useRef } from 'react';
import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil'; import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil';
import { Key } from 'ts-key-enum'; import { Key } from 'ts-key-enum';
import { Avatar, IconNotes, IconSparkles, IconX, isDefined } from 'twenty-ui'; import { Avatar, IconNotes, IconSparkles, IconX, isDefined } from 'twenty-ui';
import { useDebounce } from 'use-debounce';
import { getLogoUrlFromDomainName } from '~/utils'; import { getLogoUrlFromDomainName } from '~/utils';
import { generateILikeFiltersForCompositeFields } from '~/utils/array/generateILikeFiltersForCompositeFields';
const SEARCH_BAR_HEIGHT = 56; const SEARCH_BAR_HEIGHT = 56;
const SEARCH_BAR_PADDING = 3; const SEARCH_BAR_PADDING = 3;
@ -131,7 +130,6 @@ const StyledEmpty = styled.div`
export const CommandMenu = () => { export const CommandMenu = () => {
const { toggleCommandMenu, onItemClick, closeCommandMenu } = useCommandMenu(); const { toggleCommandMenu, onItemClick, closeCommandMenu } = useCommandMenu();
const commandMenuRef = useRef<HTMLDivElement>(null); const commandMenuRef = useRef<HTMLDivElement>(null);
const openActivityRightDrawer = useOpenActivityRightDrawer({ const openActivityRightDrawer = useOpenActivityRightDrawer({
objectNameSingular: CoreObjectNameSingular.Note, objectNameSingular: CoreObjectNameSingular.Note,
}); });
@ -139,9 +137,9 @@ export const CommandMenu = () => {
const [commandMenuSearch, setCommandMenuSearch] = useRecoilState( const [commandMenuSearch, setCommandMenuSearch] = useRecoilState(
commandMenuSearchState, commandMenuSearchState,
); );
const [deferredCommandMenuSearch] = useDebounce(commandMenuSearch, 300); // 200ms - 500ms
const commandMenuCommands = useRecoilValue(commandMenuCommandsState); const commandMenuCommands = useRecoilValue(commandMenuCommandsState);
const { closeKeyboardShortcutMenu } = useKeyboardShortcutMenu(); const { closeKeyboardShortcutMenu } = useKeyboardShortcutMenu();
const handleSearchChange = (event: React.ChangeEvent<HTMLInputElement>) => { const handleSearchChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setCommandMenuSearch(event.target.value); setCommandMenuSearch(event.target.value);
}; };
@ -167,99 +165,45 @@ export const CommandMenu = () => {
[closeCommandMenu], [closeCommandMenu],
); );
const isWorkspaceMigratedForSearch = useIsFeatureEnabled( const { loading: isPeopleLoading, records: people } =
'IS_WORKSPACE_MIGRATED_FOR_SEARCH', useSearchRecords<Person>({
); skip: !isCommandMenuOpened,
objectNameSingular: CoreObjectNameSingular.Person,
limit: 3,
searchInput: deferredCommandMenuSearch ?? undefined,
});
const isSearchEnabled = const { loading: isCompaniesLoading, records: companies } =
useIsFeatureEnabled('IS_SEARCH_ENABLED') && isWorkspaceMigratedForSearch; useSearchRecords<Company>({
skip: !isCommandMenuOpened,
objectNameSingular: CoreObjectNameSingular.Company,
limit: 3,
searchInput: deferredCommandMenuSearch ?? undefined,
});
const { records: peopleFromFindMany } = useFindManyRecords<Person>({ const { loading: isNotesLoading, records: notes } = useFindManyRecords<Note>({
skip: !isCommandMenuOpened || isSearchEnabled,
objectNameSingular: CoreObjectNameSingular.Person,
filter: commandMenuSearch
? makeOrFilterVariables([
...generateILikeFiltersForCompositeFields(commandMenuSearch, 'name', [
'firstName',
'lastName',
]),
...generateILikeFiltersForCompositeFields(
commandMenuSearch,
'emails',
['primaryEmail'],
),
])
: undefined,
limit: 3,
});
const { records: peopleFromSearch } = useSearchRecords<Person>({
skip: !isCommandMenuOpened || !isSearchEnabled,
objectNameSingular: CoreObjectNameSingular.Person,
limit: 3,
searchInput: commandMenuSearch ?? undefined,
});
const people = isSearchEnabled ? peopleFromSearch : peopleFromFindMany;
const { records: companiesFromSearch } = useSearchRecords<Company>({
skip: !isCommandMenuOpened || !isSearchEnabled,
objectNameSingular: CoreObjectNameSingular.Company,
limit: 3,
searchInput: commandMenuSearch ?? undefined,
});
const { records: companiesFromFindMany } = useFindManyRecords<Company>({
skip: !isCommandMenuOpened || isSearchEnabled,
objectNameSingular: CoreObjectNameSingular.Company,
filter: commandMenuSearch
? {
name: { ilike: `%${commandMenuSearch}%` },
}
: undefined,
limit: 3,
});
const companies = isSearchEnabled
? companiesFromSearch
: companiesFromFindMany;
const { records: notes } = useFindManyRecords<Note>({
skip: !isCommandMenuOpened, skip: !isCommandMenuOpened,
objectNameSingular: CoreObjectNameSingular.Note, objectNameSingular: CoreObjectNameSingular.Note,
filter: commandMenuSearch filter: deferredCommandMenuSearch
? makeOrFilterVariables([ ? makeOrFilterVariables([
{ title: { ilike: `%${commandMenuSearch}%` } }, { title: { ilike: `%${deferredCommandMenuSearch}%` } },
{ body: { ilike: `%${commandMenuSearch}%` } }, { body: { ilike: `%${deferredCommandMenuSearch}%` } },
]) ])
: undefined, : undefined,
limit: 3, limit: 3,
}); });
const { records: opportunitiesFromFindMany } = useFindManyRecords({ const { loading: isOpportunitiesLoading, records: opportunities } =
skip: !isCommandMenuOpened || isSearchEnabled, useSearchRecords<Opportunity>({
objectNameSingular: CoreObjectNameSingular.Opportunity, skip: !isCommandMenuOpened,
filter: commandMenuSearch objectNameSingular: CoreObjectNameSingular.Opportunity,
? { limit: 3,
name: { ilike: `%${commandMenuSearch}%` }, searchInput: deferredCommandMenuSearch ?? undefined,
} });
: undefined,
limit: 3,
});
const { records: opportunitiesFromSearch } = useSearchRecords<Opportunity>({
skip: !isCommandMenuOpened || !isSearchEnabled,
objectNameSingular: CoreObjectNameSingular.Opportunity,
limit: 3,
searchInput: commandMenuSearch ?? undefined,
});
const opportunities = isSearchEnabled
? opportunitiesFromSearch
: opportunitiesFromFindMany;
const peopleCommands = useMemo( const peopleCommands = useMemo(
() => () =>
people.map(({ id, name: { firstName, lastName } }) => ({ people?.map(({ id, name: { firstName, lastName } }) => ({
id, id,
label: `${firstName} ${lastName}`, label: `${firstName} ${lastName}`,
to: `object/person/${id}`, to: `object/person/${id}`,
@ -269,7 +213,7 @@ export const CommandMenu = () => {
const companyCommands = useMemo( const companyCommands = useMemo(
() => () =>
companies.map(({ id, name }) => ({ companies?.map(({ id, name }) => ({
id, id,
label: name ?? '', label: name ?? '',
to: `object/company/${id}`, to: `object/company/${id}`,
@ -279,7 +223,7 @@ export const CommandMenu = () => {
const opportunityCommands = useMemo( const opportunityCommands = useMemo(
() => () =>
opportunities.map(({ id, name }) => ({ opportunities?.map(({ id, name }) => ({
id, id,
label: name ?? '', label: name ?? '',
to: `object/opportunity/${id}`, to: `object/opportunity/${id}`,
@ -289,7 +233,7 @@ export const CommandMenu = () => {
const noteCommands = useMemo( const noteCommands = useMemo(
() => () =>
notes.map((note) => ({ notes?.map((note) => ({
id: note.id, id: note.id,
label: note.title ?? '', label: note.title ?? '',
to: '', to: '',
@ -299,12 +243,20 @@ export const CommandMenu = () => {
); );
const otherCommands = useMemo(() => { const otherCommands = useMemo(() => {
return [ const commandsArray: Command[] = [];
...peopleCommands, if (peopleCommands?.length > 0) {
...companyCommands, commandsArray.push(...(peopleCommands as Command[]));
...opportunityCommands, }
...noteCommands, if (companyCommands?.length > 0) {
] as Command[]; commandsArray.push(...(companyCommands as Command[]));
}
if (opportunityCommands?.length > 0) {
commandsArray.push(...(opportunityCommands as Command[]));
}
if (noteCommands?.length > 0) {
commandsArray.push(...(noteCommands as Command[]));
}
return commandsArray;
}, [peopleCommands, companyCommands, noteCommands, opportunityCommands]); }, [peopleCommands, companyCommands, noteCommands, opportunityCommands]);
const checkInShortcuts = (cmd: Command, search: string) => { const checkInShortcuts = (cmd: Command, search: string) => {
@ -322,17 +274,17 @@ export const CommandMenu = () => {
const matchingNavigateCommand = commandMenuCommands.filter( const matchingNavigateCommand = commandMenuCommands.filter(
(cmd) => (cmd) =>
(commandMenuSearch.length > 0 (deferredCommandMenuSearch.length > 0
? checkInShortcuts(cmd, commandMenuSearch) || ? checkInShortcuts(cmd, deferredCommandMenuSearch) ||
checkInLabels(cmd, commandMenuSearch) checkInLabels(cmd, deferredCommandMenuSearch)
: true) && cmd.type === CommandType.Navigate, : true) && cmd.type === CommandType.Navigate,
); );
const matchingCreateCommand = commandMenuCommands.filter( const matchingCreateCommand = commandMenuCommands.filter(
(cmd) => (cmd) =>
(commandMenuSearch.length > 0 (deferredCommandMenuSearch.length > 0
? checkInShortcuts(cmd, commandMenuSearch) || ? checkInShortcuts(cmd, deferredCommandMenuSearch) ||
checkInLabels(cmd, commandMenuSearch) checkInLabels(cmd, deferredCommandMenuSearch)
: true) && cmd.type === CommandType.Create, : true) && cmd.type === CommandType.Create,
); );
@ -352,7 +304,7 @@ export const CommandMenu = () => {
label: 'Open Copilot', label: 'Open Copilot',
type: CommandType.Navigate, type: CommandType.Navigate,
onCommandClick: () => { onCommandClick: () => {
setCopilotQuery(commandMenuSearch); setCopilotQuery(deferredCommandMenuSearch);
openCopilotRightDrawer(); openCopilotRightDrawer();
}, },
}; };
@ -363,10 +315,23 @@ export const CommandMenu = () => {
.map((cmd) => cmd.id) .map((cmd) => cmd.id)
.concat(matchingCreateCommand.map((cmd) => cmd.id)) .concat(matchingCreateCommand.map((cmd) => cmd.id))
.concat(matchingNavigateCommand.map((cmd) => cmd.id)) .concat(matchingNavigateCommand.map((cmd) => cmd.id))
.concat(people.map((person) => person.id)) .concat(people?.map((person) => person.id))
.concat(companies.map((company) => company.id)) .concat(companies?.map((company) => company.id))
.concat(opportunities.map((opportunity) => opportunity.id)) .concat(opportunities?.map((opportunity) => opportunity.id))
.concat(notes.map((note) => note.id)); .concat(notes?.map((note) => note.id));
const isNoResults =
!matchingCreateCommand.length &&
!matchingNavigateCommand.length &&
!people?.length &&
!companies?.length &&
!notes?.length &&
!opportunities?.length;
const isLoading =
isPeopleLoading ||
isNotesLoading ||
isOpportunitiesLoading ||
isCompaniesLoading;
return ( return (
<> <>
@ -410,14 +375,9 @@ export const CommandMenu = () => {
} }
}} }}
> >
{!matchingCreateCommand.length && {isNoResults && !isLoading && (
!matchingNavigateCommand.length && <StyledEmpty>No results found</StyledEmpty>
!people.length && )}
!companies.length &&
!notes.length &&
!opportunities.length && (
<StyledEmpty>No results found</StyledEmpty>
)}
{isCopilotEnabled && ( {isCopilotEnabled && (
<CommandGroup heading="Copilot"> <CommandGroup heading="Copilot">
<SelectableItem itemId={copilotCommand.id}> <SelectableItem itemId={copilotCommand.id}>
@ -425,8 +385,8 @@ export const CommandMenu = () => {
id={copilotCommand.id} id={copilotCommand.id}
Icon={copilotCommand.Icon} Icon={copilotCommand.Icon}
label={`${copilotCommand.label} ${ label={`${copilotCommand.label} ${
commandMenuSearch.length > 2 deferredCommandMenuSearch.length > 2
? `"${commandMenuSearch}"` ? `"${deferredCommandMenuSearch}"`
: '' : ''
}`} }`}
onClick={copilotCommand.onCommandClick} onClick={copilotCommand.onCommandClick}
@ -467,7 +427,7 @@ export const CommandMenu = () => {
))} ))}
</CommandGroup> </CommandGroup>
<CommandGroup heading="People"> <CommandGroup heading="People">
{people.map((person) => ( {people?.map((person) => (
<SelectableItem itemId={person.id} key={person.id}> <SelectableItem itemId={person.id} key={person.id}>
<CommandMenuItem <CommandMenuItem
id={person.id} id={person.id}
@ -493,7 +453,7 @@ export const CommandMenu = () => {
))} ))}
</CommandGroup> </CommandGroup>
<CommandGroup heading="Companies"> <CommandGroup heading="Companies">
{companies.map((company) => ( {companies?.map((company) => (
<SelectableItem itemId={company.id} key={company.id}> <SelectableItem itemId={company.id} key={company.id}>
<CommandMenuItem <CommandMenuItem
id={company.id} id={company.id}
@ -514,7 +474,7 @@ export const CommandMenu = () => {
))} ))}
</CommandGroup> </CommandGroup>
<CommandGroup heading="Opportunities"> <CommandGroup heading="Opportunities">
{opportunities.map((opportunity) => ( {opportunities?.map((opportunity) => (
<SelectableItem <SelectableItem
itemId={opportunity.id} itemId={opportunity.id}
key={opportunity.id} key={opportunity.id}
@ -522,14 +482,14 @@ export const CommandMenu = () => {
<CommandMenuItem <CommandMenuItem
id={opportunity.id} id={opportunity.id}
key={opportunity.id} key={opportunity.id}
label={opportunity.name} label={opportunity.name ?? ''}
to={`object/opportunity/${opportunity.id}`} to={`object/opportunity/${opportunity.id}`}
Icon={() => ( Icon={() => (
<Avatar <Avatar
type="rounded" type="rounded"
avatarUrl={null} avatarUrl={null}
placeholderColorSeed={opportunity.id} placeholderColorSeed={opportunity.id}
placeholder={opportunity.name} placeholder={opportunity.name ?? ''}
/> />
)} )}
/> />
@ -537,7 +497,7 @@ export const CommandMenu = () => {
))} ))}
</CommandGroup> </CommandGroup>
<CommandGroup heading="Notes"> <CommandGroup heading="Notes">
{notes.map((note) => ( {notes?.map((note) => (
<SelectableItem itemId={note.id} key={note.id}> <SelectableItem itemId={note.id} key={note.id}>
<CommandMenuItem <CommandMenuItem
id={note.id} id={note.id}

View File

@ -1,6 +1,3 @@
import { useQuery, WatchQueryFetchPolicy } from '@apollo/client';
import { useRecoilValue } from 'recoil';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState'; import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { ObjectMetadataItemIdentifier } from '@/object-metadata/types/ObjectMetadataItemIdentifier'; import { ObjectMetadataItemIdentifier } from '@/object-metadata/types/ObjectMetadataItemIdentifier';
@ -13,7 +10,9 @@ import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { getSearchRecordsQueryResponseField } from '@/object-record/utils/getSearchRecordsQueryResponseField'; import { getSearchRecordsQueryResponseField } from '@/object-record/utils/getSearchRecordsQueryResponseField';
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar'; import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useQuery, WatchQueryFetchPolicy } from '@apollo/client';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useRecoilValue } from 'recoil';
import { logError } from '~/utils/logError'; import { logError } from '~/utils/logError';
export type UseSearchRecordsParams = ObjectMetadataItemIdentifier & export type UseSearchRecordsParams = ObjectMetadataItemIdentifier &
@ -43,10 +42,8 @@ export const useSearchRecords = <T extends ObjectRecord = ObjectRecord>({
}); });
const { enqueueSnackBar } = useSnackBar(); const { enqueueSnackBar } = useSnackBar();
const { data, loading, error, previousData } =
const { data, loading, error } = useQuery<RecordGqlOperationSearchResult>( useQuery<RecordGqlOperationSearchResult>(searchRecordsQuery, {
searchRecordsQuery,
{
skip: skip:
skip || !objectMetadataItem || !currentWorkspaceMember || !searchInput, skip || !objectMetadataItem || !currentWorkspaceMember || !searchInput,
variables: { variables: {
@ -66,14 +63,15 @@ export const useSearchRecords = <T extends ObjectRecord = ObjectRecord>({
}, },
); );
}, },
}, });
);
const effectiveData = loading ? previousData : data;
const queryResponseField = getSearchRecordsQueryResponseField( const queryResponseField = getSearchRecordsQueryResponseField(
objectMetadataItem.namePlural, objectMetadataItem.namePlural,
); );
const result = data?.[queryResponseField]; const result = effectiveData?.[queryResponseField];
const records = useMemo( const records = useMemo(
() => () =>