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| %>
-

<%= avatar(news.author) %><%= link_to_project(news.project) + ': ' unless news.project == @project %> - <%= link_to h(news.title), news_path(news) %> - <%= "(#{t(:label_x_comments, count: news.comments_count)})" if news.comments_count > 0 %>

+

<%= avatar(news.author) %><%= link_to_project(news.project) + ': ' unless news.project == @project %> + <%= link_to h(news.title), news_path(news) %> + <%= "(#{t(:label_x_comments, count: news.comments_count)})" if news.comments_count > 0 %>

<%= authoring news.created_at, news.author %>

<%= format_text(news.summary.present? ? news.summary : truncate(news.description, length: 150, escape: false), object: news) %> diff --git a/app/views/users/hover_card/show.html.erb b/app/views/users/hover_card/show.html.erb new file mode 100644 index 000000000000..5fd0177bb0eb --- /dev/null +++ b/app/views/users/hover_card/show.html.erb @@ -0,0 +1,3 @@ + + <%= render Users::HoverCardComponent.new(id: @id) %> + diff --git a/config/locales/en.yml b/config/locales/en.yml index 9657cfe546b0..3f0d35b3e96d 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -539,8 +539,12 @@ en: groups: member_in_these_groups: "This user is currently a member of the following groups:" no_results_title_text: This user is currently not a member in any group. + summary_with_more: Member of %{names} and %{count_link}. + more: "%{count} more" + summary: Member of %{names}. memberships: no_results_title_text: This user is currently not a member of a project. + open_profile: "Open profile" page: text: "Text" placeholder_users: @@ -4132,6 +4136,7 @@ en: code_500_missing_enterprise_token: "The request can not be handled due to invalid or missing Enterprise token." not_found: work_package: "The work package you are looking for cannot be found or has been deleted." + user: "The user you are looking for cannot be found or has been deleted." expected: date: "YYYY-MM-DD (ISO 8601 date only)" datetime: "YYYY-MM-DDThh:mm:ss[.lll][+hh:mm] (any compatible ISO 8601 datetime)" diff --git a/config/routes.rb b/config/routes.rb index 158ed8a6a293..38c278f2cdea 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -642,6 +642,7 @@ resources :memberships, controller: "users/memberships", only: %i[update create destroy] member do + get "/hover_card" => "users/hover_card#show" get "/edit(/:tab)" => "users#edit", as: "edit" get "/change_status/:change_action" => "users#change_status_info", as: "change_status_info" post :change_status diff --git a/frontend/src/app/core/global_search/input/global-search-input.component.html b/frontend/src/app/core/global_search/input/global-search-input.component.html index 86954b119595..0419ceecb2e0 100644 --- a/frontend/src/app/core/global_search/input/global-search-input.component.html +++ b/frontend/src/app/core/global_search/input/global-search-input.component.html @@ -59,6 +59,7 @@
diff --git a/frontend/src/app/core/setup/globals/global-listeners/hover-card-trigger.service.ts b/frontend/src/app/core/setup/globals/global-listeners/hover-card-trigger.service.ts index eff95f2b842c..1ef3bb63b4c2 100644 --- a/frontend/src/app/core/setup/globals/global-listeners/hover-card-trigger.service.ts +++ b/frontend/src/app/core/setup/globals/global-listeners/hover-card-trigger.service.ts @@ -29,12 +29,23 @@ import { Injectable, Injector, NgZone } from '@angular/core'; import { OpModalService } from 'core-app/shared/components/modal/modal.service'; import { HoverCardComponent } from 'core-app/shared/components/modals/preview-modal/hover-card-modal/hover-card.modal'; +import { PortalOutletTarget } from 'core-app/shared/components/modal/portal-outlet-target.enum'; @Injectable({ providedIn: 'root' }) export class HoverCardTriggerService { private modalElement:HTMLElement; private mouseInModal = false; + private hoverTimeout:number|null = null; + private closeTimeout:number|null = null; + // Set to custom when opening the hover card on top of another modal + private modalTarget:PortalOutletTarget = PortalOutletTarget.Default; + private previousTarget:HTMLElement|null = null; + + // The time you need to keep hovering over a trigger before the hover card is shown + OPEN_DELAY_IN_MS = 1000; + // The time you need to keep away from trigger/hover card before an opened card is closed + CLOSE_DELAY_IN_MS = 250; constructor( readonly opModalService:OpModalService, @@ -49,31 +60,63 @@ export class HoverCardTriggerService { e.stopPropagation(); // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const el = e.target as HTMLElement; - if (el) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const turboFrameUrl = el.getAttribute('data-hover-card-url'); + if (!el) { return; } - if (!turboFrameUrl) { - return; - } + if (this.previousTarget && this.previousTarget === el) { + // Re-entering the trigger counts as hovering over the card: + this.mouseInModal = true; + // But we will not re-render the same card, abort here + return; + } + this.previousTarget = el; + + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const turboFrameUrl = el.getAttribute('data-hover-card-url'); + if (!turboFrameUrl) { return; } + + // When set in an angular component, the url attribute might be wrapped in additional quotes. Strip them. + const cleanedTurboFrameUrl = turboFrameUrl.replace(/^"(.*)"$/, '$1'); + + // Reset close timer for when hovering over multiple triggers in quick succession. + // A timer from a previous hover card might still be running. We do not want it to + // close the new (i.e. this) hover card. + if (this.closeTimeout) { + clearTimeout(this.closeTimeout); + this.closeTimeout = null; + } + + // Set a delay before showing the hover card + this.hoverTimeout = window.setTimeout(() => { + this.parseHoverCardOptions(el); - this.opModalService.show( + // There is only one possible slot to insert a modal. If that slot is taken, we assume the other modal + // to be more important than a hover card and give up. + const modal = this.opModalService.showIfNotActive( HoverCardComponent, this.injector, - { turboFrameSrc: turboFrameUrl, event: e }, + { turboFrameSrc: cleanedTurboFrameUrl, event: e }, true, - ).subscribe((previewModal) => { + false, + this.modalTarget, + ); + + modal?.subscribe((previewModal) => { this.modalElement = previewModal.elementRef.nativeElement as HTMLElement; + previewModal.alignment = 'top'; + void previewModal.reposition(this.modalElement, el); }); - } + }, this.OPEN_DELAY_IN_MS); }); jQuery(document.body).on('mouseleave', '.op-hover-card--preview-trigger', () => { + this.clearHoverTimer(); + this.mouseInModal = false; this.closeAfterTimeout(); }); jQuery(document.body).on('mouseleave', '.op-hover-card', () => { + this.clearHoverTimer(); this.mouseInModal = false; this.closeAfterTimeout(); }); @@ -83,13 +126,30 @@ export class HoverCardTriggerService { }); } + // Should be called when the mouse leaves the hover-zone so that we no longer attempt ot display the hover card. + private clearHoverTimer() { + if (this.hoverTimeout) { + clearTimeout(this.hoverTimeout); + this.hoverTimeout = null; + } + } + private closeAfterTimeout() { this.ngZone.runOutsideAngular(() => { - setTimeout(() => { + this.closeTimeout = window.setTimeout(() => { if (!this.mouseInModal) { this.opModalService.close(); + // Allow opening this target once more, since it has been orderly closed + this.previousTarget = null; } - }, 100); + }, this.CLOSE_DELAY_IN_MS); }); } + + private parseHoverCardOptions(el:HTMLElement) { + const modalTarget = el.getAttribute('data-hover-card-target'); + if (modalTarget) { + this.modalTarget = parseInt(modalTarget, 10); + } + } } diff --git a/frontend/src/app/shared/components/autocompleter/user-autocompleter/user-autocompleter-template.component.html b/frontend/src/app/shared/components/autocompleter/user-autocompleter/user-autocompleter-template.component.html index 6e40633b218d..e7753cd2dfa6 100644 --- a/frontend/src/app/shared/components/autocompleter/user-autocompleter/user-autocompleter-template.component.html +++ b/frontend/src/app/shared/components/autocompleter/user-autocompleter/user-autocompleter-template.component.html @@ -7,6 +7,8 @@ *ngIf="item && item.href" [principal]="item" [hideName]="true" + [hoverCard]="hoverCards" + [hoverCardModalTarget]="isOpenedInModal ? 'custom' : 'default'" size="mini" > ; @ViewChild('footerTemplate') footerTemplate?:TemplateRef; diff --git a/frontend/src/app/shared/components/autocompleter/user-autocompleter/user-autocompleter.component.ts b/frontend/src/app/shared/components/autocompleter/user-autocompleter/user-autocompleter.component.ts index 904b8f0afcc2..91cc6cd53fb0 100644 --- a/frontend/src/app/shared/components/autocompleter/user-autocompleter/user-autocompleter.component.ts +++ b/frontend/src/app/shared/components/autocompleter/user-autocompleter/user-autocompleter.component.ts @@ -56,6 +56,7 @@ import { addFiltersToPath } from 'core-app/core/apiv3/helpers/add-filters-to-pat import { UserAutocompleterTemplateComponent } from 'core-app/shared/components/autocompleter/user-autocompleter/user-autocompleter-template.component'; import { IUser } from 'core-app/core/state/principals/user.model'; import { compareByAttribute } from 'core-app/shared/helpers/angular/tracking-functions'; +import { SHOW_USER_HOVER_CARD } from 'core-app/shared/components/time_entries/create/create.modal'; export const usersAutocompleterSelector = 'op-user-autocompleter'; @@ -87,18 +88,31 @@ export interface IUserAutocompleteItem { export class UserAutocompleterComponent extends OpAutocompleterComponent implements OnInit, ControlValueAccessor { @Input() public inviteUserToProject:string|undefined; + @Input() public isOpenedInModal:boolean = false; + @Input() public hoverCards:boolean = true; + @Input() public url:string = this.apiV3Service.users.path; @Output() public userInvited = new EventEmitter(); @InjectField(OpInviteUserModalService) opInviteUserModalService:OpInviteUserModalService; + @InjectField(SHOW_USER_HOVER_CARD, true) showUserHoverCard:boolean; getOptionsFn = this.getAvailableUsers.bind(this); ngOnInit():void { super.ngOnInit(); - this.applyTemplates(UserAutocompleterTemplateComponent, { inviteUserToProject: this.inviteUserToProject }); + // Disabling hover cards by injection takes precedence over the input setting + if (!this.showUserHoverCard) { + this.hoverCards = false; + } + + this.applyTemplates(UserAutocompleterTemplateComponent, { + inviteUserToProject: this.inviteUserToProject, + isOpenedInModal: this.isOpenedInModal, + hoverCards: this.hoverCards, + }); this .opInviteUserModalService diff --git a/frontend/src/app/shared/components/fields/display/field-types/user-display-field.module.ts b/frontend/src/app/shared/components/fields/display/field-types/user-display-field.module.ts index c28ede70d080..9f5c50c4fbfc 100644 --- a/frontend/src/app/shared/components/fields/display/field-types/user-display-field.module.ts +++ b/frontend/src/app/shared/components/fields/display/field-types/user-display-field.module.ts @@ -29,13 +29,19 @@ import { DisplayField } from 'core-app/shared/components/fields/display/display-field.module'; import { InjectField } from 'core-app/shared/helpers/angular/inject-field.decorator'; import { PrincipalRendererService } from 'core-app/shared/components/principal/principal-renderer.service'; +import { PrincipalLike } from 'core-app/shared/components/principal/principal-types'; + +interface Attribute { + url?:string; + name?:string; +} export class UserDisplayField extends DisplayField { @InjectField() principalRenderer:PrincipalRendererService; public get value() { if (this.schema) { - return this.attribute && this.attribute.name; + return this.typeSafeAttribute().name || ''; } return null; } @@ -46,10 +52,14 @@ export class UserDisplayField extends DisplayField { } else { this.principalRenderer.render( element, - this.attribute, + this.typeSafeAttribute() as PrincipalLike, { hide: false, link: false }, - { hide: false, size: 'medium' }, + { hide: false, size: 'medium', hoverCard: { url: this.typeSafeAttribute().url || '' } }, ); } } + + private typeSafeAttribute():Attribute { + return this.attribute as Attribute; + } } diff --git a/frontend/src/app/shared/components/modal/modal.service.ts b/frontend/src/app/shared/components/modal/modal.service.ts index c495e3d8a3dc..eacdd7b1ad32 100644 --- a/frontend/src/app/shared/components/modal/modal.service.ts +++ b/frontend/src/app/shared/components/modal/modal.service.ts @@ -67,6 +67,26 @@ export class OpModalService { }); } + /** + * Checks whether there is currently an active modal. Only shows the requested modal if this is not the case. + * Will return null if showing the modal is denied. + * @see show + */ + public showIfNotActive( + modal:ComponentType, + injector:Injector|'global', + locals:Record = {}, + notFullscreen = false, + mobileTopPosition = false, + target = PortalOutletTarget.Default, + ):Observable|null { + if (this.activeModalInstance$.value) { + return null; + } + + return this.show(modal, injector, locals, notFullscreen, mobileTopPosition, target); + } + /** * Open a Modal reference and append it to the portal * diff --git a/frontend/src/app/shared/components/principal/principal-renderer.service.ts b/frontend/src/app/shared/components/principal/principal-renderer.service.ts index eff236ea5d2f..6e4d52856b35 100644 --- a/frontend/src/app/shared/components/principal/principal-renderer.service.ts +++ b/frontend/src/app/shared/components/principal/principal-renderer.service.ts @@ -6,12 +6,19 @@ import idFromLink from 'core-app/features/hal/helpers/id-from-link'; import { IPrincipal } from 'core-app/core/state/principals/principal.model'; import { PrincipalLike } from './principal-types'; import { hrefFromPrincipal, PrincipalType, typeFromHref } from './principal-helper'; +import { PortalOutletTarget } from 'core-app/shared/components/modal/portal-outlet-target.enum'; export type AvatarSize = 'default'|'medium'|'mini'; +export interface HoverCardOptions { + url?:string; + modalTarget?:PortalOutletTarget; +} + export interface AvatarOptions { hide:boolean; size:AvatarSize; + hoverCard?:HoverCardOptions; } export interface NameOptions { @@ -99,6 +106,11 @@ export class PrincipalRendererService { container.classList.add('op-principal'); const type = typeFromHref(hrefFromPrincipal(principal)) as PrincipalType; + // Only actual users provide a hover card with additional info + if (type !== 'user') { + avatar.hoverCard = undefined; + } + if (!avatar.hide) { const el = this.renderAvatar(principal, avatar, type); container.appendChild(el); @@ -129,6 +141,8 @@ export class PrincipalRendererService { fallback.title = principal.name; fallback.textContent = userInitials; + this.setHoverCardAttributes(fallback, options, principal); + if (type === 'placeholder_user' && colorMode !== colorModes.lightHighContrast) { fallback.style.color = colorCode; fallback.style.borderColor = colorCode; @@ -144,7 +158,11 @@ export class PrincipalRendererService { return fallback; } - private renderUserAvatar(principal:PrincipalLike|IPrincipal, fallback:HTMLElement, options:AvatarOptions):void { + private renderUserAvatar( + principal:PrincipalLike|IPrincipal, + fallback:HTMLElement, + options:AvatarOptions, + ):void { const url = this.userAvatarUrl(principal); if (!url) { @@ -155,6 +173,9 @@ export class PrincipalRendererService { image.classList.add('op-principal--avatar'); image.classList.add('op-avatar'); image.classList.add(`op-avatar_${options.size}`); + + this.setHoverCardAttributes(image, options, principal); + image.src = url; image.title = principal.name; image.alt = principal.name; @@ -170,6 +191,11 @@ export class PrincipalRendererService { return id ? this.apiV3Service.users.id(id).avatar.toString() : null; } + private userHoverCardUrl(principal:PrincipalLike|IPrincipal):string|null { + const id = principal.id || idFromLink(hrefFromPrincipal(principal)); + return id ? `/users/${id}/hover_card` : null; + } + private renderName( principal:PrincipalLike|IPrincipal, type:PrincipalType, @@ -226,4 +252,24 @@ export class PrincipalRendererService { const last = name[lastSpace + 1]?.toUpperCase(); return [first, last].join(''); } + + private setHoverCardAttributes(element:HTMLElement, options:AvatarOptions, principal:PrincipalLike|IPrincipal):void { + const hoverCard = options.hoverCard; + + if (!hoverCard?.url) { + // In some cases, there is no URL given although a hover card is expected. For example when the principle + // is rendered from an angular template. We try to infer the URL here. + const url = this.userHoverCardUrl(principal); + if (hoverCard && url) { + hoverCard.url = url; + } else { + return; + } + } + + element.classList.add('op-hover-card--preview-trigger'); + + element.setAttribute('data-hover-card-url', hoverCard.url); + element.setAttribute('data-hover-card-target', String(hoverCard.modalTarget || PortalOutletTarget.Default)); + } } diff --git a/frontend/src/app/shared/components/principal/principal.component.ts b/frontend/src/app/shared/components/principal/principal.component.ts index 8a8e4ac5c4df..3d521120a7f1 100644 --- a/frontend/src/app/shared/components/principal/principal.component.ts +++ b/frontend/src/app/shared/components/principal/principal.component.ts @@ -40,14 +40,12 @@ import { PathHelperService } from 'core-app/core/path-helper/path-helper.service import { ApiV3Service } from 'core-app/core/apiv3/api-v3.service'; import { TimezoneService } from 'core-app/core/datetime/timezone.service'; -import { - AvatarSize, - PrincipalRendererService, -} from './principal-renderer.service'; +import { AvatarOptions, AvatarSize, PrincipalRendererService } from './principal-renderer.service'; import { PrincipalLike } from './principal-types'; import { populateInputsFromDataset } from 'core-app/shared/components/dataset-inputs'; import { PrincipalType } from 'core-app/shared/components/principal/principal-helper'; import { PrincipalsResourceService } from 'core-app/core/state/principals/principals.service'; +import { PortalOutletTarget } from 'core-app/shared/components/modal/portal-outlet-target.enum'; export const principalSelector = 'op-principal'; @@ -76,6 +74,12 @@ export class OpPrincipalComponent implements OnInit { @Input() size:AvatarSize = 'default'; + @Input() avatarClasses? = ''; + + @Input() hoverCard= true; + @Input() hoverCardUrl= ''; + @Input() hoverCardModalTarget:'default'|'custom' = 'default'; + @Input() title = ''; public constructor( @@ -93,6 +97,19 @@ export class OpPrincipalComponent implements OnInit { ngOnInit() { if (this.principal.name) { + const avatarOptions:AvatarOptions = { + hide: this.hideAvatar, + size: this.size, + }; + + if (this.hoverCard) { + avatarOptions.hoverCard = { + url: this.hoverCardUrl, + modalTarget: this.hoverCardModalTarget === 'custom' + ? PortalOutletTarget.Custom : PortalOutletTarget.Default, + }; + } + this.principalRenderer.render( this.elementRef.nativeElement as HTMLElement, this.principal, @@ -101,10 +118,7 @@ export class OpPrincipalComponent implements OnInit { link: this.link, classes: this.nameClasses, }, - { - hide: this.hideAvatar, - size: this.size, - }, + avatarOptions, this.title === '' ? null : this.title, ); } diff --git a/frontend/src/app/shared/components/time_entries/create/create.modal.ts b/frontend/src/app/shared/components/time_entries/create/create.modal.ts index 6025be38a499..50d90c4493e6 100644 --- a/frontend/src/app/shared/components/time_entries/create/create.modal.ts +++ b/frontend/src/app/shared/components/time_entries/create/create.modal.ts @@ -1,14 +1,17 @@ -import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { ChangeDetectionStrategy, Component, InjectionToken } from '@angular/core'; import { HalResourceEditingService } from 'core-app/shared/components/fields/edit/services/hal-resource-editing.service'; import { TimeEntryResource } from 'core-app/features/hal/resources/time-entry-resource'; import { HalResource } from 'core-app/features/hal/resources/hal-resource'; import { TimeEntryBaseModal } from '../shared/modal/base.modal'; +export const SHOW_USER_HOVER_CARD = new InjectionToken('SHOW_USER_HOVER_CARD'); + @Component({ templateUrl: '../shared/modal/base.modal.html', changeDetection: ChangeDetectionStrategy.OnPush, providers: [ HalResourceEditingService, + { provide: SHOW_USER_HOVER_CARD, useValue: false }, ], }) export class TimeEntryCreateModalComponent extends TimeEntryBaseModal { diff --git a/lib/redmine/menu_manager/top_menu_helper.rb b/lib/redmine/menu_manager/top_menu_helper.rb index 1881d9b12bf4..059b72f2b604 100644 --- a/lib/redmine/menu_manager/top_menu_helper.rb +++ b/lib/redmine/menu_manager/top_menu_helper.rb @@ -138,7 +138,7 @@ def render_direct_login end def render_user_drop_down(items) - avatar = avatar(User.current, class: "op-top-menu-user-avatar") + avatar = avatar(User.current, class: "op-top-menu-user-avatar", hover_card: { active: false }) render_menu_dropdown_with_items( label: avatar.presence || "", label_options: { diff --git a/lookbook/docs/patterns/25-hover-cards.md.erb b/lookbook/docs/patterns/25-hover-cards.md.erb index 2bb9f2bc0a9d..219ccad1830d 100644 --- a/lookbook/docs/patterns/25-hover-cards.md.erb +++ b/lookbook/docs/patterns/25-hover-cards.md.erb @@ -1,4 +1,4 @@ -The HoverCard is a pattern related to the `Primer::Beta::Popover` and is used to show additional contexual information on certain kinds of resources like work packages and users. The hover card is opened by hovering over a certain trigger. When hovering outside of the card or its trigger, the popover is closed again. +The HoverCard is a pattern related to the `Primer::Beta::Popover` and is used to show additional contextual information on certain kinds of resources like work packages and users. The hover card is opened by hovering over a certain trigger. When hovering outside of the card or its trigger, the popover is closed again. ## Overview @@ -27,7 +27,7 @@ The HoverCard always consists of two basic parts: ## Used in - WorkPackage preview when linking via `#ID` -- Soon: User preview when hovering the avatar +- User preview when hovering the avatar ## Technical notes @@ -61,13 +61,27 @@ Additionally, the trigger element needs to pass the URL for the `turboFrame` as class="op-hover-card--preview-trigger"> #14 + + + + + ``` +Note that the user example is simplified. For actual use in the application, it is recommended to use the `AvatarComponent`, which offers an option for hover cards. + **Actually rendered card content**: ```html <%= render WorkPackages::HoverCardComponent.new(id: 14) %> - %> + + + + <%= render Users::HoverCardComponent.new(id: 14) %> + ``` diff --git a/lookbook/previews/open_project/users/avatar_component_preview.rb b/lookbook/previews/open_project/users/avatar_component_preview.rb index afc90b99f790..fe647903a0c7 100644 --- a/lookbook/previews/open_project/users/avatar_component_preview.rb +++ b/lookbook/previews/open_project/users/avatar_component_preview.rb @@ -7,9 +7,12 @@ class AvatarComponentPreview < Lookbook::Preview # @param size select { choices: [default, medium, mini] } # @param link toggle # @param show_name toggle - def default(size: :default, link: true, show_name: true) + # @param hover_card toggle + # @param hover_card_target select { choices: [default, custom] } + def default(size: :default, link: true, show_name: true, hover_card: true, hover_card_target: :default) user = FactoryBot.build_stubbed(:user) - render(Users::AvatarComponent.new(user:, size:, link:, show_name:)) + render(Users::AvatarComponent.new(user:, size:, link:, show_name:, + hover_card: { active: hover_card, target: hover_card_target })) end def sizes diff --git a/modules/avatars/app/helpers/avatar_helper.rb b/modules/avatars/app/helpers/avatar_helper.rb index 97672ef0dc4c..97f9eea7486b 100644 --- a/modules/avatars/app/helpers/avatar_helper.rb +++ b/modules/avatars/app/helpers/avatar_helper.rb @@ -45,8 +45,8 @@ module AvatarHelper # Returns the avatar image tag for the given +user+ if avatars are enabled # +user+ can be a User or a string that will be scanned for an email address (eg. 'joe ') - def avatar(principal, size: "default", hide_name: true, name_classes: "", **) - build_principal_avatar_tag(principal, size:, hide_name:, name_classes:, **) + def avatar(principal, size: "default", hide_name: true, hover_card: { active: true, target: :default }, name_classes: "", **) + build_principal_avatar_tag(principal, size:, hide_name:, hover_card:, name_classes:, **) rescue StandardError => e Rails.logger.error "Failed to create avatar for #{principal}: #{e}" "".html_safe @@ -99,22 +99,27 @@ def build_principal_avatar_tag(user, **) id: user.id } + inputs = { + principal:, + link: tag_options[:link], + size: tag_options[:size], + hideName: tag_options[:hide_name], + nameClasses: tag_options[:name_classes], + title: tag_options.fetch(:title, "") + } + + inputs = hover_card_options(user, inputs, tag_options) + angular_component_tag "opce-principal", class: tag_options[:class], - inputs: { - principal:, - link: tag_options[:link], - size: tag_options[:size], - hideName: tag_options[:hide_name], - nameClasses: tag_options[:name_classes], - title: tag_options.fetch(:title, "") - } + inputs: end def merge_default_avatar_options(user, options) default_options = { size: "default", - hide_name: true + hide_name: true, + hover_card: {} } default_options[:title] = h(user.name) if user.respond_to?(:name) @@ -136,4 +141,22 @@ def extract_email_address(object) object.mail end end + + private + + def hover_card_options(user, inputs = {}, tag_options = {}) + # The hover card will be triggered by hovering over the avatar (if enabled) + hover_card = tag_options[:hover_card] + if hover_card.fetch(:active, true) + inputs[:hoverCard] = true + inputs[:hoverCardModalTarget] = hover_card.fetch(:target, :default) + + inputs[:hoverCardUrl] = hover_card_user_path(user.id) + else + # We must explicitly set this to false since the angular renderer defines their own default to `true` + inputs[:hoverCard] = false + end + + inputs + end end diff --git a/modules/avatars/spec/helpers/avatar_helper_spec.rb b/modules/avatars/spec/helpers/avatar_helper_spec.rb index bee8c25b06e6..37b0149856d6 100644 --- a/modules/avatars/spec/helpers/avatar_helper_spec.rb +++ b/modules/avatars/spec/helpers/avatar_helper_spec.rb @@ -24,22 +24,27 @@ allow(user).to receive(:local_avatar_attachment).and_return avatar_stub end - def expected_user_avatar_tag(user) + def expected_user_avatar_tag(user, hover_card: true, modal_target: "default", hover_card_url: "/users/#{user.id}/hover_card") principal = { href: "/api/v3/users/#{user.id}", name: user.name, id: user.id } - angular_component_tag "opce-principal", - inputs: { - principal:, - hideName: true, - nameClasses: "", - link: nil, - title: user.name, - size: "default" - } + inputs = { + principal:, + hideName: true, + nameClasses: "", + link: nil, + title: user.name, + size: "default", + hoverCard: hover_card + } + + inputs[:hoverCardModalTarget] = modal_target if hover_card + inputs[:hoverCardUrl] = hover_card_url if hover_card + + angular_component_tag "opce-principal", inputs: end def local_expected_url(user) @@ -210,4 +215,16 @@ def gravatar_expected_url(digest, options = {}) expect(helper.avatar(user)).to be_html_eql(expected_user_avatar_tag(user)) end end + + context "when using hover cards" do + it "can be disabled" do + avatar = helper.avatar(user, hover_card: { active: false }) + expect(avatar).to be_html_eql(expected_user_avatar_tag(user, hover_card: false)) + end + + it "provides a custom modal target" do + avatar = helper.avatar(user, hover_card: { target: :custom }) + expect(avatar).to be_html_eql(expected_user_avatar_tag(user, modal_target: :custom)) + end + end end diff --git a/spec/components/users/hover_card_component_spec.rb b/spec/components/users/hover_card_component_spec.rb new file mode 100644 index 000000000000..450fbbcb9638 --- /dev/null +++ b/spec/components/users/hover_card_component_spec.rb @@ -0,0 +1,132 @@ +# 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 "rails_helper" + +RSpec.describe Users::HoverCardComponent, type: :component do + include Rails.application.routes.url_helpers + + let(:project) { create(:project) } + let(:user) { create(:user) } + let(:another_user) { create(:user, member_with_permissions: { project => [:manage_members] }) } + let(:current_user) { another_user } + + let(:groups) { [] } + + subject { described_class.new(id: user.id) } + + before do + groups + login_as(current_user) + render_inline(subject) + page.extend TestSelectorFinders + end + + it "renders successfully" do + page.find_test_selector("user-hover-card-name", text: user.name) + end + + context "when displaying email addresses" do + it "hides the email address of a user" do + expect(page).not_to have_test_selector("user-hover-card-email") + end + + context "with the rights to view email addresses" do + # Admin is allowed to see emails + let(:current_user) { build(:admin) } + + it "shows the email address of a user" do + page.find_test_selector("user-hover-card-email", text: user.mail) + end + end + end + + context "when showing the group summary" do + it "shows a no results text for users without group memberships" do + g = page.find_test_selector("user-hover-card-groups") + expect(g).to have_text(I18n.t("users.groups.no_results_title_text")) + end + + context "with the user being member of some groups" do + let(:groups) do + Array.new(2) { create(:group, members: user) } + end + + it "lists the group names for a user" do + g = page.find_test_selector("user-hover-card-groups") + + expect(g).to have_text("Member of #{groups.first.lastname}, #{groups.last.lastname}.") + end + + context "with no rights to manage members" do + # No manage_members permission: + let(:another_user) { create(:user) } + + it "does not show groups" do + g = page.find_test_selector("user-hover-card-groups") + + expect(g).to have_text(I18n.t("users.groups.no_results_title_text")) + end + end + end + + context "with the user being member of many groups" do + let(:groups) do + Array.new(8) { create(:group, members: user) } + end + + it "lists some group names with truncation" do + g = page.find_test_selector("user-hover-card-groups") + + expect(g).to have_text("Member of #{groups.slice(0, 4).map(&:lastname).join(', ')} and 4 more.") + end + end + end + + context "when clicking on the Open Profile button" do + it "leads to the users profile" do + b = page.find_test_selector("user-hover-card-profile-btn") + + expect(b).to have_text(I18n.t("users.open_profile")) + expect(b["href"]).to eq(user_path(user)) + end + + context "with the right to manage users" do + let(:current_user) { build(:admin) } + + it "leads to editing the users profile" do + b = page.find_test_selector("user-hover-card-profile-btn") + + expect(b).to have_text(I18n.t("users.open_profile")) + expect(b["href"]).to eq(edit_user_path(user)) + end + end + end +end diff --git a/spec/features/members/membership_spec.rb b/spec/features/members/membership_spec.rb index 8e33149e5273..850125f8e771 100644 --- a/spec/features/members/membership_spec.rb +++ b/spec/features/members/membership_spec.rb @@ -138,6 +138,22 @@ expect(members_page).not_to have_group group.name end + it "shows more information when hovering over an avatar" do + members_page.in_user_row(peter) do |row| + # Hover over the avatar of peter to open the hover card + row.find(".op-principal--avatar").hover + end + + members_page.in_user_hover_card(peter) do + find_test_selector("user-hover-card-name", text: peter.name) + find_test_selector("user-hover-card-email", text: peter.mail) + find_test_selector("user-hover-card-groups", text: "Member of #{peter.groups.first.name}") + + button = find_test_selector("user-hover-card-profile-btn", text: "Open profile") + expect(button["href"]).to eq(edit_user_url(peter)) + end + end + context "as a member" do current_user { peter } diff --git a/spec/support/pages/members.rb b/spec/support/pages/members.rb index 0dbe8cafe1ef..d3367ee23fe6 100644 --- a/spec/support/pages/members.rb +++ b/spec/support/pages/members.rb @@ -82,6 +82,10 @@ def in_user_row(user, &) page.within(".principal-#{user.id}", &) end + def in_user_hover_card(user, &) + page.within_test_selector("user-hover-card-#{user.id}", &) + end + ## # Adds the given user to this project. #