widgetCardContextMenu.tsx 12 KB

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