metricQueryContextMenu.tsx 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283
  1. import {useMemo} from 'react';
  2. import * as Sentry from '@sentry/react';
  3. import {openAddToDashboardModal, openModal} from 'sentry/actionCreators/modal';
  4. import {navigateTo} from 'sentry/actionCreators/navigation';
  5. import Feature from 'sentry/components/acl/feature';
  6. import FeatureDisabled from 'sentry/components/acl/featureDisabled';
  7. import type {MenuItemProps} from 'sentry/components/dropdownMenu';
  8. import {DropdownMenu} from 'sentry/components/dropdownMenu';
  9. import {Hovercard} from 'sentry/components/hovercard';
  10. import {CreateMetricAlertFeature} from 'sentry/components/metrics/createMetricAlertFeature';
  11. import {
  12. IconClose,
  13. IconCopy,
  14. IconDashboard,
  15. IconEllipsis,
  16. IconSettings,
  17. IconSiren,
  18. } from 'sentry/icons';
  19. import {t} from 'sentry/locale';
  20. import type {Organization} from 'sentry/types/organization';
  21. import {trackAnalytics} from 'sentry/utils/analytics';
  22. import {isCustomMeasurement, isCustomMetric, isVirtualMetric} from 'sentry/utils/metrics';
  23. import {
  24. convertToDashboardWidget,
  25. encodeWidgetQuery,
  26. getWidgetAsQueryParams,
  27. getWidgetQuery,
  28. } from 'sentry/utils/metrics/dashboard';
  29. import {hasCustomMetrics, hasMetricAlertFeature} from 'sentry/utils/metrics/features';
  30. import {
  31. isMetricsQueryWidget,
  32. type MetricDisplayType,
  33. type MetricsQuery,
  34. } from 'sentry/utils/metrics/types';
  35. import {useVirtualMetricsContext} from 'sentry/utils/metrics/virtualMetricsContext';
  36. import useOrganization from 'sentry/utils/useOrganization';
  37. import usePageFilters from 'sentry/utils/usePageFilters';
  38. import useRouter from 'sentry/utils/useRouter';
  39. import {useMetricsContext} from 'sentry/views/metrics/context';
  40. import {CreateAlertModal} from 'sentry/views/metrics/createAlertModal';
  41. import {OrganizationContext} from 'sentry/views/organizationContext';
  42. import {openExtractionRuleEditModal} from 'sentry/views/settings/projectMetrics/metricsExtractionRuleEditModal';
  43. type ContextMenuProps = {
  44. displayType: MetricDisplayType;
  45. metricsQuery: MetricsQuery;
  46. widgetIndex: number;
  47. };
  48. export function MetricQueryContextMenu({
  49. metricsQuery,
  50. displayType,
  51. widgetIndex,
  52. }: ContextMenuProps) {
  53. const {getExtractionRule} = useVirtualMetricsContext();
  54. const organization = useOrganization();
  55. const router = useRouter();
  56. const {removeWidget, duplicateWidget, widgets} = useMetricsContext();
  57. const createAlert = useMemo(
  58. () => getCreateAlert(organization, metricsQuery),
  59. [metricsQuery, organization]
  60. );
  61. const createDashboardWidget = useCreateDashboardWidget(
  62. organization,
  63. metricsQuery,
  64. displayType
  65. );
  66. // At least one query must remain
  67. const canDelete = widgets.filter(isMetricsQueryWidget).length > 1;
  68. const hasDashboardFeature = organization.features.includes('dashboards-edit');
  69. const items = useMemo<MenuItemProps[]>(
  70. () => [
  71. {
  72. leadingItems: [<IconCopy key="icon" />],
  73. key: 'duplicate',
  74. label: t('Duplicate'),
  75. onAction: () => {
  76. trackAnalytics('ddm.widget.duplicate', {
  77. organization,
  78. });
  79. Sentry.metrics.increment('ddm.widget.duplicate');
  80. duplicateWidget(widgetIndex);
  81. },
  82. },
  83. {
  84. leadingItems: [<IconSiren key="icon" />],
  85. key: 'add-alert',
  86. label: <CreateMetricAlertFeature>{t('Create Alert')}</CreateMetricAlertFeature>,
  87. disabled:
  88. !createAlert ||
  89. !hasMetricAlertFeature(organization) ||
  90. isVirtualMetric(metricsQuery),
  91. onAction: () => {
  92. trackAnalytics('ddm.create-alert', {
  93. organization,
  94. source: 'widget',
  95. });
  96. Sentry.metrics.increment('ddm.widget.alert');
  97. createAlert?.();
  98. },
  99. },
  100. {
  101. leadingItems: [<IconDashboard key="icon" />],
  102. key: 'add-dashboard',
  103. label: (
  104. <Feature
  105. organization={organization}
  106. hookName="feature-disabled:dashboards-edit"
  107. features="dashboards-edit"
  108. renderDisabled={p => (
  109. <Hovercard
  110. body={
  111. <FeatureDisabled
  112. features={p.features}
  113. hideHelpToggle
  114. featureName={t('Metric Alerts')}
  115. />
  116. }
  117. >
  118. {typeof p.children === 'function' ? p.children(p) : p.children}
  119. </Hovercard>
  120. )}
  121. >
  122. <span>{t('Add to Dashboard')}</span>
  123. </Feature>
  124. ),
  125. disabled: !createDashboardWidget || !hasDashboardFeature,
  126. onAction: () => {
  127. if (!organization.features.includes('dashboards-edit')) {
  128. return;
  129. }
  130. trackAnalytics('ddm.add-to-dashboard', {
  131. organization,
  132. source: 'widget',
  133. });
  134. Sentry.metrics.increment('ddm.widget.dashboard');
  135. createDashboardWidget?.();
  136. },
  137. },
  138. {
  139. leadingItems: [<IconSettings key="icon" />],
  140. key: 'settings',
  141. disabled: !isCustomMetric({mri: metricsQuery.mri}),
  142. label: t('Metric Settings'),
  143. onAction: () => {
  144. trackAnalytics('ddm.widget.settings', {
  145. organization,
  146. });
  147. Sentry.metrics.increment('ddm.widget.settings');
  148. if (!isVirtualMetric(metricsQuery)) {
  149. navigateTo(
  150. `/settings/projects/:projectId/metrics/${encodeURIComponent(
  151. metricsQuery.mri
  152. )}`,
  153. router
  154. );
  155. } else {
  156. const extractionRule = getExtractionRule(metricsQuery.mri);
  157. if (extractionRule) {
  158. openExtractionRuleEditModal({
  159. metricExtractionRule: extractionRule,
  160. });
  161. }
  162. }
  163. },
  164. },
  165. {
  166. leadingItems: [<IconClose key="icon" />],
  167. key: 'delete',
  168. label: t('Remove Metric'),
  169. disabled: !canDelete,
  170. onAction: () => {
  171. Sentry.metrics.increment('ddm.widget.delete');
  172. removeWidget(widgetIndex);
  173. },
  174. },
  175. ],
  176. [
  177. createAlert,
  178. organization,
  179. metricsQuery,
  180. createDashboardWidget,
  181. hasDashboardFeature,
  182. canDelete,
  183. duplicateWidget,
  184. widgetIndex,
  185. router,
  186. getExtractionRule,
  187. removeWidget,
  188. ]
  189. );
  190. if (!hasCustomMetrics(organization)) {
  191. return null;
  192. }
  193. return (
  194. <DropdownMenu
  195. items={items}
  196. triggerProps={{
  197. 'aria-label': t('Widget actions'),
  198. size: 'md',
  199. showChevron: false,
  200. icon: <IconEllipsis direction="down" size="sm" />,
  201. }}
  202. position="bottom-end"
  203. />
  204. );
  205. }
  206. export function getCreateAlert(organization: Organization, metricsQuery: MetricsQuery) {
  207. if (
  208. !metricsQuery.mri ||
  209. !metricsQuery.aggregation ||
  210. isCustomMeasurement(metricsQuery) ||
  211. !organization.access.includes('alerts:write')
  212. ) {
  213. return undefined;
  214. }
  215. return function () {
  216. return openModal(deps => (
  217. <OrganizationContext.Provider value={organization}>
  218. <CreateAlertModal metricsQuery={metricsQuery} {...deps} />
  219. </OrganizationContext.Provider>
  220. ));
  221. };
  222. }
  223. export function useCreateDashboardWidget(
  224. organization: Organization,
  225. metricsQuery: MetricsQuery,
  226. displayType?: MetricDisplayType
  227. ) {
  228. const router = useRouter();
  229. const {resolveVirtualMRI} = useVirtualMetricsContext();
  230. const {selection} = usePageFilters();
  231. return useMemo(() => {
  232. if (
  233. !metricsQuery.mri ||
  234. !metricsQuery.aggregation ||
  235. isCustomMeasurement(metricsQuery)
  236. ) {
  237. return undefined;
  238. }
  239. const queryCopy = {...metricsQuery};
  240. if (isVirtualMetric(metricsQuery) && metricsQuery.condition) {
  241. const {mri, aggregation} = resolveVirtualMRI(
  242. metricsQuery.mri,
  243. metricsQuery.condition,
  244. metricsQuery.aggregation
  245. );
  246. queryCopy.mri = mri;
  247. queryCopy.aggregation = aggregation;
  248. }
  249. const widgetQuery = getWidgetQuery(queryCopy);
  250. const urlWidgetQuery = encodeWidgetQuery(widgetQuery);
  251. const widgetAsQueryParams = getWidgetAsQueryParams(
  252. selection,
  253. urlWidgetQuery,
  254. displayType
  255. );
  256. return () =>
  257. openAddToDashboardModal({
  258. organization,
  259. selection,
  260. widget: convertToDashboardWidget([queryCopy], displayType),
  261. router,
  262. widgetAsQueryParams,
  263. location: router.location,
  264. actions: ['add-and-open-dashboard', 'add-and-stay-on-current-page'],
  265. allowCreateNewDashboard: false,
  266. });
  267. }, [metricsQuery, selection, displayType, resolveVirtualMRI, organization, router]);
  268. }