teamSelector.tsx 7.2 KB

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