Why does FormField values differs depending on form state?

After trying to do some integration with my frontend logic I found that the %Phoenix.HTML.FormField returns different values depending if the changeset is valid or not and I wasn’t expecting that…

I have some code that essentially looks like this:

# <dynamic_inputs_for field={f[:options]} />
def dynamic_inputs_for (assigns) do
  assign_new(assigns, :options, fn %{field: field} ->
    options =
      Enum.map(field.value, fn option ->
        option
         # Here, 'id', 'name', and 'values' refers to the embed structure
         # This exists when the form is first loaded on the page
         |> Map.take([:id, :name, :values])
         # I'm trying to push the errors from the field down to the client as well
         # The final code is going to be different, but I'm doing something along the lines of 
         # |> Map.put(:errors, field.errors)
      end)

     Jason.encode!(options)
  end)

  # [...]
end

And then, I use the @options assign to initialize some javascript code on the client. It’s all good until I submit the form with invalid data for the :options embed… In the response I see that field.value is now actually a list of changesets instead of a list of values - Has this always worked like this? (I mean, since the introduction of the %Phoenix.HTML.FormField struct).

It seems to me that, Phoenix.HTML.FormField should always return the proper values to use in forms, as the documentation states that the value field is the value for the input. Although, I understand if this was done for integrating with inputs_for - if that’s the case, it kinda “locks you in” on using inputs_for for getting the correct values for embeds.

I’m sure I’m missing something. WDYT!?

1 Like

Yes. You only get structs when there are no changes. Structs cannot track changes nor invalid input. The form integration however needs to support both - valid changes as well as any input, which didn’t (yet) yield a valid change.

1 Like

I Imagine this is only the case only for embeds_many field’s values because standard core components deal directly with field.values, right? It kinda makes you wonder if %FormField shouldn’t be used to track the changes instead :thinking:

Nope, you’ll see different types of values for any form field. E.g. for Ecto.Enum you’d get an atom before there are any changes and a string value after. It also depends on which Phoenix.HTML.FormData implementation you’re using. This has been a really leaky abstraction for ages and continues to be that it seems.

Are you 100% sure? I just tested this and it seems that’s not the case. I submitted a required string field without any values and what I get back is just the empty value, not a changeset. For an enum field, I submitted an invalid value (not within the enum) and I get back what was submitted as well. So, it seems the only thing behaving differently is the embeds_many field.

See for example this:

changeset_with_changes = Ecto.Changeset.change({%{field: 10}, %{field: :integer}}, %{field: 10}) 
|> Ecto.Changeset.cast(%{field: "100"}, [:field])

Here I created a changeset with a change (field changed to 100)

If I then make a form out of it and get the value of field from the form:

100 = Phoenix.Component.to_form(changeset_with_changes, as: :some_struct)[:field].value

I get the (expected) integer value 100.

If I however create a changeset that has no changes (but some params a.k.a as the input to cast/4):

changeset_without_changes = Ecto.Changeset.change({%{field: 10}, %{field: :integer}}, %{field: 10}) 
|> Ecto.Changeset.cast(%{field: "10"}, [:field])

and then get the value of field from the form:

"10" = Phoenix.Component.to_form(changeset_without_changes, as: :some_struct)[:field].value

I get the string “10”. This is because the implementation of input_value/3 for Ecto.Changeset will look for the value in these places:

  • changeset’s changes
  • changeset’s params
  • changeset’s data

In this order. If there are no changes, but there is a value in params, that one will be returned. And since this is the input passed to cast/4 there is no guarantee that it will have the type specified in your schema. So there is no guarantee that accessing the value of a form input will return a specific type.

1 Like

There’s also a good reason for this order. Valid changes will be in changeset.changes, invalid casted changes (e.g. consider "test" for an :integer field or "100", when validated to be in 0..10) are retained with changeset.params. If both don’t hold the field, then the input is expect to be untouched so it falls back to changeset.data.

2 Likes