widgetCardContextMenu.tsx 12 KB

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