Skip to content

Commit 34506df

Browse files
committed
Change highlighting mode for the project selector to let the server handle the highlighting
1 parent 7802bfa commit 34506df

8 files changed

Lines changed: 240 additions & 11 deletions

File tree

app/components/header/projects/filterable_tree_view_component.html.erb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ See COPYRIGHT and LICENSE files for more details.
3131
<%= render(
3232
Primer::OpenProject::FilterableTreeView.new(
3333
src: tree_src,
34+
show_search_highlighting: false,
3435
include_sub_items_check_box_arguments: { hidden: true },
3536
filter_mode_control_arguments: logged? ? {} : { hidden: true },
3637
filter_input_arguments: { autofocus: true,

app/components/header/projects/node_component.html.erb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,8 @@ See COPYRIGHT and LICENSE files for more details.
5757
node: child_node,
5858
current_project_id: @current_project_id,
5959
favorited_ids: @favorited_ids,
60-
jump: @jump
60+
jump: @jump,
61+
query_terms: @query_terms
6162
) %>
6263
<% end %>
6364
<% end %>

app/components/header/projects/node_component.rb

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,13 +31,14 @@
3131
module Header
3232
module Projects
3333
class NodeComponent < ApplicationComponent
34-
def initialize(component:, node:, current_project_id:, favorited_ids:, jump:)
34+
def initialize(component:, node:, current_project_id:, favorited_ids:, jump:, query_terms: [])
3535
super()
3636
@component = component
3737
@node = node
3838
@current_project_id = current_project_id
3939
@favorited_ids = favorited_ids
4040
@jump = jump
41+
@query_terms = query_terms
4142
end
4243

4344
private
@@ -54,7 +55,7 @@ def href
5455
end
5556

5657
def label
57-
helpers.project_node_label(project, favorited: favorited?)
58+
helpers.project_node_label(project, favorited: favorited?, query_terms: @query_terms)
5859
end
5960
end
6061
end

app/controllers/header/projects_controller.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ class Header::ProjectsController < ApplicationController
3737
def index
3838
@current_project_id = params[:current_project_id].presence&.to_i
3939
@jump = params[:jump].presence
40+
@query_terms = query.split
4041
@projects = load_projects
4142
@favorited_ids = load_favorited_ids
4243
@tree = build_tree(@projects)

app/helpers/header/projects_helper.rb

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,9 @@
3030

3131
module Header
3232
module ProjectsHelper
33-
def project_node_label(project, favorited: false)
34-
parts = [project.name]
33+
def project_node_label(project, favorited: false, query_terms: [])
34+
name_html = query_terms.any? ? content_tag(:span, highlight_name(project.name, query_terms)) : project.name
35+
parts = [name_html]
3536
parts << favorite_icon if favorited
3637
parts << workspace_type_badge(project) if show_workspace_type_badge?(project)
3738

@@ -41,6 +42,55 @@ def project_node_label(project, favorited: false)
4142

4243
private
4344

45+
def highlight_name(name, query_terms)
46+
ranges = find_highlight_ranges(name, query_terms)
47+
return h(name) if ranges.empty?
48+
49+
build_highlighted_segments(name, merge_highlight_ranges(ranges))
50+
end
51+
52+
def find_highlight_ranges(name, query_terms)
53+
query_terms.flat_map { |term| occurrences_of(term, in_string: name) }
54+
end
55+
56+
# Returns all character ranges where +term+ appears case-insensitively in +in_string+.
57+
def occurrences_of(term, in_string:)
58+
regex = Regexp.new(Regexp.escape(term), Regexp::IGNORECASE)
59+
ranges = []
60+
start = 0
61+
while (match = regex.match(in_string, start))
62+
ranges << (match.begin(0)...match.end(0))
63+
start = match.begin(0) + 1
64+
end
65+
ranges
66+
end
67+
68+
# Splits +name+ into plain-text and highlighted segments according to +ranges+,
69+
# then joins them into a single HTML-safe string.
70+
def build_highlighted_segments(name, ranges)
71+
pos = 0
72+
segments = ranges.flat_map do |range|
73+
before = pos < range.begin ? h(name[pos...range.begin]) : nil
74+
highlighted = content_tag(:span, name[range], class: "op-search-highlight")
75+
pos = range.end
76+
[before, highlighted].compact
77+
end
78+
segments << h(name[pos..]) if pos < name.length
79+
safe_join(segments)
80+
end
81+
82+
# Merges overlapping or adjacent ranges into a minimal set of non-overlapping ranges.
83+
def merge_highlight_ranges(ranges)
84+
ranges.sort_by(&:begin).each_with_object([]) do |range, merged|
85+
if merged.empty? || range.begin >= merged.last.end
86+
merged << range
87+
else
88+
last = merged.last
89+
merged[-1] = (last.begin...[last.end, range.end].max)
90+
end
91+
end
92+
end
93+
4494
def favorite_icon
4595
render(Primer::Beta::Octicon.new(icon: :"star-fill", size: :small, classes: "op-primer--star-icon", ml: 2))
4696
end

app/views/header/projects/index.html.erb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,8 @@ See COPYRIGHT and LICENSE files for more details.
4747
node:,
4848
current_project_id: @current_project_id,
4949
favorited_ids: @favorited_ids,
50-
jump: @jump
50+
jump: @jump,
51+
query_terms: @query_terms
5152
) %>
5253
<% end %>
5354
<% end %>

spec/features/projects/navigation_spec.rb

Lines changed: 45 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@
3939
})
4040
end
4141
shared_let(:admin) { create(:admin) }
42+
shared_let(:portfolio_project) { create(:portfolio, name: "Test Portfolio") }
43+
shared_let(:program_project) { create(:program, name: "Test Program") }
4244

4345
let(:top_menu) { Components::Projects::TopMenu.new }
4446

@@ -89,11 +91,49 @@
8991
end
9092
end
9193

92-
context "with workspace type badges in project dropdown" do
93-
shared_let(:portfolio_project) { create(:portfolio, name: "Test Portfolio") }
94-
shared_let(:program_project) { create(:program, name: "Test Program") }
95-
shared_let(:regular_project) { project }
94+
context "with search highlighting in the project dropdown" do
95+
before do
96+
login_as admin
97+
visit home_path
98+
top_menu.toggle!
99+
end
100+
101+
it "highlights the matching portion of a project name" do
102+
top_menu.search("Test")
103+
wait_for_network_idle
96104

105+
within top_menu.search_results do
106+
expect(page).to have_css(".op-search-highlight", text: "Test")
107+
end
108+
end
109+
110+
it "does not show highlight spans when no query is given" do
111+
within top_menu.search_results do
112+
expect(page).to have_no_css(".op-search-highlight")
113+
end
114+
end
115+
116+
it "highlights case-insensitively, preserving the original casing from the project name" do
117+
top_menu.search("test")
118+
wait_for_network_idle
119+
120+
within top_menu.search_results do
121+
expect(page).to have_css(".op-search-highlight", text: "Test")
122+
end
123+
end
124+
125+
it "highlights the name but not the workspace type badge" do
126+
top_menu.search("Portfolio")
127+
wait_for_network_idle
128+
129+
within top_menu.search_results do
130+
expect(page).to have_css(".op-search-highlight", text: "Portfolio")
131+
expect(page).to have_no_css(".description .op-search-highlight")
132+
end
133+
end
134+
end
135+
136+
context "with workspace type badges in project dropdown" do
97137
before do
98138
login_as admin
99139
visit home_path
@@ -103,7 +143,7 @@
103143
it "displays badges for portfolio and program workspaces but not for regular projects" do
104144
top_menu.expect_result(portfolio_project.name, workspace_badge: "Portfolio")
105145
top_menu.expect_result(program_project.name, workspace_badge: "Program")
106-
top_menu.expect_result(regular_project.name, workspace_badge: false)
146+
top_menu.expect_result(project.name, workspace_badge: false)
107147
end
108148
end
109149
end
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
# frozen_string_literal: true
2+
3+
#-- copyright
4+
# OpenProject is an open source project management software.
5+
# Copyright (C) the OpenProject GmbH
6+
#
7+
# This program is free software; you can redistribute it and/or
8+
# modify it under the terms of the GNU General Public License version 3.
9+
#
10+
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
11+
# Copyright (C) 2006-2013 Jean-Philippe Lang
12+
# Copyright (C) 2010-2013 the ChiliProject Team
13+
#
14+
# This program is free software; you can redistribute it and/or
15+
# modify it under the terms of the GNU General Public License
16+
# as published by the Free Software Foundation; either version 2
17+
# of the License, or (at your option) any later version.
18+
#
19+
# This program is distributed in the hope that it will be useful,
20+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
21+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
22+
# GNU General Public License for more details.
23+
#
24+
# You should have received a copy of the GNU General Public License
25+
# along with this program; if not, write to the Free Software
26+
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
27+
#
28+
# See COPYRIGHT and LICENSE files for more details.
29+
#++
30+
31+
require "spec_helper"
32+
33+
RSpec.describe Header::ProjectsHelper do
34+
# Shorthand for the expected highlight span markup.
35+
let(:hl) { ->(text) { %(<span class="op-search-highlight">#{text}</span>) } }
36+
37+
describe "#highlight_name" do
38+
subject(:result) { helper.send(:highlight_name, name, query_terms) }
39+
40+
context "with no query terms" do
41+
let(:name) { "My Project" }
42+
let(:query_terms) { [] }
43+
44+
it "returns the plain name" do
45+
expect(result).to eq "My Project"
46+
end
47+
end
48+
49+
context "when no term matches" do
50+
let(:name) { "My Project" }
51+
let(:query_terms) { ["foo"] }
52+
53+
it "returns the plain name" do
54+
expect(result).to eq "My Project"
55+
end
56+
end
57+
58+
context "with a single matching term" do
59+
let(:name) { "My Project" }
60+
let(:query_terms) { ["Project"] }
61+
62+
it "wraps the match in a highlight span" do
63+
expect(result).to eq "My #{hl.call('Project')}"
64+
end
65+
end
66+
67+
context "with a match at the beginning of the name" do
68+
let(:name) { "Alpha Team" }
69+
let(:query_terms) { ["Alpha"] }
70+
71+
it { is_expected.to eq "#{hl.call('Alpha')} Team" }
72+
end
73+
74+
context "with a match at the end of the name" do
75+
let(:name) { "Team Alpha" }
76+
let(:query_terms) { ["Alpha"] }
77+
78+
it { is_expected.to eq "Team #{hl.call('Alpha')}" }
79+
end
80+
81+
context "when the term matches the full name" do
82+
let(:name) { "Alpha" }
83+
let(:query_terms) { ["Alpha"] }
84+
85+
it { is_expected.to eq hl.call("Alpha") }
86+
end
87+
88+
context "with case-insensitive matching" do
89+
let(:name) { "My PROJECT" }
90+
let(:query_terms) { ["project"] }
91+
92+
it "highlights the match using the original casing from the name" do
93+
expect(result).to eq "My #{hl.call('PROJECT')}"
94+
end
95+
end
96+
97+
context "with multiple occurrences of the same term" do
98+
let(:name) { "Foo and Foo" }
99+
let(:query_terms) { ["Foo"] }
100+
101+
it "highlights every occurrence" do
102+
expect(result).to eq "#{hl.call('Foo')} and #{hl.call('Foo')}"
103+
end
104+
end
105+
106+
context "with multiple non-overlapping terms" do
107+
let(:name) { "Alpha Beta" }
108+
let(:query_terms) { ["Alpha", "Beta"] }
109+
110+
it "highlights each term independently" do
111+
expect(result).to eq "#{hl.call('Alpha')} #{hl.call('Beta')}"
112+
end
113+
end
114+
115+
context "with overlapping term matches" do
116+
# "Over" covers indices 0..4, "erlap" covers 1..6 → merged to 0..6 = "Overlap".
117+
let(:name) { "Overlap" }
118+
let(:query_terms) { ["Over", "erlap"] }
119+
120+
it "merges the overlapping ranges into a single span" do
121+
expect(result).to eq hl.call("Overlap")
122+
end
123+
end
124+
125+
context "with HTML special characters in the name" do
126+
let(:name) { "A & B <Project>" }
127+
let(:query_terms) { ["Project"] }
128+
129+
it "escapes characters outside the match and leaves the span unescaped" do
130+
expect(result).to eq "A &amp; B &lt;#{hl.call('Project')}&gt;"
131+
end
132+
end
133+
end
134+
end

0 commit comments

Comments
 (0)