Liveview form validation with user and profile changesets

I’m trying to create a registration form with the username, email and password fields. Email and password fields are part of the User schema, the username is part of the Profile schema. User has “has_one” on Profile and Profile has “belongs_to” on User (I wanted a one-to-one association).

The liveview form accepts a single changeset to do the validation, I think. Currently I’m using the User changeset (from phx.gen.auth). I’m trying to add the username in the mix so that on user creation I save the user first and then the associated profile with the username.

Is there a standard way to do validation on a combination of changeset? I’m new to ecto and just now I found out about cast_assoc but i’m not sure how to use it. Initially I tried to create a schemaless changeset that contains just [:username, :email, :password] and use that in the form but I was just duplicating code from User and Profile all over the place. I found out about embedded schemas and tried to create a schema that embeds both user and profile but had some problems… well at least I’m discovering ecto features by applying them incorrectly :sweat_smile:

I’d suggest taking a look at inputs_for/1 which “renders nested form inputs for associations or embeds” and works really well with cast_assoc/3.

source: Phoenix.Component.inputs_for/1 docs

5 Likes

@codeanpeace wow thank you so much. That approach was pretty clean together with cast_assoc/3.

1 Like

Hello. @giusdp I’ve been trying to perform exactly the same task through trying and error :smile:

I’ve came across the inputs_for to render nested form inputs, but I couldn’t connecting the dots yet.

Were you able to implement the form?

Hey @dhony, if you’ve used phx.gen.auth then you should already have the user schema and context. The way I solved was by adding cast_assoc/3 to the registration_changeset:

def registration_changeset(user, attrs, opts \\ []) do
    user
    |> cast(attrs, [:email, :password])
    |> validate_email(opts)
    |> validate_password(opts)
    |> cast_assoc(:profile, required: true, with: &Profile.registration_changeset/2) <--- new
  end

I have my Profile schema where I created the registration_changeset function which just does validation on the :username field (no ‘required’ constraint on user_id since it does not exist yet).

That cast_assoc basically adds into the User changeset a new field: profile: %Ecto.Changeset{...profile stuff...} so I tied together the creation of the user with also the creation of the profile. In my case I don’t care about users without a profile but if you want profiles to be an optional thing then adding the cast_assoc during the creation of the user registration changeset is not the way to go.

Then in the user_registration_live.ex I changed the simple_form that was generated with phx.gen.auth into a <.form> component

<.form
        :let={f}
        for={@form}
        id="registration_form"
        phx-submit="save"
        phx-change="validate"
        phx-trigger-action={@trigger_submit}
        action={~p"/users/log_in?_action=registered"}
        method="post"
      >
        <div class="space-y-8 mt-10">
          <.error :if={@check_errors}>
            Oops, something went wrong! Please check the errors below.
          </.error>

          <.input field={@form[:email]} type="email" label="Email" required />

          <.inputs_for :let={p} field={f[:profile]}>
            <.input field={p[:username]} type="text" label="Username" required />
          </.inputs_for>

          <.input field={@form[:password]} type="password" label="Password" required />
          <div class="mt-2 flex items-center justify-between gap-6">
            <.button phx-disable-with="Creating account..." class="w-full">Create an account</.button>
          </div>
        </div>
      </.form>

As you can notice in the inputs_for, the field is f[:profile] where f is the name of the form/changeset, created from the function at the top, registration_changeset (via the context). That cast_assoc adds :profile in the changeset, so we can just extract it for the username input.

I didn’t have to change anything else in the event handlers or mount function as ecto just takes care of doing the validation of nested changesets.

What broke where all the tests about the registration as the user_fixtures weren’t working anymore. They were expecting the user registration changeset to have a “profile: %{}” in them (cause of the cast_assoc there). With some trial and error it’s easy to fix them tho.

Hope this helps!

Quick heads up, cast_assoc/3 can be used even when the association is optional. It’s the required: true option that makes it not optional and likely why those tests broke.

Anyways, glad you figured it out and nice write up!

1 Like

damn completely overlooked that required, my brain just wasn’t registering it yesterday :joy:
thanks again

1 Like

Hey @giusdp. This worked perfectly.

I was messing around with form and simple_form.

Thanks a lot for dedicating time to explain every single detail of your implementation :grinning:

One more question.

We already present the email using it <%= @current_user.email %>.

I’m trying to figure out how to present the username. Something like this <%= @current_user.profile.username %>

I could find any response on the forum about it. It might be simple but not understand how to make it works :confused:

If you want to do something like that, @current_user needs to look something like:

%MyApp.Accounts.User{
  email: "hi@there.com",
  profile: %MyApp.Accounts.Profile{username: "alchemist")
}

This means you need to correctly preload the association for the user’s profile when you fetch @current_user from the database.

How are you currently fetching @current_user?

1 Like

Many thanks for this post, it helped me move in the right direction as I’m trying to enrich the default registration flow generated by phx.gen.auth --live.

Note to anyone doing the same:

  • This doesn’t work with simple_form.
  • when changing simple_form to form, don’t forget to remove the <:actions>...</:actions> markup from around the <.button>.component.

(BTW, trying to do this by keeping simple_form causes errors. AIUI, simple_form cannot be combined with inputs_for.)