mirror of
https://github.com/opf/openproject.git
synced 2026-06-14 03:30:14 +00:00
[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:
@@ -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
|
||||
@@ -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
|
||||
@@ -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 %>
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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],
|
||||
|
||||
Generated
+27
-16
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
-102
@@ -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>
|
||||
-37
@@ -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))
|
||||
-297
@@ -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,
|
||||
},
|
||||
];
|
||||
};
|
||||
-108
@@ -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>
|
||||
}
|
||||
-12
@@ -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
|
||||
-117
@@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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:**
|
||||
|
||||
- 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user