clemensm

clemensm

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

First 10 of 11 Posts Switch mode

andreaseriksson

andreaseriksson

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.

pedromvieira

pedromvieira

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)

jonathanpglick

jonathanpglick

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!

clemensm

clemensm OP

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.

crova

crova

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.

andreaseriksson

andreaseriksson

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

andreaseriksson

andreaseriksson

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?)

LostKobrakai

LostKobrakai

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.

clemensm

clemensm OP

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.

sntran

sntran

I also like to see that we have a better way of handling this use case. Not everybody will have JS enabled, so we should always try to make a web page accessible without JS first, then enhance it with JS.

This way, your web page will always work. It just works “nicer” when JS is enabled.

Form is a strong example. Form has been working fine without JS. People just forget how we did thing before.

Where Next?

Trending in Questions Top

stjefim
Hello! Suppose you are building workflow (order / task / payment) processing system with the following requirements: Each workflow con...
New
jonnycharles
I’m in search of an Elixir library that offers PDF generation capabilities similar to Ruby’s Prawn. While there have been discussions abo...
New
spammy
I’m looking to build a personal workflow to quickly deploy web applications written in elixir/phoenix, for local consumption (ie not on t...
New
silverdr
Using Phoenix.LiveView.TagEngine as an EEx.Engine is deprecated! To compile HEEx, use Phoenix.LiveView.TagEngine.compile/2 instead. Sta...
New
dli
Before I dive in myself, did anyone successfully sprinkle Hologram into their existing LiveView app? Looking for hints regarding: Addi...
New
bottlenecked
Hi all, I wanted to ask how the community is dealing with post-release steps. Today we have Ecto migrations, which make sure that the db...
New
michallepicki
I am using Oban and occasionally, shortly after a deployment, a handful of jobs can fail because of dependency on other parts of the syst...
New

Other Trending Topics Top

JesseHerrick
Hey, I’m Jesse and I’m the main contributor behind Dexter, a full-featured, lightning-fast Elixir LSP optimized for large codebases. It s...
New
jimsynz
Beam Bots (or just BB for short) is a framework for building fault-tolerant robotics applications in Elixir using familiar OTP patterns. ...
New
Damirados
Hello everyone. After busy few months I am happy to announce v0.1.0 of Emerge &amp; Solve. They are GUI (Emerge) and State management (S...
New
ausimian
Emily is an Elixir library that runs Nx computations on Apple’s MLX. Install it as the default Nx backend and Nx, defn, Axon, Nx.Serving,...
New
type1fool
I just stumbled on a newly redesigned elixir-lang.org. :tada: It looks like @Software_Mansion did the work, and I think it is generally a...
New
akoutmos
@hugobarauna and I (Alex Koutmos) have been hard at work on writing a book on Nerves that takes you from simply blinking LEDs to building...
New

We're in Beta

About us Mission Statement