diff --git a/app/assets/images/lookbook/hover_card.png b/app/assets/images/lookbook/hover_card.png new file mode 100644 index 00000000000..8dde8feb902 Binary files /dev/null and b/app/assets/images/lookbook/hover_card.png differ diff --git a/app/components/_index.sass b/app/components/_index.sass index d20f0a29e39..2864192e81f 100644 --- a/app/components/_index.sass +++ b/app/components/_index.sass @@ -2,6 +2,7 @@ @import "shares/invite_user_form_component" @import "work_packages/details/tab_component" @import "work_packages/progress/modal_body_component" +@import "work_packages/hover_card_component" @import "work_packages/split_view_component" @import "open_project/common/attribute_component" @import "open_project/common/submenu_component" diff --git a/app/components/work_packages/highlighted_date_component.html.erb b/app/components/work_packages/highlighted_date_component.html.erb new file mode 100644 index 00000000000..5611b5bef81 --- /dev/null +++ b/app/components/work_packages/highlighted_date_component.html.erb @@ -0,0 +1,11 @@ +<%= + if @start_date == @due_date + render(Primer::Beta::Text.new(**text_arguments, classes: date_classes(@start_date))) { parsed_date(@start_date) } + else + component_wrapper do + concat(render(Primer::Beta::Text.new(**text_arguments)) { parsed_date(@start_date) }) + concat(render(Primer::Beta::Text.new(**text_arguments)) { " - " }) if @due_date.present? + concat(render(Primer::Beta::Text.new(**text_arguments, classes: date_classes(@due_date))) { parsed_date(@due_date) }) + end + end +%> diff --git a/app/components/work_packages/highlighted_date_component.rb b/app/components/work_packages/highlighted_date_component.rb new file mode 100644 index 00000000000..8c59ef70e81 --- /dev/null +++ b/app/components/work_packages/highlighted_date_component.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +class WorkPackages::HighlightedDateComponent < ApplicationComponent + include OpPrimer::ComponentHelpers + include OpTurbo::Streamable + + def initialize(work_package:) + super + + @work_package = work_package + @start_date = work_package.start_date + @due_date = work_package.due_date + end + + def parsed_date(date) + return if date.nil? + + date.strftime(I18n.t("date.formats.default")) + end + + def date_classes(date) + return if date.nil? + + diff = (date - Time.zone.today).to_i + if diff === 0 + return "__hl_date_due_today" + elsif diff <= -1 + return "__hl_date_overdue" + end + + "__hl_date_not_overdue" + end + + def text_arguments + { + font_size: :small, + color: :muted + } + end +end diff --git a/app/components/work_packages/highlighted_type_component.rb b/app/components/work_packages/highlighted_type_component.rb new file mode 100644 index 00000000000..a13e528adf1 --- /dev/null +++ b/app/components/work_packages/highlighted_type_component.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class WorkPackages::HighlightedTypeComponent < ApplicationComponent + include OpPrimer::ComponentHelpers + + def initialize(work_package:, **system_arguments) + super + + @type = work_package.type + @system_arguments = system_arguments.merge({ classes: "__hl_inline_type_#{@type.id}" }) + end + + def call + render(Primer::Beta::Text.new(**@system_arguments)) { @type.name.upcase } + end +end diff --git a/app/components/work_packages/hover_card_component.html.erb b/app/components/work_packages/hover_card_component.html.erb new file mode 100644 index 00000000000..8bdd2f075ef --- /dev/null +++ b/app/components/work_packages/hover_card_component.html.erb @@ -0,0 +1,40 @@ +<%= + if @work_package.present? + grid_layout('op-wp-hover-card', tag: :div) do |grid| + grid.with_area(:status, tag: :div, color: :muted) do + render WorkPackages::StatusButtonComponent.new(work_package: @work_package, + user: helpers.current_user, + readonly: true, + button_arguments: { size: :small }) + end + + grid.with_area(:id, tag: :div, color: :muted) do + render(Primer::Beta::Text.new(font_size: :small)) { "##{@work_package.id}" } + end + + grid.with_area(:project, tag: :div, color: :muted) do + render(Primer::Beta::Text.new(font_size: :small)) { "- #{@work_package.project.name}" } + end + + grid.with_area(:middleRow, tag: :div) do + concat(render(WorkPackages::HighlightedTypeComponent.new(work_package: @work_package, mr: 1))) + concat(render(Primer::Beta::Text.new(font_weight: :semibold)) { @work_package.subject }) + end + + if @assignee.present? + grid.with_area(:assignee, tag: :div) do + render(Users::AvatarComponent.new(user: @assignee, show_name: false, size: :medium)) + end + end + + grid.with_area(:dates, tag: :div) do + render(WorkPackages::HighlightedDateComponent.new(work_package: @work_package)) + 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.work_package")) + end + end +%> diff --git a/app/components/work_packages/hover_card_component.rb b/app/components/work_packages/hover_card_component.rb new file mode 100644 index 00000000000..02979da2eb9 --- /dev/null +++ b/app/components/work_packages/hover_card_component.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class WorkPackages::HoverCardComponent < ApplicationComponent + include OpPrimer::ComponentHelpers + + def initialize(id:) + super + + @id = id + @work_package = WorkPackage.visible.find_by(id:) + @assignee = @work_package.present? ? @work_package.assigned_to : nil + end +end diff --git a/app/components/work_packages/hover_card_component.sass b/app/components/work_packages/hover_card_component.sass new file mode 100644 index 00000000000..af7a019e17b --- /dev/null +++ b/app/components/work_packages/hover_card_component.sass @@ -0,0 +1,15 @@ +.op-wp-hover-card + display: grid + align-items: center + grid-template-columns: max-content max-content max-content auto 1fr + grid-template-rows: max-content 1fr auto + grid-row-gap: 5px + grid-column-gap: 5px + grid-template-areas: "status status id project project" "middleRow middleRow middleRow middleRow middleRow" "assignee assignee dates dates dates" + overflow: hidden + + &--middleRow + align-self: flex-start + + &--dates + justify-self: flex-end diff --git a/app/components/work_packages/status_button_component.html.erb b/app/components/work_packages/status_button_component.html.erb new file mode 100644 index 00000000000..5c28c8fa34f --- /dev/null +++ b/app/components/work_packages/status_button_component.html.erb @@ -0,0 +1,24 @@ +<%= + if @readonly + render(Primer::Beta::Button.new(**button_arguments)) do |button| + button.with_leading_visual_icon(icon: "lock") if readonly? + @status.name + end + else + render(Primer::Alpha::ActionMenu.new(**@menu_arguments)) do |menu| + menu.with_show_button(**button_arguments) do |button| + button.with_trailing_action_icon(icon: "triangle-down") + button.with_leading_visual_icon(icon: "lock") if readonly? + @status.name + end + + @items.each do |item| + menu.with_item(label: item.name, + content_arguments: { classes: "__hl_inline_status_#{item.id}", + align_items: :center }) do |menu_item| + menu_item.with_trailing_visual_icon(icon: :lock) if item.is_readonly? + end + end + end + end +%> diff --git a/app/components/work_packages/status_button_component.rb b/app/components/work_packages/status_button_component.rb new file mode 100644 index 00000000000..61b19cb887b --- /dev/null +++ b/app/components/work_packages/status_button_component.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +class WorkPackages::StatusButtonComponent < ApplicationComponent + include OpPrimer::ComponentHelpers + + def initialize(work_package:, user:, readonly: false, button_arguments: {}, menu_arguments: {}) + super + + @work_package = work_package + @user = user + @status = work_package.status + @project = work_package.project + + @readonly = readonly + @menu_arguments = menu_arguments + @button_arguments = button_arguments.merge({ classes: "__hl_background_status_#{@status.id}" }) + + @items = available_statusses + end + + def button_title + I18n.t("js.label_edit_status") + end + + def disabled? + !@user.allowed_in_project?(:edit_work_packages, @project) + end + + def readonly? + @status.is_readonly? + end + + def button_arguments + { title: button_title, + disabled: disabled?, + aria: { + label: button_title + } }.deep_merge(@button_arguments) + end + + def available_statusses + WorkPackages::UpdateContract.new(@work_package, @user) + .assignable_statuses + end +end diff --git a/app/controllers/work_packages/hover_card_controller.rb b/app/controllers/work_packages/hover_card_controller.rb new file mode 100644 index 00000000000..7979c7821fd --- /dev/null +++ b/app/controllers/work_packages/hover_card_controller.rb @@ -0,0 +1,37 @@ +#-- 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 WorkPackages + class HoverCardController < ApplicationController + before_action :load_and_authorize_in_optional_project + + def show + @id = params[:id] + render layout: nil + end + end +end diff --git a/app/helpers/work_packages_helper.rb b/app/helpers/work_packages_helper.rb index 8e237f03638..2d37bcad6da 100644 --- a/app/helpers/work_packages_helper.rb +++ b/app/helpers/work_packages_helper.rb @@ -171,20 +171,6 @@ module WorkPackagesHelper end end - # Returns a string of css classes that apply to the issue - def work_package_css_classes(work_package) - s = "work_package preview-trigger".html_safe - s << " status-#{work_package.status.position}" if work_package.status - s << " priority-#{work_package.priority.position}" if work_package.priority - s << " closed" if work_package.closed? - s << " overdue" if work_package.overdue? - s << " child" if work_package.child? - s << " parent" unless work_package.leaf? - s << " created-by-me" if User.current.logged? && work_package.author_id == User.current.id - s << " assigned-to-me" if User.current.logged? && work_package.assigned_to_id == User.current.id - s - end - def work_package_associations_to_address(associated) ret = "".html_safe diff --git a/app/views/work_packages/hover_card/show.html.erb b/app/views/work_packages/hover_card/show.html.erb new file mode 100644 index 00000000000..d8bb3091633 --- /dev/null +++ b/app/views/work_packages/hover_card/show.html.erb @@ -0,0 +1,3 @@ + + <%= render WorkPackages::HoverCardComponent.new(id: @id) %> + diff --git a/config/initializers/permissions.rb b/config/initializers/permissions.rb index d0ea7360287..a47ee9006cd 100644 --- a/config/initializers/permissions.rb +++ b/config/initializers/permissions.rb @@ -217,7 +217,8 @@ Rails.application.reloader.to_prepare do work_packages: %i[show index], work_packages_api: [:get], "work_packages/reports": %i[report report_details], - "work_packages/menus": %i[show] + "work_packages/menus": %i[show], + "work_packages/hover_card": %i[show] }, permissible_on: %i[work_package project], contract_actions: { work_packages: %i[read] } diff --git a/config/routes.rb b/config/routes.rb index 0eaec67895d..63631830755 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -571,6 +571,8 @@ Rails.application.routes.draw do resources :work_packages, only: [:index] do concerns :shareable + get "hover_card" => "work_packages/hover_card#show", on: :member + # move bulk of wps get "move/new" => "work_packages/moves#new", on: :collection, as: "new_move" post "move" => "work_packages/moves#create", on: :collection, as: "move" diff --git a/frontend/src/app/app.module.ts b/frontend/src/app/app.module.ts index f8d39a6f527..a35922776ad 100644 --- a/frontend/src/app/app.module.ts +++ b/frontend/src/app/app.module.ts @@ -53,7 +53,7 @@ import { OpenprojectDashboardsModule } from 'core-app/features/dashboards/openpr import { OpenprojectWorkPackageGraphsModule, } from 'core-app/shared/components/work-package-graphs/openproject-work-package-graphs.module'; -import { PreviewTriggerService } from 'core-app/core/setup/globals/global-listeners/preview-trigger.service'; +import { HoverCardTriggerService } from 'core-app/core/setup/globals/global-listeners/hover-card-trigger.service'; import { OpenprojectOverviewModule } from 'core-app/features/overview/openproject-overview.module'; import { OpenprojectMyPageModule } from 'core-app/features/my-page/openproject-my-page.module'; import { OpenprojectProjectsModule } from 'core-app/features/projects/openproject-projects.module'; @@ -77,8 +77,8 @@ import { PasswordConfirmationModalComponent, } from 'core-app/shared/components/modals/request-for-confirmation/password-confirmation.modal'; import { - WpPreviewModalComponent, -} from 'core-app/shared/components/modals/preview-modal/wp-preview-modal/wp-preview.modal'; + HoverCardComponent, +} from 'core-app/shared/components/modals/preview-modal/hover-card-modal/hover-card.modal'; import { OpHeaderProjectSelectComponent, } from 'core-app/shared/components/header-project-select/header-project-select.component'; @@ -240,7 +240,7 @@ import { SpotSwitchComponent } from 'core-app/spot/components/switch/switch.comp export function initializeServices(injector:Injector) { return () => { - const PreviewTrigger = injector.get(PreviewTriggerService); + const PreviewTrigger = injector.get(HoverCardTriggerService); const topMenuService = injector.get(TopMenuService); const keyboardShortcuts = injector.get(KeyboardShortcutService); // Conditionally add the Revit Add-In settings button @@ -370,7 +370,7 @@ export function initializeServices(injector:Injector) { ConfirmDialogModalComponent, DynamicContentModalComponent, PasswordConfirmationModalComponent, - WpPreviewModalComponent, + HoverCardComponent, // Main menu MainMenuResizerComponent, diff --git a/frontend/src/app/core/path-helper/path-helper.service.ts b/frontend/src/app/core/path-helper/path-helper.service.ts index 1b2274db1c3..dc4db90fcea 100644 --- a/frontend/src/app/core/path-helper/path-helper.service.ts +++ b/frontend/src/app/core/path-helper/path-helper.service.ts @@ -280,6 +280,10 @@ export class PathHelperService { return `${this.workPackagePath(workPackageId)}/shares`; } + public workPackageHoverCardPath(workPackageId:string|number) { + return `${this.workPackagePath(workPackageId)}/hover_card`; + } + public workPackageProgressModalPath(workPackageId:string|number) { if (workPackageId === 'new') { return `${this.workPackagePath(workPackageId)}/progress/new`; diff --git a/frontend/src/app/core/setup/globals/global-listeners/preview-trigger.service.ts b/frontend/src/app/core/setup/globals/global-listeners/hover-card-trigger.service.ts similarity index 64% rename from frontend/src/app/core/setup/globals/global-listeners/preview-trigger.service.ts rename to frontend/src/app/core/setup/globals/global-listeners/hover-card-trigger.service.ts index 239f5e5dabe..eff95f2b842 100644 --- a/frontend/src/app/core/setup/globals/global-listeners/preview-trigger.service.ts +++ b/frontend/src/app/core/setup/globals/global-listeners/hover-card-trigger.service.ts @@ -28,10 +28,10 @@ import { Injectable, Injector, NgZone } from '@angular/core'; import { OpModalService } from 'core-app/shared/components/modal/modal.service'; -import { WpPreviewModalComponent } from 'core-app/shared/components/modals/preview-modal/wp-preview-modal/wp-preview.modal'; +import { HoverCardComponent } from 'core-app/shared/components/modals/preview-modal/hover-card-modal/hover-card.modal'; @Injectable({ providedIn: 'root' }) -export class PreviewTriggerService { +export class HoverCardTriggerService { private modalElement:HTMLElement; private mouseInModal = false; @@ -44,23 +44,23 @@ export class PreviewTriggerService { } setupListener() { - jQuery(document.body).on('mouseover', '.preview-trigger', (e) => { + jQuery(document.body).on('mouseover', '.op-hover-card--preview-trigger', (e) => { e.preventDefault(); 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 href = el.getAttribute('href'); + const turboFrameUrl = el.getAttribute('data-hover-card-url'); - if (!href) { + if (!turboFrameUrl) { return; } this.opModalService.show( - WpPreviewModalComponent, + HoverCardComponent, this.injector, - { workPackageLink: href, event: e }, + { turboFrameSrc: turboFrameUrl, event: e }, true, ).subscribe((previewModal) => { this.modalElement = previewModal.elementRef.nativeElement as HTMLElement; @@ -69,16 +69,16 @@ export class PreviewTriggerService { } }); - jQuery(document.body).on('mouseleave', '.preview-trigger', () => { + jQuery(document.body).on('mouseleave', '.op-hover-card--preview-trigger', () => { this.closeAfterTimeout(); }); - jQuery(document.body).on('mouseleave', '.op-wp-preview-modal', () => { + jQuery(document.body).on('mouseleave', '.op-hover-card', () => { this.mouseInModal = false; this.closeAfterTimeout(); }); - jQuery(document.body).on('mouseenter', '.op-wp-preview-modal', () => { + jQuery(document.body).on('mouseenter', '.op-hover-card', () => { this.mouseInModal = true; }); } @@ -92,21 +92,4 @@ export class PreviewTriggerService { }, 100); }); } - - private isMouseOverPreview(e:JQuery.MouseLeaveEvent) { - if (!this.modalElement) { - return false; - } - - const previewElement = jQuery(this.modalElement.children[0]); - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - if (previewElement && previewElement.offset()) { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const horizontalHover = e.pageX >= Math.floor(previewElement.offset()!.left) && e.pageX < previewElement.offset()!.left + previewElement.width()!; - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const verticalHover = e.pageY >= Math.floor(previewElement.offset()!.top) && e.pageY < previewElement.offset()!.top + previewElement.height()!; - return horizontalHover && verticalHover; - } - return false; - } } diff --git a/frontend/src/app/shared/components/fields/macros/work-package-quickinfo-macro.component.ts b/frontend/src/app/shared/components/fields/macros/work-package-quickinfo-macro.component.ts index c2839b95704..35194ac8105 100644 --- a/frontend/src/app/shared/components/fields/macros/work-package-quickinfo-macro.component.ts +++ b/frontend/src/app/shared/components/fields/macros/work-package-quickinfo-macro.component.ts @@ -75,6 +75,8 @@ export class WorkPackageQuickinfoMacroComponent { workPackageLink:string; + workPackageHoverCardUrl:string; + detailed = false; constructor(readonly elementRef:ElementRef, @@ -93,6 +95,7 @@ export class WorkPackageQuickinfoMacroComponent { const id:string = element.dataset.id!; this.detailed = element.dataset.detailed === 'true'; this.workPackageLink = this.pathHelper.workPackagePath(id); + this.workPackageHoverCardUrl = this.pathHelper.workPackageHoverCardPath(id); this.workPackage$ = this .apiV3Service diff --git a/frontend/src/app/shared/components/fields/macros/work-package-quickinfo-macro.html b/frontend/src/app/shared/components/fields/macros/work-package-quickinfo-macro.html index 5715653d381..32fd8bd05d3 100644 --- a/frontend/src/app/shared/components/fields/macros/work-package-quickinfo-macro.html +++ b/frontend/src/app/shared/components/fields/macros/work-package-quickinfo-macro.html @@ -9,10 +9,11 @@ [displayFieldOptions]="{ writable: false }" fieldName="type"> - + [attr.data-work-package-id]="workPackage.id" + [attr.data-hover-card-url]="workPackageHoverCardUrl"> #{{workPackage.id}}: + + + + + + + + diff --git a/frontend/src/app/shared/components/modals/preview-modal/hover-card-modal/hover-card.modal.sass b/frontend/src/app/shared/components/modals/preview-modal/hover-card-modal/hover-card.modal.sass new file mode 100644 index 00000000000..ca901fb7c05 --- /dev/null +++ b/frontend/src/app/shared/components/modals/preview-modal/hover-card-modal/hover-card.modal.sass @@ -0,0 +1,10 @@ +@import "helpers" + +.op-hover-card + position: absolute + background-color: var(--body-background) + z-index: 5000 + min-width: 350px + box-shadow: var(--shadow-floating-large) + pointer-events: all + padding: 1rem diff --git a/frontend/src/app/shared/components/modals/preview-modal/wp-preview-modal/wp-preview.modal.ts b/frontend/src/app/shared/components/modals/preview-modal/hover-card-modal/hover-card.modal.ts similarity index 65% rename from frontend/src/app/shared/components/modals/preview-modal/wp-preview-modal/wp-preview.modal.ts rename to frontend/src/app/shared/components/modals/preview-modal/hover-card-modal/hover-card.modal.ts index a5ebb78b1c0..e9c481007ed 100644 --- a/frontend/src/app/shared/components/modals/preview-modal/wp-preview-modal/wp-preview.modal.ts +++ b/frontend/src/app/shared/components/modals/preview-modal/hover-card-modal/hover-card.modal.ts @@ -32,17 +32,13 @@ import { Component, ElementRef, Inject, - OnInit, Input, + OnInit, + ViewChild, } from '@angular/core'; import { OpModalComponent } from 'core-app/shared/components/modal/modal.component'; -import { OpModalLocalsToken, OpModalService } from 'core-app/shared/components/modal/modal.service'; +import { OpModalLocalsToken } from 'core-app/shared/components/modal/modal.service'; import { OpModalLocalsMap } from 'core-app/shared/components/modal/modal.types'; -import { I18nService } from 'core-app/core/i18n/i18n.service'; -import { WorkPackageResource } from 'core-app/features/hal/resources/work-package-resource'; -import idFromLink from 'core-app/features/hal/helpers/id-from-link'; -import { ApiV3Service } from 'core-app/core/apiv3/api-v3.service'; -import { StateService } from '@uirouter/core'; import { computePosition, flip, @@ -50,22 +46,27 @@ import { Placement, shift, } from '@floating-ui/dom'; -import { - WorkPackageIsolatedQuerySpaceDirective, -} from 'core-app/features/work-packages/directives/query-space/wp-isolated-query-space.directive'; +import { WorkPackageIsolatedQuerySpaceDirective } from 'core-app/features/work-packages/directives/query-space/wp-isolated-query-space.directive'; @Component({ - templateUrl: './wp-preview.modal.html', - styleUrls: ['./wp-preview.modal.sass'], + templateUrl: './hover-card.modal.html', + styleUrls: ['./hover-card.modal.sass'], changeDetection: ChangeDetectionStrategy.OnPush, hostDirectives: [WorkPackageIsolatedQuerySpaceDirective], }) -export class WpPreviewModalComponent extends OpModalComponent implements OnInit { - public workPackage:WorkPackageResource; +export class HoverCardComponent extends OpModalComponent implements OnInit { + @ViewChild('turboFrame') + set turboFrame(frame:ElementRef|undefined) { + if (frame !== undefined) { + frame.nativeElement?.addEventListener('turbo:frame-load', () => { + const modal = this.elementRef.nativeElement as HTMLElement; + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-explicit-any + void this.reposition(modal, this.locals.event.target as HTMLElement); + }); + } + } - public text = { - created_by: this.i18n.t('js.label_created_by'), - }; + turboFrameSrc:string; @Input() public alignment?:Placement = 'bottom-end'; @@ -75,32 +76,13 @@ export class WpPreviewModalComponent extends OpModalComponent implements OnInit readonly elementRef:ElementRef, @Inject(OpModalLocalsToken) readonly locals:OpModalLocalsMap, readonly cdRef:ChangeDetectorRef, - readonly i18n:I18nService, - readonly apiV3Service:ApiV3Service, - readonly opModalService:OpModalService, - readonly $state:StateService, ) { super(locals, cdRef, elementRef); } ngOnInit() { super.ngOnInit(); - const { workPackageLink } = this.locals; - const workPackageId = idFromLink(workPackageLink as string|null); - - this - .apiV3Service - .work_packages - .id(workPackageId) - .requireAndStream() - .subscribe((workPackage:WorkPackageResource) => { - this.workPackage = workPackage; - this.cdRef.detectChanges(); - - const modal = this.elementRef.nativeElement as HTMLElement; - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-explicit-any - void this.reposition(modal, this.locals.event.target as HTMLElement); - }); + this.turboFrameSrc = this.locals.turboFrameSrc as string; } public async reposition(element:HTMLElement, target:HTMLElement) { @@ -125,9 +107,4 @@ export class WpPreviewModalComponent extends OpModalComponent implements OnInit top: `${y}px`, }); } - - public openStateLink(event:{ workPackageId:string; requestedState:string }) { - const params = { workPackageId: event.workPackageId }; - void this.$state.go(event.requestedState, params); - } } diff --git a/frontend/src/app/shared/components/modals/preview-modal/wp-preview-modal/wp-preview.modal.html b/frontend/src/app/shared/components/modals/preview-modal/wp-preview-modal/wp-preview.modal.html deleted file mode 100644 index f87dd3384d3..00000000000 --- a/frontend/src/app/shared/components/modals/preview-modal/wp-preview-modal/wp-preview.modal.html +++ /dev/null @@ -1,10 +0,0 @@ -
- -
diff --git a/frontend/src/app/shared/components/modals/preview-modal/wp-preview-modal/wp-preview.modal.sass b/frontend/src/app/shared/components/modals/preview-modal/wp-preview-modal/wp-preview.modal.sass deleted file mode 100644 index 2ceae2dfb05..00000000000 --- a/frontend/src/app/shared/components/modals/preview-modal/wp-preview-modal/wp-preview.modal.sass +++ /dev/null @@ -1,9 +0,0 @@ -@import "helpers" - -.op-wp-preview-modal - position: absolute - z-index: 5000 - min-width: 350px - padding: 0px - box-shadow: 0px 0px 5px 2px rgba(0, 0, 0, 0.25) - pointer-events: all \ No newline at end of file diff --git a/lib/open_project/text_formatting/filters/mention_filter.rb b/lib/open_project/text_formatting/filters/mention_filter.rb index f007d464ea0..9ced1b421ca 100644 --- a/lib/open_project/text_formatting/filters/mention_filter.rb +++ b/lib/open_project/text_formatting/filters/mention_filter.rb @@ -75,7 +75,8 @@ module OpenProject::TextFormatting def work_package_mention(work_package) link_to("##{work_package.id}", work_package_path_or_url(id: work_package.id, only_path: context[:only_path]), - class: "issue work_package preview-trigger") + class: "issue work_package op-hover-card--preview-trigger", + data: { "hover-card-url": hover_card_work_package_path(work_package.id) }) end def class_from_mention(mention) diff --git a/lib/open_project/text_formatting/matchers/link_handlers/work_packages.rb b/lib/open_project/text_formatting/matchers/link_handlers/work_packages.rb index dfffcf173db..42128b5a873 100644 --- a/lib/open_project/text_formatting/matchers/link_handlers/work_packages.rb +++ b/lib/open_project/text_formatting/matchers/link_handlers/work_packages.rb @@ -66,7 +66,8 @@ module OpenProject::TextFormatting::Matchers def render_work_package_link(wp_id) link_to("##{wp_id}", work_package_path_or_url(id: wp_id, only_path: context[:only_path]), - class: "issue work_package preview-trigger") + class: "issue work_package op-hover-card--preview-trigger", + data: { "hover-card-url": hover_card_work_package_path(wp_id) }) end end end diff --git a/lookbook/docs/patterns/25-hover-cards.md.erb b/lookbook/docs/patterns/25-hover-cards.md.erb new file mode 100644 index 00000000000..2bb9f2bc0a9 --- /dev/null +++ b/lookbook/docs/patterns/25-hover-cards.md.erb @@ -0,0 +1,73 @@ +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. + +## Overview + +![Exemplary hover card](<%= image_path('lookbook/hover_card.png') %>) + +## Anatomy + +The HoverCard always consists of two basic parts: + +1. A trigger: That can be anything that is hoverable, like a link or a chip +2. The actual card: A small popover that is opened directly next to the trigger. The actual content of the card depends on the type of resource it is calling. + + +## Best practices + +**Do** + +- Put in a slight delay between hovering and displaying the card to avoid accidental triggering, which can be annoying. +- Keep the content of the card simple. Only the essentials. + +**Don't** + +- Don't put additional interactive elements inside of the card. Since the popover closes as soon as you move the mouse out, users will find it frustrating if they try further interacting with it and have it keep disappearing +- Don't put too many triggers on one page, as it can otherwise become annoying to have too many items trigger a card that blocks part of the screen + +## Used in + +- WorkPackage preview when linking via `#ID` +- Soon: User preview when hovering the avatar + +## Technical notes + +Unfortunately, we could not easily use the `Primer::Beta::Popover` component. +That is why, the `HoverCard` is technically an Angular modal which renders inside a `turboFrame`. +This modal is triggered by a class called `op-hover-card--preview-trigger` which can be set in any element. +A global event listener is registered on all elements with this class and triggers the modal when being hovered. +Additionally, the trigger element needs to pass the URL for the `turboFrame` as a data attribute called `data-hover-card-url`. + +### Code structure + +**Angular modal**: +```html + +
+ + +
+``` + +**Trigger**: +```html + + + #14 + +``` + +**Actually rendered card content**: +```html + + + <%= render WorkPackages::HoverCardComponent.new(id: 14) %> + + %> +``` diff --git a/lookbook/previews/open_project/work_packages/status_button_component_preview.rb b/lookbook/previews/open_project/work_packages/status_button_component_preview.rb new file mode 100644 index 00000000000..1f05e2540eb --- /dev/null +++ b/lookbook/previews/open_project/work_packages/status_button_component_preview.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module OpenProject::WorkPackages + # @logical_path OpenProject/WorkPackages + class StatusButtonComponentPreview < ViewComponent::Preview + # !! Currently nothing happens when changing the status!! + # @display min_height 400px + # @param readonly [Boolean] + # @param size [Symbol] select [small, medium, large] + def playground(readonly: true, size: :medium) + user = FactoryBot.build_stubbed(:admin) + render(WorkPackages::StatusButtonComponent.new(work_package: WorkPackage.visible.first, + user:, + readonly:, + button_arguments: { size: })) + end + end +end diff --git a/spec/features/work_packages/details/markdown/activity_comments_spec.rb b/spec/features/work_packages/details/markdown/activity_comments_spec.rb index 06ba11d9d6e..07afd80e0e7 100644 --- a/spec/features/work_packages/details/markdown/activity_comments_spec.rb +++ b/spec/features/work_packages/details/markdown/activity_comments_spec.rb @@ -247,7 +247,7 @@ RSpec.describe "activity comments", :js do wp_page.expect_comment text: "Single ##{work_package2.id}" expect(page).to have_css(".user-comment opce-macro-wp-quickinfo", count: 2) - expect(page).to have_css(".user-comment .work-package--quickinfo.preview-trigger", count: 2) + expect(page).to have_css(".user-comment opce-macro-wp-quickinfo .op-hover-card--preview-trigger", count: 2) end end diff --git a/spec/features/wysiwyg/macros/quicklink_macros_spec.rb b/spec/features/wysiwyg/macros/quicklink_macros_spec.rb index d487d2ad5e1..8852bdf61b6 100644 --- a/spec/features/wysiwyg/macros/quicklink_macros_spec.rb +++ b/spec/features/wysiwyg/macros/quicklink_macros_spec.rb @@ -55,7 +55,7 @@ RSpec.describe "Wysiwyg work package quicklink macros", :js do # Expect output widget within("#content") do expect(page).to have_link("##{work_package.id}") - expect(page).to have_no_css(".work-package--quickinfo.preview-trigger") + expect(page).to have_no_css("opce-macro-wp-quickinfo .op-hover-card--preview-trigger") end # Edit page again @@ -77,7 +77,7 @@ RSpec.describe "Wysiwyg work package quicklink macros", :js do expected_macro_text = "#{work_package.type.name.upcase} ##{work_package.id}: My subject" expect(page).to have_css("opce-macro-wp-quickinfo", text: expected_macro_text) expect(page).to have_css("span", text: work_package.type.name.upcase) - expect(page).to have_css(".work-package--quickinfo.preview-trigger", text: "##{work_package.id}") + expect(page).to have_css(".op-hover-card--preview-trigger", text: "##{work_package.id}") expect(page).to have_css("span", text: "My subject") end @@ -102,7 +102,7 @@ RSpec.describe "Wysiwyg work package quicklink macros", :js do expect(page).to have_css("opce-macro-wp-quickinfo", text: expected_macro_text) expect(page).to have_css("span", text: work_package.status.name) expect(page).to have_css("span", text: work_package.type.name.upcase) - expect(page).to have_css(".work-package--quickinfo.preview-trigger", text: "##{work_package.id}") + expect(page).to have_css(".op-hover-card--preview-trigger", text: "##{work_package.id}") expect(page).to have_css("span", text: "My subject") # Dates are being rendered in two nested spans expect(page).to have_css("span", text: "01/01/2020", count: 2) diff --git a/spec/helpers/work_packages_helper_spec.rb b/spec/helpers/work_packages_helper_spec.rb index a485dc669f8..5aa1b857019 100644 --- a/spec/helpers/work_packages_helper_spec.rb +++ b/spec/helpers/work_packages_helper_spec.rb @@ -158,122 +158,6 @@ RSpec.describe WorkPackagesHelper do end end - describe "#work_package_css_classes" do - let(:statuses) { (1..5).map { |_i| build_stubbed(:status) } } - let(:priority) { build_stubbed(:priority, is_default: true) } - let(:status) { statuses[0] } - let(:stub_work_package) do - build_stubbed(:work_package, - status:, - priority:) - end - - it "always has the work_package class" do - expect(helper.work_package_css_classes(stub_work_package)).to include("work_package") - end - - it "returns the position of the work_package's status" do - stub_work_package.status = open_status - allow(open_status).to receive(:position).and_return(5) - - expect(helper.work_package_css_classes(stub_work_package)).to include("status-5") - end - - it "returns the position of the work_package's priority" do - allow(priority).to receive(:position).and_return(5) - - expect(helper.work_package_css_classes(stub_work_package)).to include("priority-5") - end - - it "has a closed class if the work_package is closed" do - allow(stub_work_package).to receive(:closed?).and_return(true) - - expect(helper.work_package_css_classes(stub_work_package)).to include("closed") - end - - it "has no closed class if the work_package is not closed" do - allow(stub_work_package).to receive(:closed?).and_return(false) - - expect(helper.work_package_css_classes(stub_work_package)).not_to include("closed") - end - - it "has an overdue class if the work_package is overdue" do - allow(stub_work_package).to receive(:overdue?).and_return(true) - - expect(helper.work_package_css_classes(stub_work_package)).to include("overdue") - end - - it "has an overdue class if the work_package is not overdue" do - allow(stub_work_package).to receive(:overdue?).and_return(false) - - expect(helper.work_package_css_classes(stub_work_package)).not_to include("overdue") - end - - it "has a child class if the work_package is a child" do - allow(stub_work_package).to receive(:child?).and_return(true) - - expect(helper.work_package_css_classes(stub_work_package)).to include("child") - end - - it "has no child class if the work_package is not a child" do - allow(stub_work_package).to receive(:child?).and_return(false) - - expect(helper.work_package_css_classes(stub_work_package)).not_to include("child") - end - - it "has a parent class if the work_package is a parent" do - allow(stub_work_package).to receive(:leaf?).and_return(false) - - expect(helper.work_package_css_classes(stub_work_package)).to include("parent") - end - - it "has no parent class if the work_package is not a parent" do - allow(stub_work_package).to receive(:leaf?).and_return(true) - - expect(helper.work_package_css_classes(stub_work_package)).not_to include("parent") - end - - it "has a created-by-me class if the work_package is a created by the current user" do - stub_user = double("user", logged?: true, id: 5) - allow(User).to receive(:current).and_return(stub_user) - allow(stub_work_package).to receive(:author_id).and_return(5) - - expect(helper.work_package_css_classes(stub_work_package)).to include("created-by-me") - end - - it "has no created-by-me class if the work_package is not created by the current user" do - stub_user = double("user", logged?: true, id: 5) - allow(User).to receive(:current).and_return(stub_user) - allow(stub_work_package).to receive(:author_id).and_return(4) - - expect(helper.work_package_css_classes(stub_work_package)).not_to include("created-by-me") - end - - it "has a created-by-me class if the work_package is the current user is not logged in" do - expect(helper.work_package_css_classes(stub_work_package)).not_to include("created-by-me") - end - - it "has a assigned-to-me class if the work_package is a created by the current user" do - stub_user = double("user", logged?: true, id: 5) - allow(User).to receive(:current).and_return(stub_user) - allow(stub_work_package).to receive(:assigned_to_id).and_return(5) - - expect(helper.work_package_css_classes(stub_work_package)).to include("assigned-to-me") - end - - it "has no assigned-to-me class if the work_package is not created by the current user" do - stub_user = double("user", logged?: true, id: 5) - allow(User).to receive(:current).and_return(stub_user) - allow(stub_work_package).to receive(:assigned_to_id).and_return(4) - - expect(helper.work_package_css_classes(stub_work_package)).not_to include("assigned-to-me") - end - - it "has no assigned-to-me class if the work_package is the current user is not logged in" do - expect(helper.work_package_css_classes(stub_work_package)).not_to include("assigned-to-me") - end - end - describe "#work_packages_columns_options" do it "returns the columns options" do expect(helper.work_packages_columns_options) diff --git a/spec/lib/api/v3/repositories/revision_representer_spec.rb b/spec/lib/api/v3/repositories/revision_representer_spec.rb index 13ce2f9b2b7..edb89a94b4b 100644 --- a/spec/lib/api/v3/repositories/revision_representer_spec.rb +++ b/spec/lib/api/v3/repositories/revision_representer_spec.rb @@ -95,7 +95,8 @@ RSpec.describe API::V3::Repositories::RevisionRepresenter do id = work_package.id str = "Totally references " str << "##{id}" end diff --git a/spec/lib/open_project/text_formatting/markdown/in_tool_links_spec.rb b/spec/lib/open_project/text_formatting/markdown/in_tool_links_spec.rb index 22968a58013..881f437cc40 100644 --- a/spec/lib/open_project/text_formatting/markdown/in_tool_links_spec.rb +++ b/spec/lib/open_project/text_formatting/markdown/in_tool_links_spec.rb @@ -267,7 +267,8 @@ RSpec.describe OpenProject::TextFormatting, let(:work_package_link) do link_to("##{work_package.id}", work_package_path(work_package), - class: "issue work_package preview-trigger op-uc-link", + data: { "hover-card-url": hover_card_work_package_path(work_package.id) }, + class: "issue work_package op-hover-card--preview-trigger op-uc-link", target: "_top") end @@ -337,7 +338,8 @@ RSpec.describe OpenProject::TextFormatting, let(:work_package_link) do link_to("##{work_package.id}", work_package_path(work_package), - class: "issue work_package preview-trigger op-uc-link", + data: { "hover-card-url": hover_card_work_package_path(work_package.id) }, + class: "issue work_package op-hover-card--preview-trigger op-uc-link", target: "_top") end @@ -656,7 +658,7 @@ RSpec.describe OpenProject::TextFormatting, let(:expected) do <<~EXPECTED

CookBook documentation

-

##{work_package.id}

+

##{work_package.id}

           [[CookBook documentation]]
 
diff --git a/spec/requests/api/v3/render_resource_spec.rb b/spec/requests/api/v3/render_resource_spec.rb
index c66def7ce8e..13aa6a0dd1a 100644
--- a/spec/requests/api/v3/render_resource_spec.rb
+++ b/spec/requests/api/v3/render_resource_spec.rb
@@ -90,7 +90,8 @@ RSpec.describe "API v3 Render resource" do
               <<~HTML
                 

Hello World! Have a look at - ##{id}

@@ -180,7 +181,7 @@ RSpec.describe "API v3 Render resource" do it_behaves_like "valid response" do let(:text) do - "

Hello *World*! Have a look at #1

\n\n

with two lines.

" + "

Hello *World*! Have a look at #1

\n\n

with two lines.

" end end end