diff --git a/app/components/_index.sass b/app/components/_index.sass index de484ac15231..dfef4ac3790e 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..3da40e158876 --- /dev/null +++ b/app/components/header/project_select_component.html.erb @@ -0,0 +1,61 @@ +<%#-- 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(".title"), + visually_hide_title: true, + anchor_side: :outside_bottom, + anchor_align: :start, + size: :medium_portrait, + 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" }, + accesskey: "5" + ) 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", + test_selector: "op-header-project-select" } + ) do %> + <%= 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 new file mode 100644 index 000000000000..c90bab8760f1 --- /dev/null +++ b/app/components/header/project_select_component.rb @@ -0,0 +1,59 @@ +# 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 + + 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 + @current_menu_item = current_menu_item + end + + def trigger_label + @current_project&.name || t(".all_projects") + end + + def tree_src + frame_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 + 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..a4f88734cf17 --- /dev/null +++ b/app/components/header/project_select_component.sass @@ -0,0 +1,46 @@ +//-- 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 + 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 + + .Button-label + @include text-shortener 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/components/header/projects/node_component.html.erb b/app/components/header/projects/node_component.html.erb new file mode 100644 index 000000000000..740185e15f75 --- /dev/null +++ b/app/components/header/projects/node_component.html.erb @@ -0,0 +1,75 @@ +<%#-- 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. + +++#%> + +<% add_icons = ->(tree_node) do + if current? + tree_node.with_trailing_action_button( + icon: :"x-circle", + tag: :a, + href: home_path(jump: @jump), + show_tooltip: false, + 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( + label:, + select_variant: :none, + current: current?, + expanded: expanded?, + href:, + 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::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( + label:, + select_variant: :none, + current: current?, + href:, + disabled: !matches_query?, + data: { node_id: project.id, test_selector: "op-header-project-select--item" } + ) do |leaf| %> + <% add_icons.call(leaf) %> + <% end %> +<% end %> 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 new file mode 100644 index 000000000000..0fbda05651ff --- /dev/null +++ b/app/controllers/header/projects_controller.rb @@ -0,0 +1,168 @@ +# 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, :frame + + MAX_NUMBER_OF_PROJECTS = 300 + VALID_FILTER_MODES = %w[all favorited].freeze + + def index + @current_project_id = params[:current_project_id].presence&.to_i + @jump = params[:jump].presence + @projects = load_projects + @favorited_ids = load_favorited_ids + @tree = build_tree(@projects) + + 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 + @query ||= params[:query].to_s.strip + end + + def filter_mode + mode = params[:filter_mode].to_s + VALID_FILTER_MODES.include?(mode) ? mode : "all" + end + + def load_projects + projects = base_scope.to_a + projects = ensure_current_project_present(projects) + + 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) + end + + projects + end + + def base_scope + scope = Project.visible.active.order(:lft).limit(MAX_NUMBER_OF_PROJECTS) + query.split.each do |term| + scope = scope.where("LOWER(name) LIKE LOWER(?)", "%#{ActiveRecord::Base.sanitize_sql_like(term)}%") + end + if filter_mode == "favorited" + scope = current_user.logged? ? scope.where(id: favorite_project_ids) : scope.none + end + scope + end + + def ensure_current_project_present(projects) + 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.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) + return Project.none if projects.empty? + + 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 + user_project_favorites.select(:favorited_id) + end + + def load_favorited_ids + return Set.new unless current_user.logged? + + 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: current_user.id) + end + + # Builds a nested structure from a flat, lft-ordered list of projects. + # Each level is sorted alphabetically by project name. + def build_tree(projects) + 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| + node = nodes[project.id] + parent = nodes[project.parent_id] + + if parent + parent[:children] << node + else + roots << node + end + end + + 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/helpers/header/projects_helper.rb b/app/helpers/header/projects_helper.rb new file mode 100644 index 000000000000..1a22579784a7 --- /dev/null +++ b/app/helpers/header/projects_helper.rb @@ -0,0 +1,62 @@ +# 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) + project.workspace_type.in?(%w[portfolio program]) + end + 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..7f3a8455ca93 --- /dev/null +++ b/app/views/header/projects/index.html.erb @@ -0,0 +1,46 @@ +<%#-- 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", + test_selector: "op-header-project-select--list" } + ) + ) do |tree| %> + <% @tree.each do |node| %> + <%= 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 1fbb87c1bf75..95725a638c77 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -3600,6 +3600,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/config/locales/js-en.yml b/config/locales/js-en.yml index 9c4cf449afcc..3a02759c3c81 100644 --- a/config/locales/js-en.yml +++ b/config/locales/js-en.yml @@ -1114,16 +1114,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/config/routes.rb b/config/routes.rb index b30de28cb28f..e12cf76bc09e 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -310,6 +310,14 @@ resource :identifier_suggestion, only: %i[show], controller: "identifier_suggestion" end + namespace :header do + resources :projects, only: :index do + collection do + get :frame + end + end + 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 0ea99df4102a..0c7f63984727 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -4404,6 +4404,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" } @@ -4412,45 +4413,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", @@ -4461,12 +4469,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", @@ -7421,9 +7431,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", 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/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, 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 0e47fa61439b..000000000000 --- a/frontend/src/app/shared/components/header-project-select/header-project-select.component.html +++ /dev/null @@ -1,102 +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 { - - } -
    - } -
    - -
    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 d61fffe37309..000000000000 --- a/frontend/src/app/shared/components/header-project-select/header-project-select.component.ts +++ /dev/null @@ -1,297 +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'; - -@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 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 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: { - plural: this.I18n.t('js.label_project_plural'), - select: this.I18n.t('js.label_all_projects'), - search_placeholder: this.I18n.t('js.include_projects.search_placeholder') - }, - workspace: { - 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') - }; - - public get currentText() { - return this.text.workspace; - } - - 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 37784251c713..000000000000 --- a/frontend/src/app/shared/components/header-project-select/list/header-project-select-list.component.html +++ /dev/null @@ -1,108 +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())) { - - } - @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 }} - @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 83c15898858a..000000000000 --- a/frontend/src/app/shared/components/header-project-select/list/header-project-select-list.component.ts +++ /dev/null @@ -1,117 +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 { 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 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') - }; - - 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}`; - } -} diff --git a/frontend/src/global_styles/layout/_main_menu.sass b/frontend/src/global_styles/layout/_main_menu.sass index 18dc1dc80684..d20d90fab6a1 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 @@ -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) @@ -100,9 +100,9 @@ $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) + 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 3215f4284578..4a2d20ff1403 100644 --- a/frontend/src/global_styles/primer/_overrides.sass +++ b/frontend/src/global_styles/primer/_overrides.sass @@ -219,3 +219,12 @@ ul.SegmentedControl, &:before animation: none clip-path: inset(0) + +// At some point in time, this needs to 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..b3bfd25850a4 --- /dev/null +++ b/frontend/src/stimulus/controllers/header-project-select.controller.ts @@ -0,0 +1,72 @@ +/* + * -- 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 { + 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)) { + window.OpenProject.guardedLocalStorage(STORAGE_KEY, button.dataset.name); + } + }; +} diff --git a/frontend/src/stimulus/setup.ts b/frontend/src/stimulus/setup.ts index da140656c9ab..303e1ab973df 100644 --- a/frontend/src/stimulus/setup.ts +++ b/frontend/src/stimulus/setup.ts @@ -29,6 +29,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'; @@ -97,6 +98,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..8367401d4ff3 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_menu_item: current_menu_item, + current_user: User.current + )) 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..9f0903dce090 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). +- 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,162 @@ 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 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). +- 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 +249,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/controllers/header/projects_controller_spec.rb b/spec/controllers/header/projects_controller_spec.rb new file mode 100644 index 000000000000..87f7e488f941 --- /dev/null +++ b/spec/controllers/header/projects_controller_spec.rb @@ -0,0 +1,191 @@ +# 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 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 + + 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/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/projects/favorite_spec.rb b/spec/features/projects/favorite_spec.rb index e0311e5fc007..8e8a854a7aa3 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 8300c55c1cc0..39c42211fca9 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 536732c5b6b3..4ffc8994b629 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 "