]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-8562 Rewrite issues bulk change 1474/head
authorStas Vilchik <vilchiks@gmail.com>
Tue, 27 Dec 2016 13:08:38 +0000 (14:08 +0100)
committerTeryk Bellahsene <teryk.bellahsene@sonarsource.com>
Fri, 30 Dec 2016 16:54:11 +0000 (17:54 +0100)
it/it-tests/src/test/java/it/issue/IssueSearchTest.java
server/sonar-web/src/main/js/api/issues.js
server/sonar-web/src/main/js/apps/issues/BulkChangeForm.js [new file with mode: 0644]
server/sonar-web/src/main/js/apps/issues/controller.js
server/sonar-web/src/main/js/apps/issues/templates/BulkChangeForm.hbs [new file with mode: 0644]
server/sonar-web/src/main/js/apps/issues/templates/issues-workspace-header.hbs
server/sonar-web/src/main/js/apps/issues/workspace-header-view.js
server/sonar-web/src/main/less/components/modals.less

index 6d56d27d45b056cc98556db176da8a24c6c57f9b..f5d0852b953f662c5a2f828ce27ee7e19facd7e8 100644 (file)
@@ -310,7 +310,6 @@ public class IssueSearchTest extends AbstractIssueTest {
   }
 
   @Test
-  @Ignore("bulk change form is not available yet")
   public void bulk_change() {
     runSelenese(ORCHESTRATOR, "/issue/IssueSearchTest/bulk_change.html");
   }
index 5ac907806fb491273f361ebdbcb509cbc1df4eda..a2592a4af6fd7051477624c369e21dee21865526 100644 (file)
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
 // @flow
-import { getJSON } from '../helpers/request';
+import { getJSON, post } from '../helpers/request';
+
+export const searchIssues = (query: {}) => (
+    getJSON('/api/issues/search', query)
+);
 
 export function getFacets (query: {}, facets: Array<string>): Promise<*> {
-  const url = '/api/issues/search';
   const data = {
     ...query,
     facets: facets.join(),
     ps: 1,
     additionalFields: '_all'
   };
-  return getJSON(url, data).then(r => {
+  return searchIssues(data).then(r => {
     return { facets: r.facets, response: r };
   });
 }
@@ -62,9 +65,24 @@ export function getAssignees (query: {}): Promise<*> {
 }
 
 export function getIssuesCount (query: {}): Promise<*> {
-  const url = '/api/issues/search';
   const data = { ...query, ps: 1, facetMode: 'effort' };
-  return getJSON(url, data).then(r => {
+  return searchIssues(data).then(r => {
     return { issues: r.total, debt: r.debtTotal };
   });
 }
+
+export const searchIssueTags = (ps: number = 500) => (
+    getJSON('/api/issues/tags', { ps })
+);
+
+export function getIssueFilters () {
+  const url = '/api/issue_filters/search';
+  return getJSON(url).then(r => r.issueFilters);
+}
+
+export const bulkChangeIssues = (issueKeys: Array<string>, query: {}) => (
+    post('/api/issues/bulk_change', {
+      issues: issueKeys.join(),
+      ...query
+    })
+);
diff --git a/server/sonar-web/src/main/js/apps/issues/BulkChangeForm.js b/server/sonar-web/src/main/js/apps/issues/BulkChangeForm.js
new file mode 100644 (file)
index 0000000..3020335
--- /dev/null
@@ -0,0 +1,291 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2016 SonarSource SA
+ * mailto:contact AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+// @flow
+import sortBy from 'lodash/sortBy';
+import ModalForm from '../../components/common/modal-form';
+import Template from './templates/BulkChangeForm.hbs';
+import getCurrentUserFromStore from '../../app/utils/getCurrentUserFromStore';
+import { searchIssues, searchIssueTags, bulkChangeIssues } from '../../api/issues';
+import { searchUsers } from '../../api/users';
+import { translate, translateWithParameters } from '../../helpers/l10n';
+
+const LIMIT = 500;
+const INPUT_WIDTH = '250px';
+const MINIMUM_QUERY_LENGTH = 2;
+
+type Issue = {
+  actions?: Array<string>,
+  assignee: string | null,
+  transitions?: Array<string>
+};
+
+const hasAction = (action: string) => (issue: Issue) => (
+    issue.actions && issue.actions.includes(action)
+);
+
+export default ModalForm.extend({
+  template: Template,
+
+  initialize () {
+    this.issues = null;
+    this.paging = null;
+    this.tags = null;
+    this.loadIssues();
+    this.loadTags();
+  },
+
+  loadIssues () {
+    const { query } = this.options;
+    searchIssues({
+      ...query,
+      additionalFields: 'actions,transitions',
+      ps: LIMIT
+    }).then(r => {
+      this.issues = r.issues;
+      this.paging = r.paging;
+      this.render();
+    });
+  },
+
+  loadTags () {
+    searchIssueTags().then(r => {
+      this.tags = r.tags;
+      this.render();
+    });
+  },
+
+  prepareAssigneeSelect () {
+    const input = this.$('#assignee');
+    if (input.length) {
+      const canBeAssignedToMe = this.issues && this.canBeAssignedToMe(this.issues);
+      const currentUser = getCurrentUserFromStore();
+      const canBeUnassigned = this.issues && this.canBeUnassigned(this.issues);
+      const defaultOptions = [];
+      if (canBeAssignedToMe && currentUser.isLoggedIn) {
+        defaultOptions.push({ id: currentUser.login, text: `${currentUser.name} (${currentUser.login})` });
+      }
+      if (canBeUnassigned) {
+        defaultOptions.push({ id: '', text: translate('unassigned') });
+      }
+
+      input.select2({
+        allowClear: false,
+        placeholder: translate('search_verb'),
+        width: INPUT_WIDTH,
+        formatNoMatches: () => translate('select2.noMatches'),
+        formatSearching: () => translate('select2.searching'),
+        formatInputTooShort: () => translateWithParameters('select2.tooShort', MINIMUM_QUERY_LENGTH),
+        query: query => {
+          if (query.term.length === 0) {
+            query.callback({ results: defaultOptions });
+          } else if (query.term.length >= MINIMUM_QUERY_LENGTH) {
+            searchUsers(query.term).then(r => {
+              query.callback({
+                results: r.users.map(user => ({
+                  id: user.login,
+                  text: `${user.name} (${user.login})`
+                }))
+              });
+            });
+          }
+        }
+      });
+
+      input.on('change', () => this.$('#assign-action').prop('checked', true));
+    }
+  },
+
+  prepareTypeSelect () {
+    this.$('#type').select2({
+      minimumResultsForSearch: 999,
+      width: INPUT_WIDTH
+    }).on('change', () => this.$('#set-type-action').prop('checked', true));
+  },
+
+  prepareSeveritySelect () {
+    const format = state => (
+        state.id ?
+            `<i class="icon-severity-${state.id.toLowerCase()}"></i> ${state.text}` :
+            state.text
+    );
+    this.$('#severity').select2({
+      minimumResultsForSearch: 999,
+      width: INPUT_WIDTH,
+      formatResult: format,
+      formatSelection: format
+    }).on('change', () => this.$('#set-severity-action').prop('checked', true));
+  },
+
+  prepareTagsInput () {
+    this.$('#add_tags').select2({
+      width: INPUT_WIDTH,
+      tags: this.tags
+    }).on('change', () => this.$('#add-tags-action').prop('checked', true));
+
+    this.$('#remove_tags').select2({
+      width: INPUT_WIDTH,
+      tags: this.tags
+    }).on('change', () => this.$('#remove-tags-action').prop('checked', true));
+  },
+
+  onRender () {
+    ModalForm.prototype.onRender.apply(this, arguments);
+    this.prepareAssigneeSelect();
+    this.prepareTypeSelect();
+    this.prepareSeveritySelect();
+    this.prepareTagsInput();
+  },
+
+  handleErrors (response) {
+    const message = response['err_msg'] ? translate(response['err_msg']) : translate('default_error_message');
+    this.showErrors([{ msg: message }]);
+  },
+
+  onFormSubmit () {
+    ModalForm.prototype.onFormSubmit.apply(this, arguments);
+    const actions = [];
+    const query = {};
+
+    const assignee = this.$('#assignee').val();
+    if (this.$('#assign-action').is(':checked') && assignee != null) {
+      actions.push('assign');
+      query['assign'] = assignee;
+    }
+
+    const type = this.$('#type').val();
+    if (this.$('#set-type-action').is(':checked') && type) {
+      actions.push('set_type');
+      query['set_type'] = type;
+    }
+
+    const severity = this.$('#severity').val();
+    if (this.$('#set-severity-action').is(':checked') && severity) {
+      actions.push('set_severity');
+      query['set_severity'] = severity;
+    }
+
+    const addedTags = this.$('#add_tags').val();
+    if (this.$('#add-tags-action').is(':checked') && addedTags) {
+      actions.push('add_tags');
+      query['add_tags'] = addedTags;
+    }
+
+    const removedTags = this.$('#remove_tags').val();
+    if (this.$('#remove-tags-action').is(':checked') && removedTags) {
+      actions.push('remove_tags');
+      query['remove_tags'] = removedTags;
+    }
+
+    const transition = this.$('[name="do_transition.transition"]:checked').val();
+    if (transition) {
+      actions.push('do_transition');
+      query['do_transition'] = transition;
+    }
+
+    const comment = this.$('#comment').val();
+    if (comment) {
+      actions.push('comment');
+      query['comment'] = comment;
+    }
+
+    const sendNotifications = this.$('#send-notifications').is(':checked');
+    if (sendNotifications) {
+      query['sendNotifications'] = sendNotifications;
+    }
+
+    this.disableForm();
+    this.showSpinner();
+
+    const issueKeys = this.issues.map(issue => issue.key);
+    bulkChangeIssues(issueKeys, { ...query, actions: actions.join() }).then(
+        () => {
+          this.destroy();
+          this.options.onChange();
+        },
+        (e: Object) => {
+          this.enableForm();
+          this.hideSpinner();
+          e.response.json().then(r => this.handleErrors(r));
+        }
+    );
+  },
+
+  canBeAssigned (issues: Array<Issue>) {
+    return issues.filter(hasAction('assign')).length;
+  },
+
+  canBeAssignedToMe (issues: Array<Issue>) {
+    return issues.filter(hasAction('assign_to_me')).length;
+  },
+
+  canBeUnassigned (issues: Array<Issue>) {
+    return issues.filter(issue => issue.assignee).length;
+  },
+
+  canChangeType (issues: Array<Issue>) {
+    return issues.filter(hasAction('set_type')).length;
+  },
+
+  canChangeSeverity (issues: Array<Issue>) {
+    return issues.filter(hasAction('set_severity')).length;
+  },
+
+  canChangeTags (issues: Array<Issue>) {
+    return issues.filter(hasAction('set_tags')).length;
+  },
+
+  canBeCommented (issues: Array<Issue>) {
+    return issues.filter(hasAction('comment')).length;
+  },
+
+  availableTransitions (issues: Array<Issue>) {
+    const transitions = {};
+    issues.forEach(issue => {
+      if (issue.transitions) {
+        issue.transitions.forEach(t => {
+          if (transitions[t] != null) {
+            transitions[t]++;
+          } else {
+            transitions[t] = 1;
+          }
+        });
+      }
+    });
+    return sortBy(Object.keys(transitions)).map(transition => ({
+      transition,
+      count: transitions[transition]
+    }));
+  },
+
+  serializeData () {
+    return {
+      ...ModalForm.prototype.serializeData.apply(this, arguments),
+      isLoaded: this.issues != null && this.tags != null,
+      issues: this.issues,
+      limitReached: this.paging && this.paging.total > LIMIT,
+      canBeAssigned: this.issues && this.canBeAssigned(this.issues),
+      canChangeType: this.issues && this.canChangeType(this.issues),
+      canChangeSeverity: this.issues && this.canChangeSeverity(this.issues),
+      canChangeTags: this.issues && this.canChangeTags(this.issues),
+      canBeCommented: this.issues && this.canBeCommented(this.issues),
+      availableTransitions: this.issues && this.availableTransitions(this.issues)
+    };
+  }
+});
index 92fe0ebc113149e4139f2f6f89857a5190d44696..9586f4130a70d8a44e3783adb539aef3ccd620f6 100644 (file)
@@ -130,6 +130,18 @@ export default Controller.extend({
     return q;
   },
 
+  getQueryAsObject () {
+    const state = this.options.app.state;
+    const query = state.get('query');
+    if (query.assigned_to_me) {
+      Object.assign(query, { assignees: '__me__' });
+    }
+    if (state.get('isContext')) {
+      Object.assign(query, state.get('contextQuery'));
+    }
+    return query;
+  },
+
   getQuery (separator, addContext, handleMyIssues = false) {
     if (separator == null) {
       separator = '|';
diff --git a/server/sonar-web/src/main/js/apps/issues/templates/BulkChangeForm.hbs b/server/sonar-web/src/main/js/apps/issues/templates/BulkChangeForm.hbs
new file mode 100644 (file)
index 0000000..85f568c
--- /dev/null
@@ -0,0 +1,143 @@
+{{#if isLoaded}}
+  <form id="bulk-change-form">
+    <div class="modal-head">
+      <h2>{{tp 'issue_bulk_change.form.title' issues.length}}</h2>
+    </div>
+    <div class="modal-body">
+      <div class="js-modal-messages"></div>
+
+      {{#if limitReached}}
+        <div class="alert alert-warning">
+          {{tp 'issue_bulk_change.max_issues_reached' issues.length}}
+        </div>
+      {{/if}}
+
+      {{! assign }}
+      {{#if canBeAssigned}}
+        <div class="modal-field">
+          <label for="assignee">{{t 'issue.assign.formlink'}}</label>
+          <input id="assign-action" name="actions[]" type="checkbox" value="assign">
+          <input id="assignee" type="hidden">
+          <div class="pull-right note">
+            ({{tp 'issue_bulk_change.x_issues' canBeAssigned}})
+          </div>
+        </div>
+      {{/if}}
+
+      {{! type }}
+      {{#if canChangeType}}
+        <div class="modal-field">
+          <label for="type">{{t 'issue.set_type'}}</label>
+          <input id="set-type-action" name="actions[]" type="checkbox" value="set_type">
+          <select id="type" name="set_type.type">
+            <option value="BUG">{{t 'issue.type.BUG'}}</option>
+            <option value="VULNERABILITY">{{t 'issue.type.VULNERABILITY'}}</option>
+            <option value="CODE_SMELL">{{t 'issue.type.CODE_SMELL'}}</option>
+          </select>
+          <div class="pull-right note">
+            ({{tp 'issue_bulk_change.x_issues' canChangeType}})
+          </div>
+        </div>
+      {{/if}}
+
+      {{! severity }}
+      {{#if canChangeSeverity}}
+        <div class="modal-field">
+          <label for="severity">{{t 'issue.set_severity'}}</label>
+          <input id="set-severity-action" name="actions[]" type="checkbox" value="set_severity">
+          <select id="severity" name="set_severity.severity">
+            <option value="BLOCKER">{{t 'severity.BLOCKER'}}</option>
+            <option value="CRITICAL">{{t 'severity.CRITICAL'}}</option>
+            <option value="MAJOR">{{t 'severity.MAJOR'}}</option>
+            <option value="MINOR">{{t 'severity.MINOR'}}</option>
+            <option value="INFO">{{t 'severity.INFO'}}</option>
+          </select>
+          <div class="pull-right note">
+            ({{tp 'issue_bulk_change.x_issues' canChangeSeverity}})
+          </div>
+        </div>
+      {{/if}}
+
+      {{! add tags }}
+      {{#if canChangeTags}}
+        <div class="modal-field">
+          <label for="add_tags">{{t 'issue.add_tags'}}</label>
+          <input id="add-tags-action" name="actions[]" type="checkbox" value="add_tags">
+          <input id="add_tags" name="add_tags.tags" type="text">
+          <div class="pull-right note">
+            ({{tp 'issue_bulk_change.x_issues' canChangeTags}})
+          </div>
+        </div>
+      {{/if}}
+
+      {{! remove tags }}
+      {{#if canChangeTags}}
+        <div class="modal-field">
+          <label for="remove_tags">{{t 'issue.remove_tags'}}</label>
+          <input id="remove-tags-action" name="actions[]" type="checkbox" value="remove_tags">
+          <input id="remove_tags" name="remove_tags.tags" type="text">
+          <div class="pull-right note">
+            ({{tp 'issue_bulk_change.x_issues' canChangeTags}})
+          </div>
+        </div>
+      {{/if}}
+
+      {{! transitions }}
+      {{#notEmpty availableTransitions}}
+        <div class="modal-field">
+          <label>{{t 'issue.transition'}}</label>
+          {{#each availableTransitions}}
+            <input type="radio" id="transition-{{transition}}" name="do_transition.transition"
+                   value="{{transition}}">
+            <label for="transition-{{transition}}" style="float: none; display: inline; left: 0; cursor: pointer;">
+              {{t 'issue.transition' transition}}
+            </label>
+            <div class="pull-right note">
+              ({{tp 'issue_bulk_change.x_issues' count}})
+            </div>
+            <br>
+          {{/each}}
+        </div>
+      {{/notEmpty}}
+
+      {{! comment }}
+      {{#if canBeCommented}}
+        <div class="modal-field">
+          <label for="comment">
+            {{t 'issue.comment.formlink'}}
+            <i class="icon-help" title="{{t 'issue_bulk_change.comment.help'}}"></i>
+          </label>
+          <div>
+            <textarea rows="4" name="comment" id="comment" style="width: 100%"></textarea>
+          </div>
+          <div class="pull-right">
+            {{> ../../../components/common/templates/_markdown-tips}}
+          </div>
+        </div>
+      {{/if}}
+
+      {{! notifications }}
+      <div class="modal-field">
+        <label for="send-notifications">{{t 'issue.send_notifications'}}</label>
+        <input id="send-notifications" name="sendNotifications" type="checkbox" value="true">
+      </div>
+
+    </div>
+    <div class="modal-foot">
+      <i class="js-modal-spinner spinner spacer-right hidden"></i>
+      <button id="bulk-change-submit">{{t 'apply'}}</button>
+      <a id="bulk-change-cancel" href="#" class="js-modal-close">{{t 'cancel'}}</a>
+    </div>
+  </form>
+{{else}}
+  <div class="modal-head">
+    <h2>{{t 'bulk_change'}}</h2>
+  </div>
+  <div class="modal-body">
+    <div class="js-modal-messages"></div>
+    <i class="icon-spinner"></i>
+  </div>
+  <div class="modal-foot">
+    &nbsp;
+  </div>
+{{/if}}
index 44325865dd0a99155e822cbc1ad35b364e4a5af3..b95572f16539ece90446a2aaa025d5045c22001c 100644 (file)
@@ -60,7 +60,7 @@
     <button id="issues-reload" class="js-reload">{{t "reload"}}</button>
     <button class="js-new-search" id="issues-new-search">{{t "issue_filter.new_search"}}</button>
     {{#if state.canBulkChange}}
-      <button id="issues-bulk-change" class="dropdown-toggle" data-toggle="dropdown" disabled>
+      <button id="issues-bulk-change" class="dropdown-toggle" data-toggle="dropdown">
         {{t "bulk_change"}}
       </button>
       <ul class="dropdown-menu dropdown-menu-right">
index 83123885cdd307dcaf801e48c45a6a4bfcff3bbd..ae3d4e613d85365fa2092a6ffac49974109067ed 100644 (file)
@@ -17,8 +17,8 @@
  * along with this program; if not, write to the Free Software Foundation,
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
-import $ from 'jquery';
 import WorkspaceHeaderView from '../../components/navigator/workspace-header-view';
+import BulkChangeForm from './BulkChangeForm';
 import Template from './templates/issues-workspace-header.hbs';
 
 export default WorkspaceHeaderView.extend({
@@ -34,17 +34,6 @@ export default WorkspaceHeaderView.extend({
     };
   },
 
-  initialize () {
-    WorkspaceHeaderView.prototype.initialize.apply(this, arguments);
-    this._onBulkIssues = window.onBulkIssues;
-    window.onBulkIssues = this.afterBulkChange.bind(this);
-  },
-
-  onDestroy () {
-    WorkspaceHeaderView.prototype.onDestroy.apply(this, arguments);
-    window.onBulkIssues = this._onBulkIssues;
-  },
-
   onSelectionClick (e) {
     e.preventDefault();
     this.toggleSelection();
@@ -56,13 +45,11 @@ export default WorkspaceHeaderView.extend({
   },
 
   afterBulkChange () {
-    const that = this;
-    $('#modal').dialog('close');
     const selectedIndex = this.options.app.state.get('selectedIndex');
     const selectedKeys = this.options.app.list.where({ selected: true }).map(item => item.id);
     this.options.app.controller.fetchList().done(() => {
-      that.options.app.state.set({ selectedIndex });
-      that.options.app.list.selectByKeys(selectedKeys);
+      this.options.app.state.set({ selectedIndex });
+      this.options.app.list.selectByKeys(selectedKeys);
     });
   },
 
@@ -104,17 +91,21 @@ export default WorkspaceHeaderView.extend({
   },
 
   bulkChange () {
-    const query = this.options.app.controller.getQuery('&', true, true);
-    const url = window.baseUrl + '/issues/bulk_change_form?' + query;
-    window.openModalWindow(url, {});
+    const query = this.options.app.controller.getQueryAsObject();
+    new BulkChangeForm({
+      query,
+      onChange: () => this.afterBulkChange()
+    }).render();
   },
 
   bulkChangeSelected () {
     const selected = this.options.app.list.where({ selected: true });
-    const selectedKeys = selected.map(item => item.id).slice(0, 200);
-    const query = 'issues=' + selectedKeys.join();
-    const url = window.baseUrl + '/issues/bulk_change_form?' + query;
-    window.openModalWindow(url, {});
+    const selectedKeys = selected.map(item => item.id).slice(0, 500);
+    const query = { issues: selectedKeys.join() };
+    new BulkChangeForm({
+      query,
+      onChange: () => this.afterBulkChange()
+    }).render();
   },
 
   serializeData () {
index bc771a5d25b3919ff63821316e63fefef989e58e..3739c4f81fc4e66b4b03cf44b9b63699dfc5c0df 100644 (file)
@@ -155,6 +155,12 @@ ul.modal-head-metadata li {
   margin-bottom: 10px;
 }
 
+.modal-field input[type="radio"],
+.modal-field input[type="checkbox"] {
+  margin-top: 5px;
+  margin-bottom: 4px;
+}
+
 .modal-field input[type=text],
 .modal-field input[type=email],
 .modal-field input[type=password],