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
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:
- Put the static path as the action into the form, even for the liveview version.
- 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.
- In the router remove the
:editand:newpaths, and replace them withlive_path’s. - In the controller, remove the
:editand:newfunctions, we no longer need them. Also delete thenew.html.eexandedit.html.eextemplates, this will be handled by the LiveView in the future. - In the controller, add a helper module where we can put the duplicate functionality, i.e. funcitons for validation, creation and updates
- 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
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
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
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.
Popular in Questions
Other popular topics
Categories:
Sub Categories:
Forums
Popular Tags
- #ecto
- #liveview
- #troubleshooting
- #learning-elixir
- #deployment
- #library
- #erlang
- #testing
- #genserver
- #mix
- #absinthe
- #remote-other
- #otp
- #plug
- #how-to-question
- #macros
- #postgres
- #channels
- #elixirconf
- #exunit
- #discussion
- #code-sync
- #javascript
- #podcasts
- #onsite
- #dialyzer
- #docker
- #authentication
- #umbrella
- #full-time-contract
- #podcasts-by-brainlid
- #ecto-query
- #elixir-ls
- #phoenix_html
- #iex
- #blog-post
- #graphql
- #genstage
- #ai
- #websockets
- #supervisor
- #advent-of-code
- #elixirconf-us
- #distillery
- #processes
- #forms
- #api
- #metaprogramming
- #security
- #performance









