123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282 |
- import {Fragment, useEffect, useMemo, useState} from 'react';
- import {css} from '@emotion/react';
- import styled from '@emotion/styled';
- import chunk from 'lodash/chunk';
- import isEqual from 'lodash/isEqual';
- import uniqBy from 'lodash/uniqBy';
- import SuggestedAvatarStack from 'sentry/components/avatar/suggestedAvatarStack';
- import {Button} from 'sentry/components/button';
- import ButtonBar from 'sentry/components/buttonBar';
- import PanelTable from 'sentry/components/panels/panelTable';
- import SearchBar from 'sentry/components/searchBar';
- import Tag from 'sentry/components/tag';
- import {IconChevron} from 'sentry/icons';
- import {t, tn} from 'sentry/locale';
- import ConfigStore from 'sentry/stores/configStore';
- import MemberListStore from 'sentry/stores/memberListStore';
- import TeamStore from 'sentry/stores/teamStore';
- import {space} from 'sentry/styles/space';
- import type {CodeOwner, ParsedOwnershipRule} from 'sentry/types';
- import {useTeams} from 'sentry/utils/useTeams';
- import {OwnershipOwnerFilter} from 'sentry/views/settings/project/projectOwnership/ownershipOwnerFilter';
- interface OwnershipRulesTableProps {
- codeowners: CodeOwner[];
- projectRules: ParsedOwnershipRule[];
- }
- /**
- * Once we mash the rules together we need codeowners id for more context
- */
- interface MixedOwnershipRule extends ParsedOwnershipRule {
- codeownersId?: string;
- }
- const PAGE_LIMIT = 25;
- export function OwnershipRulesTable({
- projectRules,
- codeowners,
- }: OwnershipRulesTableProps) {
- const [search, setSearch] = useState<string>('');
- const [page, setPage] = useState<number>(0);
- const [selectedActors, setSelectedActors] = useState<string[] | null>(null);
- const {teams} = useTeams({provideUserTeams: true});
- const combinedRules = useMemo(() => {
- const codeownerRulesWithId = codeowners.flatMap<MixedOwnershipRule>(owners =>
- (owners.schema?.rules ?? []).map(rule => ({
- ...rule,
- codeownersId: owners.id,
- }))
- );
- return [...codeownerRulesWithId, ...projectRules];
- }, [projectRules, codeowners]);
- /**
- * All unique actors from both codeowners and project rules
- */
- const allActors = useMemo(() => {
- const actors = combinedRules
- .flatMap(rule => rule.owners)
- .filter(actor => actor.name)
- .map(owner => ({...owner, id: `${owner.id}`}));
- return (
- uniqBy(actors, actor => `${actor.type}:${actor.id}`)
- // Sort by type, then by name
- // Teams first, then users
- .sort((a, b) => {
- if (a.type === 'team' && b.type === 'user') {
- return -1;
- }
- if (a.type === 'user' && b.type === 'team') {
- return 1;
- }
- return a.name.localeCompare(b.name);
- })
- );
- }, [combinedRules]);
- const myTeams = useMemo(() => {
- const user = ConfigStore.get('user');
- const memberTeamsIds = teams.filter(team => team.isMember).map(team => team.id);
- return allActors.filter(actor => {
- if (actor.type === 'user') {
- return actor.id === user.id;
- }
- return memberTeamsIds.includes(actor.id);
- });
- }, [allActors, teams]);
- useEffect(() => {
- if (myTeams.length > 0 && selectedActors === null) {
- setSelectedActors(myTeams.map(actor => `${actor.type}:${actor.id}`));
- }
- }, [myTeams, selectedActors]);
- /**
- * Rules chunked into pages
- */
- const chunkedRules = useMemo(() => {
- const filteredRules: MixedOwnershipRule[] = combinedRules.filter(
- rule =>
- // Filter by query
- (rule.matcher.type.includes(search) || rule.matcher.pattern.includes(search)) &&
- // Selected actors not set
- (selectedActors === null ||
- // Selected actors was cleared
- selectedActors.length === 0 ||
- rule.owners.some(owner => selectedActors.includes(`${owner.type}:${owner.id}`)))
- );
- return chunk(filteredRules, PAGE_LIMIT);
- }, [combinedRules, search, selectedActors]);
- const hasNextPage = chunkedRules[page + 1] !== undefined;
- const hasPrevPage = page !== 0;
- useEffect(() => {
- // Reset to first page if the list of rules changes
- if (!chunkedRules[page]) {
- setPage(0);
- }
- }, [chunkedRules, page]);
- const handleChangeFilter = (activeFilters: string[]) => {
- setSelectedActors(activeFilters.length > 0 ? activeFilters : []);
- };
- const handleSearch = (value: string) => {
- setSearch(value);
- setPage(0);
- // TODO(scttcper): persist search to query parameters
- };
- return (
- <RulesTableWrapper data-test-id="ownership-rules-table">
- <SearchAndSelectorWrapper>
- <OwnershipOwnerFilter
- actors={allActors}
- selectedTeams={selectedActors ?? []}
- handleChangeFilter={handleChangeFilter}
- isMyTeams={
- !!selectedActors &&
- selectedActors.length > 0 &&
- isEqual(
- selectedActors,
- myTeams.map(actor => `${actor.type}:${actor.id}`)
- )
- }
- />
- <StyledSearchBar
- name="ownershipSearch"
- placeholder={t('Search by type or rule')}
- query={search}
- onChange={handleSearch}
- />
- </SearchAndSelectorWrapper>
- <StyledPanelTable
- headers={[t('Type'), t('Rule'), t('Owner')]}
- isEmpty={chunkedRules.length === 0}
- emptyMessage={t('No ownership rules found')}
- >
- {chunkedRules[page]?.map((rule, index) => {
- let name: string | undefined = 'unknown';
- // ID might not be a string, so we need to convert it
- const owners = rule.owners.map(owner => ({...owner, id: `${owner.id}`}));
- if (owners[0]?.type === 'team') {
- const team = TeamStore.getById(owners[0].id);
- if (team?.slug) {
- name = `#${team.slug}`;
- }
- } else if (owners[0]?.type === 'user') {
- const user = MemberListStore.getById(owners[0].id);
- name = user?.name;
- }
- return (
- <Fragment key={`${rule.matcher.type}:${rule.matcher.pattern}-${index}`}>
- <RowItem>
- <Tag type="highlight">{rule.matcher.type}</Tag>
- </RowItem>
- <RowRule>{rule.matcher.pattern}</RowRule>
- <RowItem>
- <AvatarContainer numAvatars={Math.min(owners.length, 3)}>
- <SuggestedAvatarStack
- owners={owners}
- suggested={false}
- reverse={false}
- />
- </AvatarContainer>
- {name}
- {owners.length > 1 &&
- tn(' and %s other', ' and %s others', owners.length - 1)}
- </RowItem>
- </Fragment>
- );
- })}
- </StyledPanelTable>
- <PaginationWrapper>
- <ButtonBar merged>
- <Button
- icon={<IconChevron direction="left" size="sm" />}
- onClick={() => {
- setPage(page - 1);
- }}
- size="sm"
- disabled={!hasPrevPage}
- aria-label={t('Previous page')}
- />
- <Button
- icon={<IconChevron direction="right" size="sm" />}
- onClick={() => {
- setPage(page + 1);
- }}
- size="sm"
- disabled={!hasNextPage}
- aria-label={t('Next page')}
- />
- </ButtonBar>
- </PaginationWrapper>
- </RulesTableWrapper>
- );
- }
- const SearchAndSelectorWrapper = styled('div')`
- display: flex;
- align-items: center;
- gap: ${space(2)};
- `;
- const StyledSearchBar = styled(SearchBar)`
- flex-grow: 1;
- `;
- const RulesTableWrapper = styled('div')`
- display: flex;
- flex-direction: column;
- gap: ${space(2)};
- margin-bottom: ${space(2)};
- `;
- const StyledPanelTable = styled(PanelTable)`
- grid-template-columns: min-content minmax(1fr, max-content) auto;
- font-size: ${p => p.theme.fontSizeMedium};
- margin-bottom: 0;
- ${p =>
- !p.isEmpty &&
- css`
- & > div {
- padding: ${space(1.5)} ${space(2)};
- }
- `}
- `;
- const PaginationWrapper = styled('div')`
- display: flex;
- justify-content: flex-end;
- `;
- const RowItem = styled('div')`
- display: flex;
- align-items: center;
- gap: ${space(1)};
- `;
- const RowRule = styled('div')`
- display: flex;
- align-items: center;
- gap: ${space(1)};
- font-family: ${p => p.theme.text.familyMono};
- font-size: ${p => p.theme.fontSizeSmall};
- word-break: break-word;
- `;
- const AvatarContainer = styled('div')<{numAvatars: number}>`
- max-width: ${p => 24 + (p.numAvatars - 1) * (24 * 0.5)}px;
- `;
|