From: Stas Vilchik Date: Wed, 29 Oct 2014 16:56:05 +0000 (+0100) Subject: SONAR-5718 Add a new issues page X-Git-Tag: 5.0-RC1~508 X-Git-Url: https://source.dussan.org/?a=commitdiff_plain;h=b716af32093789a36c31996c2cf0e3eabf407c71;p=sonarqube.git SONAR-5718 Add a new issues page --- diff --git a/server/sonar-web/Gruntfile.coffee b/server/sonar-web/Gruntfile.coffee index 69b39a66782..5892d28aa23 100644 --- a/server/sonar-web/Gruntfile.coffee +++ b/server/sonar-web/Gruntfile.coffee @@ -254,6 +254,9 @@ module.exports = (grunt) -> '<%= pkg.assets %>js/templates/issues.js': [ '<%= pkg.sources %>hbs/issues/**/*.hbs' ] + '<%= pkg.assets %>js/templates/issues-old.js': [ + '<%= pkg.sources %>hbs/issues-old/**/*.hbs' + ] '<%= pkg.assets %>js/templates/api-documentation.js': [ '<%= pkg.sources %>hbs/api-documentation/**/*.hbs' ] diff --git a/server/sonar-web/src/main/coffee/coding-rules/views/coding-rules-facets-view.coffee b/server/sonar-web/src/main/coffee/coding-rules/views/coding-rules-facets-view.coffee index 55b3f0b5720..a83094f7084 100644 --- a/server/sonar-web/src/main/coffee/coding-rules/views/coding-rules-facets-view.coffee +++ b/server/sonar-web/src/main/coffee/coding-rules/views/coding-rules-facets-view.coffee @@ -12,7 +12,7 @@ define [ ui: facets: '.navigator-facets-list-item' - options: '.navigator-facets-list-item-option' + options: '.facet' events: @@ -50,4 +50,4 @@ define [ property = jQuery(@).data 'property' if !!params[property] _(params[property].split(',')).map (value) -> - jQuery('.navigator-facets-list-item[data-property="' + property + '"] .navigator-facets-list-item-option[data-key="' + value + '"]').addClass 'active' + jQuery('.navigator-facets-list-item[data-property="' + property + '"] .facet[data-key="' + value + '"]').addClass 'active' diff --git a/server/sonar-web/src/main/coffee/issue/collections/issues.coffee b/server/sonar-web/src/main/coffee/issue/collections/issues.coffee new file mode 100644 index 00000000000..7dc4da76734 --- /dev/null +++ b/server/sonar-web/src/main/coffee/issue/collections/issues.coffee @@ -0,0 +1,46 @@ +define [ + 'backbone' + 'issue/models/issue' +], ( + Backbone + Issue +) -> + + class extends Backbone.Collection + model: Issue + + url: -> + "#{baseUrl}/api/issues/search" + + + parse: (r) -> + find = (source, key, keyField) -> + searchDict = {} + searchDict[keyField || 'key'] = key + _.findWhere(source, searchDict) || key + + @paging = + p: r.p + ps: r.ps + total: r.total + maxResultsReached: r.p * r.ps >= r.total + + r.issues.map (issue) -> + component = find r.components, issue.component + project = find r.projects, issue.project + rule = find r.rules, issue.rule + + if component + _.extend issue, + componentLongName: component.longName + componentQualifier: component.qualifier + + if project + _.extend issue, + projectLongName: project.longName + + if rule + _.extend issue, + ruleName: rule.name + + issue diff --git a/server/sonar-web/src/main/coffee/issue/issue-view.coffee b/server/sonar-web/src/main/coffee/issue/issue-view.coffee index 2be7d3120e2..1618fe8d26a 100644 --- a/server/sonar-web/src/main/coffee/issue/issue-view.coffee +++ b/server/sonar-web/src/main/coffee/issue/issue-view.coffee @@ -38,7 +38,6 @@ define [ class IssueView extends Marionette.Layout - className: 'code-issues' template: Templates['issue'] diff --git a/server/sonar-web/src/main/coffee/issues/app.coffee b/server/sonar-web/src/main/coffee/issues/app.coffee new file mode 100644 index 00000000000..29bcf6fee9a --- /dev/null +++ b/server/sonar-web/src/main/coffee/issues/app.coffee @@ -0,0 +1,117 @@ +requirejs.config + baseUrl: "#{baseUrl}/js" + + paths: + 'backbone': 'third-party/backbone' + 'backbone.marionette': 'third-party/backbone.marionette' + 'handlebars': 'third-party/handlebars' + + shim: + 'backbone.marionette': + deps: ['backbone'] + exports: 'Marionette' + 'backbone': + exports: 'Backbone' + 'handlebars': + exports: 'Handlebars' + + +requirejs [ + 'backbone', 'backbone.marionette' + + 'issues/models/state' + 'issues/layout' + 'issues/models/issues' + 'issues/models/facets' + 'issues/models/filters' + + 'issues/controller' + 'issues/router' + + 'issues/workspace-list-view' + 'issues/workspace-header-view' + + 'issues/facets-view' + 'issues/filters-view' + + 'common/handlebars-extensions' +], ( + Backbone, Marionette + + State + Layout + Issues + Facets + Filters + + Controller + Router + + WorkspaceListView + WorkspaceHeaderView + + FacetsView + FiltersView +) -> + + $ = jQuery + App = new Marionette.Application + + + App.addInitializer -> + @layout = new Layout() + $('.issues').empty().append @layout.render().el + + + App.addInitializer -> + @state = new State() + @issues = new Issues() + @facets = new Facets() + @filters = new Filters() + + + App.addInitializer -> + @controller = new Controller app: @ + + + App.addInitializer -> + @controller.fetchFilters() + + + App.addInitializer -> + @issuesView = new WorkspaceListView + app: @ + collection: @issues + @layout.workspaceListRegion.show @issuesView + @issuesView.bindScrollEvents() + + + App.addInitializer -> + @workspaceHeaderView = new WorkspaceHeaderView + app: @ + collection: @issues + @layout.workspaceHeaderRegion.show @workspaceHeaderView + + + App.addInitializer -> + @facetsView = new FacetsView + app: @ + collection: @facets + @layout.facetsRegion.show @facetsView + + + App.addInitializer -> + @filtersView = new FiltersView + app: @ + collection: @filters + @layout.filtersRegion.show @filtersView + + + App.addInitializer -> + key.setScope 'list' + @router = new Router app: @ + Backbone.history.start() + + + l10nXHR = window.requestMessages() + jQuery.when(l10nXHR).done -> App.start() diff --git a/server/sonar-web/src/main/coffee/issues/component-viewer/main.coffee b/server/sonar-web/src/main/coffee/issues/component-viewer/main.coffee new file mode 100644 index 00000000000..a51aaf116c4 --- /dev/null +++ b/server/sonar-web/src/main/coffee/issues/component-viewer/main.coffee @@ -0,0 +1,191 @@ +define [ + 'backbone' + 'backbone.marionette' + 'templates/issues' + 'issues/models/issues' + 'issues/issue-view' +], ( + Backbone + Marionette + Templates + Issues + IssueView +) -> + + $ = jQuery + + API_SOURCES = "#{baseUrl}/api/sources/show" + LINES_AROUND = 500 + + + class extends Marionette.ItemView + template: Templates['issues-component-viewer'] + + + ui: + sourceBeforeSpinner: '.js-component-viewer-source-before' + sourceAfterSpinner: '.js-component-viewer-source-after' + + + events: + 'click .js-close-component-viewer': 'closeComponentViewer' + + + initialize: (options) -> + @source = new Backbone.Model + source: [] + formattedSource: [] + @component = new Backbone.Model() + @issues = new Issues() + @listenTo @issues, 'add', @addIssue + @listenTo options.app.state, 'change:selectedIndex', @select + @bindShortcuts() + @loadSourceBeforeThrottled = _.throttle @loadSourceBefore, 1000 + @loadSourceAfterThrottled = _.throttle @loadSourceAfter, 1000 + + + bindShortcuts: -> + key 'delete,backspace', 'componentViewer', => + @options.app.controller.closeComponentViewer() + false + + + bindScrollEvents: -> + $(window).on 'scroll.issues-component-viewer', (=> @onScroll()) + + + unbindScrollEvents: -> + $(window).off 'scroll.issues-component-viewer' + + + onScroll: -> + if @model.get('hasSourceBefore') && $(window).scrollTop() <= @ui.sourceBeforeSpinner.offset().top + @loadSourceBeforeThrottled() + + if @model.get('hasSourceAfter') && $(window).scrollTop() + $(window).height() >= @ui.sourceAfterSpinner.offset().top + @loadSourceAfterThrottled() + + + loadSourceBefore: -> + @unbindScrollEvents() + formattedSource = @source.get 'formattedSource' + firstLine = _.first(formattedSource).lineNumber + @requestSources(firstLine - LINES_AROUND, firstLine - 1).done (data) => + newFormattedSource = _.map data.sources, (item) => lineNumber: item[0], code: item[1] + formattedSource = newFormattedSource.concat formattedSource + @source.set formattedSource: formattedSource + @model.set hasSourceBefore: newFormattedSource.length == LINES_AROUND + @render() + @scrollToLine firstLine + @bindScrollEvents() if @model.get('hasSourceBefore') || @model.get('hasSourceAfter') + + + loadSourceAfter: -> + @unbindScrollEvents() + formattedSource = @source.get 'formattedSource' + lastLine = _.last(formattedSource).lineNumber + @requestSources(lastLine + 1, lastLine + LINES_AROUND) + .done (data) => + newFormattedSource = _.map data.sources, (item) => lineNumber: item[0], code: item[1] + formattedSource = formattedSource.concat newFormattedSource + @source.set formattedSource: formattedSource + @model.set hasSourceAfter: newFormattedSource.length == LINES_AROUND + @render() + @bindScrollEvents() if @model.get('hasSourceBefore') || @model.get('hasSourceAfter') + .fail => + @source.set formattedSource: [] + @model.set hasSourceAfter: false + @render() + + + onRender: -> + @renderIssues() + + + renderIssues: -> + @issues.forEach (issue) => + @renderIssue issue + + + addIssue: (issue) -> + @renderIssue issue + + + renderIssue: (issue) -> + line = issue.get 'line' || 0 + row = @$("[data-line-number=#{line}]") + issueView = new IssueView model: issue + if row.length == 0 + issueView.render().$el.insertBefore @$('.issues-workspace-component-viewer-code') + else + row.find('.line').addClass 'issue' + barRow = $('').insertAfter row + barCell = $('').appendTo barRow + issueView.render().$el.appendTo barCell + + + select: -> + selected = @options.app.state.get 'selectedIndex' + selectedKey = @options.app.issues.at(selected).get 'key' + @highlightIssue selectedKey + + + highlightIssue: (key) -> + @$("[data-issue-key]").removeClass 'highlighted' + @$("[data-issue-key='#{key}']").addClass 'highlighted' + + + openFileByIssue: (issue) -> + componentKey = issue.get 'component' + @issues.reset @options.app.issues.filter (issue) => issue.get('component') == componentKey + + line = issue.get('line') || 0 + @model.set key: componentKey, issueLine: line + + @requestSources(line - LINES_AROUND, line + LINES_AROUND).done (data) => + formattedSource = _.map data.sources, (item) => lineNumber: item[0], code: item[1] + @source.set + source: data.sources + formattedSource: formattedSource + @model.set hasSourceBefore: line > LINES_AROUND + @render() + @highlightIssue issue.get 'key' + @scrollToLine issue.get 'line' + @bindScrollEvents() + @requestIssues() + + + requestSources: (lineFrom, lineTo) -> + lineFrom = Math.max 0, lineFrom + $.get API_SOURCES, key: @model.get('key'), from: lineFrom, to: lineTo + + + requestIssues: -> + lastIssue = @options.app.issues.at @options.app.issues.length - 1 + lastLine = _.last(@source.get('formattedSource')).lineNumber + needMore = lastIssue.get('component') == @model.get 'key' + needMore = needMore && (lastIssue.get('line') <= lastLine) + if needMore + @options.app.controller.fetchNextPage().done => + @addNextIssuesPage() + @requestIssues() + + + addNextIssuesPage: -> + componentKey = @model.get 'key' + @issues.add @options.app.issues.filter (issue) => issue.get('component') == componentKey + + + scrollToLine: (line) -> + row = @$("[data-line-number=#{line}]") + goal = if row.length > 0 then row.offset().top - 40 else 0 + $(window).scrollTop goal + + + closeComponentViewer: -> + @options.app.controller.closeComponentViewer() + + + serializeData: -> + _.extend super, + source: @source.get 'formattedSource' diff --git a/server/sonar-web/src/main/coffee/issues/component-viewer/state.coffee b/server/sonar-web/src/main/coffee/issues/component-viewer/state.coffee new file mode 100644 index 00000000000..45e46c2b32a --- /dev/null +++ b/server/sonar-web/src/main/coffee/issues/component-viewer/state.coffee @@ -0,0 +1,12 @@ +define [ + 'backbone' +], ( + Backbone +) -> + + class extends Backbone.Model + + defaults: + hasSourceBefore: true + hasSourceAfter: true + diff --git a/server/sonar-web/src/main/coffee/issues/controller.coffee b/server/sonar-web/src/main/coffee/issues/controller.coffee new file mode 100644 index 00000000000..a1a05844c57 --- /dev/null +++ b/server/sonar-web/src/main/coffee/issues/controller.coffee @@ -0,0 +1,163 @@ +define [ + 'backbone.marionette' + + 'issues/component-viewer/main' + 'issues/component-viewer/state' +], ( + Marionette + + ComponentViewer + ComponentViewerState +) -> + + $ = jQuery + EXTRA_FIELDS = 'actions,transitions,assigneeName,reporterName,actionPlanName' + PAGE_SIZE = 50 + + + class extends Marionette.Controller + + initialize: (options) -> + @listenTo options.app.state, 'change:query', @fetchIssues + + + _issuesParameters: -> + p: @options.app.state.get 'page' + ps: PAGE_SIZE + s: 'FILE_LINE' + asc: true + extra_fields: EXTRA_FIELDS + facets: @options.app.state.get('facets').join() + + + _allFacets: -> + @options.app.state.get('allFacets').map (facet) -> { property: facet } + + + fetchIssues: (firstPage = true) -> + if firstPage + @options.app.state.set { selectedIndex: 0, page: 1 }, { silent: true } + + data = @_issuesParameters() + _.extend data, @options.app.state.get 'query' + + fetchIssuesProcess = window.process.addBackgroundProcess() + $.get "#{baseUrl}/api/issues/search", data, (r) => + issues = @options.app.issues.parseIssues r + if firstPage + @options.app.issues.reset issues + else + @options.app.issues.add issues + + _.extend @options.app.facets, + components: r.components + projects: r.projects + rules: r.rules + users: r.users + actionPlans: r.actionPlans + @options.app.facets.reset @_allFacets() + @options.app.facets.add r.facets, merge: true + @enableFacets @options.app.state.get 'facets' + + @options.app.state.set + page: r.p + pageSize: r.ps + total: r.total + maxResultsReached: r.p * r.ps >= r.total + + window.process.finishBackgroundProcess fetchIssuesProcess + + + fetchNextPage: -> + @options.app.state.nextPage() + @fetchIssues false + + + fetchFilters: -> + $.get "#{baseUrl}/api/issue_filters/app", (r) => + @options.app.state.set + canBulkChange: r.canBulkChange + canManageFilters: r.canManageFilters + @options.app.filters.reset r.favorites + + + enableFacet: (facet) -> + @options.app.facets.get(facet).set enabled: true + + + enableFacets: (facets) -> + facets.forEach @enableFacet, @ + + + newSearch: -> + @options.app.state.unset 'filter' + @options.app.state.setQuery resolved: 'false' + + + applyFilter: (filter) -> + query = @parseQuery filter.get 'query' + @options.app.state.setQuery query + @options.app.state.set filter: filter, changed: false + + + parseQuery: (query, separator = '|') -> + q = {} + (query || '').split(separator).forEach (t) -> + tokens = t.split('=') + if tokens[0] && tokens[1]? + q[tokens[0]] = decodeURIComponent tokens[1] + q + + + getQuery: (separator = '|') -> + filter = @options.app.state.get 'query' + route = [] + _.map filter, (value, property) -> + route.push "#{property}=#{decodeURIComponent value}" + route.join separator + + + _prepareComponent: (issue) -> + key: issue.get 'component' + name: issue.get 'componentLongName' + qualifier: issue.get 'componentQualifier' + project: issue.get 'project' + projectName: issue.get 'projectLongName' + + + showComponentViewer: (issue) -> + key.setScope 'componentViewer' + @options.app.issuesView.unbindScrollEvents() + @options.app.state.set 'component', @_prepareComponent(issue) + @options.app.componentViewer = new ComponentViewer + app: @options.app + model: new ComponentViewerState() + @options.app.layout.workspaceComponentViewerRegion.show @options.app.componentViewer + @options.app.layout.showComponentViewer() + @options.app.componentViewer.openFileByIssue issue + + + closeComponentViewer: -> + key.setScope 'list' + @options.app.state.unset 'component' + @options.app.componentViewer.unbindScrollEvents() + @options.app.layout.workspaceComponentViewerRegion.reset() + @options.app.layout.hideComponentViewer() + @options.app.issuesView.bindScrollEvents() + @options.app.issuesView.scrollToIssue() + + + selectNextIssue: -> + index = @options.app.state.get('selectedIndex') + 1 + if index < @options.app.issues.length + @options.app.state.set selectedIndex: index + else + unless @options.app.state.get('maxResultsReached') + @fetchNextPage().done => + @options.app.state.set selectedIndex: index + + + selectPreviousIssue: -> + index = @options.app.state.get('selectedIndex') - 1 + if index >= 0 + @options.app.state.set selectedIndex: index diff --git a/server/sonar-web/src/main/coffee/issues/facets-view.coffee b/server/sonar-web/src/main/coffee/issues/facets-view.coffee new file mode 100644 index 00000000000..e1e6e121a78 --- /dev/null +++ b/server/sonar-web/src/main/coffee/issues/facets-view.coffee @@ -0,0 +1,43 @@ +define [ + 'backbone.marionette' + + 'issues/facets/base-facet' + 'issues/facets/severity-facet' + 'issues/facets/status-facet' + 'issues/facets/project-facet' + 'issues/facets/assignee-facet' + 'issues/facets/rule-facet' + 'issues/facets/resolution-facet' + 'issues/facets/creation-date-facet' +], ( + Marionette + BaseFacet + SeverityFacet + StatusFacet + ProjectFacet + AssigneeFacet + RuleFacet + ResolutionFacet + CreationDateFacet +) -> + + class extends Marionette.CollectionView + className: 'issues-facets-list' + + + getItemView: (model) -> + switch model.get 'property' + when 'severities' then SeverityFacet + when 'statuses' then StatusFacet + when 'assignees' then AssigneeFacet + when 'resolutions' then ResolutionFacet + when 'created' then CreationDateFacet + when 'componentRootUuids' then ProjectFacet + when 'rules' then RuleFacet + when 'creationDate' then CreationDateFacet + else BaseFacet + + + itemViewOptions: -> + app: @options.app + diff --git a/server/sonar-web/src/main/coffee/issues/facets/assignee-facet.coffee b/server/sonar-web/src/main/coffee/issues/facets/assignee-facet.coffee new file mode 100644 index 00000000000..0c922e9ba5e --- /dev/null +++ b/server/sonar-web/src/main/coffee/issues/facets/assignee-facet.coffee @@ -0,0 +1,52 @@ +define [ + 'issues/facets/base-facet' + 'templates/issues' +], ( + BaseFacet + Templates +) -> + + $ = jQuery + + + class extends BaseFacet + template: Templates['issues-assignee-facet'] + + + onRender: -> + super + + value = @options.app.state.get('query')['assigned'] + if value? && (!value || value == 'false') + @$('.js-issues-facet').filter("[data-unassigned]").addClass 'active' + + + toggleFacet: (e) -> + unassigned = $(e.currentTarget).is "[data-unassigned]" + if unassigned + $(e.currentTarget).toggleClass 'active' + checked = $(e.currentTarget).is '.active' + if checked + @options.app.state.updateFilter assigned: 'false', assignees: null + else + @options.app.state.updateFilter assigned: null, assignees: null + else + super + + + getValuesWithLabels: -> + values = @model.getValues() + users = @options.app.facets.users + values.forEach (v) => + login = v.val + name = '' + if login + user = _.findWhere users, login: login + name = user.name if user? + v.label = name + values + + + serializeData: -> + _.extend super, + values: @getValuesWithLabels() diff --git a/server/sonar-web/src/main/coffee/issues/facets/base-facet.coffee b/server/sonar-web/src/main/coffee/issues/facets/base-facet.coffee new file mode 100644 index 00000000000..190028b9326 --- /dev/null +++ b/server/sonar-web/src/main/coffee/issues/facets/base-facet.coffee @@ -0,0 +1,52 @@ +define [ + 'backbone.marionette' + 'templates/issues' +], ( + Marionette + Templates +) -> + + $ = jQuery + + + class extends Marionette.ItemView + className: 'issues-facet-box' + template: Templates['issues-base-facet'] + + + modelEvents: -> + 'change': 'render' + + + events: -> + 'click .js-issues-facet-toggle': 'toggle' + 'click .js-issues-facet': 'toggleFacet' + + + onRender: -> + console.log @model.id, @model.get 'enabled' + @$el.toggleClass 'issues-facet-box-collapsed', !@model.get('enabled') + + property = @model.get 'property' + value = @options.app.state.get('query')[property] + if typeof value == 'string' + value.split(',').forEach (s) => + @$('.js-issues-facet').filter("[data-value='#{s}']").addClass 'active' + + + toggle: -> + @model.toggle() + + + getValue: -> + @$('.js-issues-facet.active').map(-> $(@).data 'value').get().join() + + + toggleFacet: (e) -> + $(e.currentTarget).toggleClass 'active' + property = @model.get 'property' + value = @getValue() + obj = {} + obj[property] = value + @options.app.state.updateFilter obj + diff --git a/server/sonar-web/src/main/coffee/issues/facets/creation-date-facet.coffee b/server/sonar-web/src/main/coffee/issues/facets/creation-date-facet.coffee new file mode 100644 index 00000000000..5979ba1f977 --- /dev/null +++ b/server/sonar-web/src/main/coffee/issues/facets/creation-date-facet.coffee @@ -0,0 +1,42 @@ +define [ + 'issues/facets/base-facet' + 'templates/issues' +], ( + BaseFacet + Templates +) -> + + $ = jQuery + + + class extends BaseFacet + template: Templates['issues-creation-date-facet'] + + + events: -> + _.extend super, + 'change input': 'applyFacet' + + + onRender: -> + @$el.toggleClass 'issues-facet-box-collapsed', !@model.get('enabled') + + @$('input').datepicker + dateFormat: 'yy-mm-dd' + changeMonth: true + changeYear: true + + props = ['createdAfter', 'createdBefore', 'createdAt'] + query = @options.app.state.get 'query' + props.forEach (prop) => + value = query[prop] + @$("input[name=#{prop}]").val value if value? + + + applyFacet: -> + obj = {} + @$('input').each -> + property = $(@).prop 'name' + value = $(@).val() + obj[property] = value + @options.app.state.updateFilter obj diff --git a/server/sonar-web/src/main/coffee/issues/facets/project-facet.coffee b/server/sonar-web/src/main/coffee/issues/facets/project-facet.coffee new file mode 100644 index 00000000000..4b4495000ed --- /dev/null +++ b/server/sonar-web/src/main/coffee/issues/facets/project-facet.coffee @@ -0,0 +1,25 @@ +define [ + 'issues/facets/base-facet' +], ( + BaseFacet +) -> + + + class extends BaseFacet + + getValuesWithLabels: -> + values = @model.getValues() + projects = @options.app.facets.projects + values.forEach (v) => + uuid = v.val + label = '' + if uuid + project = _.findWhere projects, uuid: uuid + label = project.longName if project? + v.label = label + values + + + serializeData: -> + _.extend super, + values: @getValuesWithLabels() diff --git a/server/sonar-web/src/main/coffee/issues/facets/resolution-facet.coffee b/server/sonar-web/src/main/coffee/issues/facets/resolution-facet.coffee new file mode 100644 index 00000000000..a51aa4ab796 --- /dev/null +++ b/server/sonar-web/src/main/coffee/issues/facets/resolution-facet.coffee @@ -0,0 +1,43 @@ +define [ + 'issues/facets/base-facet' + 'templates/issues' +], ( + BaseFacet + Templates +) -> + + $ = jQuery + + + class extends BaseFacet + template: Templates['issues-resolution-facet'] + + + onRender: -> + super + + value = @options.app.state.get('query')['resolved'] + if value? && (!value || value == 'false') + @$('.js-issues-facet').filter("[data-unresolved]").addClass 'active' + + + toggleFacet: (e) -> + unresolved = $(e.currentTarget).is "[data-unresolved]" + $(e.currentTarget).toggleClass 'active' + if unresolved + checked = $(e.currentTarget).is '.active' + value = if checked then 'false' else null + @options.app.state.updateFilter resolved: value, resolutions: null + else + @options.app.state.updateFilter resolved: null, resolutions: @getValue() + + + sortValues: (values) -> + order = ['FIXED', 'FALSE-POSITIVE', 'CLOSED'] + _.sortBy values, (v) -> order.indexOf v.val + + + serializeData: -> + _.extend super, + values: @sortValues @model.getValues() + diff --git a/server/sonar-web/src/main/coffee/issues/facets/rule-facet.coffee b/server/sonar-web/src/main/coffee/issues/facets/rule-facet.coffee new file mode 100644 index 00000000000..df5b544bb9a --- /dev/null +++ b/server/sonar-web/src/main/coffee/issues/facets/rule-facet.coffee @@ -0,0 +1,25 @@ +define [ + 'issues/facets/base-facet' +], ( + BaseFacet +) -> + + + class extends BaseFacet + + getValuesWithLabels: -> + values = @model.getValues() + rules = @options.app.facets.rules + values.forEach (v) => + key = v.val + label = '' + if key + rule = _.findWhere rules, key: key + label = rule.name if rule? + v.label = label + values + + + serializeData: -> + _.extend super, + values: @getValuesWithLabels() diff --git a/server/sonar-web/src/main/coffee/issues/facets/severity-facet.coffee b/server/sonar-web/src/main/coffee/issues/facets/severity-facet.coffee new file mode 100644 index 00000000000..2f7dac47911 --- /dev/null +++ b/server/sonar-web/src/main/coffee/issues/facets/severity-facet.coffee @@ -0,0 +1,22 @@ +define [ + 'issues/facets/base-facet' + 'templates/issues' +], ( + BaseFacet + Templates +) -> + + + class extends BaseFacet + template: Templates['issues-severity-facet'] + + + sortValues: (values) -> + order = ['BLOCKER', 'MINOR', 'CRITICAL', 'INFO', 'MAJOR'] + _.sortBy values, (v) -> order.indexOf v.val + + + serializeData: -> + _.extend super, + values: @sortValues @model.getValues() + diff --git a/server/sonar-web/src/main/coffee/issues/facets/status-facet.coffee b/server/sonar-web/src/main/coffee/issues/facets/status-facet.coffee new file mode 100644 index 00000000000..ced0a9dedfb --- /dev/null +++ b/server/sonar-web/src/main/coffee/issues/facets/status-facet.coffee @@ -0,0 +1,21 @@ +define [ + 'issues/facets/base-facet' + 'templates/issues' +], ( + BaseFacet + Templates +) -> + + + class extends BaseFacet + template: Templates['issues-status-facet'] + + + sortValues: (values) -> + order = ['OPEN', 'RESOLVED', 'REOPENED', 'CLOSED', 'CONFIRMED'] + _.sortBy values, (v) -> order.indexOf v.val + + + serializeData: -> + _.extend super, + values: @sortValues @model.getValues() diff --git a/server/sonar-web/src/main/coffee/issues/filters-view.coffee b/server/sonar-web/src/main/coffee/issues/filters-view.coffee new file mode 100644 index 00000000000..1d9f9e9eaed --- /dev/null +++ b/server/sonar-web/src/main/coffee/issues/filters-view.coffee @@ -0,0 +1,77 @@ +define [ + 'backbone.marionette' + 'templates/issues' +], ( + Marionette + Templates +) -> + + $ = jQuery + + + class extends Marionette.ItemView + template: Templates['issues-filters'] + + + events: + 'click .js-issues-toggle-filters': 'toggleFilters' + 'click .js-issues-filter': 'applyFilter' + 'click .js-issues-new-search': 'newSearch' + 'click .js-issues-save-as': 'saveAs' + 'click .js-issues-save': 'save' + 'click .js-issues-copy': 'copy' + 'click .js-issues-edit': 'edit' + + + initialize: (options) -> + @listenTo options.app.state, 'change:filter', @render + @listenTo options.app.state, 'change:changed', @render + @listenTo options.app.filters, 'all', @render + window.onSaveAs = window.onCopy = window.onEdit = (id) => + $('#modal').dialog 'close' + @options.app.controller.fetchFilters().done => + filter = @collection.get id + filter.fetch().done => @options.app.controller.applyFilter filter + + + toggleFilters: -> + @$('.issues-filters-list').toggle() + + + applyFilter: (e) -> + id = $(e.currentTarget).data 'id' + filter = @collection.get id + filter.fetch().done => @options.app.controller.applyFilter filter + + + newSearch: -> + @options.app.controller.newSearch() + + + saveAs: -> + query = @options.app.controller.getQuery '&' + url = "#{baseUrl}/issues/save_as_form?#{query}" + openModalWindow url, {} + + + save: -> + query = @options.app.controller.getQuery '&' + url = "#{baseUrl}/issues/save/#{@options.app.state.get('filter').id}?#{query}" + $.post(url).done => + @options.app.state.set changed: false + + + copy: -> + url = "#{baseUrl}/issues/copy_form/#{@options.app.state.get('filter').id}" + openModalWindow url, {} + + + edit: -> + url = "#{baseUrl}/issues/edit_form/#{@options.app.state.get('filter').id}" + openModalWindow url, {} + + + serializeData: -> + _.extend super, + state: @options.app.state.toJSON() + filter: @options.app.state.get('filter')?.toJSON() diff --git a/server/sonar-web/src/main/coffee/issues/issue-view.coffee b/server/sonar-web/src/main/coffee/issues/issue-view.coffee new file mode 100644 index 00000000000..624327d2672 --- /dev/null +++ b/server/sonar-web/src/main/coffee/issues/issue-view.coffee @@ -0,0 +1,11 @@ +define [ + 'issue/issue-view' + 'templates/issues' +], ( + IssueView + Templates +) -> + + + class extends IssueView + template: Templates['issues-issue'] diff --git a/server/sonar-web/src/main/coffee/issues/layout.coffee b/server/sonar-web/src/main/coffee/issues/layout.coffee new file mode 100644 index 00000000000..c0649f25b4c --- /dev/null +++ b/server/sonar-web/src/main/coffee/issues/layout.coffee @@ -0,0 +1,63 @@ +define [ + 'backbone.marionette' + 'templates/issues' +], ( + Marionette + Templates +) -> + + $ = jQuery + + # http://stackoverflow.com/questions/7600454/how-to-prevent-page-scrolling-when-scrolling-a-div-element + $.fn.isolatedScroll = -> + @on 'mousewheel DOMMouseScroll', (e) -> + delta = e.wheelDelta || (e.originalEvent && e.originalEvent.wheelDelta) || -e.detail + bottomOverflow = @scrollTop + $(@).outerHeight() - @scrollHeight >= 0 + topOverflow = @scrollTop <= 0 + e.preventDefault() if (delta < 0 && bottomOverflow) || (delta > 0 && topOverflow) + @ + + + class extends Marionette.Layout + template: Templates['issues-layout'] + + + regions: + filtersRegion: '.issues-filters' + facetsRegion: '.issues-facets' + workspaceHeaderRegion: '.issues-workspace-header' + workspaceListRegion: '.issues-workspace-list' + workspaceComponentViewerRegion: '.issues-workspace-component-viewer' + + + initialize: -> + $(window).on 'scroll.issues-layout', (=> @onScroll()) + + + onClose: -> + $(window).off 'scroll.issues-layout' + + + onRender: -> + @$('.issues-side').isolatedScroll() + + + onScroll: -> + scrollTop = $(window).scrollTop() + $('.issues').toggleClass 'sticky', scrollTop >= 30 + @$('.issues-side').css top: Math.max(0, Math.min(30 - scrollTop, 30)) + + + showSpinner: (region) -> + @[region].show new Marionette.ItemView + template: _.template('') + + + showComponentViewer: -> + @scroll = $(window).scrollTop() + $('.issues').addClass 'issues-extended-view' + + + hideComponentViewer: -> + $('.issues').removeClass 'issues-extended-view' + $(window).scrollTop @scroll if @scroll? diff --git a/server/sonar-web/src/main/coffee/issues/models/facet.coffee b/server/sonar-web/src/main/coffee/issues/models/facet.coffee new file mode 100644 index 00000000000..fa7cb817441 --- /dev/null +++ b/server/sonar-web/src/main/coffee/issues/models/facet.coffee @@ -0,0 +1,22 @@ +define [ + 'backbone' +], ( + Backbone +) -> + + class extends Backbone.Model + idAttribute: 'property' + + + defaults: + enabled: false + + + getValues: -> + console.log @toJSON() + @get('values') || [] + + + toggle: -> + enabled = @get 'enabled' + @set enabled: !enabled diff --git a/server/sonar-web/src/main/coffee/issues/models/facets.coffee b/server/sonar-web/src/main/coffee/issues/models/facets.coffee new file mode 100644 index 00000000000..b0be4ad3c3c --- /dev/null +++ b/server/sonar-web/src/main/coffee/issues/models/facets.coffee @@ -0,0 +1,10 @@ +define [ + 'backbone' + 'issues/models/facet' +], ( + Backbone + Facet +) -> + + class extends Backbone.Collection + model: Facet diff --git a/server/sonar-web/src/main/coffee/issues/models/filter.coffee b/server/sonar-web/src/main/coffee/issues/models/filter.coffee new file mode 100644 index 00000000000..bbb881f924e --- /dev/null +++ b/server/sonar-web/src/main/coffee/issues/models/filter.coffee @@ -0,0 +1,14 @@ +define [ + 'backbone' +], ( + Backbone +) -> + + class extends Backbone.Model + + url: -> + "/api/issue_filters/show/#{@id}" + + + parse: (r) -> + if r.filter? then r.filter else r diff --git a/server/sonar-web/src/main/coffee/issues/models/filters.coffee b/server/sonar-web/src/main/coffee/issues/models/filters.coffee new file mode 100644 index 00000000000..b6c8f9dc59f --- /dev/null +++ b/server/sonar-web/src/main/coffee/issues/models/filters.coffee @@ -0,0 +1,10 @@ +define [ + 'backbone' + 'issues/models/filter' +], ( + Backbone + Filter +) -> + + class extends Backbone.Collection + model: Filter diff --git a/server/sonar-web/src/main/coffee/issues/models/issues.coffee b/server/sonar-web/src/main/coffee/issues/models/issues.coffee new file mode 100644 index 00000000000..7174163359e --- /dev/null +++ b/server/sonar-web/src/main/coffee/issues/models/issues.coffee @@ -0,0 +1,61 @@ +define [ + 'backbone' + 'issue/models/issue' +], ( + Backbone + Issue +) -> + + class extends Backbone.Collection + model: Issue + + url: -> + "#{baseUrl}/api/issues/search" + + + # Used to parse /api/issues/search response + parseIssues: (r) -> + find = (source, key, keyField) -> + searchDict = {} + searchDict[keyField || 'key'] = key + _.findWhere(source, searchDict) || key + + r.issues.map (issue) -> + component = find r.components, issue.component + project = find r.projects, issue.project + rule = find r.rules, issue.rule + + if component + _.extend issue, + componentLongName: component.longName + componentQualifier: component.qualifier + + if project + _.extend issue, + projectLongName: project.longName + + if rule + _.extend issue, + ruleName: rule.name + + if _.isArray(issue.sources) && issue.sources.length > 0 + source = '' + issue.sources.forEach (line) -> + source = line[1] if line[0] == issue.line + _.extend issue, source: source + + + if _.isArray(issue.scm) && issue.scm.length > 0 + scmAuthor = '' + scmDate = '' + + issue.scm.forEach (line) -> + if line[0] == issue.line + scmAuthor = line[1] + scmDate = line[2] + + _.extend issue, + scmAuthor: scmAuthor + scmDate: scmDate + + issue diff --git a/server/sonar-web/src/main/coffee/issues/models/state.coffee b/server/sonar-web/src/main/coffee/issues/models/state.coffee new file mode 100644 index 00000000000..6ef3f104983 --- /dev/null +++ b/server/sonar-web/src/main/coffee/issues/models/state.coffee @@ -0,0 +1,42 @@ +define [ + 'backbone' +], ( + Backbone +) -> + + class extends Backbone.Model + + defaults: + page: 1 + maxResultsReached: false + + query: {} + + facets: ['severities', 'statuses', 'resolutions', 'componentRootUuids'] + allFacets: ['severities', 'statuses', 'resolutions', 'componentRootUuids', 'assignees', 'reporters', 'rule', + 'languages', 'actionPlan', 'creationDate'] + + + nextPage: -> + page = @get 'page' + @set page: page + 1 + + + cleanQuery: (query) -> + q = {} + Object.keys(query).forEach (key) -> + q[key] = query[key] if query[key] + q + + + updateFilter: (obj) -> + filter = @get 'query' + _.extend filter, obj + @setQuery @cleanQuery filter + + + setQuery: (query) -> + @set { query: query }, { silent: true } + @trigger 'change:query' + @set changed: true + diff --git a/server/sonar-web/src/main/coffee/issues/router.coffee b/server/sonar-web/src/main/coffee/issues/router.coffee new file mode 100644 index 00000000000..b89b9e5ace3 --- /dev/null +++ b/server/sonar-web/src/main/coffee/issues/router.coffee @@ -0,0 +1,32 @@ +define [ + 'backbone' +], ( + Backbone +) -> + + class extends Backbone.Router + routeSeparator: '|' + + routes: + '': 'emptyQuery' + ':query': 'index' + + + initialize: (options) -> + @options = options + @listenTo @options.app.state, 'change:query', @updateRoute + + + emptyQuery: -> + @navigate 'resolved=false', { trigger: true, replace: true } + + + index: (query) -> + filter = @options.app.controller.parseQuery query + @options.app.state.setQuery filter + + + updateRoute: -> + route = @options.app.controller.getQuery() + @navigate route + diff --git a/server/sonar-web/src/main/coffee/issues/workspace-header-view.coffee b/server/sonar-web/src/main/coffee/issues/workspace-header-view.coffee new file mode 100644 index 00000000000..fd2e92e439a --- /dev/null +++ b/server/sonar-web/src/main/coffee/issues/workspace-header-view.coffee @@ -0,0 +1,70 @@ +define [ + 'backbone.marionette' + 'templates/issues' +], ( + Marionette + Templates +) -> + + $ = jQuery + + + class extends Marionette.ItemView + template: Templates['issues-workspace-header'] + + + collectionEvents: + 'all': 'render' + + + events: + 'click .js-back': 'returnToList' + 'click .js-issues-bulk-change': 'bulkChange' + 'click .js-issues-next': 'selectNextIssue' + 'click .js-issues-prev': 'selectPrevIssue' + + + initialize: (options) -> + @listenTo options.app.state, 'change', @render + @_onBulkIssues = window.onBulkIssues + window.onBulkIssues = => + $('#modal').dialog 'close' + @options.app.controller.fetchIssues() + @bindShortcuts() + + + onClose: -> + window.onBulkIssues = @_onBulkIssues + + + returnToList: -> + @options.app.controller.closeComponentViewer() + + + bulkChange: -> + query = @options.app.controller.getQuery '&' + url = "#{baseUrl}/issues/bulk_change_form?#{query}" + openModalWindow url, {} + + + selectNextIssue: -> + @options.app.controller.selectNextIssue() + + + selectPrevIssue: -> + @options.app.controller.selectPreviousIssue() + + + bindShortcuts: -> + key 'j,up', => + @options.app.controller.selectPreviousIssue() + false + key 'k,down', => + @options.app.controller.selectNextIssue() + false + + + serializeData: -> + _.extend super, + state: @options.app.state.toJSON() + diff --git a/server/sonar-web/src/main/coffee/issues/workspace-list-item-view.coffee b/server/sonar-web/src/main/coffee/issues/workspace-list-item-view.coffee new file mode 100644 index 00000000000..1d5f67c4df1 --- /dev/null +++ b/server/sonar-web/src/main/coffee/issues/workspace-list-item-view.coffee @@ -0,0 +1,42 @@ +define [ + 'backbone.marionette' + 'templates/issues' + 'issues/issue-view' +], ( + Marionette + Templates + IssueBoxView +) -> + + class extends Marionette.ItemView + tagName: 'li' + className: 'issue-box' + template: Templates['issues-workspace-list-item'] + + + events: + 'click .js-issues-snippet': 'openComponentViewer' + + + initialize: (options) -> + @listenTo options.app.state, 'change:selectedIndex', @select + + + onRender: -> + @issueBoxView = new IssueBoxView model: @model + @$('.issue-box-details').append @issueBoxView.render().el + @select() + + + select: -> + selected = @options.index == @options.app.state.get 'selectedIndex' + @$el.toggleClass 'selected', selected + + + onClose: -> + @issueBoxView?.close() + + + openComponentViewer: -> + @options.app.state.set selectedIndex: @options.index + @options.app.controller.showComponentViewer @model diff --git a/server/sonar-web/src/main/coffee/issues/workspace-list-view.coffee b/server/sonar-web/src/main/coffee/issues/workspace-list-view.coffee new file mode 100644 index 00000000000..18413b1dff2 --- /dev/null +++ b/server/sonar-web/src/main/coffee/issues/workspace-list-view.coffee @@ -0,0 +1,93 @@ +define [ + 'backbone.marionette' + 'templates/issues' + 'issues/workspace-list-item-view' +], ( + Marionette + Templates + IssueView +) -> + + $ = jQuery + + TOP_OFFSET = 38 + BOTTOM_OFFSET = 10 + + + class extends Marionette.CompositeView + template: Templates['issues-workspace-list'] + itemView: IssueView + itemViewContainer: 'ul' + + + ui: + loadMore: '.js-issues-more' + + + itemViewOptions: (_, index) -> + app: @options.app + index: index + + + collectionEvents: + 'reset': 'scrollToTop' + + + initialize: -> + @loadMoreThrottled = _.throttle @loadMore, 1000 + @listenTo @options.app.state, 'change:maxResultsReached', @toggleLoadMore + @listenTo @options.app.state, 'change:selectedIndex', @scrollToIssue + @bindShortcuts() + + + onClose: -> + @unbindScrollEvents() + + + toggleLoadMore: -> + @ui.loadMore.toggle !@options.app.state.get 'maxResultsReached' + + + bindScrollEvents: -> + $(window).on 'scroll.issues-workspace-list', (=> @onScroll()) + + + unbindScrollEvents: -> + $(window).off 'scroll.issues-workspace-list' + + + bindShortcuts: -> + key 'return', 'list', => + selectedIssue = @collection.at @options.app.state.get 'selectedIndex' + @options.app.controller.showComponentViewer selectedIssue + return false + + + loadMore: -> + unless @options.app.state.get 'maxResultsReached' + @unbindScrollEvents() + @options.app.controller.fetchNextPage().done => @bindScrollEvents() + + + onScroll: -> + if $(window).scrollTop() + $(window).height() >= @ui.loadMore.offset().top + @loadMoreThrottled() + + + scrollToTop: -> + @$el.scrollParent().scrollTop 0 + + + scrollToIssue: -> + selectedIssue = @collection.at @options.app.state.get 'selectedIndex' + selectedIssueView = @children.findByModel selectedIssue + viewTop = selectedIssueView.$el.offset().top + viewBottom = viewTop + selectedIssueView.$el.outerHeight() + windowTop = $(window).scrollTop() + windowBottom = windowTop + $(window).height() + if viewTop < windowTop + $(window).scrollTop viewTop - TOP_OFFSET + if viewBottom > windowBottom + $(window).scrollTop $(window).scrollTop() - windowBottom + viewBottom + BOTTOM_OFFSET + + diff --git a/server/sonar-web/src/main/hbs/coding-rules/coding-rules-facets.hbs b/server/sonar-web/src/main/hbs/coding-rules/coding-rules-facets.hbs index d2ea9d4fd56..c274ffff513 100644 --- a/server/sonar-web/src/main/hbs/coding-rules/coding-rules-facets.hbs +++ b/server/sonar-web/src/main/hbs/coding-rules/coding-rules-facets.hbs @@ -14,9 +14,9 @@