teamProjects.tsx 7.0 KB

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