8172 update the right drawer action menu to open with command o (#8375)

Closes #8172 

- Added a shortcut property to the button component
- Displays the actions inside a dropdown
- The dropdown is toggled either by clicking on the button or with the
`command + O` shortcut



https://github.com/user-attachments/assets/4c4c88fa-85dc-404e-bb42-f2b0d57c8960
This commit is contained in:
Raphaël Bosi
2024-11-08 17:08:09 +01:00
committed by GitHub
parent 0bf2cb69da
commit e8bf81de5b
11 changed files with 177 additions and 84 deletions

View File

@@ -1,56 +0,0 @@
import { ActionMenuEntry } from '@/action-menu/types/ActionMenuEntry';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { MOBILE_VIEWPORT, MenuItemAccent } from 'twenty-ui';
type RecordShowActionMenuBarEntryProps = {
entry: ActionMenuEntry;
};
const StyledButton = styled.div<{ accent: MenuItemAccent }>`
border-radius: ${({ theme }) => theme.border.radius.sm};
color: ${(props) =>
props.accent === 'danger'
? props.theme.color.red
: props.theme.font.color.secondary};
cursor: pointer;
display: flex;
justify-content: center;
padding: ${({ theme }) => theme.spacing(2)};
transition: background 0.1s ease;
user-select: none;
&:hover {
background: ${({ theme, accent }) =>
accent === 'danger'
? theme.background.danger
: theme.background.transparent.light};
}
@media (max-width: ${MOBILE_VIEWPORT}px) {
padding: ${({ theme }) => theme.spacing(1)};
}
`;
const StyledButtonLabel = styled.div`
font-weight: ${({ theme }) => theme.font.weight.medium};
margin-left: ${({ theme }) => theme.spacing(1)};
`;
// For now, this component is the same as RecordIndexActionMenuBarEntry but they
// will probably diverge in the future
export const RecordShowActionMenuBarEntry = ({
entry,
}: RecordShowActionMenuBarEntryProps) => {
const theme = useTheme();
return (
<StyledButton
accent={entry.accent ?? 'default'}
onClick={() => entry.onClick?.()}
>
{entry.Icon && <entry.Icon size={theme.icon.size.md} />}
<StyledButtonLabel>{entry.label}</StyledButtonLabel>
</StyledButton>
);
};

View File

@@ -1,7 +1,7 @@
import { GlobalActionMenuEntriesSetter } from '@/action-menu/actions/global-actions/components/GlobalActionMenuEntriesSetter';
import { RecordActionMenuEntriesSetter } from '@/action-menu/actions/record-actions/components/RecordActionMenuEntriesSetter';
import { ActionMenuConfirmationModals } from '@/action-menu/components/ActionMenuConfirmationModals';
import { RecordShowRightDrawerActionMenuBar } from '@/action-menu/components/RecordShowRightDrawerActionMenuBar';
import { RightDrawerActionMenuDropdown } from '@/action-menu/components/RightDrawerActionMenuDropdown';
import { ActionMenuContext } from '@/action-menu/contexts/ActionMenuContext';
import { contextStoreCurrentObjectMetadataIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataIdComponentState';
@@ -21,7 +21,7 @@ export const RecordShowRightDrawerActionMenu = () => {
onActionExecutedCallback: () => {},
}}
>
<RecordShowRightDrawerActionMenuBar />
<RightDrawerActionMenuDropdown />
<ActionMenuConfirmationModals />
<RecordActionMenuEntriesSetter />
<GlobalActionMenuEntriesSetter />

View File

@@ -1,21 +0,0 @@
import { RecordShowActionMenuBarEntry } from '@/action-menu/components/RecordShowActionMenuBarEntry';
import { actionMenuEntriesComponentSelector } from '@/action-menu/states/actionMenuEntriesComponentSelector';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
export const RecordShowRightDrawerActionMenuBar = () => {
const actionMenuEntries = useRecoilComponentValueV2(
actionMenuEntriesComponentSelector,
);
const standardActionMenuEntries = actionMenuEntries.filter(
(actionMenuEntry) => actionMenuEntry.type === 'standard',
);
return (
<>
{standardActionMenuEntries.map((actionMenuEntry) => (
<RecordShowActionMenuBarEntry entry={actionMenuEntry} />
))}
</>
);
};

View File

@@ -0,0 +1,87 @@
import { actionMenuEntriesComponentSelector } from '@/action-menu/states/actionMenuEntriesComponentSelector';
import { ActionMenuComponentInstanceContext } from '@/action-menu/states/contexts/ActionMenuComponentInstanceContext';
import { RightDrawerActionMenuDropdownHotkeyScope } from '@/action-menu/types/RightDrawerActionMenuDropdownHotkeyScope';
import { getRightDrawerActionMenuDropdownIdFromActionMenuId } from '@/action-menu/utils/getRightDrawerActionMenuDropdownIdFromActionMenuId';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { useDropdownV2 } from '@/ui/layout/dropdown/hooks/useDropdownV2';
import { RightDrawerHotkeyScope } from '@/ui/layout/right-drawer/types/RightDrawerHotkeyScope';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { useTheme } from '@emotion/react';
import { Key } from 'ts-key-enum';
import { Button, MenuItem } from 'twenty-ui';
export const RightDrawerActionMenuDropdown = () => {
const actionMenuEntries = useRecoilComponentValueV2(
actionMenuEntriesComponentSelector,
);
const actionMenuId = useAvailableComponentInstanceIdOrThrow(
ActionMenuComponentInstanceContext,
);
const { closeDropdown, openDropdown } = useDropdownV2();
const theme = useTheme();
useScopedHotkeys(
[Key.Escape, 'ctrl+o,meta+o'],
() => {
closeDropdown(
getRightDrawerActionMenuDropdownIdFromActionMenuId(actionMenuId),
);
},
RightDrawerActionMenuDropdownHotkeyScope.RightDrawerActionMenuDropdown,
[closeDropdown],
);
useScopedHotkeys(
['ctrl+o,meta+o'],
() => {
openDropdown(
getRightDrawerActionMenuDropdownIdFromActionMenuId(actionMenuId),
);
},
RightDrawerHotkeyScope.RightDrawer,
[openDropdown],
);
return (
<Dropdown
dropdownId={getRightDrawerActionMenuDropdownIdFromActionMenuId(
actionMenuId,
)}
dropdownHotkeyScope={{
scope:
RightDrawerActionMenuDropdownHotkeyScope.RightDrawerActionMenuDropdown,
}}
data-select-disable
clickableComponent={<Button title="Actions" shortcut="⌘O" />}
dropdownPlacement="top-end"
dropdownOffset={{
y: parseInt(theme.spacing(2)),
}}
dropdownComponents={
<DropdownMenuItemsContainer>
{actionMenuEntries.map((item, index) => (
<MenuItem
key={index}
LeftIcon={item.Icon}
onClick={() => {
closeDropdown(
getRightDrawerActionMenuDropdownIdFromActionMenuId(
actionMenuId,
),
);
item.onClick?.();
}}
text={item.label}
/>
))}
</DropdownMenuItemsContainer>
}
/>
);
};

View File

@@ -2,7 +2,7 @@ import { expect, jest } from '@storybook/jest';
import { Meta, StoryObj } from '@storybook/react';
import { RecoilRoot } from 'recoil';
import { RecordShowRightDrawerActionMenuBar } from '@/action-menu/components/RecordShowRightDrawerActionMenuBar';
import { RightDrawerActionMenuDropdown } from '@/action-menu/components/RightDrawerActionMenuDropdown';
import { actionMenuEntriesComponentState } from '@/action-menu/states/actionMenuEntriesComponentState';
import { ActionMenuComponentInstanceContext } from '@/action-menu/states/contexts/ActionMenuComponentInstanceContext';
import { ActionMenuEntry } from '@/action-menu/types/ActionMenuEntry';
@@ -21,9 +21,9 @@ const deleteMock = jest.fn();
const addToFavoritesMock = jest.fn();
const exportMock = jest.fn();
const meta: Meta<typeof RecordShowRightDrawerActionMenuBar> = {
title: 'Modules/ActionMenu/RecordShowRightDrawerActionMenuBar',
component: RecordShowRightDrawerActionMenuBar,
const meta: Meta<typeof RightDrawerActionMenuDropdown> = {
title: 'Modules/ActionMenu/RightDrawerActionMenuDropdown',
component: RightDrawerActionMenuDropdown,
decorators: [
(Story) => (
<RecoilRoot
@@ -98,7 +98,7 @@ const meta: Meta<typeof RecordShowRightDrawerActionMenuBar> = {
export default meta;
type Story = StoryObj<typeof RecordShowRightDrawerActionMenuBar>;
type Story = StoryObj<typeof RightDrawerActionMenuDropdown>;
export const Default: Story = {
args: {
@@ -113,12 +113,21 @@ export const WithButtonClicks: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
let actionButton = await canvas.findByText('Actions');
await userEvent.click(actionButton);
const deleteButton = await canvas.findByText('Delete');
await userEvent.click(deleteButton);
actionButton = await canvas.findByText('Actions');
await userEvent.click(actionButton);
const addToFavoritesButton = await canvas.findByText('Add to favorites');
await userEvent.click(addToFavoritesButton);
actionButton = await canvas.findByText('Actions');
await userEvent.click(actionButton);
const exportButton = await canvas.findByText('Export');
await userEvent.click(exportButton);

View File

@@ -0,0 +1,3 @@
export enum RightDrawerActionMenuDropdownHotkeyScope {
RightDrawerActionMenuDropdown = 'right-drawer-action-menu-dropdown',
}

View File

@@ -0,0 +1,9 @@
import { getRightDrawerActionMenuDropdownIdFromActionMenuId } from '../getRightDrawerActionMenuDropdownIdFromActionMenuId';
describe('getRightDrawerActionMenuDropdownIdFromActionMenuId', () => {
it('should return the right drawer action menu dropdown id', () => {
expect(
getRightDrawerActionMenuDropdownIdFromActionMenuId('action-menu-id'),
).toBe('right-drawer-action-menu-dropdown-action-menu-id');
});
});

View File

@@ -0,0 +1,5 @@
export const getRightDrawerActionMenuDropdownIdFromActionMenuId = (
actionMenuId: string,
) => {
return `right-drawer-action-menu-dropdown-${actionMenuId}`;
};

View File

@@ -350,6 +350,10 @@ export const RichTextEditor = ({
editor.focus();
},
RightDrawerHotkeyScope.RightDrawer,
[],
{
preventDefault: false,
},
);
const handleBlockEditorFocus = () => {

View File

@@ -29,6 +29,7 @@ export type ButtonProps = {
to?: string;
target?: string;
dataTestId?: string;
shortcut?: string;
} & React.ComponentProps<'button'>;
const StyledButton = styled('button', {
@@ -358,6 +359,19 @@ const StyledSoonPill = styled(Pill)`
margin-left: auto;
`;
const StyledShortcutLabel = styled.div`
color: ${({ theme }) => theme.font.color.light};
font-weight: ${({ theme }) => theme.font.weight.medium};
`;
const StyledSeparator = styled.div<{ buttonSize: ButtonSize }>`
background: ${({ theme }) => theme.border.color.light};
height: ${({ theme, buttonSize }) =>
theme.spacing(buttonSize === 'small' ? 3 : 4)};
margin: 0 ${({ theme }) => theme.spacing(1)};
width: 1px;
`;
export const Button = ({
className,
Icon,
@@ -376,6 +390,7 @@ export const Button = ({
to,
target,
dataTestId,
shortcut,
}: ButtonProps) => {
const theme = useTheme();
@@ -399,6 +414,12 @@ export const Button = ({
>
{Icon && <Icon size={theme.icon.size.sm} />}
{title}
{shortcut && (
<>
<StyledSeparator buttonSize={size} />
<StyledShortcutLabel>{shortcut}</StyledShortcutLabel>
</>
)}
{soon && <StyledSoonPill label="Soon" />}
</StyledButton>
);

View File

@@ -23,6 +23,7 @@ type Story = StoryObj<typeof Button>;
export const Default: Story = {
argTypes: {
shortcut: { control: false },
Icon: { control: false },
},
args: {
@@ -54,6 +55,7 @@ export const Catalog: CatalogStory<Story, typeof Button> = {
soon: { control: false },
position: { control: false },
className: { control: false },
shortcut: { control: false },
},
parameters: {
pseudo: { hover: ['.hover'], active: ['.pressed'], focus: ['.focus'] },
@@ -126,6 +128,7 @@ export const SoonCatalog: CatalogStory<Story, typeof Button> = {
soon: { control: false },
position: { control: false },
className: { control: false },
shortcut: { control: false },
},
parameters: {
pseudo: { hover: ['.hover'], active: ['.pressed'], focus: ['.focus'] },
@@ -197,6 +200,7 @@ export const PositionCatalog: CatalogStory<Story, typeof Button> = {
fullWidth: { control: false },
soon: { control: false },
position: { control: false },
shortcut: { control: false },
},
parameters: {
pseudo: { hover: ['.hover'], active: ['.pressed'], focus: ['.focus'] },
@@ -262,6 +266,34 @@ export const PositionCatalog: CatalogStory<Story, typeof Button> = {
decorators: [CatalogDecorator],
};
export const ShortcutCatalog: CatalogStory<Story, typeof Button> = {
args: { title: 'Actions', shortcut: '⌘O' },
argTypes: {
size: { control: false },
variant: { control: false },
accent: { control: false },
disabled: { control: false },
focus: { control: false },
fullWidth: { control: false },
soon: { control: false },
position: { control: false },
shortcut: { control: false },
},
parameters: {
pseudo: { hover: ['.hover'], active: ['.pressed'], focus: ['.focus'] },
catalog: {
dimensions: [
{
name: 'sizes',
values: ['small', 'medium'] satisfies ButtonSize[],
props: (size: ButtonSize) => ({ size }),
},
],
},
},
decorators: [CatalogDecorator],
};
export const FullWidth: Story = {
args: { title: 'Filter', Icon: IconSearch, fullWidth: true },
argTypes: {