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 @@
-Issues so far
-
-
- <%= tag.display_name %>
-
-
-
- <% @issues_by_tag[tag.name].each do |issue| %>
- <%= link_to [current_project, issue], class: 'list-group-item' do %>
-
- Unassigned
-
-
-
- <% @issues_by_tag[:unassigned].each do |issue| %>
- <%= link_to [current_project, issue], class: 'list-group-item' do %>
-
-