mirror of
https://github.com/hanzoai/sentinel.git
synced 2026-09-29 22:34:33 +00:00
283 lines
8.4 KiB
TypeScript
283 lines
8.4 KiB
TypeScript
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;
|
|
`;
|