teamProjects.tsx 8.3 KB

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