metricQueryContextMenu.tsx 8.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292
  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. organization,
  157. source: 'ddm.configure-metric',
  158. metricExtractionRule: extractionRule,
  159. onSubmitSuccess: data => {
  160. // Keep the unit of the MRI in sync with the unit of the extraction rule
  161. // TODO: Remove this once we have a better way to handle this
  162. const newMRI = metricsQuery.mri.replace(/@.*$/, `@${data.unit}`);
  163. updateWidget(widgetIndex, {
  164. mri: newMRI,
  165. } as Partial<MetricsQueryWidget>);
  166. },
  167. });
  168. }
  169. }
  170. },
  171. },
  172. {
  173. leadingItems: [<IconClose key="icon" />],
  174. key: 'delete',
  175. label: t('Remove Metric'),
  176. disabled: !canDelete,
  177. onAction: () => {
  178. Sentry.metrics.increment('ddm.widget.delete');
  179. removeWidget(widgetIndex);
  180. },
  181. },
  182. ],
  183. [
  184. createAlert,
  185. organization,
  186. metricsQuery,
  187. createDashboardWidget,
  188. hasDashboardFeature,
  189. canDelete,
  190. duplicateWidget,
  191. widgetIndex,
  192. router,
  193. getExtractionRule,
  194. updateWidget,
  195. removeWidget,
  196. ]
  197. );
  198. if (!hasCustomMetrics(organization)) {
  199. return null;
  200. }
  201. return (
  202. <DropdownMenu
  203. items={items}
  204. triggerProps={{
  205. 'aria-label': t('Widget actions'),
  206. size: 'md',
  207. showChevron: false,
  208. icon: <IconEllipsis direction="down" size="sm" />,
  209. }}
  210. position="bottom-end"
  211. />
  212. );
  213. }
  214. function canCreateAlert(organization: Organization, metricsQuery: MetricsQuery) {
  215. return (
  216. organization.access.includes('alerts:write') &&
  217. metricsQuery.mri &&
  218. metricsQuery.aggregation &&
  219. !isCustomMeasurement(metricsQuery)
  220. );
  221. }
  222. export function getCreateAlert(organization: Organization, metricsQuery: MetricsQuery) {
  223. if (!canCreateAlert(organization, metricsQuery)) {
  224. return undefined;
  225. }
  226. return function () {
  227. openCreateAlertModal({metricsQuery: metricsQuery, organization});
  228. };
  229. }
  230. function useCreateDashboardWidget(
  231. organization: Organization,
  232. metricsQuery: MetricsQuery,
  233. displayType?: MetricDisplayType
  234. ) {
  235. const router = useRouter();
  236. const {resolveVirtualMRI} = useVirtualMetricsContext();
  237. const {selection} = usePageFilters();
  238. return useMemo(() => {
  239. if (
  240. !metricsQuery.mri ||
  241. !metricsQuery.aggregation ||
  242. isCustomMeasurement(metricsQuery)
  243. ) {
  244. return undefined;
  245. }
  246. const queryCopy = {...metricsQuery};
  247. if (isVirtualMetric(metricsQuery) && metricsQuery.condition) {
  248. const {mri, aggregation} = resolveVirtualMRI(
  249. metricsQuery.mri,
  250. metricsQuery.condition,
  251. metricsQuery.aggregation
  252. );
  253. queryCopy.mri = mri;
  254. queryCopy.aggregation = aggregation;
  255. }
  256. const widgetQuery = getWidgetQuery(queryCopy);
  257. const urlWidgetQuery = encodeWidgetQuery(widgetQuery);
  258. const widgetAsQueryParams = getWidgetAsQueryParams(
  259. selection,
  260. urlWidgetQuery,
  261. displayType
  262. );
  263. return () =>
  264. openAddToDashboardModal({
  265. organization,
  266. selection,
  267. widget: convertToDashboardWidget([queryCopy], displayType),
  268. router,
  269. widgetAsQueryParams,
  270. location: router.location,
  271. actions: ['add-and-open-dashboard', 'add-and-stay-on-current-page'],
  272. allowCreateNewDashboard: false,
  273. });
  274. }, [metricsQuery, selection, displayType, resolveVirtualMRI, organization, router]);
  275. }