123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334 |
- import {useRef} from 'react';
- import {createFilter} from 'react-select';
- import {Theme} from '@emotion/react';
- import styled from '@emotion/styled';
- import debounce from 'lodash/debounce';
- import {openCreateTeamModal} from 'sentry/actionCreators/modal';
- import {addTeamToProject} from 'sentry/actionCreators/projects';
- import {Button} from 'sentry/components/button';
- import SelectControl, {
- ControlProps,
- GeneralSelectValue,
- StylesConfig,
- } from 'sentry/components/forms/controls/selectControl';
- import IdBadge from 'sentry/components/idBadge';
- import {Tooltip} from 'sentry/components/tooltip';
- import {DEFAULT_DEBOUNCE_DURATION} from 'sentry/constants';
- import {IconAdd, IconUser} from 'sentry/icons';
- import {t} from 'sentry/locale';
- import {space} from 'sentry/styles/space';
- import {Organization, Project, Team} from 'sentry/types';
- import useApi from 'sentry/utils/useApi';
- import {useTeams} from 'sentry/utils/useTeams';
- import withOrganization from 'sentry/utils/withOrganization';
- const UnassignedWrapper = styled('div')`
- display: flex;
- align-items: center;
- `;
- const StyledIconUser = styled(IconUser)`
- margin-left: ${space(0.25)};
- margin-right: ${space(1)};
- color: ${p => p.theme.gray400};
- `;
- // An option to be unassigned on the team dropdown
- const unassignedOption = {
- value: null,
- label: (
- <UnassignedWrapper>
- <StyledIconUser size="md" />
- {t('Unassigned')}
- </UnassignedWrapper>
- ),
- searchKey: 'unassigned',
- actor: null,
- disabled: false,
- };
- const CREATE_TEAM_VALUE = 'CREATE_TEAM_VALUE';
- const optionFilter = createFilter({
- stringify: option => `${option.label} ${option.value}`,
- });
- // Ensures that the svg icon is white when selected
- const unassignedSelectStyles: StylesConfig = {
- option: (provided, state) => {
- // XXX: The `state.theme` is an emotion theme object, but it is not typed
- // as the emotion theme object in react-select
- const theme = state.theme as unknown as Theme;
- return {...provided, svg: {color: state.isSelected ? theme.white : undefined}};
- },
- };
- const placeholderSelectStyles: StylesConfig = {
- input: (provided, state) => {
- // XXX: The `state.theme` is an emotion theme object, but it is not typed
- // as the emotion theme object in react-select
- const theme = state.theme as unknown as Theme;
- return {
- ...provided,
- display: 'grid',
- gridTemplateColumns: 'max-content 1fr',
- alignItems: 'center',
- gridGap: space(1),
- ':before': {
- backgroundColor: theme.backgroundSecondary,
- height: 24,
- width: 24,
- borderRadius: 3,
- content: '""',
- display: 'block',
- },
- };
- },
- placeholder: provided => ({
- ...provided,
- paddingLeft: 32,
- }),
- };
- type Props = {
- onChange: (value: any) => any;
- organization: Organization;
- /**
- * Controls whether the dropdown allows to create a new team
- */
- allowCreate?: boolean;
- includeUnassigned?: boolean;
- /**
- * Can be used to restrict teams to a certain project and allow for new teams to be add to that project
- */
- project?: Project;
- /**
- * Function to control whether a team should be shown in the dropdown
- */
- teamFilter?: (team: Team) => boolean;
- /**
- * Controls whether the value in the dropdown is a team id or team slug
- */
- useId?: boolean;
- } & ControlProps;
- type TeamActor = {
- id: string;
- name: string;
- type: 'team';
- };
- type TeamOption = GeneralSelectValue & {
- actor: TeamActor | null;
- searchKey: string;
- };
- function TeamSelector(props: Props) {
- const {allowCreate, includeUnassigned, styles, onChange, ...extraProps} = props;
- const {teamFilter, organization, project, multiple, value, useId} = props;
- const api = useApi();
- const {teams, fetching, onSearch} = useTeams();
- // TODO(ts) This type could be improved when react-select types are better.
- const selectRef = useRef<any>(null);
- const canCreateTeam = organization.access.includes('project:admin');
- const canAddTeam = organization.access.includes('project:write');
- const createTeamOption = (team: Team): TeamOption => ({
- value: useId ? team.id : team.slug,
- label: `#${team.slug}`,
- leadingItems: <IdBadge team={team} hideName />,
- searchKey: team.slug,
- actor: {
- type: 'team',
- id: team.id,
- name: team.slug,
- },
- });
- /**
- * Closes the select menu by blurring input if possible since that seems to
- * be the only way to close it.
- */
- function closeSelectMenu() {
- if (!selectRef.current) {
- return;
- }
- const select = selectRef.current.select;
- const input: HTMLInputElement = select.inputRef;
- if (input) {
- // I don't think there's another way to close `react-select`
- input.blur();
- }
- }
- const createTeam = () =>
- new Promise<TeamOption>(resolve => {
- openCreateTeamModal({
- organization,
- onClose: async team => {
- if (project) {
- await handleAddTeamToProject(team);
- }
- resolve(createTeamOption(team));
- },
- });
- });
- const handleChange = (newValue: TeamOption | TeamOption[]) => {
- if (multiple) {
- const options = newValue as TeamOption[];
- const shouldCreate = options.find(option => option.value === CREATE_TEAM_VALUE);
- if (shouldCreate) {
- createTeam().then(newTeamOption => {
- onChange?.([
- ...options.filter(option => option.value !== CREATE_TEAM_VALUE),
- newTeamOption,
- ]);
- });
- } else {
- onChange?.(options);
- }
- return;
- }
- const option = newValue as TeamOption;
- if (option.value === CREATE_TEAM_VALUE) {
- createTeam().then(newTramOption => {
- onChange?.(newTramOption);
- });
- } else {
- onChange?.(option);
- }
- };
- async function handleAddTeamToProject(team: Team) {
- if (!project) {
- closeSelectMenu();
- return;
- }
- // Copy old value
- const oldValue = multiple ? [...(value ?? [])] : {value};
- // Optimistic update
- onChange?.(createTeamOption(team));
- try {
- await addTeamToProject(api, organization.slug, project.slug, team);
- } catch (err) {
- // Unable to add team to project, revert select menu value
- onChange?.(oldValue);
- }
- closeSelectMenu();
- }
- function createTeamOutsideProjectOption(team: Team): TeamOption {
- // If the option/team is currently selected, optimistically assume it is now a part of the project
- if (value === (useId ? team.id : team.slug)) {
- return createTeamOption(team);
- }
- return {
- ...createTeamOption(team),
- disabled: true,
- label: `#${team.slug}`,
- leadingItems: <IdBadge team={team} hideName />,
- trailingItems: (
- <Tooltip
- title={
- canAddTeam
- ? t('Add %s to project', `#${team.slug}`)
- : t('You do not have permission to add team to project.')
- }
- containerDisplayMode="flex"
- >
- <AddToProjectButton
- size="zero"
- borderless
- disabled={!canAddTeam}
- onClick={() => handleAddTeamToProject(team)}
- icon={<IconAdd isCircled />}
- aria-label={t('Add %s to project', `#${team.slug}`)}
- />
- </Tooltip>
- ),
- tooltip: t('%s is not a member of project', `#${team.slug}`),
- };
- }
- function getOptions() {
- const filteredTeams = teamFilter ? teams.filter(teamFilter) : teams;
- const createOption = {
- value: CREATE_TEAM_VALUE,
- label: t('Create team'),
- leadingItems: <IconAdd isCircled />,
- searchKey: 'create',
- actor: null,
- disabled: !canCreateTeam,
- 'data-test-id': 'create-team-option',
- };
- if (project) {
- const teamsInProjectIdSet = new Set(project.teams.map(team => team.id));
- const teamsInProject = filteredTeams.filter(team =>
- teamsInProjectIdSet.has(team.id)
- );
- const teamsNotInProject = filteredTeams.filter(
- team => !teamsInProjectIdSet.has(team.id)
- );
- return [
- ...(allowCreate ? [createOption] : []),
- ...teamsInProject.map(createTeamOption),
- ...teamsNotInProject.map(createTeamOutsideProjectOption),
- ...(includeUnassigned ? [unassignedOption] : []),
- ];
- }
- return [
- ...(allowCreate ? [createOption] : []),
- ...filteredTeams.map(createTeamOption),
- ...(includeUnassigned ? [unassignedOption] : []),
- ];
- }
- return (
- <SelectControl
- ref={selectRef}
- options={getOptions()}
- onInputChange={debounce(val => void onSearch(val), DEFAULT_DEBOUNCE_DURATION)}
- getOptionValue={option => option.searchKey}
- filterOption={(canditate, input) =>
- // Never filter out the create team option
- canditate.data.value === CREATE_TEAM_VALUE || optionFilter(canditate, input)
- }
- styles={{
- ...(includeUnassigned ? unassignedSelectStyles : {}),
- ...(multiple ? {} : placeholderSelectStyles),
- ...(styles ?? {}),
- }}
- isLoading={fetching}
- onChange={handleChange}
- {...extraProps}
- />
- );
- }
- const AddToProjectButton = styled(Button)`
- flex-shrink: 0;
- `;
- export {TeamSelector};
- // TODO(davidenwang): this is broken due to incorrect types on react-select
- export default withOrganization(TeamSelector) as unknown as (
- p: Omit<Props, 'organization'>
- ) => JSX.Element;
|