integrationDetailedView.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474
  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 type DeprecatedAsyncComponent from 'sentry/components/deprecatedAsyncComponent';
  7. import Form from 'sentry/components/forms/form';
  8. import JsonForm from 'sentry/components/forms/jsonForm';
  9. import type {Data, JsonFormObject} from 'sentry/components/forms/types';
  10. import HookOrDefault from 'sentry/components/hookOrDefault';
  11. import Panel from 'sentry/components/panels/panel';
  12. import PanelItem from 'sentry/components/panels/panelItem';
  13. import {t} from 'sentry/locale';
  14. import {space} from 'sentry/styles/space';
  15. import type {ObjectStatus} from 'sentry/types/core';
  16. import type {Integration, IntegrationProvider} from 'sentry/types/integrations';
  17. import {getAlertText, getIntegrationStatus} from 'sentry/utils/integrationUtil';
  18. import normalizeUrl from 'sentry/utils/url/normalizeUrl';
  19. import withOrganization from 'sentry/utils/withOrganization';
  20. import BreadcrumbTitle from 'sentry/views/settings/components/settingsBreadcrumb/breadcrumbTitle';
  21. import IntegrationButton from 'sentry/views/settings/organizationIntegrations/integrationButton';
  22. import {IntegrationContext} from 'sentry/views/settings/organizationIntegrations/integrationContext';
  23. import type {Tab} from './abstractIntegrationDetailedView';
  24. import AbstractIntegrationDetailedView from './abstractIntegrationDetailedView';
  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 provider = this.provider;
  207. const buttonProps = {
  208. size: 'sm',
  209. priority: 'primary',
  210. 'data-test-id': 'install-button',
  211. disabled: disabledFromFeatures,
  212. };
  213. return (
  214. <IntegrationContext.Provider
  215. value={{
  216. provider: provider,
  217. type: this.integrationType,
  218. installStatus: this.installationStatus,
  219. analyticsParams: {
  220. view: 'integrations_directory_integration_detail',
  221. already_installed: this.installationStatus !== 'Not Installed',
  222. },
  223. }}
  224. >
  225. <StyledIntegrationButton
  226. userHasAccess={userHasAccess}
  227. onAddIntegration={this.onInstall}
  228. onExternalClick={this.handleExternalInstall}
  229. buttonProps={buttonProps}
  230. />
  231. </IntegrationContext.Provider>
  232. );
  233. }
  234. renderConfigurations() {
  235. const {configurations} = this.state;
  236. const {organization} = this.props;
  237. const provider = this.provider;
  238. if (!configurations.length) {
  239. return this.renderEmptyConfigurations();
  240. }
  241. const alertText = getAlertText(configurations);
  242. return (
  243. <Fragment>
  244. {alertText && (
  245. <Alert type="warning" showIcon>
  246. {alertText}
  247. </Alert>
  248. )}
  249. <Panel>
  250. {configurations.map(integration => (
  251. <PanelItem key={integration.id}>
  252. <InstalledIntegration
  253. organization={organization}
  254. provider={provider}
  255. integration={integration}
  256. onRemove={this.onRemove}
  257. onDisable={this.onDisable}
  258. data-test-id={integration.id}
  259. trackIntegrationAnalytics={this.trackIntegrationAnalytics}
  260. requiresUpgrade={!!alertText}
  261. />
  262. </PanelItem>
  263. ))}
  264. </Panel>
  265. </Fragment>
  266. );
  267. }
  268. getSlackFeatures(): [JsonFormObject[], Data] {
  269. const {configurations} = this.state;
  270. const {organization} = this.props;
  271. const hasIntegration = configurations ? configurations.length > 0 : false;
  272. const forms: JsonFormObject[] = [
  273. {
  274. fields: [
  275. {
  276. name: 'issueAlertsThreadFlag',
  277. type: 'boolean',
  278. label: t('Enable Slack threads on Issue Alerts'),
  279. help: t(
  280. 'Allow Slack integration to post replies in threads for an Issue Alert notification.'
  281. ),
  282. disabled: !hasIntegration,
  283. disabledReason: t(
  284. 'You must have a Slack integration to enable this feature.'
  285. ),
  286. },
  287. {
  288. name: 'metricAlertsThreadFlag',
  289. type: 'boolean',
  290. label: t('Enable Slack threads on Metric Alerts'),
  291. help: t(
  292. 'Allow Slack integration to post replies in threads for an Metric Alert notification.'
  293. ),
  294. disabled: !hasIntegration,
  295. disabledReason: t(
  296. 'You must have a Slack integration to enable this feature.'
  297. ),
  298. },
  299. ],
  300. },
  301. ];
  302. const initialData = {
  303. issueAlertsThreadFlag: organization.issueAlertsThreadFlag,
  304. metricAlertsThreadFlag: organization.metricAlertsThreadFlag,
  305. };
  306. return [forms, initialData];
  307. }
  308. getGithubFeatures(): [JsonFormObject[], Data] {
  309. const {configurations} = this.state;
  310. const {organization} = this.props;
  311. const hasIntegration = configurations ? configurations.length > 0 : false;
  312. const forms: JsonFormObject[] = [
  313. {
  314. fields: [
  315. {
  316. name: 'githubPRBot',
  317. type: 'boolean',
  318. label: t('Enable Comments on Suspect Pull Requests'),
  319. help: t(
  320. 'Allow Sentry to comment on recent pull requests suspected of causing issues.'
  321. ),
  322. disabled: !hasIntegration,
  323. disabledReason: t(
  324. 'You must have a GitHub integration to enable this feature.'
  325. ),
  326. },
  327. {
  328. name: 'githubOpenPRBot',
  329. type: 'boolean',
  330. label: t('Enable Comments on Open Pull Requests'),
  331. help: t(
  332. 'Allow Sentry to comment on open pull requests to show recent error issues for the code being changed.'
  333. ),
  334. disabled: !hasIntegration,
  335. disabledReason: t(
  336. 'You must have a GitHub integration to enable this feature.'
  337. ),
  338. },
  339. {
  340. name: 'githubNudgeInvite',
  341. type: 'boolean',
  342. label: t('Enable Missing Member Detection'),
  343. help: t(
  344. 'Allow Sentry to detect users committing to your GitHub repositories that are not part of your Sentry organization..'
  345. ),
  346. disabled: !hasIntegration,
  347. disabledReason: t(
  348. 'You must have a GitHub integration to enable this feature.'
  349. ),
  350. },
  351. ],
  352. },
  353. ];
  354. const initialData = {
  355. githubPRBot: organization.githubPRBot,
  356. githubOpenPRBot: organization.githubOpenPRBot,
  357. githubNudgeInvite: organization.githubNudgeInvite,
  358. };
  359. return [forms, initialData];
  360. }
  361. renderFeatures() {
  362. const {organization} = this.props;
  363. const endpoint = `/organizations/${organization.slug}/`;
  364. const hasOrgWrite = organization.access.includes('org:write');
  365. let forms: JsonFormObject[], initialData: Data;
  366. switch (this.provider.key) {
  367. case 'github': {
  368. [forms, initialData] = this.getGithubFeatures();
  369. break;
  370. }
  371. case 'slack': {
  372. [forms, initialData] = this.getSlackFeatures();
  373. break;
  374. }
  375. default:
  376. return null;
  377. }
  378. return (
  379. <Form
  380. apiMethod="PUT"
  381. apiEndpoint={endpoint}
  382. saveOnBlur
  383. allowUndo
  384. initialData={initialData}
  385. onSubmitError={() => addErrorMessage('Unable to save change')}
  386. >
  387. <JsonForm
  388. disabled={!hasOrgWrite}
  389. features={organization.features}
  390. forms={forms}
  391. />
  392. </Form>
  393. );
  394. }
  395. renderBody() {
  396. return (
  397. <Fragment>
  398. <BreadcrumbTitle routes={this.props.routes} title={this.integrationName} />
  399. {this.renderAlert()}
  400. {this.renderTopSection()}
  401. {this.renderTabs()}
  402. {this.state.tab === 'overview'
  403. ? this.renderInformationCard()
  404. : this.state.tab === 'configurations'
  405. ? this.renderConfigurations()
  406. : this.renderFeatures()}
  407. </Fragment>
  408. );
  409. }
  410. }
  411. export default withOrganization(IntegrationDetailedView);
  412. const CapitalizedLink = styled('a')`
  413. text-transform: capitalize;
  414. `;
  415. const StyledIntegrationButton = styled(IntegrationButton)`
  416. margin-bottom: ${space(1)};
  417. `;