fix: Command bar is broken (#1617)

* Update CommandMenu.tsx

* remove broken states

* convert to array

* convert filter conditions

* empty condition

* finally

* update the logic

* add test

* lint

* move file
This commit is contained in:
Rishi Raj Jain
2023-09-21 23:48:44 +05:30
committed by GitHub
parent 9ab412116d
commit b5b46f923a
3 changed files with 538 additions and 93 deletions

View File

@ -17,7 +17,7 @@ import { getLogoUrlFromDomainName } from '~/utils';
import { useCommandMenu } from '../hooks/useCommandMenu';
import { commandMenuCommandsState } from '../states/commandMenuCommandsState';
import { isCommandMenuOpenedState } from '../states/isCommandMenuOpenedState';
import { CommandType } from '../types/Command';
import { Command, CommandType } from '../types/Command';
import { CommandMenuItem } from './CommandMenuItem';
import {
@ -65,6 +65,7 @@ export const CommandMenu = () => {
limit: 3,
},
});
const companies = companyData?.searchResults ?? [];
const { data: activityData } = useSearchActivityQuery({
@ -78,18 +79,38 @@ export const CommandMenu = () => {
limit: 3,
},
});
const activities = activityData?.searchResults ?? [];
const matchingNavigateCommand = commandMenuCommands.find(
const checkInShortcuts = (cmd: Command, search: string) => {
if (cmd.shortcuts && cmd.shortcuts.length > 0) {
return cmd.shortcuts
.join('')
.toLowerCase()
.includes(search.toLowerCase());
}
return false;
};
const checkInLabels = (cmd: Command, search: string) => {
if (cmd.label) {
return cmd.label.toLowerCase().includes(search.toLowerCase());
}
return false;
};
const matchingNavigateCommand = commandMenuCommands.filter(
(cmd) =>
cmd.shortcuts?.join('') === search?.toUpperCase() &&
cmd.type === CommandType.Navigate,
(search.length > 0
? checkInShortcuts(cmd, search) || checkInLabels(cmd, search)
: true) && cmd.type === CommandType.Navigate,
);
const matchingCreateCommand = commandMenuCommands.find(
const matchingCreateCommand = commandMenuCommands.filter(
(cmd) =>
cmd.shortcuts?.join('') === search?.toUpperCase() &&
cmd.type === CommandType.Create,
(search.length > 0
? checkInShortcuts(cmd, search) || checkInLabels(cmd, search)
: true) && cmd.type === CommandType.Create,
);
return (
@ -100,122 +121,96 @@ export const CommandMenu = () => {
closeCommandMenu();
}
}}
label="Global Command Menu"
shouldFilter={false}
label="Global Command Menu"
>
<StyledInput
placeholder="Search"
value={search}
placeholder="Search"
onValueChange={setSearch}
/>
<StyledList>
<StyledEmpty>No results found.</StyledEmpty>
{!matchingCreateCommand && (
{matchingCreateCommand.length < 1 &&
matchingNavigateCommand.length < 1 &&
people.length < 1 &&
companies.length < 1 &&
activities.length < 1 && <StyledEmpty>No results found.</StyledEmpty>}
{matchingCreateCommand.length > 0 && (
<StyledGroup heading="Create">
{commandMenuCommands
.filter((cmd) => cmd.type === CommandType.Create)
.map((cmd) => (
<CommandMenuItem
key={cmd.label}
to={cmd.to}
label={cmd.label}
Icon={cmd.Icon}
shortcuts={cmd.shortcuts || []}
onClick={cmd.onCommandClick}
/>
))}
{matchingCreateCommand.map((cmd) => (
<CommandMenuItem
to={cmd.to}
key={cmd.label}
Icon={cmd.Icon}
label={cmd.label}
onClick={cmd.onCommandClick}
shortcuts={cmd.shortcuts || []}
/>
))}
</StyledGroup>
)}
{matchingCreateCommand && (
<StyledGroup heading="Create">
<CommandMenuItem
key={matchingCreateCommand.label}
to={matchingCreateCommand.to}
label={matchingCreateCommand.label}
Icon={matchingCreateCommand.Icon}
shortcuts={matchingCreateCommand.shortcuts || []}
onClick={matchingCreateCommand.onCommandClick}
/>
</StyledGroup>
)}
{matchingNavigateCommand && (
{matchingNavigateCommand.length > 0 && (
<StyledGroup heading="Navigate">
<CommandMenuItem
to={matchingNavigateCommand.to}
label={matchingNavigateCommand.label}
shortcuts={matchingNavigateCommand.shortcuts}
key={matchingNavigateCommand.label}
/>
{matchingNavigateCommand.map((cmd) => (
<CommandMenuItem
to={cmd.to}
key={cmd.label}
label={cmd.label}
onClick={cmd.onCommandClick}
shortcuts={cmd.shortcuts || []}
/>
))}
</StyledGroup>
)}
{!!people.length && (
{people.length > 0 && (
<StyledGroup heading="People">
{people.map((person) => (
<CommandMenuItem
key={person.id}
to={`person/${person.id}`}
label={person.displayName}
key={person.id}
Icon={() => (
<Avatar
avatarUrl={null}
placeholder={person.displayName}
colorId={person.id}
type="rounded"
avatarUrl={null}
colorId={person.id}
placeholder={person.displayName}
/>
)}
/>
))}
</StyledGroup>
)}
{!!companies.length && (
{companies.length > 0 && (
<StyledGroup heading="Companies">
{companies.map((company) => (
<CommandMenuItem
to={`companies/${company.id}`}
label={company.name}
key={company.id}
label={company.name}
to={`companies/${company.id}`}
Icon={() => (
<Avatar
avatarUrl={getLogoUrlFromDomainName(company.domainName)}
colorId={company.id}
placeholder={company.name}
avatarUrl={getLogoUrlFromDomainName(company.domainName)}
/>
)}
/>
))}
</StyledGroup>
)}
{!!activities.length && (
{activities.length > 0 && (
<StyledGroup heading="Notes">
{activities.map((activity) => (
<CommandMenuItem
onClick={() => openActivityRightDrawer(activity.id)}
label={activity.title ?? ''}
key={activity.id}
Icon={IconNotes}
key={activity.id}
label={activity.title ?? ''}
onClick={() => openActivityRightDrawer(activity.id)}
/>
))}
</StyledGroup>
)}
{!matchingNavigateCommand && (
<StyledGroup heading="Navigate">
{commandMenuCommands
.filter(
(cmd) =>
(cmd.shortcuts?.join('').includes(search?.toUpperCase()) ||
cmd.label?.toUpperCase().includes(search?.toUpperCase())) &&
cmd.type === CommandType.Navigate,
)
.map((cmd) => (
<CommandMenuItem
key={cmd.shortcuts?.join('') ?? ''}
to={cmd.to}
label={cmd.label}
shortcuts={cmd.shortcuts}
/>
))}
</StyledGroup>
)}
</StyledList>
</StyledDialog>
);

View File

@ -1,15 +1,160 @@
import { MemoryRouter } from 'react-router-dom';
import { expect } from '@storybook/jest';
import type { Meta, StoryObj } from '@storybook/react';
import { fireEvent, userEvent, within } from '@storybook/testing-library';
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
import { sleep } from '~/testing/sleep';
import { CommandMenu } from '../CommandMenu';
import { WrapperCommandMenu } from './WrapperCommandMenu';
const meta: Meta<typeof CommandMenu> = {
title: 'Modules/CommandMenu/CommandMenu',
component: CommandMenu,
enum CommandType {
Navigate = 'Navigate',
Create = 'Create',
}
const meta: Meta<typeof WrapperCommandMenu> = {
title: 'Modules/CommandMenu/WrapperCommandMenu',
component: () => (
<WrapperCommandMenu
companies={[
{
__typename: 'Company',
accountOwner: null,
address: '',
createdAt: '2023-09-19T08:35:37.174Z',
domainName: 'facebook.com',
employees: null,
linkedinUrl: null,
xUrl: null,
annualRecurringRevenue: null,
idealCustomerProfile: false,
id: 'twenty-118995f3-5d81-46d6-bf83-f7fd33ea6102',
name: 'Facebook',
_activityCount: 0,
},
{
__typename: 'Company',
accountOwner: null,
address: '',
createdAt: '2023-09-19T08:35:37.188Z',
domainName: 'airbnb.com',
employees: null,
linkedinUrl: null,
xUrl: null,
annualRecurringRevenue: null,
idealCustomerProfile: false,
id: 'twenty-89bb825c-171e-4bcc-9cf7-43448d6fb278',
name: 'Airbnb',
_activityCount: 0,
},
{
__typename: 'Company',
accountOwner: null,
address: '',
createdAt: '2023-09-19T08:35:37.206Z',
domainName: 'claap.io',
employees: null,
linkedinUrl: null,
xUrl: null,
annualRecurringRevenue: null,
idealCustomerProfile: false,
id: 'twenty-9d162de6-cfbf-4156-a790-e39854dcd4eb',
name: 'Claap',
_activityCount: 0,
},
]}
activities={[
{
__typename: 'Activity',
id: 'twenty-fe256b39-3ec3-4fe3-8997-b76aa0bfb400',
title: 'Performance update',
body: '[{"id":"555df0c3-ab88-4c62-abae-c9b557c37c5b","type":"paragraph","props":{"textColor":"default","backgroundColor":"default","textAlignment":"left"},"content":[{"type":"text","text":"In the North American region, we have observed a strong growth rate of 18% in sales. Europe followed suit with a significant 14% increase, while Asia-Pacific sustained its performance with a steady 10% rise. Special kudos to the North American team for the excellent work done in penetrating new markets and establishing stronger footholds in the existing ones.","styles":{}}],"children":[]},{"id":"13530934-b3ce-4332-9238-3760aa4acb3e","type":"paragraph","props":{"textColor":"default","backgroundColor":"default","textAlignment":"left"},"content":[],"children":[]}]',
},
{
__typename: 'Activity',
id: 'twenty-fe256b39-3ec3-4fe3-8997-b76aa0bfc408',
title: 'Buyout Proposal',
body: '[{"id":"333df0c3-ab88-4c62-abae-c9b557c37c5b","type":"paragraph","props":{"textColor":"default","backgroundColor":"default","textAlignment":"left"},"content":[{"type":"text","text":"We are considering the potential acquisition of [Company], a leading company in [Industry/Specific Technology]. This company has demonstrated remarkable success and pioneering advancements in their field, paralleling our own commitment to progress. By integrating their expertise with our own, we believe that we can amplify our growth, broaden our offerings, and fortify our position at the forefront of technology. This prospective partnership could help to ensure our continued leadership in the industry and allow us to deliver even more innovative solutions for our customers.","styles":{}}],"children":[]},{"id":"13530934-b3ce-4332-9238-3760aa4acb3e","type":"paragraph","props":{"textColor":"default","backgroundColor":"default","textAlignment":"left"},"content":[],"children":[]}]',
},
]}
values={[
{
to: '/people',
label: 'Go to People',
type: CommandType.Navigate,
shortcuts: ['G', 'P'],
},
{
to: '/companies',
label: 'Go to Companies',
type: CommandType.Navigate,
shortcuts: ['G', 'C'],
},
{
to: '/opportunities',
label: 'Go to Opportunities',
type: CommandType.Navigate,
shortcuts: ['G', 'O'],
},
{
to: '/settings/profile',
label: 'Go to Settings',
type: CommandType.Navigate,
shortcuts: ['G', 'S'],
},
{
to: '/tasks',
label: 'Go to Tasks',
type: CommandType.Navigate,
shortcuts: ['G', 'T'],
},
{
to: '',
label: 'Create Task',
type: CommandType.Create,
},
]}
people={[
{
__typename: 'Person',
id: 'twenty-0aa00beb-ac73-4797-824e-87a1f5aea9e0',
phone: '+33780123456',
email: 'sylvie.palmer@linkedin.com',
city: 'Los Angeles',
firstName: 'Sylvie',
lastName: 'Palmer',
displayName: 'Sylvie Palmer',
avatarUrl: null,
createdAt: '2023-09-19T08:35:37.240Z',
},
{
__typename: 'Person',
id: 'twenty-1d151852-490f-4466-8391-733cfd66a0c8',
phone: '+33782345678',
email: 'isabella.scott@microsoft.com',
city: 'New York',
firstName: 'Isabella',
lastName: 'Scott',
displayName: 'Isabella Scott',
avatarUrl: null,
createdAt: '2023-09-19T08:35:37.257Z',
},
{
__typename: 'Person',
id: 'twenty-240da2ec-2d40-4e49-8df4-9c6a049190df',
phone: '+33788901234',
email: 'bertrand.voulzy@google.com',
city: 'Seattle',
firstName: 'Bertrand',
lastName: 'Voulzy',
displayName: 'Bertrand Voulzy',
avatarUrl: null,
createdAt: '2023-09-19T08:35:37.291Z',
},
]}
/>
),
decorators: [
(Story) => (
<MemoryRouter>
@ -21,36 +166,94 @@ const meta: Meta<typeof CommandMenu> = {
};
export default meta;
type Story = StoryObj<typeof CommandMenu>;
type Story = StoryObj<typeof WrapperCommandMenu>;
export const Default: Story = {};
export const CmdK: Story = {
export const DefaultWithoutSearch: Story = {
play: async ({ canvasElement }) => {
fireEvent.keyDown(canvasElement, {
key: 'k',
code: 'KeyK',
metaKey: true,
});
await sleep(50);
const canvas = within(document.body);
const searchInput = await canvas.findByPlaceholderText('Search');
await sleep(10);
await userEvent.type(searchInput, '');
expect(await canvas.findByText('Create Task')).toBeInTheDocument();
expect(await canvas.findByText('Go to People')).toBeInTheDocument();
expect(await canvas.findByText('Go to Companies')).toBeInTheDocument();
expect(await canvas.findByText('Go to Opportunities')).toBeInTheDocument();
expect(await canvas.findByText('Go to Settings')).toBeInTheDocument();
expect(await canvas.findByText('Go to Tasks')).toBeInTheDocument();
},
};
await userEvent.type(searchInput, '{arrowdown}');
await userEvent.type(searchInput, '{arrowup}');
await userEvent.type(searchInput, '{arrowdown}');
await userEvent.type(searchInput, '{arrowdown}');
await userEvent.type(searchInput, '{enter}');
await sleep(50);
export const MatchingPersonCompanyActivityCreateNavigate: Story = {
play: async ({ canvasElement }) => {
fireEvent.keyDown(canvasElement, {
key: 'k',
code: 'KeyK',
metaKey: true,
});
await sleep(50);
const canvas = within(document.body);
const searchInput = await canvas.findByPlaceholderText('Search');
await sleep(10);
await userEvent.type(searchInput, 'a');
expect(await canvas.findByText('Isabella Scott')).toBeInTheDocument();
expect(await canvas.findByText('Airbnb')).toBeInTheDocument();
expect(await canvas.findByText('Buyout Proposal')).toBeInTheDocument();
expect(await canvas.findByText('Create Task')).toBeInTheDocument();
expect(await canvas.findByText('Go to Tasks')).toBeInTheDocument();
},
};
export const OnlyMatchingCreateAndNavigate: Story = {
play: async ({ canvasElement }) => {
fireEvent.keyDown(canvasElement, {
key: 'k',
code: 'KeyK',
metaKey: true,
});
await sleep(50);
const canvas = within(document.body);
const searchInput = await canvas.findByPlaceholderText('Search');
await sleep(10);
await userEvent.type(searchInput, 'tas');
expect(await canvas.findByText('Create Task')).toBeInTheDocument();
expect(await canvas.findByText('Go to Tasks')).toBeInTheDocument();
},
};
export const AtleastMatchingOnePerson: Story = {
play: async ({ canvasElement }) => {
fireEvent.keyDown(canvasElement, {
key: 'k',
code: 'KeyK',
metaKey: true,
});
await sleep(50);
const canvas = within(document.body);
const searchInput = await canvas.findByPlaceholderText('Search');
await sleep(10);
await userEvent.type(searchInput, 'sy');
expect(await canvas.findByText('Sylvie Palmer')).toBeInTheDocument();
},
};
export const NotMatchingAnything: Story = {
play: async ({ canvasElement }) => {
fireEvent.keyDown(canvasElement, {
key: 'k',
code: 'KeyK',
metaKey: true,
});
await sleep(50);
const canvas = within(document.body);
const searchInput = await canvas.findByPlaceholderText('Search');
await sleep(10);
await userEvent.type(searchInput, 'asdasdasd');
expect(await canvas.findByText('No results found.')).toBeInTheDocument();
},
};

View File

@ -0,0 +1,247 @@
import { useState } from 'react';
import { useRecoilValue } from 'recoil';
import { useOpenActivityRightDrawer } from '@/activities/hooks/useOpenActivityRightDrawer';
import { IconNotes } from '@/ui/icon';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { AppHotkeyScope } from '@/ui/utilities/hotkey/types/AppHotkeyScope';
import { Avatar } from '@/users/components/Avatar';
import { getLogoUrlFromDomainName } from '~/utils';
import { useCommandMenu } from '../../hooks/useCommandMenu';
import { isCommandMenuOpenedState } from '../../states/isCommandMenuOpenedState';
import { Command, CommandType } from '../../types/Command';
import { CommandMenuItem } from '../CommandMenuItem';
import {
StyledDialog,
StyledEmpty,
StyledGroup,
StyledInput,
StyledList,
} from '../CommandMenuStyles';
export const WrapperCommandMenu = ({
values,
people,
companies,
activities,
}: {
values: Command[];
people: Array<{
__typename?: 'Person';
id: string;
phone?: string | null;
email?: string | null;
city?: string | null;
firstName?: string | null;
lastName?: string | null;
displayName: string;
avatarUrl?: string | null;
createdAt: string;
}>;
companies: Array<{
__typename?: 'Company';
address: string;
createdAt: string;
domainName: string;
employees?: number | null;
linkedinUrl?: string | null;
xUrl?: string | null;
annualRecurringRevenue?: number | null;
idealCustomerProfile: boolean;
id: string;
name: string;
_activityCount: number;
accountOwner?: {
__typename?: 'User';
id: string;
email: string;
displayName: string;
avatarUrl?: string | null;
} | null;
}>;
activities: Array<{
__typename?: 'Activity';
id: string;
title?: string | null;
body?: string | null;
}>;
}) => {
const { openCommandMenu, closeCommandMenu } = useCommandMenu();
const openActivityRightDrawer = useOpenActivityRightDrawer();
const isCommandMenuOpened = useRecoilValue(isCommandMenuOpenedState);
const [search, setSearch] = useState('');
const commandMenuCommands = values;
const peopleData = people.filter((i) =>
search.length > 0
? (i.firstName
? i.firstName?.toLowerCase().includes(search.toLowerCase())
: false) ||
(i.lastName
? i.lastName?.toLowerCase().includes(search.toLowerCase())
: false)
: false,
);
const companyData = companies.filter((i) =>
search.length > 0
? i.name
? i.name?.toLowerCase().includes(search.toLowerCase())
: false
: false,
);
const activityData = activities.filter((i) =>
search.length > 0
? (i.title
? i.title?.toLowerCase().includes(search.toLowerCase())
: false) ||
(i.body ? i.body?.toLowerCase().includes(search.toLowerCase()) : false)
: false,
);
useScopedHotkeys(
'ctrl+k,meta+k',
() => {
openCommandMenu();
},
AppHotkeyScope.CommandMenu,
[openCommandMenu],
);
const checkInShortcuts = (cmd: Command, search: string) => {
if (cmd.shortcuts && cmd.shortcuts.length > 0) {
return cmd.shortcuts
.join('')
.toLowerCase()
.includes(search.toLowerCase());
}
return false;
};
const checkInLabels = (cmd: Command, search: string) => {
if (cmd.label) {
return cmd.label.toLowerCase().includes(search.toLowerCase());
}
return false;
};
const matchingNavigateCommand = commandMenuCommands.filter(
(cmd) =>
(search.length > 0
? checkInShortcuts(cmd, search) || checkInLabels(cmd, search)
: true) && cmd.type === CommandType.Navigate,
);
const matchingCreateCommand = commandMenuCommands.filter(
(cmd) =>
(search.length > 0
? checkInShortcuts(cmd, search) || checkInLabels(cmd, search)
: true) && cmd.type === CommandType.Create,
);
return (
<StyledDialog
open={isCommandMenuOpened}
onOpenChange={(opened) => {
if (!opened) {
closeCommandMenu();
}
}}
shouldFilter={false}
label="Global Command Menu"
>
<StyledInput
value={search}
placeholder="Search"
onValueChange={setSearch}
/>
<StyledList>
{matchingCreateCommand.length < 1 &&
matchingNavigateCommand.length < 1 &&
peopleData.length < 1 &&
companyData.length < 1 &&
activityData.length < 1 && (
<StyledEmpty>No results found.</StyledEmpty>
)}
{matchingCreateCommand.length > 0 && (
<StyledGroup heading="Create">
{matchingCreateCommand.map((cmd) => (
<CommandMenuItem
to={cmd.to}
key={cmd.label}
Icon={cmd.Icon}
label={cmd.label}
onClick={cmd.onCommandClick}
shortcuts={cmd.shortcuts || []}
/>
))}
</StyledGroup>
)}
{matchingNavigateCommand.length > 0 && (
<StyledGroup heading="Navigate">
{matchingNavigateCommand.map((cmd) => (
<CommandMenuItem
to={cmd.to}
key={cmd.label}
label={cmd.label}
onClick={cmd.onCommandClick}
shortcuts={cmd.shortcuts || []}
/>
))}
</StyledGroup>
)}
{peopleData.length > 0 && (
<StyledGroup heading="People">
{peopleData.map((person) => (
<CommandMenuItem
key={person.id}
to={`person/${person.id}`}
label={person.displayName}
Icon={() => (
<Avatar
type="rounded"
avatarUrl={null}
colorId={person.id}
placeholder={person.displayName}
/>
)}
/>
))}
</StyledGroup>
)}
{companyData.length > 0 && (
<StyledGroup heading="Companies">
{companyData.map((company) => (
<CommandMenuItem
key={company.id}
label={company.name}
to={`companies/${company.id}`}
Icon={() => (
<Avatar
colorId={company.id}
placeholder={company.name}
avatarUrl={getLogoUrlFromDomainName(company.domainName)}
/>
)}
/>
))}
</StyledGroup>
)}
{activityData.length > 0 && (
<StyledGroup heading="Notes">
{activityData.map((activity) => (
<CommandMenuItem
Icon={IconNotes}
key={activity.id}
label={activity.title ?? ''}
onClick={() => openActivityRightDrawer(activity.id)}
/>
))}
</StyledGroup>
)}
</StyledList>
</StyledDialog>
);
};