diff --git a/app/components/_index.sass b/app/components/_index.sass index 45a89e33bc3f..0941b751e3c6 100644 --- a/app/components/_index.sass +++ b/app/components/_index.sass @@ -18,3 +18,4 @@ @import "op_primer/border_box_table_component" @import "work_packages/exports/modal_dialog_component" @import "work_package_relations_tab/index_component" +@import "users/hover_card_component" diff --git a/app/components/shares/invite_user_form_component.html.erb b/app/components/shares/invite_user_form_component.html.erb index dea952c59aa6..f1bc280c58b9 100644 --- a/app/components/shares/invite_user_form_component.html.erb +++ b/app/components/shares/invite_user_form_component.html.erb @@ -10,7 +10,7 @@ ) do |form| grid_layout('invite-user-form', tag: :div) do |invite_form| invite_form.with_area('invitee') do - render(Shares::Invitee.new(form)) + render(Shares::Invitee.new(form, allow_hover_cards:)) end invite_form.with_area('permission') do diff --git a/app/components/shares/invite_user_form_component.rb b/app/components/shares/invite_user_form_component.rb index f8d40024bae9..07f5559506f2 100644 --- a/app/components/shares/invite_user_form_component.rb +++ b/app/components/shares/invite_user_form_component.rb @@ -32,14 +32,15 @@ class InviteUserFormComponent < ApplicationComponent # rubocop:disable OpenProje include OpTurbo::Streamable include OpPrimer::ComponentHelpers - attr_reader :entity, :strategy, :errors + attr_reader :entity, :strategy, :errors, :allow_hover_cards - def initialize(strategy:, errors: nil) + def initialize(strategy:, errors: nil, allow_hover_cards: false) super @strategy = strategy @entity = strategy.entity @errors = errors + @allow_hover_cards = allow_hover_cards end def new_share diff --git a/app/components/shares/manage_shares_component.html.erb b/app/components/shares/manage_shares_component.html.erb index 9cca5dea7e57..af18bb5f4229 100644 --- a/app/components/shares/manage_shares_component.html.erb +++ b/app/components/shares/manage_shares_component.html.erb @@ -1,7 +1,7 @@ <%= if strategy.manageable? modal_content.with_row do - render(Shares::InviteUserFormComponent.new(strategy:, errors: errors)) + render(Shares::InviteUserFormComponent.new(strategy:, errors:, allow_hover_cards:)) end end @@ -100,10 +100,15 @@ end else strategy.shares.each do |share| - render(Shares::ShareRowComponent.new(share:, strategy:, container: border_box)) + render(Shares::ShareRowComponent.new(share:, strategy:, container: border_box, allow_hover_cards:)) end end end end + if allow_hover_cards + modal_content.with_row do + helpers.angular_component_tag 'opce-custom-modal-overlay', class: 'op-user-share-modal-overlay' + end + end %> diff --git a/app/components/shares/manage_shares_component.rb b/app/components/shares/manage_shares_component.rb index fa61c6afdd57..22246aed01a3 100644 --- a/app/components/shares/manage_shares_component.rb +++ b/app/components/shares/manage_shares_component.rb @@ -36,6 +36,7 @@ class ManageSharesComponent < ApplicationComponent # rubocop:disable OpenProject attr_reader :strategy, :entity, :errors, + :allow_hover_cards, :modal_content def initialize(strategy:, modal_content:, errors: nil) @@ -45,6 +46,7 @@ def initialize(strategy:, modal_content:, errors: nil) @entity = strategy.entity @errors = errors @modal_content = modal_content + @allow_hover_cards = strategy.allow_hover_cards? end def self.wrapper_key diff --git a/app/components/shares/share_row_component.html.erb b/app/components/shares/share_row_component.html.erb index b7045299e475..47c931db3e76 100644 --- a/app/components/shares/share_row_component.html.erb +++ b/app/components/shares/share_row_component.html.erb @@ -13,7 +13,8 @@ end user_row_grid.with_area(:avatar, tag: :div) do - render(Users::AvatarComponent.new(user: principal, show_name: false, size: :medium)) + render(Users::AvatarComponent.new(user: principal, show_name: false, size: :medium, + hover_card: { active: allow_hover_cards, target: :custom })) end user_row_grid.with_area(:user_details, tag: :div, classes: 'ellipsis') do diff --git a/app/components/shares/share_row_component.rb b/app/components/shares/share_row_component.rb index 45df3ab20e0b..ae3b98457683 100644 --- a/app/components/shares/share_row_component.rb +++ b/app/components/shares/share_row_component.rb @@ -36,7 +36,7 @@ class ShareRowComponent < ApplicationComponent # rubocop:disable OpenProject/Add include OpTurbo::Streamable include OpPrimer::ComponentHelpers - def initialize(share:, strategy:, container: nil) + def initialize(share:, strategy:, container: nil, allow_hover_cards: false) super @share = share @@ -45,6 +45,7 @@ def initialize(share:, strategy:, container: nil) @principal = share.principal @available_roles = strategy.available_roles @container = container + @allow_hover_cards = allow_hover_cards end def wrapper_uniq_by @@ -53,7 +54,7 @@ def wrapper_uniq_by private - attr_reader :share, :entity, :principal, :container, :available_roles, :strategy + attr_reader :share, :entity, :principal, :container, :available_roles, :strategy, :allow_hover_cards def share_editable? @share_editable ||= User.current != share.principal && sharing_manageable? diff --git a/app/components/users/avatar_component.rb b/app/components/users/avatar_component.rb index 907f226ce736..c0c84ed78e65 100644 --- a/app/components/users/avatar_component.rb +++ b/app/components/users/avatar_component.rb @@ -32,7 +32,8 @@ class AvatarComponent < ApplicationComponent include AvatarHelper include OpPrimer::ComponentHelpers - def initialize(user:, show_name: true, link: true, size: "default", classes: "", title: nil, name_classes: "") + def initialize(user:, show_name: true, link: true, size: "default", classes: "", title: nil, name_classes: "", + hover_card: { active: true, target: :default }) super @user = user @@ -40,6 +41,7 @@ def initialize(user:, show_name: true, link: true, size: "default", classes: "", @link = link @size = size @title = title + @hover_card = hover_card @classes = classes @name_classes = name_classes end @@ -49,14 +51,19 @@ def render? end def call - helpers.avatar( - @user, + options = { size: @size, link: @link, hide_name: !@show_name, title: @title, class: @classes, - name_classes: @name_classes + name_classes: @name_classes, + hover_card: @hover_card + } + + helpers.avatar( + @user, + **options ) end end diff --git a/app/components/users/hover_card_component.html.erb b/app/components/users/hover_card_component.html.erb new file mode 100644 index 000000000000..1bc4de56a894 --- /dev/null +++ b/app/components/users/hover_card_component.html.erb @@ -0,0 +1,85 @@ +<%#-- 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. + +++#%> + +<%= + if @user.present? + flex_layout(classes: 'op-user-hover-card', data: { test_selector: "user-hover-card-#{@user.id}" }) do |flex| + flex.with_row do + render(Users::AvatarComponent.new(user: @user, show_name: false, link: false, hover_card: { active: false })) + end + + flex.with_row do + flex_layout(classes: 'op-user-hover-card--info') do |f| + f.with_column(classes: 'op-user-hover-card--name') do + render(Primer::Beta::Text.new(font_weight: :semibold, data: { test_selector: 'user-hover-card-name' })) do + @user.name + end + end + + if show_email? + f.with_column(classes: 'op-user-hover-card--email') do + render(Primer::Beta::Text.new(font_size: :small, + color: :muted, + data: { test_selector: 'user-hover-card-email' })) do + @user.mail + end + end + end + end + end + + flex.with_row do + flex_layout(classes: 'op-user-hover-card--group-list') do |f| + f.with_column do + render(Primer::Beta::Octicon.new(icon: :people)) + end + + f.with_column do + render(Primer::Beta::Text.new(color: :muted, data: { test_selector: 'user-hover-card-groups' })) do + group_membership_summary + end + end + end + end + + flex.with_row do + render(Primer::Beta::Button.new(tag: :a, + href: helpers.allowed_management_user_profile_path(@user), + data: { test_selector: 'user-hover-card-profile-btn' })) do + I18n.t("users.open_profile") + end + end + end + else + render Primer::Beta::Blankslate.new(border: false, narrow: true) do |component| + component.with_visual_icon(icon: "x-circle") + component.with_heading(tag: :h3).with_content(I18n.t("api_v3.errors.not_found.user")) + end + end +%> diff --git a/app/components/users/hover_card_component.rb b/app/components/users/hover_card_component.rb new file mode 100644 index 000000000000..afd040da99bd --- /dev/null +++ b/app/components/users/hover_card_component.rb @@ -0,0 +1,95 @@ +# 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 Users::HoverCardComponent < ApplicationComponent + include OpPrimer::ComponentHelpers + + def initialize(id:) + super + + @id = id + @user = User.find(@id) + end + + def show_email? + (@user == User.current) || User.current.allowed_globally?(:view_user_email) + end + + # Constructs a string in the form of: + # "Member of group4, group5" + # or + # "Member of group1, group2 and 3 more" + # The latter string is cut off since the complete list of group names would exceed the allowed `max_length`. + def group_membership_summary(max_length = 40) + groups = @user.groups.visible + return no_group_text if groups.empty? + + group_links = linked_group_names(groups) + + cutoff_index = calculate_cutoff_index(groups.map(&:name), max_length) + build_summary(group_links, cutoff_index) + end + + private + + def linked_group_names(groups) + groups.map { |group| link_to(h(group.name), show_group_path(group)) } + end + + def no_group_text + t("users.groups.no_results_title_text") + end + + # Calculate the index at which to cut off the group names, based on plain text length + def calculate_cutoff_index(names, max_length) + current_length = 0 + + names.each_with_index do |name, index| + new_length = current_length + name.length + (index > 0 ? 2 : 0) # 2 for ", " separator + return index if new_length > max_length + + current_length = new_length + end + + names.size # No cutoff needed -> return the total size + end + + def build_summary(links, cutoff_index) + summary_links = safe_join(links[0...cutoff_index], ", ") + remaining_count = links.size - cutoff_index + remaining_count_link = link_to(t("users.groups.more", count: remaining_count), user_path(@user)) + + if remaining_count > 0 + t("users.groups.summary_with_more", names: summary_links, count_link: remaining_count_link).html_safe + else + t("users.groups.summary", names: summary_links).html_safe + end + end +end diff --git a/app/components/users/hover_card_component.sass b/app/components/users/hover_card_component.sass new file mode 100644 index 000000000000..cc3a1787b851 --- /dev/null +++ b/app/components/users/hover_card_component.sass @@ -0,0 +1,16 @@ +// Correct the z-index of the regular hover card container so that it is above the dropdown of user auto completers +.spot-modal-overlay:has(.op-user-hover-card) + z-index: 9600 + +.op-user-hover-card + gap: 1rem + overflow: hidden + + .op-user-hover-card--info + gap: 0.5rem + + .op-user-hover-card--name, .op-user-hover-card--email + @include text-shortener() + + .op-user-hover-card--group-list + gap: 0.5rem diff --git a/app/controllers/users/hover_card_controller.rb b/app/controllers/users/hover_card_controller.rb new file mode 100644 index 000000000000..714c09a96589 --- /dev/null +++ b/app/controllers/users/hover_card_controller.rb @@ -0,0 +1,37 @@ +# 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 Users::HoverCardController < ApplicationController + no_authorization_required! :show + + def show + @id = params[:id] + render layout: nil + end +end diff --git a/app/forms/shares/invitee.rb b/app/forms/shares/invitee.rb index e8eb8f8407d9..c60ed79d1c3c 100644 --- a/app/forms/shares/invitee.rb +++ b/app/forms/shares/invitee.rb @@ -51,14 +51,17 @@ class Invitee < ApplicationForm multiple: true, focusDirectly: true, appendToComponent: true, - disabled: @disabled + disabled: @disabled, + isOpenedInModal: true, + hoverCards: @allow_hover_cards } ) end - def initialize(disabled: false) + def initialize(disabled: false, allow_hover_cards: false) super() @disabled = disabled + @allow_hover_cards = allow_hover_cards end end end diff --git a/app/models/sharing_strategies/base_strategy.rb b/app/models/sharing_strategies/base_strategy.rb index bff01d94d896..25c00ff2e859 100644 --- a/app/models/sharing_strategies/base_strategy.rb +++ b/app/models/sharing_strategies/base_strategy.rb @@ -50,6 +50,10 @@ def manageable? raise NotImplementedError, "Override in a subclass and return true if the current user can manage sharing" end + def allow_hover_cards? + raise NotImplementedError, "Override in a subclass and return true if hover cards should appear hovering users" + end + def create_contract_class raise NotImplementedError, "Override in a subclass and return the contract class for creating a share" end diff --git a/app/models/sharing_strategies/project_query_strategy.rb b/app/models/sharing_strategies/project_query_strategy.rb index eb05b1970d78..7a898438bb62 100644 --- a/app/models/sharing_strategies/project_query_strategy.rb +++ b/app/models/sharing_strategies/project_query_strategy.rb @@ -54,6 +54,10 @@ def viewable? @entity.visible? end + def allow_hover_cards? + true + end + def create_contract_class Shares::ProjectQueries::CreateContract end diff --git a/app/models/sharing_strategies/work_package_strategy.rb b/app/models/sharing_strategies/work_package_strategy.rb index 556767dc08aa..018c6b0f5c71 100644 --- a/app/models/sharing_strategies/work_package_strategy.rb +++ b/app/models/sharing_strategies/work_package_strategy.rb @@ -53,6 +53,13 @@ def viewable? user.allowed_in_project?(:view_shared_work_packages, @entity.project) end + # Since the work package share dialog is embedded into an angular page, hover cards would compete for the + # portal outlet when rendering, causing bugs. Until the work package share dialog is refactored to be an + # async-dialog, we must disable hover cards for it. + def allow_hover_cards? + false + end + def share_description(share) # rubocop:disable Metrics/PerceivedComplexity,Metrics/AbcSize scope = %i[sharing user_details] diff --git a/app/views/news/index.html.erb b/app/views/news/index.html.erb index 968d66ba0867..0ac67a4077d0 100644 --- a/app/views/news/index.html.erb +++ b/app/views/news/index.html.erb @@ -58,9 +58,9 @@ See COPYRIGHT and LICENSE files for more details. <% if @newss.any? %> <% @newss.each do |news| %>