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 Tag from 'sentry/components/badge/tag'; 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 {IconChevron} from 'sentry/icons'; import {t, tn} from 'sentry/locale'; import MemberListStore from 'sentry/stores/memberListStore'; import TeamStore from 'sentry/stores/teamStore'; import {space} from 'sentry/styles/space'; import type {ParsedOwnershipRule} from 'sentry/types/group'; import type {CodeOwner} from 'sentry/types/integrations'; import {useTeams} from 'sentry/utils/useTeams'; import {useUser} from 'sentry/utils/useUser'; 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 user = useUser(); const [search, setSearch] = useState(''); const [page, setPage] = useState(0); const [selectedActors, setSelectedActors] = useState(null); const {teams} = useTeams({provideUserTeams: true}); const combinedRules = useMemo(() => { const codeownerRulesWithId = codeowners.flatMap(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 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, user]); 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 ( 0 && isEqual( selectedActors, myTeams.map(actor => `${actor.type}:${actor.id}`) ) } /> {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 firstUser = MemberListStore.getById(owners[0].id); name = firstUser?.name; } return ( {rule.matcher.type} {rule.matcher.pattern} {name} {owners.length > 1 && tn(' and %s other', ' and %s others', owners.length - 1)} ); })}