mirror of
https://github.com/hanzoai/sentinel.git
synced 2026-09-29 22:34:33 +00:00
332 lines
9.4 KiB
TypeScript
332 lines
9.4 KiB
TypeScript
import {Component, createRef} from 'react';
|
|
import {findDOMNode} from 'react-dom';
|
|
import type {MultiValueProps} from 'react-select';
|
|
import styled from '@emotion/styled';
|
|
import debounce from 'lodash/debounce';
|
|
import isEqual from 'lodash/isEqual';
|
|
|
|
import {addTeamToProject} from 'sentry/actionCreators/projects';
|
|
import type {Client} from 'sentry/api';
|
|
import ActorAvatar from 'sentry/components/avatar/actorAvatar';
|
|
import {Button} from 'sentry/components/button';
|
|
import SelectControl from 'sentry/components/forms/controls/selectControl';
|
|
import IdBadge from 'sentry/components/idBadge';
|
|
import {Tooltip} from 'sentry/components/tooltip';
|
|
import {IconAdd} from 'sentry/icons';
|
|
import {t} from 'sentry/locale';
|
|
import MemberListStore from 'sentry/stores/memberListStore';
|
|
import ProjectsStore from 'sentry/stores/projectsStore';
|
|
import TeamStore from 'sentry/stores/teamStore';
|
|
import {space} from 'sentry/styles/space';
|
|
import type {Actor, Member, Organization, Project, Team, User} from 'sentry/types';
|
|
import {buildTeamId, buildUserId} from 'sentry/utils';
|
|
import withApi from 'sentry/utils/withApi';
|
|
import withProjects from 'sentry/utils/withProjects';
|
|
|
|
export type Owner = {
|
|
actor: Actor;
|
|
label: React.ReactNode;
|
|
searchKey: string;
|
|
value: string;
|
|
disabled?: boolean;
|
|
};
|
|
|
|
function ValueComponent({data, removeProps}: MultiValueProps<Owner>) {
|
|
return (
|
|
<ValueWrapper onClick={removeProps.onClick}>
|
|
<ActorAvatar actor={data.actor} size={28} />
|
|
</ValueWrapper>
|
|
);
|
|
}
|
|
|
|
const getSearchKeyForUser = (user: User) =>
|
|
`${user.email?.toLowerCase()} ${user.name?.toLowerCase()}`;
|
|
|
|
type Props = {
|
|
api: Client;
|
|
disabled: boolean;
|
|
onChange: (owners: Owner[]) => void;
|
|
organization: Organization;
|
|
project: Project;
|
|
projects: Project[];
|
|
value: any;
|
|
onInputChange?: (text: string) => void;
|
|
};
|
|
|
|
type State = {
|
|
inputValue: string;
|
|
loading: boolean;
|
|
};
|
|
|
|
class SelectOwners extends Component<Props, State> {
|
|
state: State = {
|
|
loading: false,
|
|
inputValue: '',
|
|
};
|
|
|
|
componentDidUpdate(prevProps: Props) {
|
|
// Once a team has been added to the project the menu can be closed.
|
|
if (!isEqual(this.props.projects, prevProps.projects)) {
|
|
this.closeSelectMenu();
|
|
}
|
|
}
|
|
|
|
private selectRef = createRef<any>();
|
|
|
|
renderUserBadge = (user: User) => (
|
|
<IdBadge avatarSize={24} user={user} hideEmail useLink={false} />
|
|
);
|
|
|
|
createMentionableUser = (user: User): Owner => ({
|
|
value: buildUserId(user.id),
|
|
label: this.renderUserBadge(user),
|
|
searchKey: getSearchKeyForUser(user),
|
|
actor: {
|
|
type: 'user' as const,
|
|
id: user.id,
|
|
name: user.name,
|
|
},
|
|
});
|
|
|
|
createUnmentionableUser = ({user}): Owner => ({
|
|
...this.createMentionableUser(user),
|
|
disabled: true,
|
|
label: (
|
|
<DisabledLabel>
|
|
<Tooltip
|
|
position="left"
|
|
title={t('%s is not a member of project', user.name || user.email)}
|
|
>
|
|
{this.renderUserBadge(user)}
|
|
</Tooltip>
|
|
</DisabledLabel>
|
|
),
|
|
});
|
|
|
|
createMentionableTeam = (team: Team): Owner => ({
|
|
value: buildTeamId(team.id),
|
|
label: <IdBadge team={team} />,
|
|
searchKey: `#${team.slug}`,
|
|
actor: {
|
|
type: 'team' as const,
|
|
id: team.id,
|
|
name: team.slug,
|
|
},
|
|
});
|
|
|
|
createUnmentionableTeam = (team: Team): Owner => {
|
|
const {organization} = this.props;
|
|
const canAddTeam = organization.access.includes('project:write');
|
|
|
|
return {
|
|
...this.createMentionableTeam(team),
|
|
disabled: true,
|
|
label: (
|
|
<Container>
|
|
<DisabledLabel>
|
|
<Tooltip
|
|
position="left"
|
|
title={t('%s is not a member of project', `#${team.slug}`)}
|
|
>
|
|
<IdBadge team={team} />
|
|
</Tooltip>
|
|
</DisabledLabel>
|
|
<Tooltip
|
|
title={
|
|
canAddTeam
|
|
? t('Add %s to project', `#${team.slug}`)
|
|
: t('You do not have permission to add team to project.')
|
|
}
|
|
>
|
|
<AddToProjectButton
|
|
size="zero"
|
|
borderless
|
|
disabled={!canAddTeam}
|
|
onClick={this.handleAddTeamToProject.bind(this, team)}
|
|
icon={<IconAdd isCircled />}
|
|
aria-label={t('Add %s to project', `#${team.slug}`)}
|
|
/>
|
|
</Tooltip>
|
|
</Container>
|
|
),
|
|
};
|
|
};
|
|
|
|
getMentionableUsers() {
|
|
return MemberListStore.getAll().map(this.createMentionableUser);
|
|
}
|
|
|
|
getMentionableTeams() {
|
|
const {project} = this.props;
|
|
const projectData = ProjectsStore.getBySlug(project.slug);
|
|
|
|
if (!projectData) {
|
|
return [];
|
|
}
|
|
|
|
return projectData.teams.map(this.createMentionableTeam);
|
|
}
|
|
|
|
/**
|
|
* Get list of teams that are not in the current project, for use in `MultiSelectMenu`
|
|
*/
|
|
getTeamsNotInProject(teamsInProject: Owner[] = []) {
|
|
const teams = TeamStore.getAll() || [];
|
|
const excludedTeamIds = teamsInProject.map(({actor}) => actor.id);
|
|
|
|
return teams
|
|
.filter(team => !excludedTeamIds.includes(team.id))
|
|
.map(this.createUnmentionableTeam);
|
|
}
|
|
|
|
/**
|
|
* Closes the select menu by blurring input if possible since that seems to be the only
|
|
* way to close it.
|
|
*/
|
|
closeSelectMenu() {
|
|
// Close select menu
|
|
if (this.selectRef.current) {
|
|
// eslint-disable-next-line react/no-find-dom-node
|
|
const node = findDOMNode(this.selectRef.current);
|
|
const input: HTMLInputElement | null = (node as Element)?.querySelector(
|
|
'.Select-input input'
|
|
);
|
|
if (input) {
|
|
// I don't think there's another way to close `react-select`
|
|
input.blur();
|
|
}
|
|
}
|
|
}
|
|
|
|
async handleAddTeamToProject(team) {
|
|
const {api, organization, project, value} = this.props;
|
|
// Copy old value
|
|
const oldValue = [...value];
|
|
|
|
// Optimistic update
|
|
this.props.onChange([...this.props.value, this.createMentionableTeam(team)]);
|
|
|
|
try {
|
|
// Try to add team to project
|
|
// Note: we can't close select menu here because we have to wait for ProjectsStore to update first
|
|
// The reason for this is because we have little control over `react-select`'s `AsyncSelect`
|
|
// We can't control when `handleLoadOptions` gets called, but it gets called when select closes, so
|
|
// wait for store to update before closing the menu. Otherwise, we'll have stale items in the select menu
|
|
await addTeamToProject(api, organization.slug, project.slug, team);
|
|
} catch (err) {
|
|
// Unable to add team to project, revert select menu value
|
|
this.props.onChange(oldValue);
|
|
this.closeSelectMenu();
|
|
}
|
|
}
|
|
|
|
handleChange = (newValue: Owner[]) => {
|
|
this.props.onChange(newValue);
|
|
};
|
|
|
|
handleInputChange = (inputValue: string) => {
|
|
this.setState({inputValue});
|
|
|
|
if (this.props.onInputChange) {
|
|
this.props.onInputChange(inputValue);
|
|
}
|
|
};
|
|
|
|
queryMembers = debounce((query, cb) => {
|
|
const {api, organization} = this.props;
|
|
|
|
// Because this function is debounced, the component can potentially be
|
|
// unmounted before this fires, in which case, `this.api` is null
|
|
if (!api) {
|
|
return null;
|
|
}
|
|
|
|
return api
|
|
.requestPromise(`/organizations/${organization.slug}/members/`, {
|
|
query: {query},
|
|
})
|
|
.then(
|
|
(data: Member[]) => cb(null, data),
|
|
err => cb(err)
|
|
);
|
|
}, 250);
|
|
|
|
handleLoadOptions = () => {
|
|
const usersInProject = this.getMentionableUsers();
|
|
const teamsInProject = this.getMentionableTeams();
|
|
const teamsNotInProject = this.getTeamsNotInProject(teamsInProject);
|
|
const usersInProjectById = usersInProject.map(({actor}) => actor.id);
|
|
|
|
// Return a promise for `react-select`
|
|
return new Promise((resolve, reject) => {
|
|
this.queryMembers(this.state.inputValue, (err, result) => {
|
|
if (err) {
|
|
reject(err);
|
|
} else {
|
|
resolve(result);
|
|
}
|
|
});
|
|
})
|
|
.then(members =>
|
|
// Be careful here as we actually want the `users` object, otherwise it means user
|
|
// has not registered for sentry yet, but has been invited
|
|
members
|
|
? (members as Member[])
|
|
.filter(({user}) => user && !usersInProjectById.includes(user.id))
|
|
.map(this.createUnmentionableUser)
|
|
: []
|
|
)
|
|
.then(members => {
|
|
return [...usersInProject, ...teamsInProject, ...teamsNotInProject, ...members];
|
|
});
|
|
};
|
|
|
|
render() {
|
|
return (
|
|
<SelectControl
|
|
multiple
|
|
name="owners"
|
|
filterOption={(option, filterText) =>
|
|
option.data.searchKey.indexOf(filterText) > -1
|
|
}
|
|
ref={this.selectRef}
|
|
loadOptions={this.handleLoadOptions}
|
|
defaultOptions
|
|
async
|
|
clearable
|
|
disabled={this.props.disabled}
|
|
cache={false}
|
|
aria-label={t('Rule owner')}
|
|
placeholder={t('Owners')}
|
|
components={{
|
|
MultiValue: ValueComponent,
|
|
}}
|
|
onInputChange={this.handleInputChange}
|
|
onChange={this.handleChange}
|
|
value={this.props.value}
|
|
css={{width: 300}}
|
|
/>
|
|
);
|
|
}
|
|
}
|
|
|
|
export default withApi(withProjects(SelectOwners));
|
|
|
|
const Container = styled('div')`
|
|
display: flex;
|
|
justify-content: space-between;
|
|
`;
|
|
|
|
const DisabledLabel = styled('div')`
|
|
opacity: 0.5;
|
|
overflow: hidden; /* Needed so that "Add to team" button can fit */
|
|
`;
|
|
|
|
const AddToProjectButton = styled(Button)`
|
|
flex-shrink: 0;
|
|
`;
|
|
|
|
const ValueWrapper = styled('a')`
|
|
margin-right: ${space(0.5)};
|
|
`;
|