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