ownershipRulesTable.tsx 8.5 KB

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