widgetCardContextMenu.tsx 12 KB

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