Skip to content

Commit b2d648a

Browse files
✨ Feat(web): implement actions menu to simplify forms (#629)
* feat(web): Add action menu to grid event and all day event forms * feat(web): implement action menu for someday event form * fix(web): Failing tests
1 parent 29e15d9 commit b2d648a

File tree

16 files changed

+444
-177
lines changed

16 files changed

+444
-177
lines changed

packages/web/src/common/utils/someday.util.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
Schema_SomedayEventsColumn,
99
} from "@web/common/types/web.event.types";
1010
import { validateSomedayEvents } from "@web/common/validators/someday.event.validator";
11+
import { ID_SOMEDAY_EVENT_ACTION_MENU } from "@web/views/Forms/SomedayEventForm/SomedayEventActionMenu";
1112

1213
export const getSomedayEventCategory = (
1314
event: Schema_Event,
@@ -139,3 +140,8 @@ export const setSomedayEventsOrder = (
139140
return { ...event, order };
140141
});
141142
};
143+
144+
export const isSomedayEventActionMenuOpen = () => {
145+
const actionMenu = document.getElementById(ID_SOMEDAY_EVENT_ACTION_MENU);
146+
return !!actionMenu;
147+
};

packages/web/src/views/Calendar/Calendar.form.test.tsx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,12 +51,15 @@ describe("Event Form", () => {
5151

5252
await act(async () => {
5353
await user.click(
54-
within(form).getByRole("button", {
55-
name: /delete event/i,
56-
}),
54+
within(form).getByRole("button", { name: /open actions menu/i }),
5755
);
5856
});
5957

58+
await waitFor(() => {
59+
expect(screen.getByText("Delete")).toBeInTheDocument();
60+
});
61+
await user.click(screen.getByText("Delete"));
62+
6063
await waitFor(() => {
6164
expect(screen.queryByRole("form")).not.toBeInTheDocument();
6265
});

packages/web/src/views/Calendar/components/Draft/hooks/state/useDraftForm.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { OpenChangeReason } from "@floating-ui/react";
22
import { Categories_Event } from "@core/types/event.types";
33
import { isContextMenuOpen } from "@web/common/utils";
4+
import { isSomedayEventActionMenuOpen } from "@web/common/utils/someday.util";
45
import { useEventForm } from "@web/views/Forms/hooks/useEventForm";
56

67
export const useDraftForm = (
@@ -11,8 +12,8 @@ export const useDraftForm = (
1112
setIsFormOpen: (isOpen: boolean) => void,
1213
) => {
1314
const handleDiscard = (reason?: OpenChangeReason) => {
14-
if (isContextMenuOpen()) {
15-
// Prevent discard if context menu is open.
15+
if (isContextMenuOpen() || isSomedayEventActionMenuOpen()) {
16+
// Prevent discard if context menu or someday action menu is open.
1617
// Discarding the event will mess with the context menu
1718
// logic flow because the context menu depends on having
1819
// a draft event selected.
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import React, { MouseEvent, useState } from "react";
2+
import styled from "styled-components";
3+
import {
4+
FloatingFocusManager,
5+
FloatingPortal,
6+
flip,
7+
offset,
8+
shift,
9+
useClick,
10+
useDismiss,
11+
useFloating,
12+
useInteractions,
13+
useRole,
14+
} from "@floating-ui/react";
15+
// @ts-expect-error - Icon name might not be present in type definitions but does exist at runtime
16+
import { DotsThreeVertical } from "@phosphor-icons/react";
17+
import IconButton from "@web/components/IconButton/IconButton";
18+
19+
interface ActionsMenuProps {
20+
children: (closeMenu: () => void) => React.ReactNode;
21+
id?: string;
22+
}
23+
24+
export const ActionsMenu: React.FC<ActionsMenuProps> = ({ children, id }) => {
25+
const [open, setOpen] = useState(false);
26+
27+
const { x, y, refs, strategy, context } = useFloating({
28+
open,
29+
onOpenChange: (open) => {
30+
setOpen(open);
31+
},
32+
middleware: [offset(8), flip(), shift({ padding: 8 })],
33+
placement: "bottom-end",
34+
});
35+
36+
const click = useClick(context);
37+
const dismiss = useDismiss(context);
38+
const role = useRole(context);
39+
40+
const { getReferenceProps, getFloatingProps } = useInteractions([
41+
click,
42+
dismiss,
43+
role,
44+
]);
45+
46+
const closeMenu = () => {};
47+
48+
return (
49+
<>
50+
<TriggerWrapper
51+
ref={refs.setReference}
52+
{...getReferenceProps({
53+
onClick: (e: MouseEvent<HTMLDivElement>) => {
54+
// Prevent default behaviour (like focusing inputs) and stop bubbling to parent form
55+
e.preventDefault();
56+
e.stopPropagation();
57+
},
58+
})}
59+
>
60+
<IconButton id="actions-menu-trigger" aria-label="Open actions menu">
61+
<DotsThreeVertical />
62+
</IconButton>
63+
</TriggerWrapper>
64+
{open && (
65+
<FloatingPortal>
66+
<FloatingFocusManager context={context} modal={false}>
67+
<StyledMenu
68+
ref={refs.setFloating}
69+
style={{ position: strategy, top: y ?? 0, left: x ?? 0 }}
70+
{...getFloatingProps()}
71+
id={id}
72+
>
73+
{children(closeMenu)}
74+
</StyledMenu>
75+
</FloatingFocusManager>
76+
</FloatingPortal>
77+
)}
78+
</>
79+
);
80+
};
81+
82+
const TriggerWrapper = styled.div`
83+
display: inline-flex;
84+
`;
85+
86+
const StyledMenu = styled.div`
87+
display: flex;
88+
flex-direction: column;
89+
gap: 8px;
90+
background-color: ${({ theme }) => theme.color.menu.bg};
91+
border: 1px solid ${({ theme }) => theme.color.border.primary};
92+
padding: 8px;
93+
border-radius: ${({ theme }) => theme.shape.borderRadius || "6px"};
94+
box-shadow: 0px 4px 8px rgba(0, 0, 0, 0.1);
95+
z-index: 3;
96+
`;
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import React, { ReactNode, useState } from "react";
2+
import styled from "styled-components";
3+
import {
4+
Tooltip,
5+
TooltipContent,
6+
TooltipTrigger,
7+
} from "@web/components/Tooltip";
8+
import { StyledShortcutTip } from "@web/components/Tooltip/styled";
9+
10+
/**
11+
* Shared menu item styling for the EventActionMenu buttons.
12+
*/
13+
const StyledMenuItem = styled.button`
14+
display: flex;
15+
align-items: center;
16+
gap: 8px;
17+
width: 100%;
18+
background: none;
19+
border: none;
20+
padding: 4px 8px;
21+
cursor: pointer;
22+
color: ${({ theme }) => theme.color.text.dark};
23+
font-size: ${({ theme }) => theme.text.size.m};
24+
text-align: left;
25+
26+
&:hover {
27+
background-color: ${({ theme }) => theme.color.bg.secondary};
28+
color: ${({ theme }) => theme.color.text.light};
29+
}
30+
`;
31+
32+
export interface MenuItemProps
33+
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
34+
/**
35+
* Content to render inside the delayed tooltip. If omitted, the tooltip is disabled.
36+
*/
37+
tooltipContent?: ReactNode;
38+
}
39+
40+
const MenuItem: React.FC<MenuItemProps> = ({
41+
tooltipContent,
42+
children,
43+
...rest
44+
}) => {
45+
const [isTooltipOpen, setIsTooltipOpen] = useState(false);
46+
47+
// With tooltip
48+
return (
49+
<Tooltip
50+
open={isTooltipOpen}
51+
onOpenChange={setIsTooltipOpen}
52+
placement="right-end"
53+
>
54+
<TooltipTrigger asChild>
55+
<StyledMenuItem {...rest}>{children}</StyledMenuItem>
56+
</TooltipTrigger>
57+
<TooltipContent>
58+
<StyledShortcutTip>{tooltipContent}</StyledShortcutTip>
59+
</TooltipContent>
60+
</Tooltip>
61+
);
62+
};
63+
64+
export default MenuItem;
Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,21 @@
11
import React from "react";
22
import { Trash } from "@phosphor-icons/react";
3-
import TooltipIconButton from "@web/components/TooltipIconButton/TooltipIconButton";
3+
import MenuItem from "../ActionsMenu/MenuItem";
44

5-
export const DeleteButton = ({ onClick }: { onClick: () => void }) => {
5+
interface Props {
6+
onClick: () => void;
7+
}
8+
9+
export const DeleteButton: React.FC<Props> = ({ onClick }) => {
610
return (
7-
<TooltipIconButton
8-
icon={<Trash />}
9-
buttonProps={{ "aria-label": "Delete Event [DEL]" }}
10-
tooltipProps={{
11-
shortcut: "DEL",
12-
description: "Delete Event",
13-
onClick,
14-
}}
15-
/>
11+
<MenuItem
12+
role="menuitem"
13+
onClick={onClick}
14+
aria-label="Delete Event"
15+
tooltipContent={<span>DEL</span>}
16+
>
17+
<Trash size={16} />
18+
<span>Delete</span>
19+
</MenuItem>
1620
);
1721
};

packages/web/src/views/Forms/EventForm/DuplicateButton.tsx

Lines changed: 19 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,22 +2,26 @@ import React from "react";
22
import { Copy } from "@phosphor-icons/react";
33
import { getMetaKey } from "@web/common/utils/shortcut.util";
44
import { Text } from "@web/components/Text";
5-
import TooltipIconButton from "@web/components/TooltipIconButton/TooltipIconButton";
5+
import MenuItem from "../ActionsMenu/MenuItem";
66

7-
export const DuplicateButton = ({ onClick }: { onClick: () => void }) => {
7+
interface Props {
8+
onClick: () => void;
9+
}
10+
11+
export const DuplicateButton: React.FC<Props> = ({ onClick }) => {
812
return (
9-
<TooltipIconButton
10-
icon={<Copy />}
11-
buttonProps={{ "aria-label": "Duplicate Event [Meta+D]" }}
12-
tooltipProps={{
13-
shortcut: (
14-
<Text size="s" style={{ display: "flex", alignItems: "center" }}>
15-
{getMetaKey()} + D
16-
</Text>
17-
),
18-
description: "Duplicate Event",
19-
onClick,
20-
}}
21-
/>
13+
<MenuItem
14+
role="menuitem"
15+
onClick={onClick}
16+
aria-label="Duplicate Event"
17+
tooltipContent={
18+
<Text size="s" style={{ display: "flex", alignItems: "center" }}>
19+
{getMetaKey()} + D
20+
</Text>
21+
}
22+
>
23+
<Copy size={16} />
24+
<span>Duplicate</span>
25+
</MenuItem>
2226
);
2327
};
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import React from "react";
2+
import { ActionsMenu } from "../ActionsMenu/ActionsMenu";
3+
import { DeleteButton } from "./DeleteButton";
4+
import { DuplicateButton } from "./DuplicateButton";
5+
import { MoveToSidebarButton } from "./MoveToSidebarButton";
6+
7+
interface Props {
8+
isDraft: boolean;
9+
onConvert?: () => void;
10+
onDuplicate: () => void;
11+
onDelete: () => void;
12+
}
13+
14+
const ID_EVENT_ACTION_MENU = "event-action-menu";
15+
16+
export const EventActionMenu: React.FC<Props> = ({
17+
isDraft,
18+
onConvert,
19+
onDuplicate,
20+
onDelete,
21+
}) => {
22+
return (
23+
<ActionsMenu id={ID_EVENT_ACTION_MENU}>
24+
{(close) => (
25+
<>
26+
{!isDraft && (
27+
<MoveToSidebarButton
28+
onClick={() => {
29+
onConvert?.();
30+
close();
31+
}}
32+
/>
33+
)}
34+
<DuplicateButton
35+
onClick={() => {
36+
onDuplicate();
37+
close();
38+
}}
39+
/>
40+
<DeleteButton
41+
onClick={() => {
42+
onDelete();
43+
close();
44+
}}
45+
/>
46+
</>
47+
)}
48+
</ActionsMenu>
49+
);
50+
};

packages/web/src/views/Forms/EventForm/EventForm.test.tsx

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import React, { act } from "react";
22
import "@testing-library/jest-dom/extend-expect";
3-
import { screen } from "@testing-library/react";
3+
import { screen, waitFor, within } from "@testing-library/react";
44
import userEvent from "@testing-library/user-event";
55
import { Schema_Event } from "@core/types/event.types";
66
import { render } from "@web/__tests__/__mocks__/mock.render";
@@ -132,6 +132,8 @@ test("should call onDuplicate when meta+d keyboard shortcut is used", async () =
132132
});
133133

134134
test("should call duplicateEvent when duplicate icon btn is clicked", async () => {
135+
const user = userEvent.setup();
136+
135137
const sampleEvent: Schema_Event = {
136138
_id: "event123",
137139
title: "Test Event for Duplication",
@@ -165,14 +167,18 @@ test("should call duplicateEvent when duplicate icon btn is clicked", async () =
165167
// Ensure the form is rendered (good sanity check)
166168
expect(eventForm).toBeInTheDocument();
167169

168-
const duplicateEventButton = eventForm.querySelector(
169-
'[type="button"][aria-label="Duplicate Event [Meta+D]"]',
170-
);
170+
const form = screen.getByRole("form");
171171

172-
// Ensure the form is rendered (good sanity check)
173-
expect(duplicateEventButton).toBeInTheDocument();
172+
await act(async () => {
173+
await user.click(
174+
within(form).getByRole("button", { name: /open actions menu/i }),
175+
);
176+
});
174177

175-
await act(async () => userEvent.click(duplicateEventButton!));
178+
await waitFor(() => {
179+
expect(screen.getByText("Duplicate")).toBeInTheDocument();
180+
});
181+
await user.click(screen.getByText("Duplicate"));
176182

177183
expect(mockOnDuplicate).toHaveBeenCalledTimes(1);
178184
expect(mockOnDuplicate).toHaveBeenCalledWith(sampleEvent);

0 commit comments

Comments
 (0)