# Cartograph
URI-based navigation for Phoenix LiveView
[Hex Docs](https://hexdocs.pm/cartograph)
## Installation
Add `:cartograph` to your list of dependencies in `mix.exs`:
```elixir
def deps do
[
{:cartograph, "~> 0.1"}
]
end
```
## Overview
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 `c: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 `c: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:
```elixir
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
end
```
This 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 `t: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:
```elixir
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}
end
```
This is a good pracice in general, but we can make this more data-driven with the `@cartograph_parser` module attribute:
```elixir
@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 `t: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:
```elixir
def handle_params(params, _uri, socket) do
updated_socket =
socket
|> parse_params(params, :selected_roles)
|> refresh_user_list(socket)
{:noreply, updated_socket}
end
```
If 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>
"""
end
```
When `handle_params: true` is provided, the `Cartograph.LiveViewParams.__using__/1` macro will add a boilerplate implementation of `c: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
end
```
See the api docs for the `Cartograph.LiveViewParams` and `Cartograph.Component` modules for detailed documentation.