phoenixframework / phoenix_html

Building blocks for working with HTML in Phoenix

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

`Phoenix.HTML.FormField` doesn't reflect changes in Changeset when final value is different from the supplied params.

linusdm opened this issue · comments

The Phoenix.HTML.FormField struct created by inputs_for seems to have an issue in some specific situations. The value field doesn't always reflect the value in the Changeset. Instead the value is populated with the params value, which is incorrect. I would expect the value field of the FormField to be the same as the resulting value from the Changeset for that field.

The situation described here is that the changed value (which comes from casting the supplied params) is reverted back to its original value. This is reflected by the Changeset but not by the FormField. Please correct me if any of the assumptions I'm making are wrong.

This is a reproduction with a unit test.

The dependencies:

Mix.install([
  {:ecto, "~> 3.9"},
  {:phoenix_html, "~> 3.3"},
  {:phoenix_ecto, "~> 4.4"}
])

ExUnit.start(auto_run: false)

An example schema with a nested child:

defmodule Parent do
  use Ecto.Schema
  import Ecto.Changeset
  alias Parent.Child

  schema "parent" do
    has_one(:child, Child)
  end

  def changeset(parent, attrs \\ %{}) do
    parent
    |> cast(attrs, [])
    |> cast_assoc(:child)
  end

  defmodule Child do
    use Ecto.Schema
    import Ecto.Changeset

    schema "child" do
      field(:boolean_field, :boolean)
      field(:another_field, :string)
    end

    def changeset(child, attrs) do
      cast(child, attrs, [:boolean_field, :another_field])
      # This is important!
      # we change the boolean_field back to false (in real life this is something dynamic)
      |> put_change(:boolean_field, false)
    end
  end
end

A test to trigger the error:

defmodule Test do
  use ExUnit.Case
  import Ecto.Changeset

  test "FormField should reflect changes" do
    parent = %Parent{child: %Parent.Child{id: 1, boolean_field: false}}

    changeset =
      Parent.changeset(parent, %{
        "child" => %{"id" => "1", "boolean_field" => "true", "another_field" => "bananas"}
      })

    # This assert proves the above setup results in the desired outcome.
    # Remember that boolean_field is changed back to `false` no matter
    # what the params were. This works as expected.
    changed_parent = apply_changes(changeset)
    assert %{boolean_field: false, another_field: "bananas"} = changed_parent.child

    # Emulate creation of a FormField (this should replicate the usage of
    # the <.form> and <.inputs_for> components more or less).
    form_parent = Phoenix.HTML.FormData.to_form(changeset, [])
    [form_child] = Phoenix.HTML.Form.inputs_for(form_parent, :child)

    # works as expected for the other field
    assert form_child[:another_field].value == "bananas"
    
    # BOOM! This is not what is expected. We would expect `false` here,
    # just like in the resulting struct after doing `apply_changes/1` on the changeset.
    assert form_child[:boolean_field].value == false
  end
end

ExUnit.run()

I've described my initial explanation on the forum (it explains the details of my specific situation for some context). I think the above is the essence.

This is a link to a .livemd to reproduce locally in case that's easier: https://gist.github.com/linusdm/e995320494c2bdcb6093c8f467831704

Hi @linusdm. This is intentional. The value in the params always wins because the goal is always to render back what the user typed. This is how input_value has worked for several years too. You will have achieve the desired behaviour in another way. I will improve the docs here.

I'm very curious to learn more about this behavior and how I can still obtain the desired result. I knew about how LV prevents overwrites when the input is active, to avoid unintended changes in the browser input, but this is new to me.

Thank you for the clear and fast response! 💜

@josevalim one option is to change or delete the params as they come in instead of the changeset. Or use two separate fields: "boolean_field" becomes "foo_bar_field" in the changeset and then you check for the changeset value in order to render the proper UI and so on. I haven't tried any of those but those are the first thought on top of my hand.

Ok, I can see how those two techniques can help here. I'll try tomorrow. Thanks.

I still fail to understand how you can see the effect of doing a put_change in some circumstances, but not in this specific situation. I often do change values in the changeset when acting on a phx-change event and see them reflected in the form, although the final value is different from what was initially submitted with params.

I would be surprised that this is the rule: you can't change any of the submitted values and expect the new values to show up in the form. I do that often, and it works most of the time, so that can't be true. I'm probably missing something very trivial here... 🫣

You are right, I misspoke: https://github.com/phoenixframework/phoenix_ecto/blob/main/lib/phoenix_ecto/html.ex#L105

will look into it with more care tomorrow.

I conflated the LV client behavior with the server one. :)

Ok, I looked at the code and the behaviour is indeed correct. You initialize it as boolean_field: false which means that, when you set it to false on put_change, no change is registered whatsoever because it matches the initial value.

We don't want to send changes to the db unless they changed. And the fallback is change => params => data. Luckily, the fix is as easy as replacing put_change by force_change.

I conflated the LV client behavior with the server one. :)

That restores my sanity! :D

https://github.com/phoenixframework/phoenix_ecto/blob/main/lib/phoenix_ecto/html.ex#L105

Oh, that explains beautifully what happens. Thanks for linking!

So, if you put_change something on the Changeset, the behaviour depends on whether the new situation results in a change, or not. If you happen to change it to something different from the original data, then the change will be replied back (probably 99% of the times this is the case). But if you change it back to its original value and it differs from the params, then the params take precedence, and the original value is not replied back. This is my case, and probably is something that doesn't happen very often.

But it baffles me that params are sandwiched in-between here. The params will kick in depending on what the original data was, which is quite unpredictable because often the value you pass into put_assoc is independent of what the original value was. Imho, the fact that it does or does not match the original value shouldn't matter.

I realise that flipping the fallback mechanism to [changes] -> [data] -> [params] wouldn't work either (params would never kick in). So I'm left in doubt.

We don't want to send changes to the db unless they changed.

Regarding the DB changes: this is happening on the Changeset level, I don't think the Form has anything to do with this.

Luckily, the fix is as easy as replacing put_change by force_change.

This indeed has the desired result! TIL about why you'd ever use force_change :)

Thank you for educating me, and having taken the time! 🙏

Regarding the DB changes: this is happening on the Changeset level, I don't think the Form has anything to do with this.

Right, i am explaining one of the reasons why they are not tracked as changes!