Is there an elegant way to render non-live views/forms inside LiveViews?

I’m a huge fan of LiveView as a simplification over the API + frontend JS appropach, and have been happily using it in production for most of the year.

But my app is a fairly simple “structure” (1 route with a game view, all complexity is inside the state of the liveview, not in the structure of the overall app).

I’ve recently started needing more traditional forms + admin interfaces in those apps, so I reached for phx.gen.live. The result is quite a bit more complicated than traditional forms, and the only reason I want live forms is to integrate quickly and smoothly with the rest of my app. E.g. not having to drop and re-establish websockets if a user edits their profile with a form while playing the game.

What’s my best approach here integrating “static” forms? Should I be trying to find a Rails/Turbolinks way of submitting the request via the open websocket and rendering a partial result? Or is there a better way?

Thanks :heart:

1 Like

Just use traditional controllers and the so-called dead views if that works better for your use case :+1:t2: That’s still possible in Phoenix.

Yes, but then I get websocket reconnects in between LiveView and deadviews, so that’s not ideal. I’m working out how best to serve the deadviews over the websocket (or another approach that acieves a similar smooth experience).

So LiveView is more than capable of simple forms. I think the generators do make it appear more complex than it really is due to the way the form is embedded in a modal. If you can code up a simple form using “dead view”, it is actually a lot simpler to code them up with LiveViews.

I typically just code them from scratch, but I have built up a library of components to reduce a lot of the boilerplate.

Essentially, you just need to implement the render function for the template (or have your separate template file), the mount function (which needs to set up the changeset for the form), an event handler to validate (typically wired to the phx-change on the form) and an event handler for form submit. I generally combine the template containing the HTML form into the main LiveView file module by implementing the render function.

An (untested) complete example might looks something like this - I’m sure better developers than me will pick holes in it, but it works for me:

defmodule MyWeb.Admin.Units.UnitEditLive do
  use MyWeb, :live_view

  alias MyWeb.Live.FormComponents

  @impl true
  def render(assigns) do
    ~H"""
      <.form let={f} for={@changeset} phx-change="validate" phx-submit="save">
        <%= hidden_inputs_for(f) %>

        <FormComponents.text_input form={f} field={:name} />
        <FormComponents.text_input form={f} field={:symbol} />
        <FormComponents.text_input form={f} field={:description} />


        <div class="mt-8 flex-shrink-0 flex justify-end">
          <%= live_patch("Cancel",
            to: Routes.live_path(@socket, MyWeb.Admin.Units.UnitsIndexLive),
            class: "btn-plain"
          ) %>
          <%= submit("Save", phx_disable_with: "Saving...", class: "btn-small mr-2") %>
        </div>
      </.form>
    """
  end

  @impl true
  def mount(params, _session, socket) do
    {thing, action} =
      case params do
        %{"id" => id} ->
          {MyApp.Thingz.get!(id), :update}

        _ ->
          {%{}, :create}
      end

    changeset = MyApp.Thingz.change(thing)
      
    socket =
      socket
      |> assign(changeset: changeset)
      |> assign(action: action)

    {:ok, socket}
  end

  @impl true
  def handle_event("validate", %{"form" => params}, socket) do
    changeset = MyApp.Thingz.change(params)

    socket =
      socket
      |> assign(changeset: changeset)

    {:noreply, socket}
  end

  def handle_event("save", %{"form" => params}, socket) do
    result = case action do
      :update -> MyApp.Thingz.update(params)
      :create -> MyApp.Thingz.create(params)
    end
    
    socket = case result do
      {:ok, updated_thing} ->
        # probably some redirect to the index
        push_redirect(socket, to: Routes....)
        
      {:error, %Ecto.Changeset{} = changeset} ->
          {:noreply, assign(socket, changeset: changeset)}
    end
    
    {:noreply, socket}
  end
end

I create a separate dumb index liveview that has links to the edit view. You will need a route defined for the index view, and two pointing to the “edit” form - one to edit and one to create, e.g.

  live "/admin/units", Admin.Units.UnitsIndexLive
  live "/admin/units/:id/edit", Admin.Units.UnitEditLive
  live "/admin/units/new", Admin.Units.UnitEditLive

Once you get into the swing of it, you can bang them out pretty quickly, but the first one or two will take a little longer while you figure out the recipe.

If you build some basic components for common form controls (e.g. text input with error helper), there doesn’t have to be much boilerplate. You can take a look at some sample components to rapidly build forms here: phoenix/components.ex at master · phoenixframework/phoenix · GitHub (depends on the new version of Phoenix/LiveView). The above example might use a basic text input component like this:

defmodule MyWeb.Live.FormComponents do
  use Phoenix.Component
  import Phoenix.HTML.Form
  import MyWeb.ErrorHelpers  

  def text_input(assigns) do
    f = assigns.form
    field = assigns.field

    assigns = 
      assigns
      |> assign_new(:input_class, fn -> "input w-48" end)
      |> assign_new(:label, fn -> humanize(assigns[:field]) end)
      |> assign_new(:disabled, fn -> false end)
      |> assign_new(:value, fn -> Phoenix.HTML.Form.input_value(f, field) end)
    
    ~H"""
    <div class="mb-2 ml-2">
      <%= label @form, @field, @label, class: "input-lbl" %>
      <%= text_input @form, @field, class: "input w-48", disabled: @disabled, value: @value %>
      <%= error_tag @form, @field %>
    </div>
    """
  end
end

I should add - pretty much the whole lot can be done in a LiveComponent rather than a LiveView, so you can embed a form easily in a larger LiveView. You would have to transfer the code in mount to the LiveComponents update callback and target the events to @myself - see Phoenix.LiveComponent — Phoenix LiveView v0.18.1 - but otherwise it all works

mix phx.gen.live does this, generating a simple form in form_component.ex. You could just take that, embed it where you want and delete the rest.

The generators coming in phoenix 1.7 (on master already) will share the same <.simple_form> component for dead views and LiveViews, and if you specifically want examples of regular forms working inside LV, the phx.gen.auth --live generator uses this approach for the login and registration templates.

4 Likes

This looks like exactly what I need, thanks.