Просмотр исходного кода

ref(tsc): Convert ProjectTeams to FC + useApiQuery (#65195)

Malachi Willey 1 год назад
Родитель
Сommit
510808f378

+ 65 - 0
static/app/actionCreators/projects.tsx

@@ -1,3 +1,4 @@
+import {useCallback} from 'react';
 import type {Query} from 'history';
 import chunk from 'lodash/chunk';
 import debounce from 'lodash/debounce';
@@ -13,6 +14,9 @@ import LatestContextStore from 'sentry/stores/latestContextStore';
 import ProjectsStatsStore from 'sentry/stores/projectsStatsStore';
 import ProjectsStore from 'sentry/stores/projectsStore';
 import type {PlatformKey, Project, Team} from 'sentry/types';
+import type {ApiQueryKey} from 'sentry/utils/queryClient';
+import {setApiQueryData, useApiQuery, useQueryClient} from 'sentry/utils/queryClient';
+import useApi from 'sentry/utils/useApi';
 
 type UpdateParams = {
   orgId: string;
@@ -407,3 +411,64 @@ export async function fetchAnyReleaseExistence(
 
   return data.length > 0;
 }
+
+function makeProjectTeamsQueryKey({
+  orgSlug,
+  projectSlug,
+}: {orgSlug: string; projectSlug: string}): ApiQueryKey {
+  return [`/projects/${orgSlug}/${projectSlug}/teams/`];
+}
+
+export function useFetchProjectTeams({
+  orgSlug,
+  projectSlug,
+}: {orgSlug: string; projectSlug: string}) {
+  return useApiQuery<Team[]>(makeProjectTeamsQueryKey({orgSlug, projectSlug}), {
+    staleTime: 0,
+    retry: false,
+    enabled: Boolean(orgSlug && projectSlug),
+  });
+}
+
+export function useAddTeamToProject({
+  orgSlug,
+  projectSlug,
+}: {orgSlug: string; projectSlug: string}) {
+  const api = useApi();
+  const queryClient = useQueryClient();
+
+  return useCallback(
+    async (team: Team) => {
+      await addTeamToProject(api, orgSlug, projectSlug, team);
+
+      setApiQueryData<Team[]>(
+        queryClient,
+        makeProjectTeamsQueryKey({orgSlug, projectSlug}),
+        prevData => (Array.isArray(prevData) ? [...prevData, team] : [team])
+      );
+    },
+    [api, orgSlug, projectSlug, queryClient]
+  );
+}
+
+export function useRemoveTeamFromProject({
+  orgSlug,
+  projectSlug,
+}: {orgSlug: string; projectSlug: string}) {
+  const api = useApi();
+  const queryClient = useQueryClient();
+
+  return useCallback(
+    async (teamSlug: string) => {
+      await removeTeamFromProject(api, orgSlug, projectSlug, teamSlug);
+
+      setApiQueryData<Team[]>(
+        queryClient,
+        makeProjectTeamsQueryKey({orgSlug, projectSlug}),
+        prevData =>
+          Array.isArray(prevData) ? prevData.filter(team => team?.slug !== teamSlug) : []
+      );
+    },
+    [api, orgSlug, projectSlug, queryClient]
+  );
+}

+ 16 - 56
static/app/views/settings/project/projectTeams.spec.tsx

@@ -1,4 +1,3 @@
-import {RouteComponentPropsFixture} from 'sentry-fixture/routeComponentPropsFixture';
 import {TeamFixture} from 'sentry-fixture/team';
 
 import {initializeOrg} from 'sentry-test/initializeOrg';
@@ -17,7 +16,6 @@ import ProjectTeams from 'sentry/views/settings/project/projectTeams';
 describe('ProjectTeams', function () {
   let org: Organization;
   let project: Project;
-  let routerContext: Record<string, any>;
 
   const team1WithAdmin = TeamFixture({
     access: ['team:read', 'team:write', 'team:admin'],
@@ -44,7 +42,6 @@ describe('ProjectTeams', function () {
       ...initialData.project,
       access: ['project:admin', 'project:write', 'project:admin'],
     };
-    routerContext = initialData.routerContext;
 
     TeamStore.loadInitialData([team1WithAdmin, team2WithAdmin]);
 
@@ -69,17 +66,6 @@ describe('ProjectTeams', function () {
     MockApiClient.clearMockResponses();
   });
 
-  it('renders', function () {
-    render(
-      <ProjectTeams
-        {...RouteComponentPropsFixture()}
-        params={{projectId: project.slug}}
-        organization={org}
-        project={project}
-      />
-    );
-  });
-
   it('can remove a team from project', async function () {
     MockApiClient.addMockResponse({
       url: `/projects/${org.slug}/${project.slug}/teams/`,
@@ -101,14 +87,9 @@ describe('ProjectTeams', function () {
       statusCode: 200,
     });
 
-    render(
-      <ProjectTeams
-        {...RouteComponentPropsFixture()}
-        params={{projectId: project.slug}}
-        organization={org}
-        project={project}
-      />
-    );
+    render(<ProjectTeams organization={org} project={project} />);
+
+    expect(await screen.findByText('Project Teams for project-slug')).toBeInTheDocument();
 
     expect(mock1).not.toHaveBeenCalled();
 
@@ -158,14 +139,9 @@ describe('ProjectTeams', function () {
       statusCode: 200,
     });
 
-    render(
-      <ProjectTeams
-        {...RouteComponentPropsFixture()}
-        params={{projectId: project.slug}}
-        organization={org}
-        project={project}
-      />
-    );
+    render(<ProjectTeams organization={org} project={project} />);
+
+    expect(await screen.findByText('Project Teams for project-slug')).toBeInTheDocument();
 
     // Remove first team
     await userEvent.click(screen.getAllByRole('button', {name: 'Remove'})[0]);
@@ -209,14 +185,9 @@ describe('ProjectTeams', function () {
       body: [team3NoAdmin],
     });
 
-    render(
-      <ProjectTeams
-        {...RouteComponentPropsFixture()}
-        params={{projectId: project.slug}}
-        organization={org}
-        project={project}
-      />
-    );
+    render(<ProjectTeams organization={org} project={project} />);
+
+    expect(await screen.findByText('Project Teams for project-slug')).toBeInTheDocument();
 
     expect(mock1).not.toHaveBeenCalled();
 
@@ -257,14 +228,9 @@ describe('ProjectTeams', function () {
       statusCode: 200,
     });
 
-    render(
-      <ProjectTeams
-        {...RouteComponentPropsFixture()}
-        params={{projectId: project.slug}}
-        organization={org}
-        project={project}
-      />
-    );
+    render(<ProjectTeams organization={org} project={project} />);
+
+    expect(await screen.findByText('Project Teams for project-slug')).toBeInTheDocument();
 
     expect(mock).not.toHaveBeenCalled();
 
@@ -296,18 +262,12 @@ describe('ProjectTeams', function () {
     const createTeam = MockApiClient.addMockResponse({
       url: `/organizations/${org.slug}/teams/`,
       method: 'POST',
-      body: {slug: 'new-team'},
+      body: TeamFixture({slug: 'new-team'}),
     });
 
-    render(
-      <ProjectTeams
-        {...RouteComponentPropsFixture()}
-        params={{projectId: project.slug}}
-        project={project}
-        organization={org}
-      />,
-      {context: routerContext}
-    );
+    render(<ProjectTeams project={project} organization={org} />);
+
+    expect(await screen.findByText('Project Teams for project-slug')).toBeInTheDocument();
 
     // Add new team
     await userEvent.click(screen.getAllByRole('button', {name: 'Add Team'})[1]);

+ 52 - 124
static/app/views/settings/project/projectTeams.tsx

@@ -1,134 +1,58 @@
-import type {RouteComponentProps} from 'react-router';
-
 import {addErrorMessage} from 'sentry/actionCreators/indicator';
-import {openCreateTeamModal} from 'sentry/actionCreators/modal';
-import {addTeamToProject, removeTeamFromProject} from 'sentry/actionCreators/projects';
+import {
+  useAddTeamToProject,
+  useFetchProjectTeams,
+  useRemoveTeamFromProject,
+} from 'sentry/actionCreators/projects';
 import {hasEveryAccess} from 'sentry/components/acl/access';
+import LoadingError from 'sentry/components/loadingError';
+import LoadingIndicator from 'sentry/components/loadingIndicator';
+import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle';
 import {t, tct} from 'sentry/locale';
 import TeamStore from 'sentry/stores/teamStore';
-import type {Organization, Project, Team} from 'sentry/types';
+import type {Organization, Project} from 'sentry/types';
 import routeTitleGen from 'sentry/utils/routeTitle';
-import DeprecatedAsyncView from 'sentry/views/deprecatedAsyncView';
 import SettingsPageHeader from 'sentry/views/settings/components/settingsPageHeader';
 import TeamSelectForProject from 'sentry/views/settings/components/teamSelect/teamSelectForProject';
 import TextBlock from 'sentry/views/settings/components/text/textBlock';
 import PermissionAlert from 'sentry/views/settings/project/permissionAlert';
 
-type Props = {
+type ProjectTeamsProps = {
   organization: Organization;
   project: Project;
-} & RouteComponentProps<{projectId: string}, {}>;
-
-type State = {
-  projectTeams: null | Team[];
-} & DeprecatedAsyncView['state'];
-
-class ProjectTeams extends DeprecatedAsyncView<Props, State> {
-  getEndpoints(): ReturnType<DeprecatedAsyncView['getEndpoints']> {
-    const {organization, project} = this.props;
-    return [['projectTeams', `/projects/${organization.slug}/${project.slug}/teams/`]];
+};
+
+export function ProjectTeams({organization, project}: ProjectTeamsProps) {
+  const {
+    data: projectTeams,
+    isLoading,
+    isError,
+  } = useFetchProjectTeams({orgSlug: organization.slug, projectSlug: project.slug});
+  const handleAddTeamToProject = useAddTeamToProject({
+    orgSlug: organization.slug,
+    projectSlug: project.slug,
+  });
+  const handleRemoveTeamFromProject = useRemoveTeamFromProject({
+    orgSlug: organization.slug,
+    projectSlug: project.slug,
+  });
+
+  const canCreateTeam =
+    organization.access.includes('org:write') &&
+    organization.access.includes('team:write') &&
+    organization.access.includes('project:write');
+  const hasWriteAccess = hasEveryAccess(['project:write'], {organization, project});
+
+  if (isError) {
+    return <LoadingError message={t('Failed to load project teams')} />;
   }
 
-  getTitle() {
-    const {projectId} = this.props.params;
-    return routeTitleGen(t('Project Teams'), projectId, false);
+  if (isLoading) {
+    return <LoadingIndicator />;
   }
 
-  canCreateTeam = () => {
-    const access = this.props.organization.access;
-    return (
-      access.includes('org:write') &&
-      access.includes('team:write') &&
-      access.includes('project:write')
-    );
-  };
-
-  handleRemove = (teamSlug: Team['slug']) => {
-    if (this.state.loading) {
-      return;
-    }
-
-    const {organization, project} = this.props;
-
-    removeTeamFromProject(this.api, organization.slug, project.slug, teamSlug)
-      .then(() => this.handleRemovedTeam(teamSlug))
-      .catch(() => {
-        addErrorMessage(t('Could not remove the %s team', teamSlug));
-        this.setState({loading: false});
-      });
-  };
-
-  handleRemovedTeam = (teamSlug: Team['slug']) => {
-    this.setState(prevState => ({
-      projectTeams: [
-        ...(prevState.projectTeams || []).filter(team => team.slug !== teamSlug),
-      ],
-    }));
-  };
-
-  handleAddedTeam = (team: Team) => {
-    this.setState(prevState => ({
-      projectTeams: [...(prevState.projectTeams || []), team],
-    }));
-  };
-
-  handleAdd = (teamSlug: string) => {
-    if (this.state.loading) {
-      return;
-    }
-
-    const team = TeamStore.getBySlug(teamSlug);
-    if (!team) {
-      addErrorMessage(tct('Unable to find "[teamSlug]"', {teamSlug}));
-      this.setState({error: true});
-      return;
-    }
-
-    const {organization, project} = this.props;
-
-    addTeamToProject(this.api, organization.slug, project.slug, team).then(
-      () => {
-        this.handleAddedTeam(team);
-      },
-      () => {
-        this.setState({
-          error: true,
-          loading: false,
-        });
-      }
-    );
-  };
-
-  handleCreateTeam = (e: React.MouseEvent) => {
-    e.stopPropagation();
-    e.preventDefault();
-
-    const {project, organization} = this.props;
-
-    if (!this.canCreateTeam()) {
-      return;
-    }
-
-    openCreateTeamModal({
-      project,
-      organization,
-      onClose: data => {
-        addTeamToProject(this.api, organization.slug, project.slug, data).then(
-          this.remountComponent,
-          this.remountComponent
-        );
-      },
-    });
-  };
-
-  renderBody() {
-    const {project, organization} = this.props;
-    const {projectTeams} = this.state;
-
-    const canCreateTeam = this.canCreateTeam();
-    const hasWriteAccess = hasEveryAccess(['project:write'], {organization, project});
-
-    return (
+  return (
+    <SentryDocumentTitle title={routeTitleGen(t('Project Teams'), project.slug, false)}>
       <div>
         <SettingsPageHeader title={t('Project Teams for %s', project.slug)} />
         <TextBlock>
@@ -149,18 +73,22 @@ class ProjectTeams extends DeprecatedAsyncView<Props, State> {
           organization={organization}
           project={project}
           selectedTeams={projectTeams ?? []}
-          onAddTeam={this.handleAdd}
-          onRemoveTeam={this.handleRemove}
-          onCreateTeam={(team: Team) => {
-            addTeamToProject(this.api, organization.slug, project.slug, team).then(
-              this.remountComponent,
-              this.remountComponent
-            );
+          onAddTeam={teamSlug => {
+            const team = TeamStore.getBySlug(teamSlug);
+
+            if (!team) {
+              addErrorMessage(tct('Unable to find "[teamSlug]"', {teamSlug}));
+              return;
+            }
+
+            handleAddTeamToProject(team);
           }}
+          onRemoveTeam={handleRemoveTeamFromProject}
+          onCreateTeam={handleAddTeamToProject}
         />
       </div>
-    );
-  }
+    </SentryDocumentTitle>
+  );
 }
 
 export default ProjectTeams;