diff --git a/app/components/_index.sass b/app/components/_index.sass index de484ac1523..dfef4ac3790 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 00000000000..3da40e15887 --- /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 00000000000..c90bab8760f --- /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 00000000000..a4f88734cf1 --- /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 00000000000..b6129714355 --- /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 00000000000..03c0310ec94 --- /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 00000000000..740185e15f7 --- /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 00000000000..7f1574cfb0d --- /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 00000000000..0fbda05651f --- /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 00000000000..1a22579784a --- /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 00000000000..7f3a8455ca9 --- /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 9f47732ce75..63344b1fc29 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 9c4cf449afc..3a02759c3c8 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 b30de28cb28..e12cf76bc09 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -310,6 +310,14 @@ Rails.application.routes.draw do 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 e9d138cb623..278d09a71b0 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -4441,6 +4441,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" } @@ -4449,45 +4450,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", @@ -4498,12 +4506,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", @@ -7458,9 +7468,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 b3de94b03e8..8f664848c7a 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 713ab9e8cfa..ed0fc67d4a3 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 0e47fa61439..00000000000 --- 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 f7e0e4b44d5..00000000000 --- 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 d61fffe3730..00000000000 --- 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 160c9f7f130..00000000000 --- 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 37784251c71..00000000000 --- 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 80c1f28b83f..00000000000 --- 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 83c15898858..00000000000 --- 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 18dc1dc8068..d20d90fab6a 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 3215f428457..4a2d20ff140 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 00000000000..b3bfd25850a --- /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 da140656c9a..303e1ab973d 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 6673fb4cdbc..8367401d4ff 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 @@ module Redmine::MenuManager::TopMenu::ProjectsMenu 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 dcccdb1fbd4..9f0903dce09 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 00000000000..87f7e488f94 --- /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 bf65de0bf80..98dc4a83d36 100644 --- a/spec/features/admin/menu_item_traversal_spec.rb +++ b/spec/features/admin/menu_item_traversal_spec.rb @@ -83,7 +83,7 @@ RSpec.describe "Menu item traversal" do 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 e0311e5fc00..8e8a854a7aa 100644 --- a/spec/features/projects/favorite_spec.rb +++ b/spec/features/projects/favorite_spec.rb @@ -153,8 +153,9 @@ RSpec.describe "Favorite projects", :js do 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 @@ RSpec.describe "Favorite projects", :js do 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 8300c55c1cc..39c42211fca 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 @@ RSpec.describe "Projects navigation", :js do 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 @@ RSpec.describe "Projects navigation", :js do 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 @@ RSpec.describe "Projects navigation", :js do 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 @@ RSpec.describe "Projects navigation", :js do 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 536732c5b6b..4ffc8994b62 100644 --- a/spec/features/projects/project_autocomplete_spec.rb +++ b/spec/features/projects/project_autocomplete_spec.rb @@ -104,9 +104,8 @@ RSpec.describe "Projects autocomplete page", :js do # Filter for projects top_menu.search "