`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!