phoenix_live_view icon indicating copy to clipboard operation
phoenix_live_view copied to clipboard

Unused param not sent for hidden inputs in LV 1.0

Open linusdm opened this issue 1 year ago • 9 comments

Environment

  • Elixir version (elixir -v): 1.18.1
  • Phoenix version (mix deps): 1.7.18
  • Phoenix LiveView version (mix deps): 1.0.1

Actual behavior

After moving to LiveView 1.0 I encountered the following problem. We integrate flatpickr for date inputs using clients hooks (e.g. phx-hook).

This library dynamically generates an alternative form input element that allows interaction and hides the original input element (using the type="hidden" attribute).

After changing to the new way to track used inputs I noticed that inputs with this hook are never marked as unused. This makes the input immediately seem used after a handle_event cycle. In cases where the input is validated as required for example, this makes the input show up with its error markup and validation error, even though an unrelated input has triggered the validation while the date input is untouched.

I guess there are more JS libraries that use a similar pattern (adding an alternative input field, hide the original, and keep them in sync). All libraries using a similar pattern should see this change in behaviour with LV 1.0.

I see that hidden fields don’t get a companion parameter with the unused prefix by design: see here. There is also some reasoning about not sending the unused-parameters for hidden fields in this PR: https://github.com/phoenixframework/phoenix_live_view/pull/3244.

For an example integration see this gist of @mcrumm (which would also break with LV 1.0): https://gist.github.com/mcrumm/88313d9f210ea17a640e673ff0d0232b

Expected behavior

Consider also adding the unused companion param for hidden inputs to allow this type of JS integration without breaking tracking of unused inputs.

linusdm avatar Jan 08 '25 07:01 linusdm

Not sending _unused for hidden inputs were very helpful for us, since we have some huge forms with a lot of hidden inputs that generate a ton of extra noise on the wire, but I absolutely see the need in cases like this. Would be nice if you could opt-out of the _unused somehow?

tmjoen avatar Jan 08 '25 16:01 tmjoen

Could I ask if a revert of https://github.com/phoenixframework/phoenix_live_view/pull/3244 would be accepted. This blocks us from upgrading to v1.0.

cw789 avatar Feb 13 '25 11:02 cw789

@cw789 I don't think simply reverting is the way to go.

This library dynamically generates an alternative form input element that allows interaction and hides the original input element (using the type="hidden" attribute).

Even if we reverted that and sent _unused for hidden inputs, because it's a hidden field, it would never be marked as used, only on submit. I guess with phx-feedback-for, the correct feedback name was set on the library generated input?

If only showing errors on submit is fine, another solution here would to not rely on used_input? for those special inputs and instead only renders errors for submit events:

<.input type="my-special-date-input" errors={@submitted? && form[:date].errors || []}>

and then adjust the input component to use assign_new for errors.

If someone can provide an up to date example script that shows such an integration, that could also help move this forward.

This blocks us from upgrading to v1.0.

Note that we provide the phx-feedback-for shim exactly for such cases where a migration to used_input might be problematic. So until we find a good solution here, why not use the shim instead?

SteffenDE avatar Feb 13 '25 11:02 SteffenDE

Okay, it seems not to be that simple. Thanks for your details.

I guess with phx-feedback-for, the correct feedback name was set on the library generated input?

My issues does not concern the mentioned library. It concerns the following code of the Petal Components library: https://github.com/petalframework/petal_components/blob/main/lib/petal_components/field.ex#L221-L277

But also an custom hook we use to format numbers where the value is submitted on a hidden input.

In both cases there is no phx-feedback-for involved. That's why the phx-feedback-for shim does not do anything for this cases.

I will try to provide a minimal example script.

cw789 avatar Feb 13 '25 12:02 cw789

Very minimal example using the Petal Components library.

# sample.exs
Application.put_env(:sample, Example.Endpoint,
  http: [ip: {127, 0, 0, 1}, port: 5001],
  server: true,
  live_view: [signing_salt: "aaaaaaaa"],
  secret_key_base: String.duplicate("a", 64)
)

Mix.install(
  [
    {:plug_cowboy, "~> 2.5"},
    {:jason, "~> 1.0"},
    {:phoenix, "~> 1.7"},
    {:phoenix_live_view,
     github: "phoenixframework/phoenix_live_view", branch: "main", override: true},
    {:petal_components, "~> 2.8"},
    {:heroicons, github: "tailwindlabs/heroicons", tag: "v2.2.0", app: false, compile: false, sparse: "optimized"}
  ]
)

defmodule Example.HomeLive do
  use Phoenix.LiveView, layout: {__MODULE__, :live}
  use PetalComponents

  @impl true
  def mount(_params, _session, socket) do
    changeset = build_changeset()

    {:ok, assign_form(socket, changeset)}
  end

  @impl true
  def handle_event("validate", %{"object" => object_params}, socket) do
    changeset =
      object_params
      |> build_changeset()
      |> Map.put(:action, :validate)

    {:noreply, assign_form(socket, changeset)}
  end

  @impl true
  def handle_event("submit", %{"object" => object_params}, socket) do
    changeset = build_changeset(object_params)

    case validate_changeset(changeset) do
      {:ok, _object} ->
        socket =
          socket
          |> put_flash(:success, "Object successfully created")
          |> assign_form(build_changeset())

        {:noreply, socket}

      {:error, changeset} ->
        socket =
          socket
          |> put_flash(:error, inspect(changeset.errors))

        {:noreply, assign_form(socket, changeset)}
    end
  end

  defp assign_form(socket, changeset) do
    assign(socket, form: to_form(changeset, as: :object))
  end

  defp build_changeset(params \\ %{}) do
    data = %{}

    types = %{
      text: :string,
      checkbox_group: {:array, :string},
    }

    {data, types}
    |> Ecto.Changeset.cast(params, Map.keys(types))
    |> Ecto.Changeset.validate_required([:text, :checkbox_group])
    |> Ecto.Changeset.validate_length(:text, min: 3, max: 50)
  end

  defp validate_changeset(changeset) do
    Ecto.Changeset.apply_action(changeset, :validate)
  end

  def render("live.html", assigns) do
    ~H"""
    <script src="/assets/phoenix/phoenix.js">
    </script>
    <script src="/assets/phoenix_live_view/phoenix_live_view.js">
    </script>
    <%!-- <script src="https://cdn.tailwindcss.com"></script> --%>
    <script>
      let liveSocket = new window.LiveView.LiveSocket("/live", window.Phoenix.Socket)
      liveSocket.connect()
    </script>
    <style>
      * { font-size: 1.1em; }
    </style>
    {@inner_content}
    """
  end

  def render(assigns) do
    ~H"""
    <.container max_width="md" class="mx-auto my-20">
      <.h2>Simple form</.h2>

      <.form for={@form} phx-submit="submit" phx-change="validate">
        <.field
          required
          field={@form[:text]}
          placeholder="Text"
          phx-debounce="blur"
          label="Text"
          required
        />

        <.field
          type="checkbox-group"
          field={@form[:checkbox_group]}
          options={[{"Option 1", "1"}, {"Option 2", "2"}, {"Option 3", "3"}]}
          required
        />

        <.field
          type="radio-group"
          field={@form[:radio_group]}
          group_layout="row"
          options={[{"Option 1", "1"}, {"Option 2", "2"}, {"Option 3", "3"}]}
        />

        <div class="flex justify-end">
          <.button>Submit</.button>
        </div>
      </.form>
    </.container>
    """
  end
end

defmodule Example.Router do
  use Phoenix.Router
  import Phoenix.LiveView.Router

  pipeline :browser do
    plug(:accepts, ["html"])
  end

  scope "/", Example do
    pipe_through(:browser)

    live("/", HomeLive, :index)
  end
end

defmodule Example.Endpoint do
  use Phoenix.Endpoint, otp_app: :sample
  socket("/live", Phoenix.LiveView.Socket)

  plug Plug.Static, from: {:phoenix, "priv/static"}, at: "/assets/phoenix"
  plug Plug.Static, from: {:phoenix_live_view, "priv/static"}, at: "/assets/phoenix_live_view"

  plug(Example.Router)
end

{:ok, _} = Supervisor.start_link([Example.Endpoint], strategy: :one_for_one)
Process.sleep(:infinity)

cw789 avatar Feb 13 '25 15:02 cw789

@cw789 this can be fixed in petal:

diff --git a/lib/petal_components/field.ex b/lib/petal_components/field.ex
index 1034f54..c9142dc 100644
--- a/lib/petal_components/field.ex
+++ b/lib/petal_components/field.ex
@@ -237,7 +237,7 @@ defmodule PetalComponents.Field do
       <.field_label required={@required} class={@label_class}>
         {@label}
       </.field_label>
-      <input type="hidden" name={@name} value="" />
+      <input type="hidden" name={@name <> "[]"} value="" />
       <div class={[
         "pc-checkbox-group",
         @group_layout == "row" && "pc-checkbox-group--row",

_unused works just fine if there is at least one non-hidden input with the same name. This is basically the same problem as https://github.com/phoenixframework/phoenix_live_view/issues/3577.

So I'd suggest to open up an issue in petal_components. Note that you need to filter the empty value on the server (which Ecto does by default iirc).

SteffenDE avatar Feb 13 '25 15:02 SteffenDE

Thank you for putting me on the right track. Really appreciate your work.

cw789 avatar Feb 14 '25 06:02 cw789

I faced the same thing while I was working on Autocomplete and Date Picker components. Both were using a hidden input to control the actual selected value versus what the user sees.

I ended up changing it to use <input type="text" hidden />, which seemed to work well.

andrielfn avatar Mar 13 '25 17:03 andrielfn

I think that's actually a good solution here. Does anyone see a downside to doing <input type="text" hidden /> instead of <input type="hidden" />?

SteffenDE avatar Mar 14 '25 14:03 SteffenDE

I think hiding the input is a great suggestion forward and hidden inputs remain unsent. Thanks!

josevalim avatar Jul 28 '25 13:07 josevalim

@linusdm you can set back the original type after initializing Flatpickr. For some reason, the library assumes that you need to change the original input to type="hidden".

You can use my new code snippet:

https://gist.github.com/RodolfoSilva/a3f710deb14005242d8e9182ca3741d6

Flatpickr always changes the input type during initialization, so you can set it to text type and hide with css after instantiating Flatpickr.

https://github.com/flatpickr/flatpickr/blob/548330e6e18871d6ca1ca78746c0e89cb12a0303/src/index.ts#L2643

RodolfoSilva avatar Sep 18 '25 22:09 RodolfoSilva