diff options
Diffstat (limited to 'server/sonar-web')
7 files changed, 490 insertions, 29 deletions
diff --git a/server/sonar-web/src/main/js/api/issues.js b/server/sonar-web/src/main/js/api/issues.js index 5ac907806fb..a2592a4af6f 100644 --- a/server/sonar-web/src/main/js/api/issues.js +++ b/server/sonar-web/src/main/js/api/issues.js @@ -18,17 +18,20 @@ * 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 index 00000000000..302033520c8 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/issues/BulkChangeForm.js @@ -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) + }; + } +}); diff --git a/server/sonar-web/src/main/js/apps/issues/controller.js b/server/sonar-web/src/main/js/apps/issues/controller.js index 92fe0ebc113..9586f4130a7 100644 --- a/server/sonar-web/src/main/js/apps/issues/controller.js +++ b/server/sonar-web/src/main/js/apps/issues/controller.js @@ -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 index 00000000000..85f568c9615 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/issues/templates/BulkChangeForm.hbs @@ -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"> + + </div> +{{/if}} diff --git a/server/sonar-web/src/main/js/apps/issues/templates/issues-workspace-header.hbs b/server/sonar-web/src/main/js/apps/issues/templates/issues-workspace-header.hbs index 44325865dd0..b95572f1653 100644 --- a/server/sonar-web/src/main/js/apps/issues/templates/issues-workspace-header.hbs +++ b/server/sonar-web/src/main/js/apps/issues/templates/issues-workspace-header.hbs @@ -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"> diff --git a/server/sonar-web/src/main/js/apps/issues/workspace-header-view.js b/server/sonar-web/src/main/js/apps/issues/workspace-header-view.js index 83123885cdd..ae3d4e613d8 100644 --- a/server/sonar-web/src/main/js/apps/issues/workspace-header-view.js +++ b/server/sonar-web/src/main/js/apps/issues/workspace-header-view.js @@ -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 () { diff --git a/server/sonar-web/src/main/less/components/modals.less b/server/sonar-web/src/main/less/components/modals.less index bc771a5d25b..3739c4f81fc 100644 --- a/server/sonar-web/src/main/less/components/modals.less +++ b/server/sonar-web/src/main/less/components/modals.less @@ -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], |