Make notification navigation reliable

This commit is contained in:
SimpleTest 2026-08-03 06:20:10 +03:00
parent ead8a67d0f
commit 5e5478389d
8 changed files with 32 additions and 59 deletions

View File

@ -1037,39 +1037,6 @@ export const mountStaticAidMaps = root => {
} }
export const Hooks = { export const Hooks = {
NotificationLink: {
mounted() {
this.markOpened = event => {
if (event.defaultPrevented || event.button > 1) return
const openUrl = this.el.dataset.openUrl
const csrfToken = document.querySelector("meta[name='csrf-token']")?.content
if (!openUrl || !csrfToken) return
// Do not await this request. The link owns navigation, while keepalive
// lets the small read-marker request finish during LiveView or full-page
// navigation without making the destination wait for it.
fetch(openUrl, {
method: "POST",
credentials: "same-origin",
keepalive: true,
headers: {
// This endpoint is protected by the regular browser pipeline,
// which accepts HTML requests. The 204 response has no body.
"accept": "text/html",
"x-csrf-token": csrfToken,
},
}).catch(() => {})
}
this.el.addEventListener("click", this.markOpened, {capture: true})
},
destroyed() {
this.el.removeEventListener("click", this.markOpened, {capture: true})
},
},
SearchableCategoryPicker: { SearchableCategoryPicker: {
mounted() { mounted() {
this.refresh = () => { this.refresh = () => {

View File

@ -189,7 +189,9 @@ test("activity approval, privacy controls, reporting, and moderation work end to
await gotoLiveView(participant.page, activityURL); await gotoLiveView(participant.page, activityURL);
await waitForMapReady(participant.page); await waitForMapReady(participant.page);
await participant.page.getByRole("button", { name: "Block organizer" }).click(); await participant.page.getByRole("button", { name: "Block organizer" }).click();
await expect(participant.page).toHaveURL(/\/activities$/); await expect
.poll(() => new URL(participant.page.url()).pathname)
.toBe("/activities");
await waitForMapReady(participant.page); await waitForMapReady(participant.page);
await gotoLiveView(participant.page, "/profile"); await gotoLiveView(participant.page, "/profile");
const blockedUsers = participant.page.getByRole("heading", { name: "Blocked users" }).locator(".."); const blockedUsers = participant.page.getByRole("heading", { name: "Blocked users" }).locator("..");
@ -237,36 +239,40 @@ test("activity approval, privacy controls, reporting, and moderation work end to
await expect(admin.page.getByRole("heading", { name: "Trust and safety" })).toBeVisible(); await expect(admin.page.getByRole("heading", { name: "Trust and safety" })).toBeVisible();
const reportCard = admin.page const reportCard = admin.page
.locator("article") .locator("details")
.filter({ hasText: reportDescription }); .filter({ hasText: reportDescription });
await reportCard.locator("summary").click();
await reportCard.getByRole("button", { name: "View scoped evidence" }).click(); await reportCard.getByRole("button", { name: "View scoped evidence" }).click();
const evidence = admin.page const evidence = admin.page
.getByRole("heading", { name: "Reported evidence" }) .getByRole("heading", { name: "Reported evidence" })
.locator("xpath=ancestor::section"); .locator("xpath=ancestor::section");
await expect(evidence.getByText(organizerMessageText)).toBeVisible(); await expect(evidence.getByText(organizerMessageText)).toBeVisible();
await evidence.getByRole("button", { name: "Close" }).click(); await evidence.getByRole("button", { name: "Close" }).click();
await reportCard.getByRole("combobox").selectOption("resolved"); await reportCard.locator("summary").click();
await reportCard.getByPlaceholder("Resolution note").fill("Reviewed in isolated browser E2E."); await reportCard.getByRole("combobox", { name: "Decision" }).selectOption("resolved");
await reportCard.getByRole("button", { name: "Save" }).click(); await reportCard.getByLabel("Resolution note").fill("Reviewed in isolated browser E2E.");
await reportCard.getByRole("button", { name: "Save decision" }).click();
await expect(admin.page.getByText("Report updated.")).toBeVisible(); await expect(admin.page.getByText("Report updated.")).toBeVisible();
await gotoLiveView(admin.page, "/moderation?queue=proposals");
const proposalCard = admin.page const proposalCard = admin.page
.locator("article") .locator("details")
.filter({ hasText: proposalTitle }); .filter({ hasText: proposalTitle });
await proposalCard.getByPlaceholder("Rejection note").fill("Covered by an existing category."); await proposalCard.locator(":scope > summary").click();
await proposalCard.getByLabel("Rejection note").fill("Covered by an existing category.");
await proposalCard.getByRole("button", { name: "Reject" }).click(); await proposalCard.getByRole("button", { name: "Reject" }).click();
await expect(admin.page.getByText("Proposal rejected.")).toBeVisible(); await expect(admin.page.getByText("Proposal rejected.")).toBeVisible();
await gotoLiveView(admin.page, "/admin/users"); await gotoLiveView(admin.page, "/admin/users");
await admin.page.getByLabel("Search users").fill(participantEmail); await admin.page.getByLabel("Search users").fill(participantEmail);
await expect(admin.page.locator("main article")).toHaveCount(1);
const participantRow = admin.page const participantRow = admin.page
.locator("article") .locator("main tbody tr")
.filter({ hasText: participantEmail }); .filter({ hasText: participantEmail });
await expect(participantRow).toHaveCount(1); await expect(participantRow).toHaveCount(1);
await participantRow.getByRole("combobox").first().selectOption("restricted"); await participantRow.getByRole("link", { name: /^Manage / }).click();
await participantRow.getByLabel("Internal note").fill("E2E restriction boundary."); await admin.page.getByLabel("Status").selectOption("restricted");
await participantRow.getByRole("button", { name: "Save" }).click(); await admin.page.getByLabel("Internal note").fill("E2E restriction boundary.");
await admin.page.getByRole("button", { name: "Save account status" }).click();
await expect(admin.page.getByText("Account status updated.")).toBeVisible(); await expect(admin.page.getByText("Account status updated.")).toBeVisible();
await gotoLiveView(participant.page, "/categories/proposals"); await gotoLiveView(participant.page, "/categories/proposals");

View File

@ -70,8 +70,10 @@ test("two users complete medicine tracking, handover, realtime chat, and blind r
await expect(requester.page.getByRole("heading", { name: requestTitle })).toBeVisible(); await expect(requester.page.getByRole("heading", { name: requestTitle })).toBeVisible();
await gotoLiveView(helper.page, requestURL); await gotoLiveView(helper.page, requestURL);
await helper.page.getByRole("button", { name: "I can help" }).click(); await clickUntilVisible(
await expect(helper.page.getByRole("heading", { name: "Private match chat" })).toBeVisible(); helper.page.getByRole("button", { name: "I can help" }),
helper.page.getByRole("heading", { name: "Private match chat" }),
);
await expect(requester.page.getByRole("heading", { name: "Private match chat" })).toBeVisible(); await expect(requester.page.getByRole("heading", { name: "Private match chat" })).toBeVisible();
await helper.page await helper.page
@ -207,10 +209,10 @@ test("two users complete medicine tracking, handover, realtime chat, and blind r
).toBeVisible(); ).toBeVisible();
await gotoLiveView(replacement.page, roadsideURL); await gotoLiveView(replacement.page, roadsideURL);
await replacement.page.getByRole("button", { name: "I can help" }).click(); await clickUntilVisible(
await expect( replacement.page.getByRole("button", { name: "I can help" }),
replacement.page.getByRole("heading", { name: "Private match chat" }), replacement.page.getByRole("heading", { name: "Private match chat" }),
).toBeVisible(); );
const replacementName = const replacementName =
runID && fixturePassword runID && fixturePassword
? "Staging E2E Replacement Helper" ? "Staging E2E Replacement Helper"

View File

@ -123,7 +123,7 @@ test("nearby alerts, private notification inbox, preferences, and data export wo
const inbox = subscriber.page const inbox = subscriber.page
.getByRole("heading", { name: "Inbox" }) .getByRole("heading", { name: "Inbox" })
.locator("xpath=ancestor::section"); .locator("xpath=ancestor::section");
const notification = inbox.getByRole("button", { name: /New help request nearby/ }); const notification = inbox.getByRole("link", { name: /New help request nearby/ });
await expect(notification).toBeVisible({ timeout: 20_000 }); await expect(notification).toBeVisible({ timeout: 20_000 });
await expect(inbox.getByText(privateArea)).toHaveCount(0); await expect(inbox.getByText(privateArea)).toHaveCount(0);
await notification.click(); await notification.click();

View File

@ -5,7 +5,7 @@ defmodule WhoNeedHelpWeb.NotificationOpenController do
def create(conn, %{"id" => id}) do def create(conn, %{"id" => id}) do
case Notifications.notification_opened(conn.assigns.current_scope, id) do case Notifications.notification_opened(conn.assigns.current_scope, id) do
{:ok, _notification} -> send_resp(conn, :no_content, "") {:ok, notification} -> redirect(conn, to: notification.path)
{:error, :not_found} -> send_resp(conn, :not_found, "") {:error, :not_found} -> send_resp(conn, :not_found, "")
end end
end end

View File

@ -282,11 +282,9 @@ defmodule WhoNeedHelpWeb.NotificationLive do
<div :if={@notifications != []} class="mt-4 divide-y divide-base-300"> <div :if={@notifications != []} class="mt-4 divide-y divide-base-300">
<.link <.link
:for={notification <- @notifications} :for={notification <- @notifications}
navigate={notification.path} href={~p"/notifications/#{notification.id}/opened"}
method="post"
id={"notification-#{notification.id}"} id={"notification-#{notification.id}"}
phx-hook="NotificationLink"
data-open-url={~p"/notifications/#{notification.id}/opened"}
data-notification-id={notification.id}
class={[ class={[
"flex w-full items-start gap-3 px-2 py-4 text-left transition hover:bg-base-200", "flex w-full items-start gap-3 px-2 py-4 text-left transition hover:bg-base-200",
is_nil(notification.read_at) && "font-semibold" is_nil(notification.read_at) && "font-semibold"

View File

@ -7,7 +7,7 @@ defmodule WhoNeedHelpWeb.NotificationOpenControllerTest do
import WhoNeedHelp.AccountsFixtures import WhoNeedHelp.AccountsFixtures
test "marks an owned notification as read without redirecting", %{conn: conn} do test "marks an owned notification as read and redirects to its destination", %{conn: conn} do
user = user_fixture() user = user_fixture()
{:ok, notification} = {:ok, notification} =
@ -21,7 +21,7 @@ defmodule WhoNeedHelpWeb.NotificationOpenControllerTest do
conn = conn |> log_in_user(user) |> post(~p"/notifications/#{notification.id}/opened") conn = conn |> log_in_user(user) |> post(~p"/notifications/#{notification.id}/opened")
assert response(conn, 204) == "" assert redirected_to(conn) == "/support"
assert Repo.get!(Notification, notification.id).read_at assert Repo.get!(Notification, notification.id).read_at
end end

View File

@ -82,7 +82,7 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
assert has_element?( assert has_element?(
view, view,
"a[href='/support'][phx-hook='NotificationLink'][data-open-url='/notifications/#{notification.id}/opened'][data-notification-id='#{notification.id}']" "a[href='/notifications/#{notification.id}/opened'][data-method='post']"
) )
view view