peck
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
Most Liked
tme_317
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.
peck
@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.
f0rest8
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!








