Skip to content

Commit

Permalink
Handle a/buttons with data-to & data-method (Static) (#34)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
germsvel authored Feb 20, 2024
1 parent ed583e1 commit d699792
Show file tree
Hide file tree
Showing 5 changed files with 312 additions and 21 deletions.
55 changes: 55 additions & 0 deletions lib/phoenix_test/html/data_attribute_form.ex
Original file line number Diff line number Diff line change
@@ -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
69 changes: 55 additions & 14 deletions lib/phoenix_test/static.ex
Original file line number Diff line number Diff line change
Expand Up @@ -48,28 +48,55 @@ 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
click_button(session, "button", text)
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

Expand All @@ -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
Expand Down
101 changes: 101 additions & 0 deletions test/phoenix_test/html/data_attribute_form_test.exs
Original file line number Diff line number Diff line change
@@ -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("""
<a data-method="put" data-to="/users/2" data-csrf="token">
Delete
</a>
""")

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("""
<a data-method="put" data-to="/users/2" data-csrf="token">
Delete
</a>
""")

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("""
<a data-method="put" data-to="/users/2" data-csrf="token">
Delete
</a>
""")

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("""
<a data-to="/users/2" data-csrf="token">
Delete
</a>
""")

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("""
<a data-method="put" data-csrf="token">
Delete
</a>
""")

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("""
<a data-method="put" data-to="/users/2">
Delete
</a>
""")

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
91 changes: 84 additions & 7 deletions test/phoenix_test/static_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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()}
Expand Down Expand Up @@ -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:
<a href="/users/2" data-method="delete">
Incomplete data-method Delete
</a>
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
Expand All @@ -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
Expand Down Expand Up @@ -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:
<button data-method="delete">
Incomplete data-method Delete
</button>
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
Expand Down
17 changes: 17 additions & 0 deletions test/support/page_view.ex
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,23 @@ defmodule PhoenixTest.PageView do
&nbsp; Has extra space &nbsp;
</div>
<a href="/users/2" data-method="delete">Incomplete data-method Delete</a>
<a
href="/page/delete_record"
data-method="delete"
data-to="/page/delete_record"
data-csrf="sometoken"
>
Data-method Delete
</a>
<button data-method="delete">Incomplete data-method Delete</button>
<button data-method="delete" data-to="/page/delete_record" data-csrf="sometoken">
Data-method Delete
</button>
<form action="/page/get_record">
<button>Get record</button>
</form>
Expand Down

0 comments on commit d699792

Please sign in to comment.