widgetCardContextMenu.tsx 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333
  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 {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 {
  20. MEPConsumer,
  21. MEPState,
  22. } from 'sentry/utils/performance/contexts/metricsEnhancedSetting';
  23. import {
  24. getWidgetDiscoverUrl,
  25. getWidgetIssueUrl,
  26. getWidgetMetricsUrl,
  27. hasDatasetSelector,
  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. if (isPreview) {
  88. return (
  89. <WidgetViewerContext.Consumer>
  90. {({setData}) => (
  91. <MEPConsumer>
  92. {metricSettingContext => (
  93. <ContextWrapper>
  94. {!organization.features.includes('performance-mep-bannerless-ui') &&
  95. isMetricsData === false &&
  96. metricSettingContext &&
  97. metricSettingContext.metricSettingState !==
  98. MEPState.TRANSACTIONS_ONLY && (
  99. <SampledTag
  100. tooltipText={t('This widget is only applicable to indexed events.')}
  101. >
  102. {t('Indexed')}
  103. </SampledTag>
  104. )}
  105. <StyledDropdownMenuControl
  106. items={[
  107. {
  108. key: 'preview',
  109. label: t(
  110. 'This is a preview only. To edit, you must add this dashboard.'
  111. ),
  112. },
  113. ]}
  114. triggerProps={{
  115. 'aria-label': t('Widget actions'),
  116. size: 'xs',
  117. borderless: true,
  118. showChevron: false,
  119. icon: <IconEllipsis direction="down" size="sm" />,
  120. }}
  121. position="bottom-end"
  122. disabledKeys={[...disabledKeys, 'preview']}
  123. />
  124. <Button
  125. aria-label={t('Open Widget Viewer')}
  126. borderless
  127. size="xs"
  128. icon={<IconExpand />}
  129. onClick={() => {
  130. (seriesData || tableData) &&
  131. setData({
  132. seriesData,
  133. tableData,
  134. pageLinks,
  135. totalIssuesCount,
  136. seriesResultsType,
  137. });
  138. openWidgetViewerPath(index);
  139. }}
  140. />
  141. </ContextWrapper>
  142. )}
  143. </MEPConsumer>
  144. )}
  145. </WidgetViewerContext.Consumer>
  146. );
  147. }
  148. if (
  149. organization.features.includes('discover-basic') &&
  150. widget.widgetType &&
  151. [WidgetType.DISCOVER, WidgetType.ERRORS, WidgetType.TRANSACTIONS].includes(
  152. widget.widgetType
  153. )
  154. ) {
  155. const optionDisabled =
  156. hasDatasetSelector(organization) && widget.widgetType === WidgetType.DISCOVER;
  157. // Open Widget in Discover
  158. if (widget.queries.length) {
  159. const discoverPath = getWidgetDiscoverUrl(
  160. widget,
  161. selection,
  162. organization,
  163. 0,
  164. isMetricsData
  165. );
  166. menuOptions.push({
  167. key: 'open-in-discover',
  168. label: t('Open in Discover'),
  169. to: optionDisabled
  170. ? undefined
  171. : widget.queries.length === 1
  172. ? discoverPath
  173. : undefined,
  174. tooltip: t(
  175. 'We are splitting datasets to make them easier to digest. Please confirm the dataset for this widget by clicking Edit Widget.'
  176. ),
  177. tooltipOptions: {disabled: !optionDisabled},
  178. disabled: optionDisabled,
  179. showDetailsInOverlay: true,
  180. onAction: () => {
  181. if (widget.queries.length === 1) {
  182. trackAnalytics('dashboards_views.open_in_discover.opened', {
  183. organization,
  184. widget_type: widget.displayType,
  185. });
  186. return;
  187. }
  188. trackAnalytics('dashboards_views.query_selector.opened', {
  189. organization,
  190. widget_type: widget.displayType,
  191. });
  192. openDashboardWidgetQuerySelectorModal({organization, widget, isMetricsData});
  193. },
  194. });
  195. }
  196. }
  197. if (widget.widgetType === WidgetType.ISSUE) {
  198. const issuesLocation = getWidgetIssueUrl(widget, selection, organization);
  199. menuOptions.push({
  200. key: 'open-in-issues',
  201. label: t('Open in Issues'),
  202. to: issuesLocation,
  203. });
  204. }
  205. if (widget.widgetType === WidgetType.METRICS) {
  206. const metricsLocation = getWidgetMetricsUrl(widget, selection, organization);
  207. menuOptions.push({
  208. key: 'open-in-metrics',
  209. label: t('Open in Metrics'),
  210. to: metricsLocation,
  211. });
  212. }
  213. if (organization.features.includes('dashboards-edit')) {
  214. menuOptions.push({
  215. key: 'duplicate-widget',
  216. label: t('Duplicate Widget'),
  217. onAction: () => onDuplicate?.(),
  218. });
  219. widgetLimitReached && disabledKeys.push('duplicate-widget');
  220. menuOptions.push({
  221. key: 'edit-widget',
  222. label: t('Edit Widget'),
  223. onAction: () => onEdit?.(),
  224. });
  225. menuOptions.push({
  226. key: 'delete-widget',
  227. label: t('Delete Widget'),
  228. priority: 'danger',
  229. onAction: () => {
  230. openConfirmModal({
  231. message: t('Are you sure you want to delete this widget?'),
  232. priority: 'danger',
  233. onConfirm: () => onDelete?.(),
  234. });
  235. },
  236. });
  237. }
  238. if (!menuOptions.length) {
  239. return null;
  240. }
  241. return (
  242. <WidgetViewerContext.Consumer>
  243. {({setData}) => (
  244. <MEPConsumer>
  245. {metricSettingContext => (
  246. <ContextWrapper>
  247. {!organization.features.includes('performance-mep-bannerless-ui') &&
  248. isMetricsData === false &&
  249. metricSettingContext &&
  250. metricSettingContext.metricSettingState !==
  251. MEPState.TRANSACTIONS_ONLY && (
  252. <SampledTag
  253. tooltipText={t('This widget is only applicable to indexed events.')}
  254. >
  255. {t('Indexed')}
  256. </SampledTag>
  257. )}
  258. <StyledDropdownMenuControl
  259. items={menuOptions}
  260. triggerProps={{
  261. 'aria-label': t('Widget actions'),
  262. size: 'xs',
  263. borderless: true,
  264. showChevron: false,
  265. icon: <IconEllipsis direction="down" size="sm" />,
  266. }}
  267. position="bottom-end"
  268. disabledKeys={[...disabledKeys]}
  269. />
  270. <Button
  271. aria-label={t('Open Widget Viewer')}
  272. borderless
  273. size="xs"
  274. icon={<IconExpand />}
  275. onClick={() => {
  276. setData({
  277. seriesData,
  278. tableData,
  279. pageLinks,
  280. totalIssuesCount,
  281. seriesResultsType,
  282. });
  283. openWidgetViewerPath(widget.id ?? index);
  284. }}
  285. />
  286. </ContextWrapper>
  287. )}
  288. </MEPConsumer>
  289. )}
  290. </WidgetViewerContext.Consumer>
  291. );
  292. }
  293. export default WidgetCardContextMenu;
  294. const ContextWrapper = styled('div')`
  295. display: flex;
  296. align-items: center;
  297. height: ${space(3)};
  298. margin-left: ${space(1)};
  299. gap: ${space(0.25)};
  300. `;
  301. const StyledDropdownMenuControl = styled(DropdownMenu)`
  302. display: flex;
  303. & > button {
  304. z-index: auto;
  305. }
  306. `;
  307. const SampledTag = styled(Tag)`
  308. margin-right: ${space(0.5)};
  309. `;