How to add a non-changeset field to a form that was created from a changeset

I have an existing form that I create from a changeset and that I assign to my socket using <.simple_form>. Inside of the <.simple_form> I added a <select> field in addition to fields based on fields from the form’s data.

This works well and I am able to retrieve the form data and the extra field as separate keys in the params sent to handle_event.

The problem is that I have a phx-change event that validates the form input using the changeset by setting a new form based on that changeset to the socket. When this happens, whatever has been selected in the non-changeset-tracked <select> field is reset to its default value.

Is there a way to persist the selected value for the non-changeset-tracked <select> field so that it does not get wiped out when the form is reset by the validation?

Here’s an example of the relevant form code:

<.simple_form
        for={@form}
        id="new-user-form"
        multipart
        phx-target={@myself}
        phx-change="validate"
        phx-submit="create"
      >
      <label>Customer type:</label>
      <select name="customer_type" id="customer_type">
        <%= for {type_atom, type_description} <- customer_types() do %>
          <option value={type_atom} selected={default_customer_type() == type_atom}>
            <%= type_description %>
          </option>
        <% end %>
      </select>
        <.input field={@form[:name]} type="text" label="Name" phx-debounce="blur" />
        <.input field={@form[:email]} type="email" label="Email" phx-debounce="blur" />
        <:actions>
          <.button phx-disable-with="Creating user...">Create User</.button>
        </:actions>
      </.simple_form>

Welcome!

Have you tried persisting the selected value as a socket assign in handle_event?

You’d also want to update the <option ... selected={...}> to use that assign if set to ensure subsequent re-renders are aware of the previously selected option.

1 Like

Hi @distefam, welcome to the forum!

To prevent the value from being wiped from the form on validation, I think you need to re-assign the form during the validation. Additionally, I suggest using the input component from the core_components with the option type="select". Here’s an example:

def render(assigns) do
  ~H"""
  <.simple_form :let={f} for={@form} as="my_params" phx-change="validate">
    <.input type="select" field={f[:customer_type]} options={[{"display_value1", 1}, {"display_value2", 2}]} />
  </simple_form>
  """
end

def mount(_params, _session, socket) do
  {:ok, assign(socket, form: to_form(%{})}
end

def handle_event("validate", %{"my_params" => params}, socket) do
  # do your validation here
  validated_params = validate(params)
  
  # this is where you want to re-assign the form to the socket. It should include
  # the parameters that were passed from the form to the `handle_event` callback
  {:noreply, assign(socket, form: to_form(validated_params)}
end

I think one of the key things here is that you can either pass a list of strings to the options attr of the input component, or you can pass a list of tuples, which will set the option’s value and inner HTML.

Hope this helps :slight_smile:

Gus

1 Like

I’d argue that you should use a source for the form, which manages all the fields the form deals with. If your schema/changeset doesn’t match up it’s imo not an appropriate source. You could map between the formats at a different level (after form handling is done).

I think that is a usecase for a virtual field in the changeset, since your are backing the form using the changeset.

1 Like

Thank you all for your replies. I was able to combine what a lot of you said into a working, and I believe much more elegant, solution.

The reason I didn’t want to create a new non-changeset source is that I wanted to retain the validation of the changeset. So, I added customer_type as a virtual field to the User schema as @greven suggested. Thank you for that suggestion, I didn’t realize that was possible.

Then, @gus 's suggestion of using <.input> and passing a tuple to its options really helped. I had already created a few helper functions to map the display text with the backing data but I didn’t realize that the <.input> form component already has this functionality. So, I was able to clean up my initial code a bit.

Combining <.input options=...> with the virtual field also allowed me to more safely parse responses using String.to_existing_atom() since I used the type Ecto.Enum in my schema for the virtual field.

The final (truncated for clarity) solution follows:

# form_component.ex (a LiveView component)
<.simple_form
        for={@form}
        id="new-user-form"
        multipart
        phx-target={@myself}
        phx-change="validate"
        phx-submit="create"
      >
        <.input field={@form[:customer_type]} type="select" options={customer_types()} />
    ...
</.simple_form>

def customer_types(), do: [{"Individual", :individual}, {"Family Admin", :family_admin}, ...]
# user.ex
schema "users" do
field :customer_type, Ecto.Enum,
      values: [:individual, :family_admin, :family_member],
      virtual: true
...
end

I originally had this reply as a draft reply to a specific post and published it that way without thinking. I’m not sure how to remove it as a reply to a specific post and make it just a thread-level reply.

3 Likes