integrationReposAddRepository.tsx 5.0 KB

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