integrationReposAddRepository.tsx 5.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166
  1. import {useCallback, useEffect, useMemo, useState} from 'react';
  2. import styled from '@emotion/styled';
  3. import debounce from 'lodash/debounce';
  4. import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator';
  5. import {addRepository, migrateRepository} from 'sentry/actionCreators/integrations';
  6. import DropdownAutoComplete from 'sentry/components/dropdownAutoComplete';
  7. import DropdownButton from 'sentry/components/dropdownButton';
  8. import {t} from 'sentry/locale';
  9. import RepositoryStore from 'sentry/stores/repositoryStore';
  10. import type {Integration, IntegrationRepository, Repository} from 'sentry/types';
  11. import useApi from 'sentry/utils/useApi';
  12. import useOrganization from 'sentry/utils/useOrganization';
  13. interface IntegrationReposAddRepositoryProps {
  14. currentRepositories: Repository[];
  15. integration: Integration;
  16. onAddRepository: (repo: Repository) => void;
  17. onSearchError: (errorStatus: number | null | undefined) => void;
  18. }
  19. interface IntegrationRepoSearchResult {
  20. repos: IntegrationRepository[];
  21. searchable: boolean;
  22. }
  23. export function IntegrationReposAddRepository({
  24. integration,
  25. currentRepositories,
  26. onSearchError,
  27. onAddRepository,
  28. }: IntegrationReposAddRepositoryProps) {
  29. const api = useApi({persistInFlight: true});
  30. const organization = useOrganization();
  31. const [dropdownBusy, setDropdownBusy] = useState(true);
  32. const [adding, setAdding] = useState(false);
  33. const [searchResult, setSearchResult] = useState<IntegrationRepoSearchResult>({
  34. repos: [],
  35. searchable: false,
  36. });
  37. const searchRepositoriesRequest = useCallback(
  38. async (searchQuery?: string) => {
  39. try {
  40. const data: IntegrationRepoSearchResult = await api.requestPromise(
  41. `/organizations/${organization.slug}/integrations/${integration.id}/repos/`,
  42. {method: 'GET', query: {search: searchQuery}}
  43. );
  44. setSearchResult(data);
  45. } catch (error) {
  46. onSearchError(error?.status);
  47. }
  48. setDropdownBusy(false);
  49. },
  50. [api, integration, organization, onSearchError]
  51. );
  52. useEffect(() => {
  53. // Load the repositories before the dropdown is opened
  54. searchRepositoriesRequest();
  55. }, [searchRepositoriesRequest]);
  56. const debouncedSearchRepositoriesRequest = useMemo(
  57. () => debounce(query => searchRepositoriesRequest(query), 200),
  58. [searchRepositoriesRequest]
  59. );
  60. const handleSearchRepositories = useCallback(
  61. (e?: React.ChangeEvent<HTMLInputElement>) => {
  62. setDropdownBusy(true);
  63. onSearchError(null);
  64. debouncedSearchRepositoriesRequest(e?.target.value);
  65. },
  66. [debouncedSearchRepositoriesRequest, onSearchError]
  67. );
  68. const addRepo = async (selection: {value: string}) => {
  69. setAdding(true);
  70. const migratableRepo = currentRepositories.find(item => {
  71. if (!(selection.value && item.externalSlug)) {
  72. return false;
  73. }
  74. return selection.value === item.externalSlug;
  75. });
  76. let promise: Promise<Repository>;
  77. if (migratableRepo) {
  78. promise = migrateRepository(api, organization.slug, migratableRepo.id, integration);
  79. } else {
  80. promise = addRepository(api, organization.slug, selection.value, integration);
  81. }
  82. try {
  83. const repo = await promise;
  84. onAddRepository(repo);
  85. addSuccessMessage(t('Repository added'));
  86. RepositoryStore.resetRepositories();
  87. } catch (error) {
  88. addErrorMessage(t('Unable to add repository.'));
  89. } finally {
  90. setAdding(false);
  91. }
  92. };
  93. const dropdownItems = useMemo(() => {
  94. const repositories = new Set(
  95. currentRepositories.filter(item => item.integrationId).map(i => i.externalSlug)
  96. );
  97. const repositoryOptions = searchResult.repos.filter(
  98. repo => !repositories.has(repo.identifier)
  99. );
  100. return repositoryOptions.map(repo => ({
  101. searchKey: repo.name,
  102. value: repo.identifier,
  103. label: <RepoName>{repo.name}</RepoName>,
  104. }));
  105. }, [currentRepositories, searchResult]);
  106. if (
  107. !['github', 'gitlab'].includes(integration.provider.key) &&
  108. !organization.access.includes('org:integrations')
  109. ) {
  110. return (
  111. <DropdownButton
  112. disabled
  113. title={t(
  114. 'You must be an organization owner, manager or admin to add repositories'
  115. )}
  116. isOpen={false}
  117. size="xs"
  118. >
  119. {t('Add Repository')}
  120. </DropdownButton>
  121. );
  122. }
  123. return (
  124. <DropdownWrapper>
  125. <DropdownAutoComplete
  126. items={dropdownItems}
  127. onSelect={addRepo}
  128. onChange={searchResult.searchable ? handleSearchRepositories : undefined}
  129. emptyMessage={t('No repositories available')}
  130. noResultsMessage={t('No repositories found')}
  131. searchPlaceholder={t('Search Repositories')}
  132. busy={dropdownBusy}
  133. alignMenu="right"
  134. >
  135. {({isOpen}) => (
  136. <DropdownButton isOpen={isOpen} size="xs" busy={adding}>
  137. {t('Add Repository')}
  138. </DropdownButton>
  139. )}
  140. </DropdownAutoComplete>
  141. </DropdownWrapper>
  142. );
  143. }
  144. const DropdownWrapper = styled('div')`
  145. text-transform: none;
  146. `;
  147. const RepoName = styled('div')`
  148. font-weight: ${p => p.theme.fontWeightNormal};
  149. `;