teamProjects.tsx 7.5 KB

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