index.tsx 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310
  1. import {useCallback, useContext, useEffect} from 'react';
  2. import type {InjectedRouter} from 'react-router';
  3. import styled from '@emotion/styled';
  4. import type {Location} from 'history';
  5. import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator';
  6. import {openDebugFileSourceModal} from 'sentry/actionCreators/modal';
  7. import type {Client} from 'sentry/api';
  8. import Access from 'sentry/components/acl/access';
  9. import Feature from 'sentry/components/acl/feature';
  10. import DropdownAutoComplete from 'sentry/components/dropdownAutoComplete';
  11. import DropdownButton from 'sentry/components/dropdownButton';
  12. import EmptyStateWarning from 'sentry/components/emptyStateWarning';
  13. import LoadingIndicator from 'sentry/components/loadingIndicator';
  14. import MenuItem from 'sentry/components/menuItem';
  15. import Panel from 'sentry/components/panels/panel';
  16. import PanelBody from 'sentry/components/panels/panelBody';
  17. import PanelHeader from 'sentry/components/panels/panelHeader';
  18. import AppStoreConnectContext from 'sentry/components/projects/appStoreConnectContext';
  19. import {Tooltip} from 'sentry/components/tooltip';
  20. import {t} from 'sentry/locale';
  21. import ProjectsStore from 'sentry/stores/projectsStore';
  22. import type {Organization, Project} from 'sentry/types';
  23. import type {CustomRepo} from 'sentry/types/debugFiles';
  24. import {CustomRepoType} from 'sentry/types/debugFiles';
  25. import {defined} from 'sentry/utils';
  26. import {handleXhrErrorResponse} from 'sentry/utils/handleXhrErrorResponse';
  27. import Repository from './repository';
  28. import {dropDownItems, expandKeys, getRequestMessages} from './utils';
  29. const SECTION_TITLE = t('Custom Repositories');
  30. type Props = {
  31. api: Client;
  32. customRepositories: CustomRepo[];
  33. isLoading: boolean;
  34. location: Location;
  35. organization: Organization;
  36. project: Project;
  37. router: InjectedRouter;
  38. };
  39. function CustomRepositories({
  40. api,
  41. organization,
  42. customRepositories: repositories,
  43. project,
  44. router,
  45. location,
  46. isLoading,
  47. }: Props) {
  48. const appStoreConnectContext = useContext(AppStoreConnectContext);
  49. const orgSlug = organization.slug;
  50. const appStoreConnectSourcesQuantity = repositories.filter(
  51. repository => repository.type === CustomRepoType.APP_STORE_CONNECT
  52. ).length;
  53. const persistData = useCallback(
  54. ({
  55. updatedItems,
  56. updatedItem,
  57. index,
  58. refresh,
  59. }: {
  60. index?: number;
  61. refresh?: boolean;
  62. updatedItem?: CustomRepo;
  63. updatedItems?: CustomRepo[];
  64. }) => {
  65. let items = updatedItems ?? [];
  66. if (updatedItem && defined(index)) {
  67. items = [...repositories];
  68. items.splice(index, 1, updatedItem);
  69. }
  70. const {successMessage, errorMessage} = getRequestMessages(
  71. items.length,
  72. repositories.length
  73. );
  74. const symbolSources = JSON.stringify(items.map(expandKeys));
  75. const promise: Promise<any> = api.requestPromise(
  76. `/projects/${orgSlug}/${project.slug}/`,
  77. {
  78. method: 'PUT',
  79. data: {symbolSources},
  80. }
  81. );
  82. promise.catch(() => {
  83. addErrorMessage(errorMessage);
  84. });
  85. promise.then(result => {
  86. ProjectsStore.onUpdateSuccess(result);
  87. addSuccessMessage(successMessage);
  88. if (refresh) {
  89. window.location.reload();
  90. }
  91. });
  92. return promise;
  93. },
  94. [api, orgSlug, project.slug, repositories]
  95. );
  96. const handleCloseModal = useCallback(() => {
  97. router.push({
  98. ...location,
  99. query: {
  100. ...location.query,
  101. customRepository: undefined,
  102. },
  103. });
  104. }, [location, router]);
  105. const openDebugFileSourceDialog = useCallback(() => {
  106. const {customRepository} = location.query;
  107. if (!customRepository) {
  108. return;
  109. }
  110. const itemIndex = repositories.findIndex(
  111. repository => repository.id === customRepository
  112. );
  113. const item = repositories[itemIndex];
  114. if (!item) {
  115. return;
  116. }
  117. openDebugFileSourceModal({
  118. organization,
  119. sourceConfig: item,
  120. sourceType: item.type,
  121. appStoreConnectSourcesQuantity,
  122. appStoreConnectStatusData: appStoreConnectContext?.[item.id],
  123. onSave: updatedItem =>
  124. persistData({updatedItem: updatedItem as CustomRepo, index: itemIndex}),
  125. onClose: handleCloseModal,
  126. });
  127. }, [
  128. appStoreConnectContext,
  129. appStoreConnectSourcesQuantity,
  130. handleCloseModal,
  131. location.query,
  132. organization,
  133. persistData,
  134. repositories,
  135. ]);
  136. useEffect(() => {
  137. openDebugFileSourceDialog();
  138. }, [location.query, appStoreConnectContext, openDebugFileSourceDialog]);
  139. function handleAddRepository(repoType: CustomRepoType) {
  140. openDebugFileSourceModal({
  141. organization,
  142. appStoreConnectSourcesQuantity,
  143. sourceType: repoType,
  144. onSave: updatedData =>
  145. persistData({updatedItems: [...repositories, updatedData] as CustomRepo[]}),
  146. });
  147. }
  148. function handleDeleteRepository(repoId: CustomRepo['id']) {
  149. const newRepositories = [...repositories];
  150. const index = newRepositories.findIndex(item => item.id === repoId);
  151. newRepositories.splice(index, 1);
  152. persistData({
  153. updatedItems: newRepositories as CustomRepo[],
  154. refresh: repositories[index].type === CustomRepoType.APP_STORE_CONNECT,
  155. });
  156. }
  157. function handleEditRepository(repoId: CustomRepo['id']) {
  158. router.push({
  159. ...location,
  160. query: {
  161. ...location.query,
  162. customRepository: repoId,
  163. },
  164. });
  165. }
  166. async function handleSyncRepositoryNow(repoId: CustomRepo['id']) {
  167. try {
  168. await api.requestPromise(
  169. `/projects/${orgSlug}/${project.slug}/appstoreconnect/${repoId}/refresh/`,
  170. {
  171. method: 'POST',
  172. }
  173. );
  174. addSuccessMessage(t('Repository sync started.'));
  175. } catch (error) {
  176. const errorMessage = t(
  177. 'Rate limit for refreshing repository exceeded. Try again in a few minutes.'
  178. );
  179. addErrorMessage(errorMessage);
  180. handleXhrErrorResponse(errorMessage, error);
  181. }
  182. }
  183. return (
  184. <Feature features="custom-symbol-sources" organization={organization}>
  185. {({hasFeature}) => (
  186. <Access access={['project:write']} project={project}>
  187. {({hasAccess}) => {
  188. const addRepositoryButtonDisabled = !hasAccess || isLoading;
  189. return (
  190. <Panel>
  191. <PanelHeader hasButtons>
  192. {SECTION_TITLE}
  193. <Tooltip
  194. title={
  195. !hasAccess
  196. ? t('You do not have permission to add custom repositories.')
  197. : undefined
  198. }
  199. >
  200. <DropdownAutoComplete
  201. alignMenu="right"
  202. disabled={addRepositoryButtonDisabled}
  203. onSelect={item => handleAddRepository(item.value)}
  204. items={dropDownItems.map(dropDownItem => ({
  205. ...dropDownItem,
  206. label: (
  207. <DropDownLabel
  208. aria-label={t(
  209. 'Open %s custom repository modal',
  210. dropDownItem.label
  211. )}
  212. >
  213. {dropDownItem.label}
  214. </DropDownLabel>
  215. ),
  216. }))}
  217. >
  218. {({isOpen}) => (
  219. <DropdownButton
  220. isOpen={isOpen}
  221. disabled={addRepositoryButtonDisabled}
  222. size="xs"
  223. aria-label={t('Add Repository')}
  224. >
  225. {t('Add Repository')}
  226. </DropdownButton>
  227. )}
  228. </DropdownAutoComplete>
  229. </Tooltip>
  230. </PanelHeader>
  231. <PanelBody>
  232. {isLoading ? (
  233. <LoadingIndicator />
  234. ) : !repositories.length ? (
  235. <EmptyStateWarning>
  236. <p>{t('No custom repositories configured')}</p>
  237. </EmptyStateWarning>
  238. ) : (
  239. repositories.map((repository, index) => (
  240. <Repository
  241. key={index}
  242. repository={
  243. repository.type === CustomRepoType.APP_STORE_CONNECT
  244. ? {
  245. ...repository,
  246. details: appStoreConnectContext?.[repository.id],
  247. }
  248. : repository
  249. }
  250. hasFeature={
  251. repository.type === CustomRepoType.APP_STORE_CONNECT
  252. ? hasFeature || appStoreConnectSourcesQuantity === 1
  253. : hasFeature
  254. }
  255. hasAccess={hasAccess}
  256. onDelete={handleDeleteRepository}
  257. onEdit={handleEditRepository}
  258. onSyncNow={handleSyncRepositoryNow}
  259. />
  260. ))
  261. )}
  262. </PanelBody>
  263. </Panel>
  264. );
  265. }}
  266. </Access>
  267. )}
  268. </Feature>
  269. );
  270. }
  271. export default CustomRepositories;
  272. const DropDownLabel = styled(MenuItem)`
  273. color: ${p => p.theme.textColor};
  274. font-size: ${p => p.theme.fontSizeMedium};
  275. font-weight: 400;
  276. text-transform: none;
  277. span {
  278. padding: 0;
  279. }
  280. `;