How is `value` of `Phoenix.HTML.FormField` a string for boolean field?

So, I have an embedded schema, it’s pretty large – I am doing the entire state of the app in one schema called State. It is made up of two embed_one’s, and the first one is Control which has some parameters for lights:

  embedded_schema do
      field :light_top,       :float,   default: 0.0        # brightness
      field :light_bottom,    :float,   default: 0.0        # brightness
      field :light_top_on,    :boolean, default: false
      field :light_bottom_on, :boolean, default: false
  ...
  def changeset(%__MODULE__{} = ctrl, params \\ %{}) do
      ctrl
      |> cast(params, @cast)
      |> validate_required([])
    end

Note, I have checked @cast a dozen times, it is correct and includes all these fields. And the changeset for State has the proper |> cast_embed(:ctrl, with: &MyApp.Control.changeset/2).

Then in my LiveView controller, I handle the change event:

  def handle_event("change", %{"state" => state} = _params, socket) do
    MyApp.State.changeset(socket.assigns.form.data, state)
    |> Map.put(:action, :insert)
    |> form_to(socket)
    |> noreply()
  end

  defp form_to(changeset, socket) do
    assign(socket, :form, to_form(changeset))
  end

  def noreply(socket) do
    {:noreply, socket}
  end

In my view components, I have a controlbar component which is passed @form[:ctrl] as @ctrl. (I thought it was FormData at first, but it is actually just a Form), That gets passed down to the lighting controls of the control bar:

  attr :ctrl, Phoenix.HTML.Form

  defp ctrlbar_lighting(assigns) do
    ~H"""
    <div class="flex flex-row gap-4 items-center">
      <.light_button        field={@ctrl[:light_bottom_on]} title="Bottom Light On/Off" />
      <.input type="number" field={@ctrl[:light_bottom]}    title="Bottom Light Brighness" />
      <.light_button        field={@ctrl[:light_top_on]}    title="Top Light On/Off"   />
      <.input type="number" field={@ctrl[:light_top]}       title="Top Light Brighness" />
    </div>
    """
  end
  
  attr :title, :string
  attr :field, Phoenix.HTML.FormField

  defp light_button(assigns) do
    ~H"""
    <label class="button rounded pt-2 h-10 w-12 mt-2 text-center bg-slate-800" title={@title}>
      <.input type="checkbox" field={@field} style="display: none;" />
      <.icon name="hero-light-bulb" class={light_color(@field.value)} />     
    </label>
    """
  end

  defp light_color(on_state) do
    on_state |>  dbg()   # "false" String? HOW!?
    if on_state do
      "text-yellow-500"
    else
      "text-white"
    end
  end

It almost works except for some odd behaviors. One in particular is the comment I made above… when I cycle through clicks of a light button, the field value always returns true when on as it should, but returns "false" (a string!) when off, even though I am casting. How is that even possible? I point out that when it is off, that’s the default, so the changeset shows no changes for that state. Still I can’t fathom why I am getting a string.

1 Like

I had to work around this yesterday, the easiest way is to use force_changes: true in the call to cast. This behavior is subtly mentioned in the docs for input_value/2.

The issue is caused when a value in the changeset, is the same as the value being casted. It’s happening because Phoenix.Ecto.HTML checks the changeset.changes, then changeset.params, and finally the data itself. The relevant bit of code is here

I personally find this behavior really difficult to work with and I think it’s a footgun. I was going to make a PR to “fix” this (and would still be more than happy to) but found the note at the bottom of the input_value/2doc and figured it was intentional.

1 Like

I’m not arguing the footguniness (footgunnery?) of this but there are far-more-often-than-not alternate—and arguably more elegant—ways to solve these problems without manually checking form values. For example, OP’s specific one can be solved with small amount of CSS:

<.icon name="hero-light-bulb" class="text-white checked:text-yellow-500" />

EDIT: I was a bit sloppy with my copy-paste and general reading. You actually want this (though untested):

<.input type="checkbox" field={@field} style="display: none;" class="peer" />
<.icon name="hero-light-bulb" class="text-white peer-checked:text-yellow-500" /> 
1 Like

This cannot really be fixed. It needs to fallback to “params” to properly retain form state even if the input is incomplete or invalid and not allowed to be casted as is.

Thanks! That did the trick. Of course, given what @LostKobrakai said, I now wonder if this will come around to bite me elsewhere, but we shall see.

Btw, How did you even figure that out? The docs you link to make it seems like I would need to use something like normalize_value(:light_top_on, @field.value). But I don’t see how that would work b/c it doesn’t have a reference to @field – It’s not very clear on what arg1 is supposed to be.

Definitely a footgun. Actually worse b/c just about everyone is bound to loose their foot on this at some point.

Had no idea that was a thing… is it a Tailwind thing? I will try it. Thanks.

That particular syntax is Tailwind but of course Tailwind is just vanilla CSS—it does little, if any, magic. peer is just CSS Combinators (unfortunately MDN doesn’t have a direct link to the combinators section so that is the first section of several).

While again there are exceptions to every rule, if you are directly checking field values for presentation purposes, there is almost always a better way. It’s an iffy boundary and I always reach for CSS or JS commands before checking field values, though I’ve totally done it before.

There is an example in CoreComponents. The first arg is the field type, like checkbox or select.