widgetCardContextMenu.tsx 9.0 KB

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