How to print changeset errors without using the changeset in form_for?

I have a login page which allows to authenticate an admin.
If there are no users at all in the database (first time the app runs), I will create the superuser based on the login data entered (email/password).

My login form looks like this:

<h1>Login</h1>

<%= form_for @conn,
             Routes.admin_session_path(@conn, :create),
             [as: :session],
             fn f -> %>
  <div>
    <%= text_input f, :email, placeholder: "Email" %>
    <%= error_tag f, :email %>
  </div>

  <div>
    <%= password_input f, :password, placeholder: "Password" %>
    <%= error_tag f, :password %>
  </div>

  <%= submit "Log in" %>
<% end %>

And my controller actions:

def new(conn, _params) do
  render(conn, "new.html")
end

def create(conn, %{"session" => %{"email" => email, "password" => pass}}) do
  case Accounts.authenticate_by_email_and_pass(email, pass) do
    {:ok, %{"active" => true} = user} ->
      conn
      |> MyAppWeb.Authentication.login(user)
      |> put_flash(:info, "Welcome back!")
      |> redirect(to: Routes.admin_question_path(conn, :index))

    {:ok, %{"active" => false}} ->
      conn
      |> put_flash(:error, "Account is disabled")
      |> render("new.html")

    {:error, _reason} ->
      case Accounts.count_users() do
        0 ->
          case Accounts.register_user(%{"name" => "Superuser", "email" => email, "password" => pass, "type" => "admin"}) do
            {:ok, user} ->
              conn
              |> put_flash(:info, "#{user.name} created!")
              |> redirect(to: Routes.admin_session_path(conn, :new))

            {:error, %Ecto.Changeset{} = changeset} ->
              conn
              |> put_flash(:error, changeset.errors)
              |> render("new.html")
          end
        _ ->
          conn
          |> put_flash(:error, "Invalid username/password combination")
          |> render("new.html")
      end
  end
end

Of course as expected I have a problem on this line:
put_flash(:error, changeset.errors)

But as the login form is not displayed based on a changeset (but rather uses the connection: form_for @conn), how do I display the list of changeset errors when the superuser is being attempted to be created?

:wave:

Check out Ecto.Changeset.traverse_errors/2:

errors = Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} ->
  Enum.reduce(opts, msg, fn {key, value}, acc ->
    String.replace(acc, "%{#{key}}", to_string(value))
  end)
end)

error_msg =
  errors
  |> Enum.map(fn {key, errors} -> "#{key}: #{Enum.join(errors, ", ")}")
  |> Enum.join("\n")

put_flash(conn, :error, error_msg)

Also note that ecto schema structs have atom keys, so these two clauses might not match:

case Accounts.authenticate_by_email_and_pass(email, pass) do
    {:ok, %{"active" => true} = user} -> # <----- this one
      conn
      |> MyAppWeb.Authentication.login(user)
      |> put_flash(:info, "Welcome back!")
      |> redirect(to: Routes.admin_question_path(conn, :index))

    {:ok, %{"active" => false}} -> # <---- and this one
      conn
      |> put_flash(:error, "Account is disabled")
      |> render("new.html")
1 Like

Thank you!

By the way, there are no return lines on the screen when using Enum.join("\n"), so I replaced it by Enum.join("<br>") but now I see literally <br> on the screen.

Anyway you have been tremendous help already, I can try to find the solution for that last annoyance myself; if you have some time though any explanation is welcome ofc:)

I wouldn’t put changeset errors in a flash message, I’d probably render them into a template.

But this is a very special case, where there is a changeset (with possible errors) only when the superuser is being created, the first time the app runs. Therefore, instead of injecting a variable in the template just for that one-time case, I thought it might be a valid case to put it into flash, as I do for the other errors for this template.

By adding a variable in the template, I will have now like two error-display mechanisms, some errors will be in the flash, some errors in a template variable.

Also: the flash comes with an element already designed with html/css.

Regarding the issue with <br> being literally printed, take a look at raw/1 for the solution :slight_smile:

3 Likes

Or traverse, print and format the errors in the view, rather than the controller…

3 Likes

You can put it all together and replace the map + join by map_join

{:error, changeset} ->
        parsed_errors =
          Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} ->
            Enum.reduce(opts, msg, fn {key, value}, acc ->
              String.replace(acc, "%{#{key}}", to_string(value))
            end)
          end)
          |> Enum.map_join("\n", fn {key, errors} -> "#{key} #{Enum.join(errors, ", ")}" end)