widgetCardContextMenu.tsx 9.4 KB

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