Fix back navigation from filter views to cards (#2725)

* test: update sign_in_as system test helper to be more generic

and support fixture users with more than one board

* test: backfill a test for the card "back link"

when coming from a board filter page

* Fix back navigation to filter views from cards

When navigating from a filter view to a card, the back link now shows
the filter's label and navigates back to the filter URL instead of the
card's board.

The turbo-navigation Stimulus controller stores the page title alongside
the referrer URL in sessionStorage. On the card show page, a
referrerBackLink target rewrites the back link's href and label from the
stored values. This is scoped only to the card show page to avoid
navigation loops in multi-level deep pages.

* Add referrer allowlist to prevent stale referrer rewrites

The back link is only rewritten when the stored referrer's path matches
an allowlist of paths passed via prefer_referrer. This prevents stale
referrers from unrelated pages (e.g., settings) from overriding the
card's back link.

* Move back link navigation tests to dedicated system test file

Extract back link tests from smoke_test.rb into their own file since
they test specific JavaScript behavior that needs regression coverage.
Move sign_in_as helper to ApplicationSystemTestCase for reuse.

* Move markdown paste tests to dedicated system test file
This commit is contained in:
Mike Dalessio
2026-03-18 17:03:13 -04:00
committed by GitHub
parent ad1b10368d
commit 248fc5d3e7
9 changed files with 120 additions and 50 deletions
+7 -2
View File
@@ -10,8 +10,13 @@ module ApplicationHelper
tag.span class: class_names("icon icon--#{name}", options.delete(:class)), "aria-hidden": true, **options tag.span class: class_names("icon icon--#{name}", options.delete(:class)), "aria-hidden": true, **options
end end
def back_link_to(label, url, action, **options) def back_link_to(label, url, action, prefer_referrer: [], **options)
link_to url, class: "btn btn--back btn--circle-mobile", data: { controller: "hotkey", action: action }, **options do data = { controller: "hotkey", action: action }
if prefer_referrer.any?
data[:turbo_navigation_target] = "referrerBackLink"
data[:turbo_navigation_allowed_referrer_paths] = prefer_referrer.join(",")
end
link_to url, class: "btn btn--back btn--circle-mobile", data: data, **options do
icon_tag("arrow-left") + tag.strong("Back to #{label}", class: "overflow-ellipsis") + tag.kbd("ESC", class: "txt-x-small hide-on-touch").html_safe icon_tag("arrow-left") + tag.strong("Back to #{label}", class: "overflow-ellipsis") + tag.kbd("ESC", class: "txt-x-small hide-on-touch").html_safe
end end
end end
+2 -2
View File
@@ -1,6 +1,6 @@
module BoardsHelper module BoardsHelper
def link_back_to_board(board) def link_back_to_board(board, prefer_referrer: [])
back_link_to board.name, board, "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click click->turbo-navigation#backIfSamePath" back_link_to board.name, board, "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click click->turbo-navigation#backIfSamePath", prefer_referrer:
end end
def link_to_edit_board(board) def link_to_edit_board(board)
@@ -1,8 +1,12 @@
import { Controller } from "@hotwired/stimulus" import { Controller } from "@hotwired/stimulus"
export default class extends Controller { export default class extends Controller {
static values = { label: String }
static targets = [ "referrerBackLink" ]
rememberLocation() { rememberLocation() {
sessionStorage.setItem("referrerUrl", window.location.href) sessionStorage.setItem("referrerUrl", window.location.href)
sessionStorage.setItem("referrerLabel", this.labelValue)
} }
backIfSamePath(event) { backIfSamePath(event) {
@@ -16,6 +20,19 @@ export default class extends Controller {
Turbo.visit(this.#referrerUrl) Turbo.visit(this.#referrerUrl)
} }
} }
referrerBackLinkTargetConnected(link) {
if (!this.#referrerUrl || !this.#referrerLabel) { return }
const allowedPaths = (link.dataset.turboNavigationAllowedReferrerPaths || "").split(",")
const referrerPath = new URL(this.#referrerUrl).pathname
if (!allowedPaths.includes(referrerPath)) { return }
link.href = this.#referrerUrl
const strong = link.querySelector("strong")
if (strong) { strong.textContent = `Back to ${this.#referrerLabel}` }
}
get #referrerPath() { get #referrerPath() {
if (!this.#referrerUrl) return null if (!this.#referrerUrl) return null
return new URL(this.#referrerUrl).pathname return new URL(this.#referrerUrl).pathname
@@ -24,4 +41,8 @@ export default class extends Controller {
get #referrerUrl() { get #referrerUrl() {
return sessionStorage.getItem("referrerUrl") return sessionStorage.getItem("referrerUrl")
} }
get #referrerLabel() {
return sessionStorage.getItem("referrerLabel")
}
} }
+1 -1
View File
@@ -7,7 +7,7 @@
<% content_for :header do %> <% content_for :header do %>
<div class="header__actions header__actions--start"> <div class="header__actions header__actions--start">
<%= link_back_to_board(@card.board) %> <%= link_back_to_board @card.board, prefer_referrer: [ cards_path, board_path(@card.board) ] %>
</div> </div>
<% end %> <% end %>
+1
View File
@@ -5,6 +5,7 @@
<body class="<%= @body_class %>" <body class="<%= @body_class %>"
data-controller="local-time timezone-cookie turbo-navigation theme bridge--title bridge--text-size bridge--insets" data-controller="local-time timezone-cookie turbo-navigation theme bridge--title bridge--text-size bridge--insets"
data-action="turbo:morph@window->local-time#refreshAll turbo:before-visit@document->turbo-navigation#rememberLocation" data-action="turbo:morph@window->local-time#refreshAll turbo:before-visit@document->turbo-navigation#rememberLocation"
data-turbo-navigation-label-value="<%= @page_title %>"
data-platform="<%= platform.type %>" data-platform="<%= platform.type %>"
data-bridge-platform="<%= platform.bridge_name %>" data-bridge-platform="<%= platform.bridge_name %>"
data-bridge-components="<%= platform.bridge_components %>" data-bridge-components="<%= platform.bridge_components %>"
+6
View File
@@ -28,4 +28,10 @@ class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
else else
driven_by :chrome_headless, screen_size: [ 1200, 1000 ] driven_by :chrome_headless, screen_size: [ 1200, 1000 ]
end end
private
def sign_in_as(user)
visit session_transfer_url(user.identity.transfer_id, script_name: nil)
assert_current_path root_path
end
end end
+38
View File
@@ -0,0 +1,38 @@
require "application_system_test_case"
class BackLinkNavigationTest < ApplicationSystemTestCase
test "card back link returns to board filter view when navigating from it" do
sign_in_as(users(:david))
filter_url = board_url(boards(:writebook), creator_ids: [ users(:david).id ])
visit filter_url
click_on cards(:logo).title
back_link = find("a.btn--back")
assert_selector "a.btn--back strong", text: "Back to Writebook"
back_link.click
assert_current_path filter_url, ignore_query: false
end
test "card back link returns to global filter view when navigating from it" do
sign_in_as(users(:kevin))
filter_url = cards_url(creator_ids: [ users(:kevin).id ])
visit filter_url
click_on cards(:text).title
assert_selector "a.btn--back strong", text: "Back to all boards"
find("a.btn--back").click
assert_current_path filter_url, ignore_query: false
end
test "card back link is not rewritten when navigating from a non-filter page" do
sign_in_as(users(:david))
visit account_settings_url
click_on "Invite people"
visit card_url(cards(:logo))
assert_selector "a.btn--back strong", text: "Back to Writebook"
end
end
+44
View File
@@ -0,0 +1,44 @@
require "application_system_test_case"
class MarkdownPasteTest < ApplicationSystemTestCase
test "markdown paste adds block spacing" do
sign_in_as(users(:david))
visit card_url(cards(:layout))
find("lexxy-editor").click
paste_markdown("Hello\n\nWorld")
within("lexxy-editor") do
assert_selector "p", text: "Hello"
assert_selector "p br", visible: :all
assert_selector "p", text: "World"
end
end
test "markdown paste preserves line breaks" do
sign_in_as(users(:david))
visit card_url(cards(:layout))
find("lexxy-editor").click
paste_markdown("Hello\nWorld")
inner_html = find("lexxy-editor p", text: "Hello").native.property("innerHTML")
children = Nokogiri::HTML5.fragment(inner_html).children
assert_pattern do
children => [
{ name: "span", inner_html: "Hello" },
{ name: "br" },
{ name: "span", inner_html: "World" }
]
end
end
private
def paste_markdown(markdown)
page.execute_script(<<~JS, markdown)
const dt = new DataTransfer();
dt.setData("text/plain", arguments[0]);
document.activeElement.dispatchEvent(new ClipboardEvent("paste", { clipboardData: dt, bubbles: true }));
JS
end
end
-45
View File
@@ -76,38 +76,6 @@ class SmokeTest < ApplicationSystemTestCase
assert_no_selector "div##{dom_id(notification)}" assert_no_selector "div##{dom_id(notification)}"
end end
test "markdown paste adds block spacing" do
sign_in_as(users(:david))
visit card_url(cards(:layout))
find("lexxy-editor").click
paste_markdown("Hello\n\nWorld")
within("lexxy-editor") do
assert_selector "p", text: "Hello"
assert_selector "p br", visible: :all
assert_selector "p", text: "World"
end
end
test "markdown paste preserves line breaks" do
sign_in_as(users(:david))
visit card_url(cards(:layout))
find("lexxy-editor").click
paste_markdown("Hello\nWorld")
inner_html = find("lexxy-editor p", text: "Hello").native.property("innerHTML")
children = Nokogiri::HTML5.fragment(inner_html).children
assert_pattern do
children => [
{ name: "span", inner_html: "Hello" },
{ name: "br" },
{ name: "span", inner_html: "World" }
]
end
end
test "dragging card to a new column" do test "dragging card to a new column" do
sign_in_as(users(:david)) sign_in_as(users(:david))
@@ -127,22 +95,9 @@ class SmokeTest < ApplicationSystemTestCase
end end
private private
def sign_in_as(user)
visit session_transfer_url(user.identity.transfer_id, script_name: nil)
assert_selector "h1", text: "Latest Activity"
end
def fill_in_lexxy(selector = "lexxy-editor", with:) def fill_in_lexxy(selector = "lexxy-editor", with:)
editor_element = find(selector) editor_element = find(selector)
editor_element.set with editor_element.set with
page.execute_script("arguments[0].value = '#{with}'", editor_element) page.execute_script("arguments[0].value = '#{with}'", editor_element)
end end
def paste_markdown(markdown)
page.execute_script(<<~JS, markdown)
const dt = new DataTransfer();
dt.setData("text/plain", arguments[0]);
document.activeElement.dispatchEvent(new ClipboardEvent("paste", { clipboardData: dt, bubbles: true }));
JS
end
end end