Real World Phoenix |> Sign Up Flow SPA style with LiveView!

Tjaco oostdijkPosted by Tjaco Oostdijk on 18-12-2019

In the last post we configured our app to be able to handle sign up of different user roles. In this post we’ll explore how we can let these different types of users register for an account.

While you were not watching I have added the Bulma css framework to our app and we’ll use that to create a registration page that has 2 tabs. one for student registration and one for teacher registration. Bulma doesn’t come with any javascript included. So if we want to get a more SPA feel to our sign up flow, we’d have to add some js sprinkles. This is where LiveView steps in! We can create a snappy SPA feel without having to write any javascript at all! Let’s explore how this would work.

In this project I had LiveView enabled already. If you need to add LiveView to your project I’d advise you to check out the documentation here, as always it is very clear so that’ll get you up and running quickly.

I have played with the idea of only using LiveView to toggle the tabs for these sign-up types, but that seems a bit silly because we could just use anchor tags for that. So let’s go with a full LiveView form! This also gives us a couple of benefits. The first being that we can have live form validation when a user starts filling in the form. The second benefit is that we can call our backend directly to process the sign-up. As we are using Pow for our user registration, it is a bit more work to hook into the signup process that is managed by Pow. In the end of this article I’ll reference the way to do this with Pow’s callbacks. If we use a LiveView form we can bypass the controller flow totally and just use our live component to target the sign-up method needed based on the type of registration.

With that said, let’s look at how we can put together our LiveView form: We’ll create two tabs that will switch between student and teacher signup and use the ‘phx-click’ binding to switch between these two types.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<div class="tabs is-boxed">
  <ul>
    <li class="<%= if @type == "student", do: "is-active"%>">
      <a>
        <span phx-click="switch-type-student">Student Registration</span>
      </a>
    </li>
    <li class="<%= if @type != "student", do: "is-active"%>">
      <a>
        <span phx-click="switch-type-teacher">Teacher Registration</span>
      </a>
    </li>
  </ul>
</div>

I have introduced a @type value that we’ll use to switch between the types of registration. It’s used to show the selected tab and also to show the fields for student vs teacher registration, currently that is only a teacher biography. We store the specifics for a student/teacher in a separate table that is associated with the user account. Here we can use the inputs_for helper to add associated fields to our form as you can see below:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
 <%= if @type == "student" do %>
   <%= inputs_for f, :student_profile, fn fp -> %>
     <div class="field">
       <%= label fp, :first_name, class: "label" %>
       <%= text_input fp, :first_name, class: "input" %>
       <%= error_tag fp, :first_name %>
     </div>

     <div class="field">
       <%= label fp, :last_name, class: "label" %>
       <%= text_input fp, :last_name, class: "input" %>
       <%= error_tag fp, :last_name %>
     </div>
   <% end %>
 <% else %>
   <%= inputs_for f, :teacher_profile, fn fp -> %>
     <div class="field">
       <%= label fp, :first_name, class: "label" %>
       <%= text_input fp, :first_name, class: "input" %>
       <%= error_tag fp, :first_name %>
     </div>

     <div class="field">
       <%= label fp, :last_name, class: "label" %>
       <%= text_input fp, :last_name, class: "input" %>
       <%= error_tag fp, :last_name %>
     </div>

     <div class="field">
       <%= label fp, :bio, class: "label" %>
       <%= textarea fp, :bio, class: "input" %>
       <%= error_tag fp, :bio %>
     </div>
   <% end %>
 <% end %>

In our backend we’ll need to mount a LiveView component that will handle these interactions. We’ll create a file called lib/student_manager_web/live/user_registration.ex.

The LiveView behaviour requires you to implement two callbacks. render/1 to render the actual html and mount/2 that is used to initialize some state when the component is mounted.

In the render/1 function we’ll render our .leex template file that contains our form.

1
def render(assigns), do: Phoenix.View.render(StudentManagerWeb.Pow.RegistrationView, "new.html", assigns)

Here is the full contents of our .leex file:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
<%= f = form_for @changeset, "#", [as: :user, phx_change: :validate, phx_submit: :save] %>
  <%= if @changeset.action do %>
    <div class="alert alert-danger">
      <p>Oops, something went wrong! Please check the errors below.</p>
    </div>
  <% end %>

   <div class="tabs is-boxed">
      <ul>
        <li class="<%= if @type == "student", do: "is-active"%>">
          <a>
            <span phx-click="switch-type-student">Student Registration</span>
          </a>
        </li>
        <li class="<%= if @type != "student", do: "is-active"%>">
          <a>
            <span phx-click="switch-type-teacher">Teacher Registration</span>
          </a>
        </li>
      </ul>
    </div>
    <%= if @type == "student" do %>
      <%= inputs_for f, :student_profile, fn fp -> %>
      <div class="field">
        <%= label fp, :first_name, class: "label" %>
        <%= text_input fp, :first_name, class: "input" %>
        <%= error_tag fp, :first_name %>
      </div>

      <div class="field">
        <%= label fp, :last_name, class: "label" %>
        <%= text_input fp, :last_name, class: "input" %>
        <%= error_tag fp, :last_name %>
      </div>
      <% end %>
    <% else %>
      <%= inputs_for f, :teacher_profile, fn fp -> %>
      <div class="field">
        <%= label fp, :first_name, class: "label" %>
        <%= text_input fp, :first_name, class: "input" %>
        <%= error_tag fp, :first_name %>
      </div>

      <div class="field">
        <%= label fp, :last_name, class: "label" %>
        <%= text_input fp, :last_name, class: "input" %>
        <%= error_tag fp, :last_name %>
      </div>

      <div class="field">
        <%= label fp, :bio, class: "label" %>
        <%= textarea fp, :bio, class: "input" %>
        <%= error_tag fp, :bio %>
      </div>
      <% end %>
    <% end %>

  <div class="field">
    <%= label f, Pow.Ecto.Schema.user_id_field(@changeset), class: "label" %>
    <div class="control">
      <%= text_input f, Pow.Ecto.Schema.user_id_field(@changeset), class: "input" %>
    </div>
    <%= error_tag f, Pow.Ecto.Schema.user_id_field(@changeset) %>
  </div>

  <div class="field">
    <%= label f, :password, class: "label" %>
    <%= password_input f, :password, value: input_value(f, :password), class: "input" %>
    <%= error_tag f, :password %>
  </div>

  <div class="field">
    <%= label f, :confirm_password, class: "label" %>
    <%= password_input f, :confirm_password, value: input_value(f, :confirm_password), class: "input" %>
    <%= error_tag f, :confirm_password %>
  </div>
  <div>
    <%= submit "Register", phx_disable_with: "Saving...", class: "button is-link" %>
  </div>
</form>

So that is pretty straightforward. Now let’s create the mount/2 function and see how we can make the form interactive. On mount, we’ll assign the type variable the “student” value, so we are defaulting to the student sign-up.

1
2
3
4
5
6
7
def mount(_session, socket) do
  {:ok,
    assign(socket, %{
          changeset: Accounts.User.changeset(%User{}, %{}),
          type: "student"
    })}
end

To switch the sign-up form and thus the type value to “teacher”, we just need to add these callback functions to our module.

1
2
3
4
5
6
7
def handle_event("switch-type-student", _path, socket) do
  {:noreply, assign(socket, type: "student")}
end

def handle_event("switch-type-teacher", _path, socket) do
  {:noreply, assign(socket, type: "teacher")}
end

Once we update the type value in our assigns, the component will automatically re-render by calling the render/1 function.

Now it’s time to create a relationship that will store the student and teacher data. We’ll create a Studentprofile and a TeacherProfile. We can define exactly what we want to store there.

These two migrations should do the trick for our database:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
defmodule StudentManager.Repo.Migrations.AddTeacherProfile do
  use Ecto.Migration

  def change do
    create table(:teacher_profiles) do
      add :first_name, :string
      add :last_name, :string
      add :bio, :string
      add :user_id, references(:users)

      timestamps()
    end
  end
end
1
2
3
4
5
6
7
8
9
10
11
12
13
14
defmodule StudentManager.Repo.Migrations.AddStudentProfile do
  use Ecto.Migration

  def change do
    create table(:student_profiles) do
      add :first_name, :string
      add :last_name, :string
      add :instrument, :string
      add :user_id, references(:users)

      timestamps()
    end
  end
end

In the form page above you had already seen the inputs_for helper that added these fields to our form. So how do we store these associations when we create our user. Fortunately Ecto makes this really straightforward! We’ll use cast_assoc that will use our Teacher and/or StudentProfile changeset method to store the associated struct.

Here is the implementation added to our sign up changesets

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
def teacher_registration_changeset(user_or_changeset, attrs) do
  user_or_changeset
  |> Repo.preload(:teacher_profile)
  |> changeset(attrs)
  |> cast_assoc(:teacher_profile)
  |> change(%{roles: ["teacher"]})
end

def student_registration_changeset(user_or_changeset, attrs) do
  user_or_changeset
  |> Repo.preload(:student_profile)
  |> changeset(attrs)
  |> cast_assoc(:student_profile)
  |> change(%{roles: ["student"]})
end

The last part of our puzzle is creating the callbacks for our LiveView Form. The form helper takes two options which define the callback functions being called in our LiveView component.

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

So phx_change and phx_submit will call our handlers on any change and submit respectively. Note that his means that our validate callback will be called on every change in the form. That is a lot of requests, because it happens per keystroke basically. This is running on a websocket connection so for now this is fine and if we do want to limit this we can use the awesome ratelimiting features that have been added to Phoenix LiveView, phx-debounce and phx-throttle. Read more about that here.

And here are the function definitions in our LiveView component:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
def handle_event("validate", %{"user" => params}, socket) do
  changeset =
    %User{}
    |> StudentManager.Accounts.User.changeset(params)
    |> Map.put(:action, :insert)

  {:noreply, assign(socket, changeset: changeset)}
end

def handle_event("save", %{"user" => user_params}, socket) do
  case Accounts.create_user(user_params) do
    {:ok, user} ->
      {:stop,
        socket
        |> put_flash(:info, "user created")
        |> redirect(to: "/")}

    {:error, %Ecto.Changeset{} = changeset} ->
      {:noreply, assign(socket, changeset: changeset)}
  end
end

That should make both the user and teacher sign up work as expected. Hope you learned something new from this post. Next up is setting up email sending and making sure people get a nice welcoming email when they sign up for our app.

Bij Kabisa staat privacy hoog in het vaandel. Wij vinden het belangrijk dat er zorgvuldig wordt omgegaan met de data die onze bezoekers achterlaten. Zo zult u op onze website geen tracking-cookies vinden van third-parties zoals Facebook, Hotjar of Hubspot. Er worden alleen cookies geplaatst van Google en Vimeo. Deze worden gebruikt voor analyses, om zo de gebruikerservaring van onze websitebezoekers te kunnen verbeteren. Tevens zorgen deze cookies ervoor dat er relevante advertenties worden getoond. Lees meer over het gebruik van cookies in ons privacy statement.