projectsTable.tsx 12 KB

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