Skip to content
Snippets Groups Projects
Unverified Commit 35b3dfc6 authored by Raphael Krut-Landau's avatar Raphael Krut-Landau Committed by GitHub
Browse files

fix(webapp/collections): Allow only one action menu popover to be open at once (#45619)

This also makes collections tabbable

No longer skip e2e test: "scenarios > collections > trash > should open only one context menu at a time"
parent 68ade651
No related branches found
No related tags found
No related merge requests found
......@@ -691,7 +691,7 @@ describe("scenarios > collections > trash", () => {
});
});
it.skip("should open only one context menu at a time (metabase#44910)", () => {
it("should open only one context menu at a time (metabase#44910)", () => {
cy.request("PUT", `/api/card/${ORDERS_QUESTION_ID}`, { archived: true });
cy.request("PUT", `/api/card/${ORDERS_COUNT_QUESTION_ID}`, {
archived: true,
......
......@@ -20,7 +20,6 @@ import {
isPreviewEnabled,
} from "metabase/collections/utils";
import { ConfirmDeleteModal } from "metabase/components/ConfirmDeleteModal";
import EventSandbox from "metabase/components/EventSandbox";
import { bookmarks as BookmarkEntity } from "metabase/entities";
import { useDispatch } from "metabase/lib/redux";
import { entityForObject } from "metabase/lib/schema";
......@@ -167,36 +166,32 @@ function ActionMenu({
}, [item, dispatch]);
return (
// this component is used within a `<Link>` component,
// so we must prevent events from triggering the activation of the link
<EventSandbox preventDefault>
<>
<EntityItemMenu
className={className}
item={item}
isBookmarked={isBookmarked}
isXrayEnabled={!item.archived && isXrayEnabled}
canUseMetabot={canUseMetabot}
onPin={canPin ? handlePin : undefined}
onMove={canMove ? handleMove : undefined}
onCopy={canCopy ? handleCopy : undefined}
onArchive={canArchive ? handleArchive : undefined}
onToggleBookmark={!item.archived ? handleToggleBookmark : undefined}
onTogglePreview={canPreview ? handleTogglePreview : undefined}
onRestore={canRestore ? handleRestore : undefined}
onDeletePermanently={
canDelete ? handleStartDeletePermanently : undefined
}
<>
<EntityItemMenu
className={className}
item={item}
isBookmarked={isBookmarked}
isXrayEnabled={!item.archived && isXrayEnabled}
canUseMetabot={canUseMetabot}
onPin={canPin ? handlePin : undefined}
onMove={canMove ? handleMove : undefined}
onCopy={canCopy ? handleCopy : undefined}
onArchive={canArchive ? handleArchive : undefined}
onToggleBookmark={!item.archived ? handleToggleBookmark : undefined}
onTogglePreview={canPreview ? handleTogglePreview : undefined}
onRestore={canRestore ? handleRestore : undefined}
onDeletePermanently={
canDelete ? handleStartDeletePermanently : undefined
}
/>
{showDeleteModal && (
<ConfirmDeleteModal
name={item.name}
onClose={() => setShowDeleteModal(false)}
onDelete={handleDeletePermanently}
/>
{showDeleteModal && (
<ConfirmDeleteModal
name={item.name}
onClose={() => setShowDeleteModal(false)}
onDelete={handleDeletePermanently}
/>
)}
</>
</EventSandbox>
)}
</>
);
}
......
......@@ -7,6 +7,7 @@ import type {
CreateBookmark,
DeleteBookmark,
} from "metabase/collections/types";
import EventSandbox from "metabase/components/EventSandbox";
import Tooltip from "metabase/core/components/Tooltip";
import { getIcon } from "metabase/lib/icon";
import { modelToUrl } from "metabase/lib/urls";
......@@ -121,16 +122,20 @@ function PinnedItemCard({
<ActionsContainer h={item ? undefined : "2rem"}>
{item?.model === "dataset" && <ModelDetailLink model={item} />}
{hasActions && (
<ActionMenu
databases={databases}
bookmarks={bookmarks}
createBookmark={createBookmark}
deleteBookmark={deleteBookmark}
item={item}
collection={collection}
onCopy={onCopy}
onMove={onMove}
/>
// This component is used within a `<Link>` component,
// so we must prevent events from triggering the activation of the link
<EventSandbox preventDefault sandboxedEvents={["onClick"]}>
<ActionMenu
databases={databases}
bookmarks={bookmarks}
createBookmark={createBookmark}
deleteBookmark={deleteBookmark}
item={item}
collection={collection}
onCopy={onCopy}
onMove={onMove}
/>
</EventSandbox>
)}
</ActionsContainer>
</Header>
......
......@@ -10,6 +10,7 @@ import {
isFullyParameterized,
isPreviewShown,
} from "metabase/collections/utils";
import EventSandbox from "metabase/components/EventSandbox";
import CS from "metabase/css/core/index.css";
import type { IconName } from "metabase/ui";
import Visualization from "metabase/visualizations/components/Visualization";
......@@ -48,16 +49,20 @@ const PinnedQuestionCard = ({
const isPreview = isPreviewShown(item);
const actionMenu = (
<ActionMenu
item={item}
collection={collection}
databases={databases}
bookmarks={bookmarks}
onCopy={onCopy}
onMove={onMove}
createBookmark={onCreateBookmark}
deleteBookmark={onDeleteBookmark}
/>
// This component is used within a `<Link>` component,
// so we must prevent events from triggering the activation of the link
<EventSandbox preventDefault sandboxedEvents={["onClick"]}>
<ActionMenu
item={item}
collection={collection}
databases={databases}
bookmarks={bookmarks}
onCopy={onCopy}
onMove={onMove}
createBookmark={onCreateBookmark}
deleteBookmark={onDeleteBookmark}
/>
</EventSandbox>
);
const positionedActionMenu = (
......
import { useMemo, useCallback } from "react";
import * as React from "react";
import _ from "underscore";
type Options = {
preventDefault?: boolean;
};
type SandboxedEvents =
| "onBlur"
| "onChange"
| "onClick"
| "onContextMenu"
| "onDoubleClick"
| "onDrag"
| "onDragEnd"
| "onDragEnter"
| "onDragExit"
| "onDragLeave"
| "onDragOver"
| "onDragStart"
| "onDrop"
| "onFocus"
| "onInput"
| "onInvalid"
| "onKeyDown"
| "onKeyPress"
| "onKeyUp"
| "onMouseDown"
| "onMouseEnter"
| "onMouseLeave"
| "onMouseMove"
| "onMouseOut"
| "onMouseOver"
| "onMouseUp"
| "onSubmit";
type DivProps = React.HTMLAttributes<HTMLDivElement>;
/** A name of an event that can fire on an HTMLDivElement, such as "onClick" or "onMouseUp" */
type EventName = Exclude<
{
[K in keyof DivProps]: K extends `on${string}` ? K : never;
}[keyof DivProps],
undefined
>;
function _stop<E extends React.SyntheticEvent>(
event: E,
......@@ -45,24 +25,72 @@ function _stop<E extends React.SyntheticEvent>(
}
}
type EventSandboxProps = {
export type EventSandboxProps = {
children: React.ReactNode;
enableMouseEvents?: boolean;
disabled?: boolean;
unsandboxEvents?: SandboxedEvents[];
/** Explicitly specify which events are sandboxed. By default all events are sandboxed */
sandboxedEvents?: EventName[];
/** Explicitly specify which events are *not* sandboxed. By default, all events are sandboxed, minus the ones mentioned here */
unsandboxedEvents?: EventName[];
preventDefault?: boolean;
className?: string;
/** Do not sandbox the 'onMouse*' events. (NOTE: This does not include onClick.)*/
enableMouseEvents?: boolean;
/** Do not sandbox the 'onKey*' events */
enableKeyEvents?: boolean;
};
// Prevent DOM events from bubbling through the React component tree
// This is useful for modals and popovers as they are often targeted to
// interactive elements.
const eventsSandboxedByDefault: EventName[] = [
"onClick",
"onContextMenu",
"onDoubleClick",
"onDrag",
"onDragEnd",
"onDragEnter",
"onDragExit",
"onDragLeave",
"onDragOver",
"onDragStart",
"onDrop",
"onKeyDown",
"onKeyPress",
"onKeyUp",
"onFocus",
"onBlur",
"onChange",
"onInput",
"onInvalid",
"onSubmit",
"onMouseDown",
"onMouseEnter",
"onMouseLeave",
"onMouseMove",
"onMouseOver",
"onMouseOut",
"onMouseUp",
];
/** All supported events that start with 'onMouse' */
const allOnMouseEvents = eventsSandboxedByDefault.filter(name =>
name.startsWith("onMouse"),
);
/** All supported events that start with 'onKey' */
const allOnKeyEvents = eventsSandboxedByDefault.filter(name =>
name.startsWith("onKey"),
);
/** Prevent DOM events from bubbling through the React component tree.
*
* This is useful for modals and popovers as they are often targeted to interactive elements. */
function EventSandbox({
children,
disabled,
sandboxedEvents = eventsSandboxedByDefault,
unsandboxedEvents = [],
enableMouseEvents = false,
enableKeyEvents = false,
preventDefault = false,
unsandboxEvents = [],
className,
}: EventSandboxProps) {
const stop = useCallback(
......@@ -72,59 +100,36 @@ function EventSandbox({
[preventDefault],
);
const baseProps = useMemo(() => {
return _.omit(
{
onClick: stop,
onContextMenu: stop,
onDoubleClick: stop,
onDrag: stop,
onDragEnd: stop,
onDragEnter: stop,
onDragExit: stop,
onDragLeave: stop,
onDragOver: stop,
onDragStart: stop,
onDrop: stop,
onKeyDown: stop,
onKeyPress: stop,
onKeyUp: stop,
onFocus: stop,
onBlur: stop,
onChange: stop,
onInput: stop,
onInvalid: stop,
onSubmit: stop,
},
unsandboxEvents,
);
}, [stop, unsandboxEvents]);
const sandboxProps = useMemo(() => {
const allUnsandboxedEvents = unsandboxedEvents
.concat(enableMouseEvents ? allOnMouseEvents : [])
.concat(enableKeyEvents ? allOnKeyEvents : []);
const extraProps = useMemo(() => {
const mouseEventBlockers = _.omit(
{
onMouseDown: stop,
onMouseEnter: stop,
onMouseLeave: stop,
onMouseMove: stop,
onMouseOver: stop,
onMouseOut: stop,
onMouseUp: stop,
},
unsandboxEvents,
const sandboxedEventNames = _.difference(
sandboxedEvents,
allUnsandboxedEvents,
);
return enableMouseEvents ? {} : mouseEventBlockers;
}, [stop, enableMouseEvents, unsandboxEvents]);
const entries = sandboxedEventNames.map<[EventName, typeof stop]>(name => [
name,
stop,
]);
return Object.fromEntries(entries);
}, [
stop,
sandboxedEvents,
unsandboxedEvents,
enableMouseEvents,
enableKeyEvents,
]);
return disabled === true ? (
<React.Fragment>{children}</React.Fragment>
) : (
<div className={className} {...baseProps} {...extraProps}>
<div className={className} {...sandboxProps}>
{children}
</div>
);
}
// eslint-disable-next-line import/no-default-export -- deprecated usage
export default EventSandbox;
......@@ -124,7 +124,7 @@ export class WindowModal extends Component<WindowModalProps> {
container={this._modalElement}
enableMouseEvents={enableMouseEvents}
// disable keydown to allow FocusTrap to work
unsandboxEvents={["onKeyDown"]}
unsandboxedEvents={["onKeyDown"]}
>
<TransitionGroup
appear={enableTransition}
......
......@@ -2,23 +2,23 @@ import ReactDOM from "react-dom";
import EventSandbox from "metabase/components/EventSandbox";
import type { EventSandboxProps } from "../EventSandbox/EventSandbox";
// Prevent DOM events from bubbling through the React component tree
// See https://reactjs.org/docs/portals.html#event-bubbling-through-portals
function SandboxedPortal({
const SandboxedPortal = ({
children,
container,
enableMouseEvents = false,
unsandboxEvents = [],
}) {
...props
}: {
children: React.ReactNode;
container: Element;
} & EventSandboxProps) => {
return ReactDOM.createPortal(
<EventSandbox
enableMouseEvents={enableMouseEvents}
unsandboxEvents={unsandboxEvents}
>
{children}
</EventSandbox>,
<EventSandbox {...props}>{children}</EventSandbox>,
container,
);
}
};
// eslint-disable-next-line import/no-default-export -- deprecated usage
export default SandboxedPortal;
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment