useContextMenu.tsx 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216
  1. import { css } from '@emotion/css';
  2. import React, { MouseEvent, useCallback, useState } from 'react';
  3. import { DataFrame, Field, GrafanaTheme, LinkModel } from '@grafana/data';
  4. import { ContextMenu, MenuGroup, MenuItem, stylesFactory, useTheme } from '@grafana/ui';
  5. import { Config } from './layout';
  6. import { EdgeDatum, NodeDatum } from './types';
  7. import { getEdgeFields, getNodeFields } from './utils';
  8. /**
  9. * Hook that contains state of the context menu, both for edges and nodes and provides appropriate component when
  10. * opened context menu should be opened.
  11. */
  12. export function useContextMenu(
  13. getLinks: (dataFrame: DataFrame, rowIndex: number) => LinkModel[],
  14. nodes: DataFrame,
  15. edges: DataFrame,
  16. config: Config,
  17. setConfig: (config: Config) => void,
  18. setFocusedNodeId: (id: string) => void
  19. ): {
  20. onEdgeOpen: (event: MouseEvent<SVGElement>, edge: EdgeDatum) => void;
  21. onNodeOpen: (event: MouseEvent<SVGElement>, node: NodeDatum) => void;
  22. MenuComponent: React.ReactNode;
  23. } {
  24. const [menu, setMenu] = useState<JSX.Element | undefined>(undefined);
  25. const onNodeOpen = useCallback(
  26. (event, node) => {
  27. const extraNodeItem = config.gridLayout
  28. ? [
  29. {
  30. label: 'Show in Graph layout',
  31. onClick: (node: NodeDatum) => {
  32. setFocusedNodeId(node.id);
  33. setConfig({ ...config, gridLayout: false });
  34. },
  35. },
  36. ]
  37. : undefined;
  38. const renderer = getItemsRenderer(getLinks(nodes, node.dataFrameRowIndex), node, extraNodeItem);
  39. if (renderer) {
  40. setMenu(
  41. <ContextMenu
  42. renderHeader={() => <NodeHeader node={node} nodes={nodes} />}
  43. renderMenuItems={renderer}
  44. onClose={() => setMenu(undefined)}
  45. x={event.pageX}
  46. y={event.pageY}
  47. />
  48. );
  49. }
  50. },
  51. [config, nodes, getLinks, setMenu, setConfig, setFocusedNodeId]
  52. );
  53. const onEdgeOpen = useCallback(
  54. (event, edge) => {
  55. const renderer = getItemsRenderer(getLinks(edges, edge.dataFrameRowIndex), edge);
  56. if (renderer) {
  57. setMenu(
  58. <ContextMenu
  59. renderHeader={() => <EdgeHeader edge={edge} edges={edges} />}
  60. renderMenuItems={renderer}
  61. onClose={() => setMenu(undefined)}
  62. x={event.pageX}
  63. y={event.pageY}
  64. />
  65. );
  66. }
  67. },
  68. [edges, getLinks, setMenu]
  69. );
  70. return { onEdgeOpen, onNodeOpen, MenuComponent: menu };
  71. }
  72. function getItemsRenderer<T extends NodeDatum | EdgeDatum>(
  73. links: LinkModel[],
  74. item: T,
  75. extraItems?: Array<LinkData<T>> | undefined
  76. ) {
  77. if (!(links.length || extraItems?.length)) {
  78. return undefined;
  79. }
  80. const items = getItems(links);
  81. return () => {
  82. let groups = items?.map((group, index) => (
  83. <MenuGroup key={`${group.label}${index}`} label={group.label}>
  84. {(group.items || []).map(mapMenuItem(item))}
  85. </MenuGroup>
  86. ));
  87. if (extraItems) {
  88. groups = [...extraItems.map(mapMenuItem(item)), ...groups];
  89. }
  90. return groups;
  91. };
  92. }
  93. function mapMenuItem<T extends NodeDatum | EdgeDatum>(item: T) {
  94. return function NodeGraphMenuItem(link: LinkData<T>) {
  95. return (
  96. <MenuItem
  97. key={link.label}
  98. url={link.url}
  99. label={link.label}
  100. ariaLabel={link.ariaLabel}
  101. onClick={link.onClick ? () => link.onClick?.(item) : undefined}
  102. target={'_self'}
  103. />
  104. );
  105. };
  106. }
  107. type LinkData<T extends NodeDatum | EdgeDatum> = {
  108. label: string;
  109. ariaLabel?: string;
  110. url?: string;
  111. onClick?: (item: T) => void;
  112. };
  113. function getItems(links: LinkModel[]) {
  114. const defaultGroup = 'Open in Explore';
  115. const groups = links.reduce<{ [group: string]: Array<{ l: LinkModel; newTitle?: string }> }>((acc, l) => {
  116. let group;
  117. let title;
  118. if (l.title.indexOf('/') !== -1) {
  119. group = l.title.split('/')[0];
  120. title = l.title.split('/')[1];
  121. acc[group] = acc[group] || [];
  122. acc[group].push({ l, newTitle: title });
  123. } else {
  124. acc[defaultGroup] = acc[defaultGroup] || [];
  125. acc[defaultGroup].push({ l });
  126. }
  127. return acc;
  128. }, {});
  129. return Object.keys(groups).map((key) => {
  130. return {
  131. label: key,
  132. ariaLabel: key,
  133. items: groups[key].map((link) => ({
  134. label: link.newTitle || link.l.title,
  135. ariaLabel: link.newTitle || link.l.title,
  136. url: link.l.href,
  137. onClick: link.l.onClick,
  138. })),
  139. };
  140. });
  141. }
  142. function NodeHeader(props: { node: NodeDatum; nodes: DataFrame }) {
  143. const index = props.node.dataFrameRowIndex;
  144. const fields = getNodeFields(props.nodes);
  145. return (
  146. <div>
  147. {fields.title && <Label field={fields.title} index={index} />}
  148. {fields.subTitle && <Label field={fields.subTitle} index={index} />}
  149. {fields.details.map((f) => (
  150. <Label key={f.name} field={f} index={index} />
  151. ))}
  152. </div>
  153. );
  154. }
  155. function EdgeHeader(props: { edge: EdgeDatum; edges: DataFrame }) {
  156. const index = props.edge.dataFrameRowIndex;
  157. const fields = getEdgeFields(props.edges);
  158. return (
  159. <div>
  160. {fields.details.map((f) => (
  161. <Label key={f.name} field={f} index={index} />
  162. ))}
  163. </div>
  164. );
  165. }
  166. export const getLabelStyles = stylesFactory((theme: GrafanaTheme) => {
  167. return {
  168. label: css`
  169. label: Label;
  170. line-height: 1.25;
  171. margin: ${theme.spacing.formLabelMargin};
  172. padding: ${theme.spacing.formLabelPadding};
  173. color: ${theme.colors.textFaint};
  174. font-size: ${theme.typography.size.sm};
  175. font-weight: ${theme.typography.weight.semibold};
  176. `,
  177. value: css`
  178. label: Value;
  179. font-size: ${theme.typography.size.sm};
  180. font-weight: ${theme.typography.weight.semibold};
  181. color: ${theme.colors.formLabel};
  182. margin-top: ${theme.spacing.xxs};
  183. display: block;
  184. `,
  185. };
  186. });
  187. function Label(props: { field: Field; index: number }) {
  188. const { field, index } = props;
  189. const value = field.values.get(index) || '';
  190. const styles = getLabelStyles(useTheme());
  191. return (
  192. <div className={styles.label}>
  193. <div>{field.config.displayName || field.name}</div>
  194. <span className={styles.value}>{value}</span>
  195. </div>
  196. );
  197. }