integrationDetailedView.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492
  1. import {Fragment} from 'react';
  2. import styled from '@emotion/styled';
  3. import {addErrorMessage} from 'sentry/actionCreators/indicator';
  4. import type {RequestOptions} from 'sentry/api';
  5. import {Alert} from 'sentry/components/alert';
  6. import {Button} from 'sentry/components/button';
  7. import type DeprecatedAsyncComponent from 'sentry/components/deprecatedAsyncComponent';
  8. import Form from 'sentry/components/forms/form';
  9. import JsonForm from 'sentry/components/forms/jsonForm';
  10. import type {Data, JsonFormObject} from 'sentry/components/forms/types';
  11. import HookOrDefault from 'sentry/components/hookOrDefault';
  12. import Panel from 'sentry/components/panels/panel';
  13. import PanelItem from 'sentry/components/panels/panelItem';
  14. import {IconOpen} from 'sentry/icons';
  15. import {t} from 'sentry/locale';
  16. import {space} from 'sentry/styles/space';
  17. import type {Integration, IntegrationProvider, ObjectStatus} from 'sentry/types';
  18. import {getAlertText, getIntegrationStatus} from 'sentry/utils/integrationUtil';
  19. import {normalizeUrl} from 'sentry/utils/withDomainRequired';
  20. import withOrganization from 'sentry/utils/withOrganization';
  21. import BreadcrumbTitle from 'sentry/views/settings/components/settingsBreadcrumb/breadcrumbTitle';
  22. import type {Tab} from './abstractIntegrationDetailedView';
  23. import AbstractIntegrationDetailedView from './abstractIntegrationDetailedView';
  24. import {AddIntegrationButton} from './addIntegrationButton';
  25. import InstalledIntegration from './installedIntegration';
  26. // Show the features tab if the org has features for the integration
  27. const integrationFeatures = ['github', 'slack'];
  28. const FirstPartyIntegrationAlert = HookOrDefault({
  29. hookName: 'component:first-party-integration-alert',
  30. defaultComponent: () => null,
  31. });
  32. const FirstPartyIntegrationAdditionalCTA = HookOrDefault({
  33. hookName: 'component:first-party-integration-additional-cta',
  34. defaultComponent: () => null,
  35. });
  36. type State = {
  37. configurations: Integration[];
  38. information: {providers: IntegrationProvider[]};
  39. };
  40. class IntegrationDetailedView extends AbstractIntegrationDetailedView<
  41. AbstractIntegrationDetailedView['props'],
  42. State & AbstractIntegrationDetailedView['state']
  43. > {
  44. tabs: Tab[] = ['overview', 'configurations', 'features'];
  45. getEndpoints(): ReturnType<DeprecatedAsyncComponent['getEndpoints']> {
  46. const {organization} = this.props;
  47. const {integrationSlug} = this.props.params;
  48. return [
  49. [
  50. 'information',
  51. `/organizations/${organization.slug}/config/integrations/?provider_key=${integrationSlug}`,
  52. ],
  53. [
  54. 'configurations',
  55. `/organizations/${organization.slug}/integrations/?provider_key=${integrationSlug}&includeConfig=0`,
  56. ],
  57. ];
  58. }
  59. get integrationType() {
  60. return 'first_party' as const;
  61. }
  62. get provider() {
  63. return this.state.information.providers[0];
  64. }
  65. get description() {
  66. return this.metadata.description;
  67. }
  68. get author() {
  69. return this.metadata.author;
  70. }
  71. get alerts() {
  72. const provider = this.provider;
  73. const metadata = this.metadata;
  74. // The server response for integration installations includes old icon CSS classes
  75. // We map those to the currently in use values to their react equivalents
  76. // and fallback to IconFlag just in case.
  77. const alerts = (metadata.aspects.alerts || []).map(item => ({
  78. ...item,
  79. showIcon: true,
  80. }));
  81. if (!provider.canAdd && metadata.aspects.externalInstall) {
  82. alerts.push({
  83. type: 'warning',
  84. showIcon: true,
  85. text: metadata.aspects.externalInstall.noticeText,
  86. });
  87. }
  88. return alerts;
  89. }
  90. get resourceLinks() {
  91. const metadata = this.metadata;
  92. return [
  93. {url: metadata.source_url, title: 'View Source'},
  94. {url: metadata.issue_url, title: 'Report Issue'},
  95. ];
  96. }
  97. get metadata() {
  98. return this.provider.metadata;
  99. }
  100. get isEnabled() {
  101. return this.state.configurations.length > 0;
  102. }
  103. get installationStatus() {
  104. // TODO: add transations
  105. const {configurations} = this.state;
  106. const statusList = configurations.map(getIntegrationStatus);
  107. // if we have conflicting statuses, we have a priority order
  108. if (statusList.includes('active')) {
  109. return 'Installed';
  110. }
  111. if (statusList.includes('disabled')) {
  112. return 'Disabled';
  113. }
  114. if (statusList.includes('pending_deletion')) {
  115. return 'Pending Deletion';
  116. }
  117. return 'Not Installed';
  118. }
  119. get integrationName() {
  120. return this.provider.name;
  121. }
  122. get featureData() {
  123. return this.metadata.features;
  124. }
  125. renderTabs() {
  126. // TODO: Convert to styled component
  127. const tabs = integrationFeatures.includes(this.provider.key)
  128. ? this.tabs
  129. : this.tabs.filter(tab => tab !== 'features');
  130. return (
  131. <ul className="nav nav-tabs border-bottom" style={{paddingTop: '30px'}}>
  132. {tabs.map(tabName => (
  133. <li
  134. key={tabName}
  135. className={this.state.tab === tabName ? 'active' : ''}
  136. onClick={() => this.onTabChange(tabName)}
  137. >
  138. <CapitalizedLink>{this.getTabDisplay(tabName)}</CapitalizedLink>
  139. </li>
  140. ))}
  141. </ul>
  142. );
  143. }
  144. onInstall = (integration: Integration) => {
  145. // send the user to the configure integration view for that integration
  146. const {organization} = this.props;
  147. this.props.router.push(
  148. normalizeUrl(
  149. `/settings/${organization.slug}/integrations/${integration.provider.key}/${integration.id}/`
  150. )
  151. );
  152. };
  153. onRemove = (integration: Integration) => {
  154. const {organization} = this.props;
  155. const origIntegrations = [...this.state.configurations];
  156. const integrations = this.state.configurations.map(i =>
  157. i.id === integration.id
  158. ? {...i, organizationIntegrationStatus: 'pending_deletion' as ObjectStatus}
  159. : i
  160. );
  161. this.setState({configurations: integrations});
  162. const options: RequestOptions = {
  163. method: 'DELETE',
  164. error: () => {
  165. this.setState({configurations: origIntegrations});
  166. addErrorMessage(t('Failed to remove Integration'));
  167. },
  168. };
  169. this.api.request(
  170. `/organizations/${organization.slug}/integrations/${integration.id}/`,
  171. options
  172. );
  173. };
  174. onDisable = (integration: Integration) => {
  175. let url: string;
  176. if (!integration.domainName) {
  177. return;
  178. }
  179. const [domainName, orgName] = integration.domainName.split('/');
  180. if (integration.accountType === 'User') {
  181. url = `https://${domainName}/settings/installations/`;
  182. } else {
  183. url = `https://${domainName}/organizations/${orgName}/settings/installations/`;
  184. }
  185. window.open(url, '_blank');
  186. };
  187. handleExternalInstall = () => {
  188. this.trackIntegrationAnalytics('integrations.installation_start');
  189. };
  190. renderAlert() {
  191. return (
  192. <FirstPartyIntegrationAlert
  193. integrations={this.state.configurations ?? []}
  194. hideCTA
  195. />
  196. );
  197. }
  198. renderAdditionalCTA() {
  199. return (
  200. <FirstPartyIntegrationAdditionalCTA
  201. integrations={this.state.configurations ?? []}
  202. />
  203. );
  204. }
  205. renderTopButton(disabledFromFeatures: boolean, userHasAccess: boolean) {
  206. const {organization} = this.props;
  207. const provider = this.provider;
  208. const {metadata} = provider;
  209. const size = 'sm' as const;
  210. const priority = 'primary' as const;
  211. const buttonProps = {
  212. style: {marginBottom: space(1)},
  213. size,
  214. priority,
  215. 'data-test-id': 'install-button',
  216. disabled: disabledFromFeatures,
  217. organization,
  218. };
  219. if (!userHasAccess) {
  220. return this.renderRequestIntegrationButton();
  221. }
  222. if (provider.canAdd) {
  223. return (
  224. <AddIntegrationButton
  225. provider={provider}
  226. onAddIntegration={this.onInstall}
  227. installStatus={this.installationStatus}
  228. analyticsParams={{
  229. view: 'integrations_directory_integration_detail',
  230. already_installed: this.installationStatus !== 'Not Installed',
  231. }}
  232. {...buttonProps}
  233. />
  234. );
  235. }
  236. if (metadata.aspects.externalInstall) {
  237. return (
  238. <Button
  239. icon={<IconOpen />}
  240. href={metadata.aspects.externalInstall.url}
  241. onClick={this.handleExternalInstall}
  242. external
  243. {...buttonProps}
  244. >
  245. {metadata.aspects.externalInstall.buttonText}
  246. </Button>
  247. );
  248. }
  249. // This should never happen but we can't return undefined without some refactoring.
  250. return <Fragment />;
  251. }
  252. renderConfigurations() {
  253. const {configurations} = this.state;
  254. const {organization} = this.props;
  255. const provider = this.provider;
  256. if (!configurations.length) {
  257. return this.renderEmptyConfigurations();
  258. }
  259. const alertText = getAlertText(configurations);
  260. return (
  261. <Fragment>
  262. {alertText && (
  263. <Alert type="warning" showIcon>
  264. {alertText}
  265. </Alert>
  266. )}
  267. <Panel>
  268. {configurations.map(integration => (
  269. <PanelItem key={integration.id}>
  270. <InstalledIntegration
  271. organization={organization}
  272. provider={provider}
  273. integration={integration}
  274. onRemove={this.onRemove}
  275. onDisable={this.onDisable}
  276. data-test-id={integration.id}
  277. trackIntegrationAnalytics={this.trackIntegrationAnalytics}
  278. requiresUpgrade={!!alertText}
  279. />
  280. </PanelItem>
  281. ))}
  282. </Panel>
  283. </Fragment>
  284. );
  285. }
  286. getSlackFeatures(): [JsonFormObject[], Data] {
  287. const {configurations} = this.state;
  288. const {organization} = this.props;
  289. const hasIntegration = configurations ? configurations.length > 0 : false;
  290. const forms: JsonFormObject[] = [
  291. {
  292. fields: [
  293. {
  294. name: 'issueAlertsThreadFlag',
  295. type: 'boolean',
  296. label: t('Enable Slack threads on Issue Alerts'),
  297. help: t(
  298. 'Allow Slack integration to post replies in threads for an Issue Alert notification.'
  299. ),
  300. disabled: !hasIntegration,
  301. disabledReason: t(
  302. 'You must have a Slack integration to enable this feature.'
  303. ),
  304. },
  305. {
  306. name: 'metricAlertsThreadFlag',
  307. type: 'boolean',
  308. label: t('Enable Slack threads on Metric Alerts'),
  309. help: t(
  310. 'Allow Slack integration to post replies in threads for an Metric Alert notification.'
  311. ),
  312. disabled: !hasIntegration,
  313. disabledReason: t(
  314. 'You must have a Slack integration to enable this feature.'
  315. ),
  316. },
  317. ],
  318. },
  319. ];
  320. const initialData = {
  321. issueAlertsThreadFlag: organization.issueAlertsThreadFlag,
  322. metricAlertsThreadFlag: organization.metricAlertsThreadFlag,
  323. };
  324. return [forms, initialData];
  325. }
  326. getGithubFeatures(): [JsonFormObject[], Data] {
  327. const {configurations} = this.state;
  328. const {organization} = this.props;
  329. const hasIntegration = configurations ? configurations.length > 0 : false;
  330. const forms: JsonFormObject[] = [
  331. {
  332. fields: [
  333. {
  334. name: 'githubPRBot',
  335. type: 'boolean',
  336. label: t('Enable Comments on Suspect Pull Requests'),
  337. help: t(
  338. 'Allow Sentry to comment on recent pull requests suspected of causing issues.'
  339. ),
  340. disabled: !hasIntegration,
  341. disabledReason: t(
  342. 'You must have a GitHub integration to enable this feature.'
  343. ),
  344. },
  345. {
  346. name: 'githubOpenPRBot',
  347. type: 'boolean',
  348. label: t('Enable Comments on Open Pull Requests'),
  349. help: t(
  350. 'Allow Sentry to comment on open pull requests to show recent error issues for the code being changed.'
  351. ),
  352. disabled: !hasIntegration,
  353. disabledReason: t(
  354. 'You must have a GitHub integration to enable this feature.'
  355. ),
  356. },
  357. {
  358. name: 'githubNudgeInvite',
  359. type: 'boolean',
  360. label: t('Enable Missing Member Detection'),
  361. help: t(
  362. 'Allow Sentry to detect users committing to your GitHub repositories that are not part of your Sentry organization..'
  363. ),
  364. disabled: !hasIntegration,
  365. disabledReason: t(
  366. 'You must have a GitHub integration to enable this feature.'
  367. ),
  368. },
  369. ],
  370. },
  371. ];
  372. const initialData = {
  373. githubPRBot: organization.githubPRBot,
  374. githubOpenPRBot: organization.githubOpenPRBot,
  375. githubNudgeInvite: organization.githubNudgeInvite,
  376. };
  377. return [forms, initialData];
  378. }
  379. renderFeatures() {
  380. const {organization} = this.props;
  381. const endpoint = `/organizations/${organization.slug}/`;
  382. const hasOrgWrite = organization.access.includes('org:write');
  383. let forms: JsonFormObject[], initialData: Data;
  384. switch (this.provider.key) {
  385. case 'github': {
  386. [forms, initialData] = this.getGithubFeatures();
  387. break;
  388. }
  389. case 'slack': {
  390. [forms, initialData] = this.getSlackFeatures();
  391. break;
  392. }
  393. default:
  394. return null;
  395. }
  396. return (
  397. <Form
  398. apiMethod="PUT"
  399. apiEndpoint={endpoint}
  400. saveOnBlur
  401. allowUndo
  402. initialData={initialData}
  403. onSubmitError={() => addErrorMessage('Unable to save change')}
  404. >
  405. <JsonForm
  406. disabled={!hasOrgWrite}
  407. features={organization.features}
  408. forms={forms}
  409. />
  410. </Form>
  411. );
  412. }
  413. renderBody() {
  414. return (
  415. <Fragment>
  416. <BreadcrumbTitle routes={this.props.routes} title={this.integrationName} />
  417. {this.renderAlert()}
  418. {this.renderTopSection()}
  419. {this.renderTabs()}
  420. {this.state.tab === 'overview'
  421. ? this.renderInformationCard()
  422. : this.state.tab === 'configurations'
  423. ? this.renderConfigurations()
  424. : this.renderFeatures()}
  425. </Fragment>
  426. );
  427. }
  428. }
  429. export default withOrganization(IntegrationDetailedView);
  430. const CapitalizedLink = styled('a')`
  431. text-transform: capitalize;
  432. `;