How to (Part 2): Swap Registration Flow to a Live View With phx_gen_auth — Multi-step form
Background
I’m currently developing a social connection web application, called Metamorphic, and I decided that I didn’t want a “never-ending” form on the registration page — or at least, that’s how it was beginning to feel as I would decide to add more fields to the signup.
This meant that I needed to implement what is commonly known as a multi-step (or multi-part) form. Typically, this would be implemented using JavaScript, but I had a feeling that Phoenix Live View could do the trick.
As always, reading the Phoenix Live View docs proved critical to navigating a few unexpected behaviors after following this wonderful guide from Markus.
Prerequisites
To make following along easier, we will assume you have read Part 1, where we swapped the “dead view” over to a Live View for registration.
That being said, the same basic prerequisites as Part 1 apply:
- You have a working Elixir/Phoenix app setup with the live generator (or add the appropriate code — see the docs).
- You have installed and setup phx_gen_auth (version 0.6.0) using
mix phx.gen.auth Accounts Person people
(you can substitute other names). - You are using
phoenix_live_view 0.15.4
. - You are using tailwindcss or understand your own CSS tooling.
- Optional: You have setup email confirmation with Bamboo.
Let’s Begin
So, at this point, we’ve got a working registration Live View page (per Part 1) and we need to transition the fields into a multi-step form.
This means that we are going to be working in two files within our Phoenix application: app/lib/app_web/live/person_registration_live/new.ex
and app/lib/app_web/templates/person_registration/new.html.leex
.
If you’re working in an umbrella project, then you just have a few additional directories to work through on your file path before you get to your app_web
section.
Step 1
Let’s tackle person_registration_live/new.ex
first.
This is essentially following Markus’ guide exactly with a few differences that relate specifically to our own setup (including the optional usage of Bamboo).
The only changes we made from Part 1 are the two new handle_event/3
functions after our render/1
function, and mount/3
. Let’s take a closer look at each:
The first handle_event/3
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
Our first handle_event/3
takes a "prev-step"
event that we will pass through our upcoming new.html.leex
template. We don’t need to worry about passing a value or other unsigned_params so we prefix it with the “_” character. Lastly, we need to pass the socket, which is a struct that holds our state and is used in place of conn in Live Views.
Then, we bind the new_step
variable to the largest of the two given terms, in this case, either socket.assigns.current_step - 1
or 1.
Lastly, we send a :noreply
signal with the :current_step
assigned to the new_step
in the socket.
The second handle_event/3
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 [:name] end) 2 -> Enum.any?(Keyword.keys(changeset.errors),
fn k -> k in [:email] end) 3 -> Enum.any?(Keyword.keys(changeset.errors),
fn k -> k in [:password] end) 4 -> Enum.any?(Keyword.keys(changeset.errors),
fn k -> k in [:terms_of_use] end) _ -> true end new_step =
if step_invalid, do: current_step, else: current_step + 1 {:noreply, assign(socket, :current_step, new_step)}end
Our second handle_event/3
takes a "next-step"
event and the rest of the parameters remain the same as before.
Then, we bind the current_step
variable to the current_step
we have assigned in the socket. Then, we implement a bit of logic to decide whether or not we have an invalid step, by binding invalid_step
to the return of the following case
statement.
You may well be best served to break this logic out into a separate file (context) or private function call, but it’s up to you. The important thing to understand, is that you are essentially declaring which field validations are being checked on each respective step.
You could add as many or as few fields and steps as you want here.
After our case
statement, we bind new_step
to the current_step
's state depending on whether or not the step is invalid.
Lastly, we assign our new_step
to the current_step
in the socket.
The mount/3
def mount(_params, _session, socket) do changeset = Accounts.change_person_registration(%Person{}) {:ok, socket |> assign(:current_step, 1) |> assign(:changeset, changeset)}end
What’s worth noting here, is that we additionally assign the :current_step
to the socket and give it an initial value of 1. This essentially means that when a person lands on our registration form page, they will be starting at step #1.
Perfect, let’s look at our last file to update.
Step 2
Let’s tackle our person_registration/new.html.leex
now.
I’ve stripped out all of the CSS styling to illustrate the key takeaways:
<%= unless @current_step === 1, do: "hidden" %>
We need to wrap our fields for each step in a similardiv
logic statement like this to ensure the fields (a) preserve our input throughout each step (forwards and backwards) and (b) work with our validations appropriately. However, this is not enough alone, we must do one more thing to ensure our validations don’t validate before we’ve reached the respective field.type="button"
This is the second crucial piece to the validation pie. We need to explicitly set this in our CSS otherwise the “Back” and “Continue” buttons are treated as submit events on the server which can cause undesired behavior.phx-click
This allows us to send back the proper event message to our Live View and proceed accordingly.
We ultimately pass our events "prev-step"
and "next-step"
to our respective “Back” and “Continue” buttons. This is how Phoenix Live View triggers the respective actions based on our handle_event/3
functions and navigates us through our form.
Addendum
As your multi-step form takes on more complexity, you may begin to encounter edge-cases.
One such example that I encountered, involved a solution where I needed to update the “Back” button in the person_registration/new.html.leex
template to not be rendered on the final form step:
...<%= f = form_for @changeset, "#", [phx_change: :validate, phx_submit: :save] %> ... # Where final_step is your form's final step.
<%= if @current_step > 1 and @current_step < final_step do %>
<button type="button" phx-click="prev-step">Back</button>
<% end %> ...
</form>
It will ultimately depend on the specific intricacies and needs of your application, but I wanted to share a bit more on what I’ve begun to discover utilizing this approach to a multi-step form with Phoenix Live View.
Conclusion
That’s it! We’ve got a working multi-step form using Phoenix Live View and successfully extended our registration flow from Part 1. You can easily add more fields following the logic above and build out as many, or as few, steps as you’d like.
Hope this helps anyone looking to implement a multi-step form using Phoenix Live View.
Totally open to improvements and thoughts.
Special thanks to Markus and the community at Elixir Forum.
❤ Mark