diff --git a/engine/app/assets/stylesheets/coplan/application.css b/engine/app/assets/stylesheets/coplan/application.css index 79178b8e..c1419a61 100644 --- a/engine/app/assets/stylesheets/coplan/application.css +++ b/engine/app/assets/stylesheets/coplan/application.css @@ -1709,6 +1709,10 @@ img.avatar { margin-bottom: var(--space-sm); } +.thread-popover__permalink { + margin-left: auto; +} + .thread-popover__quote { border-left: 3px solid var(--color-quote-info-border); padding: var(--space-xs) var(--space-sm); diff --git a/engine/app/javascript/controllers/coplan/text_selection_controller.js b/engine/app/javascript/controllers/coplan/text_selection_controller.js index 20e70929..6296abc4 100644 --- a/engine/app/javascript/controllers/coplan/text_selection_controller.js +++ b/engine/app/javascript/controllers/coplan/text_selection_controller.js @@ -215,6 +215,23 @@ export default class extends Controller { this._showThreadPopoverFor(event.currentTarget, "pinned") } + async copyThreadLink(event) { + if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return + + event.preventDefault() + const link = event.currentTarget + const label = link.querySelector("[data-copy-label]") + + try { + await navigator.clipboard.writeText(link.href) + label.textContent = "Copied!" + setTimeout(() => { label.textContent = "Copy link" }, 2000) + } catch { + label.textContent = "Copy failed" + setTimeout(() => { label.textContent = "Copy link" }, 2000) + } + } + // Internal: open the popover for a given mark in either "hover" or "pinned" mode. // Returns true if the popover was shown, false otherwise. _showThreadPopoverFor(trigger, mode) { diff --git a/engine/app/views/coplan/comment_threads/_thread_popover.html.erb b/engine/app/views/coplan/comment_threads/_thread_popover.html.erb index bfb86919..e0031c2d 100644 --- a/engine/app/views/coplan/comment_threads/_thread_popover.html.erb +++ b/engine/app/views/coplan/comment_threads/_thread_popover.html.erb @@ -13,6 +13,13 @@ <% if thread.out_of_date? %> out of date <% end %> + <%= link_to plan_path(plan, thread: thread.id), + class: "thread-popover__permalink btn btn--secondary btn--sm", + title: "Copy link to this comment", + data: { action: "coplan--text-selection#copyThreadLink" } do %> + + Copy link + <% end %> <% if thread.anchored? %> diff --git a/spec/system/comment_ux_spec.rb b/spec/system/comment_ux_spec.rb index a29edcb1..c89e7ed8 100644 --- a/spec/system/comment_ux_spec.rb +++ b/spec/system/comment_ux_spec.rb @@ -139,6 +139,20 @@ def create_anchored_thread(plan:, anchor_text:, body:, user:) expect(page).to have_content("Why not monolith?") end + it "opens a linked comment and exposes its permalink" do + thread = create_anchored_thread(plan: plan, anchor_text: "microservices architecture", body: "Why not monolith?", user: reviewer) + + visit plan_path(plan, thread: thread.id) + + expect(page).to have_css(".thread-popover", visible: true) + within(".thread-popover") do + expect(page).to have_link("Copy link", href: plan_path(plan, thread: thread.id)) + expect(page).to have_content("Why not monolith?") + click_link "Copy link" + expect(page).to have_link("Copied!") + end + end + it "shows reply form for open threads" do create_anchored_thread(plan: plan, anchor_text: "microservices architecture", body: "Why not monolith?", user: reviewer) visit plan_path(plan)