|undefined) {
+ if (frame !== undefined) {
+ frame.nativeElement?.addEventListener('turbo:frame-load', () => {
+ const modal = this.elementRef.nativeElement as HTMLElement;
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-explicit-any
+ void this.reposition(modal, this.locals.event.target as HTMLElement);
+ });
+ }
+ }
- public text = {
- created_by: this.i18n.t('js.label_created_by'),
- };
+ turboFrameSrc:string;
@Input() public alignment?:Placement = 'bottom-end';
@@ -75,32 +76,13 @@ export class WpPreviewModalComponent extends OpModalComponent implements OnInit
readonly elementRef:ElementRef,
@Inject(OpModalLocalsToken) readonly locals:OpModalLocalsMap,
readonly cdRef:ChangeDetectorRef,
- readonly i18n:I18nService,
- readonly apiV3Service:ApiV3Service,
- readonly opModalService:OpModalService,
- readonly $state:StateService,
) {
super(locals, cdRef, elementRef);
}
ngOnInit() {
super.ngOnInit();
- const { workPackageLink } = this.locals;
- const workPackageId = idFromLink(workPackageLink as string|null);
-
- this
- .apiV3Service
- .work_packages
- .id(workPackageId)
- .requireAndStream()
- .subscribe((workPackage:WorkPackageResource) => {
- this.workPackage = workPackage;
- this.cdRef.detectChanges();
-
- const modal = this.elementRef.nativeElement as HTMLElement;
- // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-explicit-any
- void this.reposition(modal, this.locals.event.target as HTMLElement);
- });
+ this.turboFrameSrc = this.locals.turboFrameSrc as string;
}
public async reposition(element:HTMLElement, target:HTMLElement) {
@@ -125,9 +107,4 @@ export class WpPreviewModalComponent extends OpModalComponent implements OnInit
top: `${y}px`,
});
}
-
- public openStateLink(event:{ workPackageId:string; requestedState:string }) {
- const params = { workPackageId: event.workPackageId };
- void this.$state.go(event.requestedState, params);
- }
}
diff --git a/frontend/src/app/shared/components/modals/preview-modal/wp-preview-modal/wp-preview.modal.html b/frontend/src/app/shared/components/modals/preview-modal/wp-preview-modal/wp-preview.modal.html
deleted file mode 100644
index f87dd3384d3..00000000000
--- a/frontend/src/app/shared/components/modals/preview-modal/wp-preview-modal/wp-preview.modal.html
+++ /dev/null
@@ -1,10 +0,0 @@
-
-
-
diff --git a/frontend/src/app/shared/components/modals/preview-modal/wp-preview-modal/wp-preview.modal.sass b/frontend/src/app/shared/components/modals/preview-modal/wp-preview-modal/wp-preview.modal.sass
deleted file mode 100644
index 2ceae2dfb05..00000000000
--- a/frontend/src/app/shared/components/modals/preview-modal/wp-preview-modal/wp-preview.modal.sass
+++ /dev/null
@@ -1,9 +0,0 @@
-@import "helpers"
-
-.op-wp-preview-modal
- position: absolute
- z-index: 5000
- min-width: 350px
- padding: 0px
- box-shadow: 0px 0px 5px 2px rgba(0, 0, 0, 0.25)
- pointer-events: all
\ No newline at end of file
diff --git a/lib/open_project/text_formatting/filters/mention_filter.rb b/lib/open_project/text_formatting/filters/mention_filter.rb
index f007d464ea0..9ced1b421ca 100644
--- a/lib/open_project/text_formatting/filters/mention_filter.rb
+++ b/lib/open_project/text_formatting/filters/mention_filter.rb
@@ -75,7 +75,8 @@ module OpenProject::TextFormatting
def work_package_mention(work_package)
link_to("##{work_package.id}",
work_package_path_or_url(id: work_package.id, only_path: context[:only_path]),
- class: "issue work_package preview-trigger")
+ class: "issue work_package op-hover-card--preview-trigger",
+ data: { "hover-card-url": hover_card_work_package_path(work_package.id) })
end
def class_from_mention(mention)
diff --git a/lib/open_project/text_formatting/matchers/link_handlers/work_packages.rb b/lib/open_project/text_formatting/matchers/link_handlers/work_packages.rb
index dfffcf173db..42128b5a873 100644
--- a/lib/open_project/text_formatting/matchers/link_handlers/work_packages.rb
+++ b/lib/open_project/text_formatting/matchers/link_handlers/work_packages.rb
@@ -66,7 +66,8 @@ module OpenProject::TextFormatting::Matchers
def render_work_package_link(wp_id)
link_to("##{wp_id}",
work_package_path_or_url(id: wp_id, only_path: context[:only_path]),
- class: "issue work_package preview-trigger")
+ class: "issue work_package op-hover-card--preview-trigger",
+ data: { "hover-card-url": hover_card_work_package_path(wp_id) })
end
end
end
diff --git a/lookbook/docs/patterns/25-hover-cards.md.erb b/lookbook/docs/patterns/25-hover-cards.md.erb
new file mode 100644
index 00000000000..2bb9f2bc0a9
--- /dev/null
+++ b/lookbook/docs/patterns/25-hover-cards.md.erb
@@ -0,0 +1,73 @@
+The HoverCard is a pattern related to the `Primer::Beta::Popover` and is used to show additional contexual information on certain kinds of resources like work packages and users. The hover card is opened by hovering over a certain trigger. When hovering outside of the card or its trigger, the popover is closed again.
+
+## Overview
+
+ %>)
+
+## Anatomy
+
+The HoverCard always consists of two basic parts:
+
+1. A trigger: That can be anything that is hoverable, like a link or a chip
+2. The actual card: A small popover that is opened directly next to the trigger. The actual content of the card depends on the type of resource it is calling.
+
+
+## Best practices
+
+**Do**
+
+- Put in a slight delay between hovering and displaying the card to avoid accidental triggering, which can be annoying.
+- Keep the content of the card simple. Only the essentials.
+
+**Don't**
+
+- Don't put additional interactive elements inside of the card. Since the popover closes as soon as you move the mouse out, users will find it frustrating if they try further interacting with it and have it keep disappearing
+- Don't put too many triggers on one page, as it can otherwise become annoying to have too many items trigger a card that blocks part of the screen
+
+## Used in
+
+- WorkPackage preview when linking via `#ID`
+- Soon: User preview when hovering the avatar
+
+## Technical notes
+
+Unfortunately, we could not easily use the `Primer::Beta::Popover` component.
+That is why, the `HoverCard` is technically an Angular modal which renders inside a `turboFrame`.
+This modal is triggered by a class called `op-hover-card--preview-trigger` which can be set in any element.
+A global event listener is registered on all elements with this class and triggers the modal when being hovered.
+Additionally, the trigger element needs to pass the URL for the `turboFrame` as a data attribute called `data-hover-card-url`.
+
+### Code structure
+
+**Angular modal**:
+```html
+
+
+
+
+
+```
+
+**Trigger**:
+```html
+
+
+ #14
+
+```
+
+**Actually rendered card content**:
+```html
+
+
+ <%= render WorkPackages::HoverCardComponent.new(id: 14) %>
+
+ %>
+```
diff --git a/lookbook/previews/open_project/work_packages/status_button_component_preview.rb b/lookbook/previews/open_project/work_packages/status_button_component_preview.rb
new file mode 100644
index 00000000000..1f05e2540eb
--- /dev/null
+++ b/lookbook/previews/open_project/work_packages/status_button_component_preview.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+module OpenProject::WorkPackages
+ # @logical_path OpenProject/WorkPackages
+ class StatusButtonComponentPreview < ViewComponent::Preview
+ # !! Currently nothing happens when changing the status!!
+ # @display min_height 400px
+ # @param readonly [Boolean]
+ # @param size [Symbol] select [small, medium, large]
+ def playground(readonly: true, size: :medium)
+ user = FactoryBot.build_stubbed(:admin)
+ render(WorkPackages::StatusButtonComponent.new(work_package: WorkPackage.visible.first,
+ user:,
+ readonly:,
+ button_arguments: { size: }))
+ end
+ end
+end
diff --git a/spec/features/work_packages/details/markdown/activity_comments_spec.rb b/spec/features/work_packages/details/markdown/activity_comments_spec.rb
index 06ba11d9d6e..07afd80e0e7 100644
--- a/spec/features/work_packages/details/markdown/activity_comments_spec.rb
+++ b/spec/features/work_packages/details/markdown/activity_comments_spec.rb
@@ -247,7 +247,7 @@ RSpec.describe "activity comments", :js do
wp_page.expect_comment text: "Single ##{work_package2.id}"
expect(page).to have_css(".user-comment opce-macro-wp-quickinfo", count: 2)
- expect(page).to have_css(".user-comment .work-package--quickinfo.preview-trigger", count: 2)
+ expect(page).to have_css(".user-comment opce-macro-wp-quickinfo .op-hover-card--preview-trigger", count: 2)
end
end
diff --git a/spec/features/wysiwyg/macros/quicklink_macros_spec.rb b/spec/features/wysiwyg/macros/quicklink_macros_spec.rb
index d487d2ad5e1..8852bdf61b6 100644
--- a/spec/features/wysiwyg/macros/quicklink_macros_spec.rb
+++ b/spec/features/wysiwyg/macros/quicklink_macros_spec.rb
@@ -55,7 +55,7 @@ RSpec.describe "Wysiwyg work package quicklink macros", :js do
# Expect output widget
within("#content") do
expect(page).to have_link("##{work_package.id}")
- expect(page).to have_no_css(".work-package--quickinfo.preview-trigger")
+ expect(page).to have_no_css("opce-macro-wp-quickinfo .op-hover-card--preview-trigger")
end
# Edit page again
@@ -77,7 +77,7 @@ RSpec.describe "Wysiwyg work package quicklink macros", :js do
expected_macro_text = "#{work_package.type.name.upcase} ##{work_package.id}: My subject"
expect(page).to have_css("opce-macro-wp-quickinfo", text: expected_macro_text)
expect(page).to have_css("span", text: work_package.type.name.upcase)
- expect(page).to have_css(".work-package--quickinfo.preview-trigger", text: "##{work_package.id}")
+ expect(page).to have_css(".op-hover-card--preview-trigger", text: "##{work_package.id}")
expect(page).to have_css("span", text: "My subject")
end
@@ -102,7 +102,7 @@ RSpec.describe "Wysiwyg work package quicklink macros", :js do
expect(page).to have_css("opce-macro-wp-quickinfo", text: expected_macro_text)
expect(page).to have_css("span", text: work_package.status.name)
expect(page).to have_css("span", text: work_package.type.name.upcase)
- expect(page).to have_css(".work-package--quickinfo.preview-trigger", text: "##{work_package.id}")
+ expect(page).to have_css(".op-hover-card--preview-trigger", text: "##{work_package.id}")
expect(page).to have_css("span", text: "My subject")
# Dates are being rendered in two nested spans
expect(page).to have_css("span", text: "01/01/2020", count: 2)
diff --git a/spec/helpers/work_packages_helper_spec.rb b/spec/helpers/work_packages_helper_spec.rb
index a485dc669f8..5aa1b857019 100644
--- a/spec/helpers/work_packages_helper_spec.rb
+++ b/spec/helpers/work_packages_helper_spec.rb
@@ -158,122 +158,6 @@ RSpec.describe WorkPackagesHelper do
end
end
- describe "#work_package_css_classes" do
- let(:statuses) { (1..5).map { |_i| build_stubbed(:status) } }
- let(:priority) { build_stubbed(:priority, is_default: true) }
- let(:status) { statuses[0] }
- let(:stub_work_package) do
- build_stubbed(:work_package,
- status:,
- priority:)
- end
-
- it "always has the work_package class" do
- expect(helper.work_package_css_classes(stub_work_package)).to include("work_package")
- end
-
- it "returns the position of the work_package's status" do
- stub_work_package.status = open_status
- allow(open_status).to receive(:position).and_return(5)
-
- expect(helper.work_package_css_classes(stub_work_package)).to include("status-5")
- end
-
- it "returns the position of the work_package's priority" do
- allow(priority).to receive(:position).and_return(5)
-
- expect(helper.work_package_css_classes(stub_work_package)).to include("priority-5")
- end
-
- it "has a closed class if the work_package is closed" do
- allow(stub_work_package).to receive(:closed?).and_return(true)
-
- expect(helper.work_package_css_classes(stub_work_package)).to include("closed")
- end
-
- it "has no closed class if the work_package is not closed" do
- allow(stub_work_package).to receive(:closed?).and_return(false)
-
- expect(helper.work_package_css_classes(stub_work_package)).not_to include("closed")
- end
-
- it "has an overdue class if the work_package is overdue" do
- allow(stub_work_package).to receive(:overdue?).and_return(true)
-
- expect(helper.work_package_css_classes(stub_work_package)).to include("overdue")
- end
-
- it "has an overdue class if the work_package is not overdue" do
- allow(stub_work_package).to receive(:overdue?).and_return(false)
-
- expect(helper.work_package_css_classes(stub_work_package)).not_to include("overdue")
- end
-
- it "has a child class if the work_package is a child" do
- allow(stub_work_package).to receive(:child?).and_return(true)
-
- expect(helper.work_package_css_classes(stub_work_package)).to include("child")
- end
-
- it "has no child class if the work_package is not a child" do
- allow(stub_work_package).to receive(:child?).and_return(false)
-
- expect(helper.work_package_css_classes(stub_work_package)).not_to include("child")
- end
-
- it "has a parent class if the work_package is a parent" do
- allow(stub_work_package).to receive(:leaf?).and_return(false)
-
- expect(helper.work_package_css_classes(stub_work_package)).to include("parent")
- end
-
- it "has no parent class if the work_package is not a parent" do
- allow(stub_work_package).to receive(:leaf?).and_return(true)
-
- expect(helper.work_package_css_classes(stub_work_package)).not_to include("parent")
- end
-
- it "has a created-by-me class if the work_package is a created by the current user" do
- stub_user = double("user", logged?: true, id: 5)
- allow(User).to receive(:current).and_return(stub_user)
- allow(stub_work_package).to receive(:author_id).and_return(5)
-
- expect(helper.work_package_css_classes(stub_work_package)).to include("created-by-me")
- end
-
- it "has no created-by-me class if the work_package is not created by the current user" do
- stub_user = double("user", logged?: true, id: 5)
- allow(User).to receive(:current).and_return(stub_user)
- allow(stub_work_package).to receive(:author_id).and_return(4)
-
- expect(helper.work_package_css_classes(stub_work_package)).not_to include("created-by-me")
- end
-
- it "has a created-by-me class if the work_package is the current user is not logged in" do
- expect(helper.work_package_css_classes(stub_work_package)).not_to include("created-by-me")
- end
-
- it "has a assigned-to-me class if the work_package is a created by the current user" do
- stub_user = double("user", logged?: true, id: 5)
- allow(User).to receive(:current).and_return(stub_user)
- allow(stub_work_package).to receive(:assigned_to_id).and_return(5)
-
- expect(helper.work_package_css_classes(stub_work_package)).to include("assigned-to-me")
- end
-
- it "has no assigned-to-me class if the work_package is not created by the current user" do
- stub_user = double("user", logged?: true, id: 5)
- allow(User).to receive(:current).and_return(stub_user)
- allow(stub_work_package).to receive(:assigned_to_id).and_return(4)
-
- expect(helper.work_package_css_classes(stub_work_package)).not_to include("assigned-to-me")
- end
-
- it "has no assigned-to-me class if the work_package is the current user is not logged in" do
- expect(helper.work_package_css_classes(stub_work_package)).not_to include("assigned-to-me")
- end
- end
-
describe "#work_packages_columns_options" do
it "returns the columns options" do
expect(helper.work_packages_columns_options)
diff --git a/spec/lib/api/v3/repositories/revision_representer_spec.rb b/spec/lib/api/v3/repositories/revision_representer_spec.rb
index 13ce2f9b2b7..edb89a94b4b 100644
--- a/spec/lib/api/v3/repositories/revision_representer_spec.rb
+++ b/spec/lib/api/v3/repositories/revision_representer_spec.rb
@@ -95,7 +95,8 @@ RSpec.describe API::V3::Repositories::RevisionRepresenter do
id = work_package.id
str = "Totally references "
str << "##{id}"
end
diff --git a/spec/lib/open_project/text_formatting/markdown/in_tool_links_spec.rb b/spec/lib/open_project/text_formatting/markdown/in_tool_links_spec.rb
index 22968a58013..881f437cc40 100644
--- a/spec/lib/open_project/text_formatting/markdown/in_tool_links_spec.rb
+++ b/spec/lib/open_project/text_formatting/markdown/in_tool_links_spec.rb
@@ -267,7 +267,8 @@ RSpec.describe OpenProject::TextFormatting,
let(:work_package_link) do
link_to("##{work_package.id}",
work_package_path(work_package),
- class: "issue work_package preview-trigger op-uc-link",
+ data: { "hover-card-url": hover_card_work_package_path(work_package.id) },
+ class: "issue work_package op-hover-card--preview-trigger op-uc-link",
target: "_top")
end
@@ -337,7 +338,8 @@ RSpec.describe OpenProject::TextFormatting,
let(:work_package_link) do
link_to("##{work_package.id}",
work_package_path(work_package),
- class: "issue work_package preview-trigger op-uc-link",
+ data: { "hover-card-url": hover_card_work_package_path(work_package.id) },
+ class: "issue work_package op-hover-card--preview-trigger op-uc-link",
target: "_top")
end
@@ -656,7 +658,7 @@ RSpec.describe OpenProject::TextFormatting,
let(:expected) do
<<~EXPECTED
CookBook documentation
- ##{work_package.id}
+ ##{work_package.id}
[[CookBook documentation]]
diff --git a/spec/requests/api/v3/render_resource_spec.rb b/spec/requests/api/v3/render_resource_spec.rb
index c66def7ce8e..13aa6a0dd1a 100644
--- a/spec/requests/api/v3/render_resource_spec.rb
+++ b/spec/requests/api/v3/render_resource_spec.rb
@@ -90,7 +90,8 @@ RSpec.describe "API v3 Render resource" do
<<~HTML
Hello World! Have a look at
- ##{id}
@@ -180,7 +181,7 @@ RSpec.describe "API v3 Render resource" do
it_behaves_like "valid response" do
let(:text) do
- "Hello *World*! Have a look at #1
\n\nwith two lines.
"
+ "Hello *World*! Have a look at #1
\n\nwith two lines.
"
end
end
end