Files
twenty/packages/twenty-front/src/modules/command-menu/components/CommandMenu.tsx
Raphaël Bosi 1091bc657d Command menu refactoring (#9257)
Refactored the `CommandMenu` component to make it more readable and
easier to refactor.
The file was way too big so I introduced a few hooks and eliminated code
duplication.

Introduced:
- `useMatchCommands` hook to match commands with the search
- `useCommandMenuCommands` which returns all command menu commands
- `useMatchingCommandMenuCommands` to return the commands matched with
the search
- `CommandMenuContainer` to simplify the `DefaultLayout`

- Unmounted the `CommandMenu` when it wasn't opened to improve
performances

I also introduced a new behavior: Automatically select the first item
when opening the command menu:

https://github.com/user-attachments/assets/4b683d49-570e-47c9-8939-99f42ed8691c
2024-12-30 15:22:49 +01:00

257 lines
8.3 KiB
TypeScript

import { CommandGroup } from '@/command-menu/components/CommandGroup';
import { CommandMenuDefaultSelectionEffect } from '@/command-menu/components/CommandMenuDefaultSelectionEffect';
import { CommandMenuItem } from '@/command-menu/components/CommandMenuItem';
import { CommandMenuTopBar } from '@/command-menu/components/CommandMenuTopBar';
import { COMMAND_MENU_SEARCH_BAR_HEIGHT } from '@/command-menu/constants/CommandMenuSearchBarHeight';
import { COMMAND_MENU_SEARCH_BAR_PADDING } from '@/command-menu/constants/CommandMenuSearchBarPadding';
import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
import { useCommandMenuHotKeys } from '@/command-menu/hooks/useCommandMenuHotKeys';
import { useMatchingCommandMenuCommands } from '@/command-menu/hooks/useMatchingCommandMenuCommands';
import { commandMenuSearchState } from '@/command-menu/states/commandMenuSearchState';
import { SelectableItem } from '@/ui/layout/selectable-list/components/SelectableItem';
import { SelectableList } from '@/ui/layout/selectable-list/components/SelectableList';
import { AppHotkeyScope } from '@/ui/utilities/hotkey/types/AppHotkeyScope';
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper';
import styled from '@emotion/styled';
import { useRef } from 'react';
import { useRecoilState } from 'recoil';
import { isDefined } from 'twenty-ui';
const MOBILE_NAVIGATION_BAR_HEIGHT = 64;
type CommandGroupConfig = {
heading: string;
items?: any[];
};
const StyledCommandMenu = styled.div`
background: ${({ theme }) => theme.background.secondary};
border-left: 1px solid ${({ theme }) => theme.border.color.medium};
box-shadow: ${({ theme }) => theme.boxShadow.strong};
font-family: ${({ theme }) => theme.font.family};
height: 100%;
overflow: hidden;
padding: 0;
position: fixed;
right: 0%;
top: 0%;
width: ${() => (useIsMobile() ? '100%' : '500px')};
z-index: 1000;
`;
const StyledList = styled.div`
background: ${({ theme }) => theme.background.secondary};
overscroll-behavior: contain;
transition: 100ms ease;
transition-property: height;
`;
const StyledInnerList = styled.div<{ isMobile: boolean }>`
max-height: ${({ isMobile }) =>
isMobile
? `calc(100dvh - ${COMMAND_MENU_SEARCH_BAR_HEIGHT}px - ${
COMMAND_MENU_SEARCH_BAR_PADDING * 2
}px - ${MOBILE_NAVIGATION_BAR_HEIGHT}px)`
: `calc(100dvh - ${COMMAND_MENU_SEARCH_BAR_HEIGHT}px - ${
COMMAND_MENU_SEARCH_BAR_PADDING * 2
}px)`};
padding-left: ${({ theme }) => theme.spacing(2)};
padding-right: ${({ theme }) => theme.spacing(2)};
padding-top: ${({ theme }) => theme.spacing(1)};
width: calc(100% - ${({ theme }) => theme.spacing(4)});
`;
const StyledEmpty = styled.div`
align-items: center;
color: ${({ theme }) => theme.font.color.light};
display: flex;
font-size: ${({ theme }) => theme.font.size.md};
height: 64px;
justify-content: center;
white-space: pre-wrap;
`;
export const CommandMenu = () => {
const { onItemClick, closeCommandMenu } = useCommandMenu();
const commandMenuRef = useRef<HTMLDivElement>(null);
const [commandMenuSearch, setCommandMenuSearch] = useRecoilState(
commandMenuSearchState,
);
const isMobile = useIsMobile();
useCommandMenuHotKeys();
useListenClickOutside({
refs: [commandMenuRef],
callback: closeCommandMenu,
listenerId: 'COMMAND_MENU_LISTENER_ID',
hotkeyScope: AppHotkeyScope.CommandMenuOpen,
});
const {
isNoResults,
isLoading,
copilotCommands,
matchingStandardActionRecordSelectionCommands,
matchingWorkflowRunRecordSelectionCommands,
matchingStandardActionGlobalCommands,
matchingWorkflowRunGlobalCommands,
matchingNavigateCommand,
peopleCommands,
companyCommands,
opportunityCommands,
noteCommands,
tasksCommands,
customObjectCommands,
} = useMatchingCommandMenuCommands({
commandMenuSearch,
});
const selectableItems = copilotCommands
.concat(matchingStandardActionRecordSelectionCommands)
.concat(matchingWorkflowRunRecordSelectionCommands)
.concat(matchingStandardActionGlobalCommands)
.concat(matchingWorkflowRunGlobalCommands)
.concat(matchingNavigateCommand)
.concat(peopleCommands)
.concat(companyCommands)
.concat(opportunityCommands)
.concat(noteCommands)
.concat(tasksCommands)
.concat(customObjectCommands)
.filter(isDefined);
const selectableItemIds = selectableItems.map((item) => item.id);
const commandGroups: CommandGroupConfig[] = [
{
heading: 'Copilot',
items: copilotCommands,
},
{
heading: 'Record Selection',
items: matchingStandardActionRecordSelectionCommands,
},
{
heading: 'Workflow Record Selection',
items: matchingWorkflowRunRecordSelectionCommands,
},
{
heading: 'View',
items: matchingStandardActionGlobalCommands,
},
{
heading: 'Workflows',
items: matchingWorkflowRunGlobalCommands,
},
{
heading: 'Navigate',
items: matchingNavigateCommand,
},
{
heading: 'People',
items: peopleCommands,
},
{
heading: 'Companies',
items: companyCommands,
},
{
heading: 'Opportunities',
items: opportunityCommands,
},
{
heading: 'Notes',
items: noteCommands,
},
{
heading: 'Tasks',
items: tasksCommands,
},
{
heading: 'Custom Objects',
items: customObjectCommands,
},
];
return (
<>
<CommandMenuDefaultSelectionEffect
selectableItemIds={selectableItemIds}
/>
<StyledCommandMenu ref={commandMenuRef} className="command-menu">
<CommandMenuTopBar
commandMenuSearch={commandMenuSearch}
setCommandMenuSearch={setCommandMenuSearch}
/>
<StyledList>
<ScrollWrapper
contextProviderName="commandMenu"
componentInstanceId={`scroll-wrapper-command-menu`}
>
<StyledInnerList isMobile={isMobile}>
<SelectableList
selectableListId="command-menu-list"
selectableItemIdArray={selectableItemIds}
hotkeyScope={AppHotkeyScope.CommandMenu}
onEnter={(itemId) => {
const command = selectableItems.find(
(item) => item.id === itemId,
);
if (isDefined(command)) {
const {
to,
onCommandClick,
shouldCloseCommandMenuOnClick,
} = command;
onItemClick({
shouldCloseCommandMenuOnClick,
onClick: onCommandClick,
to,
});
}
}}
>
{isNoResults && !isLoading && (
<StyledEmpty>No results found</StyledEmpty>
)}
{commandGroups.map(({ heading, items }) =>
items?.length ? (
<CommandGroup heading={heading} key={heading}>
{items.map((item) => {
return (
<SelectableItem itemId={item.id} key={item.id}>
<CommandMenuItem
key={item.id}
id={item.id}
Icon={item.Icon}
label={item.label}
to={item.to}
onClick={item.onClick}
firstHotKey={item.firstHotKey}
secondHotKey={item.secondHotKey}
shouldCloseCommandMenuOnClick={
item.shouldCloseCommandMenuOnClick
}
/>
</SelectableItem>
);
})}
</CommandGroup>
) : null,
)}
</SelectableList>
</StyledInnerList>
</ScrollWrapper>
</StyledList>
</StyledCommandMenu>
</>
);
};