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

Marked As Solved

clemensm

clemensm

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.

Also Liked

clemensm

clemensm

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.

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!

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.

Where Next?

Popular in Questions Top

siddhant3030
Hi, I have to write a raw query for one of my project. But till now I have used ecto queries and don’t have much experience writing raw ...
New
Darmani72
If I have a post route which an argument: post /my_post_route/:my_param1, MyController.my_post_handler How would get the post params ...
New
chrisalley
ExUnit now has describe blocks which is a welcome addition coming from RSpec. In the docs, it states that nested hierarchies of describe ...
New
mgjohns61585
Could someone help me? I’m making my first elixir program, number guessing game. I can’t figure out how to convert the user’s guess from ...
New
jerry
Good day to you all. I have been struggling to get a query involving like and ilike to work. Can anyone assist me on this, please? pro...
New
jay1
Why is it that the mnesia database isn’t the most preferred database for use in Elixir/Phoenix?
New
freewebwithme
Using vs code and installed ElixirLS: support and debugger. And I got an error popped up on start up says Failed to run ‘elixir’ comma...
New
jason.o
In the code below, if the create action is not set to accept “extra_key” as an input, it errors out with a message shown above. Is there ...
New
chensan
I have a User schema with a :from_id field set to type :string: defmodule TweetBot.Repo.Migrations.CreateUsers do use Ecto.Migration ...
New
JorisKok
I have a server on AWS, and was running a load test using artillery. When looking at the Phoenix dashboard I see the Ports going to 100% ...
New

Other popular topics Top

sen
Hi All, I set a environment variables in dev.exs , like below code. when i start server, how can i set the ${enable} value? thanks. d...
New
albydarned
Hello all! I am typing this post from my new MacBook Pro with the M1 chip. I’m loving it so far, and will probably use it as my daily dr...
New
skosch
To my knowledge, put_in, Map.update etc. all have the one limitation of not automatically creating intermediate keys when needed (for exa...
New
greenz1
I have a phoenix application from which a user can download multiple(5-6) files of size 1MB. I couldn’t find anything related to sending ...
New
stefanchrobot
What’s the safe way to decode a JSON string into a struct? I want to avoid calling String.to_atom. Jason.decode can give me a map with st...
New
jason.o
In the code below, if the create action is not set to accept “extra_key” as an input, it errors out with a message shown above. Is there ...
New
dblack
I’ve got an issue with an app and I’ve no idea of how to troubleshoot it. I’m hoping someone here might have seen something similar. I p...
New
nsuchy
Hi. I’ve noticed that Windows Powershell has it’s own IEX command and you cannot access Elixir’s IEX due to the conflict. This isn’t a cr...
New
JakeBecker
TL;DR: I’ve just released an implementation of Microsoft’s IDE-independent Language Server Protocol for Elixir. It adds language support ...
1144 54120 245
New
lanycrost
Hi everyone! I need implement if…else if…else condition from my elixir code, and anymore of this control flow structures not work proper...
New

Latest on Elixir Forum

Elixir Forum

We're in Beta

About us Mission Statement