teamProjects.tsx 7.4 KB

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