From d699792798a3a8f4c96078a9eb891875b4293764 Mon Sep 17 00:00:00 2001 From: German Velasco Date: Tue, 20 Feb 2024 06:51:01 -0500 Subject: [PATCH] Handle a/buttons with `data-to` & `data-method` (Static) (#34) What changed? ============= Phoenix allows form submissions through data attributes: `data-to`, `data-method` and `data-csrf`. That comes from the `Phoenix.HTML` library's little [JS snippet]: ```js function handleClick(element, targetModifierKey) { var to = element.getAttribute("data-to"), method = buildHiddenInput("_method", element.getAttribute("data-method")), csrf = buildHiddenInput("_csrf_token", element.getAttribute("data-csrf")), form = document.createElement("form"), submit = document.createElement("input"), target = element.getAttribute("target"); form.method = (element.getAttribute("data-method") === "get") ? "get" : "post"; form.action = to; form.style.display = "none"; if (target) form.target = target; else if (targetModifierKey) form.target = "_blank"; form.appendChild(csrf); form.appendChild(method); document.body.appendChild(form); // Insert a button and click it instead of using `form.submit` // because the `submit` function does not emit a `submit` event. submit.type = "submit"; form.appendChild(submit); submit.click(); } ``` [JS snippet]: https://hexdocs.pm/phoenix_html/Phoenix.HTML.html#module-javascript-library The core logic that matters to us is as follows: - It uses the `data-to` as the action. - It builds two hidden inputs for "_method" and "_csrf_token". In order for us to handle that, we reproduce some of the same logic. We pull the `to`, `method`, and `csrf_token` values from the element. Thus, our `Static` logic is now able to handle these types of form submissions. There's an open question whether or not we should support those in LiveView. It seems like an odd use case. Most people would use a regular form submission (if they don't want to use LiveView) or a LiveView form. So, for now, we don't introduce the same logic in `Live`. NOTE ---- Unlike other code, supporting this means users can have tests that pass when production is broken!!! (for example, if they forget to put the Phoenix.HTML.js in the app). That's not ideal, but it seems like a worthwhile trade-off while Phoenix supports those forms as first-class behavior. Otherwise, we cannot test those types of form submissions -- which are still standard enough that Phoenix generators come with them out of the box. --- lib/phoenix_test/html/data_attribute_form.ex | 55 ++++++++++ lib/phoenix_test/static.ex | 69 +++++++++--- .../html/data_attribute_form_test.exs | 101 ++++++++++++++++++ test/phoenix_test/static_test.exs | 91 ++++++++++++++-- test/support/page_view.ex | 17 +++ 5 files changed, 312 insertions(+), 21 deletions(-) create mode 100644 lib/phoenix_test/html/data_attribute_form.ex create mode 100644 test/phoenix_test/html/data_attribute_form_test.exs diff --git a/lib/phoenix_test/html/data_attribute_form.ex b/lib/phoenix_test/html/data_attribute_form.ex new file mode 100644 index 00000000..11b06987 --- /dev/null +++ b/lib/phoenix_test/html/data_attribute_form.ex @@ -0,0 +1,55 @@ +defmodule PhoenixTest.Html.DataAttributeForm do + @moduledoc false + + alias PhoenixTest.Html + + def build({_, _, _} = element) do + method = Html.attribute(element, "data-method") + action = Html.attribute(element, "data-to") + csrf_token = Html.attribute(element, "data-csrf") + + %{} + |> Map.put(:method, method) + |> Map.put(:action, action) + |> Map.put(:csrf_token, csrf_token) + |> Map.put(:element, element) + |> Map.put(:data, %{"_csrf_token" => csrf_token, "_method" => method}) + end + + def validate!(form, selector, text) do + method = form.method + action = form.action + csrf_token = form.csrf_token + + missing = + ["data-method": method, "data-to": action, "data-csrf": csrf_token] + |> Enum.filter(fn {_, value} -> empty?(value) end) + + unless method && action && csrf_token do + raise ArgumentError, """ + Tried submitting form via `data-method` but some data attributes are + missing. + + I expected #{inspect(selector)} with text #{inspect(text)} to include + data-method, data-to, and data-csrf. + + I found: + + #{Html.raw(form.element)} + + It seems these are missing: #{Enum.map_join(missing, ", ", fn {key, _} -> key end)}. + + NOTE: `data-method` form submissions happen through JavaScript. Tests + emulate that, but be sure to verify you're including Phoenix.HTML.js! + + See: https://hexdocs.pm/phoenix_html/Phoenix.HTML.html#module-javascript-library + """ + end + + form + end + + defp empty?(value) do + value == "" || value == nil + end +end diff --git a/lib/phoenix_test/static.ex b/lib/phoenix_test/static.ex index fb475736..b21dd891 100644 --- a/lib/phoenix_test/static.ex +++ b/lib/phoenix_test/static.ex @@ -48,13 +48,26 @@ defimpl PhoenixTest.Driver, for: PhoenixTest.Static do end def click_link(session, selector, text) do - path = - session - |> render_html() - |> Query.find!(selector, text) - |> Html.attribute("href") + if data_attribute_form?(session, selector, text) do + form = + session + |> render_html() + |> Query.find!(selector, text) + |> Html.DataAttributeForm.build() + |> Html.DataAttributeForm.validate!(selector, text) + + session.conn + |> dispatch(@endpoint, form.method, form.action, form.data) + |> maybe_redirect(session) + else + path = + session + |> render_html() + |> Query.find!(selector, text) + |> Html.attribute("href") - PhoenixTest.visit(session.conn, path) + PhoenixTest.visit(session.conn, path) + end end def click_button(session, text) do @@ -62,14 +75,28 @@ defimpl PhoenixTest.Driver, for: PhoenixTest.Static do end def click_button(session, selector, text) do - if has_active_form?(session) do - session - |> validate_submit_buttons!(selector, text) - |> submit_active_form() - else - session - |> validate_submit_buttons!(selector, text) - |> single_button_form_submit(text) + cond do + has_active_form?(session) -> + session + |> validate_submit_buttons!(selector, text) + |> submit_active_form() + + data_attribute_form?(session, selector, text) -> + form = + session + |> render_html() + |> Query.find!(selector, text) + |> Html.DataAttributeForm.build() + |> Html.DataAttributeForm.validate!(selector, text) + + session.conn + |> dispatch(@endpoint, form.method, form.action, form.data) + |> maybe_redirect(session) + + true -> + session + |> validate_submit_buttons!(selector, text) + |> single_button_form_submit(text) end end @@ -94,6 +121,20 @@ defimpl PhoenixTest.Driver, for: PhoenixTest.Static do |> submit_active_form() end + defp data_attribute_form?(session, selector, text) do + session + |> render_html() + |> Query.find(selector, text) + |> case do + {:found, element} -> + method = Html.attribute(element, "data-method") + method != "" && method != nil + + _ -> + false + end + end + defp has_active_form?(session) do case PhoenixTest.Static.get_private(session, :active_form) do :not_found -> false diff --git a/test/phoenix_test/html/data_attribute_form_test.exs b/test/phoenix_test/html/data_attribute_form_test.exs new file mode 100644 index 00000000..88f7b22a --- /dev/null +++ b/test/phoenix_test/html/data_attribute_form_test.exs @@ -0,0 +1,101 @@ +defmodule PhoenixTest.Html.DataAttributeFormTest do + use ExUnit.Case, async: true + + alias PhoenixTest.Html.DataAttributeForm + alias PhoenixTest.Query + + describe "build/1" do + test "builds a form with method, action, csrf_token" do + element = + to_element(""" + + Delete + + """) + + form = DataAttributeForm.build(element) + + assert form.method == "put" + assert form.action == "/users/2" + assert form.csrf_token == "token" + end + + test "includes original element passed to build/1" do + element = + to_element(""" + + Delete + + """) + + form = DataAttributeForm.build(element) + + assert form.element == element + end + + test "creates form data of what would be hidden inputs in regular form" do + element = + to_element(""" + + Delete + + """) + + form = DataAttributeForm.build(element) + + assert form.data["_method"] == "put" + assert form.data["_csrf_token"] == "token" + end + end + + describe "validate!/1" do + test "raises an error if data-method is missing" do + element = + to_element(""" + + Delete + + """) + + assert_raise ArgumentError, ~r/missing: data-method/, fn -> + element + |> DataAttributeForm.build() + |> DataAttributeForm.validate!("a", "Delete") + end + end + + test "raises an error if data-to is missing" do + element = + to_element(""" + + Delete + + """) + + assert_raise ArgumentError, ~r/missing: data-to/, fn -> + element + |> DataAttributeForm.build() + |> DataAttributeForm.validate!("a", "Delete") + end + end + + test "raises an error if data-csrf is missing" do + element = + to_element(""" + + Delete + + """) + + assert_raise ArgumentError, ~r/missing: data-csrf/, fn -> + element + |> DataAttributeForm.build() + |> DataAttributeForm.validate!("a", "Delete") + end + end + end + + defp to_element(html) do + Query.find!(html, "a") + end +end diff --git a/test/phoenix_test/static_test.exs b/test/phoenix_test/static_test.exs index e0987b17..214748d0 100644 --- a/test/phoenix_test/static_test.exs +++ b/test/phoenix_test/static_test.exs @@ -2,6 +2,7 @@ defmodule PhoenixTest.StaticTest do use ExUnit.Case, async: true import PhoenixTest + import PhoenixTest.TestHelpers setup do %{conn: Phoenix.ConnTest.build_conn()} @@ -57,6 +58,51 @@ defmodule PhoenixTest.StaticTest do |> assert_has("h1", "Page 2") end + test "handles navigation to a LiveView", %{conn: conn} do + conn + |> visit("/page/index") + |> click_link("To LiveView!") + |> assert_has("h1", "LiveView main page") + end + + test "handles form submission via `data-method` & `data-to` attributes", %{conn: conn} do + conn + |> visit("/page/index") + |> click_link("Data-method Delete") + |> assert_has("h1", "Record deleted") + end + + test "raises error if trying to submit via `data-` attributes but incomplete", %{conn: conn} do + msg = + """ + Tried submitting form via `data-method` but some data attributes are + missing. + + I expected "a" with text "Incomplete data-method Delete" to include + data-method, data-to, and data-csrf. + + I found: + + + Incomplete data-method Delete + + + It seems these are missing: data-to, data-csrf. + + NOTE: `data-method` form submissions happen through JavaScript. Tests + emulate that, but be sure to verify you're including Phoenix.HTML.js! + + See: https://hexdocs.pm/phoenix_html/Phoenix.HTML.html#module-javascript-library + """ + |> ignore_whitespace() + + assert_raise ArgumentError, msg, fn -> + conn + |> visit("/page/index") + |> click_link("Incomplete data-method Delete") + end + end + test "raises error when there are multiple links with same text", %{conn: conn} do assert_raise ArgumentError, ~r/Found more than one element with selector/, fn -> conn @@ -65,13 +111,6 @@ defmodule PhoenixTest.StaticTest do end end - test "handles navigation to a LiveView", %{conn: conn} do - conn - |> visit("/page/index") - |> click_link("To LiveView!") - |> assert_has("h1", "LiveView main page") - end - test "raises an error when link element can't be found with given text", %{conn: conn} do assert_raise ArgumentError, ~r/Could not find element with selector/, fn -> conn @@ -125,6 +164,44 @@ defmodule PhoenixTest.StaticTest do |> assert_has("h1", "LiveView main page") end + test "handles form submission via `data-method` & `data-to` attributes", %{conn: conn} do + conn + |> visit("/page/index") + |> click_button("Data-method Delete") + |> assert_has("h1", "Record deleted") + end + + test "raises error if trying to submit via `data-` attributes but incomplete", %{conn: conn} do + msg = + """ + Tried submitting form via `data-method` but some data attributes are + missing. + + I expected "button" with text "Incomplete data-method Delete" to include + data-method, data-to, and data-csrf. + + I found: + + + + It seems these are missing: data-to, data-csrf. + + NOTE: `data-method` form submissions happen through JavaScript. Tests + emulate that, but be sure to verify you're including Phoenix.HTML.js! + + See: https://hexdocs.pm/phoenix_html/Phoenix.HTML.html#module-javascript-library + """ + |> ignore_whitespace() + + assert_raise ArgumentError, msg, fn -> + conn + |> visit("/page/index") + |> click_button("Incomplete data-method Delete") + end + end + test "raises an error when there are no buttons on page", %{conn: conn} do assert_raise ArgumentError, ~r/Could not find an element with given selector/, fn -> conn diff --git a/test/support/page_view.ex b/test/support/page_view.ex index ddfac295..5d16cfb0 100644 --- a/test/support/page_view.ex +++ b/test/support/page_view.ex @@ -41,6 +41,23 @@ defmodule PhoenixTest.PageView do   Has extra space   + Incomplete data-method Delete + + + Data-method Delete + + + + + +