teamSelector.tsx 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334
  1. import {useRef} from 'react';
  2. import {createFilter} from 'react-select';
  3. import {Theme} from '@emotion/react';
  4. import styled from '@emotion/styled';
  5. import debounce from 'lodash/debounce';
  6. import {openCreateTeamModal} from 'sentry/actionCreators/modal';
  7. import {addTeamToProject} from 'sentry/actionCreators/projects';
  8. import {Button} from 'sentry/components/button';
  9. import SelectControl, {
  10. ControlProps,
  11. GeneralSelectValue,
  12. StylesConfig,
  13. } from 'sentry/components/forms/controls/selectControl';
  14. import IdBadge from 'sentry/components/idBadge';
  15. import {Tooltip} from 'sentry/components/tooltip';
  16. import {DEFAULT_DEBOUNCE_DURATION} from 'sentry/constants';
  17. import {IconAdd, IconUser} from 'sentry/icons';
  18. import {t} from 'sentry/locale';
  19. import {space} from 'sentry/styles/space';
  20. import {Organization, Project, Team} from 'sentry/types';
  21. import useApi from 'sentry/utils/useApi';
  22. import {useTeams} from 'sentry/utils/useTeams';
  23. import withOrganization from 'sentry/utils/withOrganization';
  24. const UnassignedWrapper = styled('div')`
  25. display: flex;
  26. align-items: center;
  27. `;
  28. const StyledIconUser = styled(IconUser)`
  29. margin-left: ${space(0.25)};
  30. margin-right: ${space(1)};
  31. color: ${p => p.theme.gray400};
  32. `;
  33. // An option to be unassigned on the team dropdown
  34. const unassignedOption = {
  35. value: null,
  36. label: (
  37. <UnassignedWrapper>
  38. <StyledIconUser size="md" />
  39. {t('Unassigned')}
  40. </UnassignedWrapper>
  41. ),
  42. searchKey: 'unassigned',
  43. actor: null,
  44. disabled: false,
  45. };
  46. const CREATE_TEAM_VALUE = 'CREATE_TEAM_VALUE';
  47. const optionFilter = createFilter({
  48. stringify: option => `${option.label} ${option.value}`,
  49. });
  50. // Ensures that the svg icon is white when selected
  51. const unassignedSelectStyles: StylesConfig = {
  52. option: (provided, state) => {
  53. // XXX: The `state.theme` is an emotion theme object, but it is not typed
  54. // as the emotion theme object in react-select
  55. const theme = state.theme as unknown as Theme;
  56. return {...provided, svg: {color: state.isSelected ? theme.white : undefined}};
  57. },
  58. };
  59. const placeholderSelectStyles: StylesConfig = {
  60. input: (provided, state) => {
  61. // XXX: The `state.theme` is an emotion theme object, but it is not typed
  62. // as the emotion theme object in react-select
  63. const theme = state.theme as unknown as Theme;
  64. return {
  65. ...provided,
  66. display: 'grid',
  67. gridTemplateColumns: 'max-content 1fr',
  68. alignItems: 'center',
  69. gridGap: space(1),
  70. ':before': {
  71. backgroundColor: theme.backgroundSecondary,
  72. height: 24,
  73. width: 24,
  74. borderRadius: 3,
  75. content: '""',
  76. display: 'block',
  77. },
  78. };
  79. },
  80. placeholder: provided => ({
  81. ...provided,
  82. paddingLeft: 32,
  83. }),
  84. };
  85. type Props = {
  86. onChange: (value: any) => any;
  87. organization: Organization;
  88. /**
  89. * Controls whether the dropdown allows to create a new team
  90. */
  91. allowCreate?: boolean;
  92. includeUnassigned?: boolean;
  93. /**
  94. * Can be used to restrict teams to a certain project and allow for new teams to be add to that project
  95. */
  96. project?: Project;
  97. /**
  98. * Function to control whether a team should be shown in the dropdown
  99. */
  100. teamFilter?: (team: Team) => boolean;
  101. /**
  102. * Controls whether the value in the dropdown is a team id or team slug
  103. */
  104. useId?: boolean;
  105. } & ControlProps;
  106. type TeamActor = {
  107. id: string;
  108. name: string;
  109. type: 'team';
  110. };
  111. type TeamOption = GeneralSelectValue & {
  112. actor: TeamActor | null;
  113. searchKey: string;
  114. };
  115. function TeamSelector(props: Props) {
  116. const {allowCreate, includeUnassigned, styles, onChange, ...extraProps} = props;
  117. const {teamFilter, organization, project, multiple, value, useId} = props;
  118. const api = useApi();
  119. const {teams, fetching, onSearch} = useTeams();
  120. // TODO(ts) This type could be improved when react-select types are better.
  121. const selectRef = useRef<any>(null);
  122. const canCreateTeam = organization.access.includes('project:admin');
  123. const canAddTeam = organization.access.includes('project:write');
  124. const createTeamOption = (team: Team): TeamOption => ({
  125. value: useId ? team.id : team.slug,
  126. label: `#${team.slug}`,
  127. leadingItems: <IdBadge team={team} hideName />,
  128. searchKey: team.slug,
  129. actor: {
  130. type: 'team',
  131. id: team.id,
  132. name: team.slug,
  133. },
  134. });
  135. /**
  136. * Closes the select menu by blurring input if possible since that seems to
  137. * be the only way to close it.
  138. */
  139. function closeSelectMenu() {
  140. if (!selectRef.current) {
  141. return;
  142. }
  143. const select = selectRef.current.select;
  144. const input: HTMLInputElement = select.inputRef;
  145. if (input) {
  146. // I don't think there's another way to close `react-select`
  147. input.blur();
  148. }
  149. }
  150. const createTeam = () =>
  151. new Promise<TeamOption>(resolve => {
  152. openCreateTeamModal({
  153. organization,
  154. onClose: async team => {
  155. if (project) {
  156. await handleAddTeamToProject(team);
  157. }
  158. resolve(createTeamOption(team));
  159. },
  160. });
  161. });
  162. const handleChange = (newValue: TeamOption | TeamOption[]) => {
  163. if (multiple) {
  164. const options = newValue as TeamOption[];
  165. const shouldCreate = options.find(option => option.value === CREATE_TEAM_VALUE);
  166. if (shouldCreate) {
  167. createTeam().then(newTeamOption => {
  168. onChange?.([
  169. ...options.filter(option => option.value !== CREATE_TEAM_VALUE),
  170. newTeamOption,
  171. ]);
  172. });
  173. } else {
  174. onChange?.(options);
  175. }
  176. return;
  177. }
  178. const option = newValue as TeamOption;
  179. if (option.value === CREATE_TEAM_VALUE) {
  180. createTeam().then(newTramOption => {
  181. onChange?.(newTramOption);
  182. });
  183. } else {
  184. onChange?.(option);
  185. }
  186. };
  187. async function handleAddTeamToProject(team: Team) {
  188. if (!project) {
  189. closeSelectMenu();
  190. return;
  191. }
  192. // Copy old value
  193. const oldValue = multiple ? [...(value ?? [])] : {value};
  194. // Optimistic update
  195. onChange?.(createTeamOption(team));
  196. try {
  197. await addTeamToProject(api, organization.slug, project.slug, team);
  198. } catch (err) {
  199. // Unable to add team to project, revert select menu value
  200. onChange?.(oldValue);
  201. }
  202. closeSelectMenu();
  203. }
  204. function createTeamOutsideProjectOption(team: Team): TeamOption {
  205. // If the option/team is currently selected, optimistically assume it is now a part of the project
  206. if (value === (useId ? team.id : team.slug)) {
  207. return createTeamOption(team);
  208. }
  209. return {
  210. ...createTeamOption(team),
  211. disabled: true,
  212. label: `#${team.slug}`,
  213. leadingItems: <IdBadge team={team} hideName />,
  214. trailingItems: (
  215. <Tooltip
  216. title={
  217. canAddTeam
  218. ? t('Add %s to project', `#${team.slug}`)
  219. : t('You do not have permission to add team to project.')
  220. }
  221. containerDisplayMode="flex"
  222. >
  223. <AddToProjectButton
  224. size="zero"
  225. borderless
  226. disabled={!canAddTeam}
  227. onClick={() => handleAddTeamToProject(team)}
  228. icon={<IconAdd isCircled />}
  229. aria-label={t('Add %s to project', `#${team.slug}`)}
  230. />
  231. </Tooltip>
  232. ),
  233. tooltip: t('%s is not a member of project', `#${team.slug}`),
  234. };
  235. }
  236. function getOptions() {
  237. const filteredTeams = teamFilter ? teams.filter(teamFilter) : teams;
  238. const createOption = {
  239. value: CREATE_TEAM_VALUE,
  240. label: t('Create team'),
  241. leadingItems: <IconAdd isCircled />,
  242. searchKey: 'create',
  243. actor: null,
  244. disabled: !canCreateTeam,
  245. 'data-test-id': 'create-team-option',
  246. };
  247. if (project) {
  248. const teamsInProjectIdSet = new Set(project.teams.map(team => team.id));
  249. const teamsInProject = filteredTeams.filter(team =>
  250. teamsInProjectIdSet.has(team.id)
  251. );
  252. const teamsNotInProject = filteredTeams.filter(
  253. team => !teamsInProjectIdSet.has(team.id)
  254. );
  255. return [
  256. ...(allowCreate ? [createOption] : []),
  257. ...teamsInProject.map(createTeamOption),
  258. ...teamsNotInProject.map(createTeamOutsideProjectOption),
  259. ...(includeUnassigned ? [unassignedOption] : []),
  260. ];
  261. }
  262. return [
  263. ...(allowCreate ? [createOption] : []),
  264. ...filteredTeams.map(createTeamOption),
  265. ...(includeUnassigned ? [unassignedOption] : []),
  266. ];
  267. }
  268. return (
  269. <SelectControl
  270. ref={selectRef}
  271. options={getOptions()}
  272. onInputChange={debounce(val => void onSearch(val), DEFAULT_DEBOUNCE_DURATION)}
  273. getOptionValue={option => option.searchKey}
  274. filterOption={(canditate, input) =>
  275. // Never filter out the create team option
  276. canditate.data.value === CREATE_TEAM_VALUE || optionFilter(canditate, input)
  277. }
  278. styles={{
  279. ...(includeUnassigned ? unassignedSelectStyles : {}),
  280. ...(multiple ? {} : placeholderSelectStyles),
  281. ...(styles ?? {}),
  282. }}
  283. isLoading={fetching}
  284. onChange={handleChange}
  285. {...extraProps}
  286. />
  287. );
  288. }
  289. const AddToProjectButton = styled(Button)`
  290. flex-shrink: 0;
  291. `;
  292. export {TeamSelector};
  293. // TODO(davidenwang): this is broken due to incorrect types on react-select
  294. export default withOrganization(TeamSelector) as unknown as (
  295. p: Omit<Props, 'organization'>
  296. ) => JSX.Element;