Skip to content

Instantly share code, notes, and snippets.

@joenoon
Last active January 10, 2020 09:09
Show Gist options
  • Save joenoon/98e4cd57e6e9b312c24eadad2de0a144 to your computer and use it in GitHub Desktop.
Save joenoon/98e4cd57e6e9b312c24eadad2de0a144 to your computer and use it in GitHub Desktop.
my liveview setup
i have one MainLive live_view defined in router as live "/*path", MainLive . then i created
a router_live.ex which looks sort of like a normal router.ex with all the live routes, but
they all point to live_components. and that router is not used in the endpoint, its purely
to get the functionality with route matching and Routes.live_path etc. my MainLive
handle_params does a URI.parse(url) and then Phoenix.Router.route_info(MyAppWeb.RouterLive,
"GET", uri.path, uri.authority) and i come out with a route that i pass down. my MainLive
render does a header/footer and in the middle something like live_component(assigns.socket,
route.live_view, id: route.route, store: store) (route here is what i created in
handle_params… route.live_view is the LiveComponent defined in the router_live.ex for the
route that matched). ive liked how it worked out so far. i also have an “AppState” that i
pass down (thats the store assign), and it allows to keep the store/session at the top and
not recreate/relookup current user etc when navigating… basically like a react app.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta http-equiv="X-UA-Compatible" content="IE=edge"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<link rel="stylesheet" href="<%= Routes.static_path(@conn, "/css/app.css") %>"/>
<%= csrf_meta_tag() %>
<link rel="icon" type="image/png" href="<%= Routes.static_path(@conn, "/images/favicon-32x32.png") %>" sizes="32x32" />
<link rel="icon" type="image/png" href="<%= Routes.static_path(@conn, "/images/favicon-16x16.png") %>" sizes="16x16" />
</head>
<body id="body_tag">
<div id="phx_root">
<%= render @view_module, @view_template, assigns %>
</div>
<script>
window._live_flash = <%= raw Jason.encode!(get_flash(@conn)) %>
</script>
<script type="text/javascript" src="<%= Routes.static_path(@conn, "/js/app.js") %>"></script>
</body>
</html>
import { Browser, LiveSocket } from 'phoenix_live_view';
...
Hooks.NewSession = {
mounted() {
const { el } = this;
// console.log('NewSession mounted', el.id);
Browser.setCookie('__phoenix_flash__', el.id + '; max-age=60000; path=/');
},
};
Hooks.PageURLHook = {
// on url change
// - blur the active element
// - scroll to top
updated() {
const { el, prev, __view } = this;
const cur = el.dataset.url;
const same = prev === cur;
// console.log('PageURLHook update', prev, 'vs', cur, 'same', same);
this.prev = cur;
if (!same) {
__view.liveSocket.blurActiveElement();
window.scrollTo(0, 0);
}
},
};
...
const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute('content');
const liveSocket = new LiveSocket('/live', Socket, {
hooks: Hooks,
params: { _csrf_token: csrfToken, _live_flash: window._live_flash },
});
liveSocket.connect();
defmodule MyAppWeb.AppState.Macros do
defmacro subscribable(key, load_assigns: load_assigns_bool) do
if load_assigns_bool do
quote do
def subscribe(%{assigns: %{store: %{unquote(key) => val}}} = socket, unquote(key))
when val != nil,
do: assign(socket, unquote(key), val)
def subscribe(socket, unquote(key)), do: load_assigns(socket, unquote(key))
end
else
quote do
def subscribe(%{assigns: %{store: %{unquote(key) => val}}} = socket, unquote(key)),
do: assign(socket, unquote(key), val)
end
end
end
end
defmodule MyAppWeb.AppState do
import MyAppWeb.AppState.Macros
require Logger
use GenServer
alias MyApp.{Accounts, User, Repo}
import Phoenix.LiveView, only: [assign: 2, assign: 3]
defmodule State do
defstruct [
:connected?,
:root_pid,
:session,
:new_session,
:flash,
:flash_seen,
:route,
:current_user
]
end
def start_link(socket, opts) do
{initial_state, _} = Keyword.pop(opts, :state, %{})
name = via_tuple(socket)
state =
%State{root_pid: root_pid(socket), connected?: socket.connected?}
|> Map.merge(initial_state)
|> Map.from_struct()
state =
%Phoenix.LiveView.Socket{}
|> assign(state)
|> Phoenix.LiveView.Utils.clear_changed()
GenServer.start_link(__MODULE__, state, name: name)
end
@impl true
def init(%{} = state) do
Process.flag(:trap_exit, true)
{:ok, state}
end
def subscribe(socket) do
state = GenServer.call(via_tuple(socket), :state)
socket
|> assign(:store, state.assigns)
end
subscribable(:current_user, load_assigns: true)
subscribable(:root_pid, load_assigns: false)
subscribable(:session, load_assigns: false)
subscribable(:new_session, load_assigns: false)
subscribable(:flash_seen, load_assigns: false)
subscribable(:route, load_assigns: false)
subscribable(:flash, load_assigns: false)
defp load_assigns(socket, key) do
state = GenServer.call(via_tuple(socket), {:load, key})
socket
|> assign(:store, state.assigns)
|> assign(key, state.assigns[key])
end
defp via_tuple(socket),
do: {:via, Registry, {MyAppWeb.AppStateRegistry, root_pid(socket)}}
@impl true
def terminate(reason, state) do
Logger.debug(
"[#{if state.assigns.connected?, do: "LIVE", else: "SSR"}] #{inspect(self())} AppState terminated: #{
inspect(reason)
}"
)
:ok
end
defp root_pid(socket) do
socket.root_pid || self()
end
def put(socket, key, value) do
state = GenServer.call(via_tuple(socket), {:put, key, value})
socket
|> assign(:store, state.assigns)
|> assign(key, value)
end
def reset_session(socket, session) do
signed = Phoenix.LiveView.Utils.sign_flash(socket, %{"reset_session" => session})
state = GenServer.call(via_tuple(socket), {:reset_session, signed, session})
socket
|> Phoenix.LiveView.put_flash("reset_session", session)
|> assign(:store, state.assigns)
end
def put_flash(socket, key, value) do
state = GenServer.call(via_tuple(socket), {:put_flash, key, value})
socket
|> Phoenix.LiveView.put_flash(key, value)
|> assign(:store, state.assigns)
|> assign(:flash, state.assigns.flash)
|> assign(:flash_seen, state.assigns.flash_seen)
end
def clear_flash(socket, key) do
state = GenServer.call(via_tuple(socket), {:clear_flash, key})
socket
|> socket_delete_flash(key)
|> assign(:store, state.assigns)
|> assign(:flash, state.assigns.flash)
end
def clear_flash(socket) do
state = GenServer.call(via_tuple(socket), {:clear_flash})
socket
|> socket_delete_flash()
|> assign(:store, state.assigns)
|> assign(:flash, state.assigns.flash)
end
def clear_next_flash(socket) do
socket
|> socket_delete_flash()
end
defp socket_delete_flash(%{private: %{flash: flash} = private} = socket, key)
when is_map(flash) and is_binary(key) do
%{socket | private: Map.put(private, :flash, Map.delete(flash, key))}
end
defp socket_delete_flash(socket, _key), do: socket
defp socket_delete_flash(%{private: private} = socket) do
%{socket | private: Map.delete(private, :flash)}
end
@impl true
def handle_call({:reset_session, signed, session}, _from, state) do
Logger.debug("AppState handle_call {:reset_session, #{inspect(session)}}")
broadcast!(state, "reset_session")
new_assigns =
state.assigns
|> Map.put(:session, session)
|> Map.put(:new_session, signed)
|> Map.put(:flash_seen, false)
|> Map.put(:current_user, nil)
|> case do
new_assigns ->
if new_assigns.session["user_id"] == state.assigns.session["user_id"] &&
state.assigns.current_user do
new_assigns
|> Map.put(:current_user, state.assigns.current_user)
else
new_assigns
end
end
new_state = assign(state, new_assigns)
{:reply, new_state, new_state}
end
@impl true
def handle_call({:load, :current_user}, _from, state) do
broadcast!(state, "load current_user")
state = load_current_user(state)
{:reply, state, state}
end
@impl true
def handle_call(:state, _from, state) do
{:reply, state, state}
end
@impl true
def handle_call({:put, key, value}, _from, state) do
broadcast!(state, "put #{key}")
new_state = assign(state, key, value)
{:reply, new_state, new_state}
end
@impl true
def handle_call({:put_flash, key, value}, _from, state) do
broadcast!(state, "put_flash #{key}")
new_state =
state
|> assign(:flash, Map.put(state.assigns.flash, key, value))
|> assign(:flash_seen, false)
{:reply, new_state, new_state}
end
@impl true
def handle_call({:clear_flash, key}, _from, state) do
broadcast!(state, "clear_flash #{key}")
new_state = assign(state, :flash, Map.delete(state.assigns.flash, key))
{:reply, new_state, new_state}
end
@impl true
def handle_call({:clear_flash}, _from, state) do
broadcast!(state, "clear_flash")
new_state = assign(state, :flash, %{})
{:reply, new_state, new_state}
end
@impl true
def handle_info({:broadcast, reason}, %{changed: changed, assigns: %{connected?: true}} = state)
when changed != %{} do
Logger.debug(
"[LIVE] [broadcast to #{inspect(state.assigns.root_pid)}] #{reason} #{
inspect(state.changed)
}"
)
send(state.assigns.root_pid, {:updated_store, state.assigns})
state =
state
|> Phoenix.LiveView.Utils.clear_changed()
{:noreply, state}
end
@impl true
def handle_info({:broadcast, _reason}, state) do
# Logger.debug(
# "[#{if state.assigns.connected?, do: "LIVE", else: "SSR"}] [broadcast noop] #{reason}"
# )
{:noreply, state}
end
defp load_current_user(
%{assigns: %{current_user: nil, session: %{"user_id" => user_id}}} = state
)
when user_id != nil do
state
|> assign(:current_user, Accounts.get_user_by_id(user_id))
end
defp load_current_user(state), do: state
defp broadcast!(%{assigns: %{connected?: true}}, reason) do
send(self(), {:broadcast, reason})
end
defp broadcast!(_, _), do: nil
end
# List all child processes to be supervised
children = [
{Registry, keys: :unique, name: MyAppWeb.AppStateRegistry},
...
]
defmodule MyAppWeb.ExampleComponent do
use MyAppWeb, :live_component
def update(assigns, socket) do
socket
|> assign(assigns)
|> AppState.subscribe(:current_user)
|> okreply()
end
def render(assigns) do
~L"""
<div><%= inspect(@current_user) %></div>
"""
end
end
defmodule MyAppWeb.FlashComponent do
use MyAppWeb, :live_component
def update(assigns, socket) do
socket
|> assign(assigns)
|> AppState.subscribe(:flash)
|> mark_as_seen()
|> okreply()
end
defp mark_as_seen(%{connected?: true, assigns: %{flash: flash}} = socket)
when flash != %{} do
socket
|> AppState.put(:flash_seen, true)
end
defp mark_as_seen(socket) do
socket
end
def handle_event("delete_flash", %{"flash_key" => key}, socket) do
socket
|> AppState.clear_flash(key)
|> noreply()
end
def render(assigns) do
~L"""
<div>
<%= if @flash["info"] do %>
<div class="container is-fluid app--flash">
<div class="notification is-success">
<button phx-click="delete_flash" phx-value-flash_key="info" class="delete"></button>
<%= @flash["info"] %>
</div>
</div>
<% end %>
<%= if @flash["error"] do %>
<div class="container is-fluid app--flash">
<div class="notification is-danger">
<button phx-click="delete_flash" phx-value-flash_key="error" class="delete"></button>
<%= @flash["error"] %>
</div>
</div>
<% end %>
</div>
"""
end
end
defmodule MyAppWeb.LiveHelpers do
require Logger
import Phoenix.LiveView, only: [redirect: 2, assign: 3, live_redirect: 2]
def execute_on_main(socket, fun) do
send(socket.root_pid || self(), {:execute_on_main, fun})
socket
end
def smart_redirect_on_main(socket, opts) do
socket
|> assign(:did_redirect, true)
|> execute_on_main(fn main_socket ->
main_socket
|> case do
%{connected?: true} = main_socket ->
live_redirect(main_socket, opts)
main_socket ->
redirect(main_socket, opts)
end
end)
end
def handle_params_reply(socket) do
case socket do
%{redirected: {:redirect, _}} -> {:stop, socket}
_ -> {:noreply, socket}
end
end
def noreply(socket), do: {:noreply, socket}
def okreply(socket), do: {:ok, socket}
def require_assigns(socket, keys) do
Enum.each(keys, fn k ->
case socket.assigns do
%{^k => _} -> nil
_ -> raise "Missing required assign in #{inspect(socket.assigns[:route])}: #{k}"
end
end)
socket
end
def ensure_user(%{assigns: %{current_user: user}} = socket) when user != nil,
do: assign(socket, :allowed, true)
def ensure_user(socket) do
execute_on_main(socket, fn main_socket ->
Logger.warn("ensure_user: Access to page denied. You have been redirected.")
main_socket
|> redirect(to: "/")
|> MyAppWeb.AppState.put_flash(
"error",
"Please log in to continue."
)
end)
socket
|> assign(:allowed, false)
end
def copy_assign(socket, from_key, to_key) do
socket
|> assign(to_key, socket.assigns[from_key])
end
end
defmodule MyAppWeb.LiveView.Session do
@moduledoc """
Joe Noon on 9/3/2019
LiveView does not have a direct way to change the session, but does provide a way to set flash.
The application can use `put_flash(socket, "reset_session", %{...})`, and this plug will make that
the persisted session.
"""
@behaviour Plug
def init(opts), do: opts
def call(conn, _) do
conn
|> handle_session()
|> handle_reset_session()
end
def handle_session(
%{
private: %{
plug_session: plug_session
}
} = conn
) do
conn
|> Plug.Conn.put_private(:plug_session, merge_session(plug_session))
end
def handle_reset_session(
%{
private: %{
plug_session: plug_session
}
} = conn
) do
conn
|> Plug.Conn.put_private(:plug_session, reset_session(plug_session))
end
### Util functions
defp merge_session(%{"phoenix_flash" => %{"session" => %{} = new_session} = flash} = session) do
flash = Map.delete(flash, "session")
session
|> Map.put("phoenix_flash", flash)
|> Map.merge(new_session)
end
defp merge_session(session), do: session
defp reset_session(%{"phoenix_flash" => %{"reset_session" => %{} = new_session} = flash}) do
flash = Map.delete(flash, "reset_session")
new_session
|> Map.put("phoenix_flash", flash)
end
defp reset_session(session), do: session
end
defmodule MyAppWeb.MainLive do
require Logger
use MyAppWeb, :live_view
def mount(session, socket) do
AppState.start_link(socket, state: %{session: session, flash: get_flash(socket)})
socket =
socket
|> AppState.subscribe()
{:ok, socket}
end
defp get_flash(%{connected?: false, private: %{conn_session: %{"phoenix_flash" => flash}}})
when is_map(flash),
do: Map.take(flash, ["info", "error"])
defp get_flash(%{connected?: true, private: %{connect_params: %{"_live_flash" => flash}}})
when is_map(flash),
do: Map.take(flash, ["info", "error"])
defp get_flash(_), do: %{}
defp cleanup_flash(%{assigns: %{flash_seen: true}} = socket) do
Logger.debug("[#{if socket.connected?, do: "LIVE", else: "SSR"}] [flash] cleanup now")
socket
|> AppState.clear_flash()
end
defp cleanup_flash(socket) do
Logger.debug("[#{if socket.connected?, do: "LIVE", else: "SSR"}] [flash] cleanup next")
socket
|> AppState.clear_next_flash()
end
def handle_params(params, url, socket) do
uri = URI.parse(url)
case Phoenix.Router.route_info(MyAppWeb.RouterLive, "GET", uri.path, uri.authority) do
:error ->
socket
|> redirect(to: "/")
|> handle_params_reply()
route_info ->
full_params =
Map.delete(params, "path")
|> Map.merge(route_info.path_params)
route = %{
route: route_info.route,
live_view: route_info.plug_opts,
params: full_params,
uri: uri,
url: url
}
Logger.debug(
"[#{if socket.connected?, do: "LIVE", else: "SSR"}] [handle_params] url=#{route.url}"
)
socket
|> AppState.put(:route, route)
|> AppState.subscribe(:flash_seen)
|> cleanup_flash()
|> handle_params_reply()
end
end
def handle_info({:execute_on_main, fun}, socket) when is_function(fun, 1) do
fun.(socket)
|> handle_params_reply()
end
def handle_info({:updated_store, store}, socket) do
socket
|> assign(:store, store)
|> noreply()
end
def render(%{route: route, store: store} = assigns) do
~L"""
<%= live_component(@socket, MyAppWeb.NavbarComponent, id: "NavbarComponent", store: @store) %>
<%= live_component(@socket, MyAppWeb.FlashComponent, id: "FlashComponent", store: @store) %>
<main id="main">
<%=
live_component(assigns.socket, route.live_view,
id: route.route, store: store
)
%>
</main>
<%= Phoenix.View.render MyAppWeb.LayoutView, "footer.html", assigns %>
<%= if store.new_session do %>
<div id="<%= store.new_session %>" phx-hook="NewSession"></div>
<% end %>
<div id="page-url-hook" data-url="url-<%= safe_id(route.url) %>" phx-hook="PageURLHook"></div>
"""
end
def render(assigns), do: ~L(<div></div>)
end
defmodule MyAppWeb do
@moduledoc """
The entrypoint for defining your web interface, such
as controllers, views, channels and so on.
This can be used in your application as:
use MyAppWeb, :controller
use MyAppWeb, :view
The definitions below will be executed for every view,
controller, etc, so keep them short and clean, focused
on imports, uses and aliases.
Do NOT define functions inside the quoted expressions
below. Instead, define any helper function in modules
and import those modules here.
"""
def controller do
quote do
use Phoenix.Controller, namespace: MyAppWeb
import Plug.Conn
import MyAppWeb.Gettext
alias MyAppWeb.Router.Helpers, as: Routes
import Phoenix.LiveView.Controller, only: [live_render: 3]
end
end
def view do
quote do
use Phoenix.View,
root: "lib/my_app_web/templates",
namespace: MyAppWeb
# Import convenience functions from controllers
import Phoenix.Controller, only: [get_flash: 1, get_flash: 2, view_module: 1]
# Use all HTML functionality (forms, tags, etc)
use Phoenix.HTML
import MyAppWeb.HTMLHelpers
import MyAppWeb.ErrorHelpers
import MyAppWeb.Gettext
alias MyAppWeb.RouterLive.Helpers, as: Routes
alias MyAppWeb.AppState
import Phoenix.LiveView,
only: [
live_render: 2,
live_render: 3,
live_link: 1,
live_link: 2,
live_component: 3,
live_component: 4
]
end
end
def live_component do
quote do
use Phoenix.LiveComponent
use Phoenix.HTML
import MyAppWeb.LiveHelpers
import MyAppWeb.HTMLHelpers
import MyAppWeb.ErrorHelpers
import MyAppWeb.Gettext
alias MyAppWeb.RouterLive.Helpers, as: Routes
alias MyAppWeb.AppState
end
end
def live_view do
quote do
use Phoenix.LiveView
use Phoenix.HTML
import MyAppWeb.LiveHelpers
import MyAppWeb.HTMLHelpers
import MyAppWeb.ErrorHelpers
import MyAppWeb.Gettext
alias MyAppWeb.RouterLive.Helpers, as: Routes
alias MyAppWeb.AppState
end
end
def router do
quote do
use Phoenix.Router
import Plug.Conn
import Phoenix.Controller
import Phoenix.LiveView.Router
end
end
def channel do
quote do
use Phoenix.Channel
import MyAppWeb.Gettext
end
end
@doc """
When used, dispatch to the appropriate controller/view/etc.
"""
defmacro __using__(which) when is_atom(which) do
apply(__MODULE__, which, [])
end
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment