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