teamSelector.tsx 7.4 KB

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