diff --git a/app/components/_index.sass b/app/components/_index.sass index 7fb1cf543d5a..eb4fcff82185 100644 --- a/app/components/_index.sass +++ b/app/components/_index.sass @@ -44,6 +44,7 @@ @import "work_packages/progress/modal_body_component" @import "work_packages/reminder/modal_body_component" @import "work_packages/split_view_component" +@import "header/project_select_component" @import "homescreen/link_component" @import "homescreen/links_component" @import "homescreen/new_features_component" diff --git a/app/components/header/project_select_component.html.erb b/app/components/header/project_select_component.html.erb new file mode 100644 index 000000000000..3e1435420457 --- /dev/null +++ b/app/components/header/project_select_component.html.erb @@ -0,0 +1,81 @@ +<%#-- copyright +OpenProject is an open source project management software. +Copyright (C) the OpenProject GmbH + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License version 3. + +OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +Copyright (C) 2006-2013 Jean-Philippe Lang +Copyright (C) 2010-2013 the ChiliProject Team + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License +as published by the Free Software Foundation; either version 2 +of the License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program; if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +See COPYRIGHT and LICENSE files for more details. + +++#%> + +<%= render( + Primer::Alpha::Overlay.new( + title: t("header_project_select.title"), + visually_hide_title: true, + anchor_side: :outside_bottom, + anchor_align: :start, + size: :auto, + padding: :none + ) + ) do |overlay| %> + <% overlay.with_show_button( + id: "projects-menu", + classes: "op-project-select--trigger-button", + scheme: :invisible, + data: { "test-selector": "op-projects-menu" } + ) do |button| %> + <%= trigger_label %> + <% button.with_trailing_visual_icon(icon: :"triangle-down") %> + <% end %> + + <% overlay.with_body(classes: "op-project-select--body", data: { controller: "header-project-select" }) do %> + <%= render( + Primer::OpenProject::FilterableTreeView.new( + src: tree_src, + include_sub_items_check_box_arguments: { hidden: true }, + filter_mode_control_arguments: logged_in? ? {} : { hidden: true } + ) + ) do |tree_view| %> + <% if logged_in? %> + <% tree_view.with_filter_mode(name: "all", label: t("filterable_tree_view.filter_mode.all"), selected: true) %> + <% tree_view.with_filter_mode(name: "favorited", label: t("header_project_select.favorites")) %> + <% end %> + <% end %> + + <% unless OpenProject::FeatureDecisions.portfolio_models_active? %> + <%= helpers.flex_layout(justify_content: :flex_end, p: 2) do |bar| %> + <% bar.with_column(mr: 2) do %> + <%= render(Primer::Beta::Button.new(tag: :a, href: projects_path)) do %> + <%= t("header_project_select.all_projects") %> + <% end %> + <% end %> + <% if can_create_projects? %> + <% bar.with_column do %> + <%= render(Primer::Beta::Button.new(tag: :a, href: new_project_path, scheme: :primary)) do %> + <%= t("header_project_select.new_project") %> + <% end %> + <% end %> + <% end %> + <% end %> + <% end %> + <% end %> +<% end %> diff --git a/app/components/header/project_select_component.rb b/app/components/header/project_select_component.rb new file mode 100644 index 000000000000..51a9f046d0dc --- /dev/null +++ b/app/components/header/project_select_component.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module Header + class ProjectSelectComponent < ApplicationComponent + include OpenProject::StaticRouting::UrlHelpers + + def initialize(current_project: nil, current_user: User.current, current_menu_item: nil) + super() + @current_project = current_project + @current_user = current_user + @current_menu_item = current_menu_item + end + + def trigger_label + @current_project&.name || I18n.t("header_project_select.trigger_no_project") + end + + def tree_src + header_projects_path( + current_project_id: @current_project&.id, + jump: @current_menu_item.presence + ) + end + + def can_create_projects? + @current_user.allowed_globally?(:add_project) + end + + def logged_in? + @current_user.logged? + end + end +end diff --git a/app/components/header/project_select_component.sass b/app/components/header/project_select_component.sass new file mode 100644 index 000000000000..35c55bbfae9a --- /dev/null +++ b/app/components/header/project_select_component.sass @@ -0,0 +1,41 @@ +//-- copyright +// OpenProject is an open source project management software. +// Copyright (C) the OpenProject GmbH +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License version 3. +// +// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +// Copyright (C) 2006-2013 Jean-Philippe Lang +// Copyright (C) 2010-2013 the ChiliProject Team +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License +// as published by the Free Software Foundation; either version 2 +// of the License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program; if not, write to the Free Software +// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +// +// See COPYRIGHT and LICENSE files for more details. +//++ + +.op-project-select + &--body + max-height: 75dvh + overflow: hidden + margin: var(--base-size-16) + margin-right: 0 + + &--trigger-button + min-width: unset + display: block + + .Button-label + @include text-shortener diff --git a/app/controllers/header/projects_controller.rb b/app/controllers/header/projects_controller.rb new file mode 100644 index 000000000000..15946aea5533 --- /dev/null +++ b/app/controllers/header/projects_controller.rb @@ -0,0 +1,118 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +class Header::ProjectsController < ApplicationController + no_authorization_required! :index + + MAX_NUMBER_OF_PROJECTS = 300 + + def index + @current_project_id = params[:current_project_id]&.to_i + @jump = params[:jump].presence + @projects = load_projects + @favorited_ids = load_favorited_ids + @tree = build_tree(@projects) + + render layout: false + end + + private + + def query + params[:query].to_s.strip + end + + def filter_mode + params[:filter_mode].to_s + end + + def load_projects + projects = base_scope.to_a + ensure_current_project_present(projects) + end + + def base_scope + scope = Project.visible.active.order(:lft).limit(MAX_NUMBER_OF_PROJECTS) + scope = scope.where("LOWER(name) LIKE LOWER(?)", "%#{ActiveRecord::Base.sanitize_sql_like(query)}%") if query.present? + scope = scope.where(id: favorite_project_ids) if filter_mode == "favorited" && User.current.logged? + scope + end + + def ensure_current_project_present(projects) + return projects if query.present? || @current_project_id.blank? + return projects if projects.any? { |p| p.id == @current_project_id } + + current = Project.visible.active.find_by(id: @current_project_id) + return projects unless current + + (projects + current.self_and_ancestors.active.to_a).uniq(&:id).sort_by(&:lft) + end + + def favorite_project_ids + Favorite.where(favorited_type: "Project", user_id: User.current.id).select(:favorited_id) + end + + def load_favorited_ids + return Set.new unless User.current.logged? + + Favorite + .where(favorited_type: "Project", user_id: User.current.id, favorited_id: @projects.map(&:id)) + .pluck(:favorited_id) + .to_set + end + + # Builds a nested structure from a flat, lft-ordered list of projects. + # Projects whose parent is not in the result set appear as roots. + # Each level is sorted alphabetically by project name. + def build_tree(projects) + nodes = projects.index_by(&:id).transform_values { |p| { project: p, children: [] } } + + roots = [] + projects.each do |project| + node = nodes[project.id] + parent = nodes[project.parent_id] + + if parent + parent[:children] << node + else + roots << node + end + end + + sort_nodes(roots) + end + + def sort_nodes(nodes) + nodes.sort_by { |n| n[:project].name.downcase }.each do |node| + node[:children] = sort_nodes(node[:children]) + node[:expanded] = node[:children].any? { |c| c[:project].id == @current_project_id || c[:expanded] } + end + end +end diff --git a/app/views/header/projects/_node.html.erb b/app/views/header/projects/_node.html.erb new file mode 100644 index 000000000000..4db5cb13fbc2 --- /dev/null +++ b/app/views/header/projects/_node.html.erb @@ -0,0 +1,81 @@ +<%#-- copyright +OpenProject is an open source project management software. +Copyright (C) the OpenProject GmbH + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License version 3. + +OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +Copyright (C) 2006-2013 Jean-Philippe Lang +Copyright (C) 2010-2013 the ChiliProject Team + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License +as published by the Free Software Foundation; either version 2 +of the License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program; if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +See COPYRIGHT and LICENSE files for more details. + +++#%> + +<% project = node[:project] %> +<% children = node[:children] %> +<% is_current = project.id == current_project_id %> +<% is_favorited = favorited_ids.include?(project.id) %> +<% href = jump.present? ? project_path(project.identifier, jump:) : project_path(project.identifier) %> +<% is_expanded = node[:expanded] %> + +<% add_icons = ->(tree_node) do + if is_current + tree_node.with_trailing_action_button( + icon: :"x-circle", + tag: :a, + href: home_path(jump:), + show_tooltip: false, + aria: { label: t("header_project_select.leave_project") } + ) + end + if is_favorited + tree_node.with_trailing_visual_icon(icon: :"star-fill", classes: "op-primer--star-icon") + end + end %> + +<% if children.any? %> + <% component.with_sub_tree( + label: project.name, + select_variant: :none, + current: is_current, + expanded: is_expanded, + href:, + data: { node_id: project.id } + ) do |sub| %> + <% add_icons.call(sub) %> + <% children.each do |child_node| %> + <%= render "header/projects/node", + component: sub, + node: child_node, + current_project_id: current_project_id, + favorited_ids: favorited_ids, + jump: %> + <% end %> + <% end %> +<% else %> + <% component.with_leaf( + label: project.name, + select_variant: :none, + current: is_current, + href:, + data: { node_id: project.id } + ) do |leaf| %> + <% add_icons.call(leaf) %> + <% end %> +<% end %> diff --git a/app/views/header/projects/index.html.erb b/app/views/header/projects/index.html.erb new file mode 100644 index 000000000000..e58e600da780 --- /dev/null +++ b/app/views/header/projects/index.html.erb @@ -0,0 +1,44 @@ +<%#-- copyright +OpenProject is an open source project management software. +Copyright (C) the OpenProject GmbH + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License version 3. + +OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +Copyright (C) 2006-2013 Jean-Philippe Lang +Copyright (C) 2010-2013 the ChiliProject Team + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License +as published by the Free Software Foundation; either version 2 +of the License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program; if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +See COPYRIGHT and LICENSE files for more details. + +++#%> + +<%= render( + Primer::Alpha::TreeView.new( + node_variant: :anchor, + data: { target: "filterable-tree-view.treeViewList" } + ) + ) do |tree| %> + <% @tree.each do |node| %> + <%= render "header/projects/node", + component: tree, + node: node, + current_project_id: @current_project_id, + favorited_ids: @favorited_ids, + jump: @jump %> + <% end %> +<% end %> diff --git a/config/locales/en.yml b/config/locales/en.yml index 0fc9db206d6e..d7f75f3a0634 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -136,7 +136,7 @@ en: title: "Beta - Try it out!" description: "This Jira Migrator is currently in beta. We currently only support Jira Server/Data Center versions 10.x and 11.x. Cloud instances are not supported at this time." contribution_callout: > - Please, help us improve the Jira Migrator with your feedback and private data donations. You can [join the development community](link) of the Jira Migrator. + Please, help us improve the Jira Migrator with your feedback and private data donations. You can [join the development community](link) of the Jira Migrator. supported_versions: "" form: fields: @@ -3520,6 +3520,14 @@ en: catppt: "Catppt available (optional)" tesseract: "Tesseract available (optional)" + header_project_select: + all_projects: "All projects" + favorites: "Favorites" + leave_project: "Leave project" + new_project: "New project" + title: "Projects" + trigger_no_project: "Select a project" + filterable_tree_view: filter_mode: all: "All" diff --git a/config/locales/js-en.yml b/config/locales/js-en.yml index 50545ecb1255..1054bba75373 100644 --- a/config/locales/js-en.yml +++ b/config/locales/js-en.yml @@ -452,7 +452,6 @@ en: label_per_page: "Per page:" label_please_wait: "Please wait" label_project: "Project" - label_project_list: "Project lists" label_project_plural: "Projects" label_visibility_settings: "Visibility settings" label_quote_comment: "Quote this comment" @@ -1145,16 +1144,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 0b53becfc638..e157f76f06bb 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -286,6 +286,10 @@ resource :identifier_suggestion, only: %i[show], controller: "identifier_suggestion" end + namespace :header do + resources :projects, only: :index + end + %w[portfolio project program].each do |workspace_type| resources workspace_type.pluralize, only: %i[new create], diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 540fb751c88f..fcb316af0370 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -189,6 +189,9 @@ "fsevents": "*" } }, + "../../../Users/hdinger/code/primer_view_components": { + "extraneous": true + }, "node_modules/@algolia/abtesting": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/@algolia/abtesting/-/abtesting-1.14.1.tgz", @@ -5000,6 +5003,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" } @@ -5008,45 +5012,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", @@ -5057,12 +5068,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", @@ -8002,9 +8015,10 @@ } }, "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", @@ -29011,9 +29025,9 @@ } }, "@github/catalyst": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/@github/catalyst/-/catalyst-1.8.0.tgz", - "integrity": "sha512-uLpi/D/mKfylYaFLfzNuloXNENi0AlcM0Z7hwYLH8Z030jBCr+ueMdX2xLxCzpMH/keYXKh0uPrHSMfcbxU6KA==" + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@github/catalyst/-/catalyst-1.8.1.tgz", + "integrity": "sha512-dnN4WWpbeuQvA17LvsGdlXEueJdBk9y+I+WO5pdNpoHNOXPsFcz3hJrq1iRmdsNgQOf4S8e83axtwIxvG62eWA==" }, "@github/clipboard-copy-element": { "version": "1.3.0", @@ -29036,9 +29050,9 @@ "integrity": "sha512-Vgm2OwWAs1ESoib/t5sjxsAYo6YTOxxAjWDRxswX7qrqoyCejTZ3hshdo4Ep5e+Mz/GVTZC3rdMtg06dk/eT4g==" }, "@github/include-fragment-element": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/@github/include-fragment-element/-/include-fragment-element-6.3.0.tgz", - "integrity": "sha512-BJTt8ZE/arsbC9lQtTH8c1hZS0ZigiN+kzH54ffQ6MhHLT83h0OpSdS9NEVocPl2uuO6w3qxnEKTDzUGMQ5rdQ==" + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/@github/include-fragment-element/-/include-fragment-element-6.4.1.tgz", + "integrity": "sha512-ffgXc7qwBtY/rYcMkAjxZJlyOPFaeC9K1Oc+n7Edwt3BAHPokUSdMfDivb+/dGO+NU2n7l1/L4v5uQN+wBeV4g==" }, "@github/mini-throttle": { "version": "2.1.1", @@ -30838,9 +30852,9 @@ } }, "@primer/behaviors": { - "version": "1.3.5", - "resolved": "https://registry.npmjs.org/@primer/behaviors/-/behaviors-1.3.5.tgz", - "integrity": "sha512-HWwz+6MrfK5NTWcg9GdKFpMBW/yrAV937oXiw2eDtsd88P3SRwoCt6ZO6QmKp9RP3nDU9cbqmuGZ0xBh0eIFeg==" + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/@primer/behaviors/-/behaviors-1.10.2.tgz", + "integrity": "sha512-93juWZbWg2DRhC11+7RT7hMpY1VD3lBosLmccqEZ65yrCHqkBCjI8Uj8wxs3y0U+wWE07LAoLHAPylyWbifg5A==" }, "@primer/css": { "version": "22.1.0", diff --git a/frontend/src/app/app.module.ts b/frontend/src/app/app.module.ts index 1bca3a907a7b..3690db39bf6a 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'; @@ -264,10 +257,6 @@ export function runBootstrap(appRef:ApplicationRef) { // Main menu MainMenuResizerComponent, - // Project selector - OpHeaderProjectSelectComponent, - OpHeaderProjectSelectListComponent, - // Form configuration OpDragScrollDirective, ], @@ -418,7 +407,6 @@ export class OpenProjectModule implements DoBootstrap { registerCustomElement('opce-editable-query-props', EditableQueryPropsComponent, { injector }); registerCustomElement('opce-time-entry-trigger-actions', TriggerActionsEntryComponent, { injector }); registerCustomElement('opce-wp-overview-graph', WorkPackageOverviewGraphComponent, { injector }); - registerCustomElement('opce-header-project-select', OpHeaderProjectSelectComponent, { injector }); registerCustomElement('opce-no-results', NoResultsComponent, { injector }); registerCustomElement('opce-non-working-days-list', OpNonWorkingDaysListComponent, { injector }); registerCustomElement('opce-main-menu-resizer', MainMenuResizerComponent, { injector }); diff --git a/frontend/src/app/shared/components/grids/widgets/project-favorites/widget-project-favorites.component.html b/frontend/src/app/shared/components/grids/widgets/project-favorites/widget-project-favorites.component.html index ad9434bb1e43..d3447d4d4c81 100644 --- a/frontend/src/app/shared/components/grids/widgets/project-favorites/widget-project-favorites.component.html +++ b/frontend/src/app/shared/components/grids/widgets/project-favorites/widget-project-favorites.component.html @@ -9,16 +9,16 @@ @if ((projects$ | async); as projects) {
@if (projects.length === 0) { -
+


-

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

{{ text.project.plural }}

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

    - -
    - -

    -
    - } - @if (!(displayMode === 'favorited' && (favorites$ | async).length === 0)) { - - {{ noSearchResultsText() }} - - } - } - } @else { - - } - @if (!portfolioModelsEnabled) { -
    -
    - - - - - @if (canCreateNewProjects$ | async) { - - - - - } -
    -
    - } -
    - } -
    - -
    diff --git a/frontend/src/app/shared/components/header-project-select/header-project-select.component.sass b/frontend/src/app/shared/components/header-project-select/header-project-select.component.sass deleted file mode 100644 index f7e0e4b44d50..000000000000 --- a/frontend/src/app/shared/components/header-project-select/header-project-select.component.sass +++ /dev/null @@ -1,37 +0,0 @@ -.op-project-select - flex: 1 - overflow: hidden - - &--trigger-button - display: flex - align-items: center - height: var(--control-medium-size) - color: var(--main-menu-font-color) - font-weight: var(--base-text-weight-semibold) - padding: 0 var(--main-menu-x-spacing) - border: 1px solid transparent - border-radius: var(--borderRadius-medium) - background-color: transparent - width: 100% - - .button--dropdown-indicator - &:before - margin-left: var(--base-size-8) - -.op-header-project-select - &--no-favorites - display: flex - text-align: center - flex-direction: column - align-items: center - margin: 0 1.25rem - - &--no-favorites-icon - margin-bottom: 0.5rem - - &--search-icon - fill: var(--body-font-color) - - &--no-favorites-subtext - font-size: 0.9rem - color: var(--fgColor-muted, var(--color-fg-subtle)) diff --git a/frontend/src/app/shared/components/header-project-select/header-project-select.component.ts b/frontend/src/app/shared/components/header-project-select/header-project-select.component.ts deleted file mode 100644 index c01fc2de5479..000000000000 --- a/frontend/src/app/shared/components/header-project-select/header-project-select.component.ts +++ /dev/null @@ -1,305 +0,0 @@ -//-- copyright -// OpenProject is an open source project management software. -// Copyright (C) the OpenProject GmbH -// -// This program is free software; you can redistribute it and/or -// modify it under the terms of the GNU General Public License version 3. -// -// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: -// Copyright (C) 2006-2013 Jean-Philippe Lang -// Copyright (C) 2010-2013 the ChiliProject Team -// -// This program is free software; you can redistribute it and/or -// modify it under the terms of the GNU General Public License -// as published by the Free Software Foundation; either version 2 -// of the License, or (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with this program; if not, write to the Free Software -// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. -// -// See COPYRIGHT and LICENSE files for more details. -//++ - -import { PathHelperService } from 'core-app/core/path-helper/path-helper.service'; -import { I18nService } from 'core-app/core/i18n/i18n.service'; -import { ChangeDetectionStrategy, Component, HostBinding, OnDestroy, OnInit, ViewEncapsulation, ElementRef, ViewChild, AfterViewInit } from '@angular/core'; -import { CurrentProjectService } from 'core-app/core/current-project/current-project.service'; -import { BehaviorSubject, Observable, ReplaySubject, Subscription } from 'rxjs'; -import { map, shareReplay, take, tap } from 'rxjs/operators'; -import { IProject } from 'core-app/core/state/projects/project.model'; -import { insertInList } from 'core-app/shared/components/project-include/insert-in-list'; -import { recursiveSort } from 'core-app/shared/components/project-include/recursive-sort'; -import { - SearchableProjectListService, -} from 'core-app/shared/components/searchable-project-list/searchable-project-list.service'; -import { CurrentUserService } from 'core-app/core/current-user/current-user.service'; -import { UntilDestroyedMixin } from 'core-app/shared/helpers/angular/until-destroyed.mixin'; -import { IProjectData } from 'core-app/shared/components/searchable-project-list/project-data'; -import { ApiV3Service } from 'core-app/core/apiv3/api-v3.service'; -import { ConfigurationService } from 'core-app/core/config/configuration.service'; - -@Component({ - selector: 'opce-header-project-select', - templateUrl: './header-project-select.component.html', - styleUrls: ['./header-project-select.component.sass'], - encapsulation: ViewEncapsulation.None, - changeDetection: ChangeDetectionStrategy.OnPush, - providers: [ - SearchableProjectListService, - ], - standalone: false, -}) -export class OpHeaderProjectSelectComponent extends UntilDestroyedMixin implements OnInit, OnDestroy, AfterViewInit { - @HostBinding('class.op-project-select') className = true; - - @ViewChild('projectSearchField', { read: ElementRef }) - - projectSearchField?:ElementRef; - - private activeProjectId:number|null = null; - - private readonly listboxId = 'op-header-project-select-listbox'; - - public dropModalOpen = false; - - public textFieldFocused = false; - - public portfolioModelsEnabled = this.configuration.activeFeatureFlags.includes('portfolioModels'); - - public canCreateNewProjects$ = this.currentUserService.hasCapabilities$('projects/create', 'global'); - - public projects$ = this.searchableProjectListService.allProjects$.pipe( - map( - (projects:IProject[]) => projects - .filter( - (project) => { - const searchText = this.searchableProjectListService.searchText; - if (searchText.length) { - const terms = searchText.toLowerCase().split(/\s+/).filter((t) => t.length > 0); - const matches = terms.every((term) => project.name.toLowerCase().includes(term)); - - if (!matches) { - return false; - } - } - - return true; - }, - ) - .sort((a, b) => a._links.ancestors.length - b._links.ancestors.length) - .reduce( - (list, project) => { - const { ancestors } = project._links; - - return insertInList( - projects, - project, - list, - ancestors, - ); - }, - [] as IProjectData[], - ), - ), - map((projects) => recursiveSort(projects)), - tap(() => { - if(this.dropModalOpen) { - // only clear loading indicator if modal is open, otherwise rendering is triggered that will cause loading of - // favorites while the modal is closed - this.loading$.next(false); - } - }), - shareReplay(), - ); - - public favorites$:Observable = this.searchableProjectListService.favoriteIds$; - - public text = { - all: this.I18n.t('js.label_all_uppercase'), - favorited: this.I18n.t('js.label_favorites'), - no_favorites: this.I18n.t('js.favorite_projects.no_results'), - no_favorites_subtext: this.I18n.t('js.favorite_projects.no_results_subtext'), - project: { - singular: this.I18n.t('js.label_project'), - plural: this.I18n.t('js.label_project_plural'), - list: this.I18n.t('js.label_project_list'), - select: this.I18n.t('js.label_all_projects'), - search_placeholder: this.I18n.t('js.include_projects.search_placeholder') - }, - workspace: { - list: this.I18n.t('js.label_workspace_list'), - search_placeholder: this.I18n.t('js.include_workspaces.search_placeholder') - }, - search_favorites_placeholder: this.I18n.t('js.include_projects.search_placeholder_favorites'), - no_results: this.I18n.t('js.include_projects.no_results'), - no_favorite_results: this.I18n.t('js.include_projects.no_favorite_results') - }; - - // Computed text properties based on portfolio models feature flag - public get currentText() { - return this.portfolioModelsEnabled ? this.text.workspace : this.text.project; - } - - public displayMode:'all'|'favorited'; - - public displayModeOptions = [ - { value: 'all', title: this.text.all }, - { value: 'favorited', title: this.text.favorited }, - ]; - - public loading$ = new BehaviorSubject(true); - - private scrollToCurrent = false; - - private subscriptionComplete$ = new ReplaySubject(1); - - private displayModeLocalStorageKey = 'openProject-project-select-display-mode'; - - constructor( - readonly pathHelper:PathHelperService, - readonly configuration:ConfigurationService, - readonly I18n:I18nService, - readonly currentProject:CurrentProjectService, - readonly searchableProjectListService:SearchableProjectListService, - readonly currentUserService:CurrentUserService, - readonly apiV3Service:ApiV3Service, - ) { - super(); - - if(this.currentProject.id) { - this.searchableProjectListService.preloadProjectIds = [this.currentProject.id]; - } - - this.projects$ - .pipe(this.untilDestroyed()) - .subscribe((projects) => { - if (this.currentProject.id && projects.length && this.scrollToCurrent) { - this.searchableProjectListService.selectedItemID$.next(parseInt(this.currentProject.id, 10)); - } else { - this.searchableProjectListService.resetActiveResult(projects); - } - - this.scrollToCurrent = false; - this.subscriptionComplete$.next(); // Signal that subscription logic is complete - }); - } - - private onTextInput:Subscription; - - ngOnInit():void { - const stored = window.OpenProject.guardedLocalStorage(this.displayModeLocalStorageKey) as 'all'|'favorited'|undefined; - this.displayMode = stored ?? 'all'; - this.onTextInput = this.searchableProjectListService.queriedSearchText$.subscribe(() => this.loading$.next(true)); - } - - ngOnDestroy():void { - this.onTextInput.unsubscribe(); - } - - ngAfterViewInit():void { - this.searchableProjectListService.selectedItemID$ - .pipe(this.untilDestroyed()) - .subscribe((selectedItemID:number|null) => { - this.activeProjectId = selectedItemID; - this.syncSearchInputAccessibility(); - }); - } - - private syncSearchInputAccessibility():void { - requestAnimationFrame(() => { - const input = this.projectSearchField?.nativeElement.querySelector('input') as HTMLInputElement | null; - - if (!input) { - return; - } - - input.setAttribute('role', 'combobox'); - input.setAttribute('aria-autocomplete', 'list'); - input.setAttribute('aria-haspopup', 'listbox'); - input.setAttribute('aria-expanded', String(this.dropModalOpen)); - input.setAttribute('aria-controls', this.listboxId); - input.setAttribute('aria-label', this.searchPlaceHolder()); - - if (this.dropModalOpen && this.activeProjectId !== null) { - input.setAttribute('aria-activedescendant', `op-header-project-select-option-${this.activeProjectId}`); - } else { - input.removeAttribute('aria-activedescendant'); - } - }); - } - - toggleDropModal():void { - this.subscriptionComplete$.pipe(take(1)).subscribe(() => { - this.dropModalOpen = !this.dropModalOpen; - if (this.dropModalOpen) { - this.loading$.next(true); - this.searchableProjectListService.enableLoading(); - this.scrollToCurrent = true; - } else { - this.searchableProjectListService.disableLoading(); - } - this.syncSearchInputAccessibility(); - }); - } - - displayModeChange(mode:'all'|'favorited'):void { - this.displayMode = mode; - window.OpenProject.guardedLocalStorage(this.displayModeLocalStorageKey, mode); - - if (this.currentProject.id) { - this.searchableProjectListService.selectedItemID$.next(parseInt(this.currentProject.id, 10)); - } - } - - close():void { - this.dropModalOpen = false; - this.searchableProjectListService.disableLoading(); - this.searchableProjectListService.searchText = ''; - this.syncSearchInputAccessibility(); - } - - currentProjectName():string { - if (this.currentProject.name !== null) { - return this.currentProject.name; - } - - return this.text.project.select; - } - - allProjectsPath():string { - return this.pathHelper.projectsPath(); - } - - newProjectPath():string { - const parentParam = this.currentProject.id ? `?parent_id=${this.currentProject.id}` : ''; - return `${this.pathHelper.projectsNewPath()}${parentParam}`; - } - - anyProjectsFound(projects:IProjectData[], favorites:string[]):boolean { - if (this.displayMode === 'all') { - return projects.length > 0; - } - - return projects.length > 0 && favorites.length > 0; - } - - searchPlaceHolder():string { - if (this.displayMode === 'all') { - return this.currentText.search_placeholder; - } - return this.text.search_favorites_placeholder; - } - - noSearchResultsText():string { - if (this.displayMode === 'all') { - return this.text.no_results; - } - return this.text.no_favorite_results; - } -} diff --git a/frontend/src/app/shared/components/header-project-select/insert-in-list.ts b/frontend/src/app/shared/components/header-project-select/insert-in-list.ts deleted file mode 100644 index 160c9f7f1303..000000000000 --- a/frontend/src/app/shared/components/header-project-select/insert-in-list.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { IProject } from 'core-app/core/state/projects/project.model'; -import { IHalResourceLink } from 'core-app/core/state/hal-resource'; -import { IProjectData } from 'core-app/shared/components/searchable-project-list/project-data'; - -const UNDISCLOSED_ANCESTOR = 'urn:openproject-org:api:v3:undisclosed'; - -// Helper function that recursively inserts a project into the hierarchy at the right place -export const insertInList = ( - projects:IProject[], - project:IProject, - list:IProjectData[], - ancestors:IHalResourceLink[], -):IProjectData[] => { - // In a set of projects, some ancestors may be undisclosed. The client then knows of its existence - // but knows nothing more than that. Those projects receive an 'undisclosed' urn for their href. For building - // the project hierarchy, they can be ignored. - const visibleAncestors = ancestors.filter((ancestor) => ancestor.href !== UNDISCLOSED_ANCESTOR); - - if (!visibleAncestors.length) { - return [ - ...list, - { - id: project.id, - name: project.name, - href: project._links.self.href, - _type: project._type, - disabled: false, - children: [], - position: 0, - }, - ]; - } - - const ancestorHref = visibleAncestors[0].href; - const ancestor:IProjectData|undefined = list.find((projectInList) => projectInList.href === ancestorHref); - - if (ancestor) { - ancestor.children = insertInList( - projects, - project, - ancestor.children, - visibleAncestors.slice(1), - ); - return [...list]; - } - - const ancestorProject = projects.find((projectInList) => projectInList._links.self.href === ancestorHref); - if (!ancestorProject) { - return [...list]; - } - - return [ - ...list, - { - id: ancestorProject.id, - name: ancestorProject.name, - href: ancestorProject._links.self.href, - _type: project._type, - disabled: true, - children: insertInList(projects, project, [], visibleAncestors.slice(1)), - position: 0, - }, - ]; -}; diff --git a/frontend/src/app/shared/components/header-project-select/list/header-project-select-list.component.html b/frontend/src/app/shared/components/header-project-select/list/header-project-select-list.component.html deleted file mode 100644 index e9de11e22b71..000000000000 --- a/frontend/src/app/shared/components/header-project-select/list/header-project-select-list.component.html +++ /dev/null @@ -1,112 +0,0 @@ -@for (project of filteredProjects; track project; let i = $index; let isFirst = $first; let isLast = $last) { -
  • - @if (!project.disabled) { - - - - @if (favorited?.includes(project.id.toString())) { - - } - @if (portfolioModelsEnabled) { - @switch (project._type) { - @case ('Portfolio') { - - - {{ this.I18n.t('js.include_workspaces.types.portfolio') }} - - } - @case ('Program') { - - - {{ this.I18n.t('js.include_workspaces.types.program') }} - - } - } - } - - @if (currentProjectService.id === project.id.toString()) { - - - - } - - } - @if (project.disabled) { - - - {{ project.name }} - @if (portfolioModelsEnabled) { - @switch (project._type) { - @case ('Portfolio') { - - - {{ this.I18n.t('js.include_workspaces.types.portfolio') }} - - } - @case ('Program') { - - - {{ this.I18n.t('js.include_workspaces.types.program') }} - - } - } - } - - - } - @if (project.children.length) { -
      - } -
    • -} diff --git a/frontend/src/app/shared/components/header-project-select/list/header-project-select-list.component.sass b/frontend/src/app/shared/components/header-project-select/list/header-project-select-list.component.sass deleted file mode 100644 index 80c1f28b83f2..000000000000 --- a/frontend/src/app/shared/components/header-project-select/list/header-project-select-list.component.sass +++ /dev/null @@ -1,12 +0,0 @@ -@import '../../app/spot/styles/sass/variables' -@import 'helpers' - -:host, -.op-header-project-select-list - flex-shrink: 1 - flex-basis: 100% - - // Since we are referring to the host element as well we need the complete class name here - &.op-header-project-select-list--root - overflow-y: auto - @include styled-scroll-bar diff --git a/frontend/src/app/shared/components/header-project-select/list/header-project-select-list.component.ts b/frontend/src/app/shared/components/header-project-select/list/header-project-select-list.component.ts deleted file mode 100644 index 8655655ef46e..000000000000 --- a/frontend/src/app/shared/components/header-project-select/list/header-project-select-list.component.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { I18nService } from 'core-app/core/i18n/i18n.service'; -import { - ChangeDetectionStrategy, - ChangeDetectorRef, - Component, - ElementRef, - EventEmitter, - HostBinding, - Input, - OnChanges, - OnInit, - Output, - SimpleChanges, -} from '@angular/core'; -import { - SearchableProjectListService, -} from 'core-app/shared/components/searchable-project-list/searchable-project-list.service'; -import { IProjectData } from 'core-app/shared/components/searchable-project-list/project-data'; -import { PathHelperService } from 'core-app/core/path-helper/path-helper.service'; -import { ConfigurationService } from 'core-app/core/config/configuration.service'; -import { CurrentProjectService } from 'core-app/core/current-project/current-project.service'; -import { getMetaContent } from 'core-app/core/setup/globals/global-helpers'; - -@Component({ - selector: '[op-header-project-select-list]', - changeDetection: ChangeDetectionStrategy.OnPush, - templateUrl: './header-project-select-list.component.html', - styleUrls: ['./header-project-select-list.component.sass'], - standalone: false, -}) -export class OpHeaderProjectSelectListComponent implements OnInit, OnChanges { - @HostBinding('class.spot-list') classNameList = true; - - @HostBinding('class.op-header-project-select-list') className = true; - - @HostBinding('attr.role') - get roleAttribute():string { - return this.root ? 'listbox' : 'group'; - } - - @HostBinding('attr.id') - get idAttribute():string|null { - return this.root ? 'op-header-project-select-listbox' : null; - } - - @Output() update = new EventEmitter(); - - @Input() @HostBinding('class.op-header-project-select-list--root') root = false; - - @Input() projects:IProjectData[] = []; - - @Input() favorited:string[] = []; - - @Input() displayMode:string; - - @Input() searchText = ''; - - public filteredProjects:IProjectData[]; - - public text = { - does_not_match_search: this.I18n.t('js.include_projects.tooltip.does_not_match_search'), - include_all_selected: this.I18n.t('js.include_projects.tooltip.include_all_selected') - }; - - public portfolioModelsEnabled = this.configuration.activeFeatureFlags.includes('portfolioModels'); - - constructor( - readonly I18n:I18nService, - readonly pathHelper:PathHelperService, - readonly configuration:ConfigurationService, - readonly searchableProjectListService:SearchableProjectListService, - readonly elementRef:ElementRef, - readonly cdRef:ChangeDetectorRef, - readonly currentProjectService:CurrentProjectService, - ) { } - - 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..c4a8f16fb68b 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) @@ -102,7 +102,7 @@ $arrow-left-width: 36px // children have no icon so we need to push them right. padding-left: var(--main-menu-x-spacing) - a:not(.Button) + a:not(.Button):not(.TreeViewItemContent) text-decoration: none line-height: var(--main-menu-item-height) position: relative diff --git a/frontend/src/global_styles/primer/_overrides.sass b/frontend/src/global_styles/primer/_overrides.sass index f9f3869f6c03..9c3a085ade21 100644 --- a/frontend/src/global_styles/primer/_overrides.sass +++ b/frontend/src/global_styles/primer/_overrides.sass @@ -218,3 +218,12 @@ ul.SegmentedControl, &:before animation: none clip-path: inset(0) + +// At some point in time, this needs be extracted into a proper component, +// something like `FilterableTreeViewSelectPanel` +.op-project-select--body + .FilterableTreeViewLayout + > .Stack + padding-right: var(--base-size-16) + .FilterableTreeViewTreeContainer + scrollbar-gutter: stable diff --git a/frontend/src/stimulus/controllers/header-project-select.controller.ts b/frontend/src/stimulus/controllers/header-project-select.controller.ts new file mode 100644 index 000000000000..efefac58001a --- /dev/null +++ b/frontend/src/stimulus/controllers/header-project-select.controller.ts @@ -0,0 +1,42 @@ +/* + * OpenProject is an open source project management software. + * Copyright (C) the OpenProject GmbH + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License version 3. + * + * See COPYRIGHT and LICENSE files for more details. + */ + +import { Controller } from '@hotwired/stimulus'; + +const STORAGE_KEY = 'openProject-project-select-display-mode'; + +export default class HeaderProjectSelectController extends Controller { + connect():void { + const stored = localStorage.getItem(STORAGE_KEY); + if (stored && stored !== 'all') { + // Defer until after all Stimulus controllers have connected (including + // filterable-tree-view), so its click listener is already set up when + // we programmatically activate the stored filter mode. + setTimeout(() => { + this.element + .querySelector(`[data-name="${stored}"]`) + ?.click(); + }, 0); + } + + this.element.addEventListener('click', this.onFilterModeClick); + } + + disconnect():void { + this.element.removeEventListener('click', this.onFilterModeClick); + } + + private onFilterModeClick = (event:MouseEvent):void => { + const button = (event.target as HTMLElement).closest('[data-name]'); + if (button?.dataset.name) { + localStorage.setItem(STORAGE_KEY, button.dataset.name); + } + }; +} diff --git a/frontend/src/stimulus/setup.ts b/frontend/src/stimulus/setup.ts index 2fa3ac2c0987..c99d9826a46c 100644 --- a/frontend/src/stimulus/setup.ts +++ b/frontend/src/stimulus/setup.ts @@ -28,6 +28,7 @@ import LazyPageController from './controllers/dynamic/work-packages/activities-t import EditablePageHeaderTitleController from './controllers/dynamic/editable-page-header-title.controller'; import WorkingHoursFormController from './controllers/dynamic/users/working-hours-form.controller'; import DailyRemindersController from './controllers/dynamic/my/daily-reminders.controller'; +import HeaderProjectSelectController from './controllers/header-project-select.controller'; import NonWorkingTimesController from './controllers/dynamic/users/non-working-times.controller'; import NonWorkingTimesFormController from './controllers/dynamic/users/non-working-times-form.controller'; import OpPasswordForceChangeController from './controllers/password-force-change.controller'; @@ -95,6 +96,7 @@ OpenProjectStimulusApplication.preregister('users--non-working-times', NonWorkin OpenProjectStimulusApplication.preregister('users--non-working-times-form', NonWorkingTimesFormController); OpenProjectStimulusApplication.preregister('password-force-change', OpPasswordForceChangeController); OpenProjectStimulusApplication.preregister('check-all', CheckAllController); +OpenProjectStimulusApplication.preregister('header-project-select', HeaderProjectSelectController); OpenProjectStimulusApplication.preregister('checkable', CheckableController); OpenProjectStimulusApplication.preregister('truncation', TruncationController); diff --git a/lib/redmine/menu_manager/top_menu/projects_menu.rb b/lib/redmine/menu_manager/top_menu/projects_menu.rb index 6673fb4cdbc4..f6dfc4cf1adc 100644 --- a/lib/redmine/menu_manager/top_menu/projects_menu.rb +++ b/lib/redmine/menu_manager/top_menu/projects_menu.rb @@ -37,9 +37,11 @@ def render_projects_top_menu_node private def render_projects_dropdown - content_tag(:div, class: "main-menu-item") do - angular_component_tag("opce-header-project-select") - end + render(Header::ProjectSelectComponent.new( + current_project: @project, + current_user: User.current, + current_menu_item: current_menu_item + )) end include OpenProject::StaticRouting::UrlHelpers diff --git a/lookbook/docs/components/tree-view/filterable-tree-view.md.erb b/lookbook/docs/components/tree-view/filterable-tree-view.md.erb index dcccdb1fbd4c..cffbb7bc850a 100644 --- a/lookbook/docs/components/tree-view/filterable-tree-view.md.erb +++ b/lookbook/docs/components/tree-view/filterable-tree-view.md.erb @@ -1,4 +1,4 @@ -The `FilterableTreeView` is an extended `TreeView` implementation for scenarios where filtering and managing selections within a large tree structure is required. It extends the `TreeView` by introducing filtering and view control options, making it particularly useful for workflows that require selection of nodes from hierarchies. +The `FilterableTreeView` is an extended `TreeView` implementation for scenarios where filtering within a large tree structure is required. It extends the `TreeView` by introducing filtering and view control options, making it useful for workflows that require searching and either selecting nodes from hierarchies or navigating to them via links. ## Overview @@ -10,8 +10,8 @@ The `FilterableTreeView` consists of the following UI elements: 1. **Segmented Control (optional)**: Allows toggling between viewing all items and only selected ones. Labels and filter logic are configurable 2. **Search Field**: A text field with a leading search icon used to filter the tree content via text -3. **Checkbox "Include sub-nodes" (optional)**: When enabled, checking a parent node will also include its descendants -4. **TreeView**: An embedded `TreeView` component with multi-select enabled and static loading strategy +3. **Checkbox "Include sub-nodes" (optional)**: When enabled, checking a parent node will also include its descendants. Hidden automatically when using `select_variant: :single` or `select_variant: :none`. +4. **TreeView**: An embedded `TreeView` component. Supports `select_variant: :multiple` (default), `:single`, or `:none` (link nodes). ## Features @@ -19,8 +19,7 @@ The `FilterableTreeView` consists of the following UI elements: The embedded `TreeView` behaves according to its specification, with a few notable constraints and extensions: -- **Loading strategy**: Only supports `static` loading. -- **Multi-select support**: Enabled by default. +- **Multi-select support**: Enabled by default (`select_variant: :multiple`). For details on the `TreeView` itself, refer to the [TreeView documentation](./tree_view). @@ -37,12 +36,12 @@ The search field enables text-based filtering of the tree. - Non-matching nodes in a visible path are **greyed out and disabled** (not clickable). - Matching characters in results are **highlighted**. -**Single-select considerations:** +**Single-select considerations (`select_variant: :single`):** - Selections made **before** filtering are preserved, even if they are not visible in the current filter. - On form submission, **the selected item** is submitted, even if it is currently not shown (e.g filtered out). -**Multi-select considerations:** +**Multi-select considerations (`select_variant: :multiple`):** - Selections made **before** filtering are preserved, even if they are not visible in the current filter. - If **"Include sub-nodes"** is enabled: @@ -68,7 +67,9 @@ A segmented control toggles the visible tree scope: The labels as well as the logic for the segmented control are configurable. For details, please check the technical notes section at the bottom. -The segmented control can be disabled and hidden by passing `hidden: true` as `filter_mode_control_arguments`. Please have a look at [this preview](../../../inspect/primer/open_project/filterable_tree_view/hide_segmented_control) for details. +The segmented control is visible by default for all `select_variant` values, including `:none`. It can be hidden by passing `hidden: true` as `filter_mode_control_arguments`. Please have a look at [this preview](../../../inspect/primer/open_project/filterable_tree_view/hide_segmented_control) for details. + +> **Note:** The "Selected" mode in the segmented control only makes sense when nodes are selectable (`:multiple` or `:single`). When using `select_variant: :none`, it is recommended to either hide the segmented control or replace the default filter modes with custom ones relevant to your use case. --- @@ -84,7 +85,163 @@ This checkbox controls whether checking a parent node selects all of its childre - When **unchecked again**: - The component does **remember** how a selection was made (manual vs. auto). Previously auto-selected descendants get unselected while manually selected items remain selected. -The checkbox can be visually hidden by passing `hidden: true` to the `include_sub_items_check_box_arguments`. The logic remains however. Please have a look at [this preview](../../../inspect/primer/open_project/filterable_tree_view/hide_checkbox) for details. +The checkbox is **automatically hidden** when `select_variant: :single` or `select_variant: :none` is used, since sub-item inclusion only makes sense in multi-select mode. + +It can also be visually hidden manually by passing `hidden: true` to the `include_sub_items_check_box_arguments`. The logic remains however. Please have a look at [this preview](../../../inspect/primer/open_project/filterable_tree_view/hide_checkbox) for details. + +--- + +### Async (server-side) filtering + +By default, `FilterableTreeView` filters the tree client-side. When the tree is too large to load at once, or when filtering requires server knowledge (e.g. database queries), **async mode** can be activated by passing a `src:` URL to the component. + +#### Behavior summary + +- On initial rendering, the component fetches the full tree from `src`. +- Every change to the filter input triggers a debounced (300 ms) GET request to `src`. +- Switching the segmented control triggers a fetch for all modes except `"selected"`, which is handled client-side. +- Toggling **"Include sub-items"** does **not** immediately trigger a server request. The current `include_sub_items` value is sent on the next fetch (query or filter-mode change), and the server must return the appropriate descendants at that point. +- After a filtered response is received, the client automatically expands all sub-trees. +- When the filter is cleared, the expansion state from before filtering started is restored. +- When the form is submitted, the component sends all nodes that have been checked at **some point in time** (no matter whether the current filter shows them or not). +- When the form is submitted with **"Include sub-items"** checked, +- On form submit: The form receives the complete selection regardless of the current filter state: + - Nodes currently visible and checked are submitted via the `TreeView`'s normal form inputs. + - Nodes that were checked but are currently filtered out (not in the DOM) are submitted via hidden inputs injected by the component directly. + - The `include_sub_items` checkbox value is included in the form submission so that the server knows whether to include all descendants or not. + +#### Selection persistence + +Checked nodes are identified by `data-node-id` and preserved across tree replacements. Nodes that are checked but absent from the current response are submitted via hidden inputs injected by the component, so the form payload is always complete regardless of what is currently visible. + +#### Using async mode with a form + +Pass `form_arguments:` to the component as usual. In async mode, the `include_sub_items` value is automatically included in the form submission (unlike client-side mode, where it is a purely visual control). + +```erb +<%= form_with(url: my_path) do |f| %> + <%= render(Primer::OpenProject::FilterableTreeView.new( + src: my_items_path, + form_arguments: { builder: f, name: "items" } + )) %> +<% end %> +``` + +When using forms, pass `form_arguments:` to the `TreeView` in your endpoint fragment as well, so node checkboxes are wired to the form. The component sends `name` as a query parameter automatically, so your controller can reconstruct a `FormBuilder`: + +```ruby +# app/controllers/my_items_controller.rb +def index + name = params[:name].presence || "items" + builder = ActionView::Helpers::FormBuilder.new("", nil, view_context, {}) + + render layout: false, locals: { builder: builder, name: name, ... } +end +``` + +```erb +<%# app/views/my_items/index.html.erb – with form support %> +<%= render(Primer::Alpha::TreeView.new( + data: { target: "filterable-tree-view.treeViewList" }, + form_arguments: { builder: builder, name: name } +)) do |tree| %> + ... +<% end %> +``` + +#### How to use the component in OpenProject + +##### Step 1 – Render the component + +Pass a `src:` URL pointing to your server endpoint. The component fetches and renders the tree on mount, and re-fetches on every filter change. + +```erb +<%= render(Primer::OpenProject::FilterableTreeView.new(src: my_items_path)) %> +``` + +> **Note:** Do not pass `tree_view_arguments:` alongside `src:`. The initial tree shell is replaced by the first fetch, so those arguments would be lost. Configure the `TreeView` inside your endpoint instead. + +##### Step 2 – Create the server endpoint + +The endpoint must respond to `GET` and return a `` HTML fragment (no layout, no surrounding HTML). The component sends the following query parameters with every request: + +| Parameter | Type | Description | +|:---|:---|:---| +| `query` | `String` | Current text from the filter input (empty string when unfiltered). | +| `filter_mode` | `String` | Active segmented-control tab name, e.g. `"all"`, `"selected"`, or a custom mode. Note: `"selected"` mode is handled client-side and never triggers a server request. | +| `include_sub_items` | `"true"` / `"false"` | Whether the "Include sub-items" checkbox is checked. The server must use this to expand the result — toggling the checkbox does not trigger a server request on its own. | +| `checked_ids[]` | `Array` | One entry per currently checked node ID. Required so the server can include all descendants of checked nodes when `include_sub_items=true`, even if those nodes do not match the query. | + +Minimal Rails wiring: + +```ruby +# config/routes.rb +resources :my_items, only: [:index] +``` + +```ruby +# app/controllers/my_items_controller.rb +def index + query = params[:query].to_s.strip + include_sub_items = params[:include_sub_items] == "true" + checked_ids = Array(params["checked_ids[]"]).map(&:to_s) + + @nodes = MyItem.filter(query, include_sub_items:, checked_ids:) + + render layout: false +end +``` + +**What the server should do with `include_sub_items` and `checked_ids[]`:** + +When `include_sub_items` is `"true"`, the server should include all descendants of every node in `checked_ids[]` in the response — even if they don't match the query string — so the client can check and disable them visually. The client does not expand sub-trees on its own when `include_sub_items` is toggled; it relies entirely on what the server returns on the next filter request. + +##### Step 3 – Return the tree fragment + +The response must be a single `` element with `data-target="filterable-tree-view.treeViewList"`. Use `Primer::Alpha::TreeView` to render it. + +```erb +<%# app/views/my_items/index.html.erb %> +<%= render(Primer::Alpha::TreeView.new( + data: { target: "filterable-tree-view.treeViewList" } +)) do |tree| %> + <% @nodes.each do |node| %> + <%= render partial: "my_items/node", locals: { component: tree, node: node, query: query } %> + <% end %> +<% end %> +``` + +###### Node partial requirements + +Each node **must** carry a stable `data-node-id` so the component can preserve selections across tree replacements. The client automatically expands all sub-trees after a filtered response, so you do not need to set `expanded:` in the partial. + +**Leaf nodes:** + +```erb +<%# app/views/my_items/_node.html.erb (leaf) %> +<% component.with_leaf( + label: node.label, + data: { node_id: node.id } +) %> +``` + +**Sub-tree (branch) nodes:** + +```erb +<%# app/views/my_items/_node.html.erb (branch) %> +<% component.with_sub_tree( + label: node.label, + sub_tree_component_klass: Primer::OpenProject::FilterableTreeView::SubTree, + select_strategy: :self, + data: { node_id: node.id } +) do |sub| %> + <% node.children.each do |child| %> + <%= render partial: "my_items/node", locals: { component: sub, node: child, query: query } %> + <% end %> +<% end %> +``` + +> **Important:** Pass `sub_tree_component_klass:` and `select_strategy:` only at the **top-level** `TreeView`. `FilterableTreeView::SubTree#with_sub_tree` already forwards them to nested levels — passing them again will raise a `keyword argument duplicated` error. --- @@ -93,15 +250,17 @@ The checkbox can be visually hidden by passing `hidden: true` to the `include_su **Do:** - Use this component when users need to **search and selectively choose** items in a tree. +- Use `select_variant: :none` with `node_variant: :anchor` when nodes should act as **navigation links** rather than selectable items. - Enable **"Include sub-nodes"** if selecting a parent should automatically include all descendants. +- Hide the segmented control (`filter_mode_control_arguments: { hidden: true }`) when using `select_variant: :none` unless you provide a custom segmented control. **Don't:** -- Don’t use `FilterableTreeView` with a dynamic loading strategy – only static loading is supported. - Don't use the component for simple hierarchies which don't need filtering; instead use `TreeView`. ## Used in +- The global project selector - (Planned) In the admin settings as a way of selecting projects in which to enable a certain object, like a project attribute, type or custom field. ## Examples diff --git a/spec/features/menu_items/top_menu_item_spec.rb b/spec/features/menu_items/top_menu_item_spec.rb index 21b37a35cbb0..f8bcd7e295a2 100644 --- a/spec/features/menu_items/top_menu_item_spec.rb +++ b/spec/features/menu_items/top_menu_item_spec.rb @@ -163,8 +163,8 @@ def click_link_in_open_menu(title) describe "Projects" do let(:top_menu) { find_by_id("projects-menu") } - let(:all_projects) { I18n.t("js.label_project_list") } - let(:add_project) { I18n.t("js.label_project") } + let(:all_projects) { I18n.t("label_project_list") } + let(:add_project) { I18n.t("label_project") } context "as an admin" do let(:user) { create(:admin) }