From 09bd037e8d5455fcc73ed012f8908cc62bc7a75e Mon Sep 17 00:00:00 2001 From: Henriette Darge Date: Fri, 8 May 2026 14:30:53 +0200 Subject: [PATCH 1/9] Use new async FilterableTreeView for global project selector --- app/components/_index.sass | 1 + .../header/project_select_component.html.erb | 81 ++++++++ .../header/project_select_component.rb | 61 ++++++ .../header/project_select_component.sass | 41 ++++ app/controllers/header/projects_controller.rb | 111 +++++++++++ app/views/header/projects/_node.html.erb | 82 ++++++++ app/views/header/projects/index.html.erb | 44 +++++ config/locales/en.yml | 8 + config/routes.rb | 4 + frontend/package-lock.json | 64 ++++--- .../src/global_styles/layout/_main_menu.sass | 6 +- .../src/global_styles/primer/_overrides.sass | 9 + .../header-project-select.controller.ts | 42 ++++ frontend/src/stimulus/setup.ts | 2 + .../menu_manager/top_menu/projects_menu.rb | 8 +- .../tree-view/filterable-tree-view.md.erb | 179 +++++++++++++++++- .../features/menu_items/top_menu_item_spec.rb | 4 +- 17 files changed, 704 insertions(+), 43 deletions(-) create mode 100644 app/components/header/project_select_component.html.erb create mode 100644 app/components/header/project_select_component.rb create mode 100644 app/components/header/project_select_component.sass create mode 100644 app/controllers/header/projects_controller.rb create mode 100644 app/views/header/projects/_node.html.erb create mode 100644 app/views/header/projects/index.html.erb create mode 100644 frontend/src/stimulus/controllers/header-project-select.controller.ts diff --git a/app/components/_index.sass b/app/components/_index.sass index db76bc961b5e..20cbdad86e5c 100644 --- a/app/components/_index.sass +++ b/app/components/_index.sass @@ -43,6 +43,7 @@ @import "work_packages/progress/modal_body_component" @import "work_packages/reminder/modal_body_component" @import "work_packages/split_view_component" +@import "header/project_select_component" @import "homescreen/link_component" @import "homescreen/links_component" @import "homescreen/new_features_component" diff --git a/app/components/header/project_select_component.html.erb b/app/components/header/project_select_component.html.erb new file mode 100644 index 000000000000..3e1435420457 --- /dev/null +++ b/app/components/header/project_select_component.html.erb @@ -0,0 +1,81 @@ +<%#-- 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. + +++#%> + +<%= render( + Primer::Alpha::Overlay.new( + title: t("header_project_select.title"), + visually_hide_title: true, + anchor_side: :outside_bottom, + anchor_align: :start, + size: :auto, + padding: :none + ) + ) do |overlay| %> + <% overlay.with_show_button( + id: "projects-menu", + classes: "op-project-select--trigger-button", + scheme: :invisible, + data: { "test-selector": "op-projects-menu" } + ) do |button| %> + <%= trigger_label %> + <% button.with_trailing_visual_icon(icon: :"triangle-down") %> + <% end %> + + <% overlay.with_body(classes: "op-project-select--body", data: { controller: "header-project-select" }) do %> + <%= render( + Primer::OpenProject::FilterableTreeView.new( + src: tree_src, + include_sub_items_check_box_arguments: { hidden: true }, + filter_mode_control_arguments: logged_in? ? {} : { hidden: true } + ) + ) do |tree_view| %> + <% if logged_in? %> + <% tree_view.with_filter_mode(name: "all", label: t("filterable_tree_view.filter_mode.all"), selected: true) %> + <% tree_view.with_filter_mode(name: "favorited", label: t("header_project_select.favorites")) %> + <% end %> + <% end %> + + <% unless OpenProject::FeatureDecisions.portfolio_models_active? %> + <%= helpers.flex_layout(justify_content: :flex_end, p: 2) do |bar| %> + <% bar.with_column(mr: 2) do %> + <%= render(Primer::Beta::Button.new(tag: :a, href: projects_path)) do %> + <%= t("header_project_select.all_projects") %> + <% end %> + <% end %> + <% if can_create_projects? %> + <% bar.with_column do %> + <%= render(Primer::Beta::Button.new(tag: :a, href: new_project_path, scheme: :primary)) do %> + <%= t("header_project_select.new_project") %> + <% end %> + <% end %> + <% end %> + <% end %> + <% end %> + <% end %> +<% end %> diff --git a/app/components/header/project_select_component.rb b/app/components/header/project_select_component.rb new file mode 100644 index 000000000000..5c63a29c0555 --- /dev/null +++ b/app/components/header/project_select_component.rb @@ -0,0 +1,61 @@ +# 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. +#++ + +module Header + class ProjectSelectComponent < ApplicationComponent + include OpenProject::StaticRouting::UrlHelpers + + def initialize(current_project: nil, current_user: User.current, current_menu_item: nil) + super() + @current_project = current_project + @current_user = current_user + @current_menu_item = current_menu_item + end + + def trigger_label + @current_project&.name || I18n.t("header_project_select.trigger_no_project") + end + + def tree_src + header_projects_path( + current_project_id: @current_project&.id, + jump: @current_menu_item.presence + ) + end + + def can_create_projects? + @current_user.allowed_globally?(:add_project) + end + + def logged_in? + @current_user.logged? + end + end +end diff --git a/app/components/header/project_select_component.sass b/app/components/header/project_select_component.sass new file mode 100644 index 000000000000..35c55bbfae9a --- /dev/null +++ b/app/components/header/project_select_component.sass @@ -0,0 +1,41 @@ +//-- 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. +//++ + +.op-project-select + &--body + max-height: 75dvh + overflow: hidden + margin: var(--base-size-16) + margin-right: 0 + + &--trigger-button + min-width: unset + display: block + + .Button-label + @include text-shortener diff --git a/app/controllers/header/projects_controller.rb b/app/controllers/header/projects_controller.rb new file mode 100644 index 000000000000..46f25b6cfd44 --- /dev/null +++ b/app/controllers/header/projects_controller.rb @@ -0,0 +1,111 @@ +# 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. +#++ + +class Header::ProjectsController < ApplicationController + no_authorization_required! :index + + MAX_NUMBER_OF_PROJECTS = 300 + + def index + @current_project_id = params[:current_project_id]&.to_i + @jump = params[:jump].presence + @projects = load_projects + @favorited_ids = load_favorited_ids + @tree = build_tree(@projects) + + render layout: false + end + + private + + def query + params[:query].to_s.strip + end + + def filter_mode + params[:filter_mode].to_s + end + + def load_projects + scope = Project.visible.active.order(:lft).limit(MAX_NUMBER_OF_PROJECTS) + + scope = scope.where("LOWER(name) LIKE LOWER(?)", "%#{ActiveRecord::Base.sanitize_sql_like(query)}%") if query.present? + + if filter_mode == "favorited" && User.current.logged? + favorite_ids = Favorite.where(favorited_type: "Project", user_id: User.current.id).select(:favorited_id) + scope = scope.where(id: favorite_ids) + end + + projects = scope.to_a + + # When not searching, ensure the current project is always present in the result + # even if it sits beyond the MAX_NUMBER_OF_PROJECTS limit (e.g. large instances). + if query.blank? && @current_project_id.present? && projects.none? { |p| p.id == @current_project_id } + current = Project.visible.active.find_by(id: @current_project_id) + + if current + # Include its ancestors so the tree path is intact. + extras = current.self_and_ancestors.active.to_a + projects = (projects + extras).uniq(&:id).sort_by(&:lft) + end + end + + projects + end + + def load_favorited_ids + return Set.new unless User.current.logged? + + Favorite + .where(favorited_type: "Project", user_id: User.current.id, favorited_id: @projects.map(&:id)) + .pluck(:favorited_id) + .to_set + end + + # Builds a nested structure from a flat, lft-ordered list of projects. + # Projects whose parent is not in the result set appear as roots. + def build_tree(projects) + nodes = projects.index_by(&:id).transform_values { |p| { project: p, children: [] } } + + roots = [] + projects.each do |project| + node = nodes[project.id] + parent = nodes[project.parent_id] + + if parent + parent[:children] << node + else + roots << node + end + end + + roots + end +end diff --git a/app/views/header/projects/_node.html.erb b/app/views/header/projects/_node.html.erb new file mode 100644 index 000000000000..94ba85c180cf --- /dev/null +++ b/app/views/header/projects/_node.html.erb @@ -0,0 +1,82 @@ +<%#-- 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. + +++#%> + +<% project = node[:project] %> +<% children = node[:children] %> +<% is_current = project.id == current_project_id %> +<% is_favorited = favorited_ids.include?(project.id) %> +<% href = jump.present? ? project_path(project.identifier, jump:) : project_path(project.identifier) %> +<% subtree_contains_current = ->(n) { n[:project].id == current_project_id || n[:children].any? { |c| subtree_contains_current.call(c) } } %> +<% is_expanded = subtree_contains_current.call(node) %> + +<% add_icons = ->(tree_node) do + if is_current + tree_node.with_trailing_action_button( + icon: :"x-circle", + tag: :a, + href: home_path(jump:), + show_tooltip: false, + aria: { label: t("header_project_select.leave_project") } + ) + end + if is_favorited + tree_node.with_trailing_visual_icon(icon: :"star-fill", classes: "op-primer--star-icon") + end + end %> + +<% if children.any? %> + <% component.with_sub_tree( + label: project.name, + select_variant: :none, + current: is_current, + expanded: is_expanded, + href:, + data: { node_id: project.id } + ) do |sub| %> + <% add_icons.call(sub) %> + <% children.each do |child_node| %> + <%= render "header/projects/node", + component: sub, + node: child_node, + current_project_id: current_project_id, + favorited_ids: favorited_ids, + jump: %> + <% end %> + <% end %> +<% else %> + <% component.with_leaf( + label: project.name, + select_variant: :none, + current: is_current, + href:, + data: { node_id: project.id } + ) do |leaf| %> + <% add_icons.call(leaf) %> + <% end %> +<% end %> diff --git a/app/views/header/projects/index.html.erb b/app/views/header/projects/index.html.erb new file mode 100644 index 000000000000..e58e600da780 --- /dev/null +++ b/app/views/header/projects/index.html.erb @@ -0,0 +1,44 @@ +<%#-- 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. + +++#%> + +<%= render( + Primer::Alpha::TreeView.new( + node_variant: :anchor, + data: { target: "filterable-tree-view.treeViewList" } + ) + ) do |tree| %> + <% @tree.each do |node| %> + <%= render "header/projects/node", + component: tree, + node: node, + current_project_id: @current_project_id, + favorited_ids: @favorited_ids, + jump: @jump %> + <% end %> +<% end %> diff --git a/config/locales/en.yml b/config/locales/en.yml index 71acc4bd1f07..0e1d126f8f31 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -3581,6 +3581,14 @@ en: catppt: "Catppt available (optional)" tesseract: "Tesseract available (optional)" + header_project_select: + all_projects: "All projects" + favorites: "Favorites" + leave_project: "Leave project" + new_project: "New project" + title: "Projects" + trigger_no_project: "Select a project" + filterable_tree_view: filter_mode: all: "All" diff --git a/config/routes.rb b/config/routes.rb index bbdbe14003ab..6925c5412f1f 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -306,6 +306,10 @@ resource :identifier_suggestion, only: %i[show], controller: "identifier_suggestion" end + namespace :header do + resources :projects, only: :index + end + %w[portfolio project program].each do |workspace_type| resources workspace_type.pluralize, only: %i[new create], diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d021ae4babd8..1bce3045b2b8 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -185,6 +185,9 @@ "fsevents": "*" } }, + "../../../Users/hdinger/code/primer_view_components": { + "extraneous": true + }, "node_modules/@algolia/abtesting": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/@algolia/abtesting/-/abtesting-1.14.1.tgz", @@ -5226,6 +5229,7 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/@github/auto-check-element/-/auto-check-element-6.0.0.tgz", "integrity": "sha512-87mHEywJEtlG/37zFrx4PUgDqczgtv9jrauW3IojNy9y+nALIAm6e2jnWpfgcqeMWSevzph2M6reJoHpuSjyWw==", + "license": "MIT", "dependencies": { "@github/mini-throttle": "^2.1.0" } @@ -5234,45 +5238,52 @@ "version": "3.8.0", "resolved": "https://registry.npmjs.org/@github/auto-complete-element/-/auto-complete-element-3.8.0.tgz", "integrity": "sha512-rS2Uj38V1BsenLvrIswV5IXfiYH2/KUhz6inot+JXho/fFOO+01tsW1HxqSdIXqh5EDuoY0f/GQsztZcH22AXQ==", + "license": "MIT", "dependencies": { "@github/combobox-nav": "^2.1.7" } }, "node_modules/@github/catalyst": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/@github/catalyst/-/catalyst-1.8.0.tgz", - "integrity": "sha512-uLpi/D/mKfylYaFLfzNuloXNENi0AlcM0Z7hwYLH8Z030jBCr+ueMdX2xLxCzpMH/keYXKh0uPrHSMfcbxU6KA==", + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@github/catalyst/-/catalyst-1.8.1.tgz", + "integrity": "sha512-dnN4WWpbeuQvA17LvsGdlXEueJdBk9y+I+WO5pdNpoHNOXPsFcz3hJrq1iRmdsNgQOf4S8e83axtwIxvG62eWA==", "license": "MIT" }, "node_modules/@github/clipboard-copy-element": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/@github/clipboard-copy-element/-/clipboard-copy-element-1.3.0.tgz", - "integrity": "sha512-wyntkQkwoLbLo+Hqg2LIVMXDIzcvUb9bSDz+clX6nVJItwzh103rHxdXFRZD+DmxVbuEW5xSznYQXkz1jZT+xg==" + "integrity": "sha512-wyntkQkwoLbLo+Hqg2LIVMXDIzcvUb9bSDz+clX6nVJItwzh103rHxdXFRZD+DmxVbuEW5xSznYQXkz1jZT+xg==", + "license": "MIT" }, "node_modules/@github/combobox-nav": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/@github/combobox-nav/-/combobox-nav-2.3.1.tgz", - "integrity": "sha512-gwxPzLw8XKecy1nP63i9lOBritS3bWmxl02UX6G0TwMQZbMem1BCS1tEZgYd3mkrkiDrUMWaX+DbFCuDFo3K+A==" + "integrity": "sha512-gwxPzLw8XKecy1nP63i9lOBritS3bWmxl02UX6G0TwMQZbMem1BCS1tEZgYd3mkrkiDrUMWaX+DbFCuDFo3K+A==", + "license": "MIT" }, "node_modules/@github/details-menu-element": { "version": "1.0.13", "resolved": "https://registry.npmjs.org/@github/details-menu-element/-/details-menu-element-1.0.13.tgz", - "integrity": "sha512-gMkii86w/oUP5dq8yOWZn1sgbgtFj3AYETxxtpsqRggZktgd8te4+npAn4Hm+936c/lxmEzXqfjARL/CzGR4+w==" + "integrity": "sha512-gMkii86w/oUP5dq8yOWZn1sgbgtFj3AYETxxtpsqRggZktgd8te4+npAn4Hm+936c/lxmEzXqfjARL/CzGR4+w==", + "license": "MIT" }, "node_modules/@github/image-crop-element": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/@github/image-crop-element/-/image-crop-element-5.0.0.tgz", - "integrity": "sha512-Vgm2OwWAs1ESoib/t5sjxsAYo6YTOxxAjWDRxswX7qrqoyCejTZ3hshdo4Ep5e+Mz/GVTZC3rdMtg06dk/eT4g==" + "integrity": "sha512-Vgm2OwWAs1ESoib/t5sjxsAYo6YTOxxAjWDRxswX7qrqoyCejTZ3hshdo4Ep5e+Mz/GVTZC3rdMtg06dk/eT4g==", + "license": "MIT" }, "node_modules/@github/include-fragment-element": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/@github/include-fragment-element/-/include-fragment-element-6.3.0.tgz", - "integrity": "sha512-BJTt8ZE/arsbC9lQtTH8c1hZS0ZigiN+kzH54ffQ6MhHLT83h0OpSdS9NEVocPl2uuO6w3qxnEKTDzUGMQ5rdQ==" + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/@github/include-fragment-element/-/include-fragment-element-6.4.1.tgz", + "integrity": "sha512-ffgXc7qwBtY/rYcMkAjxZJlyOPFaeC9K1Oc+n7Edwt3BAHPokUSdMfDivb+/dGO+NU2n7l1/L4v5uQN+wBeV4g==", + "license": "MIT" }, "node_modules/@github/mini-throttle": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/@github/mini-throttle/-/mini-throttle-2.1.1.tgz", - "integrity": "sha512-KtOPaB+FiKJ6jcKm9UKyaM5fPURHGf+xcp+b4Mzoi81hOc6M1sIGpMZMAVbNzfa2lW5+RPGKq888Px0j76OZ/A==" + "integrity": "sha512-KtOPaB+FiKJ6jcKm9UKyaM5fPURHGf+xcp+b4Mzoi81hOc6M1sIGpMZMAVbNzfa2lW5+RPGKq888Px0j76OZ/A==", + "license": "MIT" }, "node_modules/@github/relative-time-element": { "version": "5.0.0", @@ -5283,12 +5294,14 @@ "node_modules/@github/remote-input-element": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/@github/remote-input-element/-/remote-input-element-0.4.0.tgz", - "integrity": "sha512-apsMwsFW24F+w2wzT8oKoBi9lpm6GeFOmtuL+1YwDVmIiwixfHOD3MnEsEOv0RwmHsMdWmIjP9mxWyTWPKZHGg==" + "integrity": "sha512-apsMwsFW24F+w2wzT8oKoBi9lpm6GeFOmtuL+1YwDVmIiwixfHOD3MnEsEOv0RwmHsMdWmIjP9mxWyTWPKZHGg==", + "license": "MIT" }, "node_modules/@github/tab-container-element": { "version": "3.4.0", "resolved": "https://registry.npmjs.org/@github/tab-container-element/-/tab-container-element-3.4.0.tgz", - "integrity": "sha512-Yx70pO8A0p7Stnm9knKkUNX8i4bjuwDYZarRkM8JH0Z+ffhpe++oNAPbzGI9GEcGugRHvKuSC6p4YOdoHtTniQ==" + "integrity": "sha512-Yx70pO8A0p7Stnm9knKkUNX8i4bjuwDYZarRkM8JH0Z+ffhpe++oNAPbzGI9GEcGugRHvKuSC6p4YOdoHtTniQ==", + "license": "MIT" }, "node_modules/@github/webauthn-json": { "version": "2.1.1", @@ -8209,9 +8222,10 @@ "license": "MIT" }, "node_modules/@primer/behaviors": { - "version": "1.3.5", - "resolved": "https://registry.npmjs.org/@primer/behaviors/-/behaviors-1.3.5.tgz", - "integrity": "sha512-HWwz+6MrfK5NTWcg9GdKFpMBW/yrAV937oXiw2eDtsd88P3SRwoCt6ZO6QmKp9RP3nDU9cbqmuGZ0xBh0eIFeg==" + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/@primer/behaviors/-/behaviors-1.10.2.tgz", + "integrity": "sha512-93juWZbWg2DRhC11+7RT7hMpY1VD3lBosLmccqEZ65yrCHqkBCjI8Uj8wxs3y0U+wWE07LAoLHAPylyWbifg5A==", + "license": "MIT" }, "node_modules/@primer/css": { "version": "22.1.0", @@ -29668,9 +29682,9 @@ } }, "@github/catalyst": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/@github/catalyst/-/catalyst-1.8.0.tgz", - "integrity": "sha512-uLpi/D/mKfylYaFLfzNuloXNENi0AlcM0Z7hwYLH8Z030jBCr+ueMdX2xLxCzpMH/keYXKh0uPrHSMfcbxU6KA==" + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@github/catalyst/-/catalyst-1.8.1.tgz", + "integrity": "sha512-dnN4WWpbeuQvA17LvsGdlXEueJdBk9y+I+WO5pdNpoHNOXPsFcz3hJrq1iRmdsNgQOf4S8e83axtwIxvG62eWA==" }, "@github/clipboard-copy-element": { "version": "1.3.0", @@ -29693,9 +29707,9 @@ "integrity": "sha512-Vgm2OwWAs1ESoib/t5sjxsAYo6YTOxxAjWDRxswX7qrqoyCejTZ3hshdo4Ep5e+Mz/GVTZC3rdMtg06dk/eT4g==" }, "@github/include-fragment-element": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/@github/include-fragment-element/-/include-fragment-element-6.3.0.tgz", - "integrity": "sha512-BJTt8ZE/arsbC9lQtTH8c1hZS0ZigiN+kzH54ffQ6MhHLT83h0OpSdS9NEVocPl2uuO6w3qxnEKTDzUGMQ5rdQ==" + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/@github/include-fragment-element/-/include-fragment-element-6.4.1.tgz", + "integrity": "sha512-ffgXc7qwBtY/rYcMkAjxZJlyOPFaeC9K1Oc+n7Edwt3BAHPokUSdMfDivb+/dGO+NU2n7l1/L4v5uQN+wBeV4g==" }, "@github/mini-throttle": { "version": "2.1.1", @@ -31475,9 +31489,9 @@ "dev": true }, "@primer/behaviors": { - "version": "1.3.5", - "resolved": "https://registry.npmjs.org/@primer/behaviors/-/behaviors-1.3.5.tgz", - "integrity": "sha512-HWwz+6MrfK5NTWcg9GdKFpMBW/yrAV937oXiw2eDtsd88P3SRwoCt6ZO6QmKp9RP3nDU9cbqmuGZ0xBh0eIFeg==" + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/@primer/behaviors/-/behaviors-1.10.2.tgz", + "integrity": "sha512-93juWZbWg2DRhC11+7RT7hMpY1VD3lBosLmccqEZ65yrCHqkBCjI8Uj8wxs3y0U+wWE07LAoLHAPylyWbifg5A==" }, "@primer/css": { "version": "22.1.0", diff --git a/frontend/src/global_styles/layout/_main_menu.sass b/frontend/src/global_styles/layout/_main_menu.sass index 18dc1dc80684..7e65a7f71b20 100644 --- a/frontend/src/global_styles/layout/_main_menu.sass +++ b/frontend/src/global_styles/layout/_main_menu.sass @@ -36,7 +36,7 @@ $arrow-left-width: 36px border-radius: var(--borderRadius-medium) @if $main-item border-width: 1px 1px 1px 5px - padding-left: 8px + padding-left: 8px !important @else border-width: 1px @@ -67,7 +67,7 @@ $arrow-left-width: 36px @include styled-scroll-bar padding: 0 var(--main-menu-x-spacing) var(--main-menu-x-spacing) var(--main-menu-x-spacing) - ul + ul:not(.TreeViewRootUlStyles) margin: 0 padding: 0 @@ -102,7 +102,7 @@ $arrow-left-width: 36px // children have no icon so we need to push them right. padding-left: var(--main-menu-x-spacing) - a:not(.Button) + a:not(.Button):not(.TreeViewItemContent) text-decoration: none line-height: var(--main-menu-item-height) position: relative diff --git a/frontend/src/global_styles/primer/_overrides.sass b/frontend/src/global_styles/primer/_overrides.sass index f9f3869f6c03..9c3a085ade21 100644 --- a/frontend/src/global_styles/primer/_overrides.sass +++ b/frontend/src/global_styles/primer/_overrides.sass @@ -218,3 +218,12 @@ ul.SegmentedControl, &:before animation: none clip-path: inset(0) + +// At some point in time, this needs be extracted into a proper component, +// something like `FilterableTreeViewSelectPanel` +.op-project-select--body + .FilterableTreeViewLayout + > .Stack + padding-right: var(--base-size-16) + .FilterableTreeViewTreeContainer + scrollbar-gutter: stable diff --git a/frontend/src/stimulus/controllers/header-project-select.controller.ts b/frontend/src/stimulus/controllers/header-project-select.controller.ts new file mode 100644 index 000000000000..efefac58001a --- /dev/null +++ b/frontend/src/stimulus/controllers/header-project-select.controller.ts @@ -0,0 +1,42 @@ +/* + * 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. + * + * See COPYRIGHT and LICENSE files for more details. + */ + +import { Controller } from '@hotwired/stimulus'; + +const STORAGE_KEY = 'openProject-project-select-display-mode'; + +export default class HeaderProjectSelectController extends Controller { + connect():void { + const stored = localStorage.getItem(STORAGE_KEY); + if (stored && stored !== 'all') { + // Defer until after all Stimulus controllers have connected (including + // filterable-tree-view), so its click listener is already set up when + // we programmatically activate the stored filter mode. + setTimeout(() => { + this.element + .querySelector(`[data-name="${stored}"]`) + ?.click(); + }, 0); + } + + this.element.addEventListener('click', this.onFilterModeClick); + } + + disconnect():void { + this.element.removeEventListener('click', this.onFilterModeClick); + } + + private onFilterModeClick = (event:MouseEvent):void => { + const button = (event.target as HTMLElement).closest('[data-name]'); + if (button?.dataset.name) { + localStorage.setItem(STORAGE_KEY, button.dataset.name); + } + }; +} diff --git a/frontend/src/stimulus/setup.ts b/frontend/src/stimulus/setup.ts index 2fa3ac2c0987..c99d9826a46c 100644 --- a/frontend/src/stimulus/setup.ts +++ b/frontend/src/stimulus/setup.ts @@ -28,6 +28,7 @@ import LazyPageController from './controllers/dynamic/work-packages/activities-t import EditablePageHeaderTitleController from './controllers/dynamic/editable-page-header-title.controller'; import WorkingHoursFormController from './controllers/dynamic/users/working-hours-form.controller'; import DailyRemindersController from './controllers/dynamic/my/daily-reminders.controller'; +import HeaderProjectSelectController from './controllers/header-project-select.controller'; import NonWorkingTimesController from './controllers/dynamic/users/non-working-times.controller'; import NonWorkingTimesFormController from './controllers/dynamic/users/non-working-times-form.controller'; import OpPasswordForceChangeController from './controllers/password-force-change.controller'; @@ -95,6 +96,7 @@ OpenProjectStimulusApplication.preregister('users--non-working-times', NonWorkin OpenProjectStimulusApplication.preregister('users--non-working-times-form', NonWorkingTimesFormController); OpenProjectStimulusApplication.preregister('password-force-change', OpPasswordForceChangeController); OpenProjectStimulusApplication.preregister('check-all', CheckAllController); +OpenProjectStimulusApplication.preregister('header-project-select', HeaderProjectSelectController); OpenProjectStimulusApplication.preregister('checkable', CheckableController); OpenProjectStimulusApplication.preregister('truncation', TruncationController); diff --git a/lib/redmine/menu_manager/top_menu/projects_menu.rb b/lib/redmine/menu_manager/top_menu/projects_menu.rb index 6673fb4cdbc4..f6dfc4cf1adc 100644 --- a/lib/redmine/menu_manager/top_menu/projects_menu.rb +++ b/lib/redmine/menu_manager/top_menu/projects_menu.rb @@ -37,9 +37,11 @@ def render_projects_top_menu_node private def render_projects_dropdown - content_tag(:div, class: "main-menu-item") do - angular_component_tag("opce-header-project-select") - end + render(Header::ProjectSelectComponent.new( + current_project: @project, + current_user: User.current, + current_menu_item: current_menu_item + )) end include OpenProject::StaticRouting::UrlHelpers diff --git a/lookbook/docs/components/tree-view/filterable-tree-view.md.erb b/lookbook/docs/components/tree-view/filterable-tree-view.md.erb index dcccdb1fbd4c..cffbb7bc850a 100644 --- a/lookbook/docs/components/tree-view/filterable-tree-view.md.erb +++ b/lookbook/docs/components/tree-view/filterable-tree-view.md.erb @@ -1,4 +1,4 @@ -The `FilterableTreeView` is an extended `TreeView` implementation for scenarios where filtering and managing selections within a large tree structure is required. It extends the `TreeView` by introducing filtering and view control options, making it particularly useful for workflows that require selection of nodes from hierarchies. +The `FilterableTreeView` is an extended `TreeView` implementation for scenarios where filtering within a large tree structure is required. It extends the `TreeView` by introducing filtering and view control options, making it useful for workflows that require searching and either selecting nodes from hierarchies or navigating to them via links. ## Overview @@ -10,8 +10,8 @@ The `FilterableTreeView` consists of the following UI elements: 1. **Segmented Control (optional)**: Allows toggling between viewing all items and only selected ones. Labels and filter logic are configurable 2. **Search Field**: A text field with a leading search icon used to filter the tree content via text -3. **Checkbox "Include sub-nodes" (optional)**: When enabled, checking a parent node will also include its descendants -4. **TreeView**: An embedded `TreeView` component with multi-select enabled and static loading strategy +3. **Checkbox "Include sub-nodes" (optional)**: When enabled, checking a parent node will also include its descendants. Hidden automatically when using `select_variant: :single` or `select_variant: :none`. +4. **TreeView**: An embedded `TreeView` component. Supports `select_variant: :multiple` (default), `:single`, or `:none` (link nodes). ## Features @@ -19,8 +19,7 @@ The `FilterableTreeView` consists of the following UI elements: The embedded `TreeView` behaves according to its specification, with a few notable constraints and extensions: -- **Loading strategy**: Only supports `static` loading. -- **Multi-select support**: Enabled by default. +- **Multi-select support**: Enabled by default (`select_variant: :multiple`). For details on the `TreeView` itself, refer to the [TreeView documentation](./tree_view). @@ -37,12 +36,12 @@ The search field enables text-based filtering of the tree. - Non-matching nodes in a visible path are **greyed out and disabled** (not clickable). - Matching characters in results are **highlighted**. -**Single-select considerations:** +**Single-select considerations (`select_variant: :single`):** - Selections made **before** filtering are preserved, even if they are not visible in the current filter. - On form submission, **the selected item** is submitted, even if it is currently not shown (e.g filtered out). -**Multi-select considerations:** +**Multi-select considerations (`select_variant: :multiple`):** - Selections made **before** filtering are preserved, even if they are not visible in the current filter. - If **"Include sub-nodes"** is enabled: @@ -68,7 +67,9 @@ A segmented control toggles the visible tree scope: The labels as well as the logic for the segmented control are configurable. For details, please check the technical notes section at the bottom. -The segmented control can be disabled and hidden by passing `hidden: true` as `filter_mode_control_arguments`. Please have a look at [this preview](../../../inspect/primer/open_project/filterable_tree_view/hide_segmented_control) for details. +The segmented control is visible by default for all `select_variant` values, including `:none`. It can be hidden by passing `hidden: true` as `filter_mode_control_arguments`. Please have a look at [this preview](../../../inspect/primer/open_project/filterable_tree_view/hide_segmented_control) for details. + +> **Note:** The "Selected" mode in the segmented control only makes sense when nodes are selectable (`:multiple` or `:single`). When using `select_variant: :none`, it is recommended to either hide the segmented control or replace the default filter modes with custom ones relevant to your use case. --- @@ -84,7 +85,163 @@ This checkbox controls whether checking a parent node selects all of its childre - When **unchecked again**: - The component does **remember** how a selection was made (manual vs. auto). Previously auto-selected descendants get unselected while manually selected items remain selected. -The checkbox can be visually hidden by passing `hidden: true` to the `include_sub_items_check_box_arguments`. The logic remains however. Please have a look at [this preview](../../../inspect/primer/open_project/filterable_tree_view/hide_checkbox) for details. +The checkbox is **automatically hidden** when `select_variant: :single` or `select_variant: :none` is used, since sub-item inclusion only makes sense in multi-select mode. + +It can also be visually hidden manually by passing `hidden: true` to the `include_sub_items_check_box_arguments`. The logic remains however. Please have a look at [this preview](../../../inspect/primer/open_project/filterable_tree_view/hide_checkbox) for details. + +--- + +### Async (server-side) filtering + +By default, `FilterableTreeView` filters the tree client-side. When the tree is too large to load at once, or when filtering requires server knowledge (e.g. database queries), **async mode** can be activated by passing a `src:` URL to the component. + +#### Behavior summary + +- On initial rendering, the component fetches the full tree from `src`. +- Every change to the filter input triggers a debounced (300 ms) GET request to `src`. +- Switching the segmented control triggers a fetch for all modes except `"selected"`, which is handled client-side. +- Toggling **"Include sub-items"** does **not** immediately trigger a server request. The current `include_sub_items` value is sent on the next fetch (query or filter-mode change), and the server must return the appropriate descendants at that point. +- After a filtered response is received, the client automatically expands all sub-trees. +- When the filter is cleared, the expansion state from before filtering started is restored. +- When the form is submitted, the component sends all nodes that have been checked at **some point in time** (no matter whether the current filter shows them or not). +- When the form is submitted with **"Include sub-items"** checked, +- On form submit: The form receives the complete selection regardless of the current filter state: + - Nodes currently visible and checked are submitted via the `TreeView`'s normal form inputs. + - Nodes that were checked but are currently filtered out (not in the DOM) are submitted via hidden inputs injected by the component directly. + - The `include_sub_items` checkbox value is included in the form submission so that the server knows whether to include all descendants or not. + +#### Selection persistence + +Checked nodes are identified by `data-node-id` and preserved across tree replacements. Nodes that are checked but absent from the current response are submitted via hidden inputs injected by the component, so the form payload is always complete regardless of what is currently visible. + +#### Using async mode with a form + +Pass `form_arguments:` to the component as usual. In async mode, the `include_sub_items` value is automatically included in the form submission (unlike client-side mode, where it is a purely visual control). + +```erb +<%= form_with(url: my_path) do |f| %> + <%= render(Primer::OpenProject::FilterableTreeView.new( + src: my_items_path, + form_arguments: { builder: f, name: "items" } + )) %> +<% end %> +``` + +When using forms, pass `form_arguments:` to the `TreeView` in your endpoint fragment as well, so node checkboxes are wired to the form. The component sends `name` as a query parameter automatically, so your controller can reconstruct a `FormBuilder`: + +```ruby +# app/controllers/my_items_controller.rb +def index + name = params[:name].presence || "items" + builder = ActionView::Helpers::FormBuilder.new("", nil, view_context, {}) + + render layout: false, locals: { builder: builder, name: name, ... } +end +``` + +```erb +<%# app/views/my_items/index.html.erb – with form support %> +<%= render(Primer::Alpha::TreeView.new( + data: { target: "filterable-tree-view.treeViewList" }, + form_arguments: { builder: builder, name: name } +)) do |tree| %> + ... +<% end %> +``` + +#### How to use the component in OpenProject + +##### Step 1 – Render the component + +Pass a `src:` URL pointing to your server endpoint. The component fetches and renders the tree on mount, and re-fetches on every filter change. + +```erb +<%= render(Primer::OpenProject::FilterableTreeView.new(src: my_items_path)) %> +``` + +> **Note:** Do not pass `tree_view_arguments:` alongside `src:`. The initial tree shell is replaced by the first fetch, so those arguments would be lost. Configure the `TreeView` inside your endpoint instead. + +##### Step 2 – Create the server endpoint + +The endpoint must respond to `GET` and return a `` HTML fragment (no layout, no surrounding HTML). The component sends the following query parameters with every request: + +| Parameter | Type | Description | +|:---|:---|:---| +| `query` | `String` | Current text from the filter input (empty string when unfiltered). | +| `filter_mode` | `String` | Active segmented-control tab name, e.g. `"all"`, `"selected"`, or a custom mode. Note: `"selected"` mode is handled client-side and never triggers a server request. | +| `include_sub_items` | `"true"` / `"false"` | Whether the "Include sub-items" checkbox is checked. The server must use this to expand the result — toggling the checkbox does not trigger a server request on its own. | +| `checked_ids[]` | `Array` | One entry per currently checked node ID. Required so the server can include all descendants of checked nodes when `include_sub_items=true`, even if those nodes do not match the query. | + +Minimal Rails wiring: + +```ruby +# config/routes.rb +resources :my_items, only: [:index] +``` + +```ruby +# app/controllers/my_items_controller.rb +def index + query = params[:query].to_s.strip + include_sub_items = params[:include_sub_items] == "true" + checked_ids = Array(params["checked_ids[]"]).map(&:to_s) + + @nodes = MyItem.filter(query, include_sub_items:, checked_ids:) + + render layout: false +end +``` + +**What the server should do with `include_sub_items` and `checked_ids[]`:** + +When `include_sub_items` is `"true"`, the server should include all descendants of every node in `checked_ids[]` in the response — even if they don't match the query string — so the client can check and disable them visually. The client does not expand sub-trees on its own when `include_sub_items` is toggled; it relies entirely on what the server returns on the next filter request. + +##### Step 3 – Return the tree fragment + +The response must be a single `` element with `data-target="filterable-tree-view.treeViewList"`. Use `Primer::Alpha::TreeView` to render it. + +```erb +<%# app/views/my_items/index.html.erb %> +<%= render(Primer::Alpha::TreeView.new( + data: { target: "filterable-tree-view.treeViewList" } +)) do |tree| %> + <% @nodes.each do |node| %> + <%= render partial: "my_items/node", locals: { component: tree, node: node, query: query } %> + <% end %> +<% end %> +``` + +###### Node partial requirements + +Each node **must** carry a stable `data-node-id` so the component can preserve selections across tree replacements. The client automatically expands all sub-trees after a filtered response, so you do not need to set `expanded:` in the partial. + +**Leaf nodes:** + +```erb +<%# app/views/my_items/_node.html.erb (leaf) %> +<% component.with_leaf( + label: node.label, + data: { node_id: node.id } +) %> +``` + +**Sub-tree (branch) nodes:** + +```erb +<%# app/views/my_items/_node.html.erb (branch) %> +<% component.with_sub_tree( + label: node.label, + sub_tree_component_klass: Primer::OpenProject::FilterableTreeView::SubTree, + select_strategy: :self, + data: { node_id: node.id } +) do |sub| %> + <% node.children.each do |child| %> + <%= render partial: "my_items/node", locals: { component: sub, node: child, query: query } %> + <% end %> +<% end %> +``` + +> **Important:** Pass `sub_tree_component_klass:` and `select_strategy:` only at the **top-level** `TreeView`. `FilterableTreeView::SubTree#with_sub_tree` already forwards them to nested levels — passing them again will raise a `keyword argument duplicated` error. --- @@ -93,15 +250,17 @@ The checkbox can be visually hidden by passing `hidden: true` to the `include_su **Do:** - Use this component when users need to **search and selectively choose** items in a tree. +- Use `select_variant: :none` with `node_variant: :anchor` when nodes should act as **navigation links** rather than selectable items. - Enable **"Include sub-nodes"** if selecting a parent should automatically include all descendants. +- Hide the segmented control (`filter_mode_control_arguments: { hidden: true }`) when using `select_variant: :none` unless you provide a custom segmented control. **Don't:** -- Don’t use `FilterableTreeView` with a dynamic loading strategy – only static loading is supported. - Don't use the component for simple hierarchies which don't need filtering; instead use `TreeView`. ## Used in +- The global project selector - (Planned) In the admin settings as a way of selecting projects in which to enable a certain object, like a project attribute, type or custom field. ## Examples diff --git a/spec/features/menu_items/top_menu_item_spec.rb b/spec/features/menu_items/top_menu_item_spec.rb index 21b37a35cbb0..f8bcd7e295a2 100644 --- a/spec/features/menu_items/top_menu_item_spec.rb +++ b/spec/features/menu_items/top_menu_item_spec.rb @@ -163,8 +163,8 @@ def click_link_in_open_menu(title) describe "Projects" do let(:top_menu) { find_by_id("projects-menu") } - let(:all_projects) { I18n.t("js.label_project_list") } - let(:add_project) { I18n.t("js.label_project") } + let(:all_projects) { I18n.t("label_project_list") } + let(:add_project) { I18n.t("label_project") } context "as an admin" do let(:user) { create(:admin) } From b03879a05cb85393d8988e556816f63b17c5253d Mon Sep 17 00:00:00 2001 From: Henriette Darge Date: Mon, 11 May 2026 15:09:03 +0200 Subject: [PATCH 2/9] Remove replaced angular component --- config/locales/js-en.yml | 4 - frontend/src/app/app.module.ts | 12 - .../widget-project-favorites.component.html | 6 +- .../widget-project-favorites.component.sass | 14 + .../header-project-select.component.html | 125 ------- .../header-project-select.component.sass | 37 --- .../header-project-select.component.ts | 305 ------------------ .../header-project-select/insert-in-list.ts | 64 ---- .../header-project-select-list.component.html | 112 ------- .../header-project-select-list.component.sass | 12 - .../header-project-select-list.component.ts | 121 ------- 11 files changed, 17 insertions(+), 795 deletions(-) delete mode 100644 frontend/src/app/shared/components/header-project-select/header-project-select.component.html delete mode 100644 frontend/src/app/shared/components/header-project-select/header-project-select.component.sass delete mode 100644 frontend/src/app/shared/components/header-project-select/header-project-select.component.ts delete mode 100644 frontend/src/app/shared/components/header-project-select/insert-in-list.ts delete mode 100644 frontend/src/app/shared/components/header-project-select/list/header-project-select-list.component.html delete mode 100644 frontend/src/app/shared/components/header-project-select/list/header-project-select-list.component.sass delete mode 100644 frontend/src/app/shared/components/header-project-select/list/header-project-select-list.component.ts diff --git a/config/locales/js-en.yml b/config/locales/js-en.yml index ec5086e25d0e..a0bd06f6f08e 100644 --- a/config/locales/js-en.yml +++ b/config/locales/js-en.yml @@ -443,7 +443,6 @@ en: label_per_page: "Per page:" label_please_wait: "Please wait" label_project: "Project" - label_project_list: "Project lists" label_project_plural: "Projects" label_visibility_settings: "Visibility settings" label_quote_comment: "Quote this comment" @@ -1123,16 +1122,13 @@ en: all: "All projects" selected: "Only selected" search_placeholder: "Search projects..." - search_placeholder_favorites: "Search favorites..." include_subprojects: "Include all sub-projects" tooltip: include_all_selected: "Project already included since Include all sub-projects is enabled." current_project: "This is the current project you are in." does_not_match_search: "Project does not match the search criteria." no_results: "No project matches your search criteria." - no_favorite_results: "No favorite project matches your search criteria." include_workspaces: - search_placeholder: "Search..." types: program: "Program" portfolio: "Portfolio" diff --git a/frontend/src/app/app.module.ts b/frontend/src/app/app.module.ts index b3de94b03e8f..8f664848c7a6 100644 --- a/frontend/src/app/app.module.ts +++ b/frontend/src/app/app.module.ts @@ -68,13 +68,6 @@ import { OpenprojectEnterpriseModule } from 'core-app/features/enterprise/openpr import { ConfirmDialogService } from 'core-app/shared/components/modals/confirm-dialog/confirm-dialog.service'; import { ConfirmDialogModalComponent } from 'core-app/shared/components/modals/confirm-dialog/confirm-dialog.modal'; import { DynamicContentModalComponent } from 'core-app/shared/components/modals/modal-wrapper/dynamic-content.modal'; -import { - OpHeaderProjectSelectComponent, -} from 'core-app/shared/components/header-project-select/header-project-select.component'; -import { - OpHeaderProjectSelectListComponent, -} from 'core-app/shared/components/header-project-select/list/header-project-select-list.component'; - import { PaginationService } from 'core-app/shared/components/table-pagination/pagination-service'; import { MainMenuResizerComponent } from 'core-app/shared/components/resizer/resizer/main-menu-resizer.component'; import { OpenprojectTabsModule } from 'core-app/shared/components/tabs/openproject-tabs.module'; @@ -263,10 +256,6 @@ export function runBootstrap(appRef:ApplicationRef) { // Main menu MainMenuResizerComponent, - // Project selector - OpHeaderProjectSelectComponent, - OpHeaderProjectSelectListComponent, - // Form configuration OpDragScrollDirective, ], @@ -417,7 +406,6 @@ export class OpenProjectModule implements DoBootstrap { registerCustomElement('opce-editable-query-props', EditableQueryPropsComponent, { injector }); registerCustomElement('opce-time-entry-trigger-actions', TriggerActionsEntryComponent, { injector }); registerCustomElement('opce-wp-overview-graph', WorkPackageOverviewGraphComponent, { injector }); - registerCustomElement('opce-header-project-select', OpHeaderProjectSelectComponent, { injector }); registerCustomElement('opce-no-results', NoResultsComponent, { injector }); registerCustomElement('opce-non-working-days-list', OpNonWorkingDaysListComponent, { injector }); registerCustomElement('opce-main-menu-resizer', MainMenuResizerComponent, { injector }); diff --git a/frontend/src/app/shared/components/grids/widgets/project-favorites/widget-project-favorites.component.html b/frontend/src/app/shared/components/grids/widgets/project-favorites/widget-project-favorites.component.html index ad9434bb1e43..d3447d4d4c81 100644 --- a/frontend/src/app/shared/components/grids/widgets/project-favorites/widget-project-favorites.component.html +++ b/frontend/src/app/shared/components/grids/widgets/project-favorites/widget-project-favorites.component.html @@ -9,16 +9,16 @@ @if ((projects$ | async); as projects) {
@if (projects.length === 0) { -
+


-

diff --git a/frontend/src/app/shared/components/grids/widgets/project-favorites/widget-project-favorites.component.sass b/frontend/src/app/shared/components/grids/widgets/project-favorites/widget-project-favorites.component.sass index a0e8b419fc3e..e0bf8d120484 100644 --- a/frontend/src/app/shared/components/grids/widgets/project-favorites/widget-project-favorites.component.sass +++ b/frontend/src/app/shared/components/grids/widgets/project-favorites/widget-project-favorites.component.sass @@ -4,3 +4,17 @@ &--link padding-left: 0.25rem + + &--no-favorites + display: flex + text-align: center + flex-direction: column + align-items: center + margin: 0 1.25rem + + &--no-favorites-icon + margin-bottom: 0.5rem + + &--no-favorites-subtext + font-size: 0.9rem + color: var(--fgColor-muted, var(--color-fg-subtle)) diff --git a/frontend/src/app/shared/components/header-project-select/header-project-select.component.html b/frontend/src/app/shared/components/header-project-select/header-project-select.component.html deleted file mode 100644 index 31bb8c797741..000000000000 --- a/frontend/src/app/shared/components/header-project-select/header-project-select.component.html +++ /dev/null @@ -1,125 +0,0 @@ - - - - -
-

{{ text.project.plural }}

- - @if (this.currentUserService.isLoggedIn) { - - } -
- @if (projects$ | async; as projects) { -
- @if (displayMode !== 'favorited' || (favorites$ | async)?.length > 0) { - - - - } - @if ((loading$ | async) === false) { - @if (anyProjectsFound(projects, (favorites$ | async))) { -
    - } @else { - @if (displayMode === 'favorited' && (favorites$ | async).length === 0) { -
    - -

    - -
    - -

    -
    - } - @if (!(displayMode === 'favorited' && (favorites$ | async).length === 0)) { - - {{ noSearchResultsText() }} - - } - } - } @else { - - } - @if (!portfolioModelsEnabled) { -
    -
    - - - - - @if (canCreateNewProjects$ | async) { - - - - - } -
    -
    - } -
    - } -
    - -
    diff --git a/frontend/src/app/shared/components/header-project-select/header-project-select.component.sass b/frontend/src/app/shared/components/header-project-select/header-project-select.component.sass deleted file mode 100644 index f7e0e4b44d50..000000000000 --- a/frontend/src/app/shared/components/header-project-select/header-project-select.component.sass +++ /dev/null @@ -1,37 +0,0 @@ -.op-project-select - flex: 1 - overflow: hidden - - &--trigger-button - display: flex - align-items: center - height: var(--control-medium-size) - color: var(--main-menu-font-color) - font-weight: var(--base-text-weight-semibold) - padding: 0 var(--main-menu-x-spacing) - border: 1px solid transparent - border-radius: var(--borderRadius-medium) - background-color: transparent - width: 100% - - .button--dropdown-indicator - &:before - margin-left: var(--base-size-8) - -.op-header-project-select - &--no-favorites - display: flex - text-align: center - flex-direction: column - align-items: center - margin: 0 1.25rem - - &--no-favorites-icon - margin-bottom: 0.5rem - - &--search-icon - fill: var(--body-font-color) - - &--no-favorites-subtext - font-size: 0.9rem - color: var(--fgColor-muted, var(--color-fg-subtle)) diff --git a/frontend/src/app/shared/components/header-project-select/header-project-select.component.ts b/frontend/src/app/shared/components/header-project-select/header-project-select.component.ts deleted file mode 100644 index 96eddcf953eb..000000000000 --- a/frontend/src/app/shared/components/header-project-select/header-project-select.component.ts +++ /dev/null @@ -1,305 +0,0 @@ -//-- 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. -//++ - -import { PathHelperService } from 'core-app/core/path-helper/path-helper.service'; -import { I18nService } from 'core-app/core/i18n/i18n.service'; -import { ChangeDetectionStrategy, Component, HostBinding, OnDestroy, OnInit, ViewEncapsulation, ElementRef, ViewChild, AfterViewInit, inject } from '@angular/core'; -import { CurrentProjectService } from 'core-app/core/current-project/current-project.service'; -import { BehaviorSubject, Observable, ReplaySubject, Subscription } from 'rxjs'; -import { map, shareReplay, take, tap } from 'rxjs/operators'; -import { IProject } from 'core-app/core/state/projects/project.model'; -import { insertInList } from 'core-app/shared/components/project-include/insert-in-list'; -import { recursiveSort } from 'core-app/shared/components/project-include/recursive-sort'; -import { - SearchableProjectListService, -} from 'core-app/shared/components/searchable-project-list/searchable-project-list.service'; -import { CurrentUserService } from 'core-app/core/current-user/current-user.service'; -import { UntilDestroyedMixin } from 'core-app/shared/helpers/angular/until-destroyed.mixin'; -import { IProjectData } from 'core-app/shared/components/searchable-project-list/project-data'; -import { ApiV3Service } from 'core-app/core/apiv3/api-v3.service'; -import { ConfigurationService } from 'core-app/core/config/configuration.service'; - -@Component({ - selector: 'opce-header-project-select', - templateUrl: './header-project-select.component.html', - styleUrls: ['./header-project-select.component.sass'], - encapsulation: ViewEncapsulation.None, - changeDetection: ChangeDetectionStrategy.OnPush, - providers: [ - SearchableProjectListService, - ], - standalone: false, -}) -export class OpHeaderProjectSelectComponent extends UntilDestroyedMixin implements OnInit, OnDestroy, AfterViewInit { - readonly pathHelper = inject(PathHelperService); - readonly configuration = inject(ConfigurationService); - readonly I18n = inject(I18nService); - readonly currentProject = inject(CurrentProjectService); - readonly searchableProjectListService = inject(SearchableProjectListService); - readonly currentUserService = inject(CurrentUserService); - readonly apiV3Service = inject(ApiV3Service); - - @HostBinding('class.op-project-select') className = true; - - @ViewChild('projectSearchField', { read: ElementRef }) - - projectSearchField?:ElementRef; - - private activeProjectId:number|null = null; - - private readonly listboxId = 'op-header-project-select-listbox'; - - public dropModalOpen = false; - - public textFieldFocused = false; - - public portfolioModelsEnabled = this.configuration.activeFeatureFlags.includes('portfolioModels'); - - public canCreateNewProjects$ = this.currentUserService.hasCapabilities$('projects/create', 'global'); - - public projects$ = this.searchableProjectListService.allProjects$.pipe( - map( - (projects:IProject[]) => projects - .filter( - (project) => { - const searchText = this.searchableProjectListService.searchText; - if (searchText.length) { - const terms = searchText.toLowerCase().split(/\s+/).filter((t) => t.length > 0); - const matches = terms.every((term) => project.name.toLowerCase().includes(term)); - - if (!matches) { - return false; - } - } - - return true; - }, - ) - .sort((a, b) => a._links.ancestors.length - b._links.ancestors.length) - .reduce( - (list, project) => { - const { ancestors } = project._links; - - return insertInList( - projects, - project, - list, - ancestors, - ); - }, - [] as IProjectData[], - ), - ), - map((projects) => recursiveSort(projects)), - tap(() => { - if(this.dropModalOpen) { - // only clear loading indicator if modal is open, otherwise rendering is triggered that will cause loading of - // favorites while the modal is closed - this.loading$.next(false); - } - }), - shareReplay(), - ); - - public favorites$:Observable = this.searchableProjectListService.favoriteIds$; - - public text = { - all: this.I18n.t('js.label_all_uppercase'), - favorited: this.I18n.t('js.label_favorites'), - no_favorites: this.I18n.t('js.favorite_projects.no_results'), - no_favorites_subtext: this.I18n.t('js.favorite_projects.no_results_subtext'), - project: { - singular: this.I18n.t('js.label_project'), - plural: this.I18n.t('js.label_project_plural'), - list: this.I18n.t('js.label_project_list'), - select: this.I18n.t('js.label_all_projects'), - search_placeholder: this.I18n.t('js.include_projects.search_placeholder') - }, - workspace: { - list: this.I18n.t('js.label_workspace_list'), - search_placeholder: this.I18n.t('js.include_workspaces.search_placeholder') - }, - search_favorites_placeholder: this.I18n.t('js.include_projects.search_placeholder_favorites'), - no_results: this.I18n.t('js.include_projects.no_results'), - no_favorite_results: this.I18n.t('js.include_projects.no_favorite_results') - }; - - // Computed text properties based on portfolio models feature flag - public get currentText() { - return this.portfolioModelsEnabled ? this.text.workspace : this.text.project; - } - - public displayMode:'all'|'favorited'; - - public displayModeOptions = [ - { value: 'all', title: this.text.all }, - { value: 'favorited', title: this.text.favorited }, - ]; - - public loading$ = new BehaviorSubject(true); - - private scrollToCurrent = false; - - private subscriptionComplete$ = new ReplaySubject(1); - - private displayModeLocalStorageKey = 'openProject-project-select-display-mode'; - - constructor() { - super(); - - if(this.currentProject.id) { - this.searchableProjectListService.preloadProjectIds = [this.currentProject.id]; - } - - this.projects$ - .pipe(this.untilDestroyed()) - .subscribe((projects) => { - if (this.currentProject.id && projects.length && this.scrollToCurrent) { - this.searchableProjectListService.selectedItemID$.next(parseInt(this.currentProject.id, 10)); - } else { - this.searchableProjectListService.resetActiveResult(projects); - } - - this.scrollToCurrent = false; - this.subscriptionComplete$.next(); // Signal that subscription logic is complete - }); - } - - private onTextInput:Subscription; - - ngOnInit():void { - const stored = window.OpenProject.guardedLocalStorage(this.displayModeLocalStorageKey) as 'all'|'favorited'|undefined; - this.displayMode = stored ?? 'all'; - this.onTextInput = this.searchableProjectListService.queriedSearchText$.subscribe(() => this.loading$.next(true)); - } - - ngOnDestroy():void { - this.onTextInput.unsubscribe(); - } - - ngAfterViewInit():void { - this.searchableProjectListService.selectedItemID$ - .pipe(this.untilDestroyed()) - .subscribe((selectedItemID:number|null) => { - this.activeProjectId = selectedItemID; - this.syncSearchInputAccessibility(); - }); - } - - private syncSearchInputAccessibility():void { - requestAnimationFrame(() => { - const input = this.projectSearchField?.nativeElement.querySelector('input') as HTMLInputElement | null; - - if (!input) { - return; - } - - input.setAttribute('role', 'combobox'); - input.setAttribute('aria-autocomplete', 'list'); - input.setAttribute('aria-haspopup', 'listbox'); - input.setAttribute('aria-expanded', String(this.dropModalOpen)); - input.setAttribute('aria-controls', this.listboxId); - input.setAttribute('aria-label', this.searchPlaceHolder()); - - if (this.dropModalOpen && this.activeProjectId !== null) { - input.setAttribute('aria-activedescendant', `op-header-project-select-option-${this.activeProjectId}`); - } else { - input.removeAttribute('aria-activedescendant'); - } - }); - } - - toggleDropModal():void { - this.subscriptionComplete$.pipe(take(1)).subscribe(() => { - this.dropModalOpen = !this.dropModalOpen; - if (this.dropModalOpen) { - this.loading$.next(true); - this.searchableProjectListService.enableLoading(); - this.scrollToCurrent = true; - } else { - this.searchableProjectListService.disableLoading(); - } - this.syncSearchInputAccessibility(); - }); - } - - displayModeChange(mode:'all'|'favorited'):void { - this.displayMode = mode; - window.OpenProject.guardedLocalStorage(this.displayModeLocalStorageKey, mode); - - if (this.currentProject.id) { - this.searchableProjectListService.selectedItemID$.next(parseInt(this.currentProject.id, 10)); - } - } - - close():void { - this.dropModalOpen = false; - this.searchableProjectListService.disableLoading(); - this.searchableProjectListService.searchText = ''; - this.syncSearchInputAccessibility(); - } - - currentProjectName():string { - if (this.currentProject.name !== null) { - return this.currentProject.name; - } - - return this.text.project.select; - } - - allProjectsPath():string { - return this.pathHelper.projectsPath(); - } - - newProjectPath():string { - const parentParam = this.currentProject.id ? `?parent_id=${this.currentProject.id}` : ''; - return `${this.pathHelper.projectsNewPath()}${parentParam}`; - } - - anyProjectsFound(projects:IProjectData[], favorites:string[]):boolean { - if (this.displayMode === 'all') { - return projects.length > 0; - } - - return projects.length > 0 && favorites.length > 0; - } - - searchPlaceHolder():string { - if (this.displayMode === 'all') { - return this.currentText.search_placeholder; - } - return this.text.search_favorites_placeholder; - } - - noSearchResultsText():string { - if (this.displayMode === 'all') { - return this.text.no_results; - } - return this.text.no_favorite_results; - } -} diff --git a/frontend/src/app/shared/components/header-project-select/insert-in-list.ts b/frontend/src/app/shared/components/header-project-select/insert-in-list.ts deleted file mode 100644 index 160c9f7f1303..000000000000 --- a/frontend/src/app/shared/components/header-project-select/insert-in-list.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { IProject } from 'core-app/core/state/projects/project.model'; -import { IHalResourceLink } from 'core-app/core/state/hal-resource'; -import { IProjectData } from 'core-app/shared/components/searchable-project-list/project-data'; - -const UNDISCLOSED_ANCESTOR = 'urn:openproject-org:api:v3:undisclosed'; - -// Helper function that recursively inserts a project into the hierarchy at the right place -export const insertInList = ( - projects:IProject[], - project:IProject, - list:IProjectData[], - ancestors:IHalResourceLink[], -):IProjectData[] => { - // In a set of projects, some ancestors may be undisclosed. The client then knows of its existence - // but knows nothing more than that. Those projects receive an 'undisclosed' urn for their href. For building - // the project hierarchy, they can be ignored. - const visibleAncestors = ancestors.filter((ancestor) => ancestor.href !== UNDISCLOSED_ANCESTOR); - - if (!visibleAncestors.length) { - return [ - ...list, - { - id: project.id, - name: project.name, - href: project._links.self.href, - _type: project._type, - disabled: false, - children: [], - position: 0, - }, - ]; - } - - const ancestorHref = visibleAncestors[0].href; - const ancestor:IProjectData|undefined = list.find((projectInList) => projectInList.href === ancestorHref); - - if (ancestor) { - ancestor.children = insertInList( - projects, - project, - ancestor.children, - visibleAncestors.slice(1), - ); - return [...list]; - } - - const ancestorProject = projects.find((projectInList) => projectInList._links.self.href === ancestorHref); - if (!ancestorProject) { - return [...list]; - } - - return [ - ...list, - { - id: ancestorProject.id, - name: ancestorProject.name, - href: ancestorProject._links.self.href, - _type: project._type, - disabled: true, - children: insertInList(projects, project, [], visibleAncestors.slice(1)), - position: 0, - }, - ]; -}; diff --git a/frontend/src/app/shared/components/header-project-select/list/header-project-select-list.component.html b/frontend/src/app/shared/components/header-project-select/list/header-project-select-list.component.html deleted file mode 100644 index e9de11e22b71..000000000000 --- a/frontend/src/app/shared/components/header-project-select/list/header-project-select-list.component.html +++ /dev/null @@ -1,112 +0,0 @@ -@for (project of filteredProjects; track project; let i = $index; let isFirst = $first; let isLast = $last) { -
  • - @if (!project.disabled) { - - - - @if (favorited?.includes(project.id.toString())) { - - } - @if (portfolioModelsEnabled) { - @switch (project._type) { - @case ('Portfolio') { - - - {{ this.I18n.t('js.include_workspaces.types.portfolio') }} - - } - @case ('Program') { - - - {{ this.I18n.t('js.include_workspaces.types.program') }} - - } - } - } - - @if (currentProjectService.id === project.id.toString()) { - - - - } - - } - @if (project.disabled) { - - - {{ project.name }} - @if (portfolioModelsEnabled) { - @switch (project._type) { - @case ('Portfolio') { - - - {{ this.I18n.t('js.include_workspaces.types.portfolio') }} - - } - @case ('Program') { - - - {{ this.I18n.t('js.include_workspaces.types.program') }} - - } - } - } - - - } - @if (project.children.length) { -
      - } -
    • -} diff --git a/frontend/src/app/shared/components/header-project-select/list/header-project-select-list.component.sass b/frontend/src/app/shared/components/header-project-select/list/header-project-select-list.component.sass deleted file mode 100644 index 80c1f28b83f2..000000000000 --- a/frontend/src/app/shared/components/header-project-select/list/header-project-select-list.component.sass +++ /dev/null @@ -1,12 +0,0 @@ -@import '../../app/spot/styles/sass/variables' -@import 'helpers' - -:host, -.op-header-project-select-list - flex-shrink: 1 - flex-basis: 100% - - // Since we are referring to the host element as well we need the complete class name here - &.op-header-project-select-list--root - overflow-y: auto - @include styled-scroll-bar diff --git a/frontend/src/app/shared/components/header-project-select/list/header-project-select-list.component.ts b/frontend/src/app/shared/components/header-project-select/list/header-project-select-list.component.ts deleted file mode 100644 index 9d67f1488118..000000000000 --- a/frontend/src/app/shared/components/header-project-select/list/header-project-select-list.component.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { I18nService } from 'core-app/core/i18n/i18n.service'; -import { ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, EventEmitter, HostBinding, Input, OnChanges, OnInit, Output, SimpleChanges, inject } from '@angular/core'; -import { - SearchableProjectListService, -} from 'core-app/shared/components/searchable-project-list/searchable-project-list.service'; -import { IProjectData } from 'core-app/shared/components/searchable-project-list/project-data'; -import { PathHelperService } from 'core-app/core/path-helper/path-helper.service'; -import { ConfigurationService } from 'core-app/core/config/configuration.service'; -import { CurrentProjectService } from 'core-app/core/current-project/current-project.service'; -import { getMetaContent } from 'core-app/core/setup/globals/global-helpers'; - -@Component({ - selector: '[op-header-project-select-list]', - changeDetection: ChangeDetectionStrategy.OnPush, - templateUrl: './header-project-select-list.component.html', - styleUrls: ['./header-project-select-list.component.sass'], - standalone: false, -}) -export class OpHeaderProjectSelectListComponent implements OnInit, OnChanges { - readonly I18n = inject(I18nService); - readonly pathHelper = inject(PathHelperService); - readonly configuration = inject(ConfigurationService); - readonly searchableProjectListService = inject(SearchableProjectListService); - readonly elementRef = inject(ElementRef); - readonly cdRef = inject(ChangeDetectorRef); - readonly currentProjectService = inject(CurrentProjectService); - - @HostBinding('class.spot-list') classNameList = true; - - @HostBinding('class.op-header-project-select-list') className = true; - - @HostBinding('attr.role') - get roleAttribute():string { - return this.root ? 'listbox' : 'group'; - } - - @HostBinding('attr.id') - get idAttribute():string|null { - return this.root ? 'op-header-project-select-listbox' : null; - } - - @Output() update = new EventEmitter(); - - @Input() @HostBinding('class.op-header-project-select-list--root') root = false; - - @Input() projects:IProjectData[] = []; - - @Input() favorited:string[] = []; - - @Input() displayMode:string; - - @Input() searchText = ''; - - public filteredProjects:IProjectData[]; - - public text = { - does_not_match_search: this.I18n.t('js.include_projects.tooltip.does_not_match_search'), - include_all_selected: this.I18n.t('js.include_projects.tooltip.include_all_selected') - }; - - public portfolioModelsEnabled = this.configuration.activeFeatureFlags.includes('portfolioModels'); - - ngOnInit():void { - if (this.root) { - this.searchableProjectListService.selectedItemID$.subscribe((selectedItemID) => { - // We have to push this back once so the component gets time to render the list - // and we can actually find the element and scroll to it. - requestAnimationFrame(() => { - const itemAction = (this.elementRef.nativeElement as HTMLElement) - .querySelectorAll(`.spot-list--item-action[data-project-id="${selectedItemID ?? ''}"]`); - itemAction[0]?.scrollIntoView(); - }); - }); - } - - this.updateProjectFilter(); - } - - ngOnChanges(changes:SimpleChanges) { - if (changes.displayMode || changes.projects || changes.favorited) { - this.updateProjectFilter(); - } - } - - updateProjectFilter() { - this.filteredProjects = this.projects.filter((project) => { - if (this.displayMode === 'all') { - return true; - } - - return this.showWhenFavorited(project); - }); - } - - showWhenFavorited(project:IProjectData):boolean { - if (this.isFavorited(project)) { - return true; - } - - return project.children.length > 0 && project.children.some((child) => this.showWhenFavorited(child)); - } - - isFavorited(project:IProjectData):boolean { - return this.favorited.includes(project.id.toString()); - } - - extendedUrl(projectId:string|null):string { - const currentMenuItem = getMetaContent('current_menu_item'); - const url = projectId === null ? window.appBasePath : this.pathHelper.projectPath(projectId); - - if (!currentMenuItem) { - return url; - } - - return `${url}?jump=${encodeURIComponent(currentMenuItem)}`; - } - - optionId(project:IProjectData):string { - return `op-header-project-select-option-${project.id}`; - } -} From ade8b02076decf5a46c3ce2b884d4e87f17e6b9e Mon Sep 17 00:00:00 2001 From: Henriette Darge Date: Mon, 11 May 2026 15:54:53 +0200 Subject: [PATCH 3/9] Fine tune sorting and expansion state of the new project selector --- .../header/project_select_component.rb | 4 +- app/controllers/header/projects_controller.rb | 53 +++++++++++-------- app/views/header/projects/_node.html.erb | 3 +- .../src/global_styles/layout/_main_menu.sass | 2 +- 4 files changed, 34 insertions(+), 28 deletions(-) diff --git a/app/components/header/project_select_component.rb b/app/components/header/project_select_component.rb index 5c63a29c0555..51a9f046d0dc 100644 --- a/app/components/header/project_select_component.rb +++ b/app/components/header/project_select_component.rb @@ -34,8 +34,8 @@ class ProjectSelectComponent < ApplicationComponent def initialize(current_project: nil, current_user: User.current, current_menu_item: nil) super() - @current_project = current_project - @current_user = current_user + @current_project = current_project + @current_user = current_user @current_menu_item = current_menu_item end diff --git a/app/controllers/header/projects_controller.rb b/app/controllers/header/projects_controller.rb index 46f25b6cfd44..15946aea5533 100644 --- a/app/controllers/header/projects_controller.rb +++ b/app/controllers/header/projects_controller.rb @@ -35,10 +35,10 @@ class Header::ProjectsController < ApplicationController def index @current_project_id = params[:current_project_id]&.to_i - @jump = params[:jump].presence - @projects = load_projects - @favorited_ids = load_favorited_ids - @tree = build_tree(@projects) + @jump = params[:jump].presence + @projects = load_projects + @favorited_ids = load_favorited_ids + @tree = build_tree(@projects) render layout: false end @@ -54,30 +54,29 @@ def filter_mode end def load_projects - scope = Project.visible.active.order(:lft).limit(MAX_NUMBER_OF_PROJECTS) + projects = base_scope.to_a + ensure_current_project_present(projects) + end + def base_scope + scope = Project.visible.active.order(:lft).limit(MAX_NUMBER_OF_PROJECTS) scope = scope.where("LOWER(name) LIKE LOWER(?)", "%#{ActiveRecord::Base.sanitize_sql_like(query)}%") if query.present? + scope = scope.where(id: favorite_project_ids) if filter_mode == "favorited" && User.current.logged? + scope + end - if filter_mode == "favorited" && User.current.logged? - favorite_ids = Favorite.where(favorited_type: "Project", user_id: User.current.id).select(:favorited_id) - scope = scope.where(id: favorite_ids) - end - - projects = scope.to_a + def ensure_current_project_present(projects) + return projects if query.present? || @current_project_id.blank? + return projects if projects.any? { |p| p.id == @current_project_id } - # When not searching, ensure the current project is always present in the result - # even if it sits beyond the MAX_NUMBER_OF_PROJECTS limit (e.g. large instances). - if query.blank? && @current_project_id.present? && projects.none? { |p| p.id == @current_project_id } - current = Project.visible.active.find_by(id: @current_project_id) + current = Project.visible.active.find_by(id: @current_project_id) + return projects unless current - if current - # Include its ancestors so the tree path is intact. - extras = current.self_and_ancestors.active.to_a - projects = (projects + extras).uniq(&:id).sort_by(&:lft) - end - end + (projects + current.self_and_ancestors.active.to_a).uniq(&:id).sort_by(&:lft) + end - projects + def favorite_project_ids + Favorite.where(favorited_type: "Project", user_id: User.current.id).select(:favorited_id) end def load_favorited_ids @@ -91,6 +90,7 @@ def load_favorited_ids # Builds a nested structure from a flat, lft-ordered list of projects. # Projects whose parent is not in the result set appear as roots. + # Each level is sorted alphabetically by project name. def build_tree(projects) nodes = projects.index_by(&:id).transform_values { |p| { project: p, children: [] } } @@ -106,6 +106,13 @@ def build_tree(projects) end end - roots + sort_nodes(roots) + end + + def sort_nodes(nodes) + nodes.sort_by { |n| n[:project].name.downcase }.each do |node| + node[:children] = sort_nodes(node[:children]) + node[:expanded] = node[:children].any? { |c| c[:project].id == @current_project_id || c[:expanded] } + end end end diff --git a/app/views/header/projects/_node.html.erb b/app/views/header/projects/_node.html.erb index 94ba85c180cf..4db5cb13fbc2 100644 --- a/app/views/header/projects/_node.html.erb +++ b/app/views/header/projects/_node.html.erb @@ -32,8 +32,7 @@ See COPYRIGHT and LICENSE files for more details. <% is_current = project.id == current_project_id %> <% is_favorited = favorited_ids.include?(project.id) %> <% href = jump.present? ? project_path(project.identifier, jump:) : project_path(project.identifier) %> -<% subtree_contains_current = ->(n) { n[:project].id == current_project_id || n[:children].any? { |c| subtree_contains_current.call(c) } } %> -<% is_expanded = subtree_contains_current.call(node) %> +<% is_expanded = node[:expanded] %> <% add_icons = ->(tree_node) do if is_current diff --git a/frontend/src/global_styles/layout/_main_menu.sass b/frontend/src/global_styles/layout/_main_menu.sass index 7e65a7f71b20..c4a8f16fb68b 100644 --- a/frontend/src/global_styles/layout/_main_menu.sass +++ b/frontend/src/global_styles/layout/_main_menu.sass @@ -89,7 +89,7 @@ $arrow-left-width: 36px align-items: center // -------------------- MAIN menu items --------------------------- - li a + li a:not(.Button) // work around due to dom manipulation on document: ready: // this isn't scoped to .main-item-wrapper to avoid flickering padding-left: var(--main-menu-x-spacing) From c801a941ee22d3610e0a36399c1c46e149ae776c Mon Sep 17 00:00:00 2001 From: Henriette Darge Date: Wed, 20 May 2026 13:42:30 +0200 Subject: [PATCH 4/9] Update primer to 0.86.1 --- Gemfile | 2 +- Gemfile.lock | 6 +++--- frontend/package-lock.json | 28 ++++++++++++++-------------- frontend/package.json | 4 ++-- 4 files changed, 20 insertions(+), 20 deletions(-) diff --git a/Gemfile b/Gemfile index d37178dda17d..753be8e2e84e 100644 --- a/Gemfile +++ b/Gemfile @@ -430,4 +430,4 @@ end gem "openproject-octicons", "~>19.35.0" gem "openproject-octicons_helper", "~>19.35.0" -gem "openproject-primer_view_components", "~>0.85.0" +gem "openproject-primer_view_components", "~>0.86.1" diff --git a/Gemfile.lock b/Gemfile.lock index 425ed5854e81..5d864a9af20e 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -909,7 +909,7 @@ GEM actionview openproject-octicons (= 19.35.0) railties - openproject-primer_view_components (0.85.0) + openproject-primer_view_components (0.86.1) 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.85.0) + openproject-primer_view_components (~> 0.86.1) openproject-recaptcha! openproject-reporting! openproject-resource_management! @@ -2069,7 +2069,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.85.0) sha256=16bc8358ef600f0465488a2e3c86991a9c69ed84580bd450c2dbec6f268eeaca + openproject-primer_view_components (0.86.1) sha256=ca7dff6f4a66b7380d980d94f09a5d1727d53e4d666dfbe15c21b7f0d26bf6ac openproject-recaptcha (1.0.0) openproject-reporting (1.0.0) openproject-resource_management (1.0.0) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 1bce3045b2b8..a3e1a359c34d 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -57,12 +57,12 @@ "@ng-select/ng-select": "^21.8.0", "@ngneat/content-loader": "^7.0.0", "@openproject/octicons-angular": "^19.35.0", - "@openproject/primer-view-components": "^0.85.0", + "@openproject/primer-view-components": "^0.86.1", "@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.85.0", + "@primer/view-components": "npm:@openproject/primer-view-components@^0.86.1", "@rails/request.js": "^0.0.13", "@stimulus-components/auto-submit": "^6.0.0", "@stimulus-components/reveal": "^5.0.0", @@ -7707,9 +7707,9 @@ } }, "node_modules/@openproject/primer-view-components": { - "version": "0.85.0", - "resolved": "https://registry.npmjs.org/@openproject/primer-view-components/-/primer-view-components-0.85.0.tgz", - "integrity": "sha512-W/MUtfOQnkYzPMF6unyVJ6U53epCfpXwP2DcEc3YVzEK6XIaPCSS9a86dK714Na8ysOSL6j9OGgsThyfvToJKw==", + "version": "0.86.1", + "resolved": "https://registry.npmjs.org/@openproject/primer-view-components/-/primer-view-components-0.86.1.tgz", + "integrity": "sha512-zk8bhvL/U9kPAKKkOptRb1qJCzeAJi3v8jwmxT5XFs6ATibQnmp01Whz+owDMvEO+u9ZiCYgr+iVynPFmG4TJQ==", "license": "MIT", "dependencies": { "@github/auto-check-element": "^6.0.0", @@ -8266,9 +8266,9 @@ }, "node_modules/@primer/view-components": { "name": "@openproject/primer-view-components", - "version": "0.85.0", - "resolved": "https://registry.npmjs.org/@openproject/primer-view-components/-/primer-view-components-0.85.0.tgz", - "integrity": "sha512-W/MUtfOQnkYzPMF6unyVJ6U53epCfpXwP2DcEc3YVzEK6XIaPCSS9a86dK714Na8ysOSL6j9OGgsThyfvToJKw==", + "version": "0.86.1", + "resolved": "https://registry.npmjs.org/@openproject/primer-view-components/-/primer-view-components-0.86.1.tgz", + "integrity": "sha512-zk8bhvL/U9kPAKKkOptRb1qJCzeAJi3v8jwmxT5XFs6ATibQnmp01Whz+owDMvEO+u9ZiCYgr+iVynPFmG4TJQ==", "license": "MIT", "dependencies": { "@github/auto-check-element": "^6.0.0", @@ -31171,9 +31171,9 @@ } }, "@openproject/primer-view-components": { - "version": "0.85.0", - "resolved": "https://registry.npmjs.org/@openproject/primer-view-components/-/primer-view-components-0.85.0.tgz", - "integrity": "sha512-W/MUtfOQnkYzPMF6unyVJ6U53epCfpXwP2DcEc3YVzEK6XIaPCSS9a86dK714Na8ysOSL6j9OGgsThyfvToJKw==", + "version": "0.86.1", + "resolved": "https://registry.npmjs.org/@openproject/primer-view-components/-/primer-view-components-0.86.1.tgz", + "integrity": "sha512-zk8bhvL/U9kPAKKkOptRb1qJCzeAJi3v8jwmxT5XFs6ATibQnmp01Whz+owDMvEO+u9ZiCYgr+iVynPFmG4TJQ==", "requires": { "@github/auto-check-element": "^6.0.0", "@github/auto-complete-element": "^3.8.0", @@ -31517,9 +31517,9 @@ "integrity": "sha512-NB9uYfJ01FVY6zp+33EoUbJ0paS3JrWY+PqdHPebTvyRtQgL3sX8//3jWqjt3/jL81UMEulJRM2A0hPj0/vFpQ==" }, "@primer/view-components": { - "version": "npm:@openproject/primer-view-components@0.85.0", - "resolved": "https://registry.npmjs.org/@openproject/primer-view-components/-/primer-view-components-0.85.0.tgz", - "integrity": "sha512-W/MUtfOQnkYzPMF6unyVJ6U53epCfpXwP2DcEc3YVzEK6XIaPCSS9a86dK714Na8ysOSL6j9OGgsThyfvToJKw==", + "version": "npm:@openproject/primer-view-components@0.86.1", + "resolved": "https://registry.npmjs.org/@openproject/primer-view-components/-/primer-view-components-0.86.1.tgz", + "integrity": "sha512-zk8bhvL/U9kPAKKkOptRb1qJCzeAJi3v8jwmxT5XFs6ATibQnmp01Whz+owDMvEO+u9ZiCYgr+iVynPFmG4TJQ==", "requires": { "@github/auto-check-element": "^6.0.0", "@github/auto-complete-element": "^3.8.0", diff --git a/frontend/package.json b/frontend/package.json index 329d8cd17ed1..38fb691cb062 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -108,12 +108,12 @@ "@ng-select/ng-select": "^21.8.0", "@ngneat/content-loader": "^7.0.0", "@openproject/octicons-angular": "^19.35.0", - "@openproject/primer-view-components": "^0.85.0", + "@openproject/primer-view-components": "^0.86.1", "@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.85.0", + "@primer/view-components": "npm:@openproject/primer-view-components@^0.86.1", "@rails/request.js": "^0.0.13", "@stimulus-components/auto-submit": "^6.0.0", "@stimulus-components/reveal": "^5.0.0", From 3b4ef4df69700fa03110d13f4b82bb066ec11d28 Mon Sep 17 00:00:00 2001 From: Henriette Darge Date: Wed, 20 May 2026 15:30:58 +0200 Subject: [PATCH 5/9] Add workspace information and filter results hierarchy information to project selector --- .../header/project_select_component.html.erb | 22 ++-- .../header/project_select_component.rb | 2 +- app/controllers/header/projects_controller.rb | 63 ++++++++-- app/helpers/header/projects_helper.rb | 63 ++++++++++ app/views/header/projects/_node.html.erb | 18 +-- app/views/header/projects/index.html.erb | 3 +- config/locales/en.yml | 3 +- .../admin/menu_item_traversal_spec.rb | 2 +- .../features/menu_items/top_menu_item_spec.rb | 13 +- spec/features/projects/favorite_spec.rb | 11 +- spec/features/projects/navigation_spec.rb | 46 +++---- .../projects/project_autocomplete_spec.rb | 8 +- spec/support/components/projects/top_menu.rb | 113 +++++++++--------- 13 files changed, 231 insertions(+), 136 deletions(-) create mode 100644 app/helpers/header/projects_helper.rb diff --git a/app/components/header/project_select_component.html.erb b/app/components/header/project_select_component.html.erb index 3e1435420457..f55fc9228086 100644 --- a/app/components/header/project_select_component.html.erb +++ b/app/components/header/project_select_component.html.erb @@ -33,7 +33,7 @@ See COPYRIGHT and LICENSE files for more details. visually_hide_title: true, anchor_side: :outside_bottom, anchor_align: :start, - size: :auto, + size: :medium_portrait, padding: :none ) ) do |overlay| %> @@ -47,12 +47,18 @@ See COPYRIGHT and LICENSE files for more details. <% button.with_trailing_visual_icon(icon: :"triangle-down") %> <% end %> - <% overlay.with_body(classes: "op-project-select--body", data: { controller: "header-project-select" }) do %> + <% overlay.with_body( + classes: "op-project-select--body", data: { controller: "header-project-select", + test_selector: "op-header-project-select" } + ) do %> <%= render( Primer::OpenProject::FilterableTreeView.new( src: tree_src, include_sub_items_check_box_arguments: { hidden: true }, - filter_mode_control_arguments: logged_in? ? {} : { hidden: true } + filter_mode_control_arguments: logged_in? ? {} : { hidden: true }, + filter_input_arguments: { name: "filter", label: t(:label_filter), visually_hide_label: true, + data: { test_selector: "op-header-project-select--search" } }, + no_results_node_arguments: { data: { test_selector: "op-header-project-select--no-results" }, label: I18n.t("filterable_tree_view.no_results_text") } ) ) do |tree_view| %> <% if logged_in? %> @@ -64,14 +70,16 @@ See COPYRIGHT and LICENSE files for more details. <% unless OpenProject::FeatureDecisions.portfolio_models_active? %> <%= helpers.flex_layout(justify_content: :flex_end, p: 2) do |bar| %> <% bar.with_column(mr: 2) do %> - <%= render(Primer::Beta::Button.new(tag: :a, href: projects_path)) do %> - <%= t("header_project_select.all_projects") %> + <%= render(Primer::Beta::Button.new(tag: :a, href: projects_path, test_selector: "list-project-btn")) do |button| %> + <% button.with_leading_visual_icon(icon: :"list-unordered") %> + <%= t("header_project_select.project_list") %> <% end %> <% end %> <% if can_create_projects? %> <% bar.with_column do %> - <%= render(Primer::Beta::Button.new(tag: :a, href: new_project_path, scheme: :primary)) do %> - <%= t("header_project_select.new_project") %> + <%= render(Primer::Beta::Button.new(tag: :a, href: new_project_path, scheme: :primary, test_selector: "create-project-btn")) do |button| %> + <% button.with_leading_visual_icon(icon: :plus) %> + <%= t(:label_project) %> <% end %> <% end %> <% end %> diff --git a/app/components/header/project_select_component.rb b/app/components/header/project_select_component.rb index 51a9f046d0dc..8b291984d189 100644 --- a/app/components/header/project_select_component.rb +++ b/app/components/header/project_select_component.rb @@ -40,7 +40,7 @@ def initialize(current_project: nil, current_user: User.current, current_menu_it end def trigger_label - @current_project&.name || I18n.t("header_project_select.trigger_no_project") + @current_project&.name || I18n.t("header_project_select.all_projects") end def tree_src diff --git a/app/controllers/header/projects_controller.rb b/app/controllers/header/projects_controller.rb index 15946aea5533..d476f5b3f391 100644 --- a/app/controllers/header/projects_controller.rb +++ b/app/controllers/header/projects_controller.rb @@ -32,6 +32,7 @@ class Header::ProjectsController < ApplicationController no_authorization_required! :index MAX_NUMBER_OF_PROJECTS = 300 + VALID_FILTER_MODES = %w[all favorited].freeze def index @current_project_id = params[:current_project_id]&.to_i @@ -50,49 +51,91 @@ def query end def filter_mode - params[:filter_mode].to_s + mode = params[:filter_mode].to_s + VALID_FILTER_MODES.include?(mode) ? mode : "all" end def load_projects projects = base_scope.to_a - ensure_current_project_present(projects) + projects = ensure_current_project_present(projects) + + if query.present? && projects.any? + @matching_ids = projects.to_set(&:id) + ancestors = ancestor_scope_for(projects).to_a + projects = (projects + ancestors).uniq(&:id).sort_by(&:lft) + end + + projects end def base_scope scope = Project.visible.active.order(:lft).limit(MAX_NUMBER_OF_PROJECTS) - scope = scope.where("LOWER(name) LIKE LOWER(?)", "%#{ActiveRecord::Base.sanitize_sql_like(query)}%") if query.present? - scope = scope.where(id: favorite_project_ids) if filter_mode == "favorited" && User.current.logged? + query.split.each do |term| + scope = scope.where("LOWER(name) LIKE LOWER(?)", "%#{ActiveRecord::Base.sanitize_sql_like(term)}%") + end + if filter_mode == "favorited" + scope = User.current.logged? ? scope.where(id: favorite_project_ids) : scope.none + end scope end def ensure_current_project_present(projects) - return projects if query.present? || @current_project_id.blank? + return projects if skip_current_project_inclusion? return projects if projects.any? { |p| p.id == @current_project_id } current = Project.visible.active.find_by(id: @current_project_id) return projects unless current + merge_with_ancestors(projects, current) + end + + def skip_current_project_inclusion? + query.present? || filter_mode == "favorited" || @current_project_id.blank? + end + + def merge_with_ancestors(projects, current) (projects + current.self_and_ancestors.active.to_a).uniq(&:id).sort_by(&:lft) end + # Returns a scope for all visible, active ancestors of the given projects + # that are not already in the given list. + def ancestor_scope_for(projects) + Project.visible.active + .where(ancestor_condition(projects)) + .where.not(id: projects.map(&:id)) + end + + def ancestor_condition(projects) + projects.map { |p| within_bounds(p.lft, p.rgt) }.reduce(:or) + end + + def within_bounds(lft, rgt) + Project.arel_table[:lft].lt(lft).and(Project.arel_table[:rgt].gt(rgt)) + end + def favorite_project_ids - Favorite.where(favorited_type: "Project", user_id: User.current.id).select(:favorited_id) + user_project_favorites.select(:favorited_id) end def load_favorited_ids return Set.new unless User.current.logged? - Favorite - .where(favorited_type: "Project", user_id: User.current.id, favorited_id: @projects.map(&:id)) + user_project_favorites + .where(favorited_id: @projects.map(&:id)) .pluck(:favorited_id) .to_set end + def user_project_favorites + @user_project_favorites ||= Favorite.where(favorited_type: "Project", user_id: User.current.id) + end + # Builds a nested structure from a flat, lft-ordered list of projects. - # Projects whose parent is not in the result set appear as roots. # Each level is sorted alphabetically by project name. def build_tree(projects) - nodes = projects.index_by(&:id).transform_values { |p| { project: p, children: [] } } + nodes = projects.index_by(&:id).transform_values do |p| + { project: p, children: [], matches_query: @matching_ids.nil? || @matching_ids.include?(p.id) } + end roots = [] projects.each do |project| diff --git a/app/helpers/header/projects_helper.rb b/app/helpers/header/projects_helper.rb new file mode 100644 index 000000000000..9025f4a5afd3 --- /dev/null +++ b/app/helpers/header/projects_helper.rb @@ -0,0 +1,63 @@ +# 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. +#++ + +module Header + module ProjectsHelper + def project_node_label(project, favorited: false) + parts = [project.name] + parts << favorite_icon if favorited + parts << workspace_type_badge(project) if show_workspace_type_badge?(project) + + text = parts.length == 1 ? parts.first : safe_join(parts) + render(Primer::BaseComponent.new(tag: :span, display: :inline_flex, align_items: :center)) { text } + end + + private + + def favorite_icon + render(Primer::Beta::Octicon.new(icon: :"star-fill", size: :small, classes: "op-primer--star-icon", ml: 2)) + end + + def workspace_type_badge(project) + render(Primer::BaseComponent.new(tag: :span, display: :inline_flex, align_items: :center, + color: :subtle, font_size: :small, ml: 2, classes: "description")) do + safe_join([ + render(Primer::Beta::Octicon.new(icon: workspace_icon(project.workspace_type), size: :xsmall, mr: 1)), + content_tag(:span, I18n.t(:"label_#{project.workspace_type}")) + ]) + end + end + + def show_workspace_type_badge?(project) + OpenProject::FeatureDecisions.portfolio_models_active? && + project.workspace_type.in?(%w[portfolio program]) + end + end +end diff --git a/app/views/header/projects/_node.html.erb b/app/views/header/projects/_node.html.erb index 4db5cb13fbc2..85e0ace9b58e 100644 --- a/app/views/header/projects/_node.html.erb +++ b/app/views/header/projects/_node.html.erb @@ -33,6 +33,8 @@ See COPYRIGHT and LICENSE files for more details. <% is_favorited = favorited_ids.include?(project.id) %> <% href = jump.present? ? project_path(project.identifier, jump:) : project_path(project.identifier) %> <% is_expanded = node[:expanded] %> +<% label = project_node_label(project, favorited: is_favorited) %> +<% matches_query = node[:matches_query] %> <% add_icons = ->(tree_node) do if is_current @@ -41,22 +43,21 @@ See COPYRIGHT and LICENSE files for more details. tag: :a, href: home_path(jump:), show_tooltip: false, - aria: { label: t("header_project_select.leave_project") } + aria: { label: t("header_project_select.leave_project") }, + data: { test_selector: "op-header-project-select--remove-item" } ) end - if is_favorited - tree_node.with_trailing_visual_icon(icon: :"star-fill", classes: "op-primer--star-icon") - end end %> <% if children.any? %> <% component.with_sub_tree( - label: project.name, + label:, select_variant: :none, current: is_current, expanded: is_expanded, href:, - data: { node_id: project.id } + disabled: !matches_query, + data: { node_id: project.id, test_selector: "op-header-project-select--item" } ) do |sub| %> <% add_icons.call(sub) %> <% children.each do |child_node| %> @@ -70,11 +71,12 @@ See COPYRIGHT and LICENSE files for more details. <% end %> <% else %> <% component.with_leaf( - label: project.name, + label:, select_variant: :none, current: is_current, href:, - data: { node_id: project.id } + disabled: !matches_query, + data: { node_id: project.id, test_selector: "op-header-project-select--item" } ) do |leaf| %> <% add_icons.call(leaf) %> <% end %> diff --git a/app/views/header/projects/index.html.erb b/app/views/header/projects/index.html.erb index e58e600da780..2f14ff2fdf0b 100644 --- a/app/views/header/projects/index.html.erb +++ b/app/views/header/projects/index.html.erb @@ -30,7 +30,8 @@ See COPYRIGHT and LICENSE files for more details. <%= render( Primer::Alpha::TreeView.new( node_variant: :anchor, - data: { target: "filterable-tree-view.treeViewList" } + data: { target: "filterable-tree-view.treeViewList", + test_selector: "op-header-project-select--list" } ) ) do |tree| %> <% @tree.each do |node| %> diff --git a/config/locales/en.yml b/config/locales/en.yml index 0e1d126f8f31..907b626f709a 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -3585,9 +3585,8 @@ en: all_projects: "All projects" favorites: "Favorites" leave_project: "Leave project" - new_project: "New project" + project_list: "Project lists" title: "Projects" - trigger_no_project: "Select a project" filterable_tree_view: filter_mode: diff --git a/spec/features/admin/menu_item_traversal_spec.rb b/spec/features/admin/menu_item_traversal_spec.rb index bf65de0bf808..98dc4a83d362 100644 --- a/spec/features/admin/menu_item_traversal_spec.rb +++ b/spec/features/admin/menu_item_traversal_spec.rb @@ -83,7 +83,7 @@ visit admin_index_path # Get all admin links from there - links = all("#menu-sidebar a[href]:not([data-test-selector='main-menu--arrow-left-to-project'])", visible: :all) + links = all("#menu-sidebar .menu_root a[href]:not([data-test-selector='main-menu--arrow-left-to-project'])", visible: :all) .map { |node| node["href"] } .reject { |link| link.end_with? "/#" } .compact diff --git a/spec/features/menu_items/top_menu_item_spec.rb b/spec/features/menu_items/top_menu_item_spec.rb index f8bcd7e295a2..9668af9caf79 100644 --- a/spec/features/menu_items/top_menu_item_spec.rb +++ b/spec/features/menu_items/top_menu_item_spec.rb @@ -163,19 +163,16 @@ def click_link_in_open_menu(title) describe "Projects" do let(:top_menu) { find_by_id("projects-menu") } - let(:all_projects) { I18n.t("label_project_list") } - let(:add_project) { I18n.t("label_project") } - context "as an admin" do let(:user) { create(:admin) } it "displays all items" do - expect(page).to have_css("a.button", exact_text: all_projects) - expect(page).to have_css("a.button", exact_text: add_project) + expect(page).to have_test_selector("list-project-btn") + expect(page).to have_test_selector("create-project-btn") end it "visits the projects page" do - page.find_link(all_projects).click + page.find_test_selector("list-project-btn").click expect(page).to have_current_path(projects_path) end @@ -187,8 +184,8 @@ def click_link_in_open_menu(title) end it "does not display new_project" do - expect(page).to have_css("a.button", exact_text: all_projects) - expect(page).to have_no_css("a.button", exact_text: add_project) + expect(page).to have_test_selector("list-project-btn") + expect(page).to have_no_test_selector("create-project-btn") end end diff --git a/spec/features/projects/favorite_spec.rb b/spec/features/projects/favorite_spec.rb index 92d5223d5be7..c3b15c7fa41a 100644 --- a/spec/features/projects/favorite_spec.rb +++ b/spec/features/projects/favorite_spec.rb @@ -153,8 +153,9 @@ top_menu.expect_open # projects are displayed initially - top_menu.expect_result project.name top_menu.expect_result other_project.name + top_menu.expand_node_for other_project.name + top_menu.expect_result project.name end top_menu.switch_mode "Favorites" @@ -173,17 +174,15 @@ ProjectRole.anonymous.update permissions: [:view_project] end - it "does not shows favorited projects" do + it "does not show the favorites filter" do visit project_path(project) retry_block do top_menu.toggle unless top_menu.open? top_menu.expect_open - - within(".op-project-list-modal--header") do - expect(page).to have_no_css("[data-test-selector=\"spot-toggle--option\"]", text: "Favorites") - end end + + expect(page).to have_no_button("Favorites") end end end diff --git a/spec/features/projects/navigation_spec.rb b/spec/features/projects/navigation_spec.rb index 42b78260ae44..d7aaa30a5c44 100644 --- a/spec/features/projects/navigation_spec.rb +++ b/spec/features/projects/navigation_spec.rb @@ -29,6 +29,7 @@ #++ require "spec_helper" +require "support/components/projects/top_menu" RSpec.describe "Projects navigation", :js do shared_let(:project) { create(:project) } @@ -39,6 +40,8 @@ end shared_let(:admin) { create(:admin) } + let(:top_menu) { Components::Projects::TopMenu.new } + context "as a user with all permissions" do before do login_as admin @@ -46,14 +49,13 @@ it "can deselect the current project and keep the module" do visit project_work_packages_path(project) - page.find_test_selector("op-projects-menu").click + top_menu.toggle # The currently active project is highlighted and removable - page.within_test_selector("op-header-project-select--list") do - expect(page).to have_test_selector("op-header-project-select--item-remove-icon", count: 1) - expect(page).to have_test_selector("op-header-project-select--active-item", count: 1) - - page.find_test_selector("op-header-project-select--item-remove-icon").click + within top_menu.search_results do + expect(page).to have_css(top_menu.remove_item_selector, count: 1) + expect(page).to have_css(top_menu.active_item_selector, count: 1) + page.find(top_menu.remove_item_selector).click end # Once removed, the user is redirected to the global WorkPackages page @@ -63,9 +65,9 @@ visit project_roadmap_path(project) # Remove the project again - page.find_test_selector("op-projects-menu").click - page.within_test_selector("op-header-project-select--list") do - page.find_test_selector("op-header-project-select--item-remove-icon").click + top_menu.toggle + within top_menu.search_results do + page.find(top_menu.remove_item_selector).click end # Once removed, the user is redirected to the home page @@ -95,31 +97,13 @@ before do login_as admin visit home_path - page.find_test_selector("op-projects-menu").click - wait_for_network_idle + top_menu.toggle! end it "displays badges for portfolio and program workspaces but not for regular projects" do - # Check portfolio has badge with icon and "Portfolio" text - portfolio_item = page.find('[data-test-selector="op-header-project-select--item"]', text: portfolio_project.name) - within portfolio_item do - expect(page).to have_css("svg.octicon") - expect(page).to have_text("Portfolio") - end - - # Check program has badge with icon and "Program" text - program_item = page.find('[data-test-selector="op-header-project-select--item"]', text: program_project.name) - within program_item do - expect(page).to have_css("svg.octicon") - expect(page).to have_text("Program") - end - - # Check regular project does NOT have workspace badge - regular_item = page.find('[data-test-selector="op-header-project-select--item"]', text: regular_project.name) - within regular_item do - expect(page).to have_no_text("Portfolio") - expect(page).to have_no_text("Program") - end + 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) end end end diff --git a/spec/features/projects/project_autocomplete_spec.rb b/spec/features/projects/project_autocomplete_spec.rb index 7af5b299cff0..cd60f2291cc2 100644 --- a/spec/features/projects/project_autocomplete_spec.rb +++ b/spec/features/projects/project_autocomplete_spec.rb @@ -104,9 +104,8 @@ # Filter for projects top_menu.search " Date: Mon, 1 Jun 2026 14:51:07 +0200 Subject: [PATCH 6/9] Include review feedback: Harmonize I18n keys, fix visible scope, use guarded local storage --- .../header/project_select_component.html.erb | 15 +++-- .../header/project_select_component.rb | 10 ++- .../header/projects/node_component.html.erb} | 42 ++++++------- .../header/projects/node_component.rb | 61 +++++++++++++++++++ app/controllers/header/projects_controller.rb | 33 +++++----- app/views/header/projects/index.html.erb | 13 ++-- config/locales/en.yml | 14 ++--- .../src/global_styles/primer/_overrides.sass | 2 +- .../header-project-select.controller.ts | 32 ++++++++-- .../menu_manager/top_menu/projects_menu.rb | 4 +- .../tree-view/filterable-tree-view.md.erb | 3 +- 11 files changed, 152 insertions(+), 77 deletions(-) rename app/{views/header/projects/_node.html.erb => components/header/projects/node_component.html.erb} (66%) create mode 100644 app/components/header/projects/node_component.rb diff --git a/app/components/header/project_select_component.html.erb b/app/components/header/project_select_component.html.erb index d57aec101d36..0bcd3a4e9ad4 100644 --- a/app/components/header/project_select_component.html.erb +++ b/app/components/header/project_select_component.html.erb @@ -29,7 +29,7 @@ See COPYRIGHT and LICENSE files for more details. <%= render( Primer::Alpha::Overlay.new( - title: t("header_project_select.title"), + title: t(".title"), visually_hide_title: true, anchor_side: :outside_bottom, anchor_align: :start, @@ -41,7 +41,8 @@ See COPYRIGHT and LICENSE files for more details. id: "projects-menu", classes: "op-project-select--trigger-button", scheme: :invisible, - data: { "test-selector": "op-projects-menu" } + data: { "test-selector": "op-projects-menu" }, + accesskey: "5" ) do |button| %> <%= trigger_label %> <% button.with_trailing_visual_icon(icon: :"triangle-down") %> @@ -55,15 +56,17 @@ See COPYRIGHT and LICENSE files for more details. Primer::OpenProject::FilterableTreeView.new( src: tree_src, include_sub_items_check_box_arguments: { hidden: true }, - filter_mode_control_arguments: logged_in? ? {} : { hidden: true }, - filter_input_arguments: { name: "filter", label: t(:label_filter), visually_hide_label: true, + filter_mode_control_arguments: logged? ? {} : { hidden: true }, + filter_input_arguments: { name: "filter", + label: t(:label_filter), + visually_hide_label: true, data: { test_selector: "op-header-project-select--search" } }, no_results_node_arguments: { data: { test_selector: "op-header-project-select--no-results" }, label: I18n.t("filterable_tree_view.no_results_text") } ) ) do |tree_view| %> - <% if logged_in? %> + <% if logged? %> <% tree_view.with_filter_mode(name: "all", label: t("filterable_tree_view.filter_mode.all"), selected: true) %> - <% tree_view.with_filter_mode(name: "favorited", label: t("header_project_select.favorites")) %> + <% tree_view.with_filter_mode(name: "favorited", label: t(".favorites")) %> <% end %> <% end %> <% end %> diff --git a/app/components/header/project_select_component.rb b/app/components/header/project_select_component.rb index 8b291984d189..f51464f7ca5b 100644 --- a/app/components/header/project_select_component.rb +++ b/app/components/header/project_select_component.rb @@ -32,7 +32,9 @@ module Header class ProjectSelectComponent < ApplicationComponent include OpenProject::StaticRouting::UrlHelpers - def initialize(current_project: nil, current_user: User.current, current_menu_item: nil) + delegate :logged?, to: :@current_user + + def initialize(current_project: nil, current_menu_item: nil, current_user: User.current) super() @current_project = current_project @current_user = current_user @@ -40,7 +42,7 @@ def initialize(current_project: nil, current_user: User.current, current_menu_it end def trigger_label - @current_project&.name || I18n.t("header_project_select.all_projects") + @current_project&.name || t(".all_projects") end def tree_src @@ -53,9 +55,5 @@ def tree_src def can_create_projects? @current_user.allowed_globally?(:add_project) end - - def logged_in? - @current_user.logged? - end end end diff --git a/app/views/header/projects/_node.html.erb b/app/components/header/projects/node_component.html.erb similarity index 66% rename from app/views/header/projects/_node.html.erb rename to app/components/header/projects/node_component.html.erb index 85e0ace9b58e..740185e15f75 100644 --- a/app/views/header/projects/_node.html.erb +++ b/app/components/header/projects/node_component.html.erb @@ -27,55 +27,47 @@ See COPYRIGHT and LICENSE files for more details. ++#%> -<% project = node[:project] %> -<% children = node[:children] %> -<% is_current = project.id == current_project_id %> -<% is_favorited = favorited_ids.include?(project.id) %> -<% href = jump.present? ? project_path(project.identifier, jump:) : project_path(project.identifier) %> -<% is_expanded = node[:expanded] %> -<% label = project_node_label(project, favorited: is_favorited) %> -<% matches_query = node[:matches_query] %> - <% add_icons = ->(tree_node) do - if is_current + if current? tree_node.with_trailing_action_button( icon: :"x-circle", tag: :a, - href: home_path(jump:), + href: home_path(jump: @jump), show_tooltip: false, - aria: { label: t("header_project_select.leave_project") }, + aria: { label: t("header.project_select_component.leave_project") }, data: { test_selector: "op-header-project-select--remove-item" } ) end end %> <% if children.any? %> - <% component.with_sub_tree( + <% @component.with_sub_tree( label:, select_variant: :none, - current: is_current, - expanded: is_expanded, + current: current?, + expanded: expanded?, href:, - disabled: !matches_query, + disabled: !matches_query?, data: { node_id: project.id, test_selector: "op-header-project-select--item" } ) do |sub| %> <% add_icons.call(sub) %> <% children.each do |child_node| %> - <%= render "header/projects/node", - component: sub, - node: child_node, - current_project_id: current_project_id, - favorited_ids: favorited_ids, - jump: %> + <%= render Header::Projects::NodeComponent.new( + component: sub, + node: child_node, + current_project_id: @current_project_id, + favorited_ids: @favorited_ids, + jump: @jump + ) %> <% end %> <% end %> <% else %> - <% component.with_leaf( + <% @component.with_leaf( label:, select_variant: :none, - current: is_current, + current: current?, href:, - disabled: !matches_query, + disabled: !matches_query?, data: { node_id: project.id, test_selector: "op-header-project-select--item" } ) do |leaf| %> <% add_icons.call(leaf) %> diff --git a/app/components/header/projects/node_component.rb b/app/components/header/projects/node_component.rb new file mode 100644 index 000000000000..7f1574cfb0df --- /dev/null +++ b/app/components/header/projects/node_component.rb @@ -0,0 +1,61 @@ +# 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. +#++ + +module Header + module Projects + class NodeComponent < ApplicationComponent + def initialize(component:, node:, current_project_id:, favorited_ids:, jump:) + super() + @component = component + @node = node + @current_project_id = current_project_id + @favorited_ids = favorited_ids + @jump = jump + end + + private + + def project = @node[:project] + def children = @node[:children] + def current? = project.id == @current_project_id + def favorited? = @favorited_ids.include?(project.id) + def expanded? = @node[:expanded] + def matches_query? = @node[:matches_query] + + def href + @jump.present? ? helpers.project_path(project.identifier, jump: @jump) : helpers.project_path(project.identifier) + end + + def label + helpers.project_node_label(project, favorited: favorited?) + end + end + end +end diff --git a/app/controllers/header/projects_controller.rb b/app/controllers/header/projects_controller.rb index d476f5b3f391..6eca39bcc14d 100644 --- a/app/controllers/header/projects_controller.rb +++ b/app/controllers/header/projects_controller.rb @@ -35,7 +35,7 @@ class Header::ProjectsController < ApplicationController VALID_FILTER_MODES = %w[all favorited].freeze def index - @current_project_id = params[:current_project_id]&.to_i + @current_project_id = params[:current_project_id].presence&.to_i @jump = params[:jump].presence @projects = load_projects @favorited_ids = load_favorited_ids @@ -47,7 +47,7 @@ def index private def query - params[:query].to_s.strip + @query ||= params[:query].to_s.strip end def filter_mode @@ -59,7 +59,7 @@ def load_projects projects = base_scope.to_a projects = ensure_current_project_present(projects) - if query.present? && projects.any? + if (query.present? || filter_mode == "favorited") && projects.any? @matching_ids = projects.to_set(&:id) ancestors = ancestor_scope_for(projects).to_a projects = (projects + ancestors).uniq(&:id).sort_by(&:lft) @@ -74,7 +74,7 @@ def base_scope scope = scope.where("LOWER(name) LIKE LOWER(?)", "%#{ActiveRecord::Base.sanitize_sql_like(term)}%") end if filter_mode == "favorited" - scope = User.current.logged? ? scope.where(id: favorite_project_ids) : scope.none + scope = current_user.logged? ? scope.where(id: favorite_project_ids) : scope.none end scope end @@ -94,23 +94,22 @@ def skip_current_project_inclusion? end def merge_with_ancestors(projects, current) - (projects + current.self_and_ancestors.active.to_a).uniq(&:id).sort_by(&:lft) + (projects + current.self_and_ancestors.visible.active.to_a).uniq(&:id).sort_by(&:lft) end # Returns a scope for all visible, active ancestors of the given projects # that are not already in the given list. + # Uses an EXISTS subquery instead of an OR chain to avoid up to 300 OR terms. def ancestor_scope_for(projects) - Project.visible.active - .where(ancestor_condition(projects)) - .where.not(id: projects.map(&:id)) - end + return Project.none if projects.empty? - def ancestor_condition(projects) - projects.map { |p| within_bounds(p.lft, p.rgt) }.reduce(:or) - end - - def within_bounds(lft, rgt) - Project.arel_table[:lft].lt(lft).and(Project.arel_table[:rgt].gt(rgt)) + ids = projects.map(&:id) + Project.visible.active + .where( + "EXISTS (SELECT 1 FROM projects d WHERE d.id IN (:ids) AND projects.lft < d.lft AND projects.rgt > d.rgt)", + ids: + ) + .where.not(id: ids) end def favorite_project_ids @@ -118,7 +117,7 @@ def favorite_project_ids end def load_favorited_ids - return Set.new unless User.current.logged? + return Set.new unless current_user.logged? user_project_favorites .where(favorited_id: @projects.map(&:id)) @@ -127,7 +126,7 @@ def load_favorited_ids end def user_project_favorites - @user_project_favorites ||= Favorite.where(favorited_type: "Project", user_id: User.current.id) + @user_project_favorites ||= Favorite.where(favorited_type: "Project", user_id: current_user.id) end # Builds a nested structure from a flat, lft-ordered list of projects. diff --git a/app/views/header/projects/index.html.erb b/app/views/header/projects/index.html.erb index 2f14ff2fdf0b..7f3a8455ca93 100644 --- a/app/views/header/projects/index.html.erb +++ b/app/views/header/projects/index.html.erb @@ -35,11 +35,12 @@ See COPYRIGHT and LICENSE files for more details. ) ) do |tree| %> <% @tree.each do |node| %> - <%= render "header/projects/node", - component: tree, - node: node, - current_project_id: @current_project_id, - favorited_ids: @favorited_ids, - jump: @jump %> + <%= render Header::Projects::NodeComponent.new( + component: tree, + node:, + current_project_id: @current_project_id, + favorited_ids: @favorited_ids, + jump: @jump + ) %> <% end %> <% end %> diff --git a/config/locales/en.yml b/config/locales/en.yml index eec10f55081b..104979371477 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -3590,13 +3590,6 @@ en: catppt: "Catppt available (optional)" tesseract: "Tesseract available (optional)" - header_project_select: - all_projects: "All projects" - favorites: "Favorites" - leave_project: "Leave project" - project_list: "Project lists" - title: "Projects" - filterable_tree_view: filter_mode: all: "All" @@ -3605,6 +3598,13 @@ en: include_sub_items: "Include sub-items" no_results_text: "No results" + header: + project_select_component: + all_projects: "All projects" + favorites: "Favorites" + leave_project: "Leave project" + title: "Projects" + toggle_switch: label_on: "On" label_off: "Off" diff --git a/frontend/src/global_styles/primer/_overrides.sass b/frontend/src/global_styles/primer/_overrides.sass index 80b58e7df490..4a2d20ff1403 100644 --- a/frontend/src/global_styles/primer/_overrides.sass +++ b/frontend/src/global_styles/primer/_overrides.sass @@ -220,7 +220,7 @@ ul.SegmentedControl, animation: none clip-path: inset(0) -// At some point in time, this needs be extracted into a proper component, +// At some point in time, this needs to be extracted into a proper component, // something like `FilterableTreeViewSelectPanel` .op-project-select--body .FilterableTreeViewLayout diff --git a/frontend/src/stimulus/controllers/header-project-select.controller.ts b/frontend/src/stimulus/controllers/header-project-select.controller.ts index efefac58001a..cf064424c5c7 100644 --- a/frontend/src/stimulus/controllers/header-project-select.controller.ts +++ b/frontend/src/stimulus/controllers/header-project-select.controller.ts @@ -1,22 +1,44 @@ /* + * -- 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. + * ++ */ import { Controller } from '@hotwired/stimulus'; const STORAGE_KEY = 'openProject-project-select-display-mode'; +const VALID_FILTER_MODES = new Set(['all', 'favorited']); +const NON_DEFAULT_FILTER_MODES = new Set(['favorited']); export default class HeaderProjectSelectController extends Controller { connect():void { - const stored = localStorage.getItem(STORAGE_KEY); - if (stored && stored !== 'all') { - // Defer until after all Stimulus controllers have connected (including + const stored = window.OpenProject.guardedLocalStorage(STORAGE_KEY); + if (stored && NON_DEFAULT_FILTER_MODES.has(stored)) { + // Defer until after all Stimulus/Catalyst controllers have connected (including // filterable-tree-view), so its click listener is already set up when // we programmatically activate the stored filter mode. setTimeout(() => { @@ -35,8 +57,8 @@ export default class HeaderProjectSelectController extends Controller { private onFilterModeClick = (event:MouseEvent):void => { const button = (event.target as HTMLElement).closest('[data-name]'); - if (button?.dataset.name) { - localStorage.setItem(STORAGE_KEY, button.dataset.name); + if (button?.dataset.name && VALID_FILTER_MODES.has(button.dataset.name)) { + window.OpenProject.guardedLocalStorage(STORAGE_KEY, button.dataset.name); } }; } diff --git a/lib/redmine/menu_manager/top_menu/projects_menu.rb b/lib/redmine/menu_manager/top_menu/projects_menu.rb index f6dfc4cf1adc..8367401d4ff3 100644 --- a/lib/redmine/menu_manager/top_menu/projects_menu.rb +++ b/lib/redmine/menu_manager/top_menu/projects_menu.rb @@ -39,8 +39,8 @@ def render_projects_top_menu_node def render_projects_dropdown render(Header::ProjectSelectComponent.new( current_project: @project, - current_user: User.current, - current_menu_item: current_menu_item + current_menu_item: current_menu_item, + current_user: User.current )) end diff --git a/lookbook/docs/components/tree-view/filterable-tree-view.md.erb b/lookbook/docs/components/tree-view/filterable-tree-view.md.erb index cffbb7bc850a..0f06fc80e1e4 100644 --- a/lookbook/docs/components/tree-view/filterable-tree-view.md.erb +++ b/lookbook/docs/components/tree-view/filterable-tree-view.md.erb @@ -97,14 +97,13 @@ By default, `FilterableTreeView` filters the tree client-side. When the tree is #### Behavior summary -- On initial rendering, the component fetches the full tree from `src`. +- On initial rendering, the component fetches a tree from `src`. - Every change to the filter input triggers a debounced (300 ms) GET request to `src`. - Switching the segmented control triggers a fetch for all modes except `"selected"`, which is handled client-side. - Toggling **"Include sub-items"** does **not** immediately trigger a server request. The current `include_sub_items` value is sent on the next fetch (query or filter-mode change), and the server must return the appropriate descendants at that point. - After a filtered response is received, the client automatically expands all sub-trees. - When the filter is cleared, the expansion state from before filtering started is restored. - When the form is submitted, the component sends all nodes that have been checked at **some point in time** (no matter whether the current filter shows them or not). -- When the form is submitted with **"Include sub-items"** checked, - On form submit: The form receives the complete selection regardless of the current filter state: - Nodes currently visible and checked are submitted via the `TreeView`'s normal form inputs. - Nodes that were checked but are currently filtered out (not in the DOM) are submitted via hidden inputs injected by the component directly. From 628bb8513b748655719fb4aa87c5d2d0dae725be Mon Sep 17 00:00:00 2001 From: Henriette Darge Date: Tue, 2 Jun 2026 11:00:57 +0200 Subject: [PATCH 7/9] Add a turboFrame in the project select overlay to only load the projects when it is actually opened --- .../header/project_select_component.html.erb | 22 +- .../header/project_select_component.rb | 2 +- .../header/project_select_component.sass | 5 + .../filterable_tree_view_component.html.erb | 57 ++++++ .../filterable_tree_view_component.rb | 52 +++++ app/controllers/header/projects_controller.rb | 10 +- config/routes.rb | 6 +- .../header-project-select.controller.ts | 32 +-- .../header/projects_controller_spec.rb | 190 ++++++++++++++++++ spec/support/components/projects/top_menu.rb | 1 + 10 files changed, 345 insertions(+), 32 deletions(-) create mode 100644 app/components/header/projects/filterable_tree_view_component.html.erb create mode 100644 app/components/header/projects/filterable_tree_view_component.rb create mode 100644 spec/controllers/header/projects_controller_spec.rb diff --git a/app/components/header/project_select_component.html.erb b/app/components/header/project_select_component.html.erb index 0bcd3a4e9ad4..3da40e158876 100644 --- a/app/components/header/project_select_component.html.erb +++ b/app/components/header/project_select_component.html.erb @@ -52,22 +52,10 @@ See COPYRIGHT and LICENSE files for more details. classes: "op-project-select--body", data: { controller: "header-project-select", test_selector: "op-header-project-select" } ) do %> - <%= render( - Primer::OpenProject::FilterableTreeView.new( - src: tree_src, - include_sub_items_check_box_arguments: { hidden: true }, - filter_mode_control_arguments: logged? ? {} : { hidden: true }, - filter_input_arguments: { name: "filter", - label: t(:label_filter), - visually_hide_label: true, - data: { test_selector: "op-header-project-select--search" } }, - no_results_node_arguments: { data: { test_selector: "op-header-project-select--no-results" }, label: I18n.t("filterable_tree_view.no_results_text") } - ) - ) do |tree_view| %> - <% if logged? %> - <% tree_view.with_filter_mode(name: "all", label: t("filterable_tree_view.filter_mode.all"), selected: true) %> - <% tree_view.with_filter_mode(name: "favorited", label: t(".favorites")) %> - <% end %> - <% end %> + <%= helpers.turbo_frame_tag "op-header-project-frame", + src: tree_src, + loading: :lazy, + target: "_top", + class: "op-project-select--frame" %> <% end %> <% end %> diff --git a/app/components/header/project_select_component.rb b/app/components/header/project_select_component.rb index f51464f7ca5b..c90bab8760f1 100644 --- a/app/components/header/project_select_component.rb +++ b/app/components/header/project_select_component.rb @@ -46,7 +46,7 @@ def trigger_label end def tree_src - header_projects_path( + frame_header_projects_path( current_project_id: @current_project&.id, jump: @current_menu_item.presence ) diff --git a/app/components/header/project_select_component.sass b/app/components/header/project_select_component.sass index 35c55bbfae9a..a4f88734cf17 100644 --- a/app/components/header/project_select_component.sass +++ b/app/components/header/project_select_component.sass @@ -28,11 +28,16 @@ .op-project-select &--body + display: flex max-height: 75dvh overflow: hidden margin: var(--base-size-16) margin-right: 0 + &--frame + max-height: 100% + flex-basis: 100% + &--trigger-button min-width: unset display: block diff --git a/app/components/header/projects/filterable_tree_view_component.html.erb b/app/components/header/projects/filterable_tree_view_component.html.erb new file mode 100644 index 000000000000..b61297143550 --- /dev/null +++ b/app/components/header/projects/filterable_tree_view_component.html.erb @@ -0,0 +1,57 @@ +<%#-- 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. + +++#%> + +<%= helpers.turbo_frame_tag "op-header-project-frame", class: "op-project-select--frame" do %> + <%= render( + Primer::OpenProject::FilterableTreeView.new( + src: tree_src, + include_sub_items_check_box_arguments: { hidden: true }, + filter_mode_control_arguments: logged? ? {} : { hidden: true }, + filter_input_arguments: { name: "filter", + label: t(:label_filter), + visually_hide_label: true, + data: { test_selector: "op-header-project-select--search" } }, + no_results_node_arguments: { data: { test_selector: "op-header-project-select--no-results" }, + label: I18n.t("filterable_tree_view.no_results_text") } + ) + ) do |tree_view| %> + <% if logged? %> + <% tree_view.with_filter_mode( + name: "all", + label: t("filterable_tree_view.filter_mode.all"), + selected: @filter_mode != "favorited" + ) %> + <% tree_view.with_filter_mode( + name: "favorited", + label: t("header.project_select_component.favorites"), + selected: @filter_mode == "favorited" + ) %> + <% end %> + <% end %> +<% end %> diff --git a/app/components/header/projects/filterable_tree_view_component.rb b/app/components/header/projects/filterable_tree_view_component.rb new file mode 100644 index 000000000000..03c0310ec944 --- /dev/null +++ b/app/components/header/projects/filterable_tree_view_component.rb @@ -0,0 +1,52 @@ +# 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. +#++ + +module Header + module Projects + class FilterableTreeViewComponent < ApplicationComponent + def initialize(current_project_id:, jump:, filter_mode:) + super() + @current_project_id = current_project_id + @jump = jump + @filter_mode = filter_mode + end + + private + + def tree_src + helpers.header_projects_path(current_project_id: @current_project_id, jump: @jump.presence) + end + + def logged? + helpers.current_user.logged? + end + end + end +end diff --git a/app/controllers/header/projects_controller.rb b/app/controllers/header/projects_controller.rb index 6eca39bcc14d..0fbda05651ff 100644 --- a/app/controllers/header/projects_controller.rb +++ b/app/controllers/header/projects_controller.rb @@ -29,7 +29,7 @@ #++ class Header::ProjectsController < ApplicationController - no_authorization_required! :index + no_authorization_required! :index, :frame MAX_NUMBER_OF_PROJECTS = 300 VALID_FILTER_MODES = %w[all favorited].freeze @@ -44,6 +44,14 @@ def index render layout: false end + def frame + render Header::Projects::FilterableTreeViewComponent.new( + current_project_id: params[:current_project_id].presence&.to_i, + jump: params[:jump].presence, + filter_mode: filter_mode + ), layout: false + end + private def query diff --git a/config/routes.rb b/config/routes.rb index 9a85dee16c61..ecc976d651e2 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -311,7 +311,11 @@ end namespace :header do - resources :projects, only: :index + resources :projects, only: :index do + collection do + get :frame + end + end end %w[portfolio project program].each do |workspace_type| diff --git a/frontend/src/stimulus/controllers/header-project-select.controller.ts b/frontend/src/stimulus/controllers/header-project-select.controller.ts index cf064424c5c7..b3bfd25850a4 100644 --- a/frontend/src/stimulus/controllers/header-project-select.controller.ts +++ b/frontend/src/stimulus/controllers/header-project-select.controller.ts @@ -36,25 +36,33 @@ const NON_DEFAULT_FILTER_MODES = new Set(['favorited']); export default class HeaderProjectSelectController extends Controller { connect():void { - const stored = window.OpenProject.guardedLocalStorage(STORAGE_KEY); - if (stored && NON_DEFAULT_FILTER_MODES.has(stored)) { - // Defer until after all Stimulus/Catalyst controllers have connected (including - // filterable-tree-view), so its click listener is already set up when - // we programmatically activate the stored filter mode. - setTimeout(() => { - this.element - .querySelector(`[data-name="${stored}"]`) - ?.click(); - }, 0); - } - this.element.addEventListener('click', this.onFilterModeClick); + + // Before the overlay becomes visible, inject the stored filter mode into + // the turbo-frame src so the server renders the correct initial state. + const popover = this.element.closest('[popover]'); + popover?.addEventListener('beforetoggle', this.onBeforeFirstOpen, { once: true }); } disconnect():void { this.element.removeEventListener('click', this.onFilterModeClick); } + private onBeforeFirstOpen = ():void => { + const stored = window.OpenProject.guardedLocalStorage(STORAGE_KEY); + if (!stored || !NON_DEFAULT_FILTER_MODES.has(stored)) return; + + const frame = this.element.querySelector('turbo-frame#op-header-project-frame'); + if (!frame) return; + + const src = frame.getAttribute('src'); + if (!src) return; + + const url = new URL(src, window.location.href); + url.searchParams.set('filter_mode', stored); + frame.setAttribute('src', url.toString()); + }; + private onFilterModeClick = (event:MouseEvent):void => { const button = (event.target as HTMLElement).closest('[data-name]'); if (button?.dataset.name && VALID_FILTER_MODES.has(button.dataset.name)) { diff --git a/spec/controllers/header/projects_controller_spec.rb b/spec/controllers/header/projects_controller_spec.rb new file mode 100644 index 000000000000..51cebbf4bdf7 --- /dev/null +++ b/spec/controllers/header/projects_controller_spec.rb @@ -0,0 +1,190 @@ +# 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::ProjectsController do + shared_let(:current_user) { create(:user) } + + before do + login_as current_user + end + + describe "#index" do + shared_let(:parent_project) { create(:project, name: "Alpha Parent") } + shared_let(:child_project) { create(:project, name: "Beta Child", parent: parent_project) } + shared_let(:other_project) { create(:project, name: "Gamma Other") } + + shared_let(:role) { create(:project_role) } + + before do + # Grant visibility via membership + create(:member, principal: current_user, project: parent_project, roles: [role]) + create(:member, principal: current_user, project: child_project, roles: [role]) + create(:member, principal: current_user, project: other_project, roles: [role]) + end + + subject(:make_request) { get :index } + + it "returns HTTP 200" do + make_request + expect(response).to have_http_status(:ok) + end + + it "includes visible active projects" do + make_request + expect(assigns(:projects)).to include(parent_project, child_project, other_project) + end + + it "renders without layout" do + make_request + expect(response).to render_template(layout: false) + end + + context "when searching by query" do + subject(:make_request) { get :index, params: { query: "Beta" } } + + it "returns only matching projects and their ancestors" do + make_request + expect(assigns(:projects)).to include(child_project, parent_project) + expect(assigns(:projects)).not_to include(other_project) + end + + it "marks non-matching ancestors as not matching the query" do + make_request + tree = assigns(:tree) + parent_node = tree.find { |n| n[:project] == parent_project } + expect(parent_node[:matches_query]).to be(false) + end + end + + context "with filter_mode=favorited" do + subject(:make_request) { get :index, params: { filter_mode: "favorited" } } + + context "when the user has favorited a child project" do + before do + create(:favorite, user: current_user, favorited: child_project) + end + + it "returns only favorited projects and their ancestors" do + make_request + expect(assigns(:projects)).to include(child_project, parent_project) + expect(assigns(:projects)).not_to include(other_project) + end + + it "populates favorited_ids with the favorited project" do + make_request + expect(assigns(:favorited_ids)).to include(child_project.id) + end + end + + context "when the user has no favorites" do + it "returns an empty project list" do + make_request + expect(assigns(:projects)).to be_empty + end + end + + context "when the user is anonymous" do + let(:current_user) { User.anonymous } + + it "returns an empty project list" do + make_request + expect(assigns(:projects)).to be_blank + end + end + end + + context "with current_project_id for a project not in the default list" do + let(:invisible_child) { create(:project, name: "Hidden Child", parent: parent_project) } + + subject(:make_request) { get :index, params: { current_project_id: invisible_child.id } } + + before do + create(:member, principal: current_user, project: invisible_child, roles: [role]) + end + + it "includes the current project and its ancestors" do + make_request + expect(assigns(:projects)).to include(invisible_child, parent_project) + end + end + + context "with an invalid filter_mode param" do + it "defaults to showing all projects" do + get :index, params: { filter_mode: "invalid" } + expect(assigns(:projects)).to include(parent_project, child_project, other_project) + end + end + end + + describe "#frame" do + subject(:make_request) { get :frame } + + it "returns HTTP 200" do + make_request + expect(response).to have_http_status(:ok) + end + + it "renders without layout" do + make_request + expect(response).to render_template(layout: false) + end + + it "renders the FilterableTreeViewComponent" do + make_request + expect(response.body).to include("op-header-project-frame") + end + + context "with filter_mode=favorited" do + it "passes filter_mode to the component" do + allow(Header::Projects::FilterableTreeViewComponent).to receive(:new).and_call_original + + get :frame, params: { filter_mode: "favorited" } + + expect(Header::Projects::FilterableTreeViewComponent).to have_received(:new).with( + hash_including(filter_mode: "favorited") + ) + end + end + + context "with an invalid filter_mode" do + it "defaults filter_mode to 'all'" do + allow(Header::Projects::FilterableTreeViewComponent).to receive(:new).and_call_original + + get :frame, params: { filter_mode: "bogus" } + + expect(Header::Projects::FilterableTreeViewComponent).to have_received(:new).with( + hash_including(filter_mode: "all") + ) + end + end + end +end diff --git a/spec/support/components/projects/top_menu.rb b/spec/support/components/projects/top_menu.rb index 3579fe97227a..e5ead2306d7e 100644 --- a/spec/support/components/projects/top_menu.rb +++ b/spec/support/components/projects/top_menu.rb @@ -87,6 +87,7 @@ def clear_search def search_and_select(query) search query + wait_for_network_idle selector = "#{results_selector} #{item_selector}" item = page.first(selector, text: query, wait: 5) || page.find(selector, wait: 5) item.click From 6332f015241a800f4af39e1eda7ebd1e6e1edd44 Mon Sep 17 00:00:00 2001 From: Henriette Darge Date: Tue, 2 Jun 2026 14:21:06 +0200 Subject: [PATCH 8/9] Restore BIM tab styles which were broken for a while already but the new project selector changes made it so bad that the test broke because the plus icon was overlapping the checkbox --- .../features/bim/ifc_models/pages/viewer/styles/tabs.sass | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/frontend/src/app/features/bim/ifc_models/pages/viewer/styles/tabs.sass b/frontend/src/app/features/bim/ifc_models/pages/viewer/styles/tabs.sass index 713ab9e8cfad..ed0fc67d4a38 100644 --- a/frontend/src/app/features/bim/ifc_models/pages/viewer/styles/tabs.sass +++ b/frontend/src/app/features/bim/ifc_models/pages/viewer/styles/tabs.sass @@ -121,7 +121,7 @@ $pill-padding-left: 8px .xeokit-tab - ul + ul:not(.TreeViewRootUlStyles) list-style: none padding-left: 30px margin-left: -1 * $pill-padding-left @@ -140,7 +140,8 @@ $pill-padding-left: 8px .main-menu &:not(.Button) // reset main menu indents - padding: initial + padding: initial !important + border: 0 &:hover, &:focus, From dc7491ed54dccb97e57bd834176bfd5f065fbd85 Mon Sep 17 00:00:00 2001 From: Henriette Darge Date: Wed, 3 Jun 2026 15:23:42 +0200 Subject: [PATCH 9/9] Clarify spec expectation --- frontend/src/global_styles/layout/_main_menu.sass | 2 +- lookbook/docs/components/tree-view/filterable-tree-view.md.erb | 2 +- spec/controllers/header/projects_controller_spec.rb | 3 ++- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/frontend/src/global_styles/layout/_main_menu.sass b/frontend/src/global_styles/layout/_main_menu.sass index c4a8f16fb68b..d20d90fab6a1 100644 --- a/frontend/src/global_styles/layout/_main_menu.sass +++ b/frontend/src/global_styles/layout/_main_menu.sass @@ -100,7 +100,7 @@ $arrow-left-width: 36px .main-menu--children li a:not(.Button) // children have no icon so we need to push them right. - padding-left: var(--main-menu-x-spacing) + padding-left: var(--main-menu-x-spacing) !important a:not(.Button):not(.TreeViewItemContent) text-decoration: none diff --git a/lookbook/docs/components/tree-view/filterable-tree-view.md.erb b/lookbook/docs/components/tree-view/filterable-tree-view.md.erb index 0f06fc80e1e4..9f0903dce090 100644 --- a/lookbook/docs/components/tree-view/filterable-tree-view.md.erb +++ b/lookbook/docs/components/tree-view/filterable-tree-view.md.erb @@ -39,7 +39,7 @@ The search field enables text-based filtering of the tree. **Single-select considerations (`select_variant: :single`):** - Selections made **before** filtering are preserved, even if they are not visible in the current filter. -- On form submission, **the selected item** is submitted, even if it is currently not shown (e.g filtered out). +- On form submission, **the selected item** is submitted, even if it is currently not shown (e.g. filtered out). **Multi-select considerations (`select_variant: :multiple`):** diff --git a/spec/controllers/header/projects_controller_spec.rb b/spec/controllers/header/projects_controller_spec.rb index 51cebbf4bdf7..87f7e488f941 100644 --- a/spec/controllers/header/projects_controller_spec.rb +++ b/spec/controllers/header/projects_controller_spec.rb @@ -122,12 +122,13 @@ end end - context "with current_project_id for a project not in the default list" do + context "with current_project_id for a project outside the default limit" do let(:invisible_child) { create(:project, name: "Hidden Child", parent: parent_project) } subject(:make_request) { get :index, params: { current_project_id: invisible_child.id } } before do + stub_const("Header::ProjectsController::MAX_NUMBER_OF_PROJECTS", 1) create(:member, principal: current_user, project: invisible_child, roles: [role]) end