Browse Source

feat(app-platform): Delete External Issue UI (#12558)

Implements the front-end functionality necessary to delete External
Issues created through Sentry Apps.

Also fixes a problem where the "Link" tab of the new new Issue modal
wasn't submitting the correct data. It was relying on `initialData` to
set static things like `action`, but that wasn't being udpated when the
User navigated around the modal.

Instead, `SentryAppExternalIssueForm` keeps track of it's own
`FormModel` and updates fields on that directly. This was the only way I
could figure out how to pass data that isn't to be rendered, to the
form. Ideally I think this would be done via hidden input fields.
Matte Noble 6 years ago
parent
commit
11ba5abe6b

+ 19 - 0
src/sentry/static/sentry/app/actionCreators/platformExternalIssues.jsx

@@ -0,0 +1,19 @@
+import PlatformExternalIssueActions from 'app/actions/platformExternalIssueActions';
+
+export function deleteExternalIssue(api, groupId, externalIssueId) {
+  PlatformExternalIssueActions.delete(groupId, externalIssueId);
+
+  return new Promise((resolve, reject) =>
+    api.request(`/issues/${groupId}/external-issues/${externalIssueId}/`, {
+      method: 'DELETE',
+      success: data => {
+        PlatformExternalIssueActions.deleteSuccess(data);
+        resolve(data);
+      },
+      error: error => {
+        PlatformExternalIssueActions.deleteError(error);
+        reject(error);
+      },
+    })
+  );
+}

+ 9 - 0
src/sentry/static/sentry/app/actions/platformExternalIssueActions.jsx

@@ -0,0 +1,9 @@
+import Reflux from 'reflux';
+
+const PlatformExternalIssueActions = Reflux.createActions([
+  'delete',
+  'deleteSuccess',
+  'deleteError',
+]);
+
+export default PlatformExternalIssueActions;

+ 3 - 117
src/sentry/static/sentry/app/components/group/externalIssueActions.jsx

@@ -5,16 +5,11 @@ import styled from 'react-emotion';
 
 import {addSuccessMessage, addErrorMessage} from 'app/actionCreators/indicator';
 import AsyncComponent from 'app/components/asyncComponent';
-import IssueSyncListElement, {
-  IntegrationLink,
-  IntegrationIcon,
-} from 'app/components/issueSyncListElement';
-import ExternalIssueForm, {
-  SentryAppExternalIssueForm,
-} from 'app/components/group/externalIssueForm';
+import IssueSyncListElement from 'app/components/issueSyncListElement';
+import ExternalIssueForm from 'app/components/group/externalIssueForm';
 import IntegrationItem from 'app/views/organizationIntegrations/integrationItem';
 import NavTabs from 'app/components/navTabs';
-import {t, tct} from 'app/locale';
+import {t} from 'app/locale';
 import overflowEllipsis from 'app/styles/overflowEllipsis';
 import space from 'app/styles/space';
 
@@ -151,115 +146,6 @@ class ExternalIssueActions extends AsyncComponent {
   }
 }
 
-export class SentryAppExternalIssueActions extends React.Component {
-  static propTypes = {
-    group: PropTypes.object.isRequired,
-    sentryAppComponent: PropTypes.object.isRequired,
-    sentryAppInstallation: PropTypes.object,
-    externalIssue: PropTypes.object,
-  };
-
-  constructor(props) {
-    super(props);
-
-    this.state = {
-      action: 'create',
-      showModal: false,
-    };
-  }
-
-  showModal = () => {
-    // Only show the modal when we don't have a linked issue
-    !this.props.externalIssue && this.setState({showModal: true});
-  };
-
-  hideModal = () => {
-    this.setState({showModal: false});
-  };
-
-  showLink = () => {
-    this.setState({action: 'link'});
-  };
-
-  showCreate = () => {
-    this.setState({action: 'create'});
-  };
-
-  iconExists() {
-    try {
-      require(`../../icons/${this.props.sentryAppComponent.sentryApp.slug}.svg`);
-      return true;
-    } catch (err) {
-      return false;
-    }
-  }
-
-  get link() {
-    const {sentryAppComponent, externalIssue} = this.props;
-
-    let url = '#';
-    let icon = 'icon-generic-box';
-    let displayName = tct('Link [name] Issue', {name: sentryAppComponent.sentryApp.name});
-
-    if (externalIssue) {
-      url = externalIssue.webUrl;
-      displayName = externalIssue.displayName;
-    }
-
-    if (this.iconExists()) {
-      icon = `icon-${sentryAppComponent.sentryApp.slug}`;
-    }
-
-    return (
-      <React.Fragment>
-        <IntegrationIcon src={icon} />
-        <IntegrationLink onClick={this.showModal} href={url}>
-          {displayName}
-        </IntegrationLink>
-      </React.Fragment>
-    );
-  }
-
-  get modal() {
-    const {sentryAppComponent, sentryAppInstallation, group} = this.props;
-    const {action, showModal} = this.state;
-
-    return (
-      <Modal show={showModal} onHide={this.hideModal} animation={false}>
-        <Modal.Header closeButton>
-          <Modal.Title>{`${sentryAppComponent.sentryApp.name} Issue`}</Modal.Title>
-        </Modal.Header>
-        <NavTabs underlined={true}>
-          <li className={action === 'create' ? 'active create' : 'create'}>
-            <a onClick={this.showCreate}>{t('Create')}</a>
-          </li>
-          <li className={action === 'link' ? 'active link' : 'link'}>
-            <a onClick={this.showLink}>{t('Link')}</a>
-          </li>
-        </NavTabs>
-        <Modal.Body>
-          <SentryAppExternalIssueForm
-            group={group}
-            sentryAppInstallation={sentryAppInstallation}
-            config={sentryAppComponent.schema}
-            action={action}
-            onSubmitSuccess={this.hideModal}
-          />
-        </Modal.Body>
-      </Modal>
-    );
-  }
-
-  render() {
-    return (
-      <React.Fragment>
-        {this.link}
-        {this.modal}
-      </React.Fragment>
-    );
-  }
-}
-
 const IssueTitle = styled('div')`
   font-size: 1.1em;
   font-weight: 600;

+ 1 - 101
src/sentry/static/sentry/app/components/group/externalIssueForm.jsx

@@ -4,14 +4,12 @@ import PropTypes from 'prop-types';
 import queryString from 'query-string';
 import {debounce} from 'lodash';
 
-import {addSuccessMessage, addErrorMessage} from 'app/actionCreators/indicator';
-import {addQueryParamsToExistingUrl} from 'app/utils/queryString';
+import {addSuccessMessage} from 'app/actionCreators/indicator';
 import AsyncComponent from 'app/components/asyncComponent';
 import FieldFromConfig from 'app/views/settings/components/forms/fieldFromConfig';
 import Form from 'app/views/settings/components/forms/form';
 import SentryTypes from 'app/sentryTypes';
 import {t} from 'app/locale';
-import ExternalIssueStore from 'app/stores/externalIssueStore';
 
 const MESSAGES_BY_ACTION = {
   link: t('Successfully linked issue.'),
@@ -188,102 +186,4 @@ class ExternalIssueForm extends AsyncComponent {
   }
 }
 
-export class SentryAppExternalIssueForm extends React.Component {
-  static propTypes = {
-    group: SentryTypes.Group.isRequired,
-    sentryAppInstallation: PropTypes.object,
-    config: PropTypes.object.isRequired,
-    action: PropTypes.oneOf(['link', 'create']),
-    onSubmitSuccess: PropTypes.func,
-  };
-
-  onSubmitSuccess = issue => {
-    ExternalIssueStore.add(issue);
-    this.props.onSubmitSuccess(issue);
-  };
-
-  onSubmitError = () => {
-    const appName = this.props.sentryAppInstallation.sentryApp.name;
-    addErrorMessage(t(`Unable to ${this.props.action} ${appName} issue.`));
-  };
-
-  getFieldDefault(field) {
-    const {group} = this.props;
-    if (field.type == 'textarea') {
-      field.maxRows = 10;
-      field.autosize = true;
-    }
-    switch (field.default) {
-      case 'issue.title':
-        return group.title;
-      case 'issue.description':
-        const queryParams = {referrer: this.props.sentryAppInstallation.sentryApp.name};
-        const url = addQueryParamsToExistingUrl(group.permalink, queryParams);
-        return `Sentry Issue: [${group.shortId}](${url})`;
-      default:
-        return '';
-    }
-  }
-
-  render() {
-    const {sentryAppInstallation} = this.props;
-    const config = this.props.config[this.props.action];
-    const requiredFields = config.required_fields || [];
-    const optionalFields = config.optional_fields || [];
-
-    if (!sentryAppInstallation) {
-      return '';
-    }
-
-    return (
-      <Form
-        apiEndpoint={`/sentry-app-installations/${sentryAppInstallation.uuid}/external-issues/`}
-        apiMethod="POST"
-        onSubmitSuccess={this.onSubmitSuccess}
-        onSubmitError={this.onSubmitError}
-        initialData={{
-          action: this.props.action,
-          groupId: this.props.group.id,
-          uri: config.uri,
-        }}
-      >
-        {requiredFields.map(field => {
-          field.choices = field.choices || [];
-          if (['text', 'textarea'].includes(field.type) && field.default) {
-            field.defaultValue = this.getFieldDefault(field);
-          }
-
-          return (
-            <FieldFromConfig
-              key={`${field.name}`}
-              field={field}
-              inline={false}
-              stacked
-              flexibleControlStateSize
-              required={true}
-            />
-          );
-        })}
-
-        {optionalFields.map(field => {
-          field.choices = field.choices || [];
-          if (['text', 'textarea'].includes(field.type) && field.default) {
-            field.defaultValue = this.getFieldDefault(field);
-          }
-
-          return (
-            <FieldFromConfig
-              key={`${field.name}`}
-              field={field}
-              inline={false}
-              stacked
-              flexibleControlStateSize
-            />
-          );
-        })}
-      </Form>
-    );
-  }
-}
-
 export default ExternalIssueForm;

+ 4 - 3
src/sentry/static/sentry/app/components/group/externalIssuesList.jsx

@@ -4,9 +4,8 @@ import PropTypes from 'prop-types';
 import withApi from 'app/utils/withApi';
 import withOrganization from 'app/utils/withOrganization';
 import AsyncComponent from 'app/components/asyncComponent';
-import ExternalIssueActions, {
-  SentryAppExternalIssueActions,
-} from 'app/components/group/externalIssueActions';
+import ExternalIssueActions from 'app/components/group/externalIssueActions';
+import SentryAppExternalIssueActions from 'app/components/group/sentryAppExternalIssueActions';
 import IssueSyncListElement from 'app/components/issueSyncListElement';
 import AlertLink from 'app/components/alertLink';
 import SentryTypes from 'app/sentryTypes';
@@ -133,9 +132,11 @@ class ExternalIssueList extends AsyncComponent {
 
     return issueLinkComponents.map(component => {
       const {sentryApp} = component;
+
       const installation = sentryAppInstallations.find(
         i => i.sentryApp.uuid === sentryApp.uuid
       );
+
       const issue = (externalIssues || []).find(i => i.serviceType == sentryApp.slug);
 
       return (

+ 201 - 0
src/sentry/static/sentry/app/components/group/sentryAppExternalIssueActions.jsx

@@ -0,0 +1,201 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import Modal from 'react-bootstrap/lib/Modal';
+import styled from 'react-emotion';
+
+import withApi from 'app/utils/withApi';
+import InlineSvg from 'app/components/inlineSvg';
+import {addSuccessMessage, addErrorMessage} from 'app/actionCreators/indicator';
+import {IntegrationLink, IntegrationIcon} from 'app/components/issueSyncListElement';
+import SentryAppExternalIssueForm from 'app/components/group/sentryAppExternalIssueForm';
+import NavTabs from 'app/components/navTabs';
+import {t, tct} from 'app/locale';
+import space from 'app/styles/space';
+import {deleteExternalIssue} from 'app/actionCreators/platformExternalIssues';
+
+class SentryAppExternalIssueActions extends React.Component {
+  static propTypes = {
+    api: PropTypes.object.isRequired,
+    group: PropTypes.object.isRequired,
+    sentryAppComponent: PropTypes.object.isRequired,
+    sentryAppInstallation: PropTypes.object,
+    externalIssue: PropTypes.object,
+  };
+
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      action: 'create',
+      externalIssue: props.externalIssue,
+      showModal: false,
+    };
+  }
+
+  componentDidUpdate(prevProps) {
+    if (this.props.externalIssue !== prevProps.externalIssue) {
+      this.updateExternalIssue(this.props.externalIssue);
+    }
+  }
+
+  updateExternalIssue(externalIssue) {
+    this.setState({externalIssue});
+  }
+
+  showModal = () => {
+    // Only show the modal when we don't have a linked issue
+    !this.state.externalIssue && this.setState({showModal: true});
+  };
+
+  hideModal = () => {
+    this.setState({showModal: false});
+  };
+
+  showLink = () => {
+    this.setState({action: 'link'});
+  };
+
+  showCreate = () => {
+    this.setState({action: 'create'});
+  };
+
+  deleteIssue = () => {
+    const {api, group} = this.props;
+    const {externalIssue} = this.state;
+
+    deleteExternalIssue(api, group.id, externalIssue.id)
+      .then(data => {
+        this.setState({externalIssue: null});
+        addSuccessMessage(t('Successfully unlinked issue.'));
+      })
+      .catch(error => {
+        addErrorMessage(t('Unable to unlink issue.'));
+      });
+  };
+
+  onAddRemoveClick = () => {
+    const {externalIssue} = this.state;
+
+    if (!externalIssue) {
+      this.showModal();
+    } else {
+      this.deleteIssue();
+    }
+  };
+
+  onSubmitSuccess = externalIssue => {
+    this.setState({externalIssue});
+    this.hideModal();
+  };
+
+  iconExists() {
+    try {
+      require(`../../icons/icon-${this.props.sentryAppComponent.sentryApp.slug}.svg`);
+      return true;
+    } catch (err) {
+      return false;
+    }
+  }
+
+  get link() {
+    const {sentryAppComponent} = this.props;
+    const {externalIssue} = this.state;
+    const name = sentryAppComponent.sentryApp.name;
+
+    let url = '#';
+    let icon = 'icon-generic-box';
+    let displayName = tct('Link [name] Issue', {name});
+
+    if (externalIssue) {
+      url = externalIssue.webUrl;
+      displayName = externalIssue.displayName;
+    }
+
+    if (this.iconExists()) {
+      icon = `icon-${sentryAppComponent.sentryApp.slug}`;
+    }
+
+    return (
+      <IssueLinkContainer>
+        <IssueLink>
+          <IntegrationIcon src={icon} />
+          <IntegrationLink onClick={this.showModal} href={url}>
+            {displayName}
+          </IntegrationLink>
+        </IssueLink>
+        <AddRemoveIcon
+          src="icon-close"
+          isLinked={!!externalIssue}
+          onClick={this.onAddRemoveClick}
+        />
+      </IssueLinkContainer>
+    );
+  }
+
+  get modal() {
+    const {sentryAppComponent, sentryAppInstallation, group} = this.props;
+    const {action, showModal} = this.state;
+    const name = sentryAppComponent.sentryApp.name;
+
+    return (
+      <Modal show={showModal} onHide={this.hideModal} animation={false}>
+        <Modal.Header closeButton>
+          <Modal.Title>{tct('[name] Issue', {name})}</Modal.Title>
+        </Modal.Header>
+        <NavTabs underlined={true}>
+          <li className={action === 'create' ? 'active create' : 'create'}>
+            <a onClick={this.showCreate}>{t('Create')}</a>
+          </li>
+          <li className={action === 'link' ? 'active link' : 'link'}>
+            <a onClick={this.showLink}>{t('Link')}</a>
+          </li>
+        </NavTabs>
+        <Modal.Body>
+          <SentryAppExternalIssueForm
+            group={group}
+            sentryAppInstallation={sentryAppInstallation}
+            config={sentryAppComponent.schema}
+            action={action}
+            onSubmitSuccess={this.onSubmitSuccess}
+          />
+        </Modal.Body>
+      </Modal>
+    );
+  }
+
+  render() {
+    return (
+      <React.Fragment>
+        {this.link}
+        {this.modal}
+      </React.Fragment>
+    );
+  }
+}
+
+const IssueLink = styled('div')`
+  display: flex;
+  align-items: center;
+  min-width: 0;
+`;
+
+const IssueLinkContainer = styled('div')`
+  line-height: 0;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 16px;
+`;
+
+const AddRemoveIcon = styled(InlineSvg)`
+  height: ${space(1.5)};
+  color: ${p => p.theme.gray4};
+  transition: 0.2s transform;
+  cursor: pointer;
+  box-sizing: content-box;
+  padding: ${space(1)};
+  margin: -${space(1)};
+  ${p => (p.isLinked ? '' : 'transform: rotate(45deg) scale(0.9);')};
+`;
+
+export default withApi(SentryAppExternalIssueActions);

+ 135 - 0
src/sentry/static/sentry/app/components/group/sentryAppExternalIssueForm.jsx

@@ -0,0 +1,135 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import {addErrorMessage} from 'app/actionCreators/indicator';
+import {addQueryParamsToExistingUrl} from 'app/utils/queryString';
+import FieldFromConfig from 'app/views/settings/components/forms/fieldFromConfig';
+import Form from 'app/views/settings/components/forms/form';
+import SentryTypes from 'app/sentryTypes';
+import {t} from 'app/locale';
+import ExternalIssueStore from 'app/stores/externalIssueStore';
+
+class SentryAppExternalIssueForm extends React.Component {
+  static propTypes = {
+    group: SentryTypes.Group.isRequired,
+    sentryAppInstallation: PropTypes.object,
+    config: PropTypes.object.isRequired,
+    action: PropTypes.oneOf(['link', 'create']),
+    onSubmitSuccess: PropTypes.func,
+  };
+
+  onSubmitSuccess = issue => {
+    ExternalIssueStore.add(issue);
+    this.props.onSubmitSuccess(issue);
+  };
+
+  onSubmitError = () => {
+    const {action} = this.props;
+    const appName = this.props.sentryAppInstallation.sentryApp.name;
+    addErrorMessage(t('Unable to %s %s issue.', action, appName));
+  };
+
+  getFieldDefault(field) {
+    const {group} = this.props;
+    if (field.type == 'textarea') {
+      field.maxRows = 10;
+      field.autosize = true;
+    }
+    switch (field.default) {
+      case 'issue.title':
+        return group.title;
+      case 'issue.description':
+        const queryParams = {referrer: this.props.sentryAppInstallation.sentryApp.name};
+        const url = addQueryParamsToExistingUrl(group.permalink, queryParams);
+        const shortId = group.shortId;
+        return t('Sentry Issue: [%s](%s)', shortId, url);
+      default:
+        return '';
+    }
+  }
+
+  render() {
+    const {sentryAppInstallation} = this.props;
+    const config = this.props.config[this.props.action];
+
+    const requiredFields = config.required_fields || [];
+    const optionalFields = config.optional_fields || [];
+    const metaFields = [
+      {
+        type: 'hidden',
+        name: 'action',
+        value: this.props.action,
+        defaultValue: this.props.action,
+      },
+      {
+        type: 'hidden',
+        name: 'groupId',
+        value: this.props.group.id,
+        defaultValue: this.props.group.id,
+      },
+      {
+        type: 'hidden',
+        name: 'uri',
+        value: config.uri,
+        defaultValue: config.uri,
+      },
+    ];
+
+    if (!sentryAppInstallation) {
+      return '';
+    }
+
+    return (
+      <Form
+        key={this.props.action}
+        apiEndpoint={`/sentry-app-installations/${sentryAppInstallation.uuid}/external-issues/`}
+        apiMethod="POST"
+        onSubmitSuccess={this.onSubmitSuccess}
+        onSubmitError={this.onSubmitError}
+      >
+        {metaFields.map(field => {
+          return <FieldFromConfig key={field.name} field={field} />;
+        })}
+
+        {requiredFields.map(field => {
+          field.choices = field.choices || [];
+
+          if (['text', 'textarea'].includes(field.type) && field.default) {
+            field.defaultValue = this.getFieldDefault(field);
+          }
+
+          return (
+            <FieldFromConfig
+              key={`${field.name}`}
+              field={field}
+              inline={false}
+              stacked
+              flexibleControlStateSize
+              required={true}
+            />
+          );
+        })}
+
+        {optionalFields.map(field => {
+          field.choices = field.choices || [];
+
+          if (['text', 'textarea'].includes(field.type) && field.default) {
+            field.defaultValue = this.getFieldDefault(field);
+          }
+
+          return (
+            <FieldFromConfig
+              key={`${field.name}`}
+              field={field}
+              inline={false}
+              stacked
+              flexibleControlStateSize
+            />
+          );
+        })}
+      </Form>
+    );
+  }
+}
+
+export default SentryAppExternalIssueForm;

+ 4 - 0
src/sentry/static/sentry/app/views/settings/components/forms/fieldFromConfig.jsx

@@ -3,6 +3,7 @@ import React from 'react';
 
 import BooleanField from './booleanField';
 import EmailField from './emailField';
+import HiddenField from './hiddenField';
 import NumberField from './numberField';
 import RangeField from './rangeField';
 import SelectField from './selectField';
@@ -22,6 +23,7 @@ export default class FieldFromConfig extends React.Component {
         'choice',
         'choice_mapper',
         'email',
+        'hidden',
         'multichoice',
         'number',
         'radio',
@@ -76,6 +78,8 @@ export default class FieldFromConfig extends React.Component {
         return <BooleanField {...props} />;
       case 'email':
         return <EmailField {...props} />;
+      case 'hidden':
+        return <HiddenField {...props} />;
       case 'string':
       case 'text':
       case 'url':

+ 14 - 0
src/sentry/static/sentry/app/views/settings/components/forms/hiddenField.jsx

@@ -0,0 +1,14 @@
+import React from 'react';
+import styled from 'react-emotion';
+
+import InputField from './inputField';
+
+export default class HiddenField extends React.Component {
+  render() {
+    return <HiddenInputField {...this.props} type="hidden" />;
+  }
+}
+
+const HiddenInputField = styled(InputField)`
+  display: none;
+`;

+ 24 - 5
tests/js/fixtures/sentryAppComponent.js

@@ -3,19 +3,38 @@ export function SentryAppComponent(params = {}) {
     uuid: 'ed517da4-a324-44c0-aeea-1894cd9923fb',
     type: 'issue-link',
     schema: {
-      link: {
+      create: {
         required_fields: [
-          {type: 'text', name: 'a', label: 'A', default: 'issue.title'},
-          {type: 'textarea', name: 'c', label: 'C', default: 'issue.description'},
+          {
+            type: 'text',
+            name: 'title',
+            label: 'Title',
+            default: 'issue.title',
+          },
+          {
+            type: 'textarea',
+            name: 'description',
+            label: 'Description',
+            default: 'issue.description',
+          },
           {
             type: 'select',
             name: 'numbers',
             label: 'Numbers',
-            options: [['one', 1], ['two', 2]],
+            choices: [[1, 'one'], [2, 'two']],
+            default: 1,
+          },
+        ],
+      },
+      link: {
+        required_fields: [
+          {
+            type: 'text',
+            name: 'issue',
+            label: 'Issue',
           },
         ],
       },
-      create: {required_fields: [{type: 'text', name: 'b', label: 'B'}]},
     },
     sentryApp: {
       uuid: 'b468fed3-afba-4917-80d6-bdac99c1ec05',

Some files were not shown because too many files changed in this diff