sentryAppDetailsModal.tsx 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283
  1. import {Fragment} from 'react';
  2. import styled from '@emotion/styled';
  3. import Access from 'sentry/components/acl/access';
  4. import AsyncComponent from 'sentry/components/asyncComponent';
  5. import Button from 'sentry/components/button';
  6. import CircleIndicator from 'sentry/components/circleIndicator';
  7. import SentryAppIcon from 'sentry/components/sentryAppIcon';
  8. import Tag from 'sentry/components/tag';
  9. import {IconFlag} from 'sentry/icons';
  10. import {t, tct} from 'sentry/locale';
  11. import space from 'sentry/styles/space';
  12. import {IntegrationFeature, Organization, SentryApp} from 'sentry/types';
  13. import {toPermissions} from 'sentry/utils/consolidatedScopes';
  14. import {
  15. getIntegrationFeatureGate,
  16. trackIntegrationAnalytics,
  17. } from 'sentry/utils/integrationUtil';
  18. import marked, {singleLineRenderer} from 'sentry/utils/marked';
  19. import {recordInteraction} from 'sentry/utils/recordSentryAppInteraction';
  20. type Props = {
  21. closeModal: () => void;
  22. isInstalled: boolean;
  23. onInstall: () => Promise<void>;
  24. organization: Organization;
  25. sentryApp: SentryApp;
  26. } & AsyncComponent['props'];
  27. type State = {
  28. featureData: IntegrationFeature[];
  29. } & AsyncComponent['state'];
  30. // No longer a modal anymore but yea :)
  31. export default class SentryAppDetailsModal extends AsyncComponent<Props, State> {
  32. componentDidUpdate(prevProps: Props) {
  33. // if the user changes org, count this as a fresh event to track
  34. if (this.props.organization.id !== prevProps.organization.id) {
  35. this.trackOpened();
  36. }
  37. }
  38. componentDidMount() {
  39. this.trackOpened();
  40. }
  41. trackOpened() {
  42. const {sentryApp, organization, isInstalled} = this.props;
  43. recordInteraction(sentryApp.slug, 'sentry_app_viewed');
  44. trackIntegrationAnalytics(
  45. 'integrations.install_modal_opened',
  46. {
  47. integration_type: 'sentry_app',
  48. integration: sentryApp.slug,
  49. already_installed: isInstalled,
  50. view: 'external_install',
  51. integration_status: sentryApp.status,
  52. organization,
  53. },
  54. {startSession: true}
  55. );
  56. }
  57. getEndpoints(): ReturnType<AsyncComponent['getEndpoints']> {
  58. const {sentryApp} = this.props;
  59. return [['featureData', `/sentry-apps/${sentryApp.slug}/features/`]];
  60. }
  61. featureTags(features: Pick<IntegrationFeature, 'featureGate'>[]) {
  62. return features.map(feature => {
  63. const feat = feature.featureGate.replace(/integrations/g, '');
  64. return <StyledTag key={feat}>{feat.replace(/-/g, ' ')}</StyledTag>;
  65. });
  66. }
  67. get permissions() {
  68. return toPermissions(this.props.sentryApp.scopes);
  69. }
  70. async onInstall() {
  71. const {onInstall} = this.props;
  72. // we want to make sure install finishes before we close the modal
  73. // and we should close the modal if there is an error as well
  74. try {
  75. await onInstall();
  76. } catch (_err) {
  77. /* stylelint-disable-next-line no-empty-block */
  78. }
  79. }
  80. renderPermissions() {
  81. const permissions = this.permissions;
  82. if (
  83. Object.keys(permissions).filter(scope => permissions[scope].length > 0).length === 0
  84. ) {
  85. return null;
  86. }
  87. return (
  88. <Fragment>
  89. <Title>Permissions</Title>
  90. {permissions.read.length > 0 && (
  91. <Permission>
  92. <Indicator />
  93. <Text key="read">
  94. {tct('[read] access to [resources] resources', {
  95. read: <strong>Read</strong>,
  96. resources: permissions.read.join(', '),
  97. })}
  98. </Text>
  99. </Permission>
  100. )}
  101. {permissions.write.length > 0 && (
  102. <Permission>
  103. <Indicator />
  104. <Text key="write">
  105. {tct('[read] and [write] access to [resources] resources', {
  106. read: <strong>Read</strong>,
  107. write: <strong>Write</strong>,
  108. resources: permissions.write.join(', '),
  109. })}
  110. </Text>
  111. </Permission>
  112. )}
  113. {permissions.admin.length > 0 && (
  114. <Permission>
  115. <Indicator />
  116. <Text key="admin">
  117. {tct('[admin] access to [resources] resources', {
  118. admin: <strong>Admin</strong>,
  119. resources: permissions.admin.join(', '),
  120. })}
  121. </Text>
  122. </Permission>
  123. )}
  124. </Fragment>
  125. );
  126. }
  127. renderBody() {
  128. const {sentryApp, closeModal, isInstalled, organization} = this.props;
  129. const {featureData} = this.state;
  130. // Prepare the features list
  131. const features = (featureData || []).map(f => ({
  132. featureGate: f.featureGate,
  133. description: (
  134. <span dangerouslySetInnerHTML={{__html: singleLineRenderer(f.description)}} />
  135. ),
  136. }));
  137. const {FeatureList, IntegrationFeatures} = getIntegrationFeatureGate();
  138. const overview = sentryApp.overview || '';
  139. const featureProps = {organization, features};
  140. return (
  141. <Fragment>
  142. <Heading>
  143. <SentryAppIcon sentryApp={sentryApp} size={50} />
  144. <HeadingInfo>
  145. <Name>{sentryApp.name}</Name>
  146. {!!features.length && <Features>{this.featureTags(features)}</Features>}
  147. </HeadingInfo>
  148. </Heading>
  149. <Description dangerouslySetInnerHTML={{__html: marked(overview)}} />
  150. <FeatureList {...featureProps} provider={{...sentryApp, key: sentryApp.slug}} />
  151. <IntegrationFeatures {...featureProps}>
  152. {({disabled, disabledReason}) => (
  153. <Fragment>
  154. {!disabled && this.renderPermissions()}
  155. <Footer>
  156. <Author>{t('Authored By %s', sentryApp.author)}</Author>
  157. <div>
  158. {disabled && <DisabledNotice reason={disabledReason} />}
  159. <Button size="sm" onClick={closeModal}>
  160. {t('Cancel')}
  161. </Button>
  162. <Access organization={organization} access={['org:integrations']}>
  163. {({hasAccess}) =>
  164. hasAccess && (
  165. <Button
  166. size="sm"
  167. priority="primary"
  168. disabled={isInstalled || disabled}
  169. onClick={() => this.onInstall()}
  170. style={{marginLeft: space(1)}}
  171. data-test-id="install"
  172. >
  173. {t('Accept & Install')}
  174. </Button>
  175. )
  176. }
  177. </Access>
  178. </div>
  179. </Footer>
  180. </Fragment>
  181. )}
  182. </IntegrationFeatures>
  183. </Fragment>
  184. );
  185. }
  186. }
  187. const Heading = styled('div')`
  188. display: grid;
  189. grid-template-columns: max-content 1fr;
  190. gap: ${space(1)};
  191. align-items: center;
  192. margin-bottom: ${space(2)};
  193. `;
  194. const HeadingInfo = styled('div')`
  195. display: grid;
  196. grid-template-rows: max-content max-content;
  197. align-items: start;
  198. `;
  199. const Name = styled('div')`
  200. font-weight: bold;
  201. font-size: 1.4em;
  202. `;
  203. const Description = styled('div')`
  204. margin-bottom: ${space(2)};
  205. li {
  206. margin-bottom: 6px;
  207. }
  208. `;
  209. const Author = styled('div')`
  210. color: ${p => p.theme.gray300};
  211. `;
  212. const DisabledNotice = styled(({reason, ...p}: {reason: React.ReactNode}) => (
  213. <div {...p}>
  214. <IconFlag color="red300" size="1.5em" />
  215. {reason}
  216. </div>
  217. ))`
  218. display: grid;
  219. align-items: center;
  220. flex: 1;
  221. grid-template-columns: max-content 1fr;
  222. color: ${p => p.theme.red300};
  223. font-size: 0.9em;
  224. `;
  225. const Text = styled('p')`
  226. margin: 0px 6px;
  227. `;
  228. const Permission = styled('div')`
  229. display: flex;
  230. `;
  231. const Footer = styled('div')`
  232. display: flex;
  233. padding: 20px 30px;
  234. border-top: 1px solid #e2dee6;
  235. margin: 20px -30px -30px;
  236. justify-content: space-between;
  237. `;
  238. const Title = styled('p')`
  239. margin-bottom: ${space(1)};
  240. font-weight: bold;
  241. `;
  242. const Indicator = styled(p => <CircleIndicator size={7} {...p} />)`
  243. margin-top: 7px;
  244. color: ${p => p.theme.success};
  245. `;
  246. const Features = styled('div')`
  247. margin: -${space(0.5)};
  248. `;
  249. const StyledTag = styled(Tag)`
  250. padding: ${space(0.5)};
  251. `;