|
@@ -1,9 +1,8 @@
|
|
|
-import {Component, Fragment} from 'react';
|
|
|
-import {RouteComponentProps} from 'react-router';
|
|
|
+import {Fragment, useState} from 'react';
|
|
|
+import type {RouteComponentProps} from 'react-router';
|
|
|
import styled from '@emotion/styled';
|
|
|
|
|
|
import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator';
|
|
|
-import {Client} from 'sentry/api';
|
|
|
import {hasEveryAccess} from 'sentry/components/acl/access';
|
|
|
import {Button} from 'sentry/components/button';
|
|
|
import DropdownAutoComplete from 'sentry/components/dropdownAutoComplete';
|
|
@@ -21,241 +20,169 @@ import {IconFlag, IconSubtract} from 'sentry/icons';
|
|
|
import {t} from 'sentry/locale';
|
|
|
import ProjectsStore from 'sentry/stores/projectsStore';
|
|
|
import {space} from 'sentry/styles/space';
|
|
|
-import {Organization, Project, Team} from 'sentry/types';
|
|
|
+import type {Project, Team} from 'sentry/types';
|
|
|
import {sortProjects} from 'sentry/utils';
|
|
|
-import withApi from 'sentry/utils/withApi';
|
|
|
-import withOrganization from 'sentry/utils/withOrganization';
|
|
|
+import {useApiQuery} from 'sentry/utils/queryClient';
|
|
|
+import useApi from 'sentry/utils/useApi';
|
|
|
+import useOrganization from 'sentry/utils/useOrganization';
|
|
|
import ProjectListItem from 'sentry/views/settings/components/settingsProjectItem';
|
|
|
import TextBlock from 'sentry/views/settings/components/text/textBlock';
|
|
|
import PermissionAlert from 'sentry/views/settings/project/permissionAlert';
|
|
|
|
|
|
-type Props = {
|
|
|
- api: Client;
|
|
|
- organization: Organization;
|
|
|
+interface TeamProjectsProps extends RouteComponentProps<{teamId: string}, {}> {
|
|
|
team: Team;
|
|
|
-} & RouteComponentProps<{teamId: string}, {}>;
|
|
|
-
|
|
|
-type State = {
|
|
|
- error: boolean;
|
|
|
- linkedProjects: Project[];
|
|
|
- loading: boolean;
|
|
|
- pageLinks: null | string;
|
|
|
- unlinkedProjects: Project[];
|
|
|
-};
|
|
|
-
|
|
|
-type DropdownAutoCompleteProps = React.ComponentProps<typeof DropdownAutoComplete>;
|
|
|
-type Item = Parameters<NonNullable<DropdownAutoCompleteProps['onSelect']>>[0];
|
|
|
-
|
|
|
-class TeamProjects extends Component<Props, State> {
|
|
|
- state: State = {
|
|
|
- error: false,
|
|
|
- loading: true,
|
|
|
- pageLinks: null,
|
|
|
- unlinkedProjects: [],
|
|
|
- linkedProjects: [],
|
|
|
- };
|
|
|
-
|
|
|
- componentDidMount() {
|
|
|
- this.fetchAll();
|
|
|
- }
|
|
|
-
|
|
|
- componentDidUpdate(prevProps: Props) {
|
|
|
- if (
|
|
|
- prevProps.organization.slug !== this.props.organization.slug ||
|
|
|
- prevProps.params.teamId !== this.props.params.teamId
|
|
|
- ) {
|
|
|
- this.fetchAll();
|
|
|
- }
|
|
|
-
|
|
|
- if (prevProps.location !== this.props.location) {
|
|
|
- this.fetchTeamProjects();
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- fetchAll = () => {
|
|
|
- this.fetchTeamProjects();
|
|
|
- this.fetchUnlinkedProjects();
|
|
|
- };
|
|
|
-
|
|
|
- fetchTeamProjects() {
|
|
|
- const {
|
|
|
- location,
|
|
|
- organization,
|
|
|
- params: {teamId},
|
|
|
- } = this.props;
|
|
|
-
|
|
|
- this.setState({loading: true});
|
|
|
+}
|
|
|
|
|
|
- this.props.api
|
|
|
- .requestPromise(`/organizations/${organization.slug}/projects/`, {
|
|
|
+function TeamProjects({team, location, params}: TeamProjectsProps) {
|
|
|
+ const organization = useOrganization();
|
|
|
+ const api = useApi({persistInFlight: true});
|
|
|
+ const [query, setQuery] = useState<string>('');
|
|
|
+ const teamId = params.teamId;
|
|
|
+ const {
|
|
|
+ data: linkedProjects,
|
|
|
+ isError: linkedProjectsError,
|
|
|
+ isLoading: linkedProjectsLoading,
|
|
|
+ getResponseHeader: linkedProjectsHeaders,
|
|
|
+ refetch: refetchLinkedProjects,
|
|
|
+ } = useApiQuery<Project[]>(
|
|
|
+ [
|
|
|
+ `/organizations/${organization.slug}/projects/`,
|
|
|
+ {
|
|
|
query: {
|
|
|
query: `team:${teamId}`,
|
|
|
- cursor: location.query.cursor || '',
|
|
|
+ cursor: location.query.cursor,
|
|
|
},
|
|
|
- includeAllArgs: true,
|
|
|
- })
|
|
|
- .then(([linkedProjects, _, resp]) => {
|
|
|
- this.setState({
|
|
|
- loading: false,
|
|
|
- error: false,
|
|
|
- linkedProjects,
|
|
|
- pageLinks: resp?.getResponseHeader('Link') ?? null,
|
|
|
- });
|
|
|
- })
|
|
|
- .catch(() => {
|
|
|
- this.setState({loading: false, error: true});
|
|
|
- });
|
|
|
- }
|
|
|
-
|
|
|
- fetchUnlinkedProjects(query = '') {
|
|
|
- const {
|
|
|
- organization,
|
|
|
- params: {teamId},
|
|
|
- } = this.props;
|
|
|
-
|
|
|
- this.props.api
|
|
|
- .requestPromise(`/organizations/${organization.slug}/projects/`, {
|
|
|
- query: {
|
|
|
- query: query ? `!team:${teamId} ${query}` : `!team:${teamId}`,
|
|
|
- },
|
|
|
- })
|
|
|
- .then(unlinkedProjects => {
|
|
|
- this.setState({unlinkedProjects});
|
|
|
- });
|
|
|
- }
|
|
|
-
|
|
|
- handleLinkProject = (project: Project, action: string) => {
|
|
|
- const {organization} = this.props;
|
|
|
- const {teamId} = this.props.params;
|
|
|
- this.props.api.request(
|
|
|
- `/projects/${organization.slug}/${project.slug}/teams/${teamId}/`,
|
|
|
+ },
|
|
|
+ ],
|
|
|
+ {staleTime: 0}
|
|
|
+ );
|
|
|
+ const {
|
|
|
+ data: unlinkedProjects = [],
|
|
|
+ isLoading: loadingUnlinkedProjects,
|
|
|
+ refetch: refetchUnlinkedProjects,
|
|
|
+ } = useApiQuery<Project[]>(
|
|
|
+ [
|
|
|
+ `/organizations/${organization.slug}/projects/`,
|
|
|
{
|
|
|
- method: action === 'add' ? 'POST' : 'DELETE',
|
|
|
- success: resp => {
|
|
|
- this.fetchAll();
|
|
|
- ProjectsStore.onUpdateSuccess(resp);
|
|
|
- addSuccessMessage(
|
|
|
- action === 'add'
|
|
|
- ? t('Successfully added project to team.')
|
|
|
- : t('Successfully removed project from team')
|
|
|
- );
|
|
|
- },
|
|
|
- error: () => {
|
|
|
- addErrorMessage(t("Wasn't able to change project association."));
|
|
|
- },
|
|
|
- }
|
|
|
- );
|
|
|
+ query: {query: query ? `!team:${teamId} ${query}` : `!team:${teamId}`},
|
|
|
+ },
|
|
|
+ ],
|
|
|
+ {staleTime: 0}
|
|
|
+ );
|
|
|
+
|
|
|
+ const handleLinkProject = (project: Project, action: string) => {
|
|
|
+ api.request(`/projects/${organization.slug}/${project.slug}/teams/${teamId}/`, {
|
|
|
+ method: action === 'add' ? 'POST' : 'DELETE',
|
|
|
+ success: resp => {
|
|
|
+ refetchLinkedProjects();
|
|
|
+ refetchUnlinkedProjects();
|
|
|
+ ProjectsStore.onUpdateSuccess(resp);
|
|
|
+ addSuccessMessage(
|
|
|
+ action === 'add'
|
|
|
+ ? t('Successfully added project to team.')
|
|
|
+ : t('Successfully removed project from team')
|
|
|
+ );
|
|
|
+ },
|
|
|
+ error: () => {
|
|
|
+ addErrorMessage(t("Wasn't able to change project association."));
|
|
|
+ },
|
|
|
+ });
|
|
|
};
|
|
|
|
|
|
- handleProjectSelected = (selection: Item) => {
|
|
|
- const project = this.state.unlinkedProjects.find(p => p.id === selection.value);
|
|
|
- if (project) {
|
|
|
- this.handleLinkProject(project, 'add');
|
|
|
- }
|
|
|
- };
|
|
|
-
|
|
|
- handleQueryUpdate = (evt: React.ChangeEvent<HTMLInputElement>) => {
|
|
|
- this.fetchUnlinkedProjects(evt.target.value);
|
|
|
- };
|
|
|
-
|
|
|
- projectPanelContents(projects: Project[]) {
|
|
|
- const {organization, team} = this.props;
|
|
|
- const hasWriteAccess = hasEveryAccess(['team:write'], {organization, team});
|
|
|
-
|
|
|
- return projects.length ? (
|
|
|
- sortProjects(projects).map(project => (
|
|
|
- <StyledPanelItem key={project.id}>
|
|
|
- <ProjectListItem project={project} organization={organization} />
|
|
|
- <Tooltip
|
|
|
- disabled={hasWriteAccess}
|
|
|
- title={t('You do not have enough permission to change project association.')}
|
|
|
- >
|
|
|
- <Button
|
|
|
- size="sm"
|
|
|
- disabled={!hasWriteAccess}
|
|
|
- icon={<IconSubtract isCircled size="xs" />}
|
|
|
- aria-label={t('Remove')}
|
|
|
- onClick={() => {
|
|
|
- this.handleLinkProject(project, 'remove');
|
|
|
- }}
|
|
|
- >
|
|
|
- {t('Remove')}
|
|
|
- </Button>
|
|
|
- </Tooltip>
|
|
|
- </StyledPanelItem>
|
|
|
- ))
|
|
|
- ) : (
|
|
|
- <EmptyMessage size="large" icon={<IconFlag size="xl" />}>
|
|
|
- {t("This team doesn't have access to any projects.")}
|
|
|
- </EmptyMessage>
|
|
|
- );
|
|
|
- }
|
|
|
-
|
|
|
- render() {
|
|
|
- const {organization, team} = this.props;
|
|
|
- const {linkedProjects, unlinkedProjects, error, loading} = this.state;
|
|
|
-
|
|
|
- if (error) {
|
|
|
- return <LoadingError onRetry={() => this.fetchAll()} />;
|
|
|
- }
|
|
|
-
|
|
|
- if (loading) {
|
|
|
- return <LoadingIndicator />;
|
|
|
- }
|
|
|
-
|
|
|
- const hasWriteAccess = hasEveryAccess(['team:write'], {organization, team});
|
|
|
- const otherProjects = unlinkedProjects
|
|
|
- .filter(p => p.access.includes('project:write'))
|
|
|
- .map(p => ({
|
|
|
- value: p.id,
|
|
|
- searchKey: p.slug,
|
|
|
- label: <ProjectListElement>{p.slug}</ProjectListElement>,
|
|
|
- }));
|
|
|
-
|
|
|
- return (
|
|
|
- <Fragment>
|
|
|
- <TextBlock>
|
|
|
- {t(
|
|
|
- 'If you have Team Admin permissions for other projects, you can associate them with this team.'
|
|
|
+ const linkedProjectsPageLinks = linkedProjectsHeaders?.('Link');
|
|
|
+ const hasWriteAccess = hasEveryAccess(['team:write'], {organization, team});
|
|
|
+ const otherProjects = unlinkedProjects
|
|
|
+ .filter(p => p.access.includes('project:write'))
|
|
|
+ .map(p => ({
|
|
|
+ value: p.id,
|
|
|
+ searchKey: p.slug,
|
|
|
+ label: <ProjectListElement>{p.slug}</ProjectListElement>,
|
|
|
+ }));
|
|
|
+
|
|
|
+ return (
|
|
|
+ <Fragment>
|
|
|
+ <TextBlock>
|
|
|
+ {t(
|
|
|
+ 'If you have Team Admin permissions for other projects, you can associate them with this team.'
|
|
|
+ )}
|
|
|
+ </TextBlock>
|
|
|
+ <PermissionAlert access={['team:write']} team={team} />
|
|
|
+ <Panel>
|
|
|
+ <PanelHeader hasButtons>
|
|
|
+ <div>{t('Projects')}</div>
|
|
|
+ <div style={{textTransform: 'none', fontWeight: 'normal'}}>
|
|
|
+ {!hasWriteAccess ? (
|
|
|
+ <DropdownButton
|
|
|
+ disabled
|
|
|
+ title={t('You do not have enough permission to associate a project.')}
|
|
|
+ size="xs"
|
|
|
+ >
|
|
|
+ {t('Add Project')}
|
|
|
+ </DropdownButton>
|
|
|
+ ) : (
|
|
|
+ <DropdownAutoComplete
|
|
|
+ items={otherProjects}
|
|
|
+ onChange={evt => setQuery(evt.target.value)}
|
|
|
+ onSelect={selection => {
|
|
|
+ const project = unlinkedProjects.find(p => p.id === selection.value);
|
|
|
+ if (project) {
|
|
|
+ handleLinkProject(project, 'add');
|
|
|
+ }
|
|
|
+ }}
|
|
|
+ onClose={() => setQuery('')}
|
|
|
+ busy={loadingUnlinkedProjects}
|
|
|
+ emptyMessage={t('You are not an admin for any other projects')}
|
|
|
+ alignMenu="right"
|
|
|
+ >
|
|
|
+ {({isOpen}) => (
|
|
|
+ <DropdownButton isOpen={isOpen} size="xs">
|
|
|
+ {t('Add Project')}
|
|
|
+ </DropdownButton>
|
|
|
+ )}
|
|
|
+ </DropdownAutoComplete>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ </PanelHeader>
|
|
|
+
|
|
|
+ <PanelBody>
|
|
|
+ {linkedProjectsError && (
|
|
|
+ <LoadingError onRetry={() => refetchLinkedProjects()} />
|
|
|
)}
|
|
|
- </TextBlock>
|
|
|
- <PermissionAlert access={['team:write']} team={team} />
|
|
|
-
|
|
|
- <Panel>
|
|
|
- <PanelHeader hasButtons>
|
|
|
- <div>{t('Projects')}</div>
|
|
|
- <div style={{textTransform: 'none'}}>
|
|
|
- {!hasWriteAccess ? (
|
|
|
- <DropdownButton
|
|
|
- disabled
|
|
|
- title={t('You do not have enough permission to associate a project.')}
|
|
|
- size="xs"
|
|
|
- >
|
|
|
- {t('Add Project')}
|
|
|
- </DropdownButton>
|
|
|
- ) : (
|
|
|
- <DropdownAutoComplete
|
|
|
- items={otherProjects}
|
|
|
- onChange={this.handleQueryUpdate}
|
|
|
- onSelect={this.handleProjectSelected}
|
|
|
- emptyMessage={t('You are not an admin for any other projects')}
|
|
|
- alignMenu="right"
|
|
|
- >
|
|
|
- {({isOpen}) => (
|
|
|
- <DropdownButton isOpen={isOpen} size="xs">
|
|
|
- {t('Add Project')}
|
|
|
- </DropdownButton>
|
|
|
+ {linkedProjectsLoading && <LoadingIndicator />}
|
|
|
+ {linkedProjects?.length ? (
|
|
|
+ sortProjects(linkedProjects).map(project => (
|
|
|
+ <StyledPanelItem key={project.id}>
|
|
|
+ <ProjectListItem project={project} organization={organization} />
|
|
|
+ <Tooltip
|
|
|
+ disabled={hasWriteAccess}
|
|
|
+ title={t(
|
|
|
+ 'You do not have enough permission to change project association.'
|
|
|
)}
|
|
|
- </DropdownAutoComplete>
|
|
|
- )}
|
|
|
- </div>
|
|
|
- </PanelHeader>
|
|
|
- <PanelBody>{this.projectPanelContents(linkedProjects)}</PanelBody>
|
|
|
- </Panel>
|
|
|
- <Pagination pageLinks={this.state.pageLinks} {...this.props} />
|
|
|
- </Fragment>
|
|
|
- );
|
|
|
- }
|
|
|
+ >
|
|
|
+ <Button
|
|
|
+ size="sm"
|
|
|
+ disabled={!hasWriteAccess}
|
|
|
+ icon={<IconSubtract isCircled size="xs" />}
|
|
|
+ aria-label={t('Remove')}
|
|
|
+ onClick={() => {
|
|
|
+ handleLinkProject(project, 'remove');
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ {t('Remove')}
|
|
|
+ </Button>
|
|
|
+ </Tooltip>
|
|
|
+ </StyledPanelItem>
|
|
|
+ ))
|
|
|
+ ) : linkedProjectsLoading ? null : (
|
|
|
+ <EmptyMessage size="large" icon={<IconFlag size="xl" />}>
|
|
|
+ {t("This team doesn't have access to any projects.")}
|
|
|
+ </EmptyMessage>
|
|
|
+ )}
|
|
|
+ </PanelBody>
|
|
|
+ </Panel>
|
|
|
+ <Pagination pageLinks={linkedProjectsPageLinks} />
|
|
|
+ </Fragment>
|
|
|
+ );
|
|
|
}
|
|
|
|
|
|
const StyledPanelItem = styled(PanelItem)`
|
|
@@ -263,12 +190,11 @@ const StyledPanelItem = styled(PanelItem)`
|
|
|
align-items: center;
|
|
|
justify-content: space-between;
|
|
|
padding: ${space(2)};
|
|
|
+ max-width: 100%;
|
|
|
`;
|
|
|
|
|
|
const ProjectListElement = styled('div')`
|
|
|
padding: ${space(0.25)} 0;
|
|
|
`;
|
|
|
|
|
|
-export {TeamProjects};
|
|
|
-
|
|
|
-export default withApi(withOrganization(TeamProjects));
|
|
|
+export default TeamProjects;
|