123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283 |
- import {Fragment} from 'react';
- import styled from '@emotion/styled';
- import Access from 'sentry/components/acl/access';
- import AsyncComponent from 'sentry/components/asyncComponent';
- import Button from 'sentry/components/button';
- import CircleIndicator from 'sentry/components/circleIndicator';
- import SentryAppIcon from 'sentry/components/sentryAppIcon';
- import Tag from 'sentry/components/tag';
- import {IconFlag} from 'sentry/icons';
- import {t, tct} from 'sentry/locale';
- import space from 'sentry/styles/space';
- import {IntegrationFeature, Organization, SentryApp} from 'sentry/types';
- import {toPermissions} from 'sentry/utils/consolidatedScopes';
- import {
- getIntegrationFeatureGate,
- trackIntegrationAnalytics,
- } from 'sentry/utils/integrationUtil';
- import marked, {singleLineRenderer} from 'sentry/utils/marked';
- import {recordInteraction} from 'sentry/utils/recordSentryAppInteraction';
- type Props = {
- closeModal: () => void;
- isInstalled: boolean;
- onInstall: () => Promise<void>;
- organization: Organization;
- sentryApp: SentryApp;
- } & AsyncComponent['props'];
- type State = {
- featureData: IntegrationFeature[];
- } & AsyncComponent['state'];
- // No longer a modal anymore but yea :)
- export default class SentryAppDetailsModal extends AsyncComponent<Props, State> {
- componentDidUpdate(prevProps: Props) {
- // if the user changes org, count this as a fresh event to track
- if (this.props.organization.id !== prevProps.organization.id) {
- this.trackOpened();
- }
- }
- componentDidMount() {
- this.trackOpened();
- }
- trackOpened() {
- const {sentryApp, organization, isInstalled} = this.props;
- recordInteraction(sentryApp.slug, 'sentry_app_viewed');
- trackIntegrationAnalytics(
- 'integrations.install_modal_opened',
- {
- integration_type: 'sentry_app',
- integration: sentryApp.slug,
- already_installed: isInstalled,
- view: 'external_install',
- integration_status: sentryApp.status,
- organization,
- },
- {startSession: true}
- );
- }
- getEndpoints(): ReturnType<AsyncComponent['getEndpoints']> {
- const {sentryApp} = this.props;
- return [['featureData', `/sentry-apps/${sentryApp.slug}/features/`]];
- }
- featureTags(features: Pick<IntegrationFeature, 'featureGate'>[]) {
- return features.map(feature => {
- const feat = feature.featureGate.replace(/integrations/g, '');
- return <StyledTag key={feat}>{feat.replace(/-/g, ' ')}</StyledTag>;
- });
- }
- get permissions() {
- return toPermissions(this.props.sentryApp.scopes);
- }
- async onInstall() {
- const {onInstall} = this.props;
- // we want to make sure install finishes before we close the modal
- // and we should close the modal if there is an error as well
- try {
- await onInstall();
- } catch (_err) {
- /* stylelint-disable-next-line no-empty-block */
- }
- }
- renderPermissions() {
- const permissions = this.permissions;
- if (
- Object.keys(permissions).filter(scope => permissions[scope].length > 0).length === 0
- ) {
- return null;
- }
- return (
- <Fragment>
- <Title>Permissions</Title>
- {permissions.read.length > 0 && (
- <Permission>
- <Indicator />
- <Text key="read">
- {tct('[read] access to [resources] resources', {
- read: <strong>Read</strong>,
- resources: permissions.read.join(', '),
- })}
- </Text>
- </Permission>
- )}
- {permissions.write.length > 0 && (
- <Permission>
- <Indicator />
- <Text key="write">
- {tct('[read] and [write] access to [resources] resources', {
- read: <strong>Read</strong>,
- write: <strong>Write</strong>,
- resources: permissions.write.join(', '),
- })}
- </Text>
- </Permission>
- )}
- {permissions.admin.length > 0 && (
- <Permission>
- <Indicator />
- <Text key="admin">
- {tct('[admin] access to [resources] resources', {
- admin: <strong>Admin</strong>,
- resources: permissions.admin.join(', '),
- })}
- </Text>
- </Permission>
- )}
- </Fragment>
- );
- }
- renderBody() {
- const {sentryApp, closeModal, isInstalled, organization} = this.props;
- const {featureData} = this.state;
- // Prepare the features list
- const features = (featureData || []).map(f => ({
- featureGate: f.featureGate,
- description: (
- <span dangerouslySetInnerHTML={{__html: singleLineRenderer(f.description)}} />
- ),
- }));
- const {FeatureList, IntegrationFeatures} = getIntegrationFeatureGate();
- const overview = sentryApp.overview || '';
- const featureProps = {organization, features};
- return (
- <Fragment>
- <Heading>
- <SentryAppIcon sentryApp={sentryApp} size={50} />
- <HeadingInfo>
- <Name>{sentryApp.name}</Name>
- {!!features.length && <Features>{this.featureTags(features)}</Features>}
- </HeadingInfo>
- </Heading>
- <Description dangerouslySetInnerHTML={{__html: marked(overview)}} />
- <FeatureList {...featureProps} provider={{...sentryApp, key: sentryApp.slug}} />
- <IntegrationFeatures {...featureProps}>
- {({disabled, disabledReason}) => (
- <Fragment>
- {!disabled && this.renderPermissions()}
- <Footer>
- <Author>{t('Authored By %s', sentryApp.author)}</Author>
- <div>
- {disabled && <DisabledNotice reason={disabledReason} />}
- <Button size="sm" onClick={closeModal}>
- {t('Cancel')}
- </Button>
- <Access organization={organization} access={['org:integrations']}>
- {({hasAccess}) =>
- hasAccess && (
- <Button
- size="sm"
- priority="primary"
- disabled={isInstalled || disabled}
- onClick={() => this.onInstall()}
- style={{marginLeft: space(1)}}
- data-test-id="install"
- >
- {t('Accept & Install')}
- </Button>
- )
- }
- </Access>
- </div>
- </Footer>
- </Fragment>
- )}
- </IntegrationFeatures>
- </Fragment>
- );
- }
- }
- const Heading = styled('div')`
- display: grid;
- grid-template-columns: max-content 1fr;
- gap: ${space(1)};
- align-items: center;
- margin-bottom: ${space(2)};
- `;
- const HeadingInfo = styled('div')`
- display: grid;
- grid-template-rows: max-content max-content;
- align-items: start;
- `;
- const Name = styled('div')`
- font-weight: bold;
- font-size: 1.4em;
- `;
- const Description = styled('div')`
- margin-bottom: ${space(2)};
- li {
- margin-bottom: 6px;
- }
- `;
- const Author = styled('div')`
- color: ${p => p.theme.gray300};
- `;
- const DisabledNotice = styled(({reason, ...p}: {reason: React.ReactNode}) => (
- <div {...p}>
- <IconFlag color="red300" size="1.5em" />
- {reason}
- </div>
- ))`
- display: grid;
- align-items: center;
- flex: 1;
- grid-template-columns: max-content 1fr;
- color: ${p => p.theme.red300};
- font-size: 0.9em;
- `;
- const Text = styled('p')`
- margin: 0px 6px;
- `;
- const Permission = styled('div')`
- display: flex;
- `;
- const Footer = styled('div')`
- display: flex;
- padding: 20px 30px;
- border-top: 1px solid #e2dee6;
- margin: 20px -30px -30px;
- justify-content: space-between;
- `;
- const Title = styled('p')`
- margin-bottom: ${space(1)};
- font-weight: bold;
- `;
- const Indicator = styled(p => <CircleIndicator size={7} {...p} />)`
- margin-top: 7px;
- color: ${p => p.theme.success};
- `;
- const Features = styled('div')`
- margin: -${space(0.5)};
- `;
- const StyledTag = styled(Tag)`
- padding: ${space(0.5)};
- `;
|