projectsTable.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454
  1. import {Fragment, memo, useCallback, useState} from 'react';
  2. import styled from '@emotion/styled';
  3. import {hasEveryAccess} from 'sentry/components/acl/access';
  4. import {LinkButton} from 'sentry/components/button';
  5. import ProjectBadge from 'sentry/components/idBadge/projectBadge';
  6. import {PanelTable} from 'sentry/components/panels/panelTable';
  7. import {Tooltip} from 'sentry/components/tooltip';
  8. import {IconArrow, IconChevron, IconSettings} from 'sentry/icons';
  9. import {t} from 'sentry/locale';
  10. import {space} from 'sentry/styles/space';
  11. import type {Project} from 'sentry/types/project';
  12. import {formatAbbreviatedNumber} from 'sentry/utils/formatters';
  13. import oxfordizeArray from 'sentry/utils/oxfordizeArray';
  14. import useOrganization from 'sentry/utils/useOrganization';
  15. import {PercentInput} from 'sentry/views/settings/dynamicSampling/percentInput';
  16. import {parsePercent} from 'sentry/views/settings/dynamicSampling/utils/parsePercent';
  17. interface ProjectItem {
  18. count: number;
  19. initialSampleRate: string;
  20. ownCount: number;
  21. project: Project;
  22. sampleRate: string;
  23. subProjects: SubProject[];
  24. error?: string;
  25. }
  26. interface Props extends Omit<React.ComponentProps<typeof StyledPanelTable>, 'headers'> {
  27. items: ProjectItem[];
  28. rateHeader: React.ReactNode;
  29. canEdit?: boolean;
  30. inactiveItems?: ProjectItem[];
  31. inputTooltip?: string;
  32. onChange?: (projectId: string, value: string) => void;
  33. }
  34. const COLUMN_COUNT = 4;
  35. export function ProjectsTable({
  36. items,
  37. inactiveItems = [],
  38. inputTooltip,
  39. canEdit,
  40. rateHeader,
  41. onChange,
  42. ...props
  43. }: Props) {
  44. const [tableSort, setTableSort] = useState<'asc' | 'desc'>('desc');
  45. const handleTableSort = useCallback(() => {
  46. setTableSort(value => (value === 'asc' ? 'desc' : 'asc'));
  47. }, []);
  48. const [isExpanded, setIsExpanded] = useState(false);
  49. const hasActiveItems = items.length > 0;
  50. const mainItems = hasActiveItems ? items : inactiveItems;
  51. return (
  52. <StyledPanelTable
  53. {...props}
  54. isEmpty={!items.length && !inactiveItems.length}
  55. headers={[
  56. t('Project'),
  57. <SortableHeader type="button" key="spans" onClick={handleTableSort}>
  58. {t('Sent Spans')}
  59. <IconArrow direction={tableSort === 'desc' ? 'down' : 'up'} size="xs" />
  60. </SortableHeader>,
  61. t('Stored Spans'),
  62. rateHeader,
  63. ]}
  64. >
  65. {mainItems
  66. .toSorted((a, b) => {
  67. if (a.count === b.count) {
  68. return a.project.slug.localeCompare(b.project.slug);
  69. }
  70. if (tableSort === 'asc') {
  71. return a.count - b.count;
  72. }
  73. return b.count - a.count;
  74. })
  75. .map(item => (
  76. <TableRow
  77. key={item.project.id}
  78. canEdit={canEdit}
  79. onChange={onChange}
  80. inputTooltip={inputTooltip}
  81. {...item}
  82. />
  83. ))}
  84. {hasActiveItems && inactiveItems.length > 0 && (
  85. <SectionHeader
  86. isExpanded={isExpanded}
  87. setIsExpanded={setIsExpanded}
  88. title={
  89. inactiveItems.length > 1
  90. ? t(`+%d Inactive Projects`, inactiveItems.length)
  91. : t(`+1 Inactive Project`)
  92. }
  93. />
  94. )}
  95. {hasActiveItems &&
  96. isExpanded &&
  97. inactiveItems
  98. .toSorted((a, b) => a.project.slug.localeCompare(b.project.slug))
  99. .map(item => (
  100. <TableRow
  101. key={item.project.id}
  102. canEdit={canEdit}
  103. onChange={onChange}
  104. {...item}
  105. />
  106. ))}
  107. </StyledPanelTable>
  108. );
  109. }
  110. interface SubProject {
  111. count: number;
  112. slug: string;
  113. }
  114. function SectionHeader({
  115. isExpanded,
  116. setIsExpanded,
  117. title,
  118. }: {
  119. isExpanded: boolean;
  120. setIsExpanded: React.Dispatch<React.SetStateAction<boolean>>;
  121. title: React.ReactNode;
  122. }) {
  123. return (
  124. <Fragment>
  125. <SectionHeaderCell
  126. role="button"
  127. tabIndex={0}
  128. onClick={() => setIsExpanded(value => !value)}
  129. aria-label={
  130. isExpanded ? t('Collapse inactive projects') : t('Expand inactive projects')
  131. }
  132. onKeyDown={e => {
  133. if (e.key === 'Enter' || e.key === ' ') {
  134. setIsExpanded(value => !value);
  135. }
  136. }}
  137. >
  138. <StyledIconChevron direction={isExpanded ? 'down' : 'right'} />
  139. {title}
  140. </SectionHeaderCell>
  141. {/* As the main element spans COLUMN_COUNT grid colums we need to ensure that nth child css selectors of other elements
  142. remain functional by adding hidden elements */}
  143. {Array.from({length: COLUMN_COUNT - 1}).map((_, i) => (
  144. <div key={i} style={{display: 'none'}} />
  145. ))}
  146. </Fragment>
  147. );
  148. }
  149. function getSubProjectContent(
  150. ownSlug: string,
  151. subProjects: SubProject[],
  152. isExpanded: boolean
  153. ) {
  154. let subProjectContent: React.ReactNode = t('No distributed traces');
  155. if (subProjects.length > 1) {
  156. const truncatedSubProjects = subProjects.slice(0, MAX_PROJECTS_COLLAPSED);
  157. const overflowCount = subProjects.length - MAX_PROJECTS_COLLAPSED;
  158. const moreTranslation = t('+%d more', overflowCount);
  159. const stringifiedSubProjects =
  160. overflowCount > 0
  161. ? `${truncatedSubProjects.map(p => p.slug).join(', ')}, ${moreTranslation}`
  162. : oxfordizeArray(truncatedSubProjects.map(p => p.slug));
  163. subProjectContent = isExpanded ? (
  164. <Fragment>
  165. <div>{ownSlug}</div>
  166. {subProjects.map(subProject => (
  167. <div key={subProject.slug}>{subProject.slug}</div>
  168. ))}
  169. </Fragment>
  170. ) : (
  171. t('Including spans in ') + stringifiedSubProjects
  172. );
  173. }
  174. return subProjectContent;
  175. }
  176. function getSubSpansContent(
  177. ownCount: number,
  178. subProjects: SubProject[],
  179. isExpanded: boolean
  180. ) {
  181. let subSpansContent: React.ReactNode = '';
  182. if (subProjects.length > 1) {
  183. const subProjectSum = subProjects.reduce(
  184. (acc, subProject) => acc + subProject.count,
  185. 0
  186. );
  187. subSpansContent = isExpanded ? (
  188. <Fragment>
  189. <div>{formatAbbreviatedNumber(ownCount, 2)}</div>
  190. {subProjects.map(subProject => (
  191. <div key={subProject.slug}>{formatAbbreviatedNumber(subProject.count)}</div>
  192. ))}
  193. </Fragment>
  194. ) : (
  195. formatAbbreviatedNumber(subProjectSum)
  196. );
  197. }
  198. return subSpansContent;
  199. }
  200. const MAX_PROJECTS_COLLAPSED = 3;
  201. const TableRow = memo(function TableRow({
  202. project,
  203. canEdit,
  204. count,
  205. ownCount,
  206. sampleRate,
  207. initialSampleRate,
  208. subProjects,
  209. error,
  210. inputTooltip,
  211. onChange,
  212. }: {
  213. count: number;
  214. initialSampleRate: string;
  215. ownCount: number;
  216. project: Project;
  217. sampleRate: string;
  218. subProjects: SubProject[];
  219. canEdit?: boolean;
  220. error?: string;
  221. inputTooltip?: string;
  222. onChange?: (projectId: string, value: string) => void;
  223. }) {
  224. const organization = useOrganization();
  225. const [isExpanded, setIsExpanded] = useState(false);
  226. const isExpandable = subProjects.length > 0;
  227. const hasAccess = hasEveryAccess(['project:write'], {organization, project});
  228. const subProjectContent = getSubProjectContent(project.slug, subProjects, isExpanded);
  229. const subSpansContent = getSubSpansContent(ownCount, subProjects, isExpanded);
  230. const handleChange = useCallback(
  231. (event: React.ChangeEvent<HTMLInputElement>) => {
  232. onChange?.(project.id, event.target.value);
  233. },
  234. [onChange, project.id]
  235. );
  236. const storedSpans = Math.floor(count * parsePercent(sampleRate));
  237. const initialStoredSpans = Math.floor(count * parsePercent(initialSampleRate));
  238. return (
  239. <Fragment key={project.slug}>
  240. <Cell>
  241. <FirstCellLine data-has-chevron={isExpandable}>
  242. <HiddenButton
  243. type="button"
  244. disabled={!isExpandable}
  245. aria-label={isExpanded ? t('Collapse') : t('Expand')}
  246. onClick={() => setIsExpanded(value => !value)}
  247. >
  248. {isExpandable && (
  249. <StyledIconChevron direction={isExpanded ? 'down' : 'right'} />
  250. )}
  251. <ProjectBadge project={project} disableLink avatarSize={16} />
  252. </HiddenButton>
  253. {hasAccess && (
  254. <SettingsButton
  255. title={t('Open Project Settings')}
  256. aria-label={t('Open Project Settings')}
  257. size="xs"
  258. priority="link"
  259. icon={<IconSettings />}
  260. to={`/organizations/${organization.slug}/settings/projects/${project.slug}/performance`}
  261. />
  262. )}
  263. </FirstCellLine>
  264. <SubProjects>{subProjectContent}</SubProjects>
  265. </Cell>
  266. <Cell>
  267. <FirstCellLine data-align="right">{formatAbbreviatedNumber(count)}</FirstCellLine>
  268. <SubSpans>{subSpansContent}</SubSpans>
  269. </Cell>
  270. <Cell>
  271. <FirstCellLine data-align="right">
  272. {formatAbbreviatedNumber(storedSpans)}
  273. </FirstCellLine>
  274. <SubSpans>
  275. {storedSpans !== initialStoredSpans ? (
  276. <SmallPrint>
  277. {t('previous: %s', formatAbbreviatedNumber(initialStoredSpans))}
  278. </SmallPrint>
  279. ) : null}
  280. </SubSpans>
  281. </Cell>
  282. <Cell>
  283. <FirstCellLine>
  284. <Tooltip disabled={!inputTooltip} title={inputTooltip}>
  285. <PercentInput
  286. type="number"
  287. disabled={!canEdit}
  288. onChange={handleChange}
  289. size="sm"
  290. value={sampleRate}
  291. />
  292. </Tooltip>
  293. </FirstCellLine>
  294. {error ? (
  295. <ErrorMessage>{error}</ErrorMessage>
  296. ) : sampleRate !== initialSampleRate ? (
  297. <SmallPrint>{t('previous: %s%%', initialSampleRate)}</SmallPrint>
  298. ) : null}
  299. </Cell>
  300. </Fragment>
  301. );
  302. });
  303. const StyledPanelTable = styled(PanelTable)`
  304. grid-template-columns: 1fr repeat(${COLUMN_COUNT - 1}, max-content);
  305. `;
  306. const SmallPrint = styled('span')`
  307. font-size: ${p => p.theme.fontSizeExtraSmall};
  308. color: ${p => p.theme.subText};
  309. line-height: 1.5;
  310. text-align: right;
  311. `;
  312. const ErrorMessage = styled('span')`
  313. color: ${p => p.theme.error};
  314. font-size: ${p => p.theme.fontSizeExtraSmall};
  315. line-height: 1.5;
  316. text-align: right;
  317. `;
  318. const SortableHeader = styled('button')`
  319. border: none;
  320. background: none;
  321. cursor: pointer;
  322. display: flex;
  323. text-transform: inherit;
  324. align-items: center;
  325. gap: ${space(0.5)};
  326. `;
  327. const Cell = styled('div')`
  328. display: flex;
  329. flex-direction: column;
  330. gap: ${space(0.25)};
  331. `;
  332. const SectionHeaderCell = styled('div')`
  333. display: flex;
  334. grid-column: span ${COLUMN_COUNT};
  335. padding: ${space(1.5)};
  336. align-items: center;
  337. background: ${p => p.theme.backgroundSecondary};
  338. color: ${p => p.theme.subText};
  339. cursor: pointer;
  340. `;
  341. const FirstCellLine = styled('div')`
  342. display: flex;
  343. align-items: center;
  344. height: 32px;
  345. & > * {
  346. flex-shrink: 0;
  347. }
  348. &[data-align='right'] {
  349. justify-content: flex-end;
  350. }
  351. &[data-has-chevron='false'] {
  352. padding-left: ${space(2)};
  353. }
  354. `;
  355. const SubProjects = styled('div')`
  356. color: ${p => p.theme.subText};
  357. font-size: ${p => p.theme.fontSizeSmall};
  358. margin-left: ${space(2)};
  359. & > div {
  360. line-height: 2;
  361. margin-right: -${space(2)};
  362. padding-right: ${space(2)};
  363. margin-left: -${space(1)};
  364. padding-left: ${space(1)};
  365. border-top-left-radius: ${p => p.theme.borderRadius};
  366. border-bottom-left-radius: ${p => p.theme.borderRadius};
  367. &:nth-child(odd) {
  368. background: ${p => p.theme.backgroundSecondary};
  369. }
  370. }
  371. `;
  372. const SubSpans = styled('div')`
  373. color: ${p => p.theme.subText};
  374. font-size: ${p => p.theme.fontSizeSmall};
  375. text-align: right;
  376. & > div {
  377. line-height: 2;
  378. margin-left: -${space(2)};
  379. padding-left: ${space(2)};
  380. margin-right: -${space(1)};
  381. padding-right: ${space(1)};
  382. border-top-right-radius: ${p => p.theme.borderRadius};
  383. border-bottom-right-radius: ${p => p.theme.borderRadius};
  384. &:nth-child(odd) {
  385. background: ${p => p.theme.backgroundSecondary};
  386. }
  387. }
  388. `;
  389. const HiddenButton = styled('button')`
  390. background: none;
  391. border: none;
  392. padding: 0;
  393. cursor: pointer;
  394. display: flex;
  395. align-items: center;
  396. /* Overwrite the platform icon's cursor style */
  397. &:not([disabled]) img {
  398. cursor: pointer;
  399. }
  400. `;
  401. const StyledIconChevron = styled(IconChevron)`
  402. height: 12px;
  403. width: 12px;
  404. margin-right: ${space(0.5)};
  405. color: ${p => p.theme.subText};
  406. `;
  407. const SettingsButton = styled(LinkButton)`
  408. margin-left: ${space(0.5)};
  409. color: ${p => p.theme.subText};
  410. visibility: hidden;
  411. &:focus {
  412. visibility: visible;
  413. }
  414. ${Cell}:hover & {
  415. visibility: visible;
  416. }
  417. `;