How to use live_view as a standin for CRUD forms, falling back to default controller behaviour if JS is disabled

Hello everyone

I’ve recently started digging into phoenix LiveView, and so far I really like what I am seeing. Currently I am trying to enhanve a simple CRUD form with LiveView to get live validation, but so that this is optional and the site will still work if JavaScript has been disabled. From what I understand that’s one of the use cases for using LiveView, so here’s my problem:

The LiveView is being called from inside my phoenix controller using live_render, like so:

defmodule SomeController do
  def new(conn, _params) do 
    live_render(conn, SomeLiveView)
  end
end

and the SomeLiveView can easily create an empty changeset and pass that to the .leex template:

defmodule SomeLiveView do
...
  def mount(_param, _session, socket) do
    changeset = SomeSchema.new_changeset()
    {:ok, assign(socket, changeset: changeset, endpoint: socket.endpoint)}
  end
end
<%= form_for @changeset, Routes.some_path(@endpoint, :create), [phx_change: :validate], fn f -> %>
 ...
<% end %>

And everything works fine, and on submit (also with JS disabled) the usual :create path is called in the SomeController.

Howerver this is where I’ve got my problem: The :create method in the SomeController will validate the form again, and it’s possible that for some reason it cannot be saved. Usually we’d simply render the form again with the changeset and the errors, however I do not understand how this can be done. The naive version of simply passing the changeset as a param does not seem to work, because the LiveView is not connected to the router directly:

defmodule SomeController do
  def create(conn, %{"some" => params}) do
    case SomeSchema.create(params) do
      ...
      {:error, %Ecto.Changeset{} = changeset} ->     
        live_render(conn, SomeLiveView, params: %{changeset: changeset})
    end
  end

If I do this, the param argument of mount is only :not_mounted_at_router. Of course I could try to pass the changeset in the session instead, however the documentation is very clear that this is not a good idea as that data would be serialised and sent to the client.

I also now about the user demo app, but there they create a LiveView for each path (which seems quite a lot of duplication to me), and they have no option to fallback to standard phoenix controllers if JS is disabled.

So my question is: Has anybody else solved this problem already, or knows what the solution looks like?

Thanks

Clemens

6 Likes

Honestly, why do you make it this complicated? If the user has JS disabled, basically every web page would be broken. This is considered an extrem edgecase IMO.

I had the same thought. Is it even possible to run Phoenix basic form without JS? (Ok, It can be created manually with “pure” html)

I applaud your effort to make this work both with and without JS. People seem to forget that forms can really do a lot on their own. And it’s a great exercise for accessibility and other issues like low-bandwidth or spotty connections.

I’m curious to see what you come up with since I’ve wanted to use LiveView for a similar “up-casting” of basic forms!

2 Likes

Well, turning off JS is probably the most efficient way to stop everyone tracking you across the web.

And I don’t see why my pages should be broken for people who decide that JS on by default is a bad choice, just because everyone else thinks it’s fine that a blog with only static content should be a completely blank page if I turn of JS.

3 Likes

I strongly agree with both previous posts.

Lately I’ve been looking for a similar idea with cookies and content depended on them.

If you find a conclusive solution for you problem I would love to hear about it.

1 Like

Sure, but why do you need LiveView for static content?

However reading you question in the first post again, I would consider mounting the LiveView in the templates instead of the controller and only use it for the UX that you need (live validation?)

Instead of sending the changeset to the liveview you could send the submitted params and create the changeset within the liveview again. But do you really expect a form, which was submitted because of no JS to suddenly have JS? I personally would just stick to a non liveview version until the submission was successful.

So after reading through the replies I believe that I’ve come up with a workable solution that can be used as a standin, but it does require a bunch of changes in different places, so let’s start with an overview of what need’s to be done:

  1. Put the static path as the action into the form, even for the liveview version.
  2. Add a hook in your JS code that replaces the action with “#”. Thus if JS is enabled we’ll stay in the LiveView, otherwise we’ll fallback to the default route.
  3. In the router remove the :edit and :new paths, and replace them with live_path's.
  4. In the controller, remove the :edit and :new functions, we no longer need them. Also delete the new.html.eex and edit.html.eex templates, this will be handled by the LiveView in the future.
  5. In the controller, add a helper module where we can put the duplicate functionality, i.e. funcitons for validation, creation and updates
  6. Use the code from the helper module in both the Controller and the LiveView.

I.e. if we take the example from my question, we need all these (I’ve left out the validation step, as that is rather trivial to add):

defmodule SomeWeb.Router do
  ...
  scope "/", SomeWeb do
    ...
    # make sure that the live paths are above the resources, otherwise
    # they won't match
    live "/notes/:id/edit", SomeLiveView
    live "/notes/new", SomeLiveView
    resources "/notes", NoteController, except: [:new, :edit]
  end
end
defmodule SomeWeb.SomeController do
  # Contains the duplicated code and some helpers so that we generate the same 
  # responses/redirects. If we need to do this more often, we can probably move
  # most of it to a macro and simply generate the boilerplate code
  defmodule Helpers do
    alias Phoenix.LiveView.Socket
    alias Plug.Conn
    alias SomeWeb.Router.Helpers, as: Routes

    # We need those so that we can have the same redirect code for both 
    # Socket and Conn
    alias Phoenix.Controller, as: PC
    alias Phoenix.LiveView, as: PV

    def create(conn_or_socket, some_params) do
      case SomeContext.create(some_params) do
        {:ok, some_schema} -> 
          reply(conn_or_socket, {:ok, some_schema, "Created successfully."})

        {:error, %Ecto.Changeset{} = changeset} ->
          reply(conn_or_socket, {:error, "new.html", changeset: changeset})
      end
    end

    def update(conn_or_socket, some_id, some_params) do
      some_schema = Notes.get_note!(some_id)

      case SomeContext.update(some_schema, some_params) do
        {:ok, some_schema} ->
          reply(conn_or_socket, {:ok, some_schema, " Updated successfully."})

        {:error, %Ecto.Changeset{} = changeset} ->
          reply(conn_or_socket, {:error, "edit.html", changeset: changeset})
      end
    end

    defp reply(%Conn{} = conn, {:error, template, args}), do:
      PC.render(conn, template, args)
    defp reply(%Socket{} = socket, {:error, _template, args}), do:
      {:noreply, PV.assign(socket, args)}

    defp reply(%Conn{} = conn, {:ok, some_schema, msg}), do: 
      redirect_success(conn, some_schema, msg, &PC.put_flash/3, &PC.redirect/2)
    defp reply(%Socket{} = socket, {:ok, some_schema, msg}), do: 
      {:stop, redirect_success(socket, some_schema, msg, &PV.put_flash/3, &PV.redirect/2)}

    defp redirect_success(conn_or_socket, some_schema, msg, put_flash, redirect) do
      conn_or_socket
      |> put_flash.(:info, msg)
      |> redirect.(to: Routes.some_path(conn_or_socket, :show, some_schema))
    end
  end
  
  ...
  alias FastNotesWeb.NoteController.Helpers

  def create(conn, %{"note" => note_params}), do: 
    Helpers.create(conn, note_params)

  def update(conn, %{"id" => id, "note" => note_params}), do: 
    Helpers.update(conn, id, note_params)
  ...
end
defmodule SomeLiveView do
  ...

  def mount(%{"id" => id}, session, socket) do
    changeset = SomeContext.get_changeset!(id)
    do_mount(changeset, session, socket, :update)
  end

  def mount(%{}, session, socket), do:
    do_mount(SomeContext.empty_changeset(), session, socket, :create)

  defp do_mount(changeset, _session, socket, action) do
    {:ok, assign(socket, endpoint: socket.endpoint, changeset: changeset, action: action)}
  end

  def handle_event("submit", %{"some" => some_params}, socket) do
    case socket.assigns.action do
      :update -> 
        SomeWeb.SomeController.Helpers.update(socket, socket.assigns.changeset.data.id, some_params)
      :create ->
        SomeWeb.SomeController.Helpers.create(socket, some_params)
    end
  end
  
  ...
end
some_live_view.html.leex

<% action_path = case @action do 
  :create -> Routes.note_path(@endpoint, :create)
  :update -> Routes.note_path(@endpoint, :update, @changeset.data.id)
end %>
<%= render "form.html", Map.put(assigns, :action, action_path) %>

<span><%= link "Back", to: Routes.some_path(@endpoint, :index) %></span>
form.html.eex

<%= form_for @changeset, @action, [phx_change: :validate, phx_submit: :submit, phx_hook: "formHook"], fn f -> %>
  ...
<% end %>

app.js

import {Socket} from "phoenix"
import LiveSocket from "phoenix_live_view"

const hooks = {
    formHook: {
        mounted() {
            this.el.setAttribute("action", "#");
        }
    }
}

const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content");
const liveSocket = new LiveSocket("/live", Socket, {params: {_csrf_token: csrfToken}, hooks: hooks});
liveSocket.connect();

Sorry for the rather long post, but so far this seems like the cleanest solution to me. Tested this with JS active/deactivated in the browser, and is working fine for me. The only thing left to do is to wrap all the boilerplate stuff about reply etc in the Helper into a macro so that we can simply write that stuff and only provide the functions for validation/creation/updates.