Browse Source

feat(integrations): Pull add repository component out (#63655)

Scott Cooper 1 year ago
parent
commit
7bd2f1f138

+ 37 - 37
static/app/actionCreators/integrations.tsx

@@ -13,16 +13,16 @@ const api = new Client();
 /**
  * Removes an integration from a project.
  *
- * @param {String} orgId Organization Slug
- * @param {String} projectId Project Slug
- * @param {Object} integration The organization integration to remove
+ * @param orgSlug Organization Slug
+ * @param projectId Project Slug
+ * @param integration The organization integration to remove
  */
 export function removeIntegrationFromProject(
-  orgId: string,
+  orgSlug: string,
   projectId: string,
   integration: Integration
 ) {
-  const endpoint = `/projects/${orgId}/${projectId}/integrations/${integration.id}/`;
+  const endpoint = `/projects/${orgSlug}/${projectId}/integrations/${integration.id}/`;
   addLoadingMessage();
 
   return api.requestPromise(endpoint, {method: 'DELETE'}).then(
@@ -38,16 +38,16 @@ export function removeIntegrationFromProject(
 /**
  * Add an integration to a project
  *
- * @param {String} orgId Organization Slug
- * @param {String} projectId Project Slug
- * @param {Object} integration The organization integration to add
+ * @param orgSlug Organization Slug
+ * @param projectId Project Slug
+ * @param integration The organization integration to add
  */
 export function addIntegrationToProject(
-  orgId: string,
+  orgSlug: string,
   projectId: string,
   integration: Integration
 ) {
-  const endpoint = `/projects/${orgId}/${projectId}/integrations/${integration.id}/`;
+  const endpoint = `/projects/${orgSlug}/${projectId}/integrations/${integration.id}/`;
   addLoadingMessage();
 
   return api.requestPromise(endpoint, {method: 'PUT'}).then(
@@ -63,14 +63,14 @@ export function addIntegrationToProject(
 /**
  * Delete a respository
  *
- * @param {Object} client ApiClient
- * @param {String} orgId Organization Slug
- * @param {String} repositoryId Repository ID
+ * @param client ApiClient
+ * @param orgSlug Organization Slug
+ * @param repositoryId Repository ID
  */
-export function deleteRepository(client: Client, orgId: string, repositoryId: string) {
+export function deleteRepository(client: Client, orgSlug: string, repositoryId: string) {
   addLoadingMessage();
   const promise = client.requestPromise(
-    `/organizations/${orgId}/repos/${repositoryId}/`,
+    `/organizations/${orgSlug}/repos/${repositoryId}/`,
     {
       method: 'DELETE',
     }
@@ -85,18 +85,18 @@ export function deleteRepository(client: Client, orgId: string, repositoryId: st
 /**
  * Cancel the deletion of a respository
  *
- * @param {Object} client ApiClient
- * @param {String} orgId Organization Slug
- * @param {String} repositoryId Repository ID
+ * @param client ApiClient
+ * @param orgSlug Organization Slug
+ * @param repositoryId Repository ID
  */
 export function cancelDeleteRepository(
   client: Client,
-  orgId: string,
+  orgSlug: string,
   repositoryId: string
 ) {
   addLoadingMessage();
   const promise = client.requestPromise(
-    `/organizations/${orgId}/repos/${repositoryId}/`,
+    `/organizations/${orgSlug}/repos/${repositoryId}/`,
     {
       method: 'PUT',
       data: {status: 'visible'},
@@ -113,13 +113,13 @@ export function cancelDeleteRepository(
  * Delete a repository by setting its status to hidden.
  *
  * @param client ApiClient
- * @param orgId Organization Slug
+ * @param orgSlug Organization Slug
  * @param repositoryId Repository ID
  */
-export function hideRepository(client: Client, orgId: string, repositoryId: string) {
+export function hideRepository(client: Client, orgSlug: string, repositoryId: string) {
   addLoadingMessage();
   const promise = client.requestPromise(
-    `/organizations/${orgId}/repos/${repositoryId}/`,
+    `/organizations/${orgSlug}/repos/${repositoryId}/`,
     {
       method: 'PUT',
       data: {status: 'hidden'},
@@ -153,21 +153,21 @@ function applyRepositoryAddComplete(promise: Promise<Repository>) {
 /**
  * Migrate a repository to a new integration.
  *
- * @param {Object} client ApiClient
- * @param {String} orgId Organization Slug
- * @param {String} repositoryId Repository ID
- * @param {Object} integration Integration provider data.
+ * @param client ApiClient
+ * @param orgSlug Organization Slug
+ * @param repositoryId Repository ID
+ * @param integration Integration provider data.
  */
 export function migrateRepository(
   client: Client,
-  orgId: string,
+  orgSlug: string,
   repositoryId: string,
   integration: Integration
-) {
+): Promise<Repository> {
   const data = {integrationId: integration.id};
   addLoadingMessage();
   const promise = client.requestPromise(
-    `/organizations/${orgId}/repos/${repositoryId}/`,
+    `/organizations/${orgSlug}/repos/${repositoryId}/`,
     {
       data,
       method: 'PUT',
@@ -179,24 +179,24 @@ export function migrateRepository(
 /**
  * Add a repository
  *
- * @param {Object} client ApiClient
- * @param {String} orgId Organization Slug
- * @param {String} name Repository identifier/name to add
- * @param {Object} integration Integration provider data.
+ * @param client ApiClient
+ * @param orgSlug Organization Slug
+ * @param name Repository identifier/name to add
+ * @param integration Integration provider data.
  */
 export function addRepository(
   client: Client,
-  orgId: string,
+  orgSlug: string,
   name: string,
   integration: Integration
-) {
+): Promise<Repository> {
   const data = {
     installation: integration.id,
     identifier: name,
     provider: `integrations:${integration.provider.key}`,
   };
   addLoadingMessage();
-  const promise = client.requestPromise(`/organizations/${orgId}/repos/`, {
+  const promise = client.requestPromise(`/organizations/${orgSlug}/repos/`, {
     method: 'POST',
     data,
   });

+ 1 - 1
static/app/components/repositoryRow.tsx

@@ -17,7 +17,7 @@ type Props = {
   api: Client;
   orgSlug: string;
   repository: Repository;
-  onRepositoryChange?: (data: {id: string; status: RepositoryStatus}) => void;
+  onRepositoryChange?: (data: Repository) => void;
   showProvider?: boolean;
 };
 

+ 4 - 5
static/app/views/settings/organizationIntegrations/integrationRepos.spec.tsx

@@ -10,7 +10,7 @@ import IntegrationRepos from 'sentry/views/settings/organizationIntegrations/int
 describe('IntegrationRepos', function () {
   const org = OrganizationFixture();
   const integration = GitHubIntegrationFixture();
-  let resetReposSpy;
+  let resetReposSpy: jest.SpyInstance;
 
   beforeEach(() => {
     MockApiClient.clearMockResponses();
@@ -19,8 +19,7 @@ describe('IntegrationRepos', function () {
   });
 
   afterEach(() => {
-    jest.restoreAllMocks();
-    resetReposSpy();
+    resetReposSpy.mockClear();
   });
 
   describe('Getting repositories', function () {
@@ -114,7 +113,7 @@ describe('IntegrationRepos', function () {
       expect(screen.queryByText('getsentry/sentry')).not.toBeInTheDocument();
     });
 
-    it('does not disable add repo for members', function () {
+    it('does not disable add repo for members', async function () {
       MockApiClient.addMockResponse({
         url: `/organizations/${org.slug}/integrations/1/repos/`,
         body: {
@@ -133,7 +132,7 @@ describe('IntegrationRepos', function () {
           organization={OrganizationFixture({access: []})}
         />
       );
-      expect(screen.getByText('Add Repository')).toBeEnabled();
+      await waitFor(() => expect(screen.getByText('Add Repository')).toBeEnabled());
     });
   });
 

+ 30 - 177
static/app/views/settings/organizationIntegrations/integrationRepos.tsx

@@ -1,13 +1,9 @@
 import {Fragment} from 'react';
-import styled from '@emotion/styled';
-import debounce from 'lodash/debounce';
+import type {WithRouterProps} from 'react-router';
 
-import {addRepository, migrateRepository} from 'sentry/actionCreators/integrations';
 import {Alert} from 'sentry/components/alert';
-import {Button} from 'sentry/components/button';
+import {LinkButton} from 'sentry/components/button';
 import DeprecatedAsyncComponent from 'sentry/components/deprecatedAsyncComponent';
-import DropdownAutoComplete from 'sentry/components/dropdownAutoComplete';
-import DropdownButton from 'sentry/components/dropdownButton';
 import EmptyMessage from 'sentry/components/emptyMessage';
 import Pagination from 'sentry/components/pagination';
 import Panel from 'sentry/components/panels/panel';
@@ -17,28 +13,20 @@ import RepositoryRow from 'sentry/components/repositoryRow';
 import {IconCommit} from 'sentry/icons';
 import {t} from 'sentry/locale';
 import RepositoryStore from 'sentry/stores/repositoryStore';
-import {space} from 'sentry/styles/space';
-import type {
-  Integration,
-  IntegrationRepository,
-  Organization,
-  Repository,
-} from 'sentry/types';
+import type {Integration, Organization, Repository} from 'sentry/types';
 import withOrganization from 'sentry/utils/withOrganization';
+import withSentryRouter from 'sentry/utils/withSentryRouter';
 
-type Props = DeprecatedAsyncComponent['props'] & {
-  integration: Integration;
-  organization: Organization;
-};
+import {IntegrationReposAddRepository} from './integrationReposAddRepository';
 
-type State = DeprecatedAsyncComponent['state'] & {
-  adding: boolean;
-  dropdownBusy: boolean;
-  integrationRepos: {
-    repos: IntegrationRepository[];
-    searchable: boolean;
+type Props = DeprecatedAsyncComponent['props'] &
+  WithRouterProps & {
+    integration: Integration;
+    organization: Organization;
   };
-  integrationReposErrorStatus: number | null;
+
+type State = DeprecatedAsyncComponent['state'] & {
+  integrationReposErrorStatus: number | null | undefined;
   itemList: Repository[];
 };
 
@@ -46,19 +34,11 @@ class IntegrationRepos extends DeprecatedAsyncComponent<Props, State> {
   getDefaultState(): State {
     return {
       ...super.getDefaultState(),
-      adding: false,
       itemList: [],
-      integrationRepos: {repos: [], searchable: false},
       integrationReposErrorStatus: null,
-      dropdownBusy: true,
     };
   }
 
-  componentDidMount() {
-    super.componentDidMount();
-    this.searchRepositoriesRequest();
-  }
-
   getEndpoints(): ReturnType<DeprecatedAsyncComponent['getEndpoints']> {
     const {organization, integration} = this.props;
     return [
@@ -71,7 +51,7 @@ class IntegrationRepos extends DeprecatedAsyncComponent<Props, State> {
   }
 
   // Called by row to signal repository change.
-  onRepositoryChange = data => {
+  onRepositoryChange = (data: Repository) => {
     const itemList = this.state.itemList;
     itemList.forEach(item => {
       if (item.id === data.id) {
@@ -86,125 +66,18 @@ class IntegrationRepos extends DeprecatedAsyncComponent<Props, State> {
     RepositoryStore.resetRepositories();
   };
 
-  debouncedSearchRepositoriesRequest = debounce(
-    query => this.searchRepositoriesRequest(query),
-    200
-  );
-
-  searchRepositoriesRequest = (searchQuery?: string) => {
-    const {organization, integration} = this.props;
-    const query = {search: searchQuery};
-    const endpoint = `/organizations/${organization.slug}/integrations/${integration.id}/repos/`;
-    return this.api.request(endpoint, {
-      method: 'GET',
-      query,
-      success: data => {
-        this.setState({integrationRepos: data, dropdownBusy: false});
-      },
-      error: error => {
-        this.setState({dropdownBusy: false, integrationReposErrorStatus: error?.status});
-      },
-    });
-  };
-
-  handleSearchRepositories = (e?: React.ChangeEvent<HTMLInputElement>) => {
-    this.setState({dropdownBusy: true, integrationReposErrorStatus: null});
-    this.debouncedSearchRepositoriesRequest(e?.target.value);
+  handleSearchError = (errorStatus: number | null | undefined) => {
+    this.setState({integrationReposErrorStatus: errorStatus});
   };
 
-  addRepo(selection: {label: JSX.Element; searchKey: string; value: string}) {
-    const {integration, organization} = this.props;
-    const {itemList} = this.state;
-
-    this.setState({adding: true});
-
-    const migratableRepo = itemList.filter(item => {
-      if (!(selection.value && item.externalSlug)) {
-        return false;
-      }
-      return selection.value === item.externalSlug;
-    })[0];
-
-    let promise: Promise<Repository>;
-    if (migratableRepo) {
-      promise = migrateRepository(
-        this.api,
-        organization.slug,
-        migratableRepo.id,
-        integration
-      );
-    } else {
-      promise = addRepository(this.api, organization.slug, selection.value, integration);
-    }
-    promise.then(
-      (repo: Repository) => {
-        this.setState({adding: false, itemList: itemList.concat(repo)});
-        RepositoryStore.resetRepositories();
-      },
-      () => this.setState({adding: false})
-    );
-  }
-
-  renderDropdown() {
-    if (
-      !['github', 'gitlab'].includes(this.props.integration.provider.key) &&
-      !this.props.organization.access.includes('org:integrations')
-    ) {
-      return (
-        <DropdownButton
-          disabled
-          title={t(
-            'You must be an organization owner, manager or admin to add repositories'
-          )}
-          isOpen={false}
-          size="xs"
-        >
-          {t('Add Repository')}
-        </DropdownButton>
-      );
-    }
-    const repositories = new Set(
-      this.state.itemList.filter(item => item.integrationId).map(i => i.externalSlug)
-    );
-    const repositoryOptions = (this.state.integrationRepos.repos || []).filter(
-      repo => !repositories.has(repo.identifier)
-    );
-    const items = repositoryOptions.map(repo => ({
-      searchKey: repo.name,
-      value: repo.identifier,
-      label: (
-        <StyledListElement>
-          <StyledName>{repo.name}</StyledName>
-        </StyledListElement>
-      ),
+  handleAddRepository = (repo: Repository) => {
+    this.setState(state => ({
+      itemList: [...state.itemList, repo],
     }));
-
-    const menuHeader = <StyledReposLabel>{t('Repositories')}</StyledReposLabel>;
-    const onChange = this.state.integrationRepos.searchable
-      ? this.handleSearchRepositories
-      : undefined;
-
-    return (
-      <DropdownAutoComplete
-        items={items}
-        onSelect={this.addRepo.bind(this)}
-        onChange={onChange}
-        menuHeader={menuHeader}
-        emptyMessage={t('No repositories available')}
-        noResultsMessage={t('No repositories found')}
-        busy={this.state.dropdownBusy}
-        alignMenu="right"
-      >
-        {({isOpen}) => (
-          <DropdownButton isOpen={isOpen} size="xs" busy={this.state.adding}>
-            {t('Add Repository')}
-          </DropdownButton>
-        )}
-      </DropdownAutoComplete>
-    );
-  }
+  };
 
   renderBody() {
+    const {integration} = this.props;
     const {itemListPageLinks, integrationReposErrorStatus, itemList} = this.state;
     return (
       <Fragment>
@@ -219,7 +92,12 @@ class IntegrationRepos extends DeprecatedAsyncComponent<Props, State> {
         <Panel>
           <PanelHeader hasButtons>
             <div>{t('Repositories')}</div>
-            <DropdownWrapper>{this.renderDropdown()}</DropdownWrapper>
+            <IntegrationReposAddRepository
+              integration={integration}
+              currentRepositories={itemList}
+              onSearchError={this.handleSearchError}
+              onAddRepository={this.handleAddRepository}
+            />
           </PanelHeader>
           <PanelBody>
             {itemList.length === 0 && (
@@ -230,9 +108,9 @@ class IntegrationRepos extends DeprecatedAsyncComponent<Props, State> {
                   'Add a repository to begin tracking its commit data. Then, set up release tracking to unlock features like suspect commits, suggested issue owners, and deploy emails.'
                 )}
                 action={
-                  <Button href="https://docs.sentry.io/product/releases/">
+                  <LinkButton href="https://docs.sentry.io/product/releases/" external>
                     {t('Learn More')}
-                  </Button>
+                  </LinkButton>
                 }
               />
             )}
@@ -247,35 +125,10 @@ class IntegrationRepos extends DeprecatedAsyncComponent<Props, State> {
             ))}
           </PanelBody>
         </Panel>
-        {itemListPageLinks && (
-          <Pagination pageLinks={itemListPageLinks} {...this.props} />
-        )}
+        <Pagination pageLinks={itemListPageLinks} />
       </Fragment>
     );
   }
 }
 
-export default withOrganization(IntegrationRepos);
-
-const StyledReposLabel = styled('div')`
-  width: 250px;
-  font-size: 0.875em;
-  padding: ${space(1)} 0;
-  text-transform: uppercase;
-`;
-
-const DropdownWrapper = styled('div')`
-  text-transform: none;
-`;
-
-const StyledListElement = styled('div')`
-  display: flex;
-  align-items: center;
-  padding: ${space(0.5)};
-`;
-
-const StyledName = styled('div')`
-  flex-shrink: 1;
-  min-width: 0;
-  ${p => p.theme.overflowEllipsis};
-`;
+export default withOrganization(withSentryRouter(IntegrationRepos));

+ 166 - 0
static/app/views/settings/organizationIntegrations/integrationReposAddRepository.tsx

@@ -0,0 +1,166 @@
+import {useCallback, useEffect, useMemo, useState} from 'react';
+import styled from '@emotion/styled';
+import debounce from 'lodash/debounce';
+
+import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator';
+import {addRepository, migrateRepository} from 'sentry/actionCreators/integrations';
+import DropdownAutoComplete from 'sentry/components/dropdownAutoComplete';
+import DropdownButton from 'sentry/components/dropdownButton';
+import {t} from 'sentry/locale';
+import RepositoryStore from 'sentry/stores/repositoryStore';
+import type {Integration, IntegrationRepository, Repository} from 'sentry/types';
+import useApi from 'sentry/utils/useApi';
+import useOrganization from 'sentry/utils/useOrganization';
+
+interface IntegrationReposAddRepositoryProps {
+  currentRepositories: Repository[];
+  integration: Integration;
+  onAddRepository: (repo: Repository) => void;
+  onSearchError: (errorStatus: number | null | undefined) => void;
+}
+
+interface IntegrationRepoSearchResult {
+  repos: IntegrationRepository[];
+  searchable: boolean;
+}
+
+export function IntegrationReposAddRepository({
+  integration,
+  currentRepositories,
+  onSearchError,
+  onAddRepository,
+}: IntegrationReposAddRepositoryProps) {
+  const api = useApi({persistInFlight: true});
+  const organization = useOrganization();
+  const [dropdownBusy, setDropdownBusy] = useState(true);
+  const [adding, setAdding] = useState(false);
+  const [searchResult, setSearchResult] = useState<IntegrationRepoSearchResult>({
+    repos: [],
+    searchable: false,
+  });
+
+  const searchRepositoriesRequest = useCallback(
+    async (searchQuery?: string) => {
+      try {
+        const data: IntegrationRepoSearchResult = await api.requestPromise(
+          `/organizations/${organization.slug}/integrations/${integration.id}/repos/`,
+          {method: 'GET', query: {search: searchQuery}}
+        );
+        setSearchResult(data);
+      } catch (error) {
+        onSearchError(error?.status);
+      }
+      setDropdownBusy(false);
+    },
+    [api, integration, organization, onSearchError]
+  );
+
+  useEffect(() => {
+    // Load the repositories before the dropdown is opened
+    searchRepositoriesRequest();
+  }, [searchRepositoriesRequest]);
+
+  const debouncedSearchRepositoriesRequest = useMemo(
+    () => debounce(query => searchRepositoriesRequest(query), 200),
+    [searchRepositoriesRequest]
+  );
+
+  const handleSearchRepositories = useCallback(
+    (e?: React.ChangeEvent<HTMLInputElement>) => {
+      setDropdownBusy(true);
+      onSearchError(null);
+      debouncedSearchRepositoriesRequest(e?.target.value);
+    },
+    [debouncedSearchRepositoriesRequest, onSearchError]
+  );
+
+  const addRepo = async (selection: {value: string}) => {
+    setAdding(true);
+
+    const migratableRepo = currentRepositories.find(item => {
+      if (!(selection.value && item.externalSlug)) {
+        return false;
+      }
+      return selection.value === item.externalSlug;
+    });
+
+    let promise: Promise<Repository>;
+    if (migratableRepo) {
+      promise = migrateRepository(api, organization.slug, migratableRepo.id, integration);
+    } else {
+      promise = addRepository(api, organization.slug, selection.value, integration);
+    }
+
+    try {
+      const repo = await promise;
+      onAddRepository(repo);
+      addSuccessMessage(t('Repository added'));
+      RepositoryStore.resetRepositories();
+    } catch (error) {
+      addErrorMessage(t('Unable to add repository.'));
+    } finally {
+      setAdding(false);
+    }
+  };
+
+  const dropdownItems = useMemo(() => {
+    const repositories = new Set(
+      currentRepositories.filter(item => item.integrationId).map(i => i.externalSlug)
+    );
+    const repositoryOptions = searchResult.repos.filter(
+      repo => !repositories.has(repo.identifier)
+    );
+    return repositoryOptions.map(repo => ({
+      searchKey: repo.name,
+      value: repo.identifier,
+      label: <RepoName>{repo.name}</RepoName>,
+    }));
+  }, [currentRepositories, searchResult]);
+
+  if (
+    !['github', 'gitlab'].includes(integration.provider.key) &&
+    !organization.access.includes('org:integrations')
+  ) {
+    return (
+      <DropdownButton
+        disabled
+        title={t(
+          'You must be an organization owner, manager or admin to add repositories'
+        )}
+        isOpen={false}
+        size="xs"
+      >
+        {t('Add Repository')}
+      </DropdownButton>
+    );
+  }
+
+  return (
+    <DropdownWrapper>
+      <DropdownAutoComplete
+        items={dropdownItems}
+        onSelect={addRepo}
+        onChange={searchResult.searchable ? handleSearchRepositories : undefined}
+        emptyMessage={t('No repositories available')}
+        noResultsMessage={t('No repositories found')}
+        searchPlaceholder={t('Search Repositories')}
+        busy={dropdownBusy}
+        alignMenu="right"
+      >
+        {({isOpen}) => (
+          <DropdownButton isOpen={isOpen} size="xs" busy={adding}>
+            {t('Add Repository')}
+          </DropdownButton>
+        )}
+      </DropdownAutoComplete>
+    </DropdownWrapper>
+  );
+}
+
+const DropdownWrapper = styled('div')`
+  text-transform: none;
+`;
+
+const RepoName = styled('div')`
+  font-weight: normal;
+`;