How to correctly use .form component with a Map?

We’re trying to create a form using the .form component and a map but unable to figure out the right combination. We are not using Ecto. Our data is unstructured JSON stored in a NoSQL backend.

The documentation says to use to_form with a map, but we keep getting stuck with the error that the given map does not implement HTML.Safe.

<.form :let={f} for={@account} action={~p"/accounts/#{@tag["id"]}"}>
    # We know there's always a name, but in reality 
    # this is a for-loop over the account's keys.
    <.input field={f["name"]} />
defmodule DemoWeb.AccountsController do
  def edit(conn, %{"id" => id}) do
    # This returns a Map of arbitrary key/value pairs.
    account = Accounts.find!(id) 
    # Is this what we're supposed to do?
    account_form = Phoenix.Component.to_form(account)
    render(conn, :edit, account: account_form)

Our goal is to just render a form for a map, and then have the form fields posted back to the appropriate endpoint, where we’ll convert it back to a JSON blob and store it accordingly. Ecto, and Ecto changesets, are not used anywhere.

Where is tag coming from? If account is a map, then this is the general idea:

account = %{"name" => "Account name"}
account_form = Phoenix.Component.to_form(account)
render(conn, :edit, account: account_form

then in the template:

<.form for={@account}>
  <.input field={@account[:name] />

My bad. tag was meant to be account.

I’m assuming account is a struct since that would cause that error. You have to use either a bare map or a changeset with to_form. You can convert a struct to a map with Map.from_struct although you’ll get a warning that you should not be using atom keys with to_form. From there you could convert all the keys to strings but I would just use a changeset. Since you aren’t using changesets anywhere else, you won’t need to do anything else with them other than convert your struct to one just for use with the form:

account_form =
  |> Ecto.Changeset.change()
  |> Phoenix.Component.to_form()

change just creates a changeset, no-questions-asked (no casting or anything) so it literally only needs to exist for the form.

Account was just a Map. The following still produces an error:

def edit(conn, _) do
  tag = Phoenix.Component.to_form(%{"name" => "Tim"})
  render(conn, :edit, tag: tag)

<.form :let={f} for={@tag}>
    <label for="name">Name:</label>
    <.input field={f["name"]} />

Produces this error:

protocol Phoenix.HTML.Safe not implemented for %Phoenix.HTML.FormField{id: “id”, name: “id”, errors: , field: “id”, form: %Phoenix.HTML.Form{source: %{“name” => “Tim”}, impl: Phoenix.HTML.FormData.Map,…

That code above seems to be converting a normal Map using to_form as suggested by the documentation, but we’re obviously missing an important step.

Ohhhh crap, I’m sorry. It’s been a million years since I’ve used a controller with a form like that. AFAIR, you just use the bare map, no need for to_form. to_form is largely about change tracking which is a LiveView-specific thing. You can just do:

render(conn, :edit, account: %{"name" => "Name"})

<.form :let={f} for={@account} as="account">
  <.input field={f[:name]} />

The as option will let you collect the params under an "account" key.

Awesome. Thank you.

We also noticed another issue that was causing a Safe HTML error but wasn’t related to the form, but rather a line further down in the heex file. Fridays…

That basically means you are trying to to_string some value into HTML. Most of the time it’s a struct, but it could be a tuple or fun or whatever doesn’t cast into a string (or HTML in this case, to be a bit more technical).

EDIT this anwser is OT, because this is about Controllers, not LV.

IF YOU’D BE USING LIVEVIEW you should not use :let here, see: Phoenix.Component — Phoenix LiveView v0.20.7

This works WITH LV:

def mount(_params, _session, socket) do
  form = to_form(%{"username" => "Kermit", "email" => ""})
  {:ok, assign(socket, form: form)}
<.form for={@form} phx-change="validate" phx-submit="save">
  <.input type="text" field={@form[:username]} />
  <.input type="text" field={@form[:email]} />

You’re making a similar mistake that I made—they are using controllers, not LiveViews. to_form just doesn’t work with controllers (from the bit of testing I did). And because of this it doesn’t really matter if they use :let or not (I also had the same thought, though).

1 Like

Yeah, in a framework with relatively good documentation, we’ve found the narrative around forms to be a bit confusing and hard to follow. We’re not using LiveView, so the fact that .form is part of Phoneix.LiveView initially made us look past it.

There’s form_for in Phoenix.HTML but this blog post suggests that it’s now deprecated. And this older forum post has a post from Jose saying that .form is the correct way to go, but as noted above we initially thought that was just for LiveViews.

In core_components.ex there’s .simple_form which is used throughout the generated scaffolding code. In this case you can quickly see it just calls through to .form, but when you’re initially learning it’s an added layer of indirection.

In our case, where we’re just using a plain Map, the “secret” attribute to make it work was as:.

We’re editing JSON records with an arbitrary and unknown set of keys. All we really want a form component for is to have the proper _method and _csrf attributes injected along with proper field naming.

1 Like

Yep, forms have changed a lot. I believe there is still polish left before LV is given the v1 stamp. I certainly understand the confusion as I was clearly confused there! The whole as thing used to be more prominent before LiveView but the influx of interest in Phoenix over the past few years has been LiveView (it’s what got me here to stay) and it’s been a bit of a moving target. There are many very responsive folks on this forum, though! :slight_smile: