ContextMenu: changed menu item rendering to render prop pattern (#31993)

* ContextMenu: changed menu item rendering to render prop pattern to enable manual composition of menu items

* fixes affected components

* fixes small nits

* added some changes

* used a more descriptive variable name
This commit is contained in:
Uchechukwu Obasi 2021-03-18 12:58:07 +01:00 committed by GitHub
parent 8b2a0e3b2c
commit 52c1d7301f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 162 additions and 75 deletions

View File

@ -4,6 +4,8 @@ import { IconButton } from '../IconButton/IconButton';
import { ContextMenu } from './ContextMenu';
import { WithContextMenu } from './WithContextMenu';
import mdx from './ContextMenu.mdx';
import { MenuGroup } from '../Menu/MenuGroup';
import { MenuItem } from '../Menu/MenuItem';
export default {
title: 'General/ContextMenu',
@ -26,13 +28,23 @@ const menuItems = [
},
];
const renderMenuItems = () => {
return menuItems?.map((group, index) => (
<MenuGroup key={`${group.label}${index}`} label={group.label} ariaLabel={group.label}>
{(group.items || []).map((item) => (
<MenuItem key={item.label} label={item.label} ariaLabel={item.label} />
))}
</MenuGroup>
));
};
export const Basic = () => {
return <ContextMenu x={10} y={11} onClose={() => {}} itemsGroup={menuItems} />;
return <ContextMenu x={10} y={11} onClose={() => {}} renderMenuItems={renderMenuItems} />;
};
export const WithState = () => {
return (
<WithContextMenu getContextMenuItems={() => menuItems}>
<WithContextMenu renderMenuItems={renderMenuItems}>
{({ openMenu }) => <IconButton name="info-circle" onClick={openMenu} />}
</WithContextMenu>
);

View File

@ -3,8 +3,6 @@ import { selectors } from '@grafana/e2e-selectors';
import { useClickAway } from 'react-use';
import { Portal } from '../Portal/Portal';
import { Menu } from '../Menu/Menu';
import { MenuGroup, MenuItemsGroup } from '../Menu/MenuGroup';
import { MenuItem } from '../Menu/MenuItem';
export interface ContextMenuProps {
/** Starting horizontal position for the menu */
@ -13,69 +11,57 @@ export interface ContextMenuProps {
y: number;
/** Callback for closing the menu */
onClose?: () => void;
/** List of the menu items to display */
itemsGroup?: MenuItemsGroup[];
/** RenderProp function that returns menu items to display */
renderMenuItems?: () => React.ReactNode;
/** A function that returns header element */
renderHeader?: () => React.ReactNode;
}
export const ContextMenu: React.FC<ContextMenuProps> = React.memo(({ x, y, onClose, itemsGroup, renderHeader }) => {
const menuRef = useRef<HTMLDivElement>(null);
const [positionStyles, setPositionStyles] = useState({});
export const ContextMenu: React.FC<ContextMenuProps> = React.memo(
({ x, y, onClose, renderMenuItems, renderHeader }) => {
const menuRef = useRef<HTMLDivElement>(null);
const [positionStyles, setPositionStyles] = useState({});
useLayoutEffect(() => {
const menuElement = menuRef.current;
if (menuElement) {
const rect = menuElement.getBoundingClientRect();
const OFFSET = 5;
const collisions = {
right: window.innerWidth < x + rect.width,
bottom: window.innerHeight < rect.bottom + rect.height + OFFSET,
};
useLayoutEffect(() => {
const menuElement = menuRef.current;
if (menuElement) {
const rect = menuElement.getBoundingClientRect();
const OFFSET = 5;
const collisions = {
right: window.innerWidth < x + rect.width,
bottom: window.innerHeight < rect.bottom + rect.height + OFFSET,
};
setPositionStyles({
position: 'fixed',
left: collisions.right ? x - rect.width - OFFSET : x - OFFSET,
top: collisions.bottom ? y - rect.height - OFFSET : y + OFFSET,
});
}
}, [x, y]);
setPositionStyles({
position: 'fixed',
left: collisions.right ? x - rect.width - OFFSET : x - OFFSET,
top: collisions.bottom ? y - rect.height - OFFSET : y + OFFSET,
});
}
}, [x, y]);
useClickAway(menuRef, () => {
if (onClose) {
onClose();
}
});
useClickAway(menuRef, () => {
if (onClose) {
onClose();
}
});
const header = renderHeader && renderHeader();
const menuItems = renderMenuItems && renderMenuItems();
const header = renderHeader && renderHeader();
return (
<Portal>
<Menu
header={header}
ref={menuRef}
style={positionStyles}
ariaLabel={selectors.components.Menu.MenuComponent('Context')}
onClick={onClose}
>
{itemsGroup?.map((group, index) => (
<MenuGroup key={`${group.label}${index}`} label={group.label} ariaLabel={group.label}>
{(group.items || []).map((item) => (
<MenuItem
key={`${item.label}`}
url={item.url}
label={item.label}
ariaLabel={item.label}
target={item.target}
icon={item.icon}
active={item.active}
onClick={item.onClick}
/>
))}
</MenuGroup>
))}
</Menu>
</Portal>
);
});
return (
<Portal>
<Menu
header={header}
ref={menuRef}
style={positionStyles}
ariaLabel={selectors.components.Menu.MenuComponent('Context')}
onClick={onClose}
>
{menuItems}
</Menu>
</Portal>
);
}
);
ContextMenu.displayName = 'ContextMenu';

View File

@ -1,18 +1,16 @@
import React, { useState } from 'react';
import { ContextMenu } from '../ContextMenu/ContextMenu';
import { MenuItemsGroup } from '../Menu/MenuGroup';
interface WithContextMenuProps {
/** Menu item trigger that accepts openMenu prop */
children: (props: { openMenu: React.MouseEventHandler<HTMLElement> }) => JSX.Element;
/** A function that returns an array of menu items */
getContextMenuItems: () => MenuItemsGroup[];
renderMenuItems: () => React.ReactNode;
}
export const WithContextMenu: React.FC<WithContextMenuProps> = ({ children, getContextMenuItems }) => {
export const WithContextMenu: React.FC<WithContextMenuProps> = ({ children, renderMenuItems }) => {
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [menuPosition, setMenuPosition] = useState({ x: 0, y: 0 });
return (
<>
{children({
@ -30,7 +28,7 @@ export const WithContextMenu: React.FC<WithContextMenuProps> = ({ children, getC
onClose={() => setIsMenuOpen(false)}
x={menuPosition.x}
y={menuPosition.y}
itemsGroup={getContextMenuItems()}
renderMenuItems={renderMenuItems}
/>
)}
</>

View File

@ -4,6 +4,8 @@ import { selectors } from '@grafana/e2e-selectors';
import { css } from 'emotion';
import { WithContextMenu } from '../ContextMenu/WithContextMenu';
import { linkModelToContextMenuItems } from '../../utils/dataLinks';
import { MenuGroup, MenuItemsGroup } from '../Menu/MenuGroup';
import { MenuItem } from '../Menu/MenuItem';
interface DataLinksContextMenuProps {
children: (props: DataLinksContextMenuApi) => JSX.Element;
@ -18,8 +20,24 @@ export interface DataLinksContextMenuApi {
export const DataLinksContextMenu: React.FC<DataLinksContextMenuProps> = ({ children, links, config }) => {
const linksCounter = config.links!.length;
const getDataLinksContextMenuItems = () => {
return [{ items: linkModelToContextMenuItems(links), label: 'Data links' }];
const itemsGroup: MenuItemsGroup[] = [{ items: linkModelToContextMenuItems(links), label: 'Data links' }];
const renderMenuGroupItems = () => {
return itemsGroup.map((group, index) => (
<MenuGroup key={`${group.label}${index}`} label={group.label} ariaLabel={group.label}>
{(group.items || []).map((item) => (
<MenuItem
key={item.label}
url={item.url}
label={item.label}
ariaLabel={item.label}
target={item.target}
icon={item.icon}
active={item.active}
onClick={item.onClick}
/>
))}
</MenuGroup>
));
};
// Use this class name (exposed via render prop) to add context menu indicator to the click target of the visualization
@ -29,7 +47,7 @@ export const DataLinksContextMenu: React.FC<DataLinksContextMenuProps> = ({ chil
if (linksCounter > 1) {
return (
<WithContextMenu getContextMenuItems={getDataLinksContextMenuItems}>
<WithContextMenu renderMenuItems={renderMenuGroupItems}>
{({ openMenu }) => {
return children({ openMenu, targetClassName });
}}

View File

@ -15,12 +15,15 @@ import { HorizontalGroup } from '../Layout/Layout';
import { FormattedValueDisplay } from '../FormattedValueDisplay/FormattedValueDisplay';
import { SeriesIcon } from '../VizLegend/SeriesIcon';
import { css } from 'emotion';
import { MenuGroup, MenuGroupProps } from '../Menu/MenuGroup';
import { MenuItem } from '../Menu/MenuItem';
export type ContextDimensions<T extends Dimensions = any> = { [key in keyof T]: [number, number | undefined] | null };
export type GraphContextMenuProps = ContextMenuProps & {
getContextMenuSource: () => FlotDataPoint | null;
timeZone?: TimeZone;
itemsGroup?: MenuGroupProps[];
dimensions?: GraphDimensions;
contextDimensions?: ContextDimensions;
};
@ -40,7 +43,7 @@ export const GraphContextMenu: React.FC<GraphContextMenuProps> = ({
const itemsToRender = itemsGroup
? itemsGroup.map((group) => ({
...group,
items: group.items.filter((item) => item.label),
items: group.items?.filter((item) => item.label),
}))
: [];
@ -80,8 +83,26 @@ export const GraphContextMenu: React.FC<GraphContextMenuProps> = ({
/>
);
};
const renderMenuGroupItems = () => {
return itemsToRender?.map((group, index) => (
<MenuGroup key={`${group.label}${index}`} label={group.label} ariaLabel={group.label}>
{(group.items || []).map((item) => (
<MenuItem
key={`${item.label}`}
url={item.url}
label={item.label}
ariaLabel={item.label}
target={item.target}
icon={item.icon}
active={item.active}
onClick={item.onClick}
/>
))}
</MenuGroup>
));
};
return <ContextMenu {...otherProps} itemsGroup={itemsToRender} renderHeader={renderHeader} />;
return <ContextMenu {...otherProps} renderMenuItems={renderMenuGroupItems} renderHeader={renderHeader} />;
};
/** @internal */

View File

@ -6,6 +6,8 @@ import { useTheme } from '../../themes/ThemeContext';
import { stylesFactory } from '../../themes/stylesFactory';
import { getEdgeFields, getNodeFields } from './utils';
import { css } from 'emotion';
import { MenuGroup } from '../Menu/MenuGroup';
import { MenuItem } from '../Menu/MenuItem';
/**
* Hook that contains state of the context menu, both for edges and nodes and provides appropriate component when
@ -30,11 +32,26 @@ export function useContextMenu(
if (openedNode) {
const items = getItems(getLinks(nodes, openedNode.node.dataFrameRowIndex));
const renderMenuGroupItems = () => {
return items?.map((group, index) => (
<MenuGroup key={`${group.label}${index}`} label={group.label} ariaLabel={group.label}>
{(group.items || []).map((item) => (
<MenuItem
key={`${item.label}`}
url={item.url}
label={item.label}
ariaLabel={item.label}
onClick={item.onClick}
/>
))}
</MenuGroup>
));
};
if (items.length) {
MenuComponent = (
<ContextMenu
renderHeader={() => <NodeHeader node={openedNode.node} nodes={nodes} />}
itemsGroup={items}
renderMenuItems={renderMenuGroupItems}
onClose={() => setOpenedNode(undefined)}
x={openedNode.event.pageX}
y={openedNode.event.pageY}
@ -45,11 +62,26 @@ export function useContextMenu(
if (openedEdge) {
const items = getItems(getLinks(edges, openedEdge.edge.dataFrameRowIndex));
const renderMenuGroupItems = () => {
return items?.map((group, index) => (
<MenuGroup key={`${group.label}${index}`} label={group.label} ariaLabel={group.label}>
{(group.items || []).map((item) => (
<MenuItem
key={item.label}
url={item.url}
label={item.label}
ariaLabel={item.label}
onClick={item.onClick}
/>
))}
</MenuGroup>
));
};
if (items.length) {
MenuComponent = (
<ContextMenu
renderHeader={() => <EdgeHeader edge={openedEdge.edge} edges={edges} />}
itemsGroup={items}
renderMenuItems={renderMenuGroupItems}
onClose={() => setOpenedEdge(undefined)}
x={openedEdge.event.pageX}
y={openedEdge.event.pageY}

View File

@ -6,6 +6,8 @@ import {
IconName,
MenuItemProps,
MenuItemsGroup,
MenuGroup,
MenuItem,
Portal,
useGraphNGContext,
} from '@grafana/ui';
@ -31,9 +33,9 @@ interface ContextMenuPluginProps {
export const ContextMenuPlugin: React.FC<ContextMenuPluginProps> = ({
data,
defaultItems,
onClose,
timeZone,
defaultItems,
replaceVariables,
}) => {
const [isOpen, setIsOpen] = useState(false);
@ -105,7 +107,6 @@ export const ContextMenuView: React.FC<ContextMenuProps> = ({
if (!xField) {
return null;
}
const items = defaultItems ? [...defaultItems] : [];
let renderHeader: () => JSX.Element | null = () => null;
@ -160,9 +161,28 @@ export const ContextMenuView: React.FC<ContextMenuProps> = ({
);
}
const renderMenuGroupItems = () => {
return items?.map((group, index) => (
<MenuGroup key={`${group.label}${index}`} label={group.label} ariaLabel={group.label}>
{(group.items || []).map((item) => (
<MenuItem
key={item.label}
url={item.url}
label={item.label}
ariaLabel={item.label}
target={item.target}
icon={item.icon}
active={item.active}
onClick={item.onClick}
/>
))}
</MenuGroup>
));
};
return (
<ContextMenu
itemsGroup={items}
renderMenuItems={renderMenuGroupItems}
renderHeader={renderHeader}
x={selection.coords.viewport.x}
y={selection.coords.viewport.y}