teamProjects.tsx 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271
  1. import {Component, Fragment} from 'react';
  2. import {RouteComponentProps} from 'react-router';
  3. import styled from '@emotion/styled';
  4. import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator';
  5. import {Client} from 'sentry/api';
  6. import {hasEveryAccess} from 'sentry/components/acl/access';
  7. import {Button} from 'sentry/components/button';
  8. import DropdownAutoComplete from 'sentry/components/dropdownAutoComplete';
  9. import DropdownButton from 'sentry/components/dropdownButton';
  10. import EmptyMessage from 'sentry/components/emptyMessage';
  11. import LoadingError from 'sentry/components/loadingError';
  12. import LoadingIndicator from 'sentry/components/loadingIndicator';
  13. import Pagination from 'sentry/components/pagination';
  14. import {Panel, PanelBody, PanelHeader, PanelItem} from 'sentry/components/panels';
  15. import {Tooltip} from 'sentry/components/tooltip';
  16. import {IconFlag, IconSubtract} from 'sentry/icons';
  17. import {t} from 'sentry/locale';
  18. import ProjectsStore from 'sentry/stores/projectsStore';
  19. import {space} from 'sentry/styles/space';
  20. import {Organization, Project, Team} from 'sentry/types';
  21. import {sortProjects} from 'sentry/utils';
  22. import withApi from 'sentry/utils/withApi';
  23. import withOrganization from 'sentry/utils/withOrganization';
  24. import ProjectListItem from 'sentry/views/settings/components/settingsProjectItem';
  25. import TextBlock from 'sentry/views/settings/components/text/textBlock';
  26. import PermissionAlert from 'sentry/views/settings/project/permissionAlert';
  27. type Props = {
  28. api: Client;
  29. organization: Organization;
  30. team: Team;
  31. } & RouteComponentProps<{teamId: string}, {}>;
  32. type State = {
  33. error: boolean;
  34. linkedProjects: Project[];
  35. loading: boolean;
  36. pageLinks: null | string;
  37. unlinkedProjects: Project[];
  38. };
  39. type DropdownAutoCompleteProps = React.ComponentProps<typeof DropdownAutoComplete>;
  40. type Item = Parameters<NonNullable<DropdownAutoCompleteProps['onSelect']>>[0];
  41. class TeamProjects extends Component<Props, State> {
  42. state: State = {
  43. error: false,
  44. loading: true,
  45. pageLinks: null,
  46. unlinkedProjects: [],
  47. linkedProjects: [],
  48. };
  49. componentDidMount() {
  50. this.fetchAll();
  51. }
  52. componentDidUpdate(prevProps: Props) {
  53. if (
  54. prevProps.organization.slug !== this.props.organization.slug ||
  55. prevProps.params.teamId !== this.props.params.teamId
  56. ) {
  57. this.fetchAll();
  58. }
  59. if (prevProps.location !== this.props.location) {
  60. this.fetchTeamProjects();
  61. }
  62. }
  63. fetchAll = () => {
  64. this.fetchTeamProjects();
  65. this.fetchUnlinkedProjects();
  66. };
  67. fetchTeamProjects() {
  68. const {
  69. location,
  70. organization,
  71. params: {teamId},
  72. } = this.props;
  73. this.setState({loading: true});
  74. this.props.api
  75. .requestPromise(`/organizations/${organization.slug}/projects/`, {
  76. query: {
  77. query: `team:${teamId}`,
  78. cursor: location.query.cursor || '',
  79. },
  80. includeAllArgs: true,
  81. })
  82. .then(([linkedProjects, _, resp]) => {
  83. this.setState({
  84. loading: false,
  85. error: false,
  86. linkedProjects,
  87. pageLinks: resp?.getResponseHeader('Link') ?? null,
  88. });
  89. })
  90. .catch(() => {
  91. this.setState({loading: false, error: true});
  92. });
  93. }
  94. fetchUnlinkedProjects(query = '') {
  95. const {
  96. organization,
  97. params: {teamId},
  98. } = this.props;
  99. this.props.api
  100. .requestPromise(`/organizations/${organization.slug}/projects/`, {
  101. query: {
  102. query: query ? `!team:${teamId} ${query}` : `!team:${teamId}`,
  103. },
  104. })
  105. .then(unlinkedProjects => {
  106. this.setState({unlinkedProjects});
  107. });
  108. }
  109. handleLinkProject = (project: Project, action: string) => {
  110. const {organization} = this.props;
  111. const {teamId} = this.props.params;
  112. this.props.api.request(
  113. `/projects/${organization.slug}/${project.slug}/teams/${teamId}/`,
  114. {
  115. method: action === 'add' ? 'POST' : 'DELETE',
  116. success: resp => {
  117. this.fetchAll();
  118. ProjectsStore.onUpdateSuccess(resp);
  119. addSuccessMessage(
  120. action === 'add'
  121. ? t('Successfully added project to team.')
  122. : t('Successfully removed project from team')
  123. );
  124. },
  125. error: () => {
  126. addErrorMessage(t("Wasn't able to change project association."));
  127. },
  128. }
  129. );
  130. };
  131. handleProjectSelected = (selection: Item) => {
  132. const project = this.state.unlinkedProjects.find(p => p.id === selection.value);
  133. if (project) {
  134. this.handleLinkProject(project, 'add');
  135. }
  136. };
  137. handleQueryUpdate = (evt: React.ChangeEvent<HTMLInputElement>) => {
  138. this.fetchUnlinkedProjects(evt.target.value);
  139. };
  140. projectPanelContents(projects: Project[]) {
  141. const {organization, team} = this.props;
  142. const hasWriteAccess = hasEveryAccess(['team:write'], {organization, team});
  143. return projects.length ? (
  144. sortProjects(projects).map(project => (
  145. <StyledPanelItem key={project.id}>
  146. <ProjectListItem project={project} organization={organization} />
  147. <Tooltip
  148. disabled={hasWriteAccess}
  149. title={t('You do not have enough permission to change project association.')}
  150. >
  151. <Button
  152. size="sm"
  153. disabled={!hasWriteAccess}
  154. icon={<IconSubtract isCircled size="xs" />}
  155. aria-label={t('Remove')}
  156. onClick={() => {
  157. this.handleLinkProject(project, 'remove');
  158. }}
  159. >
  160. {t('Remove')}
  161. </Button>
  162. </Tooltip>
  163. </StyledPanelItem>
  164. ))
  165. ) : (
  166. <EmptyMessage size="large" icon={<IconFlag size="xl" />}>
  167. {t("This team doesn't have access to any projects.")}
  168. </EmptyMessage>
  169. );
  170. }
  171. render() {
  172. const {organization, team} = this.props;
  173. const {linkedProjects, unlinkedProjects, error, loading} = this.state;
  174. if (error) {
  175. return <LoadingError onRetry={() => this.fetchAll()} />;
  176. }
  177. if (loading) {
  178. return <LoadingIndicator />;
  179. }
  180. const hasWriteAccess = hasEveryAccess(['team:write'], {organization, team});
  181. const otherProjects = unlinkedProjects
  182. .filter(p => p.access.includes('project:write'))
  183. .map(p => ({
  184. value: p.id,
  185. searchKey: p.slug,
  186. label: <ProjectListElement>{p.slug}</ProjectListElement>,
  187. }));
  188. return (
  189. <Fragment>
  190. <TextBlock>
  191. {t(
  192. 'If you have Team Admin permissions for other projects, you can associate them with this team.'
  193. )}
  194. </TextBlock>
  195. <PermissionAlert access={['team:write']} team={team} />
  196. <Panel>
  197. <PanelHeader hasButtons>
  198. <div>{t('Projects')}</div>
  199. <div style={{textTransform: 'none'}}>
  200. {!hasWriteAccess ? (
  201. <DropdownButton
  202. disabled
  203. title={t('You do not have enough permission to associate a project.')}
  204. size="xs"
  205. >
  206. {t('Add Project')}
  207. </DropdownButton>
  208. ) : (
  209. <DropdownAutoComplete
  210. items={otherProjects}
  211. onChange={this.handleQueryUpdate}
  212. onSelect={this.handleProjectSelected}
  213. emptyMessage={t('You are not an admin for any other projects')}
  214. alignMenu="right"
  215. >
  216. {({isOpen}) => (
  217. <DropdownButton isOpen={isOpen} size="xs">
  218. {t('Add Project')}
  219. </DropdownButton>
  220. )}
  221. </DropdownAutoComplete>
  222. )}
  223. </div>
  224. </PanelHeader>
  225. <PanelBody>{this.projectPanelContents(linkedProjects)}</PanelBody>
  226. </Panel>
  227. <Pagination pageLinks={this.state.pageLinks} {...this.props} />
  228. </Fragment>
  229. );
  230. }
  231. }
  232. const StyledPanelItem = styled(PanelItem)`
  233. display: flex;
  234. align-items: center;
  235. justify-content: space-between;
  236. padding: ${space(2)};
  237. `;
  238. const ProjectListElement = styled('div')`
  239. padding: ${space(0.25)} 0;
  240. `;
  241. export {TeamProjects};
  242. export default withApi(withOrganization(TeamProjects));