teamSelector.tsx 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383
  1. import {useCallback, useMemo, 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. const filterOption = (canditate, input) =>
  51. // Never filter out the create team option
  52. canditate.data.value === CREATE_TEAM_VALUE || optionFilter(canditate, input);
  53. const getOptionValue = (option: TeamOption) => option.value;
  54. // Ensures that the svg icon is white when selected
  55. const unassignedSelectStyles: StylesConfig = {
  56. option: (provided, state) => {
  57. // XXX: The `state.theme` is an emotion theme object, but it is not typed
  58. // as the emotion theme object in react-select
  59. const theme = state.theme as unknown as Theme;
  60. return {...provided, svg: {color: state.isSelected ? theme.white : undefined}};
  61. },
  62. };
  63. const placeholderSelectStyles: StylesConfig = {
  64. input: (provided, state) => {
  65. // XXX: The `state.theme` is an emotion theme object, but it is not typed
  66. // as the emotion theme object in react-select
  67. const theme = state.theme as unknown as Theme;
  68. return {
  69. ...provided,
  70. display: 'grid',
  71. gridTemplateColumns: 'max-content 1fr',
  72. alignItems: 'center',
  73. gridGap: space(1),
  74. ':before': {
  75. backgroundColor: theme.backgroundSecondary,
  76. height: 24,
  77. width: 24,
  78. borderRadius: 3,
  79. content: '""',
  80. display: 'block',
  81. },
  82. };
  83. },
  84. placeholder: provided => ({
  85. ...provided,
  86. paddingLeft: 32,
  87. }),
  88. };
  89. type Props = {
  90. onChange: (value: any) => any;
  91. /**
  92. * Received via withOrganization
  93. * Note: withOrganization collects it from the context, this is not type safe
  94. */
  95. organization: Organization;
  96. /**
  97. * Controls whether the dropdown allows to create a new team
  98. */
  99. allowCreate?: boolean;
  100. includeUnassigned?: boolean;
  101. /**
  102. * Can be used to restrict teams to a certain project and allow for new teams to be add to that project
  103. */
  104. project?: Project;
  105. /**
  106. * Function to control whether a team should be shown in the dropdown
  107. */
  108. teamFilter?: (team: Team) => boolean;
  109. /**
  110. * Controls whether the value in the dropdown is a team id or team slug
  111. */
  112. useId?: boolean;
  113. } & ControlProps;
  114. type TeamActor = {
  115. id: string;
  116. name: string;
  117. type: 'team';
  118. };
  119. type TeamOption = GeneralSelectValue & {
  120. actor: TeamActor | null;
  121. searchKey: string;
  122. };
  123. function TeamSelector(props: Props) {
  124. const {
  125. allowCreate,
  126. includeUnassigned,
  127. styles: stylesProp,
  128. onChange,
  129. ...extraProps
  130. } = props;
  131. const {teamFilter, organization, project, multiple, value, useId} = props;
  132. const api = useApi();
  133. const {teams, fetching, onSearch} = useTeams();
  134. // TODO(ts) This type could be improved when react-select types are better.
  135. const selectRef = useRef<any>(null);
  136. const canCreateTeam = organization?.access?.includes('project:admin') ?? false;
  137. const canAddTeam = organization?.access?.includes('project:write') ?? false;
  138. const createTeamOption = useCallback(
  139. (team: Team): TeamOption => ({
  140. value: useId ? team.id : team.slug,
  141. label: `#${team.slug}`,
  142. leadingItems: <IdBadge team={team} hideName />,
  143. searchKey: team.slug,
  144. actor: {
  145. type: 'team',
  146. id: team.id,
  147. name: team.slug,
  148. },
  149. }),
  150. [useId]
  151. );
  152. /**
  153. * Closes the select menu by blurring input if possible since that seems to
  154. * be the only way to close it.
  155. */
  156. function closeSelectMenu() {
  157. if (!selectRef.current) {
  158. return;
  159. }
  160. const select = selectRef.current.select;
  161. const input: HTMLInputElement = select.inputRef;
  162. if (input) {
  163. // I don't think there's another way to close `react-select`
  164. input.blur();
  165. }
  166. }
  167. const handleAddTeamToProject = useCallback(
  168. async (team: Team) => {
  169. if (!project) {
  170. closeSelectMenu();
  171. return;
  172. }
  173. // Copy old value
  174. const oldValue = multiple ? [...(value ?? [])] : {value};
  175. // Optimistic update
  176. onChange?.(createTeamOption(team));
  177. try {
  178. await addTeamToProject(api, organization.slug, project.slug, team);
  179. } catch (err) {
  180. // Unable to add team to project, revert select menu value
  181. onChange?.(oldValue);
  182. }
  183. closeSelectMenu();
  184. },
  185. [api, createTeamOption, multiple, onChange, organization, project, value]
  186. );
  187. const createTeam = useCallback(
  188. () =>
  189. new Promise<TeamOption>(resolve => {
  190. openCreateTeamModal({
  191. organization,
  192. onClose: async team => {
  193. if (project) {
  194. await handleAddTeamToProject(team);
  195. }
  196. resolve(createTeamOption(team));
  197. },
  198. });
  199. }),
  200. [createTeamOption, handleAddTeamToProject, organization, project]
  201. );
  202. const handleChange = useCallback(
  203. (newValue: TeamOption | TeamOption[]) => {
  204. if (multiple) {
  205. const options = newValue as TeamOption[];
  206. const shouldCreate = options.find(option => option.value === CREATE_TEAM_VALUE);
  207. if (shouldCreate) {
  208. createTeam().then(newTeamOption => {
  209. onChange?.([
  210. ...options.filter(option => option.value !== CREATE_TEAM_VALUE),
  211. newTeamOption,
  212. ]);
  213. });
  214. } else {
  215. onChange?.(options);
  216. }
  217. return;
  218. }
  219. const option = newValue as TeamOption;
  220. if (option.value === CREATE_TEAM_VALUE) {
  221. createTeam().then(newTramOption => {
  222. onChange?.(newTramOption);
  223. });
  224. } else {
  225. onChange?.(option);
  226. }
  227. },
  228. [createTeam, multiple, onChange]
  229. );
  230. const createTeamOutsideProjectOption = useCallback(
  231. (team: Team): TeamOption => {
  232. // If the option/team is currently selected, optimistically assume it is now a part of the project
  233. if (value === (useId ? team.id : team.slug)) {
  234. return createTeamOption(team);
  235. }
  236. return {
  237. ...createTeamOption(team),
  238. disabled: true,
  239. label: `#${team.slug}`,
  240. leadingItems: <IdBadge team={team} hideName />,
  241. trailingItems: (
  242. <Tooltip
  243. title={
  244. canAddTeam
  245. ? t('Add %s to project', `#${team.slug}`)
  246. : t('You do not have permission to add team to project.')
  247. }
  248. containerDisplayMode="flex"
  249. >
  250. <AddToProjectButton
  251. size="zero"
  252. borderless
  253. disabled={!canAddTeam}
  254. onClick={() => handleAddTeamToProject(team)}
  255. icon={<IconAdd isCircled />}
  256. aria-label={t('Add %s to project', `#${team.slug}`)}
  257. />
  258. </Tooltip>
  259. ),
  260. tooltip: t('%s is not a member of project', `#${team.slug}`),
  261. };
  262. },
  263. [canAddTeam, createTeamOption, handleAddTeamToProject, useId, value]
  264. );
  265. function getOptions() {
  266. const filteredTeams = teamFilter ? teams.filter(teamFilter) : teams;
  267. const createOption = {
  268. value: CREATE_TEAM_VALUE,
  269. label: t('Create team'),
  270. leadingItems: <IconAdd isCircled />,
  271. searchKey: 'create',
  272. actor: null,
  273. disabled: !canCreateTeam,
  274. 'data-test-id': 'create-team-option',
  275. };
  276. if (project) {
  277. const teamsInProjectIdSet = new Set(project.teams.map(team => team.id));
  278. const teamsInProject = filteredTeams.filter(team =>
  279. teamsInProjectIdSet.has(team.id)
  280. );
  281. const teamsNotInProject = filteredTeams.filter(
  282. team => !teamsInProjectIdSet.has(team.id)
  283. );
  284. return [
  285. ...(allowCreate ? [createOption] : []),
  286. ...teamsInProject.map(createTeamOption),
  287. ...teamsNotInProject.map(createTeamOutsideProjectOption),
  288. ...(includeUnassigned ? [unassignedOption] : []),
  289. ];
  290. }
  291. return [
  292. ...(allowCreate ? [createOption] : []),
  293. ...filteredTeams.map(createTeamOption),
  294. ...(includeUnassigned ? [unassignedOption] : []),
  295. ];
  296. }
  297. const options = useMemo(getOptions, [
  298. teamFilter,
  299. teams,
  300. canCreateTeam,
  301. project,
  302. allowCreate,
  303. createTeamOption,
  304. includeUnassigned,
  305. createTeamOutsideProjectOption,
  306. ]);
  307. const handleInputCHange = useMemo(
  308. () => debounce(val => void onSearch(val), DEFAULT_DEBOUNCE_DURATION),
  309. [onSearch]
  310. );
  311. const styles = useMemo(
  312. () => ({
  313. ...(includeUnassigned ? unassignedSelectStyles : {}),
  314. ...(multiple ? {} : placeholderSelectStyles),
  315. ...(stylesProp ?? {}),
  316. }),
  317. [includeUnassigned, multiple, stylesProp]
  318. );
  319. return (
  320. <SelectControl
  321. ref={selectRef}
  322. options={options}
  323. onInputChange={handleInputCHange}
  324. getOptionValue={getOptionValue}
  325. filterOption={filterOption}
  326. styles={styles}
  327. isLoading={fetching}
  328. onChange={handleChange}
  329. {...extraProps}
  330. />
  331. );
  332. }
  333. const AddToProjectButton = styled(Button)`
  334. flex-shrink: 0;
  335. `;
  336. export {TeamSelector};
  337. // TODO(davidenwang): this is broken due to incorrect types on react-select
  338. export default withOrganization(TeamSelector) as unknown as (
  339. p: Omit<Props, 'organization'>
  340. ) => JSX.Element;