projectsTable.tsx 12 KB

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