Cartograph
View SourceURI-based navigation for Phoenix LiveView
Installation
Add :cartograph to your list of dependencies in mix.exs:
def deps do
[
{:cartograph, "~> 0.2"}
]
endOverview
This library provides end-to-end relative query param parsing and URI patching for LiveViews.
Unlike most other SPA-style frameworks, LiveView has very good built-in capabilities for holding a page's state in the URL and keeping it in sync with the server, but it provides no built-in utilities for computing relative query param updates.
For example, toggling a single value in a list of multi-selected filters or adding the current page number or size to an existing set of unrelated query params.
The built-in navigation utilities: Phoenix.LiveView.push_patch/2, Phoenix.LiveView.push_navigate/2, Phoenix.LiveView.JS.patch/2, and Phoenix.LiveView.JS.navigate/2 only allow specifying the entire URI on each call, which means the application developer has to roll their own solution for managing these relative param changes.
Cartograph aims to simplify this process and reduce the boilerplate required to hold all of a LiveView's state in the URL without extra client-side JS code.
The Cartograph.LiveViewParams module adds Phoenix.LiveView.handle_event/3 callbacks to the using LiveView that allow relative query patching via the Cartograph.Component.cartograph_patch/1 and Cartograph.Component.cartograph_navigate/2 functions.
These functions compute the relative query param updates from the current URI of the running LiveView and a keyword list of query manipulation operations.
Another use-case of this library is to allow pre-computing the full URI with relative query patching so that the user can copy or bookmark the link from the HTML in the browser. The Cartograph.Component.parse_patch/2 and Cartograph.Component.parse_navigate/2 functions can be used to build the uri for use with a Phoenix.Component.link/1 component's :patch or :navigate attribute.
Performance
Performance should be ideal when using Cartograph.Component.cartograph_patch/1 since this pushes a patch event to the server, which takes advantage of an internal LiveView optimization that calls Phoenix.LiveView.handle_params/3 directly without an additional round-trip.
Cartograph.Component.cartograph_navigate/2 can't take advantage of this optimization, so it incurs the extra round-trip like any other navigate event.
Lastly, use of the Cartograph.Component.parse_patch/2 and Cartograph.Component.parse_navigate/2 functions for pre-rendering the full URI in links can have a negative impact on performance for pages that render thousands of links due to the URI depending on the assigns, which means every link has to be sent to the client on re-render when the URI assign changes. This should be negligible for most pages, so the better UX of copyable links can be safely preferred for pages that don't have thousands of links on them.
Quickstart
The following example shows the basic usage of cartograph's query patching:
defmodule MyApp.ExampleLive do
use Phoenix.LiveView
# use `LiveViewParams` after `Phoenix.LiveView`.
use Cartograph.LiveViewParams
alias Phoenix.LiveView.JS
import Cartograph.Component, only: [cartograph_patch: 1]
@impl true
def handle_params(%{"userroles" => selected_roles} = _params, _uri, socket) do
valid_roles = [:admin, :member]
updated_socket =
socket
|> assign(:selected_roles, Enum.filter(selected_roles, &Enum.member?(valid_roles, &1)))
|> refresh_user_list()
{:noreply, updated_socket}
end
@impl true
def handle_params(%{} = _params, _uri, socket) do
updated_socket =
socket
|> assign(:selected_roles, [])
|> refresh_user_list()
{:noreply, updated_socket}
end
defp refresh_user_list(%Phoenix.LiveView.Socket{assigns: %{selected_roles: []}} = socket) do
stream_async(socket, :user_list, fn ->
{:ok, Repo.all(User), reset: true}
end)
end
defp refresh_user_list(%Phoenix.LiveView.Socket{assigns: %{selected_roles: roles}} = socket) do
stream_async(socket, :user_list, fn ->
res =
from(User)
|> where([user: u], u.role in ^roles)
|> Repo.all()
{:ok, res, reset: true}
end)
end
@impl true
def mount(_params, _session, socket) do
mounted_socket =
socket
|> assign_new(:selected_roles, fn -> [] end)
|> assign_new(:role_choices, fn -> [{:admin, "Admin"}, {:member, "Member"}] end)
{:ok, mounted_socket}
end
def render(assigns) do
~H"""
<section id="role-multi-select">
<details phx-mounted={JS.ignore_attributes(["open"])}>
<summary>
Selected User Roles
</summary>
<div>
<%= for {data_value, display_value} <- @role_choices do %>
<input
type="checkbox"
checked={Enum.member?(@selected_roles, data_value)}
phx-click={cartograph_patch(query: [toggle: %{"userroles[]" => data_value}])}
/>
{display_value}
<br />
<% end %>
</div>
</details>
</section>
"""
end
endThis LiveView allows filtering a list of users by zero or more roles toggled with a multi-select UI.
The array of roles to filter on is maintained in the URL via the userroles[] query param key.
The Cartograph.Component.cartograph_patch/1 function used in the template constructs a new URL query from the existing page's URL with the userroles[] query param for the corresponding value toggled.
In other words, if the current URL is /users, and we call cartograph_patch(query: [toggle: %{"userroles[]" => :admin}]), the resulting URL used in the Phoenix.LiveView.push_patch/2 event will be: /users?userroles[]=admin.
Toggling %{"userroles[]" => :member} on the result of the previous call would give /users?userroles[]=admin&userroles[]=member.
Toggling :admin again would give /users?userroles[]=member.
The :query keyword list supports the following operations:
:set:add:merge:remove:toggle
See the documentation for the Cartograph.Component.query_opts/0 type for details of how each operation can be used.
Cartograph Parsers
Cartograph also provides some conveniences for reducing boilerplate when parsing params.
Let's refactor our example LiveView to use a private function for parsing the selected_roles in order to make our handle_params/3 more extensible:
def parse_selected_roles(socket, %{"userroles" => selected_roles}) do
valid_roles = [:admin, :member]
socket
|> assign(:selected_roles, Enum.filter(selected_roles, &(Enum.member?(valid_roles, &1))))
end
def parse_selected_roles(socket, %{}), do: assign(socket, :selected_roles, [])
@impl true
def handle_params(params, _uri, socket) do
updated_socket =
socket
|> parse_selected_roles(params)
|> refresh_user_list()
{:noreply, updated_socket}
endThis is a good pracice in general, but we can make this more data-driven with the @cartograph_parser module attribute:
@cartograph_parser [
handler: &__MODULE__.parse_params/3,
keys: [:selected_roles],
]
def parse_params(socket, params, :selected_roles), do: parse_selected_params(socket, params)
@impl true
def handle_params(params, _uri, socket), do: {:noreply, refresh_user_list(socket)}The @cartograph_parser module attribute adds the handler function to the cartograph handle_params/3 lifecycle hook and runs it for each of the elements in keys.
The :handler function has the signature Cartograph.CartographParser.param_handler/0. Handler functions take the socket, the params, and an arbitrary atom for matching implementations.
So in our example above, this is roughly equivalent to the following:
def handle_params(params, _uri, socket) do
updated_socket =
socket
|> parse_params(params, :selected_roles)
|> refresh_user_list(socket)
{:noreply, updated_socket}
endIf we added another key e.g. :current_role to the @cartograph_parser keys array, then this would add a call to parse_params(socket, params, :current_role) to the reduction in the handle params lifecycle hook.
If the logic of our handle_params/3 callback can be satisfied entirely with :handler functions, then we can further reduce the boilerplate by providing the handle_params: true option to use Cartograph.LiveViewParams:
use Phoenix.LiveView
use Cartograph.LiveViewParams, handle_params: true
alias Phoenix.LiveView.JS
import Cartograph.Component, only: [cartograph_patch: 1]
@cartograph_parser [
handler: &__MODULE__.parse_params/3,
keys: [:selected_roles],
]
def parse_params(socket, params, :selected_roles), do: parse_selected_params(socket, params)
@impl true
def mount(_params, _session, socket) do
mounted_socket =
socket
|> assign_new(:selected_roles, fn -> [] end)
|> assign_new(:role_choices, fn -> [{:admin, "Admin"}, {:member, "Member"}] end)
{:ok, mounted_socket}
end
def render(assigns) do
~H"""
<section id="role-multi-select">
<details phx-mounted={JS.ignore_attributes(["open"])}>
<summary>
Selected User Roles
</summary>
<div>
<%= for {data_value, display_value} <- @role_choices do %>
<input
type="checkbox"
checked={Enum.member?(@selected_roles, data_value)}
phx-click={cartograph_patch(query: [toggle: %{"userroles[]" => data_value}])}
/>
{display_value}
<br />
<% end %>
</div>
</details>
</section>
"""
endWhen handle_params: true is provided, the Cartograph.LiveViewParams.__using__/1 macro will add a boilerplate implementation of Phoenix.LiveView.handle_params/3 to the using LiveView.
This works well for common query params such as pagination or dynamic filters when defining a "base" LiveView along with helper modules for the shared parsing functions. For example:
defmodule MyApp.BaseLiveView do
use Phoenix.LiveView
use Cartograph.LiveViewParams, handle_params: true
end
defmodule MyApp.QueryHelpers do
import Phoenix.Component, only: [assign: 3]
def parse_params(socket, %{"userroles" => selected_roles}, :selected_roles) do
valid_roles = [:admin, :member]
socket
|> assign(:selected_roles, Enum.filter(selected_roles, &Enum.member?(valid_roles, &1)))
end
def parse_params(socket, %{}, :selected_roles), do: assign(socket, :selected_roles, [])
def parse_params(socket, %{"current_role" => current_role}, :current_role)
when current_role in [:admin, :member] do
assign(socket, :current_role, current_role)
end
def parse_params(socket, %{}, :current_role), do: socket
end
defmodule MyApp.ExampleLiveView do
use MyApp.BaseLiveView
alias Phoenix.LiveView.JS
alias MyApp.QueryHelpers
import Cartograph.Component, only: [cartograph_patch: 1]
@cartograph_parser [
handler: &QueryHelpers.parse_params/3,
keys: [:selected_roles],
]
@impl true
def mount(_params, _session, socket) do
mounted_socket =
socket
|> assign_new(:selected_roles, fn -> [] end)
|> assign_new(:role_choices, fn -> [{:admin, "Admin"}, {:member, "Member"}] end)
{:ok, mounted_socket}
end
def render(assigns) do
~H"""
<section id="role-multi-select">
<details phx-mounted={JS.ignore_attributes(["open"])}>
<summary>
Selected User Roles
</summary>
<div>
<%= for {data_value, display_value} <- @role_choices do %>
<input
type="checkbox"
checked={Enum.member?(@selected_roles, data_value)}
phx-click={cartograph_patch(query: [toggle: %{"userroles[]" => data_value}])}
/>
{display_value}
<br />
<% end %>
</div>
</details>
</section>
"""
end
endSee the api docs for the Cartograph.LiveViewParams and Cartograph.Component modules for detailed documentation.