metricListItemDetails.tsx 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274
  1. import {
  2. Fragment,
  3. startTransition,
  4. type SyntheticEvent,
  5. useEffect,
  6. useMemo,
  7. useState,
  8. } from 'react';
  9. import styled from '@emotion/styled';
  10. import {navigateTo} from 'sentry/actionCreators/navigation';
  11. import {Button, LinkButton} from 'sentry/components/button';
  12. import ProjectBadge from 'sentry/components/idBadge/projectBadge';
  13. import LoadingIndicator from 'sentry/components/loadingIndicator';
  14. import {IconSettings, IconWarning} from 'sentry/icons';
  15. import {t} from 'sentry/locale';
  16. import {space} from 'sentry/styles/space';
  17. import type {MetricMeta, MRI} from 'sentry/types/metrics';
  18. import type {Project} from 'sentry/types/project';
  19. import {getReadableMetricType} from 'sentry/utils/metrics/formatters';
  20. import {formatMRI, parseMRI} from 'sentry/utils/metrics/mri';
  21. import {
  22. getMetricsTagsQueryKey,
  23. useMetricsTags,
  24. } from 'sentry/utils/metrics/useMetricsTags';
  25. import {useQueryClient} from 'sentry/utils/queryClient';
  26. import useOrganization from 'sentry/utils/useOrganization';
  27. import useRouter from 'sentry/utils/useRouter';
  28. const MAX_PROJECTS_TO_SHOW = 3;
  29. const MAX_TAGS_TO_SHOW = 5;
  30. const STANDARD_TAGS = ['release', 'environment', 'transaction', 'project'];
  31. function stopPropagationAndPreventDefault(e: SyntheticEvent) {
  32. e.stopPropagation();
  33. e.preventDefault();
  34. }
  35. export function MetricListItemDetails({
  36. metric,
  37. selectedProjects,
  38. onTagClick,
  39. }: {
  40. metric: MetricMeta;
  41. onTagClick: (mri: MRI, tag: string) => void;
  42. selectedProjects: Project[];
  43. }) {
  44. const router = useRouter();
  45. const organization = useOrganization();
  46. const queryClient = useQueryClient();
  47. const isCustomMetric = parseMRI(metric.mri)?.useCase === 'custom';
  48. const [showAllTags, setShowAllTags] = useState(false);
  49. const [showAllProjects, setShowAllProjects] = useState(false);
  50. const projectIds = useMemo(
  51. () => selectedProjects.map(project => parseInt(project.id, 10)),
  52. [selectedProjects]
  53. );
  54. const [isQueryEnabled, setIsQueryEnabled] = useState(() => {
  55. // We only wnat to disable the query if there is no data in the cache
  56. const queryKey = getMetricsTagsQueryKey(organization, metric.mri, {
  57. projects: projectIds,
  58. });
  59. const data = queryClient.getQueryData(queryKey);
  60. return !!data;
  61. });
  62. const {data: tagsData = [], isLoading: tagsIsLoading} = useMetricsTags(
  63. // TODO: improve useMetricsTag interface
  64. isQueryEnabled ? metric.mri : undefined,
  65. {
  66. projects: projectIds,
  67. }
  68. );
  69. useEffect(() => {
  70. // Start querying tags after a short delay to avoid querying
  71. // for every metric if a user quickly hovers over them
  72. const timeout = setTimeout(() => {
  73. startTransition(() => setIsQueryEnabled(true));
  74. }, 200);
  75. return () => clearTimeout(timeout);
  76. }, []);
  77. const metricProjects = selectedProjects.filter(project =>
  78. metric.projectIds.includes(parseInt(project.id, 10))
  79. );
  80. const truncatedProjects = showAllProjects
  81. ? metricProjects
  82. : metricProjects.slice(0, MAX_PROJECTS_TO_SHOW);
  83. // Display custom tags first, then sort alphabetically
  84. const sortedTags = useMemo(
  85. () =>
  86. tagsData.toSorted((a, b) => {
  87. const aIsStandard = STANDARD_TAGS.includes(a.key);
  88. const bIsStandard = STANDARD_TAGS.includes(b.key);
  89. if (aIsStandard && !bIsStandard) {
  90. return 1;
  91. }
  92. if (!aIsStandard && bIsStandard) {
  93. return -1;
  94. }
  95. return a.key.localeCompare(b.key);
  96. }),
  97. [tagsData]
  98. );
  99. const truncatedTags = showAllTags ? sortedTags : sortedTags.slice(0, MAX_TAGS_TO_SHOW);
  100. const firstMetricProject = metricProjects[0];
  101. return (
  102. <DetailsWrapper
  103. // Stop propagation and default behaviour to keep the focus in the combobox
  104. onMouseDown={stopPropagationAndPreventDefault}
  105. >
  106. <Header>
  107. <MetricName>
  108. {/* Add zero width spaces at delimiter characters for nice word breaks */}
  109. {formatMRI(metric.mri).replaceAll(/([\.\/-_])/g, '\u200b$1')}
  110. {!isCustomMetric && (
  111. <SamplingWarning>
  112. <IconWarning color="yellow400" size="xs" />
  113. {t('Prone to client-side sampling')}
  114. </SamplingWarning>
  115. )}
  116. </MetricName>
  117. {isCustomMetric &&
  118. (firstMetricProject ? (
  119. <LinkButton
  120. size="xs"
  121. to={`/settings/projects/${firstMetricProject.slug}/metrics/${encodeURIComponent(metric.mri)}`}
  122. aria-label={t('Open metric settings')}
  123. icon={<IconSettings />}
  124. borderless
  125. />
  126. ) : (
  127. // TODO: figure out when we can end up in this case
  128. <Button
  129. size="xs"
  130. onClick={() =>
  131. navigateTo(
  132. `/settings/projects/:projectId/metrics/${encodeURIComponent(metric.mri)}`,
  133. router
  134. )
  135. }
  136. aria-label={t('Open metric settings')}
  137. icon={<IconSettings />}
  138. borderless
  139. />
  140. ))}
  141. </Header>
  142. <DetailsGrid>
  143. <DetailsLabel>{t('Project')}</DetailsLabel>
  144. <DetailsValue>
  145. {truncatedProjects.map(project => (
  146. <ProjectBadge project={project} key={project.slug} avatarSize={12} />
  147. ))}
  148. {metricProjects.length > MAX_PROJECTS_TO_SHOW && !showAllProjects && (
  149. <Button priority="link" onClick={() => setShowAllProjects(true)}>
  150. {t('+%d more', metricProjects.length - MAX_PROJECTS_TO_SHOW)}
  151. </Button>
  152. )}
  153. </DetailsValue>
  154. <DetailsLabel>{t('Type')}</DetailsLabel>
  155. <DetailsValue>{getReadableMetricType(metric.type)}</DetailsValue>
  156. <DetailsLabel>{t('Unit')}</DetailsLabel>
  157. <DetailsValue>{metric.unit}</DetailsValue>
  158. <DetailsLabel>{t('Tags')}</DetailsLabel>
  159. <DetailsValue>
  160. {tagsIsLoading || !isQueryEnabled ? (
  161. <StyledLoadingIndicator mini size={12} />
  162. ) : truncatedTags.length === 0 ? (
  163. t('(None)')
  164. ) : (
  165. <Fragment>
  166. {truncatedTags.map((tag, index) => {
  167. const shouldAddDelimiter = index < truncatedTags.length - 1;
  168. return (
  169. <Fragment key={tag.key}>
  170. <TagWrapper>
  171. <Button
  172. priority="link"
  173. onClick={() => onTagClick(metric.mri, tag.key)}
  174. >
  175. {tag.key}
  176. </Button>
  177. {/* Make the comma stick to the Button when the text wraps to the next line */}
  178. {shouldAddDelimiter ? ',' : null}
  179. </TagWrapper>
  180. {shouldAddDelimiter ? ' ' : null}
  181. </Fragment>
  182. );
  183. })}
  184. <br />
  185. {tagsData.length > MAX_TAGS_TO_SHOW && !showAllTags && (
  186. <Button priority="link" onClick={() => setShowAllTags(true)}>
  187. {t('+%d more', tagsData.length - MAX_TAGS_TO_SHOW)}
  188. </Button>
  189. )}
  190. </Fragment>
  191. )}
  192. </DetailsValue>
  193. </DetailsGrid>
  194. </DetailsWrapper>
  195. );
  196. }
  197. const DetailsWrapper = styled('div')`
  198. width: 300px;
  199. line-height: 1.4;
  200. `;
  201. const Header = styled('div')`
  202. display: grid;
  203. grid-template-columns: 1fr max-content;
  204. align-items: center;
  205. gap: ${space(0.5)};
  206. padding: ${space(0.75)} ${space(0.25)} ${space(0.75)} ${space(1.5)};
  207. `;
  208. const MetricName = styled('div')`
  209. word-break: break-word;
  210. `;
  211. const DetailsGrid = styled('div')`
  212. display: grid;
  213. grid-template-columns: max-content 1fr;
  214. & > div:nth-child(4n + 1),
  215. & > div:nth-child(4n + 2) {
  216. background-color: ${p => p.theme.backgroundSecondary};
  217. }
  218. `;
  219. const StyledLoadingIndicator = styled(LoadingIndicator)`
  220. && {
  221. margin: ${space(0.75)} 0 0;
  222. height: 12px;
  223. width: 12px;
  224. }
  225. `;
  226. const DetailsLabel = styled('div')`
  227. color: ${p => p.theme.subText};
  228. padding: ${space(0.75)} ${space(1)} ${space(0.75)} ${space(1.5)};
  229. border-top-left-radius: ${p => p.theme.borderRadius};
  230. border-bottom-left-radius: ${p => p.theme.borderRadius};
  231. `;
  232. const DetailsValue = styled('div')`
  233. white-space: pre-wrap;
  234. padding: ${space(0.75)} ${space(1.5)} ${space(0.75)} ${space(1)};
  235. border-top-right-radius: ${p => p.theme.borderRadius};
  236. border-bottom-right-radius: ${p => p.theme.borderRadius};
  237. min-width: 0;
  238. `;
  239. const TagWrapper = styled('span')`
  240. white-space: nowrap;
  241. `;
  242. const SamplingWarning = styled('div')`
  243. display: grid;
  244. gap: ${space(0.25)};
  245. grid-template-columns: max-content 1fr;
  246. font-size: ${p => p.theme.fontSizeSmall};
  247. color: ${p => p.theme.yellow400};
  248. align-items: center;
  249. `;