sentryAppDetailsModal.tsx 8.0 KB

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