[74625] Make project hierarchy collapsable in the global project selector (#23137)

* Use new async FilterableTreeView for global project selector

* Remove replaced angular component

* Fine tune sorting and expansion state of the new project selector

* Update primer to 0.86.1

* Add workspace information and filter results hierarchy information to project selector

* Include review feedback: Harmonize I18n keys, fix visible scope, use guarded local storage

* Add a turboFrame in the project select overlay to only load the projects when it is actually opened

* Restore BIM tab styles which were broken for a while already but the new project selector changes made it so bad that the test broke because the plus icon was overlapping the checkbox

* Clarify spec expectation
This commit is contained in:
Henriette Darge
2026-06-08 08:14:47 +02:00
committed by GitHub
parent 13fbeb1612
commit 396de9362f
36 changed files with 1267 additions and 887 deletions
+1
View File
@@ -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"
@@ -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 %>
@@ -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
@@ -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
@@ -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 %>
@@ -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
@@ -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 %>
@@ -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
@@ -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
+62
View File
@@ -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
+46
View File
@@ -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 %>
+7
View File
@@ -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"
-3
View File
@@ -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"
+8
View File
@@ -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],
+27 -16
View File
@@ -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",
-12
View File
@@ -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 });
@@ -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,
@@ -1,102 +0,0 @@
<spot-drop-modal
[opened]="dropModalOpen"
[allowRepositioning]="false"
(closed)="close()"
alignment="bottom-start"
class="op-project-list-modal"
>
<button
id="projects-menu"
accesskey="5"
aria-haspopup="true"
class="op-project-select--trigger-button"
type="button"
slot="trigger"
(click)="toggleDropModal()"
data-test-selector="op-projects-menu"
>
<span
class="ellipsis"
[textContent]="currentProjectName()"
[attr.title]="currentProjectName()">
</span>
<i class="button--dropdown-indicator"></i>
</button>
<ng-container slot="body">
<div class="op-project-list-modal--header">
<h1 class="op-project-list-modal--title">{{ text.project.plural }}</h1>
@if (this.currentUserService.isLoggedIn) {
<spot-toggle [options]="displayModeOptions"
[ngModel]="displayMode"
(ngModelChange)="displayModeChange($event)"
/>
}
</div>
@if (projects$ | async; as projects) {
<div
class="op-project-list-modal--body spot-container"
data-searchable-list-parent="true"
>
@if (displayMode !== 'favorited' || (favorites$ | async)?.length > 0) {
<spot-text-field
#projectSearchField
[placeholder]="searchPlaceHolder()"
[(ngModel)]="searchableProjectListService.searchText"
[ngModelOptions]="{standalone: true}"
data-test-selector="op-header-project-select--search"
data-modal-focus-catcher-container="true"
(keydown)="searchableProjectListService.onKeydown($event, projects)"
(inputFocus)="textFieldFocused = true; syncSearchInputAccessibility()"
(inputBlur)="textFieldFocused = false"
>
<svg search-icon slot="before" size="small" class="op-header-project-select--search-icon"/>
</spot-text-field>
}
@if ((loading$ | async) === false) {
@if (anyProjectsFound(projects, (favorites$ | async))) {
<ul
op-header-project-select-list
class="spot-list_active"
[projects]="projects"
[displayMode]="displayMode"
[favorited]="favorites$ | async"
[searchText]="searchableProjectListService.searchText"
[root]="true"
data-list-root="true"
data-test-selector="op-header-project-select--list"
></ul>
} @else {
@if (displayMode === 'favorited' && (favorites$ | async).length === 0) {
<div
class="op-header-project-select--no-favorites"
>
<svg
class="op-header-project-select--no-favorites-icon"
star-icon
size="medium"
></svg>
<p>
<strong [textContent]="text.no_favorites"></strong>
<br/>
<span class="op-header-project-select--no-favorites-subtext"
[textContent]="text.no_favorites_subtext"></span>
</p>
</div>
}
@if (!(displayMode === 'favorited' && (favorites$ | async).length === 0)) {
<span
class="op-project-list-modal--no-results">
{{ noSearchResultsText() }}
</span>
}
}
} @else {
<op-loading-project-list class="op-project-list-modal--loading" />
}
</div>
}
</ng-container>
</spot-drop-modal>
@@ -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))
@@ -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<HTMLElement>;
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<string[]> = 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<boolean>(true);
private scrollToCurrent = false;
private subscriptionComplete$ = new ReplaySubject<void>(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;
}
}
@@ -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,
},
];
};
@@ -1,108 +0,0 @@
@for (project of filteredProjects; track project; let i = $index; let isFirst = $first; let isLast = $last) {
<li
class="spot-list--item"
role="none"
data-test-selector="op-header-project-select--item"
[attr.data-list-selector]="projectListItemIdentifier"
>
@if (!project.disabled) {
<a
tabindex="-1"
class="spot-list--item-action"
[id]="optionId(project)"
role="option"
[attr.aria-selected]="((searchableProjectListService.selectedItemID$ | async) === project.id).toString()"
[ngClass]="{
'spot-list--item-action_disabled': project.disabled,
'spot-list--item-action_active': (searchableProjectListService.selectedItemID$ | async) === project.id
}"
[href]="extendedUrl(project.identifier)"
[attr.data-list-selector]="projectListActionIdentifier"
[attr.data-project-id]="project.id"
[attr.data-test-selector]="currentProjectService.id === project.id.toString() ? 'op-header-project-select--active-item' : null"
>
<span
class="spot-list--item-title spot-list--item-title_ellipse-text"
data-test-selector="op-header-project-select--item-title"
>
<span
[opSearchHighlight]="searchText"
[textContent]="project.name"></span>
@if (favorited?.includes(project.id.toString())) {
<svg
star-fill-icon
class="op-primer--star-icon"
size="small"
></svg>
}
@switch (project._type) {
@case ('Portfolio') {
<span class="description">
<svg briefcase-icon size="xsmall" />
{{ this.I18n.t('js.include_workspaces.types.portfolio') }}
</span>
}
@case ('Program') {
<span class="description">
<svg versions-icon size="xsmall" />
{{ this.I18n.t('js.include_workspaces.types.program') }}
</span>
}
}
</span>
@if (currentProjectService.id === project.id.toString()) {
<a
[href]="extendedUrl(null)"
class="spot-list--item-remove"
data-test-selector="op-header-project-select--item-remove-icon"
>
<svg x-circle-icon size="small" aria-hidden="true"/>
</a>
}
</a>
}
@if (project.disabled) {
<span
class="spot-list--item-action spot-list--item-action_disabled"
[id]="optionId(project)"
role="option"
aria-disabled="true"
[attr.aria-selected]="((searchableProjectListService.selectedItemID$ | async) === project.id).toString()"
[ngClass]="{
'spot-list--item-action_active': (searchableProjectListService.selectedItemID$ | async) === project.id
}"
>
<span
class="spot-list--item-title spot-list--item-title_ellipse-text"
data-test-selector="op-header-project-select--item-disabled-title"
>
<span>{{ project.name }}</span>
@switch (project._type) {
@case ('Portfolio') {
<span class="description">
<svg briefcase-icon size="xsmall" />
{{ this.I18n.t('js.include_workspaces.types.portfolio') }}
</span>
}
@case ('Program') {
<span class="description">
<svg versions-icon size="xsmall" />
{{ this.I18n.t('js.include_workspaces.types.program') }}
</span>
}
}
</span>
</span>
}
@if (project.children.length) {
<ul
op-header-project-select-list
[projects]="project.children"
[displayMode]="displayMode"
[favorited]="favorited"
[selected]="selected"
[searchText]="searchText"
></ul>
}
</li>
}
@@ -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
@@ -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<string[]>();
@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}`;
}
}
@@ -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
@@ -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
@@ -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<HTMLElement>('[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<HTMLElement>('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<HTMLElement>('[data-name]');
if (button?.dataset.name && VALID_FILTER_MODES.has(button.dataset.name)) {
window.OpenProject.guardedLocalStorage(STORAGE_KEY, button.dataset.name);
}
};
}
+2
View File
@@ -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);
@@ -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
@@ -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 `<tree-view>` 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<String>` | 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 `<tree-view>` 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:**
- Dont 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
@@ -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
@@ -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
+5 -6
View File
@@ -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
+15 -31
View File
@@ -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
@@ -104,9 +104,8 @@ RSpec.describe "Projects autocomplete page", :js do
# Filter for projects
top_menu.search "<strong"
# Expect highlights
# Expect result is shown and HTML in the project name is escaped, not rendered
within(top_menu.search_results) do
expect(page).to have_css(".op-search-highlight", text: "<strong")
expect(page).to have_no_css("strong")
end
@@ -178,10 +177,7 @@ RSpec.describe "Projects autocomplete page", :js do
end
# Filter for projects
top_menu.search "<strong"
# Visit a project
top_menu.autocompleter.send_keys :enter
top_menu.search_and_select "<strong"
top_menu.expect_current_project project2.name
end
+58 -54
View File
@@ -27,7 +27,7 @@
#
# See COPYRIGHT and LICENSE files for more details.
#++
require "support/components/autocompleter/autocomplete_helpers"
require "support/finders/test_selector_finders"
module Components
module Projects
@@ -35,7 +35,7 @@ module Components
include Capybara::DSL
include Capybara::RSpecMatchers
include RSpec::Matchers
include ::Components::Autocompleter::AutocompleteHelpers
include ::TestSelectorFinders
def toggle
page.find_by_id("projects-menu").click
@@ -49,18 +49,19 @@ module Components
end
def open?
page.has_selector?(autocompleter_selector)
page.has_selector?(search_selector)
end
def switch_mode(mode)
within(".op-project-list-modal--header") do
find('[data-test-selector="spot-toggle--option"]', text: mode).click
within_test_selector("op-header-project-select") do
find_button(mode).click
end
end
def expect_current_mode(mode)
expect(page).to have_css('[data-test-selector="spot-toggle--option"][data-qa-active-toggle="true"]',
text: mode)
within_test_selector("op-header-project-select") do
expect(page).to have_css(".SegmentedControl-item--selected", text: mode)
end
end
def expect_current_project(name)
@@ -68,107 +69,110 @@ module Components
end
def expect_open
page.find(autocompleter_selector)
page.find(search_selector)
end
def expect_closed
expect(page).to have_no_selector(autocompleter_selector)
expect(page).to have_no_selector(search_selector)
end
def search(query)
search_autocomplete(autocompleter, query:, results_selector: autocompleter_results_selector)
search_field.set query
end
def clear_search
autocompleter.set ""
autocompleter.send_keys :backspace
search_field.set ""
search_field.send_keys :backspace
end
def search_and_select(query)
select_autocomplete autocompleter,
results_selector: autocompleter_results_selector,
item_selector: autocompleter_item_title_selector,
query:
search query
wait_for_network_idle
selector = "#{results_selector} #{item_selector}"
item = page.first(selector, text: query, wait: 5) || page.find(selector, wait: 5)
item.click
end
def search_results
page.find autocompleter_results_selector, wait: 10
page.find results_selector, wait: 10
end
def autocompleter
page.find autocompleter_selector, wait: 10
def search_field
page.find search_selector, wait: 10
end
def expand_node_for(name)
item = page.find("#{results_selector} #{item_selector}", text: name, wait: 10)
item.find(:xpath, "preceding-sibling::*[contains(@class, 'TreeViewItemToggle')]").click
end
def expect_result(name, disabled: false, workspace_badge: nil)
within search_results do
selector = disabled ? autocompleter_item_disabled_title_selector : autocompleter_item_title_selector
item = page.find(selector, text: name)
selector = disabled ? item_disabled_selector : item_selector
item = page.find("#{results_selector} #{selector}", text: name, wait: 10)
# Skip badge verification when workspace badge is not set
next if workspace_badge.nil?
return if workspace_badge.nil?
if workspace_badge
expect(item).to have_octicon
expect(item).to have_primer_text(workspace_badge, class: "description")
else
expect(item).to have_no_octicon
expect(item).to have_no_primer_text(class: "description")
end
if workspace_badge
expect(item).to have_octicon
expect(item).to have_primer_text(workspace_badge, class: "description")
else
expect(item).to have_no_octicon
expect(item).to have_no_primer_text(class: "description")
end
end
def expect_no_result(name)
within search_results do
expect(page).to have_no_selector(autocompleter_item_title_selector, text: name)
end
expect(page).to have_no_selector("#{results_selector} #{item_selector}", text: name, wait: 5)
end
def expect_blankslate
expect(page).not_to have_test_selector("op-project-list-modal--no-results", wait: 0)
expect(page).not_to have_test_selector("op-header-project-select--no-results", wait: 0)
end
def expect_item_with_hierarchy_level(hierarchy_level:, item_name:)
within search_results do
hierarchy_selector = hierarchy_level.times.collect { autocompleter_item_selector }.join(" ")
expect(page)
.to have_css("#{hierarchy_selector} #{autocompleter_item_title_selector}", text: item_name)
end
hierarchy_selector = ".TreeViewItemContainer[style*='--level: #{hierarchy_level};']"
expect(page)
.to have_css("#{results_selector} #{hierarchy_selector} #{item_selector}", text: item_name, wait: 10)
end
def expect_project_create_button
expect(page).to have_css(".spot-action-bar--action.-primary", text: "Project")
expect(page).to have_test_selector("create-project-btn")
end
def expect_no_project_create_button
expect(page).to have_no_css(".spot-action-bar--action.-primary", text: "Project")
expect(page).to have_no_test_selector("create-project-btn")
end
def expect_project_list_button
expect(page).to have_css(".spot-action-bar--action", text: "Project lists")
expect(page).to have_test_selector("list-project-btn")
end
def expect_no_project_list_button
expect(page).to have_no_css(".spot-action-bar--action.-primary", text: "Project lists")
expect(page).to have_no_test_selector("list-project-btn")
end
def autocompleter_item_selector
def item_selector
'[data-test-selector="op-header-project-select--item"]'
end
def autocompleter_item_title_selector
'[data-test-selector="op-header-project-select--item-title"]'
def item_disabled_selector
"#{item_selector}[aria-disabled='true']"
end
def autocompleter_item_disabled_title_selector
'[data-test-selector="op-header-project-select--item-disabled-title"]'
end
def autocompleter_results_selector
def results_selector
'[data-test-selector="op-header-project-select--list"]'
end
def autocompleter_selector
'[data-test-selector="op-header-project-select--search"] input'
def active_item_selector
"#{item_selector}[aria-current='true']"
end
def remove_item_selector
"[data-test-selector='op-header-project-select--remove-item']"
end
def search_selector
"[data-test-selector='op-header-project-select--search']"
end
end
end