import {useRef} from 'react'; import {Theme} from '@emotion/react'; import styled from '@emotion/styled'; import debounce from 'lodash/debounce'; 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 {isActiveSuperuser} from 'sentry/utils/isActiveSuperuser'; 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: ( {t('Unassigned')} ), searchKey: 'unassigned', actor: null, disabled: false, }; // 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; 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 {includeUnassigned, styles, ...extraProps} = props; const {teamFilter, organization, project, multiple, value, useId, onChange} = 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(null); const createTeamOption = (team: Team): TeamOption => ({ value: useId ? team.id : team.slug, label: `#${team.slug}`, leadingItems: , 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(); } } 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); } const canAddTeam = organization.access.includes('project:write'); return { ...createTeamOption(team), disabled: true, label: `#${team.slug}`, leadingItems: , trailingItems: ( handleAddTeamToProject(team)} icon={} aria-label={t('Add %s to project', `#${team.slug}`)} /> ), tooltip: t('%s is not a member of project', `#${team.slug}`), }; } function getOptions() { const isSuperuser = isActiveSuperuser(); const filteredTeams = isSuperuser ? teams : teamFilter ? teams.filter(teamFilter) : teams; 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 [ ...teamsInProject.map(createTeamOption), ...teamsNotInProject.map(createTeamOutsideProjectOption), ...(includeUnassigned ? [unassignedOption] : []), ]; } return [ ...filteredTeams.map(createTeamOption), ...(includeUnassigned ? [unassignedOption] : []), ]; } return ( void onSearch(val), DEFAULT_DEBOUNCE_DURATION)} getOptionValue={option => option.searchKey} styles={{ ...(includeUnassigned ? unassignedSelectStyles : {}), ...(multiple ? {} : placeholderSelectStyles), ...(styles ?? {}), }} isLoading={fetching} {...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 ) => JSX.Element;