mirror of
https://github.com/lingble/twenty.git
synced 2025-10-29 03:42:30 +00:00
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:
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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 />
|
||||
|
||||
@@ -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} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export enum RightDrawerActionMenuDropdownHotkeyScope {
|
||||
RightDrawerActionMenuDropdown = 'right-drawer-action-menu-dropdown',
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
export const getRightDrawerActionMenuDropdownIdFromActionMenuId = (
|
||||
actionMenuId: string,
|
||||
) => {
|
||||
return `right-drawer-action-menu-dropdown-${actionMenuId}`;
|
||||
};
|
||||
@@ -350,6 +350,10 @@ export const RichTextEditor = ({
|
||||
editor.focus();
|
||||
},
|
||||
RightDrawerHotkeyScope.RightDrawer,
|
||||
[],
|
||||
{
|
||||
preventDefault: false,
|
||||
},
|
||||
);
|
||||
|
||||
const handleBlockEditorFocus = () => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user