diff --git a/app/assets/images/lookbook/hover_card.png b/app/assets/images/lookbook/hover_card.png new file mode 100644 index 000000000000..8dde8feb902b 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 d20f0a29e398..2864192e81fd 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 000000000000..5611b5bef813 --- /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 000000000000..8c59ef70e81d --- /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 000000000000..a13e528adf19 --- /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 000000000000..8bdd2f075efa --- /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 000000000000..02979da2eb9a --- /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 000000000000..af7a019e17bd --- /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 000000000000..5c28c8fa34f1 --- /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 000000000000..61b19cb887bb --- /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 000000000000..7979c7821fda --- /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 8e237f03638b..2d37bcad6da7 100644 --- a/app/helpers/work_packages_helper.rb +++ b/app/helpers/work_packages_helper.rb @@ -171,20 +171,6 @@ def send_notification_option(checked = false) 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 000000000000..d8bb3091633e --- /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 d0ea7360287d..a47ee9006cdf 100644 --- a/config/initializers/permissions.rb +++ b/config/initializers/permissions.rb @@ -217,7 +217,8 @@ 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 0eaec67895d2..636318307557 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -571,6 +571,8 @@ 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 f8d39a6f527c..a35922776ad2 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 1b2274db1c3c..dc4db90fcea2 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 239f5e5dabe3..eff95f2b842c 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 c2839b957046..35194ac81054 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 5715653d3813..32fd8bd05d3f 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 000000000000..ca901fb7c057 --- /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 a5ebb78b1c0f..e9c481007ed3 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 f87dd3384d32..000000000000 --- 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 2ceae2dfb059..000000000000 --- 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 f007d464ea04..9ced1b421ca8 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 @@ def group_mention(group) 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 dfffcf173db3..42128b5a8733 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 @@ def render_work_package_macro(wp_id, detailed: false) 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 000000000000..2bb9f2bc0a9d --- /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 000000000000..1f05e2540ebd --- /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 06ba11d9d6ec..07afd80e0e7c 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 @@ 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 d487d2ad5e12..8852bdf61b64 100644 --- a/spec/features/wysiwyg/macros/quicklink_macros_spec.rb +++ b/spec/features/wysiwyg/macros/quicklink_macros_spec.rb @@ -55,7 +55,7 @@ # 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 @@ 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 @@ 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 a485dc669f8e..5aa1b857019c 100644 --- a/spec/helpers/work_packages_helper_spec.rb +++ b/spec/helpers/work_packages_helper_spec.rb @@ -158,122 +158,6 @@ 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 13ce2f9b2b79..edb89a94b4bf 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 @@ 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 22968a580137..881f437cc404 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 @@ 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 @@ 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 @@ def source_url_with_ext(**) 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 c66def7ce8ea..13aa6a0dd1af 100644
--- a/spec/requests/api/v3/render_resource_spec.rb
+++ b/spec/requests/api/v3/render_resource_spec.rb
@@ -90,7 +90,8 @@
               <<~HTML
                 

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

@@ -180,7 +181,7 @@ 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