Add search to cmd bar (#667)

* Move useFilteredSearchEntityQuery from relation picker to search module

* refactor duplicated code with useFilteredSearchCompanyQuery

* Implement similar pattern for people than for companies with useFilteredSearchEntityQuery

* Fix warning from a previous PR

* Enable search from menu

* Add companies to search

* Fix ESLint

* Refactor

* Fix according to peer review

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Félix Malfait 2023-07-16 00:23:37 +02:00 committed by GitHub
parent b982788100
commit 7959308e0b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 202 additions and 139 deletions

View File

@ -1,6 +1,7 @@
import { useLocation } from 'react-router-dom';
import { useTheme } from '@emotion/react';
import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
import { SettingsNavbar } from '@/settings/components/SettingsNavbar';
import {
IconBuildingSkyscraper,
@ -19,6 +20,7 @@ import NavTitle from './modules/ui/layout/navbar/NavTitle';
export function AppNavbar() {
const theme = useTheme();
const currentPath = useLocation().pathname;
const { openCommandMenu } = useCommandMenu();
const isSubNavbarDisplayed = useIsSubNavbarDisplayed();
@ -29,9 +31,10 @@ export function AppNavbar() {
<>
<NavItem
label="Search"
to="/search"
icon={<IconSearch size={theme.icon.size.md} />}
soon={true}
onClick={() => {
openCommandMenu();
}}
/>
<NavItem
label="Inbox"

View File

@ -1,10 +1,15 @@
import { useRecoilState } from 'recoil';
import { useState } from 'react';
import { useTheme } from '@emotion/react';
import { useRecoilValue } from 'recoil';
import { usePreviousHotkeyScope } from '@/lib/hotkeys/hooks/usePreviousHotkeyScope';
import { useFilteredSearchCompanyQuery } from '@/companies/services';
import { useScopedHotkeys } from '@/lib/hotkeys/hooks/useScopedHotkeys';
import { AppHotkeyScope } from '@/lib/hotkeys/types/AppHotkeyScope';
import { useFilteredSearchPeopleQuery } from '@/people/services';
import { Avatar } from '@/users/components/Avatar';
import { isCommandMenuOpenedState } from '../states/isCommandMenuOpened';
import { useCommandMenu } from '../hooks/useCommandMenu';
import { isCommandMenuOpenedState } from '../states/isCommandMenuOpenedState';
import { CommandMenuItem } from './CommandMenuItem';
import {
@ -16,31 +21,29 @@ import {
} from './CommandMenuStyles';
export function CommandMenu() {
const [open, setOpen] = useRecoilState(isCommandMenuOpenedState);
const { openCommandMenu, closeCommandMenu } = useCommandMenu();
const isCommandMenuOpened = useRecoilValue(isCommandMenuOpenedState);
const [search, setSearch] = useState('');
useScopedHotkeys(
'ctrl+k,meta+k',
() => {
handleOpenChange(!open);
openCommandMenu();
},
AppHotkeyScope.CommandMenu,
[setOpen, open, handleOpenChange],
[openCommandMenu],
);
const {
setHotkeyScopeAndMemorizePreviousScope,
goBackToPreviousHotkeyScope,
} = usePreviousHotkeyScope();
function handleOpenChange(newOpenState: boolean) {
if (newOpenState) {
setOpen(true);
setHotkeyScopeAndMemorizePreviousScope(AppHotkeyScope.CommandMenu);
} else {
setOpen(false);
goBackToPreviousHotkeyScope();
}
}
const people = useFilteredSearchPeopleQuery({
searchFilter: search,
selectedIds: [],
limit: 3,
});
const companies = useFilteredSearchCompanyQuery({
searchFilter: search,
selectedIds: [],
limit: 3,
});
/*
TODO: Allow performing actions on page through CommandBar
@ -79,15 +82,64 @@ export function CommandMenu() {
</StyledGroup>
);*/
const theme = useTheme();
return (
<StyledDialog
open={open}
onOpenChange={handleOpenChange}
open={isCommandMenuOpened}
onOpenChange={(opened) => {
if (!opened) {
closeCommandMenu();
}
}}
label="Global Command Menu"
shouldFilter={false}
>
<StyledInput placeholder="Search" />
<StyledInput
placeholder="Search"
value={search}
onValueChange={setSearch}
/>
<StyledList>
<StyledEmpty>No results found.</StyledEmpty>
{!!people.entitiesToSelect.length && (
<StyledGroup heading="People">
{people.entitiesToSelect.map((person) => (
<CommandMenuItem
to={`person/${person.id}`}
label={person.name}
key={person.id}
icon={
<Avatar
avatarUrl={person.avatarUrl}
size={theme.icon.size.sm}
colorId={person.id}
placeholder={person.name}
/>
}
/>
))}
</StyledGroup>
)}
{!!companies.entitiesToSelect.length && (
<StyledGroup heading="Companies">
{companies.entitiesToSelect.map((company) => (
<CommandMenuItem
to={`companies/${company.id}`}
label={company.name}
key={company.id}
icon={
<Avatar
avatarUrl={company.avatarUrl}
size={theme.icon.size.sm}
colorId={company.id}
placeholder={company.name}
/>
}
/>
))}
</StyledGroup>
)}
<StyledGroup heading="Navigate">
<CommandMenuItem
to="/people"

View File

@ -1,11 +1,10 @@
import React from 'react';
import { ReactNode } from 'react';
import { useNavigate } from 'react-router-dom';
import { useSetRecoilState } from 'recoil';
import { IconArrowUpRight } from '@/ui/icons';
import { isCommandMenuOpenedState } from '../states/isCommandMenuOpened';
import { useCommandMenu } from '../hooks/useCommandMenu';
import {
StyledIconAndLabelContainer,
@ -30,15 +29,15 @@ export function CommandMenuItem({
icon,
shortcuts,
}: OwnProps) {
const setOpen = useSetRecoilState(isCommandMenuOpenedState);
const navigate = useNavigate();
const { closeCommandMenu } = useCommandMenu();
if (to) {
if (to && !icon) {
icon = <IconArrowUpRight />;
}
const onItemClick = () => {
setOpen(false);
closeCommandMenu();
if (onClick) {
onClick();

View File

@ -12,8 +12,8 @@ export const StyledDialog = styled(Command.Dialog)`
padding: 0;
padding: ${({ theme }) => theme.spacing(1)};
position: fixed;
top: 50%;
transform: translate(-50%, -50%);
top: 30%;
transform: translateX(-50%);
width: 100%;
z-index: 1000;
`;

View File

@ -0,0 +1,31 @@
import { useRecoilState } from 'recoil';
import { usePreviousHotkeyScope } from '@/lib/hotkeys/hooks/usePreviousHotkeyScope';
import { AppHotkeyScope } from '@/lib/hotkeys/types/AppHotkeyScope';
import { isCommandMenuOpenedState } from '../states/isCommandMenuOpenedState';
export function useCommandMenu() {
const [, setIsCommandMenuOpenedState] = useRecoilState(
isCommandMenuOpenedState,
);
const {
setHotkeyScopeAndMemorizePreviousScope,
goBackToPreviousHotkeyScope,
} = usePreviousHotkeyScope();
function openCommandMenu() {
setIsCommandMenuOpenedState(true);
setHotkeyScopeAndMemorizePreviousScope(AppHotkeyScope.CommandMenu);
}
function closeCommandMenu() {
setIsCommandMenuOpenedState(false);
goBackToPreviousHotkeyScope();
}
return {
openCommandMenu,
closeCommandMenu,
};
}

View File

@ -9,24 +9,21 @@ import {
} from '@floating-ui/react';
import { useHandleCheckableCommentThreadTargetChange } from '@/comments/hooks/useHandleCheckableCommentThreadTargetChange';
import { CommentableEntityForSelect } from '@/comments/types/CommentableEntityForSelect';
import { CompanyChip } from '@/companies/components/CompanyChip';
import { useFilteredSearchCompanyQuery } from '@/companies/services';
import { usePreviousHotkeyScope } from '@/lib/hotkeys/hooks/usePreviousHotkeyScope';
import { useScopedHotkeys } from '@/lib/hotkeys/hooks/useScopedHotkeys';
import { PersonChip } from '@/people/components/PersonChip';
import { useFilteredSearchPeopleQuery } from '@/people/services';
import { RecoilScope } from '@/recoil-scope/components/RecoilScope';
import { MultipleEntitySelect } from '@/relation-picker/components/MultipleEntitySelect';
import { useFilteredSearchEntityQuery } from '@/relation-picker/hooks/useFilteredSearchEntityQuery';
import { RelationPickerHotkeyScope } from '@/relation-picker/types/RelationPickerHotkeyScope';
import { useListenClickOutsideArrayOfRef } from '@/ui/hooks/useListenClickOutsideArrayOfRef';
import { flatMapAndSortEntityForSelectArrayOfArrayByName } from '@/ui/utils/flatMapAndSortEntityForSelectArrayByName';
import { getLogoUrlFromDomainName } from '@/utils/utils';
import {
CommentableType,
CommentThread,
CommentThreadTarget,
useSearchCompanyQuery,
useSearchPeopleQuery,
} from '~/generated/graphql';
type OwnProps = {
@ -90,35 +87,14 @@ export function CommentThreadRelationPicker({ commentThread }: OwnProps) {
?.filter((relation) => relation.commentableType === 'Company')
.map((relation) => relation.commentableId) ?? [];
const personsForMultiSelect = useFilteredSearchEntityQuery({
queryHook: useSearchPeopleQuery,
searchOnFields: ['firstName', 'lastName'],
orderByField: 'lastName',
selectedIds: peopleIds,
mappingFunction: (entity) =>
({
id: entity.id,
entityType: CommentableType.Person,
name: `${entity.firstName} ${entity.lastName}`,
avatarType: 'rounded',
} as CommentableEntityForSelect),
const personsForMultiSelect = useFilteredSearchPeopleQuery({
searchFilter,
selectedIds: peopleIds,
});
const companiesForMultiSelect = useFilteredSearchEntityQuery({
queryHook: useSearchCompanyQuery,
searchOnFields: ['name'],
orderByField: 'name',
selectedIds: companyIds,
mappingFunction: (company) =>
({
id: company.id,
entityType: CommentableType.Company,
name: company.name,
avatarUrl: getLogoUrlFromDomainName(company.domainName),
avatarType: 'squared',
} as CommentableEntityForSelect),
const companiesForMultiSelect = useFilteredSearchCompanyQuery({
searchFilter,
selectedIds: companyIds,
});
const {

View File

@ -1,9 +1,9 @@
import { useRecoilScopedState } from '@/recoil-scope/hooks/useRecoilScopedState';
import { SingleEntitySelect } from '@/relation-picker/components/SingleEntitySelect';
import { useFilteredSearchEntityQuery } from '@/relation-picker/hooks/useFilteredSearchEntityQuery';
import { relationPickerSearchFilterScopedState } from '@/relation-picker/states/relationPickerSearchFilterScopedState';
import { EntityForSelect } from '@/relation-picker/types/EntityForSelect';
import { Entity } from '@/relation-picker/types/EntityTypeForSelect';
import { useFilteredSearchEntityQuery } from '@/search/hooks/useFilteredSearchEntityQuery';
import { useEditableCell } from '@/ui/components/editable-cell/hooks/useEditableCell';
import {
Company,

View File

@ -3,11 +3,9 @@ import { filterDropdownSearchInputScopedState } from '@/lib/filters-and-sorts/st
import { filterDropdownSelectedEntityIdScopedState } from '@/lib/filters-and-sorts/states/filterDropdownSelectedEntityIdScopedState';
import { useRecoilScopedState } from '@/recoil-scope/hooks/useRecoilScopedState';
import { useRecoilScopedValue } from '@/recoil-scope/hooks/useRecoilScopedValue';
import { useFilteredSearchEntityQuery } from '@/relation-picker/hooks/useFilteredSearchEntityQuery';
import { Entity } from '@/relation-picker/types/EntityTypeForSelect';
import { TableContext } from '@/ui/tables/states/TableContext';
import { getLogoUrlFromDomainName } from '@/utils/utils';
import { useSearchCompanyQuery } from '~/generated/graphql';
import { useFilteredSearchCompanyQuery } from '../services';
export function FilterDropdownCompanySearchSelect() {
const filterDropdownSearchInput = useRecoilScopedValue(
@ -20,21 +18,11 @@ export function FilterDropdownCompanySearchSelect() {
TableContext,
);
const usersForSelect = useFilteredSearchEntityQuery({
queryHook: useSearchCompanyQuery,
searchOnFields: ['name'],
orderByField: 'name',
const usersForSelect = useFilteredSearchCompanyQuery({
searchFilter: filterDropdownSearchInput,
selectedIds: filterDropdownSelectedEntityId
? [filterDropdownSelectedEntityId]
: [],
mappingFunction: (company) => ({
id: company.id,
entityType: Entity.User,
name: `${company.name}`,
avatarType: 'squared',
avatarUrl: getLogoUrlFromDomainName(company.domainName),
}),
searchFilter: filterDropdownSearchInput,
});
return (

View File

@ -2,31 +2,16 @@ import { useCallback } from 'react';
import { useRecoilScopedState } from '@/recoil-scope/hooks/useRecoilScopedState';
import { SingleEntitySelect } from '@/relation-picker/components/SingleEntitySelect';
import { useFilteredSearchEntityQuery } from '@/relation-picker/hooks/useFilteredSearchEntityQuery';
import { relationPickerSearchFilterScopedState } from '@/relation-picker/states/relationPickerSearchFilterScopedState';
import { getLogoUrlFromDomainName } from '@/utils/utils';
import { CommentableType, useSearchCompanyQuery } from '~/generated/graphql';
import { useFilteredSearchCompanyQuery } from '../services';
export function NewCompanyBoardCard() {
const [searchFilter] = useRecoilScopedState(
relationPickerSearchFilterScopedState,
);
const companies = useFilteredSearchEntityQuery({
queryHook: useSearchCompanyQuery,
selectedIds: [],
searchFilter: searchFilter,
mappingFunction: (company) => ({
entityType: CommentableType.Company,
id: company.id,
name: company.name,
domainName: company.domainName,
avatarType: 'squared',
avatarUrl: getLogoUrlFromDomainName(company.domainName),
}),
orderByField: 'name',
searchOnFields: ['name'],
});
const companies = useFilteredSearchCompanyQuery({ searchFilter });
const handleEntitySelect = useCallback(async (companyId: string) => {
return;

View File

@ -11,20 +11,18 @@ import { pipelineStageIdScopedState } from '@/pipeline-progress/states/pipelineS
import { RecoilScope } from '@/recoil-scope/components/RecoilScope';
import { useRecoilScopedState } from '@/recoil-scope/hooks/useRecoilScopedState';
import { SingleEntitySelect } from '@/relation-picker/components/SingleEntitySelect';
import { useFilteredSearchEntityQuery } from '@/relation-picker/hooks/useFilteredSearchEntityQuery';
import { relationPickerSearchFilterScopedState } from '@/relation-picker/states/relationPickerSearchFilterScopedState';
import { RelationPickerHotkeyScope } from '@/relation-picker/types/RelationPickerHotkeyScope';
import { BoardPipelineStageColumn } from '@/ui/board/components/Board';
import { NewButton } from '@/ui/board/components/NewButton';
import { getLogoUrlFromDomainName } from '@/utils/utils';
import {
CommentableType,
PipelineProgressableType,
useCreateOnePipelineProgressMutation,
useSearchCompanyQuery,
} from '~/generated/graphql';
import { currentPipelineState } from '~/pages/opportunities/currentPipelineState';
import { useFilteredSearchCompanyQuery } from '../services';
export function NewCompanyProgressButton() {
const [isCreatingCard, setIsCreatingCard] = useState(false);
const [board, setBoard] = useRecoilState(boardState);
@ -93,21 +91,7 @@ export function NewCompanyProgressButton() {
const [searchFilter] = useRecoilScopedState(
relationPickerSearchFilterScopedState,
);
const companies = useFilteredSearchEntityQuery({
queryHook: useSearchCompanyQuery,
selectedIds: [],
searchFilter: searchFilter,
mappingFunction: (company) => ({
entityType: CommentableType.Company,
id: company.id,
name: company.name,
domainName: company.domainName,
avatarType: 'squared',
avatarUrl: getLogoUrlFromDomainName(company.domainName),
}),
orderByField: 'name',
searchOnFields: ['name'],
});
const companies = useFilteredSearchCompanyQuery({ searchFilter });
return (
<>

View File

@ -1,11 +1,16 @@
import { gql } from '@apollo/client';
import { CommentableEntityForSelect } from '@/comments/types/CommentableEntityForSelect';
import { SelectedSortType } from '@/lib/filters-and-sorts/interfaces/sorts/interface';
import { useFilteredSearchEntityQuery } from '@/search/hooks/useFilteredSearchEntityQuery';
import { getLogoUrlFromDomainName } from '@/utils/utils';
import {
CommentableType,
CompanyOrderByWithRelationInput as Companies_Order_By,
CompanyWhereInput as Companies_Bool_Exp,
SortOrder as Order_By,
useGetCompaniesQuery,
useSearchCompanyQuery,
} from '~/generated/graphql';
export type CompaniesSelectedSortType = SelectedSortType<Companies_Order_By>;
@ -41,6 +46,33 @@ export function useCompaniesQuery(
return useGetCompaniesQuery({ variables: { orderBy, where } });
}
export function useFilteredSearchCompanyQuery({
searchFilter,
selectedIds = [],
limit,
}: {
searchFilter: string;
selectedIds?: string[];
limit?: number;
}) {
return useFilteredSearchEntityQuery({
queryHook: useSearchCompanyQuery,
searchOnFields: ['name'],
orderByField: 'name',
selectedIds: selectedIds,
mappingFunction: (company) =>
({
id: company.id,
entityType: CommentableType.Company,
name: company.name,
avatarUrl: getLogoUrlFromDomainName(company.domainName),
avatarType: 'squared',
} as CommentableEntityForSelect),
searchFilter,
limit,
});
}
export const defaultOrderBy: Companies_Order_By[] = [
{
createdAt: Order_By.Desc,

View File

@ -1,23 +1,16 @@
import { Key } from 'ts-key-enum';
import { useFilteredSearchCompanyQuery } from '@/companies/services';
import { useScopedHotkeys } from '@/lib/hotkeys/hooks/useScopedHotkeys';
import { useSetHotkeyScope } from '@/lib/hotkeys/hooks/useSetHotkeyScope';
import { useRecoilScopedState } from '@/recoil-scope/hooks/useRecoilScopedState';
import { SingleEntitySelect } from '@/relation-picker/components/SingleEntitySelect';
import { useFilteredSearchEntityQuery } from '@/relation-picker/hooks/useFilteredSearchEntityQuery';
import { relationPickerSearchFilterScopedState } from '@/relation-picker/states/relationPickerSearchFilterScopedState';
import { RelationPickerHotkeyScope } from '@/relation-picker/types/RelationPickerHotkeyScope';
import { useEditableCell } from '@/ui/components/editable-cell/hooks/useEditableCell';
import { isCreateModeScopedState } from '@/ui/components/editable-cell/states/isCreateModeScopedState';
import { TableHotkeyScope } from '@/ui/tables/types/TableHotkeyScope';
import { getLogoUrlFromDomainName } from '@/utils/utils';
import {
CommentableType,
Company,
Person,
useSearchCompanyQuery,
useUpdatePeopleMutation,
} from '~/generated/graphql';
import { Company, Person, useUpdatePeopleMutation } from '~/generated/graphql';
export type OwnProps = {
people: Pick<Person, 'id'> & { company?: Pick<Company, 'id'> | null };
@ -35,19 +28,9 @@ export function PeopleCompanyPicker({ people }: OwnProps) {
const addToScopeStack = useSetHotkeyScope();
const companies = useFilteredSearchEntityQuery({
queryHook: useSearchCompanyQuery,
selectedIds: [people.company?.id ?? ''],
searchFilter: searchFilter,
mappingFunction: (company) => ({
entityType: CommentableType.Company,
id: company.id,
name: company.name,
avatarType: 'squared',
avatarUrl: getLogoUrlFromDomainName(company.domainName),
}),
orderByField: 'name',
searchOnFields: ['name'],
const companies = useFilteredSearchCompanyQuery({
searchFilter,
selectedIds: people.company?.id ? [people.company.id] : [],
});
async function handleEntitySelected(entity: any) {

View File

@ -1,11 +1,15 @@
import { gql } from '@apollo/client';
import { CommentableEntityForSelect } from '@/comments/types/CommentableEntityForSelect';
import { SelectedSortType } from '@/lib/filters-and-sorts/interfaces/sorts/interface';
import { useFilteredSearchEntityQuery } from '@/search/hooks/useFilteredSearchEntityQuery';
import {
CommentableType,
PersonOrderByWithRelationInput as People_Order_By,
PersonWhereInput as People_Bool_Exp,
SortOrder,
useGetPeopleQuery,
useSearchPeopleQuery,
} from '~/generated/graphql';
export type PeopleSelectedSortType = SelectedSortType<People_Order_By>;
@ -43,6 +47,32 @@ export function usePeopleQuery(
});
}
export function useFilteredSearchPeopleQuery({
searchFilter,
selectedIds = [],
limit,
}: {
searchFilter: string;
selectedIds?: string[];
limit?: number;
}) {
return useFilteredSearchEntityQuery({
queryHook: useSearchPeopleQuery,
searchOnFields: ['firstName', 'lastName'],
orderByField: 'lastName',
selectedIds: selectedIds,
mappingFunction: (entity) =>
({
id: entity.id,
entityType: CommentableType.Person,
name: `${entity.firstName} ${entity.lastName}`,
avatarType: 'rounded',
} as CommentableEntityForSelect),
searchFilter,
limit,
});
}
export const defaultOrderBy: People_Order_By[] = [
{
createdAt: SortOrder.Desc,

View File

@ -3,8 +3,8 @@ import { filterDropdownSearchInputScopedState } from '@/lib/filters-and-sorts/st
import { filterDropdownSelectedEntityIdScopedState } from '@/lib/filters-and-sorts/states/filterDropdownSelectedEntityIdScopedState';
import { useRecoilScopedState } from '@/recoil-scope/hooks/useRecoilScopedState';
import { useRecoilScopedValue } from '@/recoil-scope/hooks/useRecoilScopedValue';
import { useFilteredSearchEntityQuery } from '@/relation-picker/hooks/useFilteredSearchEntityQuery';
import { Entity } from '@/relation-picker/types/EntityTypeForSelect';
import { useFilteredSearchEntityQuery } from '@/search/hooks/useFilteredSearchEntityQuery';
import { TableContext } from '@/ui/tables/states/TableContext';
import { useSearchUserQuery } from '~/generated/graphql';