123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430 |
- import {Fragment} from 'react';
- import styled from '@emotion/styled';
- import {addErrorMessage} from 'sentry/actionCreators/indicator';
- import type {RequestOptions} from 'sentry/api';
- import {Alert} from 'sentry/components/alert';
- import {Button} from 'sentry/components/button';
- import type DeprecatedAsyncComponent from 'sentry/components/deprecatedAsyncComponent';
- import Form from 'sentry/components/forms/form';
- import JsonForm from 'sentry/components/forms/jsonForm';
- import type {JsonFormObject} from 'sentry/components/forms/types';
- import HookOrDefault from 'sentry/components/hookOrDefault';
- import Panel from 'sentry/components/panels/panel';
- import PanelItem from 'sentry/components/panels/panelItem';
- import {IconOpen} from 'sentry/icons';
- import {t} from 'sentry/locale';
- import {space} from 'sentry/styles/space';
- import type {Integration, IntegrationProvider, ObjectStatus} from 'sentry/types';
- import {getAlertText, getIntegrationStatus} from 'sentry/utils/integrationUtil';
- import {normalizeUrl} from 'sentry/utils/withDomainRequired';
- import withOrganization from 'sentry/utils/withOrganization';
- import BreadcrumbTitle from 'sentry/views/settings/components/settingsBreadcrumb/breadcrumbTitle';
- import type {Tab} from './abstractIntegrationDetailedView';
- import AbstractIntegrationDetailedView from './abstractIntegrationDetailedView';
- import {AddIntegrationButton} from './addIntegrationButton';
- import InstalledIntegration from './installedIntegration';
- // Show the features tab if the org has features for the integration
- const integrationFeatures = ['github'];
- const FirstPartyIntegrationAlert = HookOrDefault({
- hookName: 'component:first-party-integration-alert',
- defaultComponent: () => null,
- });
- const FirstPartyIntegrationAdditionalCTA = HookOrDefault({
- hookName: 'component:first-party-integration-additional-cta',
- defaultComponent: () => null,
- });
- type State = {
- configurations: Integration[];
- information: {providers: IntegrationProvider[]};
- };
- class IntegrationDetailedView extends AbstractIntegrationDetailedView<
- AbstractIntegrationDetailedView['props'],
- State & AbstractIntegrationDetailedView['state']
- > {
- tabs: Tab[] = ['overview', 'configurations', 'features'];
- getEndpoints(): ReturnType<DeprecatedAsyncComponent['getEndpoints']> {
- const {organization} = this.props;
- const {integrationSlug} = this.props.params;
- return [
- [
- 'information',
- `/organizations/${organization.slug}/config/integrations/?provider_key=${integrationSlug}`,
- ],
- [
- 'configurations',
- `/organizations/${organization.slug}/integrations/?provider_key=${integrationSlug}&includeConfig=0`,
- ],
- ];
- }
- get integrationType() {
- return 'first_party' as const;
- }
- get provider() {
- return this.state.information.providers[0];
- }
- get description() {
- return this.metadata.description;
- }
- get author() {
- return this.metadata.author;
- }
- get alerts() {
- const provider = this.provider;
- const metadata = this.metadata;
- // The server response for integration installations includes old icon CSS classes
- // We map those to the currently in use values to their react equivalents
- // and fallback to IconFlag just in case.
- const alerts = (metadata.aspects.alerts || []).map(item => ({
- ...item,
- showIcon: true,
- }));
- if (!provider.canAdd && metadata.aspects.externalInstall) {
- alerts.push({
- type: 'warning',
- showIcon: true,
- text: metadata.aspects.externalInstall.noticeText,
- });
- }
- return alerts;
- }
- get resourceLinks() {
- const metadata = this.metadata;
- return [
- {url: metadata.source_url, title: 'View Source'},
- {url: metadata.issue_url, title: 'Report Issue'},
- ];
- }
- get metadata() {
- return this.provider.metadata;
- }
- get isEnabled() {
- return this.state.configurations.length > 0;
- }
- get installationStatus() {
- // TODO: add transations
- const {configurations} = this.state;
- const statusList = configurations.map(getIntegrationStatus);
- // if we have conflicting statuses, we have a priority order
- if (statusList.includes('active')) {
- return 'Installed';
- }
- if (statusList.includes('disabled')) {
- return 'Disabled';
- }
- if (statusList.includes('pending_deletion')) {
- return 'Pending Deletion';
- }
- return 'Not Installed';
- }
- get integrationName() {
- return this.provider.name;
- }
- get featureData() {
- return this.metadata.features;
- }
- renderTabs() {
- // TODO: Convert to styled component
- const tabs = integrationFeatures.includes(this.provider.key)
- ? this.tabs
- : this.tabs.filter(tab => tab !== 'features');
- return (
- <ul className="nav nav-tabs border-bottom" style={{paddingTop: '30px'}}>
- {tabs.map(tabName => (
- <li
- key={tabName}
- className={this.state.tab === tabName ? 'active' : ''}
- onClick={() => this.onTabChange(tabName)}
- >
- <CapitalizedLink>{this.getTabDisplay(tabName)}</CapitalizedLink>
- </li>
- ))}
- </ul>
- );
- }
- onInstall = (integration: Integration) => {
- // send the user to the configure integration view for that integration
- const {organization} = this.props;
- this.props.router.push(
- normalizeUrl(
- `/settings/${organization.slug}/integrations/${integration.provider.key}/${integration.id}/`
- )
- );
- };
- onRemove = (integration: Integration) => {
- const {organization} = this.props;
- const origIntegrations = [...this.state.configurations];
- const integrations = this.state.configurations.map(i =>
- i.id === integration.id
- ? {...i, organizationIntegrationStatus: 'pending_deletion' as ObjectStatus}
- : i
- );
- this.setState({configurations: integrations});
- const options: RequestOptions = {
- method: 'DELETE',
- error: () => {
- this.setState({configurations: origIntegrations});
- addErrorMessage(t('Failed to remove Integration'));
- },
- };
- this.api.request(
- `/organizations/${organization.slug}/integrations/${integration.id}/`,
- options
- );
- };
- onDisable = (integration: Integration) => {
- let url: string;
- if (!integration.domainName) {
- return;
- }
- const [domainName, orgName] = integration.domainName.split('/');
- if (integration.accountType === 'User') {
- url = `https://${domainName}/settings/installations/`;
- } else {
- url = `https://${domainName}/organizations/${orgName}/settings/installations/`;
- }
- window.open(url, '_blank');
- };
- handleExternalInstall = () => {
- this.trackIntegrationAnalytics('integrations.installation_start');
- };
- renderAlert() {
- return (
- <FirstPartyIntegrationAlert
- integrations={this.state.configurations ?? []}
- hideCTA
- />
- );
- }
- renderAdditionalCTA() {
- return (
- <FirstPartyIntegrationAdditionalCTA
- integrations={this.state.configurations ?? []}
- />
- );
- }
- renderTopButton(disabledFromFeatures: boolean, userHasAccess: boolean) {
- const {organization} = this.props;
- const provider = this.provider;
- const {metadata} = provider;
- const size = 'sm' as const;
- const priority = 'primary' as const;
- const buttonProps = {
- style: {marginBottom: space(1)},
- size,
- priority,
- 'data-test-id': 'install-button',
- disabled: disabledFromFeatures,
- organization,
- };
- if (!userHasAccess) {
- return this.renderRequestIntegrationButton();
- }
- if (provider.canAdd) {
- return (
- <AddIntegrationButton
- provider={provider}
- onAddIntegration={this.onInstall}
- installStatus={this.installationStatus}
- analyticsParams={{
- view: 'integrations_directory_integration_detail',
- already_installed: this.installationStatus !== 'Not Installed',
- }}
- {...buttonProps}
- />
- );
- }
- if (metadata.aspects.externalInstall) {
- return (
- <Button
- icon={<IconOpen />}
- href={metadata.aspects.externalInstall.url}
- onClick={this.handleExternalInstall}
- external
- {...buttonProps}
- >
- {metadata.aspects.externalInstall.buttonText}
- </Button>
- );
- }
- // This should never happen but we can't return undefined without some refactoring.
- return <Fragment />;
- }
- renderConfigurations() {
- const {configurations} = this.state;
- const {organization} = this.props;
- const provider = this.provider;
- if (!configurations.length) {
- return this.renderEmptyConfigurations();
- }
- const alertText = getAlertText(configurations);
- return (
- <Fragment>
- {alertText && (
- <Alert type="warning" showIcon>
- {alertText}
- </Alert>
- )}
- <Panel>
- {configurations.map(integration => (
- <PanelItem key={integration.id}>
- <InstalledIntegration
- organization={organization}
- provider={provider}
- integration={integration}
- onRemove={this.onRemove}
- onDisable={this.onDisable}
- data-test-id={integration.id}
- trackIntegrationAnalytics={this.trackIntegrationAnalytics}
- requiresUpgrade={!!alertText}
- />
- </PanelItem>
- ))}
- </Panel>
- </Fragment>
- );
- }
- renderFeatures() {
- const {configurations} = this.state;
- const {organization} = this.props;
- const hasIntegration = configurations ? configurations.length > 0 : false;
- const endpoint = `/organizations/${organization.slug}/`;
- const hasOrgWrite = organization.access.includes('org:write');
- const forms: JsonFormObject[] = [
- {
- fields: [
- {
- name: 'githubPRBot',
- type: 'boolean',
- label: t('Enable Comments on Suspect Pull Requests'),
- help: t(
- 'Allow Sentry to comment on recent pull requests suspected of causing issues.'
- ),
- disabled: !hasIntegration,
- disabledReason: t(
- 'You must have a GitHub integration to enable this feature.'
- ),
- },
- {
- name: 'githubOpenPRBot',
- type: 'boolean',
- label: t('Enable Comments on Open Pull Requests'),
- help: t(
- 'Allow Sentry to comment on open pull requests to show recent error issues for the code being changed.'
- ),
- disabled: !hasIntegration,
- disabledReason: t(
- 'You must have a GitHub integration to enable this feature.'
- ),
- visible: ({features}) => features.includes('integrations-open-pr-comment'),
- },
- {
- name: 'githubNudgeInvite',
- type: 'boolean',
- label: t('Enable Missing Member Detection'),
- help: t(
- 'Allow Sentry to detect users committing to your GitHub repositories that are not part of your Sentry organization..'
- ),
- disabled: !hasIntegration,
- disabledReason: t(
- 'You must have a GitHub integration to enable this feature.'
- ),
- visible: ({features}) => features.includes('integrations-gh-invite'),
- },
- ],
- },
- ];
- const initialData = {
- githubPRBot: organization.githubPRBot,
- githubOpenPRBot: organization.githubOpenPRBot,
- githubNudgeInvite: organization.githubNudgeInvite,
- };
- return (
- <Form
- apiMethod="PUT"
- apiEndpoint={endpoint}
- saveOnBlur
- allowUndo
- initialData={initialData}
- onSubmitError={() => addErrorMessage('Unable to save change')}
- >
- <JsonForm
- disabled={!hasOrgWrite}
- features={organization.features}
- forms={forms}
- />
- </Form>
- );
- }
- renderBody() {
- return (
- <Fragment>
- <BreadcrumbTitle routes={this.props.routes} title={this.integrationName} />
- {this.renderAlert()}
- {this.renderTopSection()}
- {this.renderTabs()}
- {this.state.tab === 'overview'
- ? this.renderInformationCard()
- : this.state.tab === 'configurations'
- ? this.renderConfigurations()
- : this.renderFeatures()}
- </Fragment>
- );
- }
- }
- export default withOrganization(IntegrationDetailedView);
- const CapitalizedLink = styled('a')`
- text-transform: capitalize;
- `;
|