Andrew Timberlake Andrew Timberlake

Hi, I’m Andrew, a programer and entrepreneur from South Africa, founder of Sitesure for monitoring websites, APIs, and background jobs.
Thanks for visiting and reading.


How to update URL params in a Phoenix LiveView

I was working on a project that displayed data tables that need to be filtered in a number of different ways. I added a filter toggle to make it easy to select a state to filter the table by and then wanted a way to update the URL with the selected filter state. Updating the URL is important because then the page can be refreshed or shared with the filter(s) in tact. Alongside the filter toggle, I also had a search field that allowed the user to search for values in the data. Both needed to work together, search and state filtering.

I first reached for JS.patch/1 which seemed to be what I needed, but it requires the full path to be passed in and doesn’t have a mechanism for manipulating the query string parameters. This makes it a bit more difficult to build multiple independent components that don’t have knowledge of the page they’re operating in. Also, I needed a way to add or update a single query parameter regardless of how many other parameters are in the URL.

Leaning on how Phoenix handles javascript events, I added my own click handler at-click and then added custom attributes at-param and at-value. The at- prefix is just my initials and borrows from the way Phoenix uses the phx- prefix for its attributes.

This now allows for the following code:

<button at-click="update-param" at-param="state" at-value="active">Update param</button>

The Javascript event handler

This is where most of the magic happens. A global event listener listens for clicks and checks if the click target has the required at-click, at-param, and at-value attributes. If it does, then it manipulates the URL querystring to add or edit the parameter and sends the new URL via the livesocket so it can be processed by handle_params/3 within the LiveView.

I also added support for list/array parameters by checking if the parameter name ends in []. If it does, then the value is added to the list. This is useful for tags or other multi-select fields.

Paste this into your app.js file just below other window.addEventListener handlers.

// Handle at-click events
// at- is a prefix for custom attributes (like phx- is for Phoenix attributes)
window.addEventListener('click', (event) => {
  const target = event.target;
  const click = target.getAttribute('at-click');
  switch (click) {
    case 'update-param': {
      const param = target.getAttribute('at-param');
      const value = target.getAttribute('at-value');
      // Must have both param and value to update the URL
      if (!param || !value) {
        return;
      }
      const queryString = new URLSearchParams(window.location.search);
      // If a list parameter, append the value to the list
      if (param.endsWith('[]')) {
        let values = queryString.getAll(param);
        values.push(value);
        values = values.filter((v, i, a) => a.indexOf(v) === i);
        queryString.delete(param);
        values.forEach((v) => queryString.append(param, v));
      } else {
        // Otherwise, set the value
        queryString.set(param, value);
      }
      // Update the url with the new query string
      const url = new URL(window.location);
      url.search = queryString.toString();
      // Push the new URL which will invoke handle_params/3 in the LiveView
      liveSocket.pushHistoryPatch(url.toString(), 'push');
    }
    default:
      // no-op
      undefined;
  }
});

A live example

This entire post is an active LiveView that demonstrates the filter toggle and tag list components. The code for the components and the handle_params code is below.

Click on a filter state to see the state parameter update in the URL (the default state is "active").

ID Name State
2 Lysandra active
5 Thalassa active
9 Elowen active

Click on the available tags and see the tags[] parameters added to the URL and the tags list grow.

Selected tags:
Select tags:

The LiveView components

@doc """
## Example:

    <.filter_toggle param="state" value={@state}>
      <:button value="all">All</:button>
      <:button value="pending">Pending</:button>
      <:button value="active">Active</:button>
      <:button value="inactive">Inactive</:button>
      <:button value="deleted">Deleted</:button>
    </.filter_toggle>
"""
attr :param, :string, required: true, doc: "the parameter to update"
attr :value, :string, required: true, doc: "the current value of the parameter"
attr :class, :string, default: nil

slot :button, required: true do
  attr :value, :string, required: true, doc: "the value to set the parameter to"
end

def filter_toggle(assigns) do
  ~H"""
  <div class={[
    "inline-flex overflow-hidden text-xs border rounded-full filter-toggle whitespace-nowrap",
    @class
  ]}>
    <button
      :for={button <- @button}
      type="button"
      class={["px-3 py-2", if(@value == button[:value], do: "bg-blue-200")]}
      at-click="update-param"
      at-value={button[:value]}
      at-param={@param}
    >
      <%= render_slot button %>
    </button>
  </div>
  """
end
@doc """
## Example:

    <.tag_list param="tag[]" tags={@tags} available_tags={~w(elixir phoenix liveview javascript)} />
"""
attr :param, :string, required: true, doc: "the parameter to update"
attr :tags, :list, required: true, doc: "the current selected tags"
attr :available_tags, :list, required: true, doc: "the avaiable tags to select"

def tag_list(assigns) do
  ~H"""
  <div class="flex gap-2">
    Selected tags:
    <div class="flex gap-1">
      <span :for={tag <- @tags} class="px-2 py-1 text-xs bg-blue-200 border border-blue-400 rounded">
        <%= tag %>
      </span>
    </div>
  </div>

  <div class="text-sm">
    Select tags:
    <button
      :for={tag <- @available_tags}
      type="button"
      at-click="update-param"
      at-param="tags[]"
      at-value={tag}
      class="px-2 py-1 text-white bg-gray-500 border rounded"
    >
      <%= tag %>
    </button>
  </div>
  """
end

The LiveView handle_params function

This adds the parameter values to the socket assigns with default values when no parameter exists.

def handle_params(params, _uri, socket) do
  {:noreply,
   assign(socket,
     state: Map.get(params, "state", "active"),
     tags: Map.get(params, "tags", [])
   )}
end

Conclusion

I hope this was useful to you. If anything in this post isn’t clear, please send me an email and I’ll do my best to help—and update the post from the feedback ;-).

7 Jun 2024