diff --git a/app/assets/javascripts/hera/modules/combobox.js b/app/assets/javascripts/hera/modules/combobox.js index a4204cb496..c760713749 100644 --- a/app/assets/javascripts/hera/modules/combobox.js +++ b/app/assets/javascripts/hera/modules/combobox.js @@ -463,9 +463,7 @@ class ComboBox { // ========================================================================== setInitialSelection() { - let $initialOption = this.$comboboxOptions.filter( - `[data-value="${this.$target.val()}"]`, - ); + let $initialOption = this.filterByDataValue(this.$comboboxOptions, 'value', this.$target.val()); if (!$initialOption.length) { if (this.isMultiSelect) { @@ -506,10 +504,8 @@ class ComboBox { updateMultiSelectUI($options) { $options.forEach(($option) => { - if ( - this.$combobox.find(`[data-option-value="${$option.data('value')}"]`) - .length - ) { + const $existingTags = this.$combobox.find('[data-behavior~=combobox-multi-option]'); + if (this.filterByDataValue($existingTags, 'option-value', $option.data('value')).length) { return; } @@ -543,7 +539,7 @@ class ComboBox { if (this.isMultiSelect) { const currentValues = this.$target.val() || []; currentValues.forEach((value) => { - const $option = this.$comboboxOptions.filter(`[data-value="${value}"]`); + const $option = this.filterByDataValue(this.$comboboxOptions, 'value', value); if ($option.length) { $options.push($option); } @@ -551,9 +547,7 @@ class ComboBox { this.$comboboxOptions.removeClass('selected'); this.$combobox.find('[data-behavior~=combobox-multi-option]').remove(); } else { - $options = this.$comboboxOptions.filter( - `[data-value="${this.$target.val()}"]`, - ); + $options = this.filterByDataValue(this.$comboboxOptions, 'value', this.$target.val()); } this.updateComboboxUI($options); @@ -563,6 +557,15 @@ class ComboBox { // Utilities // ========================================================================== + // Matches by exact attribute value instead of building a CSS attribute + // selector, so values containing quotes or other selector metacharacters + // (eg. option labels sourced from user-editable field names) can't break + // or escape the match. + filterByDataValue($elements, dataKey, value) { + const attr = `data-${dataKey}`; + return $elements.filter((_, el) => el.getAttribute(attr) === String(value)); + } + showMenu() { this.$comboboxMenu.css('display', 'block'); this.$combobox.attr('aria-expanded', 'true'); diff --git a/app/assets/javascripts/hera/pages/projects/issues_chart.js b/app/assets/javascripts/hera/pages/projects/issues_chart.js index a3c4bc654b..e982660222 100644 --- a/app/assets/javascripts/hera/pages/projects/issues_chart.js +++ b/app/assets/javascripts/hera/pages/projects/issues_chart.js @@ -1,95 +1,101 @@ -document.addEventListener('turbo:load', function(){ - var $dataElement = $('#issues-summary-data'), - $chartElement = $('#issue-chart'); - - if ($dataElement.length && $chartElement.find('svg').length == 0) { - var margin = {top: 20, bottom: 30}, - width = 354; - height = 180 - margin.top - margin.bottom; - - var x = d3.scaleBand().rangeRound([0, width]); - - var y = d3.scaleLinear() - .range([height, 0]); - - var xAxis = d3.axisBottom(x) - .tickSize(0); - - var svg = d3.select('#issue-chart').append('svg') - .attr('width', width) - .attr('height', height + margin.top + margin.bottom) - .append('g') - .attr('transform', 'translate(0,' + margin.top + ')'); - - // --------------------------------------------------------- Data variables - var tags = $dataElement.data('tags'); - var issuesByTag = $dataElement.data('issues-count'); - var highest = 0; - var data = []; - var x_domain = []; - - for (var key in tags){ - issuesCount = issuesByTag[key]; - highest = issuesCount > highest ? issuesCount : highest - data.push({letter: tags[key][0], frequency: issuesCount}); - x_domain.push(tags[key][0]); - } - data.push({letter: 'N/A', frequency: issuesByTag['unassigned']}) - x_domain.push('N/A'); - - var highest_y = Math.max(highest, issuesByTag['unassigned']); - // -------------------------------------------------------- /Data variables - - x.domain(x_domain); - - y.domain([0, highest_y]); - - d3.selection.prototype.last = function() { - return d3.select( - this.nodes()[this.size() - 1] - ); - }; - - x_axis = svg.append('g') - .attr('class', 'x axis') - .attr('transform', 'translate(0,' + height + ')') - .call(xAxis); - x_axis.selectAll("text").style("fill", "inherit"); - x_axis.selectAll("path").style("stroke", "none"); - x_axis.selectAll("text").last().classed("untagged", true); - - var bars = svg.append('g'); - - bars.selectAll('rect') - .data(data) - .enter().append('rect') - .attr('class', 'bar' ) - .attr('x', function(d) { return x(d.letter); }) - .attr('width', x.bandwidth()) - .attr('y', function(d) { return y(d.frequency); }) - .attr('height', function(d) { return height - y(d.frequency); }); - - - bars.selectAll('text') - .data(data) - .enter().append('text') - .attr('x', function(d, i) { return x(d.letter) + x.bandwidth()/2; }) - .attr('y', function(d) { return y(d.frequency);}) - .attr('dy', -5) - .attr('text-anchor', 'middle') - .attr('class', 'counter' ) - .text(function(d) {return d.frequency;}); - - var i = 0; - for( var key in tags ){ - $($('.tick')[i]).attr('fill', tags[key][1]); - $($('.bar')[i]).attr('fill', tags[key][1]); - $($('.counter')[i]).attr('fill', tags[key][1]); - i++; - } - - $($('.tick')[i]).addClass('untagged'); - $($('.bar')[i]).addClass('untagged'); - $($('.counter')[i]).addClass('untagged'); +function initIssuesChart() { + const $chartElement = $('[data-behavior~=issue-chart]'); + + if (!$chartElement.length || $chartElement.find('svg').length > 0) { return; } + + const margin = { top: 20, bottom: 0 }; + const width = 354; + const height = 180 - margin.top - margin.bottom; + + const x = d3.scaleBand().rangeRound([0, width]); + const y = d3.scaleLinear().range([height, 0]); + + const container = d3.select('[data-behavior~=issue-chart]'); + + const svg = container.append('svg') + .attr('width', width) + .attr('height', height + margin.top + margin.bottom) + .append('g') + .attr('transform', `translate(0,${margin.top})`); + + // --------------------------------------------------------- Data variables + const tags = $chartElement.data('tags') || {}; + const issuesByTag = $chartElement.data('issues-count') || {}; + let highest = 0; + const data = []; + const x_domain = []; + const colors = []; + + Object.keys(tags).forEach(key => { + const issuesCount = issuesByTag[key] || 0; + highest = issuesCount > highest ? issuesCount : highest; + data.push({ letter: tags[key][0], frequency: issuesCount }); + x_domain.push(tags[key][0]); + colors.push(tags[key][1]); + }); + + const unassignedCount = issuesByTag['unassigned'] || 0; + data.push({ letter: 'N/A', frequency: unassignedCount }); + x_domain.push('N/A'); + + const highest_y = Math.max(highest, unassignedCount); + // -------------------------------------------------------- /Data variables + + x.domain(x_domain); + y.domain([0, highest_y]); + + const bars = svg.append('g'); + + bars.selectAll('rect') + .data(data) + .enter().append('rect') + .attr('class', 'bar') + .attr('x', d => x(d.letter)) + .attr('width', x.bandwidth()) + .attr('y', d => y(d.frequency)) + .attr('height', d => height - y(d.frequency)); + + bars.selectAll('text') + .data(data) + .enter().append('text') + .attr('x', d => x(d.letter) + x.bandwidth() / 2) + .attr('y', d => y(d.frequency)) + .attr('dy', -5) + .attr('text-anchor', 'middle') + .attr('class', 'counter') + .text(d => d.frequency); + + colors.forEach((color, i) => { + $($('.bar')[i]).attr('fill', color); + $($('.counter')[i]).attr('fill', color); + }); + + $($('.bar')[colors.length]).addClass('untagged'); + $($('.counter')[colors.length]).addClass('untagged'); + + buildLegend(container, data, colors); +} + +function buildLegend(container, data, colors) { + const legend = container.append('ul').attr('class', 'issue-chart-legend'); + + const item = legend.selectAll('li') + .data(data) + .enter().append('li') + .attr('class', (_, i) => i === colors.length ? 'legend-item untagged' : 'legend-item') + .attr('title', d => d.letter); + + item.append('span') + .attr('class', 'legend-swatch') + .style('background-color', (_, i) => colors[i] || null); + + item.append('span') + .attr('class', 'legend-label') + .text(d => d.letter); +} + +document.addEventListener('turbo:frame-load', e => { + if (e.target.id === 'issues-summary') { + initIssuesChart(); } }); diff --git a/app/assets/stylesheets/hera/views/projects.scss b/app/assets/stylesheets/hera/views/projects.scss index 07483020e0..2538279f84 100644 --- a/app/assets/stylesheets/hera/views/projects.scss +++ b/app/assets/stylesheets/hera/views/projects.scss @@ -36,19 +36,60 @@ body.projects { } #issue-chart { - display: flex; - justify-content: center; align-items: center; + display: flex; + flex-direction: column; + gap: 0.75rem; .bar.untagged, - .counter.untagged, - .tick.untagged { + .counter.untagged { fill: var(--untagged-color); } svg text { font-size: 14px; } + + .issue-chart-legend { + display: flex; + flex-wrap: wrap; + gap: 0.5rem 1rem; + justify-content: center; + list-style: none; + margin: 0; + padding: 0; + + .legend-item { + align-items: center; + display: flex; + gap: 0.5rem; + + &.untagged { + .legend-swatch { + background-color: var(--untagged-color); + } + + .legend-label { + color: var(--untagged-color); + } + } + } + + .legend-label { + color: var(--text-default); + max-width: 12rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .legend-swatch { + border-radius: 50%; + flex-shrink: 0; + height: 0.75rem; + width: 0.75rem; + } + } } #issues-accordion { diff --git a/app/controllers/concerns/issues_dimension_grouping.rb b/app/controllers/concerns/issues_dimension_grouping.rb new file mode 100644 index 0000000000..6fc4618f43 --- /dev/null +++ b/app/controllers/concerns/issues_dimension_grouping.rb @@ -0,0 +1,29 @@ +module IssuesDimensionGrouping + private + + def build_all_tags_grouping + @count_by_tag = Hash.new(0) + @issues_by_tag = Hash.new { |h, k| h[k] = [] } + @tag_names = @tags.map do |tag| + [tag.name, [tag.display_name, tag.color]] + end.to_h + + @issues.each do |issue| + if issue.tags.empty? + @issues_by_tag[:unassigned] << issue + @count_by_tag[:unassigned] += 1 + else + issue.tags.each do |tag| + @issues_by_tag[tag.name] << issue + @count_by_tag[tag.name] += 1 + end + end + end + + @chart_data = { + dimension: 'tags', + tags: @tag_names.to_json, + issues_count: @count_by_tag.to_json + } + end +end diff --git a/app/controllers/projects/issues_summary_controller.rb b/app/controllers/projects/issues_summary_controller.rb new file mode 100644 index 0000000000..71ac341065 --- /dev/null +++ b/app/controllers/projects/issues_summary_controller.rb @@ -0,0 +1,11 @@ +class Projects::IssuesSummaryController < AuthenticatedController + include IssuesDimensionGrouping + include ProjectScoped + + def show + @issues = current_project.issues.includes(:tags).sort + @tags = current_project.tags + + build_all_tags_grouping + end +end diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index 3864b5b093..14cefec40d 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -3,7 +3,7 @@ class ProjectsController < AuthenticatedController before_action :set_project - helper :hera + helper :hera helper_method :current_project def index @@ -11,33 +11,11 @@ def index end def show - @activities = Activity.latest - @authors = [current_user] - @boards = current_project.methodology_library.boards - @issues = current_project.issues.includes(:tags).sort + @activities = Activity.latest + @authors = [current_user] + @boards = current_project.methodology_library.boards @methodologies = current_project.methodology_library.notes.map { |n| Methodology.new(filename: n.id, content: n.text) } - @nodes = current_project.nodes.in_tree - @tags = current_project.tags - - @count_by_tag = { unassigned: 0 } - @issues_by_tag = Hash.new { |h, k| h[k] = [] } - - @tag_names = @tags.map do |tag| - @count_by_tag[tag.name] = 0 - [tag.name, [tag.display_name, tag.color]] - end.to_h - - @issues.each do |issue| - if issue.tags.empty? - @issues_by_tag[:unassigned] << issue - @count_by_tag[:unassigned] += 1 - else - issue.tags.each do |tag| - @issues_by_tag[tag.name] << issue - @count_by_tag[tag.name] += 1 - end - end - end + @nodes = current_project.nodes.in_tree respond_to do |format| format.html { render layout: 'hera/project' if !request.xhr? } diff --git a/app/helpers/hera_helper.rb b/app/helpers/hera_helper.rb index f2af0a063b..ebf114048b 100644 --- a/app/helpers/hera_helper.rb +++ b/app/helpers/hera_helper.rb @@ -6,7 +6,7 @@ def body_css end def colored_icon_for_model(model, icon_class, extra_class = nil) - css = ['fa-solid'] + css = ['fa-solid'] css << icon_class css << extra_class if extra_class diff --git a/app/views/projects/issues/_summary.html.erb b/app/views/projects/issues/_summary.html.erb index 233d9a9765..b7ba982589 100644 --- a/app/views/projects/issues/_summary.html.erb +++ b/app/views/projects/issues/_summary.html.erb @@ -1,63 +1 @@ -
- <% if @issues.any? %> -

Issues so far

-
- -
- <% for tag in @tags do %> - <% if @issues_by_tag[tag.name].any? %> -
- -
<%= tag.display_name %>
-
- -
-
-
    - <% @issues_by_tag[tag.name].each do |issue| %> - <%= link_to [current_project, issue], class: 'list-group-item' do %> -
  • <%= issue.title %>
  • - <% end %> - <% end %> -
-
-
-
- <% end %> - <% end %> - - <%# unassigned %> - <% if @issues_by_tag[:unassigned].any? %> -
- -
Unassigned
-
- -
-
-
    - <% @issues_by_tag[:unassigned].each do |issue| %> - <%= link_to [current_project, issue], class: 'list-group-item' do %> -
  • <%= issue.title %>
  • - <% end %> - <% end %> -
-
-
-
- <% end %> -
- - <% else %> - <%= render 'shared/empty_state', - actions_partial: 'projects/issues/empty_state_actions', - name: 'issue', - docs_link: 'https://dradis.com/support/guides/projects/issues.html', - text: 'Use issues to represent vulnerabilities or findings.' - %> - <% end %> -
- -<% if @issues.any? %> -
-<% end %> +<%= render "projects/issues/summary/#{Dradis.edition}" %> diff --git a/app/views/projects/issues/_summary_content.html.erb b/app/views/projects/issues/_summary_content.html.erb new file mode 100644 index 0000000000..3df7b3e0d9 --- /dev/null +++ b/app/views/projects/issues/_summary_content.html.erb @@ -0,0 +1,14 @@ +<% if @issues.any? %> + <%= tag.div id: 'issue-chart', data: @chart_data.merge(behavior: 'issue-chart') %> + +
+ <%= render 'projects/issues/summary/tags_accordion' %> +
+<% else %> + <%= render 'shared/empty_state', + actions_partial: 'projects/issues/empty_state_actions', + name: 'issue', + docs_link: 'https://dradis.com/support/guides/projects/issues.html', + text: 'Use issues to represent vulnerabilities or findings.' + %> +<% end %> diff --git a/app/views/projects/issues/summary/_ce.html.erb b/app/views/projects/issues/summary/_ce.html.erb new file mode 100644 index 0000000000..c54f9bae00 --- /dev/null +++ b/app/views/projects/issues/summary/_ce.html.erb @@ -0,0 +1,5 @@ +
+

Issues so far

+ + <%= turbo_frame_tag 'issues-summary', src: project_issues_summary_path(current_project) %> +
diff --git a/app/views/projects/issues/summary/_tags_accordion.html.erb b/app/views/projects/issues/summary/_tags_accordion.html.erb new file mode 100644 index 0000000000..525c9f91cc --- /dev/null +++ b/app/views/projects/issues/summary/_tags_accordion.html.erb @@ -0,0 +1,39 @@ +<% @tags.each do |tag| %> + <% if @issues_by_tag[tag.name].any? %> +
+ +
<%= tag.display_name %>
+
+
+
+
    + <% @issues_by_tag[tag.name].each do |issue| %> + <%= link_to [current_project, issue], class: 'list-group-item', data: { turbo_frame: '_top' } do %> +
  • <%= issue.title %>
  • + <% end %> + <% end %> +
+
+
+
+ <% end %> +<% end %> + +<% if @issues_by_tag[:unassigned].any? %> +
+ +
Unassigned
+
+
+
+
    + <% @issues_by_tag[:unassigned].each do |issue| %> + <%= link_to [current_project, issue], class: 'list-group-item', data: { turbo_frame: '_top' } do %> +
  • <%= issue.title %>
  • + <% end %> + <% end %> +
+
+
+
+<% end %> diff --git a/app/views/projects/issues_summary/show.html.erb b/app/views/projects/issues_summary/show.html.erb new file mode 100644 index 0000000000..53a9af69b8 --- /dev/null +++ b/app/views/projects/issues_summary/show.html.erb @@ -0,0 +1,3 @@ +<%= turbo_frame_tag 'issues-summary' do %> + <%= render 'projects/issues/summary_content' %> +<% end %> diff --git a/config/routes.rb b/config/routes.rb index f0497d4a33..6f4a5fd63a 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -81,6 +81,8 @@ resources :revisions, only: [:index, :show] end + resource :issues_summary, only: [:show], controller: 'projects/issues_summary' + resources :methodologies do collection { post :preview } member do diff --git a/spec/features/projects/issues_summary_spec.rb b/spec/features/projects/issues_summary_spec.rb new file mode 100644 index 0000000000..3bd917d326 --- /dev/null +++ b/spec/features/projects/issues_summary_spec.rb @@ -0,0 +1,30 @@ +require 'rails_helper' + +describe 'Issues Summary', js: true do + subject { page } + + let(:issue) { create(:issue, node: current_project.issue_library) } + + before do + login_to_project_as_user + + tag = create(:tag) + issue.tags << tag + end + + describe 'when in the projects show view' do + it 'lazy-loads and renders the issues chart' do + visit project_path(current_project) + + expect(page).to have_selector('#issue-chart svg') + end + + it 'navigates to the full issue page when an accordion link is clicked' do + visit project_path(current_project) + + click_link issue.title + + expect(page).to have_current_path(project_issue_path(current_project, issue)) + end + end +end diff --git a/spec/requests/projects/issues_summary_spec.rb b/spec/requests/projects/issues_summary_spec.rb new file mode 100644 index 0000000000..0165015266 --- /dev/null +++ b/spec/requests/projects/issues_summary_spec.rb @@ -0,0 +1,25 @@ +require 'rails_helper' + +describe 'Projects::IssuesSummary' do + before { login_to_project_as_user } + + describe 'GET #show' do + it 'groups issues by tag' do + tag = create(:tag) + issue = create(:issue, node: current_project.issue_library) + issue.tags << tag + + get project_issues_summary_path(current_project) + + expect(response).to have_http_status(:ok) + expect(response.body).to include(tag.display_name) + end + + it 'renders the empty state when there are no issues' do + get project_issues_summary_path(current_project) + + expect(response).to have_http_status(:ok) + expect(response.body).to include('Use issues to represent vulnerabilities or findings.') + end + end +end