README.md

# 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.