teamSelector.tsx 7.0 KB

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