teamSelector.tsx 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261
  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: any) => ({
  41. ...provided,
  42. svg: {
  43. color: state.isSelected && state.theme.white,
  44. },
  45. }),
  46. };
  47. type Props = {
  48. organization: Organization;
  49. teams: Team[];
  50. onChange: (value: any) => any;
  51. /**
  52. * Function to control whether a team should be shown in the dropdown
  53. */
  54. teamFilter?: (team: Team) => boolean;
  55. /**
  56. * Can be used to restrict teams to a certain project and allow for new teams to be add to that project
  57. */
  58. project?: Project;
  59. /**
  60. * Controls whether the value in the dropdown is a team id or team slug
  61. */
  62. useId?: boolean;
  63. includeUnassigned?: boolean;
  64. } & ControlProps;
  65. type TeamActor = {
  66. type: 'team';
  67. id: string;
  68. name: string;
  69. };
  70. type TeamOption = {
  71. value: string | null;
  72. label: React.ReactElement;
  73. searchKey: string;
  74. actor: TeamActor | null;
  75. disabled?: boolean;
  76. };
  77. function TeamSelector(props: Props) {
  78. const {includeUnassigned, styles, ...extraProps} = props;
  79. const {teams, teamFilter, organization, project, multiple, value, useId, onChange} =
  80. props;
  81. const api = useApi();
  82. const [options, setOptions] = useState<TeamOption[]>([]);
  83. // TODO(ts) This type could be improved when react-select types are better.
  84. const selectRef = useRef<any>(null);
  85. const createTeamOption = (team: Team): TeamOption => ({
  86. value: useId ? team.id : team.slug,
  87. label: <IdBadge team={team} />,
  88. searchKey: `#${team.slug}`,
  89. actor: {
  90. type: 'team',
  91. id: team.id,
  92. name: team.slug,
  93. },
  94. });
  95. /**
  96. * Closes the select menu by blurring input if possible since that seems to
  97. * be the only way to close it.
  98. */
  99. function closeSelectMenu() {
  100. if (!selectRef.current) {
  101. return;
  102. }
  103. const select = selectRef.current.select;
  104. const input: HTMLInputElement = select.inputRef;
  105. if (input) {
  106. // I don't think there's another way to close `react-select`
  107. input.blur();
  108. }
  109. }
  110. async function handleAddTeamToProject(team: Team) {
  111. if (!project) {
  112. closeSelectMenu();
  113. return;
  114. }
  115. // Copy old value
  116. const oldValue = multiple ? [...(value ?? [])] : {value};
  117. // Optimistic update
  118. onChange?.(createTeamOption(team));
  119. try {
  120. await addTeamToProject(api, organization.slug, project.slug, team);
  121. // Remove add to project button without changing order
  122. const newOptions = options.map(option => {
  123. if (option.actor?.id === team.id) {
  124. option.disabled = false;
  125. option.label = <IdBadge team={team} />;
  126. }
  127. return option;
  128. });
  129. setOptions(newOptions);
  130. } catch (err) {
  131. // Unable to add team to project, revert select menu value
  132. onChange?.(oldValue);
  133. }
  134. closeSelectMenu();
  135. }
  136. function createTeamOutsideProjectOption(team: Team): TeamOption {
  137. const canAddTeam = organization.access.includes('project:write');
  138. return {
  139. ...createTeamOption(team),
  140. disabled: true,
  141. label: (
  142. <TeamOutsideProject>
  143. <DisabledLabel>
  144. <Tooltip
  145. position="left"
  146. title={t('%s is not a member of project', `#${team.slug}`)}
  147. >
  148. <IdBadge team={team} />
  149. </Tooltip>
  150. </DisabledLabel>
  151. <Tooltip
  152. title={
  153. canAddTeam
  154. ? t('Add %s to project', `#${team.slug}`)
  155. : t('You do not have permission to add team to project.')
  156. }
  157. >
  158. <AddToProjectButton
  159. type="button"
  160. size="zero"
  161. borderless
  162. disabled={!canAddTeam}
  163. onClick={() => handleAddTeamToProject(team)}
  164. icon={<IconAdd isCircled />}
  165. />
  166. </Tooltip>
  167. </TeamOutsideProject>
  168. ),
  169. };
  170. }
  171. function getInitialOptions() {
  172. const filteredTeams = teamFilter ? teams.filter(teamFilter) : teams;
  173. if (project) {
  174. const teamsInProjectIdSet = new Set(project.teams.map(team => team.id));
  175. const teamsInProject = filteredTeams.filter(team =>
  176. teamsInProjectIdSet.has(team.id)
  177. );
  178. const teamsNotInProject = filteredTeams.filter(
  179. team => !teamsInProjectIdSet.has(team.id)
  180. );
  181. return [
  182. ...teamsInProject.map(createTeamOption),
  183. ...teamsNotInProject.map(createTeamOutsideProjectOption),
  184. ...(includeUnassigned ? [unassignedOption] : []),
  185. ];
  186. }
  187. return [
  188. ...filteredTeams.map(createTeamOption),
  189. ...(includeUnassigned ? [unassignedOption] : []),
  190. ];
  191. }
  192. useEffect(
  193. () => void setOptions(getInitialOptions()),
  194. [teams, teamFilter, project, includeUnassigned]
  195. );
  196. return (
  197. <SelectControl
  198. ref={selectRef}
  199. options={options}
  200. isOptionDisabled={option => !!option.disabled}
  201. styles={{
  202. ...(styles ?? {}),
  203. ...(includeUnassigned ? unassignedSelectStyles : {}),
  204. }}
  205. {...extraProps}
  206. />
  207. );
  208. }
  209. const TeamOutsideProject = styled('div')`
  210. display: flex;
  211. justify-content: space-between;
  212. align-items: flex-end;
  213. `;
  214. const DisabledLabel = styled('div')`
  215. display: flex;
  216. opacity: 0.5;
  217. overflow: hidden; /* Needed so that "Add to team" button can fit */
  218. `;
  219. const AddToProjectButton = styled(Button)`
  220. flex-shrink: 0;
  221. `;
  222. export {TeamSelector};
  223. // TODO(davidenwang): this is broken due to incorrect types on react-select
  224. export default withTeams(withOrganization(TeamSelector)) as unknown as (
  225. p: Omit<Props, 'teams' | 'organization'>
  226. ) => JSX.Element;