teamProjects.tsx 7.0 KB

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