Simple form without changeset in Phoenix 1.7

Hi,

I’d like to create a form in Phoenix 1.7 with one text field that has no underlying changeset. Previously I’d use form_for along with text_input. Now, I guess, I should use .simple_form but I don’t know what should be there in the for attribute. When I try to use @conn, as in the 1.6, I get the error: function Plug.Conn.fetch/2 is undefined (Plug.Conn does not implement the Access behaviour. If you are using get_in/put_in/update_in, you can specify the field to be accessed using Access.key!/1).

What is the proper way to create an unbound form in Phoenix 1.7?

Thanks

Hello, from the Phoenix.HTML.Form — Phoenix.HTML v4.0.0 docs, it looks like you can use it with a Map:

With map data
form_for/4 expects as first argument any data structure that implements the Phoenix.HTML.FormData protocol. By default, Phoenix.HTML implements this protocol for Map.

This is useful when you are creating forms that are not backed by any kind of data layer. Let’s assume that we’re submitting a form to the :new action in the FooController:

<%= form_for @conn.params, Routes.foo_path(@conn, :new), fn f -> %>
  <%= text_input f, :contents %>
  <%= submit "Search" %>
<% end %>

hth

Hi,

thanks for the quick reply. This is exactly what I had before I started migrating to 1.7. Is this still the way that it should be done?

Sorry, I didn’t write any form since 1.5.7, :man_shrugging:
I’d just give @conn.params a try :grimacing:.

.simple_form is a component defined within the core_components.ex generated via mix phx.gen.new and is a simple wrapper around the Phoenix.Component.form/1 function provided by LiveView.

Using the for attribute

The for attribute can also be a map or an Ecto.Changeset. In such cases, a form will be created on the fly, and you can capture it using :let:

<.form
  :let={form}
  for={@changeset}
  phx-change="change_user"
>

However, such approach is discouraged in LiveView for two reasons:

  • LiveView can better optimize your code if you access the form fields using @form[:field] rather than through the let-variable form
  • Ecto changesets are meant to be single use. By never storing the changeset in the assign, you will be less tempted to use it across operations

source: Phoenix.Component.form/1

2 Likes

I see I’m mixing a couple of things here… To clarify more: I have a ‘standard’ (not LiveView) page and I wonder what is the proper way to create such form in Phoenix 1.7. Sorry for confusion.

A bit further down in the docs, there’s an example noting that an action attribute is required for standard forms.

Example: outside LiveView (regular HTTP requests)

The form component can still be used to submit forms outside of LiveView. In such cases, the action attribute MUST be given. Without said attribute, the form method and csrf token are discarded.

<.form :let={f} for={@changeset} action={Routes.comment_path(:create, @comment)}>
  <.input field={f[:body]} />
</.form>

In the example above, we passed a changeset to for and captured the value using :let={f}. This approach is ok outside of LiveViews, as there are no change tracking optimizations to consider.

source: Example: outside LiveView (regular HTTP requests)

1 Like

I’m curious on this as well. Currently I just cheat and make a form component with no changeset. For example for creating a form to sort content based on when it was created I can use the below.
Heex:

  <.form
    let={f}
    id="time-sort-form"
    phx-target={@myself}
    phx-change="update">

    <.input 
      field={f[:time]} 
      type="select" 
      options=
      {
        [ {"Today", "today"}, 
          {"Week", "week"},
          {"Month", "month"},
          {"Year", "year"},
          {"All Time", "all time"}
        ]
      } />

    </.form>
</div>

The ex:

defmodule APP.SortLive.FormComponent do
  use APP, :live_component

  @impl true
  def handle_event("update", %{"time" => time}, socket) do
    username = socket.assigns.user.username
    time = time
    sort = "popular"

    {:noreply, socket |> push_patch(to: ~p"/user/#{username}/?#{[sort: sort, time: time]}")}
  end
end

I pretty much create a form without a changeset, then use a handle_event to deal with the input. I do similar for things like search inputs as well, but my handle_event passes the query value to a function for the redirect.

I’d also like a nice neat example of a changeset free form as I’m pretty sure I’m doing this either wrong or ineffeciently. It works, but feels off.

3 Likes

I think it looks like this…

  def mount(_params, _session, socket) do
    {
      :ok,
      socket
      |> assign_form(%{})
    }
  end

You use an empty map as a changeset. Then in the form…

<.simple_form for={@form} as={:form} id="form-graph" phx-submit="save">

As You use normal controllers, it might look a little bit different, especially phx-submit won’t be available. But it should be the way to have a form without changeset.

BTW it’s how phx.gen.auth does when You select non live…

<.simple_form :let={f} for={@conn.params["user"]} as={:user} action={~p"/users/log_in"}>
3 Likes

Seems reasonable to me, .form automatically sets the :for assign to %{} by default when it’s not explicitly given.

If you wanted to show which option is currently selected, you could then explicitly set a form assign so that the input component can pull the field value off of the form when generating the options via <%= Phoenix.HTML.Form.options_for_select(@options, @value) %>.

  <.form
    let={f}
    for={@form}
    id="time-sort-form"
    phx-target={@myself}
    phx-change="update">
      <.input 
        type="select" 
        field={f[:time]} 
        options={@time_options}
      />
  </.form>
</div>
defmodule MyApp.SortLive.FormComponent do
  use MyApp, :live_component

  def mount(socket) do
    time_form = to_form(%{"time" => "today"}
    time_options = [{"Today", "today"}, ..., {"All Time", "all time"}]

    {:ok, 
     socket
     |> assign(:sort, "popular")
     |> assign(:time_form, time_form)
     |> assign(:time_options, time_options)}
  end

  def handle_event("update", %{"time" => time}, socket) do
    username = socket.assigns.user.username
    sort = socket.assigns.sort
    time_form = to_form(%{"time" => time})

    {:noreply,
     socket
     |> assign(:time_form, time_form)
     |> push_patch(to: ~p"/user/#{username}/?#{[sort: sort, time: time]}")}
  end
end
2 Likes