diff --git a/Gemfile b/Gemfile
index 324a2f52d554..2e3357ec8e86 100644
--- a/Gemfile
+++ b/Gemfile
@@ -433,5 +433,5 @@ end
source "https://rubygems.org", cooldown: 0 do
gem "openproject-octicons", "~>19.35.0"
gem "openproject-octicons_helper", "~>19.35.0"
- gem "openproject-primer_view_components", "~>0.88.0"
+ gem "openproject-primer_view_components", "~>0.89.0"
end
diff --git a/Gemfile.lock b/Gemfile.lock
index 9425c8e6422a..eeda4782509f 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -909,7 +909,7 @@ GEM
actionview
openproject-octicons (= 19.35.0)
railties
- openproject-primer_view_components (0.88.0)
+ openproject-primer_view_components (0.89.0)
actionview (>= 7.2.0)
activesupport (>= 7.2.0)
openproject-octicons (>= 19.34.0)
@@ -1688,7 +1688,7 @@ DEPENDENCIES
openproject-octicons (~> 19.35.0)!
openproject-octicons_helper (~> 19.35.0)!
openproject-openid_connect!
- openproject-primer_view_components (~> 0.88.0)!
+ openproject-primer_view_components (~> 0.89.0)!
openproject-recaptcha!
openproject-reporting!
openproject-resource_management!
@@ -2070,7 +2070,7 @@ CHECKSUMS
openproject-octicons (19.35.0) sha256=a5033550d0961b4a8cb0993512a899716d633e17c2b5147bc6a9ed74f3952b38
openproject-octicons_helper (19.35.0) sha256=c32d142a4bb7fda739b16768aa8846fd88ffc1750509d8056f516056e8767361
openproject-openid_connect (1.0.0)
- openproject-primer_view_components (0.88.0) sha256=be97f5e96258a738c4d95abbcb2909ed570dbb151d42a5a253c3d1de9c171e35
+ openproject-primer_view_components (0.89.0) sha256=de73dac8c53f39efbc0aa639eff0de2d84cb90ad6778be4a71eff72be2830d17
openproject-recaptcha (1.0.0)
openproject-reporting (1.0.0)
openproject-resource_management (1.0.0)
diff --git a/app/components/admin/settings/project_reserved_identifiers/sub_header_component.html.erb b/app/components/admin/settings/project_reserved_identifiers/sub_header_component.html.erb
index 36a1637df7de..1face6964a47 100644
--- a/app/components/admin/settings/project_reserved_identifiers/sub_header_component.html.erb
+++ b/app/components/admin/settings/project_reserved_identifiers/sub_header_component.html.erb
@@ -29,7 +29,7 @@
++#
%>
-<%= render(Primer::OpenProject::SubHeader.new(data: sub_header_data_attributes)) do |subheader| %>
+<%= render(Primer::OpenProject::SubHeader.new(collapsed_search: false, data: sub_header_data_attributes)) do |subheader| %>
<% subheader.with_filter_input(
name: "name",
label: t("admin.reserved_identifiers.filter_label"),
diff --git a/app/components/filter/filter_button_component.html.erb b/app/components/filter/filter_button_component.html.erb
index db2baaa23c7c..2f96643aa3c1 100644
--- a/app/components/filter/filter_button_component.html.erb
+++ b/app/components/filter/filter_button_component.html.erb
@@ -1,7 +1,7 @@
<%= component_wrapper tag: "turbo-frame" do %>
<%= render(
Primer::Beta::Button.new(
- scheme: :secondary,
+ scheme: :invisible,
disabled:,
data: { "filter--filters-form-target": "filterFormToggle",
action: "filter--filters-form#toggleDisplayFilters" },
@@ -9,6 +9,7 @@
)
) do |button| %>
<% button.with_trailing_visual_counter(count: filters_count, test_selector: "filters-button-counter") %>
- <%= t(:label_filter) %>
+ <% button.with_leading_visual_icon(icon: :filter) %>
+ <%= t(:button_all_filters) %>
<% end %>
<% end %>
diff --git a/app/components/header/projects/filterable_tree_view_component.html.erb b/app/components/header/projects/filterable_tree_view_component.html.erb
index 72decb91c72c..404c1f73b6d1 100644
--- a/app/components/header/projects/filterable_tree_view_component.html.erb
+++ b/app/components/header/projects/filterable_tree_view_component.html.erb
@@ -31,6 +31,7 @@ See COPYRIGHT and LICENSE files for more details.
<%= render(
Primer::OpenProject::FilterableTreeView.new(
src: tree_src,
+ show_search_highlighting: false,
include_sub_items_check_box_arguments: { hidden: true },
filter_mode_control_arguments: logged? ? {} : { hidden: true },
filter_input_arguments: { autofocus: true,
diff --git a/app/components/header/projects/node_component.html.erb b/app/components/header/projects/node_component.html.erb
index 740185e15f75..cb3bccb97521 100644
--- a/app/components/header/projects/node_component.html.erb
+++ b/app/components/header/projects/node_component.html.erb
@@ -57,7 +57,8 @@ See COPYRIGHT and LICENSE files for more details.
node: child_node,
current_project_id: @current_project_id,
favorited_ids: @favorited_ids,
- jump: @jump
+ jump: @jump,
+ query_terms: @query_terms
) %>
<% end %>
<% end %>
diff --git a/app/components/header/projects/node_component.rb b/app/components/header/projects/node_component.rb
index 7f1574cfb0df..ecfa4fa7064a 100644
--- a/app/components/header/projects/node_component.rb
+++ b/app/components/header/projects/node_component.rb
@@ -31,13 +31,14 @@
module Header
module Projects
class NodeComponent < ApplicationComponent
- def initialize(component:, node:, current_project_id:, favorited_ids:, jump:)
+ def initialize(component:, node:, current_project_id:, favorited_ids:, jump:, query_terms: [])
super()
@component = component
@node = node
@current_project_id = current_project_id
@favorited_ids = favorited_ids
@jump = jump
+ @query_terms = query_terms
end
private
@@ -54,7 +55,7 @@ def href
end
def label
- helpers.project_node_label(project, favorited: favorited?)
+ helpers.project_node_label(project, favorited: favorited?, query_terms: @query_terms)
end
end
end
diff --git a/app/components/members/index_sub_header_component.html.erb b/app/components/members/index_sub_header_component.html.erb
index ea8d711d7fd0..822a40d1b51b 100644
--- a/app/components/members/index_sub_header_component.html.erb
+++ b/app/components/members/index_sub_header_component.html.erb
@@ -1,25 +1,26 @@
<%= render(Primer::OpenProject::SubHeader.new) do |subheader|
- subheader.with_filter_button(
- id: "filter-member-button",
- class: "toggle-member-filter-link",
- data: filter_button_data_attributes
- ) do
- I18n.t(:description_filter)
- end
+ subheader.with_filter_button(
+ id: "filter-member-button",
+ class: "toggle-member-filter-link",
+ data: filter_button_data_attributes,
+ scheme: :secondary # Remove once there is a search in the SubHeader
+ ) do
+ I18n.t(:button_all_filters)
+ end
- if authorize_for(:members, :new)
- subheader.with_action_button(
- scheme: :primary,
- leading_icon: :plus,
- label: t(:button_add_member),
- id: "add-member-button",
- data: add_button_data_attributes
- ) do
- t("activerecord.models.member")
- end
- end
+ if authorize_for(:members, :new)
+ subheader.with_action_button(
+ scheme: :primary,
+ leading_icon: :plus,
+ label: t(:button_add_member),
+ id: "add-member-button",
+ data: add_button_data_attributes
+ ) do
+ t("activerecord.models.member")
+ end
+ end
- subheader.with_bottom_pane_component do
- render ::Members::UserFilterComponent.new(params, **@members_filter_options)
- end
-end %>
+ subheader.with_bottom_pane_component do
+ render ::Members::UserFilterComponent.new(params, **@members_filter_options)
+ end
+ end %>
diff --git a/app/components/projects/settings/life_cycle/index_component.html.erb b/app/components/projects/settings/life_cycle/index_component.html.erb
index c3b867a620e0..8a0f23adf763 100644
--- a/app/components/projects/settings/life_cycle/index_component.html.erb
+++ b/app/components/projects/settings/life_cycle/index_component.html.erb
@@ -1,7 +1,7 @@
<%=
flex_layout(data: wrapper_data_attributes) do |flex|
flex.with_row do
- render(Primer::OpenProject::SubHeader.new) do |subheader|
+ render(Primer::OpenProject::SubHeader.new(collapsed_search: false)) do |subheader|
subheader.with_filter_input(
name: "border-box-filter",
label: t("projects.settings.life_cycle.filter.label"),
diff --git a/app/components/projects/settings/project_custom_field_sections/index_component.html.erb b/app/components/projects/settings/project_custom_field_sections/index_component.html.erb
index ef6c8033a656..8eecd6ade29d 100644
--- a/app/components/projects/settings/project_custom_field_sections/index_component.html.erb
+++ b/app/components/projects/settings/project_custom_field_sections/index_component.html.erb
@@ -2,7 +2,7 @@
component_wrapper(data: wrapper_data_attributes) do
flex_layout do |flex|
flex.with_row do
- render(Primer::OpenProject::SubHeader.new) do |subheader|
+ render(Primer::OpenProject::SubHeader.new(collapsed_search: false)) do |subheader|
subheader.with_filter_input(
name: "border-box-filter",
label: t("projects.settings.project_custom_fields.filter.label"),
diff --git a/app/components/settings/project_phase_definitions/index_component.html.erb b/app/components/settings/project_phase_definitions/index_component.html.erb
index 70146686e5a1..7f11c498a5b2 100644
--- a/app/components/settings/project_phase_definitions/index_component.html.erb
+++ b/app/components/settings/project_phase_definitions/index_component.html.erb
@@ -41,7 +41,7 @@ See COPYRIGHT and LICENSE files for more details.
if allowed_to_customize_life_cycle?
flex.with_row do
- render(Primer::OpenProject::SubHeader.new) do |subheader|
+ render(Primer::OpenProject::SubHeader.new(collapsed_search: false)) do |subheader|
subheader.with_filter_input(
name: "border-box-filter",
label: t("settings.project_phase_definitions.filter.label"),
diff --git a/app/components/users/index_sub_header_component.html.erb b/app/components/users/index_sub_header_component.html.erb
index 63f31ca433a2..87e32613d848 100644
--- a/app/components/users/index_sub_header_component.html.erb
+++ b/app/components/users/index_sub_header_component.html.erb
@@ -1,5 +1,5 @@
<%=
- render(Primer::OpenProject::SubHeader.new(collapsed_search: true, data: sub_header_data_attributes)) do |subheader|
+ render(Primer::OpenProject::SubHeader.new(data: sub_header_data_attributes)) do |subheader|
subheader.with_action_button(
scheme: :primary,
leading_icon: :plus,
diff --git a/app/controllers/header/projects_controller.rb b/app/controllers/header/projects_controller.rb
index b304b96367e7..dda501b4ea97 100644
--- a/app/controllers/header/projects_controller.rb
+++ b/app/controllers/header/projects_controller.rb
@@ -37,6 +37,7 @@ class Header::ProjectsController < ApplicationController
def index
@current_project_id = params[:current_project_id].presence&.to_i
@jump = params[:jump].presence
+ @query_terms = query.split
@projects = load_projects
@favorited_ids = load_favorited_ids
@tree = build_tree(@projects)
diff --git a/app/helpers/header/projects_helper.rb b/app/helpers/header/projects_helper.rb
index 1a22579784a7..90333215e0ef 100644
--- a/app/helpers/header/projects_helper.rb
+++ b/app/helpers/header/projects_helper.rb
@@ -30,8 +30,9 @@
module Header
module ProjectsHelper
- def project_node_label(project, favorited: false)
- parts = [project.name]
+ def project_node_label(project, favorited: false, query_terms: [])
+ name_html = query_terms.any? ? content_tag(:span, highlight_name(project.name, query_terms)) : project.name
+ parts = [name_html]
parts << favorite_icon if favorited
parts << workspace_type_badge(project) if show_workspace_type_badge?(project)
@@ -41,6 +42,55 @@ def project_node_label(project, favorited: false)
private
+ def highlight_name(name, query_terms)
+ ranges = find_highlight_ranges(name, query_terms)
+ return h(name) if ranges.empty?
+
+ build_highlighted_segments(name, merge_highlight_ranges(ranges))
+ end
+
+ def find_highlight_ranges(name, query_terms)
+ query_terms.flat_map { |term| occurrences_of(term, in_string: name) }
+ end
+
+ # Returns all character ranges where +term+ appears case-insensitively in +in_string+.
+ def occurrences_of(term, in_string:)
+ regex = Regexp.new(Regexp.escape(term), Regexp::IGNORECASE)
+ ranges = []
+ start = 0
+ while (match = regex.match(in_string, start))
+ ranges << (match.begin(0)...match.end(0))
+ start = match.begin(0) + 1
+ end
+ ranges
+ end
+
+ # Splits +name+ into plain-text and highlighted segments according to +ranges+,
+ # then joins them into a single HTML-safe string.
+ def build_highlighted_segments(name, ranges)
+ pos = 0
+ segments = ranges.flat_map do |range|
+ before = pos < range.begin ? h(name[pos...range.begin]) : nil
+ highlighted = content_tag(:span, name[range], class: "op-search-highlight")
+ pos = range.end
+ [before, highlighted].compact
+ end
+ segments << h(name[pos..]) if pos < name.length
+ safe_join(segments)
+ end
+
+ # Merges overlapping or adjacent ranges into a minimal set of non-overlapping ranges.
+ def merge_highlight_ranges(ranges)
+ ranges.sort_by(&:begin).each_with_object([]) do |range, merged|
+ if merged.empty? || range.begin >= merged.last.end
+ merged << range
+ else
+ last = merged.last
+ merged[-1] = (last.begin...[last.end, range.end].max)
+ end
+ end
+ end
+
def favorite_icon
render(Primer::Beta::Octicon.new(icon: :"star-fill", size: :small, classes: "op-primer--star-icon", ml: 2))
end
diff --git a/app/views/header/projects/index.html.erb b/app/views/header/projects/index.html.erb
index a45b5de1ca6c..ecd3d6dc87f3 100644
--- a/app/views/header/projects/index.html.erb
+++ b/app/views/header/projects/index.html.erb
@@ -47,7 +47,8 @@ See COPYRIGHT and LICENSE files for more details.
node:,
current_project_id: @current_project_id,
favorited_ids: @favorited_ids,
- jump: @jump
+ jump: @jump,
+ query_terms: @query_terms
) %>
<% end %>
<% end %>
diff --git a/app/views/workflows/index.html.erb b/app/views/workflows/index.html.erb
index a5b538f9ec67..12864be76597 100644
--- a/app/views/workflows/index.html.erb
+++ b/app/views/workflows/index.html.erb
@@ -33,7 +33,7 @@ See COPYRIGHT and LICENSE files for more details.
<%=
flex_layout data: { controller: "filter--filter-list", "filter--filter-list-clear-button-id-value": "type-filter-clear-button" } do |flex|
flex.with_row do
- render(Primer::OpenProject::SubHeader.new) do |subheader|
+ render(Primer::OpenProject::SubHeader.new(collapsed_search: false)) do |subheader|
subheader.with_filter_input(
name: "type-filter-input",
label: t(".type_filter.label"),
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 8bed7205756c..7ce691e0182f 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -2902,6 +2902,7 @@ en:
button_add_sub_item: "Add sub-item"
button_add_member: Add member
button_add_watcher: "Add watcher"
+ button_all_filters: "All filters"
button_annotate: "Annotate"
button_apply: "Apply"
button_apply_changes: "Apply changes"
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index c0d256f8bb2e..43e555ad80fd 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -58,12 +58,12 @@
"@ng-select/ng-select": "^23.0.0",
"@ngneat/content-loader": "^7.0.0",
"@openproject/octicons-angular": "^19.35.0",
- "@openproject/primer-view-components": "^0.88.0",
+ "@openproject/primer-view-components": "^0.89.0",
"@openproject/reactivestates": "^3.0.1",
"@primer/css": "^22.1.0",
"@primer/live-region-element": "^0.8.0",
"@primer/primitives": "^11.5.1",
- "@primer/view-components": "npm:@openproject/primer-view-components@^0.88.0",
+ "@primer/view-components": "npm:@openproject/primer-view-components@^0.89.0",
"@rails/request.js": "^0.0.13",
"@stimulus-components/auto-submit": "^6.0.0",
"@stimulus-components/reveal": "^5.0.0",
@@ -5470,9 +5470,9 @@
}
},
"node_modules/@openproject/primer-view-components": {
- "version": "0.88.0",
- "resolved": "https://registry.npmjs.org/@openproject/primer-view-components/-/primer-view-components-0.88.0.tgz",
- "integrity": "sha512-GUM+2M/plb97HGa1tllrb33GvqSoWlx5G7brJTB4uMtR+TkeJxPxSce2QvUC9CZGugYL8UWwDfRNYafr3eH+Jw==",
+ "version": "0.89.0",
+ "resolved": "https://registry.npmjs.org/@openproject/primer-view-components/-/primer-view-components-0.89.0.tgz",
+ "integrity": "sha512-NGvr4l1WVkfims28iSJZl1+b8OTjSjGDzpRYNx6v4gKi60QZ4gCQbt0CvZa/r2c20b6q5+VdApe+bqmTzWI2aQ==",
"license": "MIT",
"dependencies": {
"@github/auto-check-element": "^6.0.0",
@@ -5884,9 +5884,9 @@
},
"node_modules/@primer/view-components": {
"name": "@openproject/primer-view-components",
- "version": "0.88.0",
- "resolved": "https://registry.npmjs.org/@openproject/primer-view-components/-/primer-view-components-0.88.0.tgz",
- "integrity": "sha512-GUM+2M/plb97HGa1tllrb33GvqSoWlx5G7brJTB4uMtR+TkeJxPxSce2QvUC9CZGugYL8UWwDfRNYafr3eH+Jw==",
+ "version": "0.89.0",
+ "resolved": "https://registry.npmjs.org/@openproject/primer-view-components/-/primer-view-components-0.89.0.tgz",
+ "integrity": "sha512-NGvr4l1WVkfims28iSJZl1+b8OTjSjGDzpRYNx6v4gKi60QZ4gCQbt0CvZa/r2c20b6q5+VdApe+bqmTzWI2aQ==",
"license": "MIT",
"dependencies": {
"@github/auto-check-element": "^6.0.0",
diff --git a/frontend/package.json b/frontend/package.json
index 16caf50cbb92..7809564eba5f 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -103,12 +103,12 @@
"@ng-select/ng-select": "^23.0.0",
"@ngneat/content-loader": "^7.0.0",
"@openproject/octicons-angular": "^19.35.0",
- "@openproject/primer-view-components": "^0.88.0",
+ "@openproject/primer-view-components": "^0.89.0",
"@openproject/reactivestates": "^3.0.1",
"@primer/css": "^22.1.0",
"@primer/live-region-element": "^0.8.0",
"@primer/primitives": "^11.5.1",
- "@primer/view-components": "npm:@openproject/primer-view-components@^0.88.0",
+ "@primer/view-components": "npm:@openproject/primer-view-components@^0.89.0",
"@rails/request.js": "^0.0.13",
"@stimulus-components/auto-submit": "^6.0.0",
"@stimulus-components/reveal": "^5.0.0",
diff --git a/lookbook/docs/components/sub-header.md.erb b/lookbook/docs/components/sub-header.md.erb
index 24f47be2f9ad..15db8357f42b 100644
--- a/lookbook/docs/components/sub-header.md.erb
+++ b/lookbook/docs/components/sub-header.md.erb
@@ -12,7 +12,7 @@ The SubHeader accepts a number of additional components. **All buttons** within
On the left side:
-- **Search filter**: (Optional) A basic [text input](https://primer.style/components/text-input) with a [search icon](https://primer.style/components/text-input#with-leading-and-trailing-visuals) which is mostly used on index pages to filter the elements below.
+- **Search filter**: (Optional) A basic [text input](https://primer.style/components/text-input) with a [search icon](https://primer.style/components/text-input#with-leading-and-trailing-visuals) which is mostly used on index pages to filter the elements below. Per default, the search input is collapsed to an icon button and changes on click to a text input. To avoid this, use `collapsed_search: false`.
- **Sort action** (Optional) A generic slot for a sort button component. It is recommended to use IconButtons here.
- **Group action** (Optional) A generic slot for a group button component. It is recommended to use IconButtons here.
- **Quick filters** (Optional) Up to 5 QuickFilter can be rendered here. When using more then one quick filter, an `All Filter` button is required.
@@ -45,6 +45,7 @@ The SubHeader automatically adapts to mobile screens:
- Use IconButtons instead of Buttons for actions only when the icon is clear enough to describe the action.
- When more actions are necessary, or if a menu is needed, use an ActionMenu instead of the ActionButtons.
- Use IconButtons for the sort and group components.
+- Use `collapsed_search: false` when the left side only shows the search input and no further filter actions.
**Don't**
@@ -161,9 +162,17 @@ The `with_quick_filter` slot only offers a generic slot for a quick filter takin
# The all filters button is required when using more than one quick filter
- component.with_filter_button(mobile_label: "All filters") do
+ component.with_filter_component(mobile_label: "All filters") do
render(Foo::AllFilterComponent.new(query: @query, project: @project))
end
end
%>
```
+
+### All filters button
+
+The "All filters" button is required when using more than one quick filter. It can be rendered either via the `with_filter_button` slot or as a component via `with_filter_component`. When using the latter, you have to manually make sure that the button fulfills the following requirements:
+
+* Use `invisible` scheme
+* Use `icon: :filter`
+* Use `label: "All filters"`
diff --git a/lookbook/previews/open_project/common/sub_header_preview.rb b/lookbook/previews/open_project/common/sub_header_preview.rb
index 2fb348cfbdb2..b958ca88e879 100644
--- a/lookbook/previews/open_project/common/sub_header_preview.rb
+++ b/lookbook/previews/open_project/common/sub_header_preview.rb
@@ -37,7 +37,7 @@ def default
component.with_filter_input(name: "filter", label: "Filter")
component.with_filter_button do |button|
button.with_trailing_visual_counter(count: "15")
- "Filter"
+ "All Filters"
end
component.with_action_button(
leading_icon: :plus,
@@ -60,7 +60,7 @@ def playground(show_filter_input: true, show_filter_button: true, show_action_bu
if show_filter_button
component.with_filter_button do |button|
button.with_trailing_visual_counter(count: "15")
- "Filter"
+ "All filters"
end
end
diff --git a/lookbook/previews/open_project/filter/simple_list_filter_preview/default.html.erb b/lookbook/previews/open_project/filter/simple_list_filter_preview/default.html.erb
index 7ed916d7c90c..5f26f00e671a 100644
--- a/lookbook/previews/open_project/filter/simple_list_filter_preview/default.html.erb
+++ b/lookbook/previews/open_project/filter/simple_list_filter_preview/default.html.erb
@@ -2,7 +2,7 @@
data-controller="filter--filter-list"
data-filter--filter-list-clear-button-id-value="ice-cream-filter-clear-button">
<%=
- render(Primer::OpenProject::SubHeader.new) do |subheader|
+ render(Primer::OpenProject::SubHeader.new(collapsed_search: false)) do |subheader|
subheader.with_filter_input(
name: "ice-cream-filter-input",
label: "Search ice cream flavor",
diff --git a/modules/documents/app/components/documents/sub_header_component.html.erb b/modules/documents/app/components/documents/sub_header_component.html.erb
index 8fb2175254aa..609ff4e20b97 100644
--- a/modules/documents/app/components/documents/sub_header_component.html.erb
+++ b/modules/documents/app/components/documents/sub_header_component.html.erb
@@ -1,5 +1,5 @@
<%=
- render(Primer::OpenProject::SubHeader.new(data: sub_header_data_attributes)) do |subheader|
+ render(Primer::OpenProject::SubHeader.new(collapsed_search: false, data: sub_header_data_attributes)) do |subheader|
subheader.with_filter_input(
name: :title,
label: I18n.t("documents.subheader.filter.label"),
diff --git a/modules/resource_management/app/components/resource_planners/index_sub_header_component.html.erb b/modules/resource_management/app/components/resource_planners/index_sub_header_component.html.erb
index 98e525a7335e..32a32ddc5946 100644
--- a/modules/resource_management/app/components/resource_planners/index_sub_header_component.html.erb
+++ b/modules/resource_management/app/components/resource_planners/index_sub_header_component.html.erb
@@ -31,6 +31,7 @@ See COPYRIGHT and LICENSE files for more details.
subheader.with_filter_button(
icon_only: true,
leading_icon: :search,
+ scheme: :default,
mobile_label: t(:label_filter)
)
diff --git a/spec/features/portfolios/index_spec.rb b/spec/features/portfolios/index_spec.rb
index 6e131b1ef37f..8d4b417a725a 100644
--- a/spec/features/portfolios/index_spec.rb
+++ b/spec/features/portfolios/index_spec.rb
@@ -156,6 +156,7 @@
end
it "lets you apply filters" do
+ click_button accessible_name: "Portfolio name filter"
portfolios_page.filter_by_name_and_identifier("Favorited")
portfolios_page.expect_portfolios_listed(portfolio_favorited)
portfolios_page.expect_portfolios_not_listed(portfolio_a, portfolio_b, inactive_portfolio)
diff --git a/spec/features/projects/export_spec.rb b/spec/features/projects/export_spec.rb
index c70daea54b27..0bc412ff3ce4 100644
--- a/spec/features/projects/export_spec.rb
+++ b/spec/features/projects/export_spec.rb
@@ -108,7 +108,7 @@ def export!(expect_success: true)
it "exports with that filter" do
index_page.expect_projects_listed(important_project, party_project)
- index_page.open_filters
+ click_button accessible_name: "Project name filter"
index_page.set_filter("name_and_identifier",
"Name or identifier",
diff --git a/spec/features/projects/lists/filters_spec.rb b/spec/features/projects/lists/filters_spec.rb
index f09208905a9e..5f8e7f61e676 100644
--- a/spec/features/projects/lists/filters_spec.rb
+++ b/spec/features/projects/lists/filters_spec.rb
@@ -84,6 +84,7 @@ def load_and_open_filters(user)
it "only shows the matching projects and filters" do
load_and_open_filters admin
+ click_button accessible_name: "Project name filter"
projects_page.filter_by_name_and_identifier("Plain")
# Filter is applied: Only the project that contains the the word "Plain" gets listed
@@ -98,6 +99,7 @@ def load_and_open_filters(user)
load_and_open_filters admin
# Filter on model attribute 'name'
+ click_button accessible_name: "Project name filter"
projects_page.filter_by_name_and_identifier("Plain")
wait_for_reload
@@ -727,6 +729,7 @@ def load_and_open_filters(user)
# Applies the filters to the filters section
projects_page.expect_filter_set "active"
+ click_button accessible_name: "Project name filter"
projects_page.expect_filter_set "name_and_identifier"
# Columns are taken from the default set as defined by the setting
diff --git a/spec/features/projects/lists/table_spec.rb b/spec/features/projects/lists/table_spec.rb
index a1f934292053..88c0b08ee9dc 100644
--- a/spec/features/projects/lists/table_spec.rb
+++ b/spec/features/projects/lists/table_spec.rb
@@ -443,6 +443,7 @@ def load_and_open_filters(user)
it "keeps applied filters, orders and columns" do
load_and_open_filters admin
+ click_button accessible_name: "Project name filter"
projects_page.filter_by_name_and_identifier("project")
wait_for_reload
diff --git a/spec/features/projects/navigation_spec.rb b/spec/features/projects/navigation_spec.rb
index 39c42211fca9..fdb1b3901960 100644
--- a/spec/features/projects/navigation_spec.rb
+++ b/spec/features/projects/navigation_spec.rb
@@ -39,6 +39,8 @@
})
end
shared_let(:admin) { create(:admin) }
+ shared_let(:portfolio_project) { create(:portfolio, name: "Test Portfolio") }
+ shared_let(:program_project) { create(:program, name: "Test Program") }
let(:top_menu) { Components::Projects::TopMenu.new }
@@ -89,11 +91,49 @@
end
end
- context "with workspace type badges in project dropdown" do
- shared_let(:portfolio_project) { create(:portfolio, name: "Test Portfolio") }
- shared_let(:program_project) { create(:program, name: "Test Program") }
- shared_let(:regular_project) { project }
+ context "with search highlighting in the project dropdown" do
+ before do
+ login_as admin
+ visit home_path
+ top_menu.toggle!
+ end
+
+ it "highlights the matching portion of a project name" do
+ top_menu.search("Test")
+ wait_for_network_idle
+ within top_menu.search_results do
+ expect(page).to have_css(".op-search-highlight", text: "Test")
+ end
+ end
+
+ it "does not show highlight spans when no query is given" do
+ within top_menu.search_results do
+ expect(page).to have_no_css(".op-search-highlight")
+ end
+ end
+
+ it "highlights case-insensitively, preserving the original casing from the project name" do
+ top_menu.search("test")
+ wait_for_network_idle
+
+ within top_menu.search_results do
+ expect(page).to have_css(".op-search-highlight", text: "Test")
+ end
+ end
+
+ it "highlights the name but not the workspace type badge" do
+ top_menu.search("Portfolio")
+ wait_for_network_idle
+
+ within top_menu.search_results do
+ expect(page).to have_css(".op-search-highlight", text: "Portfolio")
+ expect(page).to have_no_css(".description .op-search-highlight")
+ end
+ end
+ end
+
+ context "with workspace type badges in project dropdown" do
before do
login_as admin
visit home_path
@@ -103,7 +143,7 @@
it "displays badges for portfolio and program workspaces but not for regular projects" do
top_menu.expect_result(portfolio_project.name, workspace_badge: "Portfolio")
top_menu.expect_result(program_project.name, workspace_badge: "Program")
- top_menu.expect_result(regular_project.name, workspace_badge: false)
+ top_menu.expect_result(project.name, workspace_badge: false)
end
end
end
diff --git a/spec/helpers/header/projects_helper_spec.rb b/spec/helpers/header/projects_helper_spec.rb
new file mode 100644
index 000000000000..50321c896694
--- /dev/null
+++ b/spec/helpers/header/projects_helper_spec.rb
@@ -0,0 +1,134 @@
+# frozen_string_literal: true
+
+#-- copyright
+# OpenProject is an open source project management software.
+# Copyright (C) the OpenProject GmbH
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License version 3.
+#
+# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
+# Copyright (C) 2006-2013 Jean-Philippe Lang
+# Copyright (C) 2010-2013 the ChiliProject Team
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# 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 General Public License for more details.
+#
+# You should have received a copy of the GNU 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.
+#
+# See COPYRIGHT and LICENSE files for more details.
+#++
+
+require "spec_helper"
+
+RSpec.describe Header::ProjectsHelper do
+ # Shorthand for the expected highlight span markup.
+ let(:hl) { ->(text) { %(#{text}) } }
+
+ describe "#highlight_name" do
+ subject(:result) { helper.send(:highlight_name, name, query_terms) }
+
+ context "with no query terms" do
+ let(:name) { "My Project" }
+ let(:query_terms) { [] }
+
+ it "returns the plain name" do
+ expect(result).to eq "My Project"
+ end
+ end
+
+ context "when no term matches" do
+ let(:name) { "My Project" }
+ let(:query_terms) { ["foo"] }
+
+ it "returns the plain name" do
+ expect(result).to eq "My Project"
+ end
+ end
+
+ context "with a single matching term" do
+ let(:name) { "My Project" }
+ let(:query_terms) { ["Project"] }
+
+ it "wraps the match in a highlight span" do
+ expect(result).to eq "My #{hl.call('Project')}"
+ end
+ end
+
+ context "with a match at the beginning of the name" do
+ let(:name) { "Alpha Team" }
+ let(:query_terms) { ["Alpha"] }
+
+ it { is_expected.to eq "#{hl.call('Alpha')} Team" }
+ end
+
+ context "with a match at the end of the name" do
+ let(:name) { "Team Alpha" }
+ let(:query_terms) { ["Alpha"] }
+
+ it { is_expected.to eq "Team #{hl.call('Alpha')}" }
+ end
+
+ context "when the term matches the full name" do
+ let(:name) { "Alpha" }
+ let(:query_terms) { ["Alpha"] }
+
+ it { is_expected.to eq hl.call("Alpha") }
+ end
+
+ context "with case-insensitive matching" do
+ let(:name) { "My PROJECT" }
+ let(:query_terms) { ["project"] }
+
+ it "highlights the match using the original casing from the name" do
+ expect(result).to eq "My #{hl.call('PROJECT')}"
+ end
+ end
+
+ context "with multiple occurrences of the same term" do
+ let(:name) { "Foo and Foo" }
+ let(:query_terms) { ["Foo"] }
+
+ it "highlights every occurrence" do
+ expect(result).to eq "#{hl.call('Foo')} and #{hl.call('Foo')}"
+ end
+ end
+
+ context "with multiple non-overlapping terms" do
+ let(:name) { "Alpha Beta" }
+ let(:query_terms) { ["Alpha", "Beta"] }
+
+ it "highlights each term independently" do
+ expect(result).to eq "#{hl.call('Alpha')} #{hl.call('Beta')}"
+ end
+ end
+
+ context "with overlapping term matches" do
+ # "Over" covers indices 0..4, "erlap" covers 1..6 → merged to 0..6 = "Overlap".
+ let(:name) { "Overlap" }
+ let(:query_terms) { ["Over", "erlap"] }
+
+ it "merges the overlapping ranges into a single span" do
+ expect(result).to eq hl.call("Overlap")
+ end
+ end
+
+ context "with HTML special characters in the name" do
+ let(:name) { "A & B " }
+ let(:query_terms) { ["Project"] }
+
+ it "escapes characters outside the match and leaves the span unescaped" do
+ expect(result).to eq "A & B <#{hl.call('Project')}>"
+ end
+ end
+ end
+end