widgetCardContextMenu.tsx 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321
  1. import {InjectedRouter} from 'react-router';
  2. import styled from '@emotion/styled';
  3. import {Location} from 'history';
  4. import {openDashboardWidgetQuerySelectorModal} from 'sentry/actionCreators/modal';
  5. import Feature from 'sentry/components/acl/feature';
  6. import Button from 'sentry/components/button';
  7. import {openConfirmModal} from 'sentry/components/confirm';
  8. import DropdownMenuControl from 'sentry/components/dropdownMenuControl';
  9. import {MenuItemProps} from 'sentry/components/dropdownMenuItem';
  10. import {isWidgetViewerPath} from 'sentry/components/modals/widgetViewerModal/utils';
  11. import Tag from 'sentry/components/tag';
  12. import Tooltip from 'sentry/components/tooltip';
  13. import {IconEllipsis, IconExpand} from 'sentry/icons';
  14. import {t} from 'sentry/locale';
  15. import space from 'sentry/styles/space';
  16. import {Organization, PageFilters} from 'sentry/types';
  17. import {Series} from 'sentry/types/echarts';
  18. import trackAdvancedAnalyticsEvent from 'sentry/utils/analytics/trackAdvancedAnalyticsEvent';
  19. import {TableDataWithTitle} from 'sentry/utils/discover/discoverQuery';
  20. import {
  21. getWidgetDiscoverUrl,
  22. getWidgetIssueUrl,
  23. isCustomMeasurementWidget,
  24. } from 'sentry/views/dashboardsV2/utils';
  25. import {Widget, WidgetType} from '../types';
  26. import {WidgetViewerContext} from '../widgetViewer/widgetViewerContext';
  27. import {useDashboardsMEPContext} from './dashboardsMEPContext';
  28. type Props = {
  29. location: Location;
  30. organization: Organization;
  31. router: InjectedRouter;
  32. selection: PageFilters;
  33. widget: Widget;
  34. widgetLimitReached: boolean;
  35. index?: string;
  36. isPreview?: boolean;
  37. onDelete?: () => void;
  38. onDuplicate?: () => void;
  39. onEdit?: () => void;
  40. pageLinks?: string;
  41. seriesData?: Series[];
  42. seriesResultsType?: string;
  43. showContextMenu?: boolean;
  44. showWidgetViewerButton?: boolean;
  45. tableData?: TableDataWithTitle[];
  46. totalIssuesCount?: string;
  47. };
  48. function WidgetCardContextMenu({
  49. organization,
  50. selection,
  51. widget,
  52. widgetLimitReached,
  53. onDelete,
  54. onDuplicate,
  55. onEdit,
  56. showContextMenu,
  57. isPreview,
  58. showWidgetViewerButton,
  59. router,
  60. location,
  61. index,
  62. seriesData,
  63. tableData,
  64. pageLinks,
  65. totalIssuesCount,
  66. seriesResultsType,
  67. }: Props) {
  68. const {isMetricsData} = useDashboardsMEPContext();
  69. if (!showContextMenu) {
  70. return null;
  71. }
  72. const menuOptions: MenuItemProps[] = [];
  73. const usingCustomMeasurements = isCustomMeasurementWidget(widget);
  74. const disabledKeys: string[] = usingCustomMeasurements ? ['open-in-discover'] : [];
  75. const openWidgetViewerPath = (id: string | undefined) => {
  76. if (!isWidgetViewerPath(location.pathname)) {
  77. router.push({
  78. pathname: `${location.pathname}${
  79. location.pathname.endsWith('/') ? '' : '/'
  80. }widget/${id}/`,
  81. query: location.query,
  82. });
  83. }
  84. };
  85. if (isPreview) {
  86. return (
  87. <WidgetViewerContext.Consumer>
  88. {({setData}) => (
  89. <ContextWrapper>
  90. <Feature organization={organization} features={['dashboards-mep']}>
  91. {isMetricsData === false && (
  92. <SampledTag
  93. tooltipText={t('This widget is only applicable to indexed events.')}
  94. >
  95. {t('Indexed')}
  96. </SampledTag>
  97. )}
  98. </Feature>
  99. <StyledDropdownMenuControl
  100. items={[
  101. {
  102. key: 'preview',
  103. label: t(
  104. 'This is a preview only. To edit, you must add this dashboard.'
  105. ),
  106. },
  107. ]}
  108. triggerProps={{
  109. 'aria-label': t('Widget actions'),
  110. size: 'xs',
  111. borderless: true,
  112. showChevron: false,
  113. icon: <IconEllipsis direction="down" size="sm" />,
  114. }}
  115. placement="bottom right"
  116. disabledKeys={[...disabledKeys, 'preview']}
  117. />
  118. {showWidgetViewerButton && (
  119. <OpenWidgetViewerButton
  120. aria-label={t('Open Widget Viewer')}
  121. priority="link"
  122. size="zero"
  123. icon={<IconExpand size="xs" />}
  124. onClick={() => {
  125. (seriesData || tableData) &&
  126. setData({
  127. seriesData,
  128. tableData,
  129. pageLinks,
  130. totalIssuesCount,
  131. seriesResultsType,
  132. });
  133. openWidgetViewerPath(index);
  134. }}
  135. />
  136. )}
  137. </ContextWrapper>
  138. )}
  139. </WidgetViewerContext.Consumer>
  140. );
  141. }
  142. if (
  143. organization.features.includes('discover-basic') &&
  144. widget.widgetType === WidgetType.DISCOVER
  145. ) {
  146. // Open Widget in Discover
  147. if (widget.queries.length) {
  148. const discoverPath = getWidgetDiscoverUrl(
  149. widget,
  150. selection,
  151. organization,
  152. 0,
  153. isMetricsData
  154. );
  155. menuOptions.push({
  156. key: 'open-in-discover',
  157. label: usingCustomMeasurements ? (
  158. <Tooltip
  159. skipWrapper
  160. title={t(
  161. 'Widget using custom performance metrics cannot be opened in Discover.'
  162. )}
  163. >
  164. {t('Open in Discover')}
  165. </Tooltip>
  166. ) : (
  167. t('Open in Discover')
  168. ),
  169. to:
  170. !usingCustomMeasurements && widget.queries.length === 1
  171. ? discoverPath
  172. : undefined,
  173. onAction: () => {
  174. if (!usingCustomMeasurements) {
  175. if (widget.queries.length === 1) {
  176. trackAdvancedAnalyticsEvent('dashboards_views.open_in_discover.opened', {
  177. organization,
  178. widget_type: widget.displayType,
  179. });
  180. return;
  181. }
  182. trackAdvancedAnalyticsEvent('dashboards_views.query_selector.opened', {
  183. organization,
  184. widget_type: widget.displayType,
  185. });
  186. openDashboardWidgetQuerySelectorModal({organization, widget, isMetricsData});
  187. }
  188. },
  189. });
  190. }
  191. }
  192. if (widget.widgetType === WidgetType.ISSUE) {
  193. const issuesLocation = getWidgetIssueUrl(widget, selection, organization);
  194. menuOptions.push({
  195. key: 'open-in-issues',
  196. label: t('Open in Issues'),
  197. to: issuesLocation,
  198. });
  199. }
  200. if (organization.features.includes('dashboards-edit')) {
  201. menuOptions.push({
  202. key: 'duplicate-widget',
  203. label: t('Duplicate Widget'),
  204. onAction: () => onDuplicate?.(),
  205. });
  206. widgetLimitReached && disabledKeys.push('duplicate-widget');
  207. menuOptions.push({
  208. key: 'edit-widget',
  209. label: t('Edit Widget'),
  210. onAction: () => onEdit?.(),
  211. });
  212. menuOptions.push({
  213. key: 'delete-widget',
  214. label: t('Delete Widget'),
  215. priority: 'danger',
  216. onAction: () => {
  217. openConfirmModal({
  218. message: t('Are you sure you want to delete this widget?'),
  219. priority: 'danger',
  220. onConfirm: () => onDelete?.(),
  221. });
  222. },
  223. });
  224. }
  225. if (!menuOptions.length) {
  226. return null;
  227. }
  228. return (
  229. <WidgetViewerContext.Consumer>
  230. {({setData}) => (
  231. <ContextWrapper>
  232. <Feature organization={organization} features={['dashboards-mep']}>
  233. {isMetricsData === false && (
  234. <SampledTag
  235. tooltipText={t('This widget is only applicable to indexed events.')}
  236. >
  237. {t('Indexed')}
  238. </SampledTag>
  239. )}
  240. </Feature>
  241. <StyledDropdownMenuControl
  242. items={menuOptions}
  243. triggerProps={{
  244. 'aria-label': t('Widget actions'),
  245. size: 'xs',
  246. borderless: true,
  247. showChevron: false,
  248. icon: <IconEllipsis direction="down" size="sm" />,
  249. }}
  250. placement="bottom right"
  251. disabledKeys={[...disabledKeys]}
  252. />
  253. {showWidgetViewerButton && (
  254. <OpenWidgetViewerButton
  255. aria-label={t('Open Widget Viewer')}
  256. priority="link"
  257. size="zero"
  258. icon={<IconExpand size="xs" />}
  259. onClick={() => {
  260. setData({
  261. seriesData,
  262. tableData,
  263. pageLinks,
  264. totalIssuesCount,
  265. seriesResultsType,
  266. });
  267. openWidgetViewerPath(widget.id ?? index);
  268. }}
  269. />
  270. )}
  271. </ContextWrapper>
  272. )}
  273. </WidgetViewerContext.Consumer>
  274. );
  275. }
  276. export default WidgetCardContextMenu;
  277. const ContextWrapper = styled('div')`
  278. display: flex;
  279. align-items: center;
  280. height: ${space(3)};
  281. margin-left: ${space(1)};
  282. `;
  283. const StyledDropdownMenuControl = styled(DropdownMenuControl)`
  284. & > button {
  285. z-index: auto;
  286. }
  287. `;
  288. const OpenWidgetViewerButton = styled(Button)`
  289. padding: ${space(0.75)} ${space(1)};
  290. color: ${p => p.theme.textColor};
  291. &:hover {
  292. color: ${p => p.theme.textColor};
  293. background: ${p => p.theme.surface400};
  294. border-color: transparent;
  295. }
  296. `;
  297. const SampledTag = styled(Tag)`
  298. margin-right: ${space(0.5)};
  299. `;