mirror of
https://github.com/opf/openproject.git
synced 2026-06-13 19:20:00 +00:00
Merge pull request #16885 from opf/feature/55581-create-generic-hover-card
[58242] Generalize the wp preview modal to be reused for users
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 279 KiB |
@@ -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"
|
||||
|
||||
@@ -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
|
||||
%>
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
%>
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
%>
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
<turbo-frame id="op-hover-card-body">
|
||||
<%= render WorkPackages::HoverCardComponent.new(id: @id) %>
|
||||
</turbo-frame>
|
||||
@@ -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] }
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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`;
|
||||
|
||||
+10
-27
@@ -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;
|
||||
}
|
||||
}
|
||||
+3
@@ -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
|
||||
|
||||
@@ -9,10 +9,11 @@
|
||||
[displayFieldOptions]="{ writable: false }"
|
||||
fieldName="type">
|
||||
</display-field>
|
||||
<a class="work-package--quickinfo preview-trigger"
|
||||
<a class="op-hover-card--preview-trigger"
|
||||
target="_top"
|
||||
[href]="workPackageLink"
|
||||
[attr.data-work-package-id]="workPackage.id">
|
||||
[attr.data-work-package-id]="workPackage.id"
|
||||
[attr.data-hover-card-url]="workPackageHoverCardUrl">
|
||||
#{{workPackage.id}}:
|
||||
</a>
|
||||
<display-field [resource]="workPackage"
|
||||
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
<div
|
||||
class="op-hover-card"
|
||||
*ngIf="turboFrameSrc"
|
||||
>
|
||||
<turbo-frame
|
||||
#turboFrame
|
||||
loading="lazy"
|
||||
id="op-hover-card-body"
|
||||
[src]="turboFrameSrc">
|
||||
<op-content-loader
|
||||
viewBox="0 0 180 60"
|
||||
>
|
||||
<svg:rect x="10" y="0" width="60%" height="16" rx="1" />
|
||||
<svg:rect x="10" y="20" width="80%" height="16" rx="1" />
|
||||
<svg:rect x="10" y="40" width="30%" height="16" rx="1" />
|
||||
</op-content-loader>
|
||||
</turbo-frame>
|
||||
</div>
|
||||
+10
@@ -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
|
||||
+19
-42
@@ -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<HTMLIFrameElement>|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);
|
||||
}
|
||||
}
|
||||
-10
@@ -1,10 +0,0 @@
|
||||
<div
|
||||
class="op-wp-preview-modal"
|
||||
*ngIf="workPackage"
|
||||
>
|
||||
<wp-single-card
|
||||
[workPackage]="workPackage"
|
||||
orientation="horizontal"
|
||||
(stateLinkClicked)="openStateLink($event)"
|
||||
></wp-single-card>
|
||||
</div>
|
||||
-9
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
 %>)
|
||||
|
||||
## 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
|
||||
<!-- frontend/src/app/shared/components/modals/preview-modal/hover-card-modal/hover-card.modal.html -->
|
||||
<div
|
||||
class="op-hover-card"
|
||||
>
|
||||
<turbo-frame
|
||||
loading="lazy"
|
||||
id="op-hover-card-body"
|
||||
[src]="turboFrameSrc">
|
||||
</turbo-frame>
|
||||
</div>
|
||||
```
|
||||
|
||||
**Trigger**:
|
||||
```html
|
||||
<!-- app/views/module_a/index.html.erb -->
|
||||
<a href="work_packages/14/activity"
|
||||
data-hover-card-url="work_packages/14/hover_card"
|
||||
class="op-hover-card--preview-trigger">
|
||||
#14
|
||||
</a>
|
||||
```
|
||||
|
||||
**Actually rendered card content**:
|
||||
```html
|
||||
<!-- app/components/work_packages/hover_card/show.html.erb -->
|
||||
<turbo-frame id="op-hover-card-body">
|
||||
<%= render WorkPackages::HoverCardComponent.new(id: 14) %>
|
||||
</turbo-frame>
|
||||
%>
|
||||
```
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -95,7 +95,8 @@ RSpec.describe API::V3::Repositories::RevisionRepresenter do
|
||||
id = work_package.id
|
||||
|
||||
str = "Totally references <a"
|
||||
str << " class=\"issue work_package preview-trigger\""
|
||||
str << " class=\"issue work_package op-hover-card--preview-trigger\""
|
||||
str << " data-hover-card-url=\"/work_packages/#{id}/hover_card\""
|
||||
str << " href=\"/work_packages/#{id}\">"
|
||||
str << "##{id}</a>"
|
||||
end
|
||||
|
||||
@@ -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
|
||||
<p class='op-uc-p'><a class="wiki-page op-uc-link" target="_top" href="/projects/#{project.identifier}/wiki/cookbook-documentation">CookBook documentation</a></p>
|
||||
<p class='op-uc-p'><a class="issue work_package preview-trigger op-uc-link" target="_top" href="/work_packages/#{work_package.id}">##{work_package.id}</a></p>
|
||||
<p class='op-uc-p'><a class="issue work_package op-hover-card--preview-trigger op-uc-link" data-hover-card-url="/work_packages/#{work_package.id}/hover_card" target="_top" href="/work_packages/#{work_package.id}">##{work_package.id}</a></p>
|
||||
<pre class="op-uc-code-block">
|
||||
[[CookBook documentation]]
|
||||
|
||||
|
||||
@@ -90,7 +90,8 @@ RSpec.describe "API v3 Render resource" do
|
||||
<<~HTML
|
||||
<p class="op-uc-p">
|
||||
Hello World! Have a look at
|
||||
<a class="issue work_package preview-trigger op-uc-link"
|
||||
<a class="issue work_package op-hover-card--preview-trigger op-uc-link"
|
||||
data-hover-card-url="/work_packages/#{id}/hover_card"
|
||||
target="_top"
|
||||
href="#{href}">##{id}</a>
|
||||
</p>
|
||||
@@ -180,7 +181,7 @@ RSpec.describe "API v3 Render resource" do
|
||||
|
||||
it_behaves_like "valid response" do
|
||||
let(:text) do
|
||||
"<p>Hello *World*! Have a look at <a class=\"issue work_package preview-trigger\" href=\"/work_packages/1\">#1</a></p>\n\n<p>with two lines.</p>"
|
||||
"<p>Hello *World*! Have a look at <a class=\"issue work_package op-hover-card--preview-trigger\" data-hover-card-url=\"/work_packages/1/hover_card\" href=\"/work_packages/1\">#1</a></p>\n\n<p>with two lines.</p>"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user