integrationReposAddRepository.tsx 5.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170
  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 {
  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. searchable: boolean;
  26. }
  27. export function IntegrationReposAddRepository({
  28. integration,
  29. currentRepositories,
  30. onSearchError,
  31. onAddRepository,
  32. }: IntegrationReposAddRepositoryProps) {
  33. const api = useApi({persistInFlight: true});
  34. const organization = useOrganization();
  35. const [dropdownBusy, setDropdownBusy] = useState(true);
  36. const [adding, setAdding] = useState(false);
  37. const [searchResult, setSearchResult] = useState<IntegrationRepoSearchResult>({
  38. repos: [],
  39. searchable: false,
  40. });
  41. const searchRepositoriesRequest = useCallback(
  42. async (searchQuery?: string) => {
  43. try {
  44. const data: IntegrationRepoSearchResult = await api.requestPromise(
  45. `/organizations/${organization.slug}/integrations/${integration.id}/repos/`,
  46. {method: 'GET', query: {search: searchQuery}}
  47. );
  48. setSearchResult(data);
  49. } catch (error) {
  50. onSearchError(error?.status);
  51. }
  52. setDropdownBusy(false);
  53. },
  54. [api, integration, organization, onSearchError]
  55. );
  56. useEffect(() => {
  57. // Load the repositories before the dropdown is opened
  58. searchRepositoriesRequest();
  59. }, [searchRepositoriesRequest]);
  60. const debouncedSearchRepositoriesRequest = useMemo(
  61. () => debounce(query => searchRepositoriesRequest(query), 200),
  62. [searchRepositoriesRequest]
  63. );
  64. const handleSearchRepositories = useCallback(
  65. (e?: React.ChangeEvent<HTMLInputElement>) => {
  66. setDropdownBusy(true);
  67. onSearchError(null);
  68. debouncedSearchRepositoriesRequest(e?.target.value);
  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={searchResult.searchable ? handleSearchRepositories : undefined}
  133. emptyMessage={t('No repositories available')}
  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. `;