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:
Oliver Günther
2024-10-09 13:39:25 +02:00
committed by GitHub
35 changed files with 430 additions and 238 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 279 KiB

+1
View File
@@ -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
-14
View File
@@ -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>
+2 -1
View File
@@ -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] }
+2
View File
@@ -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"
+5 -5
View File
@@ -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`;
@@ -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;
}
}
@@ -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"
@@ -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>
@@ -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
@@ -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);
}
}
@@ -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>
@@ -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
![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
<!-- 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">
&lt;%= render WorkPackages::HoverCardComponent.new(id: 14) %&gt;
</turbo-frame>
%&gt;
```
@@ -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)
-116
View File
@@ -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]]
+3 -2
View File
@@ -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