Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Devise a way to find form inputs by their associated labels #44

Open
mattwynne opened this issue Mar 11, 2024 · 2 comments
Open

Devise a way to find form inputs by their associated labels #44

mattwynne opened this issue Mar 11, 2024 · 2 comments

Comments

@mattwynne
Copy link

For example, we'd like to be able to do something like this:

assert_has(field("Username", "matt"))

That field function needs to be able to walk the DOM from the label to the associated field, which isn't possible with CSS selectors.

I wonder if we could expose an extension point in assert_has that gives the user the DOM so they can walk around it themselves, or maybe support XPath too?

@germsvel
Copy link
Owner

@mattwynne I'm curious what you think of the approach proposed in #46

@teamon
Copy link

teamon commented May 9, 2024

I made a similar module to phoenix_test some time ago and I implemented finding form inputs like this:

  defp find_by_label(form, text) do
    case Floki.find(form, "label:fl-icontains('#{text}')") do
      [label] ->
        case Floki.attribute(label, "for") do
          [id] ->
            case Floki.find(form, "input[id='#{id}']") do
              [input] -> {:ok, input}
              [] -> raise "No input found for label: #{inspect(label)}"
            end

          [] ->
            raise "No 'for' attribute found for label: #{inspect(label)}"
        end

      [] ->
        :error

      _many ->
        raise "Multiple labels found matching: #{inspect(text)}"
    end
  end
Here's the full thing just in case:
defmodule Browser do
  ## PUBLIC API

  defmacro visit(conn, path) do
    quote do
      Browser.visit(@endpoint, unquote(conn), unquote(path))
    end
  end

  def visit(endpoint, conn, path) do
    conn = put(conn, :endpoint, endpoint)
    request(conn, :get, path)
  end

  def request(conn, method, path) do
    endpoint = get(conn, :endpoint) || conn.private[:phoenix_endpoint]

    conn
    |> Phoenix.ConnTest.dispatch(endpoint, method, path)
    |> follow_redirects()
    |> put(:endpoint, endpoint)
  end

  @doc """
  Fill in form input (text, email, passwsord) or textarea
  """
  def fill_in(conn, selector, opts) do
    value = Keyword.fetch!(opts, :with)

    conn = ensure_doc(conn)
    doc = get(conn, :doc)

    found =
      doc
      |> find_forms()
      |> Enum.find_value(fn {form, index} ->
        case find_by_label(form, selector) do
          {:ok, input} ->
            case Floki.attribute(input, "name") do
              [name] -> {index, name}
            end

          :error ->
            nil
        end
      end)

    if !found do
      raise "No input found for: #{inspect(selector)}"
    end

    {index, name} = found

    put_form_value(conn, index, name, value)
  end

  def click(conn, selector) do
    elements = elements(conn, :click, selector)

    case elements do
      [{:form, form, index} | _] -> submit_form(conn, form, index)
      [{:link, path} | _] -> request(conn, :get, path)
      [{:link, path, method} | _] -> request(conn, String.to_existing_atom(method), path)
      [] -> raise "No link or submit button found for: #{inspect(selector)}"
    end
  end

  def elements(%Plug.Conn{} = conn, scope, selector) do
    conn = ensure_doc(conn)
    doc = get(conn, :doc)
    elements(doc, scope, selector)
  end

  def elements(response, scope, selector) when is_binary(response) do
    doc = Floki.parse_document!(response)
    elements(doc, scope, selector)
  end

  def elements(doc, :click, selector) do
    find_form_submit_buttons(doc, selector) ++ find_links(doc, selector)
  end

  def open_browser(conn) do
    path = Path.join([System.tmp_dir!(), "#{Phoenix.LiveView.Utils.random_id()}.html"])
    File.write!(path, conn.resp_body)
    System.cmd("open", [path])
    conn
  end

  defmacro assert_html_response(conn, status) do
    quote do
      assert html_response(unquote(conn), unquote(status))

      # unquote(conn)
    end
  end

  ## PRIVATE

  defp ensure_doc(conn) do
    case get(conn, :doc) do
      nil -> put(conn, :doc, Floki.parse_document!(conn.resp_body))
      _ -> conn
    end
  end

  defp get(conn, key, default \\ nil) do
    conn.private[__MODULE__][key] || default
  end

  defp put(conn, key, value) do
    data = Map.put(conn.private[__MODULE__] || %{}, key, value)
    Plug.Conn.put_private(conn, __MODULE__, data)
  end

  defp clear(conn) do
    Plug.Conn.put_private(conn, __MODULE__, nil)
  end

  def put_form_value(conn, index, name, value) do
    forms = get(conn, :forms, %{})
    form = forms[index] || %{}
    form = Map.put(form, name, value)
    forms = Map.put(forms, index, form)
    put(conn, :forms, forms)
  end

  defp find_forms(doc) do
    doc
    |> Floki.find("form")
    |> Enum.with_index()
  end

  defp submit_form(conn, form, index) do
    [action] = Floki.attribute(form, "action")

    method =
      case Floki.attribute(form, "method") do
        [method] -> method
        [] -> "post"
      end

    default_values =
      form
      |> Floki.find("input, textarea")
      |> Enum.reduce(%{}, fn field, values ->
        case Floki.attribute(field, "name") do
          [name] ->
            case Floki.attribute(field, "value") do
              [value] ->
                Map.put(values, name, value)

              [] ->
                Map.put(values, name, "")
            end

          [] ->
            values
        end
      end)

    user_values = get(conn, :forms)[index] || %{}

    params =
      default_values
      |> Map.merge(user_values)
      |> Enum.map(fn {k, v} -> "#{k}=#{URI.encode_www_form(v)}" end)
      |> Enum.join("&")
      |> Plug.Conn.Query.decode()

    follow_redirects(
      Phoenix.ConnTest.dispatch(
        Phoenix.ConnTest.recycle(clear(conn)),
        conn.private[:phoenix_endpoint],
        method,
        action,
        params
      )
    )
  end

  defp follow_redirects(conn) do
    if conn.status in [301, 302] do
      n = get(conn, :redirects, 0)

      if n > 5 do
        raise "Too many redirects"
      end

      conn = put(conn, :redirects, n + 1)

      follow_redirects(
        Phoenix.ConnTest.dispatch(
          Phoenix.ConnTest.recycle(conn),
          conn.private[:phoenix_endpoint],
          :get,
          Phoenix.ConnTest.redirected_to(conn),
          nil
        )
      )
    else
      conn
    end
  end

  defp find_by_label(form, text) do
    case Floki.find(form, "label:fl-icontains('#{text}')") do
      [label] ->
        case Floki.attribute(label, "for") do
          [id] ->
            case Floki.find(form, "input[id='#{id}']") do
              [input] -> {:ok, input}
              [] -> raise "No input found for label: #{inspect(label)}"
            end

          [] ->
            raise "No 'for' attribute found for label: #{inspect(label)}"
        end

      [] ->
        :error

      _many ->
        raise "Multiple labels found matching: #{inspect(text)}"
    end
  end

  # defp find_form_submit_button(doc, text) do
  #   doc
  #   |> find_forms()
  #   |> Enum.find_value(fn {form, index} ->
  #     case Floki.find(form, "button[type=submit]:fl-icontains('#{text}')") do
  #       [_button] -> {:form, form, index}
  #       [] -> nil
  #       _many -> raise "Multiple buttons found matching: #{inspect(text)}"
  #     end
  #   end)
  # end

  defp find_form_submit_buttons(doc, text) do
    for {form, index} <- find_forms(doc),
        _button <- Floki.find(form, "button[type=submit]:fl-icontains('#{text}')") do
      {:form, form, index}
    end
  end

  defp find_links(doc, text) do
    for link <- Floki.find(doc, "a:fl-icontains('#{text}')") do
      href = List.first(Floki.attribute(link, "href"))

      case Floki.attribute(link, "data-method") do
        [method] -> {:link, href, method}
        [] -> {:link, href}
      end
    end
  end

  # defp find_link(doc, text) do
  #   case Floki.find(doc, "a:fl-icontains('#{text}')") do
  #     [link] -> {:link, List.first(Floki.attribute(link, "href"))}
  #     [] -> nil
  #     _many -> raise "Multiple links found matching: #{inspect(text)}"
  #   end
  # end
end

and the client side looks like this:

conn
|> visit("/sign-up")
|> fill_in("Name", with: "John Doe")
|> fill_in("Email", with: "hello@example.com")
|> fill_in("Password", with: "passwordpassword")
|> click("Create account")

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

3 participants