Browse Source

[workflow] expand workflow options (#5495)

This adds the ability to specify the release an issue is resolved in. Right now it requires that the release exists in the system.

It also makes statusDetails a mapping that you provide for status changes, vs adding them all to top level. Historic usage is still supported.

```
PUT ...
{
  "status": resolved,
  "statusDetails": {
    "inRelease": "latest"
  }
}
```

```
PUT ...
{
  "status": resolved,
  "statusDetails": {
    "inNextRelease": true
  }
}
```

Under the hood the above do the same thing except they store a slightly different Activity entry.

It also improves visuals around the ignore action.
David Cramer 7 years ago
parent
commit
2187979ea6

+ 2 - 0
CHANGES

@@ -1,6 +1,8 @@
 Version 8.18 (Unreleased)
 Version 8.18 (Unreleased)
 -------------------------
 -------------------------
 
 
+- Expanded resolution options to allow current and explicit versions.
+
 Version 8.17
 Version 8.17
 ------------
 ------------
 
 

+ 127 - 83
src/sentry/api/endpoints/project_group_index.py

@@ -91,10 +91,54 @@ class ValidationError(Exception):
     pass
     pass
 
 
 
 
+class StatusDetailsValidator(serializers.Serializer):
+    inNextRelease = serializers.BooleanField()
+    inRelease = serializers.CharField()
+    ignoreDuration = serializers.IntegerField()
+    ignoreCount = serializers.IntegerField()
+    # in hours, max of one week
+    ignoreWindow = serializers.IntegerField(max_value=7 * 24)
+    ignoreUserCount = serializers.IntegerField()
+    # in hours, max of one week
+    ignoreUserWindow = serializers.IntegerField(max_value=7 * 24)
+
+    def validate_inRelease(self, attrs, source):
+        value = attrs[source]
+        project = self.context['project']
+        if value == 'latest':
+            try:
+                attrs[source] = Release.objects.filter(
+                    projects=project,
+                    organization_id=project.organization_id,
+                ).order_by('-date_added')[0]
+            except IndexError:
+                raise serializers.ValidationError('No release data present in the system to form a basis for \'Next Release\'')
+        else:
+            try:
+                attrs[source] = Release.objects.get(
+                    projects=project,
+                    organization_id=project.organization_id,
+                    version=value,
+                )
+            except Release.DoesNotExist:
+                raise serializers.ValidationError('Unable to find a release with the given version.')
+        return attrs
+
+    def validate_inNextRelease(self, attrs, source):
+        project = self.context['project']
+        if not Release.objects.filter(
+            projects=project,
+            organization_id=project.organization_id,
+        ).exists():
+            raise serializers.ValidationError('No release data present in the system to form a basis for \'Next Release\'')
+        return attrs
+
+
 class GroupValidator(serializers.Serializer):
 class GroupValidator(serializers.Serializer):
     status = serializers.ChoiceField(choices=zip(
     status = serializers.ChoiceField(choices=zip(
         STATUS_CHOICES.keys(), STATUS_CHOICES.keys()
         STATUS_CHOICES.keys(), STATUS_CHOICES.keys()
     ))
     ))
+    statusDetails = StatusDetailsValidator()
     hasSeen = serializers.BooleanField()
     hasSeen = serializers.BooleanField()
     isBookmarked = serializers.BooleanField()
     isBookmarked = serializers.BooleanField()
     isPublic = serializers.BooleanField()
     isPublic = serializers.BooleanField()
@@ -406,100 +450,100 @@ class ProjectGroupIndexEndpoint(ProjectEndpoint):
             id__in=group_ids,
             id__in=group_ids,
         )
         )
 
 
-        if result.get('status') == 'resolvedInNextRelease':
-            try:
+        statusDetails = result.pop('statusDetails', result)
+        status = result.get('status')
+        if status in ('resolved', 'resolvedInNextRelease'):
+            if status == 'resolvedInNextRelease' or statusDetails.get('inNextRelease'):
                 release = Release.objects.filter(
                 release = Release.objects.filter(
                     projects=project,
                     projects=project,
-                    organization_id=project.organization_id
+                    organization_id=project.organization_id,
                 ).order_by('-date_added')[0]
                 ).order_by('-date_added')[0]
-            except IndexError:
-                return Response('{"detail": "No release data present in the system to form a basis for \'Next Release\'"}', status=400)
+                activity_type = Activity.SET_RESOLVED_IN_RELEASE
+                activity_data = {
+                    # no version yet
+                    'version': '',
+                }
+                status_details = {
+                    'inNextRelease': True,
+                }
+            elif statusDetails.get('inRelease'):
+                release = statusDetails['inRelease']
+                activity_type = Activity.SET_RESOLVED_IN_RELEASE
+                activity_data = {
+                    # no version yet
+                    'version': release.version,
+                }
+                status_details = {
+                    'inRelease': release.version,
+                }
+            else:
+                release = None
+                activity_type = Activity.SET_RESOLVED
+                activity_data = {}
+                status_details = {}
 
 
             now = timezone.now()
             now = timezone.now()
 
 
             for group in group_list:
             for group in group_list:
-                try:
-                    with transaction.atomic():
-                        resolution, created = GroupResolution.objects.create(
-                            group=group,
-                            release=release,
-                        ), True
-                except IntegrityError:
-                    resolution, created = GroupResolution.objects.get(
-                        group=group,
-                    ), False
+                with transaction.atomic():
+                    if release:
+                        try:
+                            with transaction.atomic():
+                                resolution, created = GroupResolution.objects.create(
+                                    group=group,
+                                    release=release,
+                                ), True
+                        except IntegrityError:
+                            resolution, created = GroupResolution.objects.get(
+                                group=group,
+                            ), False
+                    else:
+                        resolution = None
+
+                    affected = Group.objects.filter(
+                        id=group.id,
+                    ).exclude(
+                        status=GroupStatus.RESOLVED,
+                    ).update(
+                        status=GroupStatus.RESOLVED,
+                        resolved_at=now,
+                    )
+                    if not resolution:
+                        created = affected
 
 
-                self._subscribe_and_assign_issue(
-                    acting_user, group, result
-                )
+                    group.status = GroupStatus.RESOLVED
+                    group.resolved_at = now
 
 
-                if created:
-                    activity = Activity.objects.create(
-                        project=group.project,
-                        group=group,
-                        type=Activity.SET_RESOLVED_IN_RELEASE,
-                        user=acting_user,
-                        ident=resolution.id,
-                        data={
-                            # no version yet
-                            'version': '',
-                        }
+                    self._subscribe_and_assign_issue(
+                        acting_user, group, result
                     )
                     )
-                    # TODO(dcramer): we need a solution for activity rollups
-                    # before sending notifications on bulk changes
-                    if not is_bulk:
-                        activity.send_notification()
 
 
-                    issue_resolved_in_release.send(project=project, sender=acting_user)
+                    if created:
+                        activity = Activity.objects.create(
+                            project=group.project,
+                            group=group,
+                            type=activity_type,
+                            user=acting_user,
+                            ident=resolution.id if resolution else None,
+                            data=activity_data,
+                        )
+                # TODO(dcramer): we need a solution for activity rollups
+                # before sending notifications on bulk changes
+                if not is_bulk:
+                    activity.send_notification()
 
 
-            queryset.update(
-                status=GroupStatus.RESOLVED,
-                resolved_at=now,
-            )
+                issue_resolved_in_release.send(
+                    group=group,
+                    project=project,
+                    sender=acting_user,
+                )
 
 
             result.update({
             result.update({
                 'status': 'resolved',
                 'status': 'resolved',
-                'statusDetails': {
-                    'inNextRelease': True,
-                },
+                'statusDetails': status_details,
             })
             })
 
 
-        elif result.get('status') == 'resolved':
-            now = timezone.now()
-
-            with transaction.atomic():
-                happened = queryset.exclude(
-                    status=GroupStatus.RESOLVED,
-                ).update(
-                    status=GroupStatus.RESOLVED,
-                    resolved_at=now,
-                )
-
-                GroupResolution.objects.filter(
-                    group__in=group_ids,
-                ).delete()
-
-            if group_list and happened:
-                for group in group_list:
-                    group.status = GroupStatus.RESOLVED
-                    group.resolved_at = now
-                    self._subscribe_and_assign_issue(
-                        acting_user, group, result
-                    )
-                    activity = Activity.objects.create(
-                        project=group.project,
-                        group=group,
-                        type=Activity.SET_RESOLVED,
-                        user=acting_user,
-                    )
-                    # TODO(dcramer): we need a solution for activity rollups
-                    # before sending notifications on bulk changes
-                    if not is_bulk:
-                        activity.send_notification()
-
-            result['statusDetails'] = {}
-
-        elif result.get('status'):
+        elif status:
             new_status = STATUS_CHOICES[result['status']]
             new_status = STATUS_CHOICES[result['status']]
 
 
             with transaction.atomic():
             with transaction.atomic():
@@ -515,13 +559,13 @@ class ProjectGroupIndexEndpoint(ProjectEndpoint):
 
 
                 if new_status == GroupStatus.IGNORED:
                 if new_status == GroupStatus.IGNORED:
                     ignore_duration = (
                     ignore_duration = (
-                        result.pop('ignoreDuration', None)
-                        or result.pop('snoozeDuration', None)
+                        statusDetails.pop('ignoreDuration', None)
+                        or statusDetails.pop('snoozeDuration', None)
                     ) or None
                     ) or None
-                    ignore_count = result.pop('ignoreCount', None) or None
-                    ignore_window = result.pop('ignoreWindow', None) or None
-                    ignore_user_count = result.pop('ignoreUserCount', None) or None
-                    ignore_user_window = result.pop('ignoreUserWindow', None) or None
+                    ignore_count = statusDetails.pop('ignoreCount', None) or None
+                    ignore_window = statusDetails.pop('ignoreWindow', None) or None
+                    ignore_user_count = statusDetails.pop('ignoreUserCount', None) or None
+                    ignore_user_window = statusDetails.pop('ignoreUserWindow', None) or None
                     if ignore_duration or ignore_count or ignore_user_count:
                     if ignore_duration or ignore_count or ignore_user_count:
                         if ignore_duration:
                         if ignore_duration:
                             ignore_until = timezone.now() + timedelta(
                             ignore_until = timezone.now() + timedelta(

+ 1 - 5
src/sentry/static/sentry/app/components/customIgnoreCountModal.jsx

@@ -36,11 +36,7 @@ export default React.createClass({
   render() {
   render() {
     let {count, window} = this.state;
     let {count, window} = this.state;
     return (
     return (
-      <Modal
-        show={this.props.show}
-        animation={false}
-        bsSize="md"
-        onHide={this.props.onCanceled}>
+      <Modal show={this.props.show} animation={false} onHide={this.props.onCanceled}>
         <div className="modal-header">
         <div className="modal-header">
           <h4>{this.props.label}</h4>
           <h4>{this.props.label}</h4>
         </div>
         </div>

+ 1 - 5
src/sentry/static/sentry/app/components/customIgnoreDurationModal.jsx

@@ -61,11 +61,7 @@ export default React.createClass({
     const defaultTimeVal = sprintf('%02d:00', defaultDate.getUTCHours());
     const defaultTimeVal = sprintf('%02d:00', defaultDate.getUTCHours());
 
 
     return (
     return (
-      <Modal
-        show={this.props.show}
-        animation={false}
-        bsSize="md"
-        onHide={this.props.onCanceled}>
+      <Modal show={this.props.show} animation={false} onHide={this.props.onCanceled}>
         <div className="modal-header">
         <div className="modal-header">
           <h4>{t('Ignore this issue until it occurs after ..')}</h4>
           <h4>{t('Ignore this issue until it occurs after ..')}</h4>
         </div>
         </div>

+ 102 - 0
src/sentry/static/sentry/app/components/customResolutionModal.jsx

@@ -0,0 +1,102 @@
+import React from 'react';
+import ReactDOMServer from 'react-dom/server';
+import jQuery from 'jquery';
+import Modal from 'react-bootstrap/lib/Modal';
+import underscore from 'underscore';
+
+import TimeSince from './timeSince';
+import Version from './version';
+
+import {Select2FieldAutocomplete} from './forms';
+import {t} from '../locale';
+
+export default React.createClass({
+  propTypes: {
+    onSelected: React.PropTypes.func.isRequired,
+    onCanceled: React.PropTypes.func.isRequired,
+    orgId: React.PropTypes.string.isRequired,
+    projectId: React.PropTypes.string.isRequired,
+    show: React.PropTypes.bool
+  },
+
+  getInitialState() {
+    return {version: ''};
+  },
+
+  componentDidUpdate(prevProps, prevState) {
+    if (!prevProps.show && this.props.show) {
+      // XXX(cramer): this is incorrect but idgaf
+      jQuery('.modal').attr('tabindex', null);
+    }
+  },
+
+  onSubmit() {
+    this.props.onSelected({
+      inRelease: this.state.version
+    });
+  },
+
+  onChange(value) {
+    this.setState({version: value});
+  },
+
+  render() {
+    let {orgId, projectId} = this.props;
+    let {version} = this.state;
+
+    return (
+      <Modal show={this.props.show} animation={false} onHide={this.props.onCanceled}>
+        <div className="modal-header">
+          <h4>Resolved In</h4>
+        </div>
+        <div className="modal-body">
+          <form className="m-b-1">
+            <div className="control-group m-b-1">
+              <h6 className="nav-header">Version</h6>
+              <Select2FieldAutocomplete
+                name="version"
+                className="form-control"
+                onChange={v => this.onChange(v)}
+                style={{padding: '3px 10px'}}
+                placeholder={t('e.g. 1.0.4')}
+                url={`/api/0/projects/${orgId}/${projectId}/releases/`}
+                value={version}
+                id={'version'}
+                onResults={results => {
+                  return {results};
+                }}
+                onQuery={query => {
+                  return {query};
+                }}
+                formatResult={release => {
+                  return ReactDOMServer.renderToStaticMarkup(
+                    <div>
+                      <strong>
+                        <Version version={release.version} anchor={false} />
+                      </strong>
+                      <br />
+                      <small>Created <TimeSince date={release.dateCreated} /></small>
+                    </div>
+                  );
+                }}
+                formatSelection={item => underscore.escape(item.version)}
+                escapeMarkup={false}
+              />
+            </div>
+          </form>
+        </div>
+        <div className="modal-footer m-t-1">
+          <button
+            type="button"
+            className="btn btn-default"
+            onClick={this.props.onCanceled}>
+            {t('Cancel')}
+          </button>
+          <button type="button" className="btn btn-primary" onClick={this.onSubmit}>
+            {t('Save Changes')}
+          </button>
+        </div>
+      </Modal>
+    );
+  }
+});

+ 18 - 10
src/sentry/static/sentry/app/components/forms/select2Field.jsx

@@ -14,7 +14,7 @@ class Select2Field extends InputField {
         onChange={this.onChange.bind(this)}
         onChange={this.onChange.bind(this)}
         disabled={this.props.disabled}
         disabled={this.props.disabled}
         required={this.props.required}
         required={this.props.required}
-        multiple={this.props.multiple || false}
+        multiple={this.props.multiple}
         value={this.state.value}>
         value={this.state.value}>
         {(this.props.choices || []).map(choice => {
         {(this.props.choices || []).map(choice => {
           return (
           return (
@@ -49,14 +49,17 @@ class Select2Field extends InputField {
     super.onChange(e);
     super.onChange(e);
   }
   }
 
 
+  getSelect2Options() {
+    return {
+      allowClear: this.props.allowClear,
+      allowEmpty: this.props.allowEmpty,
+      width: 'element',
+      escapeMarkup: !this.props.escapeMarkup ? m => m : undefined
+    };
+  }
+
   componentDidMount() {
   componentDidMount() {
-    jQuery(this.refs.input)
-      .select2({
-        allowClear: this.props.allowClear,
-        allowEmpty: true,
-        width: 'element'
-      })
-      .on('change', this.onChange);
+    jQuery(this.refs.input).select2(this.getSelect2Options()).on('change', this.onChange);
   }
   }
 
 
   componentWillUnmount() {
   componentWillUnmount() {
@@ -68,13 +71,18 @@ Select2Field.propTypes = Object.assign(
   {
   {
     choices: React.PropTypes.array.isRequired,
     choices: React.PropTypes.array.isRequired,
     allowClear: React.PropTypes.bool,
     allowClear: React.PropTypes.bool,
-    allowEmpty: React.PropTypes.bool
+    allowEmpty: React.PropTypes.bool,
+    multiple: React.PropTypes.bool,
+    escapeMarkup: React.PropTypes.bool
   },
   },
   InputField.propTypes
   InputField.propTypes
 );
 );
 
 
 Select2Field.defaultProps = Object.assign({}, InputField.defaultProps, {
 Select2Field.defaultProps = Object.assign({}, InputField.defaultProps, {
-  allowEmpty: false
+  allowEmpty: false,
+  placeholder: '--',
+  escapeMarkup: true,
+  multiple: false
 });
 });
 
 
 export default Select2Field;
 export default Select2Field;

+ 61 - 26
src/sentry/static/sentry/app/components/forms/select2FieldAutocomplete.jsx

@@ -1,31 +1,42 @@
-import ReactDOM from 'react-dom';
-import InputField from './inputField';
+import React from 'react';
+import Select2Field from './select2Field';
 
 
-export default class Select2FieldAutocomplete extends InputField {
-  getType() {
-    return 'text';
+class Select2FieldAutocomplete extends Select2Field {
+  getField() {
+    return (
+      <input
+        id={this.getId()}
+        className="form-control"
+        ref="input"
+        type="text"
+        placeholder={this.props.placeholder}
+        onChange={this.onChange.bind(this)}
+        disabled={this.props.disabled}
+        required={this.props.required}
+        value={this.state.value}
+      />
+    );
   }
   }
 
 
-  componentDidMount() {
-    let $el = $('input', ReactDOM.findDOMNode(this));
-    $el.on('change.autocomplete', this.onChange.bind(this));
-    let separator = this.props.url.includes('?') ? '&' : '?';
-    let url = this.props.url + separator + 'autocomplete_field=' + this.props.name;
-
-    $el.select2({
+  getSelect2Options() {
+    return Object.assign(super.getSelect2Options(), {
       placeholder: this.props.placeholder || 'Start typing to search for an issue',
       placeholder: this.props.placeholder || 'Start typing to search for an issue',
-      minimumInputLength: 1,
+      minimumInputLength: this.props.minimumInputLength,
       ajax: {
       ajax: {
-        quietMillis: 100,
-        url: url,
+        url: this.props.url,
         dataType: 'json',
         dataType: 'json',
-        data: q => {
-          return {autocomplete_query: q};
-        },
-        results: data => {
-          return {results: data[this.props.name]};
-        }
+        data: this.props.onQuery.bind(this),
+        cache: true,
+        results: this.props.onResults.bind(this),
+        delay: this.props.ajaxDelay
       },
       },
+      id: this.props.id,
+      formatResult: this.props.formatResult
+        ? this.props.formatResult.bind(this)
+        : undefined,
+      formatSelection: this.props.formatSelection
+        ? this.props.formatSelection.bind(this)
+        : undefined,
       formatAjaxError: error => {
       formatAjaxError: error => {
         let resp = error.responseJSON;
         let resp = error.responseJSON;
         if (resp && resp.error_type === 'validation') {
         if (resp && resp.error_type === 'validation') {
@@ -38,9 +49,33 @@ export default class Select2FieldAutocomplete extends InputField {
       }
       }
     });
     });
   }
   }
-
-  componentWillUnmount() {
-    let $el = $('input', ReactDOM.findDOMNode(this));
-    $el.off('change.autocomplete');
-  }
 }
 }
+
+Select2FieldAutocomplete.defaultProps = Object.assign(
+  {
+    onResults: function(data, page) {
+      return {results: data[this.props.name]};
+    },
+    onQuery: function(query, page) {
+      return {autocomplete_query: query, autocomplete_field: this.props.name};
+    },
+    minimumInputLength: null,
+    ajaxDelay: 250
+  },
+  Select2Field.defaultProps
+);
+
+Select2FieldAutocomplete.propTypes = Object.assign({}, Select2Field.propTypes, {
+  ajaxDelay: React.PropTypes.number,
+  minimumInputLength: React.PropTypes.number,
+  formatResult: React.PropTypes.func,
+  formatSelection: React.PropTypes.func,
+  onResults: React.PropTypes.func,
+  onQuery: React.PropTypes.func,
+  url: React.PropTypes.string.isRequired,
+  id: React.PropTypes.any
+});
+
+delete Select2FieldAutocomplete.propTypes.choices;
+
+export default Select2FieldAutocomplete;

+ 2 - 2
src/sentry/static/sentry/app/components/version.jsx

@@ -7,8 +7,8 @@ const Version = React.createClass({
   propTypes: {
   propTypes: {
     anchor: React.PropTypes.bool,
     anchor: React.PropTypes.bool,
     version: React.PropTypes.string.isRequired,
     version: React.PropTypes.string.isRequired,
-    orgId: React.PropTypes.string.isRequired,
-    projectId: React.PropTypes.string.isRequired
+    orgId: React.PropTypes.string,
+    projectId: React.PropTypes.string
   },
   },
 
 
   getDefaultProps() {
   getDefaultProps() {

+ 350 - 269
src/sentry/static/sentry/app/views/groupDetails/actions.jsx

@@ -3,6 +3,7 @@ import {browserHistory} from 'react-router';
 import ApiMixin from '../../mixins/apiMixin';
 import ApiMixin from '../../mixins/apiMixin';
 import CustomIgnoreCountModal from '../../components/customIgnoreCountModal';
 import CustomIgnoreCountModal from '../../components/customIgnoreCountModal';
 import CustomIgnoreDurationModal from '../../components/customIgnoreDurationModal';
 import CustomIgnoreDurationModal from '../../components/customIgnoreDurationModal';
+import CustomResolutionModal from '../../components/customResolutionModal';
 import DropdownLink from '../../components/dropdownLink';
 import DropdownLink from '../../components/dropdownLink';
 import Duration from '../../components/duration';
 import Duration from '../../components/duration';
 import GroupState from '../../mixins/groupState';
 import GroupState from '../../mixins/groupState';
@@ -13,6 +14,345 @@ import LinkWithConfirmation from '../../components/linkWithConfirmation';
 import TooltipMixin from '../../mixins/tooltip';
 import TooltipMixin from '../../mixins/tooltip';
 import {t} from '../../locale';
 import {t} from '../../locale';
 
 
+const ResolveActions = React.createClass({
+  propTypes: {
+    group: React.PropTypes.object.isRequired,
+    hasRelease: React.PropTypes.bool.isRequired,
+    onUpdate: React.PropTypes.func.isRequired,
+    orgId: React.PropTypes.string.isRequired,
+    projectId: React.PropTypes.string.isRequired
+  },
+
+  getInitialState() {
+    return {
+      modal: false
+    };
+  },
+
+  onCustomResolution(statusDetails) {
+    this.setState({
+      modal: false
+    });
+    this.props.onUpdate({
+      status: 'resolved',
+      statusDetails: statusDetails
+    });
+  },
+
+  render() {
+    let {group, hasRelease, onUpdate} = this.props;
+    let resolveClassName = 'group-resolve btn btn-default btn-sm';
+    if (group.status === 'resolved') {
+      resolveClassName += ' active';
+    }
+
+    if (group.status === 'resolved' && group.statusDetails.autoResolved) {
+      return (
+        <div className="btn-group">
+          <a
+            className={resolveClassName + ' tip'}
+            title={t(
+              'This event is resolved due to the Auto Resolve configuration for this project'
+            )}>
+            <span className="icon-checkmark" />
+          </a>
+        </div>
+      );
+    } else if (group.status === 'resolved') {
+      return (
+        <div className="btn-group">
+          <a
+            className={resolveClassName}
+            title={t('Unresolve')}
+            onClick={() => onUpdate({status: 'unresolved'})}>
+            <span className="icon-checkmark" />
+          </a>
+        </div>
+      );
+    }
+
+    let actionClassName = `tip ${!hasRelease ? 'disabled' : ''}`;
+    let actionTitle = !hasRelease
+      ? t('Set up release tracking in order to use this feature.')
+      : '';
+
+    return (
+      <div style={{display: 'inline-block'}}>
+        <CustomResolutionModal
+          show={this.state.modal}
+          onSelected={this.onCustomResolution}
+          onCanceled={() => this.setState({modal: false})}
+          orgId={this.props.orgId}
+          projectId={this.props.projectId}
+        />
+        <div className="btn-group">
+          <a
+            key="resolve-button"
+            className={resolveClassName}
+            title={t('Resolve')}
+            onClick={() => onUpdate({status: 'resolved'})}>
+            <span className="icon-checkmark" style={{marginRight: 5}} />
+            {t('Resolve')}
+          </a>
+          <DropdownLink
+            key="resolve-dropdown"
+            caret={true}
+            className={resolveClassName}
+            title="">
+            <MenuItem header={true}>Resolved In</MenuItem>
+            <MenuItem noAnchor={true}>
+              <a
+                onClick={() => {
+                  return (
+                    hasRelease &&
+                    onUpdate({
+                      status: 'resolved',
+                      statusDetails: {
+                        inNextRelease: true
+                      }
+                    })
+                  );
+                }}
+                className={actionClassName}
+                title={actionTitle}>
+                {t('The next release')}
+              </a>
+              <a
+                onClick={() => {
+                  return (
+                    hasRelease &&
+                    onUpdate({
+                      status: 'resolved',
+                      statusDetails: {
+                        inRelease: 'latest'
+                      }
+                    })
+                  );
+                }}
+                className={actionClassName}
+                title={actionTitle}>
+                {t('The current release')}
+              </a>
+              <a
+                onClick={() => hasRelease && this.setState({modal: true})}
+                className={actionClassName}
+                title={actionTitle}>
+                {t('Another version ...')}
+              </a>
+            </MenuItem>
+          </DropdownLink>
+        </div>
+      </div>
+    );
+  }
+});
+
+const IgnoreActions = React.createClass({
+  propTypes: {
+    group: React.PropTypes.object.isRequired,
+    onUpdate: React.PropTypes.func.isRequired
+  },
+
+  getInitialState() {
+    return {
+      modal: false
+    };
+  },
+
+  getIgnoreDurations() {
+    return [30, 120, 360, 60 * 24, 60 * 24 * 7];
+  },
+
+  getIgnoreCounts() {
+    return [100, 1000, 10000, 100000];
+  },
+
+  getIgnoreWindows() {
+    return [[1, 'per hour'], [24, 'per day'], [24 * 7, 'per week']];
+  },
+
+  onCustomIgnore(statusDetails) {
+    this.setState({
+      modal: false
+    });
+    this.onIgnore(statusDetails);
+  },
+
+  onIgnore(statusDetails) {
+    return this.props.onUpdate({
+      status: 'ignored',
+      statusDetails: statusDetails || {}
+    });
+  },
+
+  render() {
+    let {group, onUpdate} = this.props;
+    let linkClassName = 'group-ignore btn btn-default btn-sm';
+    if (group.status === 'ignored') {
+      linkClassName += ' active';
+    }
+
+    if (group.status === 'ignored') {
+      return (
+        <div className="btn-group">
+          <a
+            className={linkClassName + ' tip'}
+            title={t('Change status to unresolved')}
+            onClick={() => onUpdate({status: 'unresolved'})}>
+            <span className="icon-ban" />
+          </a>
+        </div>
+      );
+    }
+
+    return (
+      <div style={{display: 'inline-block'}}>
+        <CustomIgnoreDurationModal
+          show={this.state.modal === 'duration'}
+          onSelected={this.onCustomIgnore}
+          onCanceled={() => this.setState({modal: null})}
+        />
+        <CustomIgnoreCountModal
+          show={this.state.modal === 'count'}
+          onSelected={this.onCustomIgnore}
+          onCanceled={() => this.setState({modal: null})}
+          label={t('Ignore this issue until it occurs again .. ')}
+          countLabel={t('Number of times')}
+          countName="ignoreCount"
+          windowName="ignoreWindow"
+          windowChoices={this.getIgnoreWindows()}
+        />
+        <CustomIgnoreCountModal
+          show={this.state.modal === 'users'}
+          onSelected={this.onCustomIgnore}
+          onCanceled={() => this.setState({modal: null})}
+          label={t('Ignore this issue until it affects an additional .. ')}
+          countLabel={t('Numbers of users')}
+          countName="ignoreUserCount"
+          windowName="ignoreUserWindow"
+          windowChoices={this.getIgnoreWindows()}
+        />
+        <div className="btn-group">
+          <a
+            className={linkClassName}
+            title={t('Ignore')}
+            onClick={() => onUpdate({status: 'ignored'})}>
+            <span className="icon-ban" style={{marginRight: 5}} />
+            {t('Ignore')}
+          </a>
+          <DropdownLink caret={true} className={linkClassName} title="">
+            <MenuItem header={true}>Ignore Until</MenuItem>
+            <li className="dropdown-submenu">
+              <DropdownLink title="This occurs again after .." caret={false}>
+                {this.getIgnoreDurations().map(duration => {
+                  return (
+                    <MenuItem noAnchor={true} key={duration}>
+                      <a
+                        onClick={this.onIgnore.bind(this, {
+                          ignoreDuration: duration
+                        })}>
+                        <Duration seconds={duration * 60} />
+                      </a>
+                    </MenuItem>
+                  );
+                })}
+                <MenuItem divider={true} />
+                <MenuItem noAnchor={true}>
+                  <a onClick={() => this.setState({modal: 'duration'})}>
+                    {t('Custom')}
+                  </a>
+                </MenuItem>
+              </DropdownLink>
+            </li>
+            <li className="dropdown-submenu">
+              <DropdownLink title="This occurs again .." caret={false}>
+                {this.getIgnoreCounts().map(count => {
+                  return (
+                    <li className="dropdown-submenu" key={count}>
+                      <DropdownLink
+                        title={t('%s times', count.toLocaleString())}
+                        caret={false}>
+                        <MenuItem noAnchor={true}>
+                          <a
+                            onClick={this.onIgnore.bind(this, {
+                              ignoreCount: count
+                            })}>
+                            {t('from now')}
+                          </a>
+                        </MenuItem>
+                        {this.getIgnoreWindows().map(([hours, label]) => {
+                          return (
+                            <MenuItem noAnchor={true} key={hours}>
+                              <a
+                                onClick={this.onIgnore.bind(this, {
+                                  ignoreCount: count,
+                                  ignoreWindow: hours
+                                })}>
+                                {label}
+                              </a>
+                            </MenuItem>
+                          );
+                        })}
+                      </DropdownLink>
+                    </li>
+                  );
+                })}
+                <MenuItem divider={true} />
+                <MenuItem noAnchor={true}>
+                  <a onClick={() => this.setState({modal: 'count'})}>
+                    {t('Custom')}
+                  </a>
+                </MenuItem>
+              </DropdownLink>
+            </li>
+            <li className="dropdown-submenu">
+              <DropdownLink title="This affects an additional .." caret={false}>
+                {this.getIgnoreCounts().map(count => {
+                  return (
+                    <li className="dropdown-submenu" key={count}>
+                      <DropdownLink
+                        title={t('%s users', count.toLocaleString())}
+                        caret={false}>
+                        <MenuItem noAnchor={true}>
+                          <a
+                            onClick={this.onIgnore.bind(this, {
+                              ignoreUserCount: count
+                            })}>
+                            {t('from now')}
+                          </a>
+                        </MenuItem>
+                        {this.getIgnoreWindows().map(([hours, label]) => {
+                          return (
+                            <MenuItem noAnchor={true} key={hours}>
+                              <a
+                                onClick={this.onIgnore.bind(this, {
+                                  ignoreUserCount: count,
+                                  ignoreUserWindow: hours
+                                })}>
+                                {label}
+                              </a>
+                            </MenuItem>
+                          );
+                        })}
+                      </DropdownLink>
+                    </li>
+                  );
+                })}
+                <MenuItem divider={true} />
+                <MenuItem noAnchor={true}>
+                  <a onClick={() => this.setState({modal: 'users'})}>
+                    {t('Custom')}
+                  </a>
+                </MenuItem>
+              </DropdownLink>
+            </li>
+          </DropdownLink>
+        </div>
+      </div>
+    );
+  }
+});
+
 export default React.createClass({
 export default React.createClass({
   mixins: [
   mixins: [
     ApiMixin,
     ApiMixin,
@@ -74,291 +414,32 @@ export default React.createClass({
     this.onUpdate({isBookmarked: !this.getGroup().isBookmarked});
     this.onUpdate({isBookmarked: !this.getGroup().isBookmarked});
   },
   },
 
 
-  onIgnore(params) {
-    this.onUpdate({
-      status: 'ignored',
-      ...params
-    });
-  },
-
-  customIgnoreModalClicked(modal) {
-    this.setState({
-      ignoreModal: modal
-    });
-  },
-
-  customIgnoreModalSelected(data) {
-    this.onIgnore(data);
-    this.customIgnoreModalCanceled();
-  },
-
-  customIgnoreModalCanceled() {
-    this.setState({ignoreModal: null});
-  },
-
-  getIgnoreDurations() {
-    return [30, 120, 360, 60 * 24, 60 * 24 * 7];
-  },
-
-  getIgnoreCounts() {
-    return [100, 1000, 10000, 100000];
-  },
-
-  getIgnoreWindows() {
-    return [[1, 'per hour'], [24, 'per day'], [24 * 7, 'per week']];
-  },
-
   render() {
   render() {
     let group = this.getGroup();
     let group = this.getGroup();
-
-    let resolveClassName = 'group-resolve btn btn-default btn-sm';
-    if (group.status === 'resolved') {
-      resolveClassName += ' active';
-    }
-
-    let resolveDropdownClasses = 'resolve-dropdown';
+    let project = this.getProject();
+    let org = this.getOrganization();
 
 
     let bookmarkClassName = 'group-bookmark btn btn-default btn-sm';
     let bookmarkClassName = 'group-bookmark btn btn-default btn-sm';
     if (group.isBookmarked) {
     if (group.isBookmarked) {
       bookmarkClassName += ' active';
       bookmarkClassName += ' active';
     }
     }
 
 
-    let ignoreClassName = 'group-ignore btn btn-default btn-sm';
-    if (group.status === 'ignored') {
-      ignoreClassName += ' active';
-    }
-
     let hasRelease = this.getProjectFeatures().has('releases');
     let hasRelease = this.getProjectFeatures().has('releases');
-    let releaseTrackingUrl =
-      '/' +
-      this.getOrganization().slug +
-      '/' +
-      this.getProject().slug +
-      '/settings/release-tracking/';
 
 
     // account for both old and new style plugins
     // account for both old and new style plugins
     let hasIssueTracking = group.pluginActions.length || group.pluginIssues.length;
     let hasIssueTracking = group.pluginActions.length || group.pluginIssues.length;
 
 
     return (
     return (
       <div className="group-actions">
       <div className="group-actions">
-        <CustomIgnoreDurationModal
-          show={this.state.ignoreModal === 'duration'}
-          onSelected={this.customIgnoreModalSelected}
-          onCanceled={this.customIgnoreModalCanceled.bind(this, 'duration')}
+        <ResolveActions
+          group={group}
+          hasRelease={hasRelease}
+          onUpdate={this.onUpdate}
+          orgId={org.slug}
+          projectId={project.slug}
         />
         />
-        <CustomIgnoreCountModal
-          show={this.state.ignoreModal === 'count'}
-          onSelected={this.customIgnoreModalSelected}
-          onCanceled={this.customIgnoreModalCanceled.bind(this, 'count')}
-          label={t('Ignore this issue until it occurs again .. ')}
-          countLabel={t('Number of times')}
-          countName="ignoreCount"
-          windowName="ignoreWindow"
-          windowChoices={this.getIgnoreWindows()}
-        />
-        <CustomIgnoreCountModal
-          show={this.state.ignoreModal === 'users'}
-          onSelected={this.customIgnoreModalSelected}
-          onCanceled={this.customIgnoreModalCanceled.bind(this, 'users')}
-          label={t('Ignore this issue until it affects an additional .. ')}
-          countLabel={t('Numbers of users')}
-          countName="ignoreUserCount"
-          windowName="ignoreUserWindow"
-          windowChoices={this.getIgnoreWindows()}
-        />
-        <div className="btn-group">
-          {group.status === 'resolved'
-            ? group.statusDetails.autoResolved
-                ? <a
-                    className={resolveClassName + ' tip'}
-                    title={t(
-                      'This event is resolved due to the Auto Resolve configuration for this project'
-                    )}>
-                    <span className="icon-checkmark" />
-                  </a>
-                : <a
-                    className={resolveClassName}
-                    title={t('Unresolve')}
-                    onClick={this.onUpdate.bind(this, {status: 'unresolved'})}>
-                    <span className="icon-checkmark" />
-                  </a>
-            : [
-                <a
-                  key="resolve-button"
-                  className={resolveClassName}
-                  title={t('Resolve')}
-                  onClick={this.onUpdate.bind(this, {status: 'resolved'})}>
-                  <span className="icon-checkmark" style={{marginRight: 5}} />
-                  {t('Resolve')}
-                </a>,
-                <DropdownLink
-                  key="resolve-dropdown"
-                  caret={true}
-                  className={resolveClassName}
-                  topLevelClasses={resolveDropdownClasses}
-                  title="">
-                  <MenuItem noAnchor={true}>
-                    {hasRelease
-                      ? <a
-                          onClick={this.onUpdate.bind(this, {
-                            status: 'resolvedInNextRelease'
-                          })}>
-                          <strong>{t('Resolved in next release')}</strong>
-                          <div className="help-text">
-                            {t(
-                              'Ignore notifications until this issue reoccurs in a future release.'
-                            )}
-                          </div>
-                        </a>
-                      : <a
-                          href={releaseTrackingUrl}
-                          className="disabled tip"
-                          title={t(
-                            'Set up release tracking in order to use this feature.'
-                          )}>
-                          <strong>{t('Resolved in next release.')}</strong>
-                          <div className="help-text">
-                            {t(
-                              'Ignore notifications until this issue reoccurs in a future release.'
-                            )}
-                          </div>
-                        </a>}
-                  </MenuItem>
-                </DropdownLink>
-              ]}
-        </div>
-        <div className="btn-group">
-          {group.status === 'ignored'
-            ? <a
-                className={ignoreClassName}
-                title={t('Remove Ignored Status')}
-                onClick={this.onUpdate.bind(this, {status: 'unresolved'})}>
-                {t('Ignore')}
-              </a>
-            : <DropdownLink
-                caret={false}
-                className={ignoreClassName}
-                title={
-                  <span>
-                    {t('Ignore')}
-                    <span
-                      className="icon-arrow-down"
-                      style={{marginLeft: 3, marginRight: -3}}
-                    />
-                  </span>
-                }>
-                <MenuItem header={true}>Ignore Until</MenuItem>
-                <li className="dropdown-submenu">
-                  <DropdownLink title="This occurs again after .." caret={false}>
-                    {this.getIgnoreDurations().map(duration => {
-                      return (
-                        <MenuItem noAnchor={true} key={duration}>
-                          <a
-                            onClick={this.onIgnore.bind(this, {
-                              ignoreDuration: duration
-                            })}>
-                            <Duration seconds={duration * 60} />
-                          </a>
-                        </MenuItem>
-                      );
-                    })}
-                    <MenuItem divider={true} />
-                    <MenuItem noAnchor={true}>
-                      <a onClick={this.customIgnoreModalClicked.bind(this, 'duration')}>
-                        {t('Custom')}
-                      </a>
-                    </MenuItem>
-                  </DropdownLink>
-                </li>
-                <li className="dropdown-submenu">
-                  <DropdownLink title="This occurs again .." caret={false}>
-                    {this.getIgnoreCounts().map(count => {
-                      return (
-                        <li className="dropdown-submenu" key={count}>
-                          <DropdownLink
-                            title={t('%s times', count.toLocaleString())}
-                            caret={false}>
-                            <MenuItem noAnchor={true}>
-                              <a
-                                onClick={this.onIgnore.bind(this, {
-                                  ignoreCount: count
-                                })}>
-                                {t('from now')}
-                              </a>
-                            </MenuItem>
-                            {this.getIgnoreWindows().map(([hours, label]) => {
-                              return (
-                                <MenuItem noAnchor={true} key={hours}>
-                                  <a
-                                    onClick={this.onIgnore.bind(this, {
-                                      ignoreCount: count,
-                                      ignoreWindow: hours
-                                    })}>
-                                    {label}
-                                  </a>
-                                </MenuItem>
-                              );
-                            })}
-                          </DropdownLink>
-                        </li>
-                      );
-                    })}
-                    <MenuItem divider={true} />
-                    <MenuItem noAnchor={true}>
-                      <a onClick={this.customIgnoreModalClicked.bind(this, 'count')}>
-                        {t('Custom')}
-                      </a>
-                    </MenuItem>
-                  </DropdownLink>
-                </li>
-                <li className="dropdown-submenu">
-                  <DropdownLink title="This affects an additional .." caret={false}>
-                    {this.getIgnoreCounts().map(count => {
-                      return (
-                        <li className="dropdown-submenu" key={count}>
-                          <DropdownLink
-                            title={t('%s users', count.toLocaleString())}
-                            caret={false}>
-                            <MenuItem noAnchor={true}>
-                              <a
-                                onClick={this.onIgnore.bind(this, {
-                                  ignoreUserCount: count
-                                })}>
-                                {t('from now')}
-                              </a>
-                            </MenuItem>
-                            {this.getIgnoreWindows().map(([hours, label]) => {
-                              return (
-                                <MenuItem noAnchor={true} key={hours}>
-                                  <a
-                                    onClick={this.onIgnore.bind(this, {
-                                      ignoreUserCount: count,
-                                      ignoreUserWindow: hours
-                                    })}>
-                                    {label}
-                                  </a>
-                                </MenuItem>
-                              );
-                            })}
-                          </DropdownLink>
-                        </li>
-                      );
-                    })}
-                    <MenuItem divider={true} />
-                    <MenuItem noAnchor={true}>
-                      <a onClick={this.customIgnoreModalClicked.bind(this, 'users')}>
-                        {t('Custom')}
-                      </a>
-                    </MenuItem>
-                  </DropdownLink>
-                </li>
-                <MenuItem noAnchor={true}>
-                  <a onClick={this.onUpdate.bind(this, {status: 'ignored'})}>
-                    {t('Forever')}
-                  </a>
-                </MenuItem>
-              </DropdownLink>}
-        </div>
+        <IgnoreActions group={group} onUpdate={this.onUpdate} />
+
         <div className="btn-group">
         <div className="btn-group">
           <a
           <a
             className={bookmarkClassName}
             className={bookmarkClassName}

+ 9 - 2
src/sentry/static/sentry/less/dropdowns.less

@@ -1,6 +1,13 @@
+.dropdown-menu {
+  .dropdown-header,
+  .dropdown-toggle,
+  > li a {
+    display: block;
+    padding: 3px 10px;
+  }
+}
+
 .dropdown-menu .dropdown-toggle {
 .dropdown-menu .dropdown-toggle {
-  display: block;
-  padding: 3px 20px;
   clear: both;
   clear: both;
   font-weight: normal;
   font-weight: normal;
   line-height: 1.42857143;
   line-height: 1.42857143;

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