0
0
Fork 0
mirror of https://github.com/discourse/discourse.git synced 2026-08-09 21:45:25 +08:00
discourse/spec/system/composer/prosemirror_links_spec.rb
Régis Hanol 7bb363b4cb
FIX: preserve selection formatting in link-insertion modal (#39374)
When CMD+K is pressed (or the link toolbar button clicked) with a
non-empty selection, the modal now only asks for the URL — the existing
selection is wrapped as the link's display content, keeping every inline
mark and block context under it.

Previously the modal pre-filled a "Link text" field from the selection.
In the rich-text editor this serialized the selected slice to markdown
and leaked syntax the user never typed: selecting a word inside an H2
produced `## word`, selecting text inside an inline code span produced
backticks, selecting inside a list produced `- word`, and so on.

The new behavior wraps the selection directly instead of replacing it:

- Rich-text editor: adds a `link` mark over the selection range,
preserving every underlying mark (bold, italic, code, emoji, mentions,
…) and every surrounding block (heading, list, blockquote, …).
- Markdown editor: inserts `[` at the selection start and `](url)` at
the end, preserving any literal characters in between.

Both editors now behave identically for the common "select then link"
flow. The link-text field is still shown when:

- Inserting a link with no selection (the user needs to provide display
text).
- Editing an existing link via the link-toolbar (the user may want to
change the displayed text).

A small `applyLink(url)` method is exposed on the text-manipulation API
of each editor, and `toolbarEvent.applyLink` lets the modal pick the
right path without knowing which editor is active. The chat composer is
wired through the same path.

Ref - t/182095
2026-05-20 11:22:35 +02:00

295 lines
12 KiB
Ruby
Vendored

# frozen_string_literal: true
describe "Composer - ProseMirror - Links" do
include_context "with prosemirror editor"
let(:upsert_hyperlink_modal) { PageObjects::Modals::UpsertHyperlink.new }
it "shows link toolbar when cursor is on a link" do
open_composer
composer.type_content("[Example](https://example.com)")
composer.send_keys(:left, :left, :left)
expect(page).to have_css("[data-identifier='composer-link-toolbar']")
expect(page).to have_css("button.composer-link-toolbar__edit")
expect(page).to have_css("button.composer-link-toolbar__copy")
expect(page).to have_css("a.composer-link-toolbar__visit", text: "example.com")
end
it "allows editing a link via toolbar" do
cdp.allow_clipboard
open_composer
composer.type_content("[Example](https://example.com)")
composer.send_keys(:left, :left, :left)
# Use Tab to navigate to the toolbar and Enter to activate edit
expect(page).to have_css("[data-identifier='composer-link-toolbar']")
composer.send_keys(:tab, :enter)
expect(upsert_hyperlink_modal).to be_open
expect(upsert_hyperlink_modal.link_text_value).to eq("Example")
expect(upsert_hyperlink_modal.link_url_value).to eq("https://example.com")
upsert_hyperlink_modal.fill_in_link_text("Updated Example")
upsert_hyperlink_modal.fill_in_link_url("https://updated-example.com")
upsert_hyperlink_modal.send_enter_link_text
expect(rich).to have_css("a[href='https://updated-example.com']", text: "Updated Example")
composer.toggle_rich_editor
expect(composer).to have_value("[Updated Example](https://updated-example.com)")
end
it "escapes URL when editing link via modal" do
cdp.allow_clipboard
open_composer
composer.type_content("[Example](https://example.com)")
composer.send_keys(:left, :left, :left)
# Use Tab to navigate to the toolbar and Enter to activate edit
expect(page).to have_css("[data-identifier='composer-link-toolbar']")
composer.send_keys(:tab, :enter)
expect(upsert_hyperlink_modal).to be_open
expect(upsert_hyperlink_modal.link_text_value).to eq("Example")
expect(upsert_hyperlink_modal.link_url_value).to eq("https://example.com")
upsert_hyperlink_modal.fill_in_link_url("https://updated-example.com?query=with space")
upsert_hyperlink_modal.click_primary_button
expect(rich).to have_css(
"a[href='https://updated-example.com/?query=with%20space']",
text: "Example",
)
end
it "preserves existing percent escapes when inserting a link" do
open_composer
composer.click_toolbar_button("link")
expect(upsert_hyperlink_modal).to be_open
upsert_hyperlink_modal.fill_in_link_text("Encoded URL")
upsert_hyperlink_modal.fill_in_link_url("https://example.com/%20test")
upsert_hyperlink_modal.click_primary_button
expect(rich).to have_css("a[href='https://example.com/%20test']", text: "Encoded URL")
composer.toggle_rich_editor
expect(composer).to have_value("[Encoded URL](https://example.com/%20test)")
end
it "handles malformed links gracefully" do
cdp.allow_clipboard
open_composer
composer.click_toolbar_button("link")
expect(upsert_hyperlink_modal).to be_open
upsert_hyperlink_modal.fill_in_link_text("Encoded URL")
upsert_hyperlink_modal.fill_in_link_url("https://example.com/100%/working 1")
upsert_hyperlink_modal.click_primary_button
expect(rich).to have_css(
"a[href='https://example.com/100%25/working%201']",
text: "Encoded URL",
)
composer.send_keys(:left, :left, :left)
find("button.composer-link-toolbar__edit").click
expect(upsert_hyperlink_modal).to be_open
expect(upsert_hyperlink_modal.link_text_value).to eq("Encoded URL")
# this ensures we keeps the corrected encoding and do not decode prior to edit
# if we decode prior to edit user may end up being confused about why the url has spaces etc...
expect(upsert_hyperlink_modal.link_url_value).to eq("https://example.com/100%25/working%201")
upsert_hyperlink_modal.close
composer.toggle_rich_editor
expect(composer).to have_value("[Encoded URL](https://example.com/100%25/working%201)")
end
it "allows copying a link URL via toolbar" do
cdp.allow_clipboard
open_composer
composer.type_content("[Example](https://example.com)")
composer.send_keys(:left, :left, :left)
find("button.composer-link-toolbar__copy").click
expect(page).to have_content(I18n.t("js.composer.link_toolbar.link_copied"))
end
it "allows unlinking a link via toolbar when markup is not auto or linkify" do
open_composer
composer.type_content("[Manual Link](https://example.com)")
find("button.composer-link-toolbar__unlink").click
expect(rich).to have_no_css("a")
expect(rich).to have_content("Manual Link")
composer.toggle_rich_editor
expect(composer).to have_value("Manual Link")
end
it "doesn't show unlink button for auto-detected links" do
open_composer
composer.type_content("<https://example.com>")
expect(page).to have_css("[data-identifier='composer-link-toolbar']")
expect(page).to have_no_css("button.composer-link-toolbar__unlink")
expect(page).to have_css("a.composer-link-toolbar__visit", text: "")
end
it "doesn't show unlink button for auto-linkified URLs" do
open_composer
composer.type_content("https://example.com")
expect(page).to have_css("[data-identifier='composer-link-toolbar']")
expect(page).to have_no_css("button.composer-link-toolbar__unlink")
expect(page).to have_css("a.composer-link-toolbar__visit", text: "")
end
it "shows visit button for valid URLs" do
open_composer
composer.type_content("[Example](https://example.com)")
expect(page).to have_css(
"a.composer-link-toolbar__visit[href='https://example.com']",
text: "example.com",
)
end
it "strips base URL from internal links in toolbar display" do
open_composer
internal_link = "#{Discourse.base_url}/t/some-topic/123"
composer.type_content("[Internal Link](#{internal_link})")
composer.send_keys(:left, :left, :left)
expect(page).to have_css("[data-identifier='composer-link-toolbar']")
expect(page).to have_css(
"a.composer-link-toolbar__visit[href='#{internal_link}']",
text: "/t/some-topic/123",
)
end
it "doesn't show visit button for invalid URLs" do
open_composer
composer.type_content("[Example](not-a-url)")
expect(page).to have_css("[data-identifier='composer-link-toolbar']")
expect(page).to have_no_css("a.composer-link-toolbar__visit")
expect(page).to have_no_css(".composer-link-toolbar__divider")
end
it "closes toolbar when cursor moves outside link" do
open_composer
composer.type_content("Text before [Example](https://example.com),")
composer.send_keys(:left)
wait_for { page.has_css?("[data-identifier='composer-link-toolbar']") }
expect(page).to have_css("a.composer-link-toolbar__visit", text: "example.com")
composer.send_keys(:right)
wait_for { page.has_no_css?("[data-identifier='composer-link-toolbar']") }
end
it "preserves emojis when editing a link via toolbar" do
open_composer
composer.type_content("[Party :tada: Time](https://example.com)")
composer.send_keys(:left, :left, :left)
# Use Tab to navigate to the toolbar and Enter to activate edit
expect(page).to have_css("[data-identifier='composer-link-toolbar']")
composer.send_keys(:tab, :enter)
expect(upsert_hyperlink_modal).to be_open
expect(upsert_hyperlink_modal.link_text_value).to eq("Party :tada: Time")
expect(upsert_hyperlink_modal.link_url_value).to eq("https://example.com")
upsert_hyperlink_modal.fill_in_link_text("Updated :tada: Party")
upsert_hyperlink_modal.fill_in_link_url("https://updated-party.com")
upsert_hyperlink_modal.click_primary_button
expect(rich).to have_css("a[href='https://updated-party.com']")
expect(rich).to have_css("a img[title=':tada:'], a img[alt=':tada:']")
composer.toggle_rich_editor
expect(composer).to have_value("[Updated :tada: Party](https://updated-party.com)")
end
it "preserves bold and italic formatting when editing a link via toolbar" do
open_composer
composer.type_content("[**Bold** and *italic* text](https://example.com)")
composer.send_keys(:left, :left, :left)
# Use Tab to navigate to the toolbar and Enter to activate edit
expect(page).to have_css("[data-identifier='composer-link-toolbar']")
composer.send_keys(:tab, :enter)
expect(upsert_hyperlink_modal).to be_open
expect(upsert_hyperlink_modal.link_text_value).to eq("**Bold** and *italic* text")
expect(upsert_hyperlink_modal.link_url_value).to eq("https://example.com")
upsert_hyperlink_modal.fill_in_link_text("Updated **bold** and *italic* content")
upsert_hyperlink_modal.fill_in_link_url("https://updated-example.com")
upsert_hyperlink_modal.click_primary_button
expect(rich).to have_css("a[href='https://updated-example.com']")
expect(rich).to have_css("strong a", text: "bold")
expect(rich).to have_css("em a", text: "italic")
composer.toggle_rich_editor
expect(composer).to have_value(
"[Updated **bold** and *italic* content](https://updated-example.com)",
)
end
context "when inserting a link over an existing selection" do
it "only shows the URL field in the modal" do
open_composer
composer.type_content("some text")
composer.select_all
composer.click_toolbar_button("link")
expect(upsert_hyperlink_modal).to be_open
expect(upsert_hyperlink_modal).to have_no_link_text_field
end
it "wraps the selection with the link while preserving inline formatting" do
open_composer
composer.type_content("Example")
composer.select_all
composer.click_toolbar_button("bold")
heading_menu = composer.heading_menu
heading_menu.expand
heading_menu.option("[data-name='heading-2']").click
expect(rich).to have_css("h2 strong", text: "Example")
composer.select_all
composer.click_toolbar_button("link")
upsert_hyperlink_modal.fill_in_link_url("https://example.com")
upsert_hyperlink_modal.click_primary_button
expect(rich).to have_css("h2 strong a[href='https://example.com']", text: "Example")
composer.toggle_rich_editor
expect(composer).to have_value("## **[Example](https://example.com)**")
end
it "does not leak heading markdown into the resulting link" do
open_composer
composer.type_content("Example")
composer.select_all
heading_menu = composer.heading_menu
heading_menu.expand
heading_menu.option("[data-name='heading-2']").click
expect(rich).to have_css("h2", text: "Example")
composer.select_all
composer.click_toolbar_button("link")
upsert_hyperlink_modal.fill_in_link_url("https://example.com")
upsert_hyperlink_modal.click_primary_button
expect(rich).to have_css("h2 a[href='https://example.com']", text: "Example")
composer.toggle_rich_editor
expect(composer).to have_value("## [Example](https://example.com)")
end
end
context "in markdown mode, when inserting a link over an existing selection" do
it "only shows the URL field and wraps the selection as-is" do
open_composer
composer.toggle_rich_editor
composer.type_content("`code word`")
composer.select_all
composer.click_toolbar_button("link")
expect(upsert_hyperlink_modal).to be_open
expect(upsert_hyperlink_modal).to have_no_link_text_field
upsert_hyperlink_modal.fill_in_link_url("https://example.com")
upsert_hyperlink_modal.click_primary_button
expect(composer).to have_value("[`code word`](https://example.com)")
end
end
it "shows both fields when inserting a link without a selection" do
open_composer
composer.click_toolbar_button("link")
expect(upsert_hyperlink_modal).to be_open
expect(upsert_hyperlink_modal).to have_link_text_field
end
it "does not infinite loop on link rewrite" do
with_logs do |logger|
open_composer
composer.type_content("[Example](https://example.com)")
composer.type_content([SystemHelpers::PLATFORM_KEY_MODIFIER, "a"])
composer.type_content("Modified")
expect(logger.logs.map { |log| log[:message] }).not_to include(
"Maximum call stack size exceeded",
)
expect(rich).to have_content("Modified")
end
end
end