widgetCardContextMenu.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406
  1. import type React from 'react';
  2. import styled from '@emotion/styled';
  3. import type {Location} from 'history';
  4. import {openDashboardWidgetQuerySelectorModal} from 'sentry/actionCreators/modal';
  5. import Tag from 'sentry/components/badge/tag';
  6. import {Button} from 'sentry/components/button';
  7. import {openConfirmModal} from 'sentry/components/confirm';
  8. import type {MenuItemProps} from 'sentry/components/dropdownMenu';
  9. import {DropdownMenu} from 'sentry/components/dropdownMenu';
  10. import {isWidgetViewerPath} from 'sentry/components/modals/widgetViewerModal/utils';
  11. import {Tooltip} from 'sentry/components/tooltip';
  12. import {IconEllipsis, IconExpand, IconInfo} from 'sentry/icons';
  13. import {t} from 'sentry/locale';
  14. import {space} from 'sentry/styles/space';
  15. import type {PageFilters} from 'sentry/types/core';
  16. import type {Series} from 'sentry/types/echarts';
  17. import type {InjectedRouter} from 'sentry/types/legacyReactRouter';
  18. import type {Organization} from 'sentry/types/organization';
  19. import {trackAnalytics} from 'sentry/utils/analytics';
  20. import type {TableDataWithTitle} from 'sentry/utils/discover/discoverQuery';
  21. import type {AggregationOutputType} from 'sentry/utils/discover/fields';
  22. import {
  23. MEPConsumer,
  24. MEPState,
  25. } from 'sentry/utils/performance/contexts/metricsEnhancedSetting';
  26. import {
  27. getWidgetDiscoverUrl,
  28. getWidgetIssueUrl,
  29. getWidgetMetricsUrl,
  30. hasDatasetSelector,
  31. isUsingPerformanceScore,
  32. performanceScoreTooltip,
  33. } from 'sentry/views/dashboards/utils';
  34. import type {Widget} from '../types';
  35. import {WidgetType} from '../types';
  36. import {WidgetViewerContext} from '../widgetViewer/widgetViewerContext';
  37. import {useDashboardsMEPContext} from './dashboardsMEPContext';
  38. type Props = {
  39. location: Location;
  40. organization: Organization;
  41. router: InjectedRouter;
  42. selection: PageFilters;
  43. widget: Widget;
  44. widgetLimitReached: boolean;
  45. description?: string;
  46. index?: string;
  47. isPreview?: boolean;
  48. onDelete?: () => void;
  49. onDuplicate?: () => void;
  50. onEdit?: () => void;
  51. pageLinks?: string;
  52. seriesData?: Series[];
  53. seriesResultsType?: Record<string, AggregationOutputType>;
  54. showContextMenu?: boolean;
  55. tableData?: TableDataWithTitle[];
  56. title?: string | React.ReactNode;
  57. totalIssuesCount?: string;
  58. };
  59. function WidgetCardContextMenu({
  60. organization,
  61. selection,
  62. widget,
  63. widgetLimitReached,
  64. onDelete,
  65. onDuplicate,
  66. onEdit,
  67. showContextMenu,
  68. isPreview,
  69. router,
  70. location,
  71. index,
  72. seriesData,
  73. tableData,
  74. pageLinks,
  75. totalIssuesCount,
  76. seriesResultsType,
  77. description,
  78. title,
  79. }: Props) {
  80. const {isMetricsData} = useDashboardsMEPContext();
  81. if (!showContextMenu) {
  82. return null;
  83. }
  84. const menuOptions: MenuItemProps[] = [];
  85. const disabledKeys: string[] = [];
  86. const openWidgetViewerPath = (id: string | undefined) => {
  87. if (!isWidgetViewerPath(location.pathname)) {
  88. router.push({
  89. pathname: `${location.pathname}${
  90. location.pathname.endsWith('/') ? '' : '/'
  91. }widget/${id}/`,
  92. query: location.query,
  93. });
  94. }
  95. };
  96. if (isPreview) {
  97. return (
  98. <WidgetViewerContext.Consumer>
  99. {({setData}) => (
  100. <MEPConsumer>
  101. {metricSettingContext => (
  102. <ContextWrapper>
  103. {!organization.features.includes('performance-mep-bannerless-ui') &&
  104. isMetricsData === false &&
  105. metricSettingContext &&
  106. metricSettingContext.metricSettingState !==
  107. MEPState.TRANSACTIONS_ONLY && (
  108. <SampledTag
  109. tooltipText={t('This widget is only applicable to indexed events.')}
  110. >
  111. {t('Indexed')}
  112. </SampledTag>
  113. )}
  114. {title && (
  115. <Tooltip
  116. title={
  117. <span>
  118. <WidgetTooltipTitle>{title}</WidgetTooltipTitle>
  119. {description && (
  120. <WidgetTooltipDescription>
  121. {description}
  122. </WidgetTooltipDescription>
  123. )}
  124. </span>
  125. }
  126. containerDisplayMode="grid"
  127. isHoverable
  128. >
  129. <WidgetTooltipButton
  130. aria-label={t('Widget description')}
  131. borderless
  132. size="xs"
  133. icon={<IconInfo />}
  134. />
  135. </Tooltip>
  136. )}
  137. <StyledDropdownMenuControl
  138. items={[
  139. {
  140. key: 'preview',
  141. label: t(
  142. 'This is a preview only. To edit, you must add this dashboard.'
  143. ),
  144. },
  145. ]}
  146. triggerProps={{
  147. 'aria-label': t('Widget actions'),
  148. size: 'xs',
  149. borderless: true,
  150. showChevron: false,
  151. icon: <IconEllipsis direction="down" size="sm" />,
  152. }}
  153. position="bottom-end"
  154. disabledKeys={[...disabledKeys, 'preview']}
  155. />
  156. <Button
  157. aria-label={t('Open Widget Viewer')}
  158. borderless
  159. size="xs"
  160. icon={<IconExpand />}
  161. onClick={() => {
  162. (seriesData || tableData) &&
  163. setData({
  164. seriesData,
  165. tableData,
  166. pageLinks,
  167. totalIssuesCount,
  168. seriesResultsType,
  169. });
  170. openWidgetViewerPath(index);
  171. }}
  172. />
  173. </ContextWrapper>
  174. )}
  175. </MEPConsumer>
  176. )}
  177. </WidgetViewerContext.Consumer>
  178. );
  179. }
  180. if (
  181. organization.features.includes('discover-basic') &&
  182. widget.widgetType &&
  183. [WidgetType.DISCOVER, WidgetType.ERRORS, WidgetType.TRANSACTIONS].includes(
  184. widget.widgetType
  185. )
  186. ) {
  187. const optionDisabled =
  188. (hasDatasetSelector(organization) && widget.widgetType === WidgetType.DISCOVER) ||
  189. isUsingPerformanceScore(widget);
  190. // Open Widget in Discover
  191. if (widget.queries.length) {
  192. const discoverPath = getWidgetDiscoverUrl(
  193. widget,
  194. selection,
  195. organization,
  196. 0,
  197. isMetricsData
  198. );
  199. menuOptions.push({
  200. key: 'open-in-discover',
  201. label: t('Open in Discover'),
  202. to: optionDisabled
  203. ? undefined
  204. : widget.queries.length === 1
  205. ? discoverPath
  206. : undefined,
  207. tooltip: isUsingPerformanceScore(widget)
  208. ? performanceScoreTooltip
  209. : t(
  210. 'We are splitting datasets to make them easier to digest. Please confirm the dataset for this widget by clicking Edit Widget.'
  211. ),
  212. tooltipOptions: {disabled: !optionDisabled},
  213. disabled: optionDisabled,
  214. showDetailsInOverlay: true,
  215. onAction: () => {
  216. if (widget.queries.length === 1) {
  217. trackAnalytics('dashboards_views.open_in_discover.opened', {
  218. organization,
  219. widget_type: widget.displayType,
  220. });
  221. return;
  222. }
  223. trackAnalytics('dashboards_views.query_selector.opened', {
  224. organization,
  225. widget_type: widget.displayType,
  226. });
  227. openDashboardWidgetQuerySelectorModal({organization, widget, isMetricsData});
  228. },
  229. });
  230. }
  231. }
  232. if (widget.widgetType === WidgetType.ISSUE) {
  233. const issuesLocation = getWidgetIssueUrl(widget, selection, organization);
  234. menuOptions.push({
  235. key: 'open-in-issues',
  236. label: t('Open in Issues'),
  237. to: issuesLocation,
  238. });
  239. }
  240. if (widget.widgetType === WidgetType.METRICS) {
  241. const metricsLocation = getWidgetMetricsUrl(widget, selection, organization);
  242. menuOptions.push({
  243. key: 'open-in-metrics',
  244. label: t('Open in Metrics'),
  245. to: metricsLocation,
  246. });
  247. }
  248. if (organization.features.includes('dashboards-edit')) {
  249. menuOptions.push({
  250. key: 'duplicate-widget',
  251. label: t('Duplicate Widget'),
  252. onAction: () => onDuplicate?.(),
  253. });
  254. widgetLimitReached && disabledKeys.push('duplicate-widget');
  255. menuOptions.push({
  256. key: 'edit-widget',
  257. label: t('Edit Widget'),
  258. onAction: () => onEdit?.(),
  259. });
  260. menuOptions.push({
  261. key: 'delete-widget',
  262. label: t('Delete Widget'),
  263. priority: 'danger',
  264. onAction: () => {
  265. openConfirmModal({
  266. message: t('Are you sure you want to delete this widget?'),
  267. priority: 'danger',
  268. onConfirm: () => onDelete?.(),
  269. });
  270. },
  271. });
  272. }
  273. if (!menuOptions.length) {
  274. return null;
  275. }
  276. return (
  277. <WidgetViewerContext.Consumer>
  278. {({setData}) => (
  279. <MEPConsumer>
  280. {metricSettingContext => (
  281. <ContextWrapper>
  282. {!organization.features.includes('performance-mep-bannerless-ui') &&
  283. isMetricsData === false &&
  284. metricSettingContext &&
  285. metricSettingContext.metricSettingState !==
  286. MEPState.TRANSACTIONS_ONLY && (
  287. <SampledTag
  288. tooltipText={t('This widget is only applicable to indexed events.')}
  289. >
  290. {t('Indexed')}
  291. </SampledTag>
  292. )}
  293. {title && (
  294. <Tooltip
  295. title={
  296. <span>
  297. <WidgetTooltipTitle>{title}</WidgetTooltipTitle>
  298. {description && (
  299. <WidgetTooltipDescription>{description}</WidgetTooltipDescription>
  300. )}
  301. </span>
  302. }
  303. containerDisplayMode="grid"
  304. isHoverable
  305. >
  306. <WidgetTooltipButton
  307. aria-label={t('Widget description')}
  308. borderless
  309. size="xs"
  310. icon={<IconInfo />}
  311. />
  312. </Tooltip>
  313. )}
  314. <StyledDropdownMenuControl
  315. items={menuOptions}
  316. triggerProps={{
  317. 'aria-label': t('Widget actions'),
  318. size: 'xs',
  319. borderless: true,
  320. showChevron: false,
  321. icon: <IconEllipsis direction="down" size="sm" />,
  322. }}
  323. position="bottom-end"
  324. disabledKeys={[...disabledKeys]}
  325. />
  326. <Button
  327. aria-label={t('Open Widget Viewer')}
  328. borderless
  329. size="xs"
  330. icon={<IconExpand />}
  331. onClick={() => {
  332. setData({
  333. seriesData,
  334. tableData,
  335. pageLinks,
  336. totalIssuesCount,
  337. seriesResultsType,
  338. });
  339. openWidgetViewerPath(widget.id ?? index);
  340. }}
  341. />
  342. </ContextWrapper>
  343. )}
  344. </MEPConsumer>
  345. )}
  346. </WidgetViewerContext.Consumer>
  347. );
  348. }
  349. export default WidgetCardContextMenu;
  350. const ContextWrapper = styled('div')`
  351. display: flex;
  352. align-items: center;
  353. height: ${space(3)};
  354. margin-left: ${space(1)};
  355. gap: ${space(0.25)};
  356. `;
  357. const StyledDropdownMenuControl = styled(DropdownMenu)`
  358. display: flex;
  359. & > button {
  360. z-index: auto;
  361. }
  362. `;
  363. const SampledTag = styled(Tag)`
  364. margin-right: ${space(0.5)};
  365. `;
  366. const WidgetTooltipTitle = styled('div')`
  367. font-weight: bold;
  368. font-size: ${p => p.theme.fontSizeMedium};
  369. text-align: left;
  370. `;
  371. const WidgetTooltipDescription = styled('div')`
  372. margin-top: ${space(0.5)};
  373. font-size: ${p => p.theme.fontSizeSmall};
  374. text-align: left;
  375. `;
  376. // We're using a button here to preserve tab accessibility
  377. const WidgetTooltipButton = styled(Button)`
  378. pointer-events: none;
  379. `;