teamSelector.tsx 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284
  1. import {useEffect, useRef, useState} from 'react';
  2. import {StylesConfig} from 'react-select';
  3. import styled from '@emotion/styled';
  4. import {addTeamToProject} from 'app/actionCreators/projects';
  5. import Button from 'app/components/button';
  6. import SelectControl, {ControlProps} from 'app/components/forms/selectControl';
  7. import IdBadge from 'app/components/idBadge';
  8. import Tooltip from 'app/components/tooltip';
  9. import {IconAdd, IconUser} from 'app/icons';
  10. import {t} from 'app/locale';
  11. import space from 'app/styles/space';
  12. import {Organization, Project, Team} from 'app/types';
  13. import useApi from 'app/utils/useApi';
  14. import withOrganization from 'app/utils/withOrganization';
  15. import withTeams from 'app/utils/withTeams';
  16. const UnassignedWrapper = styled('div')`
  17. display: flex;
  18. align-items: center;
  19. `;
  20. const StyledIconUser = styled(IconUser)`
  21. margin-left: ${space(0.25)};
  22. margin-right: ${space(1)};
  23. color: ${p => p.theme.gray400};
  24. `;
  25. // An option to be unassigned on the team dropdown
  26. const unassignedOption = {
  27. value: null,
  28. label: (
  29. <UnassignedWrapper>
  30. <StyledIconUser size="20px" />
  31. {t('Unassigned')}
  32. </UnassignedWrapper>
  33. ),
  34. searchKey: 'unassigned',
  35. actor: null,
  36. disabled: false,
  37. };
  38. // Ensures that the svg icon is white when selected
  39. const unassignedSelectStyles: StylesConfig = {
  40. option: (provided, state) => ({
  41. ...provided,
  42. svg: {
  43. color: state.isSelected && state.theme.white,
  44. },
  45. }),
  46. };
  47. const placeholderSelectStyles: StylesConfig = {
  48. input: (provided, state) => ({
  49. ...provided,
  50. display: 'grid',
  51. gridTemplateColumns: 'max-content 1fr',
  52. alignItems: 'center',
  53. gridGap: space(1),
  54. ':before': {
  55. backgroundColor: state.theme.backgroundSecondary,
  56. height: 24,
  57. width: 24,
  58. borderRadius: 3,
  59. content: '""',
  60. display: 'block',
  61. },
  62. }),
  63. placeholder: provided => ({
  64. ...provided,
  65. paddingLeft: 32,
  66. }),
  67. };
  68. type Props = {
  69. organization: Organization;
  70. teams: Team[];
  71. onChange: (value: any) => any;
  72. /**
  73. * Function to control whether a team should be shown in the dropdown
  74. */
  75. teamFilter?: (team: Team) => boolean;
  76. /**
  77. * Can be used to restrict teams to a certain project and allow for new teams to be add to that project
  78. */
  79. project?: Project;
  80. /**
  81. * Controls whether the value in the dropdown is a team id or team slug
  82. */
  83. useId?: boolean;
  84. includeUnassigned?: boolean;
  85. } & ControlProps;
  86. type TeamActor = {
  87. type: 'team';
  88. id: string;
  89. name: string;
  90. };
  91. type TeamOption = {
  92. value: string | null;
  93. label: React.ReactElement;
  94. searchKey: string;
  95. actor: TeamActor | null;
  96. disabled?: boolean;
  97. };
  98. function TeamSelector(props: Props) {
  99. const {includeUnassigned, styles, ...extraProps} = props;
  100. const {teams, teamFilter, organization, project, multiple, value, useId, onChange} =
  101. props;
  102. const api = useApi();
  103. const [options, setOptions] = useState<TeamOption[]>([]);
  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: <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. // Remove add to project button without changing order
  143. const newOptions = options.map(option => {
  144. if (option.actor?.id === team.id) {
  145. option.disabled = false;
  146. option.label = <IdBadge team={team} />;
  147. }
  148. return option;
  149. });
  150. setOptions(newOptions);
  151. } catch (err) {
  152. // Unable to add team to project, revert select menu value
  153. onChange?.(oldValue);
  154. }
  155. closeSelectMenu();
  156. }
  157. function createTeamOutsideProjectOption(team: Team): TeamOption {
  158. const canAddTeam = organization.access.includes('project:write');
  159. return {
  160. ...createTeamOption(team),
  161. disabled: true,
  162. label: (
  163. <TeamOutsideProject>
  164. <DisabledLabel>
  165. <Tooltip
  166. position="left"
  167. title={t('%s is not a member of project', `#${team.slug}`)}
  168. >
  169. <IdBadge team={team} />
  170. </Tooltip>
  171. </DisabledLabel>
  172. <Tooltip
  173. title={
  174. canAddTeam
  175. ? t('Add %s to project', `#${team.slug}`)
  176. : t('You do not have permission to add team to project.')
  177. }
  178. >
  179. <AddToProjectButton
  180. type="button"
  181. size="zero"
  182. borderless
  183. disabled={!canAddTeam}
  184. onClick={() => handleAddTeamToProject(team)}
  185. icon={<IconAdd isCircled />}
  186. />
  187. </Tooltip>
  188. </TeamOutsideProject>
  189. ),
  190. };
  191. }
  192. function getInitialOptions() {
  193. const filteredTeams = teamFilter ? teams.filter(teamFilter) : teams;
  194. if (project) {
  195. const teamsInProjectIdSet = new Set(project.teams.map(team => team.id));
  196. const teamsInProject = filteredTeams.filter(team =>
  197. teamsInProjectIdSet.has(team.id)
  198. );
  199. const teamsNotInProject = filteredTeams.filter(
  200. team => !teamsInProjectIdSet.has(team.id)
  201. );
  202. return [
  203. ...teamsInProject.map(createTeamOption),
  204. ...teamsNotInProject.map(createTeamOutsideProjectOption),
  205. ...(includeUnassigned ? [unassignedOption] : []),
  206. ];
  207. }
  208. return [
  209. ...filteredTeams.map(createTeamOption),
  210. ...(includeUnassigned ? [unassignedOption] : []),
  211. ];
  212. }
  213. useEffect(
  214. () => void setOptions(getInitialOptions()),
  215. [teams, teamFilter, project, includeUnassigned]
  216. );
  217. return (
  218. <SelectControl
  219. ref={selectRef}
  220. options={options}
  221. isOptionDisabled={option => !!option.disabled}
  222. styles={{
  223. ...(styles ?? {}),
  224. ...(includeUnassigned ? unassignedSelectStyles : {}),
  225. ...placeholderSelectStyles,
  226. }}
  227. {...extraProps}
  228. />
  229. );
  230. }
  231. const TeamOutsideProject = styled('div')`
  232. display: flex;
  233. justify-content: space-between;
  234. align-items: flex-end;
  235. `;
  236. const DisabledLabel = styled('div')`
  237. display: flex;
  238. opacity: 0.5;
  239. overflow: hidden; /* Needed so that "Add to team" button can fit */
  240. `;
  241. const AddToProjectButton = styled(Button)`
  242. flex-shrink: 0;
  243. `;
  244. export {TeamSelector};
  245. // TODO(davidenwang): this is broken due to incorrect types on react-select
  246. export default withTeams(withOrganization(TeamSelector)) as unknown as (
  247. p: Omit<Props, 'teams' | 'organization'>
  248. ) => JSX.Element;