ownershipRulesTable.tsx 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282
  1. import {Fragment, useEffect, useMemo, useState} from 'react';
  2. import {css} from '@emotion/react';
  3. import styled from '@emotion/styled';
  4. import chunk from 'lodash/chunk';
  5. import isEqual from 'lodash/isEqual';
  6. import uniqBy from 'lodash/uniqBy';
  7. import SuggestedAvatarStack from 'sentry/components/avatar/suggestedAvatarStack';
  8. import Tag from 'sentry/components/badge/tag';
  9. import {Button} from 'sentry/components/button';
  10. import ButtonBar from 'sentry/components/buttonBar';
  11. import {PanelTable} from 'sentry/components/panels/panelTable';
  12. import SearchBar from 'sentry/components/searchBar';
  13. import {IconChevron} from 'sentry/icons';
  14. import {t, tn} from 'sentry/locale';
  15. import ConfigStore from 'sentry/stores/configStore';
  16. import MemberListStore from 'sentry/stores/memberListStore';
  17. import TeamStore from 'sentry/stores/teamStore';
  18. import {space} from 'sentry/styles/space';
  19. import type {CodeOwner, ParsedOwnershipRule} from 'sentry/types';
  20. import {useTeams} from 'sentry/utils/useTeams';
  21. import {OwnershipOwnerFilter} from 'sentry/views/settings/project/projectOwnership/ownershipOwnerFilter';
  22. interface OwnershipRulesTableProps {
  23. codeowners: CodeOwner[];
  24. projectRules: ParsedOwnershipRule[];
  25. }
  26. /**
  27. * Once we mash the rules together we need codeowners id for more context
  28. */
  29. interface MixedOwnershipRule extends ParsedOwnershipRule {
  30. codeownersId?: string;
  31. }
  32. const PAGE_LIMIT = 25;
  33. export function OwnershipRulesTable({
  34. projectRules,
  35. codeowners,
  36. }: OwnershipRulesTableProps) {
  37. const [search, setSearch] = useState<string>('');
  38. const [page, setPage] = useState<number>(0);
  39. const [selectedActors, setSelectedActors] = useState<string[] | null>(null);
  40. const {teams} = useTeams({provideUserTeams: true});
  41. const combinedRules = useMemo(() => {
  42. const codeownerRulesWithId = codeowners.flatMap<MixedOwnershipRule>(owners =>
  43. (owners.schema?.rules ?? []).map(rule => ({
  44. ...rule,
  45. codeownersId: owners.id,
  46. }))
  47. );
  48. return [...codeownerRulesWithId, ...projectRules];
  49. }, [projectRules, codeowners]);
  50. /**
  51. * All unique actors from both codeowners and project rules
  52. */
  53. const allActors = useMemo(() => {
  54. const actors = combinedRules
  55. .flatMap(rule => rule.owners)
  56. .filter(actor => actor.name)
  57. .map(owner => ({...owner, id: `${owner.id}`}));
  58. return (
  59. uniqBy(actors, actor => `${actor.type}:${actor.id}`)
  60. // Sort by type, then by name
  61. // Teams first, then users
  62. .sort((a, b) => {
  63. if (a.type === 'team' && b.type === 'user') {
  64. return -1;
  65. }
  66. if (a.type === 'user' && b.type === 'team') {
  67. return 1;
  68. }
  69. return a.name.localeCompare(b.name);
  70. })
  71. );
  72. }, [combinedRules]);
  73. const myTeams = useMemo(() => {
  74. const user = ConfigStore.get('user');
  75. const memberTeamsIds = teams.filter(team => team.isMember).map(team => team.id);
  76. return allActors.filter(actor => {
  77. if (actor.type === 'user') {
  78. return actor.id === user.id;
  79. }
  80. return memberTeamsIds.includes(actor.id);
  81. });
  82. }, [allActors, teams]);
  83. useEffect(() => {
  84. if (myTeams.length > 0 && selectedActors === null) {
  85. setSelectedActors(myTeams.map(actor => `${actor.type}:${actor.id}`));
  86. }
  87. }, [myTeams, selectedActors]);
  88. /**
  89. * Rules chunked into pages
  90. */
  91. const chunkedRules = useMemo(() => {
  92. const filteredRules: MixedOwnershipRule[] = combinedRules.filter(
  93. rule =>
  94. // Filter by query
  95. (rule.matcher.type.includes(search) || rule.matcher.pattern.includes(search)) &&
  96. // Selected actors not set
  97. (selectedActors === null ||
  98. // Selected actors was cleared
  99. selectedActors.length === 0 ||
  100. rule.owners.some(owner => selectedActors.includes(`${owner.type}:${owner.id}`)))
  101. );
  102. return chunk(filteredRules, PAGE_LIMIT);
  103. }, [combinedRules, search, selectedActors]);
  104. const hasNextPage = chunkedRules[page + 1] !== undefined;
  105. const hasPrevPage = page !== 0;
  106. useEffect(() => {
  107. // Reset to first page if the list of rules changes
  108. if (!chunkedRules[page]) {
  109. setPage(0);
  110. }
  111. }, [chunkedRules, page]);
  112. const handleChangeFilter = (activeFilters: string[]) => {
  113. setSelectedActors(activeFilters.length > 0 ? activeFilters : []);
  114. };
  115. const handleSearch = (value: string) => {
  116. setSearch(value);
  117. setPage(0);
  118. // TODO(scttcper): persist search to query parameters
  119. };
  120. return (
  121. <RulesTableWrapper data-test-id="ownership-rules-table">
  122. <SearchAndSelectorWrapper>
  123. <OwnershipOwnerFilter
  124. actors={allActors}
  125. selectedTeams={selectedActors ?? []}
  126. handleChangeFilter={handleChangeFilter}
  127. isMyTeams={
  128. !!selectedActors &&
  129. selectedActors.length > 0 &&
  130. isEqual(
  131. selectedActors,
  132. myTeams.map(actor => `${actor.type}:${actor.id}`)
  133. )
  134. }
  135. />
  136. <StyledSearchBar
  137. name="ownershipSearch"
  138. placeholder={t('Search by type or rule')}
  139. query={search}
  140. onChange={handleSearch}
  141. />
  142. </SearchAndSelectorWrapper>
  143. <StyledPanelTable
  144. headers={[t('Type'), t('Rule'), t('Owner')]}
  145. isEmpty={chunkedRules.length === 0}
  146. emptyMessage={t('No ownership rules found')}
  147. >
  148. {chunkedRules[page]?.map((rule, index) => {
  149. let name: string | undefined = 'unknown';
  150. // ID might not be a string, so we need to convert it
  151. const owners = rule.owners.map(owner => ({...owner, id: `${owner.id}`}));
  152. if (owners[0]?.type === 'team') {
  153. const team = TeamStore.getById(owners[0].id);
  154. if (team?.slug) {
  155. name = `#${team.slug}`;
  156. }
  157. } else if (owners[0]?.type === 'user') {
  158. const user = MemberListStore.getById(owners[0].id);
  159. name = user?.name;
  160. }
  161. return (
  162. <Fragment key={`${rule.matcher.type}:${rule.matcher.pattern}-${index}`}>
  163. <RowItem>
  164. <Tag type="highlight">{rule.matcher.type}</Tag>
  165. </RowItem>
  166. <RowRule>{rule.matcher.pattern}</RowRule>
  167. <RowItem>
  168. <AvatarContainer numAvatars={Math.min(owners.length, 3)}>
  169. <SuggestedAvatarStack
  170. owners={owners}
  171. suggested={false}
  172. reverse={false}
  173. />
  174. </AvatarContainer>
  175. {name}
  176. {owners.length > 1 &&
  177. tn(' and %s other', ' and %s others', owners.length - 1)}
  178. </RowItem>
  179. </Fragment>
  180. );
  181. })}
  182. </StyledPanelTable>
  183. <PaginationWrapper>
  184. <ButtonBar merged>
  185. <Button
  186. icon={<IconChevron direction="left" size="sm" />}
  187. onClick={() => {
  188. setPage(page - 1);
  189. }}
  190. size="sm"
  191. disabled={!hasPrevPage}
  192. aria-label={t('Previous page')}
  193. />
  194. <Button
  195. icon={<IconChevron direction="right" size="sm" />}
  196. onClick={() => {
  197. setPage(page + 1);
  198. }}
  199. size="sm"
  200. disabled={!hasNextPage}
  201. aria-label={t('Next page')}
  202. />
  203. </ButtonBar>
  204. </PaginationWrapper>
  205. </RulesTableWrapper>
  206. );
  207. }
  208. const SearchAndSelectorWrapper = styled('div')`
  209. display: flex;
  210. align-items: center;
  211. gap: ${space(2)};
  212. `;
  213. const StyledSearchBar = styled(SearchBar)`
  214. flex-grow: 1;
  215. `;
  216. const RulesTableWrapper = styled('div')`
  217. display: flex;
  218. flex-direction: column;
  219. gap: ${space(2)};
  220. margin-bottom: ${space(2)};
  221. `;
  222. const StyledPanelTable = styled(PanelTable)`
  223. grid-template-columns: min-content minmax(1fr, max-content) auto;
  224. font-size: ${p => p.theme.fontSizeMedium};
  225. margin-bottom: 0;
  226. ${p =>
  227. !p.isEmpty &&
  228. css`
  229. & > div {
  230. padding: ${space(1.5)} ${space(2)};
  231. }
  232. `}
  233. `;
  234. const PaginationWrapper = styled('div')`
  235. display: flex;
  236. justify-content: flex-end;
  237. `;
  238. const RowItem = styled('div')`
  239. display: flex;
  240. align-items: center;
  241. gap: ${space(1)};
  242. `;
  243. const RowRule = styled('div')`
  244. display: flex;
  245. align-items: center;
  246. gap: ${space(1)};
  247. font-family: ${p => p.theme.text.familyMono};
  248. font-size: ${p => p.theme.fontSizeSmall};
  249. word-break: break-word;
  250. `;
  251. const AvatarContainer = styled('div')<{numAvatars: number}>`
  252. max-width: ${p => 24 + (p.numAvatars - 1) * (24 * 0.5)}px;
  253. `;