widgetCardContextMenu.tsx 11 KB

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