Form with Dynamically Named Field Groups

Hello! I’ve been racking my brain on this one, and I’d love to hear any suggestions.

Essentially, I am looking to build a form where “name” and “game wins count” can be edited for each of a series of teams. The tricky part is that the team names are only known at runtime.

When name is a string, ~H"<%= text_input(f, @name) %>" (or input_value) errors:

** (ArgumentError) expected field to be an atom, got: "Unstoppables[wins]"

It would be great if I could somehow use changesets and/or Phoenix.HTML.Form stuff to pull this off with orderly error messages, etc, but I’m increasingly feeling I’ll need to just make my own input tags and handle everything myself.

I noticed the older Phoenix only supported atom for t:Phoenix.HTML.Form.field/0 but the latest has atom | String.t() for the type. I admit, I’m still not clear where string fields come in.

Btw, I found this article which builds a custom embedded_schema, but alas, it depends on knowing the field names (teams in my case) at compile time, which I do not. Dynamic fields from conf to UI in Elixir and Phoenix

Seems like Phoenix may not have tooling for me in this case :slight_smile:

Thanks so much for any thoughts or advice!

text_input accepts extra options which are passed down to the tag, so you can set your custom name that way, etc. I have some very old code that calls String.to_atom when generating a dynamic field but there is a memory pressure risk in doing that (atoms are never gc’d, in my case I am OK with this as there’s a fixed bound on what strings will be passed in – but they dont yet exist so to_existing_atoms doesn’t fit – but in your case where strings are free-input I would not do this.). I don’t believe new code should need this when using the newer form/field & components.

I think if you set your name to something like text_input(form, :not_used, name: "team[#{team_name}][wins]") you might get to where you want to go? I would look at see what field actually does when you’re providing your own name (& id?), you may find it has no impact just passing the same atom for each input.

Phoenix.HTML.Form is pretty relaxed and will create fields for anything you Access – even if it does not exist in the underlying changeset – which may be helpful. Eg form[Unstoppables_wins] should give you a %Field{} where name is something reasonable, but I think you probably want to use an embedded changeset and inputs_for.

  defmodule TournamentResult do
    use Ecto.Schema
    import Ecto.Changeset

    embedded_schema do
      field :tournament_name, :string

      embeds_many :teams, Team do
        field :name, :string
        field :wins, :integer

        # need this for cast embed
        def changeset(team, params) do
          team
          |> cast(params, [:name, :wins])
        end
      end
    end

    def changeset(tournament \\ %__MODULE__{}, params) do
      tournament
      |> cast(params, [:tournament_name])
      |> cast_embed(:teams, required: true)
    end
  end

    <.form
      for={@form}
      id="tournament_form"
      phx-change="validate"
      phx-submit="submit"
      phx-auto-recover="recover"
    >
      <.inputs_for :let={team} field={@form[:teams]}>
            <!-- the core components probably can just accept the field directly, this was
                  rough hewn from a legacy project that doesnt have them but has been updated to 1.7
                  which is why I set the name and value directly.
                 
                  You also probably want a hidden input that retains the team id (ecto should generate one)  -->
              <input
                type="text"
                placeholder="Team Name"
                autocomplete="off"
                class={[
                  "w-full",
                  team[:name].errors != [] && "placeholder-red-300 bg-red-50"
                ]}
                name={team[:name].name}
                value={team[:name].value}
              />
            <!-- still generates "valid" field even though does not exist in model -->
            <%= inspect team[:some_fake_field].name %>

Just be aware that when converted to “params” your teams change from an array to a map of numeric strings ("0" => team_1) so you lose intrinsic ordering. It seems that the newer to_form accounts for this better than it used to, but I have code there to re-index things they are added or removed, may not be needed now but just a heads up.

Not sure if all that helps you. Honestly I’m a bit unclear on what part is hard, Are you able to provide a basic (non-functioning) repo as an example? Are you using LV or regular controllers?

2 Likes

Thank you so, so much for this reply, @soup! I think it may just be what I needed to get going. Will experiment with this in the next couple days and reply back how it goes.

Thank you !!

Your post was super helpful, @soup. inputs_for injects hidden id fields which were very important for the server to associate each subgroup to the original data. My data itself didn’t actually have ids, but I assigned each group an id anyway and used that in the form. The changeset was being maintained in the LiveView state and when the form params came in, Ecto.Changeset was able to associate them properly.

This was more straightforward than I was thinking, indeed.

Thanks again! I very much appreciate your thoughtful response here.

2 Likes