metricQueryContextMenu.tsx 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290
  1. import {useMemo} from 'react';
  2. import * as Sentry from '@sentry/react';
  3. import {openAddToDashboardModal} 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. type MetricsQueryWidget,
  35. } from 'sentry/utils/metrics/types';
  36. import {useVirtualMetricsContext} from 'sentry/utils/metrics/virtualMetricsContext';
  37. import useOrganization from 'sentry/utils/useOrganization';
  38. import usePageFilters from 'sentry/utils/usePageFilters';
  39. import useRouter from 'sentry/utils/useRouter';
  40. import {useMetricsContext} from 'sentry/views/metrics/context';
  41. import {openCreateAlertModal} from 'sentry/views/metrics/createAlertModal';
  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, updateWidget} = useMetricsContext();
  57. const createAlert = getCreateAlert(organization, metricsQuery);
  58. const createDashboardWidget = useCreateDashboardWidget(
  59. organization,
  60. metricsQuery,
  61. displayType
  62. );
  63. // At least one query must remain
  64. const canDelete = widgets.filter(isMetricsQueryWidget).length > 1;
  65. const hasDashboardFeature = organization.features.includes('dashboards-edit');
  66. const items = useMemo<MenuItemProps[]>(
  67. () => [
  68. {
  69. leadingItems: [<IconCopy key="icon" />],
  70. key: 'duplicate',
  71. label: t('Duplicate'),
  72. onAction: () => {
  73. trackAnalytics('ddm.widget.duplicate', {
  74. organization,
  75. });
  76. Sentry.metrics.increment('ddm.widget.duplicate');
  77. duplicateWidget(widgetIndex);
  78. },
  79. },
  80. {
  81. leadingItems: [<IconSiren key="icon" />],
  82. key: 'add-alert',
  83. label: <CreateMetricAlertFeature>{t('Create Alert')}</CreateMetricAlertFeature>,
  84. disabled: !createAlert || !hasMetricAlertFeature(organization),
  85. onAction: () => {
  86. trackAnalytics('ddm.create-alert', {
  87. organization,
  88. source: 'widget',
  89. });
  90. Sentry.metrics.increment('ddm.widget.alert');
  91. createAlert?.();
  92. },
  93. },
  94. {
  95. leadingItems: [<IconDashboard key="icon" />],
  96. key: 'add-dashboard',
  97. label: (
  98. <Feature
  99. organization={organization}
  100. hookName="feature-disabled:dashboards-edit"
  101. features="dashboards-edit"
  102. renderDisabled={p => (
  103. <Hovercard
  104. body={
  105. <FeatureDisabled
  106. features={p.features}
  107. hideHelpToggle
  108. featureName={t('Metric Alerts')}
  109. />
  110. }
  111. >
  112. {typeof p.children === 'function' ? p.children(p) : p.children}
  113. </Hovercard>
  114. )}
  115. >
  116. <span>{t('Add to Dashboard')}</span>
  117. </Feature>
  118. ),
  119. disabled: !createDashboardWidget || !hasDashboardFeature,
  120. onAction: () => {
  121. if (!organization.features.includes('dashboards-edit')) {
  122. return;
  123. }
  124. trackAnalytics('ddm.add-to-dashboard', {
  125. organization,
  126. source: 'widget',
  127. });
  128. Sentry.metrics.increment('ddm.widget.dashboard');
  129. createDashboardWidget?.();
  130. },
  131. },
  132. {
  133. leadingItems: [<IconSettings key="icon" />],
  134. key: 'settings',
  135. disabled: !isCustomMetric({mri: metricsQuery.mri}),
  136. label: t('Configure Metric'),
  137. onAction: () => {
  138. trackAnalytics('ddm.widget.settings', {
  139. organization,
  140. });
  141. Sentry.metrics.increment('ddm.widget.settings');
  142. if (!isVirtualMetric(metricsQuery)) {
  143. navigateTo(
  144. `/settings/projects/:projectId/metrics/${encodeURIComponent(
  145. metricsQuery.mri
  146. )}`,
  147. router
  148. );
  149. } else {
  150. const extractionRule = getExtractionRule(
  151. metricsQuery.mri,
  152. metricsQuery.condition!
  153. );
  154. if (extractionRule) {
  155. openExtractionRuleEditModal({
  156. metricExtractionRule: extractionRule,
  157. onSubmitSuccess: data => {
  158. // Keep the unit of the MRI in sync with the unit of the extraction rule
  159. // TODO: Remove this once we have a better way to handle this
  160. const newMRI = metricsQuery.mri.replace(/@.*$/, `@${data.unit}`);
  161. updateWidget(widgetIndex, {
  162. mri: newMRI,
  163. } as Partial<MetricsQueryWidget>);
  164. },
  165. });
  166. }
  167. }
  168. },
  169. },
  170. {
  171. leadingItems: [<IconClose key="icon" />],
  172. key: 'delete',
  173. label: t('Remove Metric'),
  174. disabled: !canDelete,
  175. onAction: () => {
  176. Sentry.metrics.increment('ddm.widget.delete');
  177. removeWidget(widgetIndex);
  178. },
  179. },
  180. ],
  181. [
  182. createAlert,
  183. organization,
  184. metricsQuery,
  185. createDashboardWidget,
  186. hasDashboardFeature,
  187. canDelete,
  188. duplicateWidget,
  189. widgetIndex,
  190. router,
  191. getExtractionRule,
  192. updateWidget,
  193. removeWidget,
  194. ]
  195. );
  196. if (!hasCustomMetrics(organization)) {
  197. return null;
  198. }
  199. return (
  200. <DropdownMenu
  201. items={items}
  202. triggerProps={{
  203. 'aria-label': t('Widget actions'),
  204. size: 'md',
  205. showChevron: false,
  206. icon: <IconEllipsis direction="down" size="sm" />,
  207. }}
  208. position="bottom-end"
  209. />
  210. );
  211. }
  212. function canCreateAlert(organization: Organization, metricsQuery: MetricsQuery) {
  213. return (
  214. organization.access.includes('alerts:write') &&
  215. metricsQuery.mri &&
  216. metricsQuery.aggregation &&
  217. !isCustomMeasurement(metricsQuery)
  218. );
  219. }
  220. export function getCreateAlert(organization: Organization, metricsQuery: MetricsQuery) {
  221. if (!canCreateAlert(organization, metricsQuery)) {
  222. return undefined;
  223. }
  224. return function () {
  225. openCreateAlertModal({metricsQuery: metricsQuery, organization});
  226. };
  227. }
  228. function useCreateDashboardWidget(
  229. organization: Organization,
  230. metricsQuery: MetricsQuery,
  231. displayType?: MetricDisplayType
  232. ) {
  233. const router = useRouter();
  234. const {resolveVirtualMRI} = useVirtualMetricsContext();
  235. const {selection} = usePageFilters();
  236. return useMemo(() => {
  237. if (
  238. !metricsQuery.mri ||
  239. !metricsQuery.aggregation ||
  240. isCustomMeasurement(metricsQuery)
  241. ) {
  242. return undefined;
  243. }
  244. const queryCopy = {...metricsQuery};
  245. if (isVirtualMetric(metricsQuery) && metricsQuery.condition) {
  246. const {mri, aggregation} = resolveVirtualMRI(
  247. metricsQuery.mri,
  248. metricsQuery.condition,
  249. metricsQuery.aggregation
  250. );
  251. queryCopy.mri = mri;
  252. queryCopy.aggregation = aggregation;
  253. }
  254. const widgetQuery = getWidgetQuery(queryCopy);
  255. const urlWidgetQuery = encodeWidgetQuery(widgetQuery);
  256. const widgetAsQueryParams = getWidgetAsQueryParams(
  257. selection,
  258. urlWidgetQuery,
  259. displayType
  260. );
  261. return () =>
  262. openAddToDashboardModal({
  263. organization,
  264. selection,
  265. widget: convertToDashboardWidget([queryCopy], displayType),
  266. router,
  267. widgetAsQueryParams,
  268. location: router.location,
  269. actions: ['add-and-open-dashboard', 'add-and-stay-on-current-page'],
  270. allowCreateNewDashboard: false,
  271. });
  272. }, [metricsQuery, selection, displayType, resolveVirtualMRI, organization, router]);
  273. }