Browse Source

feat(settings): Convert team projects to functional (#54177)

Scott Cooper 1 year ago
parent
commit
35631f0567

+ 35 - 83
static/app/views/settings/organizationTeams/teamProjects.spec.tsx

@@ -1,15 +1,15 @@
 import {initializeOrg} from 'sentry-test/initializeOrg';
 import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary';
 
-import {TeamProjects as OrganizationTeamProjects} from 'sentry/views/settings/organizationTeams/teamProjects';
+import OrganizationTeamProjects from 'sentry/views/settings/organizationTeams/teamProjects';
 
 describe('OrganizationTeamProjects', function () {
-  let team;
-  let getMock;
-  let putMock;
-  let postMock;
-  let deleteMock;
+  let getMock!: jest.Mock;
+  let putMock!: jest.Mock;
+  let postMock!: jest.Mock;
+  let deleteMock!: jest.Mock;
 
+  const team = TestStubs.Team({slug: 'team-slug'});
   const project = TestStubs.Project({
     teams: [team],
     access: ['project:read', 'project:write', 'project:admin'],
@@ -21,24 +21,13 @@ describe('OrganizationTeamProjects', function () {
     access: ['project:read', 'project:write', 'project:admin'],
   });
 
-  const {routerContext, organization} = initializeOrg({
+  const {routerContext, routerProps, organization} = initializeOrg({
     organization: TestStubs.Organization({slug: 'org-slug'}),
     projects: [project, project2],
+    router: {params: {teamId: team.slug}},
   });
 
-  const router = TestStubs.router();
-  const routerProps = {
-    router,
-    routes: router.routes,
-    params: router.params,
-    routeParams: router.params,
-    route: router.routes[0],
-    location: router.location,
-  };
-
   beforeEach(function () {
-    team = TestStubs.Team({slug: 'team-slug'});
-
     getMock = MockApiClient.addMockResponse({
       url: '/organizations/org-slug/projects/',
       body: [project, project2],
@@ -69,54 +58,26 @@ describe('OrganizationTeamProjects', function () {
     MockApiClient.clearMockResponses();
   });
 
-  it('fetches linked and unlinked projects', function () {
-    render(
-      <OrganizationTeamProjects
-        {...routerProps}
-        api={new MockApiClient()}
-        organization={organization}
-        team={team}
-        params={{teamId: team.slug}}
-        location={{...routerProps.location, query: {}}}
-      />,
-      {context: routerContext}
+  it('should fetch linked and unlinked projects', async function () {
+    const {container} = render(
+      <OrganizationTeamProjects {...routerProps} team={team} />,
+      {context: routerContext, organization}
     );
 
+    expect(await screen.findByText('project-slug')).toBeInTheDocument();
+    expect(container).toSnapshot();
+
     expect(getMock).toHaveBeenCalledTimes(2);
 
     expect(getMock.mock.calls[0][1].query.query).toBe('team:team-slug');
     expect(getMock.mock.calls[1][1].query.query).toBe('!team:team-slug');
   });
 
-  it('Should render', async function () {
-    const {container} = render(
-      <OrganizationTeamProjects
-        {...routerProps}
-        api={new MockApiClient()}
-        organization={organization}
-        team={team}
-        params={{teamId: team.slug}}
-        location={{...routerProps.location, query: {}}}
-      />,
-      {context: routerContext}
-    );
-
-    expect(await screen.findByText('project-slug')).toBeInTheDocument();
-    expect(container).toSnapshot();
-  });
-
-  it('Should allow bookmarking', async function () {
-    render(
-      <OrganizationTeamProjects
-        {...routerProps}
-        api={new MockApiClient()}
-        organization={organization}
-        team={team}
-        params={{teamId: team.slug}}
-        location={{...routerProps.location, query: {}}}
-      />,
-      {context: routerContext}
-    );
+  it('should allow bookmarking', async function () {
+    render(<OrganizationTeamProjects {...routerProps} team={team} />, {
+      context: routerContext,
+      organization,
+    });
 
     const stars = await screen.findAllByRole('button', {name: 'Bookmark Project'});
     expect(stars).toHaveLength(2);
@@ -127,25 +88,23 @@ describe('OrganizationTeamProjects', function () {
     ).toBeInTheDocument();
 
     expect(putMock).toHaveBeenCalledTimes(1);
+    expect(putMock).toHaveBeenCalledWith(
+      expect.any(String),
+      expect.objectContaining({
+        data: {isBookmarked: true},
+      })
+    );
   });
 
-  it('Should allow adding and removing projects', async function () {
-    render(
-      <OrganizationTeamProjects
-        {...routerProps}
-        api={new MockApiClient()}
-        organization={organization}
-        team={team}
-        params={{teamId: team.slug}}
-        location={{...routerProps.location, query: {}}}
-      />,
-      {context: routerContext}
-    );
+  it('should allow adding and removing projects', async function () {
+    render(<OrganizationTeamProjects {...routerProps} team={team} />, {
+      context: routerContext,
+      organization,
+    });
 
     expect(getMock).toHaveBeenCalledTimes(2);
 
     await userEvent.click(await screen.findByText('Add Project'));
-    // console.log(screen.debug());
     await userEvent.click(screen.getByRole('option', {name: 'project-slug-2'}));
 
     expect(postMock).toHaveBeenCalledTimes(1);
@@ -158,17 +117,10 @@ describe('OrganizationTeamProjects', function () {
   });
 
   it('handles filtering unlinked projects', async function () {
-    render(
-      <OrganizationTeamProjects
-        {...routerProps}
-        api={new MockApiClient()}
-        organization={organization}
-        team={team}
-        params={{teamId: team.slug}}
-        location={{...routerProps.location, query: {}}}
-      />,
-      {context: routerContext}
-    );
+    render(<OrganizationTeamProjects {...routerProps} team={team} />, {
+      context: routerContext,
+      organization,
+    });
 
     expect(getMock).toHaveBeenCalledTimes(2);
 

+ 152 - 226
static/app/views/settings/organizationTeams/teamProjects.tsx

@@ -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;