teamProjects.tsx 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200
  1. import {Fragment, useState} from 'react';
  2. import type {RouteComponentProps} from 'react-router';
  3. import styled from '@emotion/styled';
  4. import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator';
  5. import {hasEveryAccess} from 'sentry/components/acl/access';
  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 from 'sentry/components/panels/panel';
  14. import PanelBody from 'sentry/components/panels/panelBody';
  15. import PanelHeader from 'sentry/components/panels/panelHeader';
  16. import PanelItem from 'sentry/components/panels/panelItem';
  17. import {Tooltip} from 'sentry/components/tooltip';
  18. import {IconFlag, IconSubtract} from 'sentry/icons';
  19. import {t} from 'sentry/locale';
  20. import ProjectsStore from 'sentry/stores/projectsStore';
  21. import {space} from 'sentry/styles/space';
  22. import type {Project, Team} from 'sentry/types';
  23. import {sortProjects} from 'sentry/utils';
  24. import {useApiQuery} from 'sentry/utils/queryClient';
  25. import useApi from 'sentry/utils/useApi';
  26. import useOrganization from 'sentry/utils/useOrganization';
  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. interface TeamProjectsProps extends RouteComponentProps<{teamId: string}, {}> {
  31. team: Team;
  32. }
  33. function TeamProjects({team, location, params}: TeamProjectsProps) {
  34. const organization = useOrganization();
  35. const api = useApi({persistInFlight: true});
  36. const [query, setQuery] = useState<string>('');
  37. const teamId = params.teamId;
  38. const {
  39. data: linkedProjects,
  40. isError: linkedProjectsError,
  41. isLoading: linkedProjectsLoading,
  42. getResponseHeader: linkedProjectsHeaders,
  43. refetch: refetchLinkedProjects,
  44. } = useApiQuery<Project[]>(
  45. [
  46. `/organizations/${organization.slug}/projects/`,
  47. {
  48. query: {
  49. query: `team:${teamId}`,
  50. cursor: location.query.cursor,
  51. },
  52. },
  53. ],
  54. {staleTime: 0}
  55. );
  56. const {
  57. data: unlinkedProjects = [],
  58. isLoading: loadingUnlinkedProjects,
  59. refetch: refetchUnlinkedProjects,
  60. } = useApiQuery<Project[]>(
  61. [
  62. `/organizations/${organization.slug}/projects/`,
  63. {
  64. query: {query: query ? `!team:${teamId} ${query}` : `!team:${teamId}`},
  65. },
  66. ],
  67. {staleTime: 0}
  68. );
  69. const handleLinkProject = (project: Project, action: string) => {
  70. api.request(`/projects/${organization.slug}/${project.slug}/teams/${teamId}/`, {
  71. method: action === 'add' ? 'POST' : 'DELETE',
  72. success: resp => {
  73. refetchLinkedProjects();
  74. refetchUnlinkedProjects();
  75. ProjectsStore.onUpdateSuccess(resp);
  76. addSuccessMessage(
  77. action === 'add'
  78. ? t('Successfully added project to team.')
  79. : t('Successfully removed project from team')
  80. );
  81. },
  82. error: () => {
  83. addErrorMessage(t("Wasn't able to change project association."));
  84. },
  85. });
  86. };
  87. const linkedProjectsPageLinks = linkedProjectsHeaders?.('Link');
  88. const hasWriteAccess = hasEveryAccess(['team:write'], {organization, team});
  89. const otherProjects = unlinkedProjects
  90. .filter(p => p.access.includes('project:write'))
  91. .map(p => ({
  92. value: p.id,
  93. searchKey: p.slug,
  94. label: <ProjectListElement>{p.slug}</ProjectListElement>,
  95. }));
  96. return (
  97. <Fragment>
  98. <TextBlock>
  99. {t(
  100. 'If you have Team Admin permissions for other projects, you can associate them with this team.'
  101. )}
  102. </TextBlock>
  103. <PermissionAlert access={['team:write']} team={team} />
  104. <Panel>
  105. <PanelHeader hasButtons>
  106. <div>{t('Projects')}</div>
  107. <div style={{textTransform: 'none', fontWeight: 'normal'}}>
  108. {!hasWriteAccess ? (
  109. <DropdownButton
  110. disabled
  111. title={t('You do not have enough permission to associate a project.')}
  112. size="xs"
  113. >
  114. {t('Add Project')}
  115. </DropdownButton>
  116. ) : (
  117. <DropdownAutoComplete
  118. items={otherProjects}
  119. onChange={evt => setQuery(evt.target.value)}
  120. onSelect={selection => {
  121. const project = unlinkedProjects.find(p => p.id === selection.value);
  122. if (project) {
  123. handleLinkProject(project, 'add');
  124. }
  125. }}
  126. onClose={() => setQuery('')}
  127. busy={loadingUnlinkedProjects}
  128. emptyMessage={t('You are not an admin for any other projects')}
  129. alignMenu="right"
  130. >
  131. {({isOpen}) => (
  132. <DropdownButton isOpen={isOpen} size="xs">
  133. {t('Add Project')}
  134. </DropdownButton>
  135. )}
  136. </DropdownAutoComplete>
  137. )}
  138. </div>
  139. </PanelHeader>
  140. <PanelBody>
  141. {linkedProjectsError && (
  142. <LoadingError onRetry={() => refetchLinkedProjects()} />
  143. )}
  144. {linkedProjectsLoading && <LoadingIndicator />}
  145. {linkedProjects?.length ? (
  146. sortProjects(linkedProjects).map(project => (
  147. <StyledPanelItem key={project.id}>
  148. <ProjectListItem project={project} organization={organization} />
  149. <Tooltip
  150. disabled={hasWriteAccess}
  151. title={t(
  152. 'You do not have enough permission to change project association.'
  153. )}
  154. >
  155. <Button
  156. size="sm"
  157. disabled={!hasWriteAccess}
  158. icon={<IconSubtract isCircled />}
  159. aria-label={t('Remove')}
  160. onClick={() => {
  161. handleLinkProject(project, 'remove');
  162. }}
  163. >
  164. {t('Remove')}
  165. </Button>
  166. </Tooltip>
  167. </StyledPanelItem>
  168. ))
  169. ) : linkedProjectsLoading ? null : (
  170. <EmptyMessage size="large" icon={<IconFlag size="xl" />}>
  171. {t("This team doesn't have access to any projects.")}
  172. </EmptyMessage>
  173. )}
  174. </PanelBody>
  175. </Panel>
  176. <Pagination pageLinks={linkedProjectsPageLinks} />
  177. </Fragment>
  178. );
  179. }
  180. const StyledPanelItem = styled(PanelItem)`
  181. display: flex;
  182. align-items: center;
  183. justify-content: space-between;
  184. padding: ${space(2)};
  185. max-width: 100%;
  186. `;
  187. const ProjectListElement = styled('div')`
  188. padding: ${space(0.25)} 0;
  189. `;
  190. export default TeamProjects;