Struggling with forms - suspect calling handle_event("validate", ...) from within handle_event("append", ...) is not correct

Hello!

I’ve been banging my head against a wall… Maybe someone can help.

My question kind of goes deep but hopefully I can boil it down…

I have a really simple form where the user enters a float number. In addition to allowing data entry using a keyboard, I also created a “numeric keypad” made up of buttons, for entering the number more easily on a mobile phone.

How do I program the keys to append text to the input field?

Here’s the code I tried:

  # This is my standard handle_event in the form component - nothing unusual
  def handle_event("validate", %{"data" => form_params}, socket) do
    changeset = Context.to_changeset(socket.assigns.data, form_params)
    form = to_form(changeset, action: :validate, as: "data")
    {:noreply, assign(socket, form: form)}
  end
  # This is how I'm handling the button click
  def handle_event("append", %{"text" => text}, socket) do
    new_weight = get_param(socket.assigns.form, "weight") <> text
    new_params = %{"date" => get_param(socket.assigns.form, "date"), "weight" => new_weight}
    handle_event("validate", %{"data" => new_params}, socket)
  end

This code works: text is appended to the input box.

HOWEVER, validation does not kick in. For example, if I enter “2.0.0” using the buttons, no error is displayed. When I enter the same using the keyboard, the form displays the red “Invalid weight” message.

I’ve been racking my brain but I can’t explain why the error does not appear in the first scenario. I compared the form assign and the changeset in both cases and they are exactly the same, meaning that the changeset is marked as invalid and has the error message.

This is why I suspect that my way of calling handle_event("validate", ...) from within handle_event("append", ...) is not the right way of doing that. But I can’t find any examples online.

Am I going to have to use JS hooks or something?

Thanks,
Michal

Can you show the html you are using for your form?

I will send it tonight, thanks.

In general, I have to say that I am surprised by how difficult is to make customizations - even simple ones - to LiveView forms. I’ve spent a lot of time on trivial things and I am still not out of the woods. I have seen the new course about forms and maybe I’ll buy it.

Phoenix forms are geared toward doing logic on the backend. While this is a simple case and not sure why it is not working (as mentioned, will need to see more) manipulating params is almost always a smell AFAIC, especially if you are using changesets. Personally, in this case I would probably use a hook as I think it would be simplest. Otherwise, I would be using an append specific changeset, something like this (this is a bit off-the-top-of-my-head):

def append_changeset(%Data{} = data, text) do
  data
  |> cast(%{weight: "#{data.weight}#{text}"}, [:weight])
  |> validate_number(:weight) # or however you are doing this
end

Then in your append handler you can do:

def handle_event("append", %{"text" => text}, socket) do
  socket =
    update(socket, :form, fn %{data: data} ->
      changeset = Context.append_changeset(data, text)

      to_form(changeset, action: :validate, as: :data)
    end)

  {:noreply, socket}
end

And there is no need to call handle_event again

(note that data in this case is a key on form, not your assigns.data, although they are actually the same thing… it’s always confusing calling variables “data”)

But what you are doing is not wrong, it’s just bit hard follow and again, as @D4no0 said, we need to see more code!

If you use a number input, mobile browsers will natively display a numeric keyboard. You should absolutely do this, first because users expect a native keyboard, and second for accessibility reasons.

Rendering your own keyboard out of buttons is very bad practice.

3 Likes

Also, the reason it doesn’t work is probably because the input is not marked as used since you aren’t directly interacting with it.

But again, just use a native number input.

1 Like

Here’s my HTML, with irrelevant stuff removed:

      <.simple_form
        for={@form}
        id="weight-form"
        class="w-full flex flex-col gap-4"
        phx-target={@myself}
        phx-change="validate"
        phx-submit="save"
      >
        <.input field={@form[:weight]} type="text" class="text-xl text-center"
          class_container="flex items-start flex-col justify-start"/>

        <div class="flex flex-wrap rounded-lg bg-gray-300 max-w-sm mx-auto mt-24">
          <div class="w-1/3">
            <button type="button" class="w-full h-20 text-xl text-gray-700 rounded-lg hover:bg-gray-400"
              phx-click={JS.push("append", value: %{text: "1"})} phx-target={@myself}>
              1
            </button>
          </div>
          ...other buttons...
        </div>

        <:actions>
          <.button phx-disable-with="Saving…" class="w-full">Save</.button>
        </:actions>
      </.simple_form>

There is nothing too custom there.

The main reason why I’m calling handle_event from handle_event is to avoid code duplication around the creation of the changeset etc.

I do realize that manipulating params is probably not a good idea but I didn’t know how to make it work. I will try sodapopcan’s suggestion, it seems promising.

Please note that the official documentation warns against using inputs of type number.

But, also: “input not marked as used” sounds very plausible. I’ll look into it. I was just surprised that this would not come up when I compared the @form assign in detail. Where does LiveView keep this information? In the browser?

Which brings me back to my earlier question. Is there any resource I can read that explains how all this works?

Cheers,
Michal

You are indeed correct, the documentation suggests using <input type="text" inputmode="numeric" />, which is in the same spirit as what I suggested: it shows a native numeric keyboard on mobile. I would recommend doing that instead.

If this is just a personal project then it’s not a big deal, but I will reiterate that under essentially no circumstances should you ship a keyboard made of buttons.

The used_input?/1 docs I linked before explain how that functionality works. It’s new in LiveView 1.0, so if you’re reading older material keep in mind that things used to work differently. If you are looking for docs on how LiveView handles forms in general, the form bindings docs I linked a couple of paragraphs up are a good place to start. Also see the docs for form/1 and to_form/1.

Thanks, I will try with inputmode="numeric". But my question is more general. What’s the right way to modify the form inputs programmatically? The numeric field is not the only such situation I came across in my project. You can imagine a lot of other use cases, like having a “Validate” button for a markup field, or “Prev Day” and “Next Day” buttons for a date field.

I read the documentation about used_input but it doesn’t explain how to make it work if you want to modify the field programmatically.

All the best,
Michal

This is a good question, and I would like to see more discussion on this. I think it’s still somewhat in flux because, as I said, the used_input?/1 “system” is brand new and people are still migrating to it. There have been other users who have had issues (someone had a problem with their JS date picker not being marked as used, for example).

On the client (in JS, with hooks) you should be able to trigger the used state by manually dispatching a change event to the input with JS.

On the server, as the docs for used_input?/1 state, an input is considered unused when params contains a key with _unused_fieldname. So, you should be able to manually manipulate this key within the params map to suit your purpose.

Keep in mind, also, that you have control over what to do when an input is used. You do not have to hide errors when an input is unused, that’s just what the generated components (see your core_components.ex) do by default. If you want to show errors always, you can just get rid of that check (or override it with a flag). You could even create your own “used” state and use that instead.

Hi @garrison,

Yes, indeed!

I am not sure if my core components use used_input?/1. I see no reference to it in my core_components.ex (see below for an example). But maybe it is somehow handled in Phoenix.HTML.FormField? The second reason is that, if you remember, I create my own params maps and it does not contain any _unused_... fields.

I suspect that there is more going on behind the scenes, between the client and modules like Phoenix.HTML.FormField.

  def input(%{field: %Phoenix.HTML.FormField{} = field} = assigns) do
    assigns
    |> assign(field: nil, id: assigns.id || field.id)
    |> assign(:errors, Enum.map(field.errors, &translate_error(&1)))
    |> assign_new(:name, fn -> if assigns.multiple, do: field.name <> "[]", else: field.name end)
    |> assign_new(:value, fn -> field.value end)
    |> input()
  end

All the best,
Michal

You can find the line here in the installer template.

If you have an older Phoenix project then you may have generated this module before it was updated to support LV1.0 - if that’s the case you are expected to backport the feature yourself.

If you wanted, you could also copy in the updated module from a new project.