Liveview Multistep Forms (Wizards) Field Validation

Hi all,

I’ve been building a out a multi-step join/checkout flow and I’m liking the results. I used https://www.markusbodner.com/2019/05/31/multi-step-form-using-phoenix-live-view/ for inspiration.

The problem I’m having is with multiple fields on the same step, and validations called for all fields at the same time. Meaning that whenever name is starting to be filled them the error message for empty email shows up. I could solve it by having either field as a separate “step” but that would tend to annoy anyone filling out the form and doesn’t work well for things like addresses.

Gif here that probably shows much clearly than my words do: https://ibb.co/Nms7qHk since Im new user and cannot upload

I feel like I must be missing something obvious, but for the life of me I cannot figure out what it might be. Thanks for any help!

relevant lib versions:

      {:phoenix, "~> 1.4.10"},
      {:phoenix_pubsub, "~> 1.1"},
      {:phoenix_ecto, "~> 4.0"},
      {:phoenix_html, "~> 2.11"},

my changes struct

defmodule LarderWeb.Order do
  import Ecto.Changeset
  defstruct [:product_id, :name, :email]

  @types %{name: :string, email: :string, product_id: :string}

  def create_changeset() do
    {%__MODULE__{}, @types}
    |> cast(%{}, Map.keys(@types))
  end

  def changeset(order, attrs) do
    order
    |> Map.replace!(:errors, [])
    |> cast(attrs, Map.keys(@types))
    |> validate_required(Map.keys(@types))
    |> validate_format(:email, ~r/@/)
  end
end

liveview form

<%= f = form_for @changeset, "#", [phx_change: :validate, phx_submit: :save, as: "order"] %>

<%= if @current_step == 1 do %>
<%= with {:ok, %{data: products} } = Stripe.Product.list(%{active: true}) do %>
<%= for p <- products do %>
    <%= label do %>
    <%= radio_button f, :product_id, p.id %>
    <div>
      <h1>
        <%= p.name %>
      </h1>
    </div>
    <% end %>
    <% end %>

    <% end %>
    <% end %>

    <%= if @current_step == 2 do %>
    <%= label f, :name %>
    <%= text_input f, :name %>
    <%= error_tag f, :name %>
<hr/>
    <%= label f, :email %>
    <%= text_input f, :email %>
    <%= error_tag f, :email %>
    <% end %>


    <%= if @current_step == 3 do %>
    Time To Checkout
    <% end %>

    <div>


    <%= if @current_step > 1 do %>
<button phx-click="prev-step">Back</button>
<% end %>

<%= if @current_step == 3 do %>
<%= submit "Submit" %>
<% else %>
<button phx-click="next-step" phx-disable-with="Continuing">Continue</button>
<% end %>
</div>
</form>

liveview

defmodule LarderWeb.LarderLive do
  use Phoenix.LiveView
  use Phoenix.HTML
  import Bamboo.Email
  alias LarderWeb.Order

  def mount(_session, socket) do
    Phoenix.PubSub.subscribe(Larder.PubSub, "test", link: true)
    changeset = Order.create_changeset()

    socket =
      socket
      |> assign(current_step: 1)
      |> assign(changeset: changeset)

    {:ok, socket}
  end


  def render(assigns) do
    LarderWeb.OrderView.render("form.html", assigns)
  end

  def handle_event("save", %{"order" => params}, socket) do
    IO.puts("in save")
    changeset = Order.changeset(Order.create_changeset(), socket.assigns.changeset.changes)
    IO.inspect(changeset, label: "SAVE CHANGESET")
    case changeset.valid? do
      true ->
        {:stop,
         socket
         |> put_flash(:info, "Order taken")
         |> redirect(to: "/")}

      false ->
        {:noreply, assign(socket, changeset: changeset)}
    end
  end

  def handle_event("validate", %{"order" => params}, socket) do
    changeset = LarderWeb.Order.changeset(socket.assigns.changeset, params) |> Map.put(:action, :insert)
    {:noreply, assign(socket, changeset: changeset)}
  end

  def handle_info(message, socket) do
    IO.puts("Got message from broadcast")
    {:noreply, socket}
  end

  def handle_event("prev-step", _value, socket) do
    new_step = max(socket.assigns.current_step - 1, 1)
    {:noreply, assign(socket, :current_step, new_step)}
  end

  def handle_event("next-step", _value, socket) do
    current_step = socket.assigns.current_step
    changeset = socket.assigns.changeset

    step_invalid =
      case current_step do
        1 -> Enum.any?(Keyword.keys(changeset.errors), fn k -> k in [:product_id] end)
          2 -> Enum.any?(Keyword.keys(changeset.errors), fn k -> k in [:name, :email] end)
        _ -> true
      end

    new_step = if step_invalid, do: current_step, else: current_step + 1
    socket =
      socket
      |> assign(current_step: new_step)

    {:noreply, socket}
  end

  def error_tag(form, field) do
    Enum.map(Keyword.get_values(form.errors, field), fn error ->
      content_tag(:span, error,
        class: "help-block",
        data: [phx_error_for: input_id(form, field)]
      )
    end)
  end

end

Probably you need add “phx-blur” to your input field.

could you elaborate on that?

I have tried using phx-debounce with a value of blur for the input fields, and that does work to limit validation of the form until I leave a field, but as soon as the name field loses focus the email field is validated too.

I guess at the core of it I’m looking to ignore validations of fields that haven’t been “touched” by the user yet.

I mean put phx_input: “blur” to every field to be validated.

<%= if @current_step == 2 do %>
<%= label f, :name %>
<%= text_input f, :name, phx_input: “blur” %>
<%= error_tag f, :name %>
<%= label f, :email %>
<%= text_input f, :email, phx_input: “blur” %>
<%= error_tag f, :email %>
<% end %>

@peck Did you add phx_error_for to your error_tag function as documented here: https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.html#module-form-events

I had to do that to keep error messages from appearing on fields that haven’t been focused yet.

I get the same behavior with that added

@tme_317 I did, it is in the bottom of my LiveView file. :frowning:

Since you were able to do it that makes me think that there must be something simple that I missed.

Sorry I didn’t see that!

Yeah I was able to do it… instead of doing the conditional rendering approach from the article where you have <%= if @current_step == 3 do %> ... #fields ... <% end %> I am cheating by having all of the fields rendered but wrapping each step/group of fields in divs where all but the currently active step has style="display: none;". It’s a dirty hack but it works great.

I didn’t have to do that because of validation messages (rather keeping morphdom happy with my JS hooks) but this approach may work for you.

1 Like

@tme_317 yeah, I think my next step here is to strip down everything to just two fields with just one “step” so I only have save and validate events to handle and see if I can get it to work the way I want.

No shame at all in display:none hack, if it works it works! but unfortunately wouldn’t help my case since I want both name/email visible at the same time.

In your project that doesn’t show validations that haven’t been touched are you using the latest release versions of phoenix and phoenix_live_view?

@peck Yeah… I am using the latest release of Phoenix and a recent master version of LV including the awesome live_components.

I think using the display: none; instead of conditional rendering would help you since I noticed you have a the radio button :product_id which only appears on step 1 and the fields :name and :email on step 2. When validate is called I don’t think the full valid list of params are sent since not all the fields exist in the form at the same time. You can troubleshoot by IO.inspecting the params and the changeset in your validation handle_event. Your validations required all three fields to be set.

Also what if you make a fresh changeset on each call to “validate”? In other words I wouldn’t try to recycle the changeset already existing in your assigns but would just:

  def handle_event("validate", %{"order" => params}, socket) do
    changeset = Order.changeset(%Order{}, params) |> Map.put(:action, :insert)
    {:noreply, assign(socket, changeset: changeset)}
  end

Then you can get rid of |> Map.replace!(:errors, []) in your changeset function.

@tme_317 thanks so much for helping me work through it.

Updating the Changeset seems to be the problem. My mental model of iteratively build up the changeset in the in the state of the LiveView was completely wrong (and probably not the best idea for memory size of the process). It seems the right way is to have all the fields in one form and hide them hidden_input or otherwise.

1 Like

No problem at all. There may be another way to do it without hiding fields but I’m not sure since we want every field param sent to handle_event("validate"... on each keystroke (or after x milliseconds or onBlur if you set the debounce) so you can feed them all to the changeset. Also you want every field sent to your “save” event.

In order to figure out how it all worked I had to use IO.inspect() a lot to study the %Changeset{} struct and %Form{} struct and how they change in response to events calling the related functions in the modules.

Also, I was looking at your Order module and not sure if you want to persist this as a DB table but you may want to use Ecto.Schema here instead of relying on schemaless changesets since right now it validates but doesn’t persist. A good pattern for LV forms can be found in the official examples here: LV Form Examples where in the handle_event("save"... you simply call the context function to persist (which validates it again) then pattern match on the result. If it’s {:ok, user} then it worked and you redirect with flash but if {:error, changeset} is returned then you can display the errors.

Hope this helps!

I was able to follow the similar multi-step form guide with success after changing the <%= if @current_step == 1 do %> <% end %> lines in my new.html.leex template to:

# Using Tailwind CSS
<div class="<%= unless @current_step == 1, do: "hidden" %>">
  # form fields for step 1 here
</div>

<div class="<%= unless @current_step == 2, do: "hidden" %>">
  # form fields for step 2 here
</div>

<div class="<%= unless @current_step == 3, do: "hidden" %>">
  # form fields for step 3 here
</div>

This then allows the form inputs to not be reset when the Live View switches between steps.

I also applied IO.inspect to verify that my changeset wasn’t removing my previous inputs and it works!

:heart:

1 Like

I Think that the problem is using

<%= form_for ... %>
<%= if step == 1 %>

Explain: When you are using if and it is not true you don’t have the fields on your html page

you have to use it like this

<%= form_for ... %>
<div class='<%= if @step == 1, do: "hidden" %>'>
....
</div>
<div class='<%= if @step == 2, do: "hidden" %>'>
....
</div>

<% end %>

by doing that the HTML file will contains all the fields but it will show only on the exact step

Maybe don’t use an <input type="submit"/> on the continue button? <button/> should work fine. Or if you do want to use the <input type="submit"/> you should be able to pattern match on the value. You can also have <button type="submit" value="continue">Continue</button> and <button type="submit" value="submit">Submit</button>

1 Like

Yup, turns out needed to explicitly set type="button" on my <button></button> fields. This then allows me to go back to using phx-click.

I shared Medium post on it, here’s the friend link.

Thank you! :blush::heart:

Glad it helped.

I recommend that you don’t delete your post, because futures visitors will not understand what I meant in my previous comment, even if they are facing the same issue as you did. Better make a note in the comment to mention the other approach you took.

1 Like

:blush::+1: I’ll try to undelete the post when the action wait period is over.

The Medium post gives more detail on why you need to set the type="button".