Files
2024-02-15 17:01:50 +00:00

288 lines
8.0 KiB
TypeScript

import {Fragment} from 'react';
import styled from '@emotion/styled';
import Access from 'sentry/components/acl/access';
import {Button} from 'sentry/components/button';
import CircleIndicator from 'sentry/components/circleIndicator';
import DeprecatedAsyncComponent from 'sentry/components/deprecatedAsyncComponent';
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 type {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;
} & DeprecatedAsyncComponent['props'];
type State = {
featureData: IntegrationFeature[];
} & DeprecatedAsyncComponent['state'];
// No longer a modal anymore but yea :)
export default class SentryAppDetailsModal extends DeprecatedAsyncComponent<
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() {
super.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<DeprecatedAsyncComponent['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 access={['org:integrations']} organization={organization}>
{({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="errorText" size="md" />
{reason}
</div>
))`
display: grid;
align-items: center;
flex: 1;
grid-template-columns: max-content 1fr;
color: ${p => p.theme.errorText};
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)};
`;