Add `:params` opt to JS.patch and JS.navigate, and opt to merge `phx-value-*`

Goal: To make JS.patch and JS.navigate more interoperable with JS.push.

Scenario: Imagine making a reusable Phoenix component and you want to allow the user of your component to customize its interactive behavior. The JS module already gives us good tools for this, but if you want to allow for customized navigation events you have to use a callback function to generate the JS command (as demonstrated by the row_click attr in the default CoreComponents table.

This is different from push, which does support values, while patch and navigate don’t receive the values from phx-value or support setting their own values. This makes sense contextually as an event push is different from navigation.

With a slight modification we could allow users of .patch and .navigate to opt in to receiving the same phx-value-* as JS.push. Then in our component example above, the user of the component would have full control over the behavior without introducing additional complexity via callback functions.

Suggestion:

  1. Add :params as a way to pass query params to JS.patch and JS.navigate
  2. Add :values_as_params to control which phx-value-* should be included as query params. Defaults to false (or empty list?).

Examples:

cmd_1 = JS.patch("/things", values_as_params: true)

~H"""
<!--- a bunch of posts -->
<button phx-click={cmd_1} phx-value-page="2" phx-value-size="10">Next Page</button>
"""

Clicking the button would do a patch to /things?page=2&size=10.

cmd_2 = JS.patch("/things", values_as_params: [:page])

~H"""
<!--- a bunch of posts -->
<button phx-click={cmd_1} phx-value-page="2" phx-value-size="10">Next Page</button>
"""

Clicking the button would do a patch to /things?page=2.

cmd_3 = JS.patch("/things", params: %{size: 100}, values_as_params: [:page]

~H"""
<!--- a bunch of posts -->
<button phx-click={cmd_1} phx-value-page="2" phx-value-size="10">Next Page</button>
"""

Goes to /things?page=2&size=100
Using :params the user of the component can override or introduce their own params.

This would significantly simplify designing data driven components like tables. Users could easily reuse the same components for purely ephemeral uses using JS.push and more durable ones using JS.patch without having to change the component code.

I’d be happy to help work on an implementation if anyone else sees value in this.

Alternative approaches:

  1. Use callbacks functions to generate the JS commands, passing in whatever data is relevant (as mentioned above)
  2. Use JS.dispatch along with a custom event listener in app.js to implement the suggested functionality.
  3. We could choose to support custom JS commands instead, something like JS.custom where the user would register custom commands similar to how hooks are defined in the socket. (Basically a more streamlined version of alternative 2 above). This approach could also allow further customizations that are out of scope for the core LiveView library like supporting path params in the Javascript commands like /things/:id

Sketch of alternative 3 above:

const commands = {
  ParamPatch(view, el, href, opts) {
     const finalHref = determineHref(href, opts)
    # or a path param version might do
    # const finalHref = subsititutePathParams("/things/:id", {id: 5}
     view.liveSocket.pushHistoryPatch(finalHref, ... etc)
   }
}
let liveSocket = new LiveSocket("/live", Socket, { commands, ...}
  JS.custom_command("ParamPatch", opts?)
2 Likes

Hey @jakeprem,

I was planning to send a proposal to support modifying query parameters through JS.patch/navigate, for example like this:

JS.patch("?foo=bar") # -> sets current page's query parameters to foo=bar
JS.patch(query: %{"foo" => "bar"}, merge_query: true) # -> sets query parameter foo=bar and keeps existing
JS.patch(drop_query: ["foo"]) # -> removes the foo key from query paramters

I think modifying only parts of the URL is important for building complex components relying on URL state, therefore merging and dropping parameters, but keeping unrelated ones should be something we support in my opinion.

In a previous project I did something like this:

def patch_params(%JS{} = js, params, opts) do
  opts = Keyword.validate!(opts, [:type, :path, :remove_params])

  JS.dispatch(
    js,
    "patch-params",
    detail:
      Map.merge(
        %{params: Plug.Conn.Query.encode(params)},
        Map.new(opts)
      )
  )
end
window.addEventListener("link:patch-params", (e) => {
  const type = e.detail?.type || "patch";
  const path = e.detail?.path || document.location.pathname;
  const params = new URLSearchParams(e.detail?.params || "");
  const removeParams = e.detail?.remove_params || [];

  const searchParams = new URLSearchParams(document.location.search);
  Array.from(params.keys()).forEach((key) => searchParams.delete(key));
  Array.from(params.entries()).forEach(([key, value]) => {
    searchParams.append(key, value);
  });
  removeParams.forEach((key) => {
    searchParams.delete(key);
  });
  const search = searchParams.toString();
  const href = search ? (path + "?" + search) : path;
  if (type == "patch") {
    liveSocket.pushHistoryPatch(e, href, replace ? "replace" : "push", e.target);
  } else if (type == "navigate") {
    liveSocket.historyRedirect(e, href, replace ? "replace" : "push");
  } else {
    document.location.href = href;
  }
});

But this also relies on private liveSocket APIs (pushHistoryPatch/historyRedirect), so we should have an official way to do this.

To give some more light on the underlying issue: if you build a complex component that relies on URL state, for example a table with multiple filters (like selected countries, etc.), each filter needs a way to update the URL correctly. One approach that is often used is to store the parameters in the assigns and then construct the link on the server:

def handle_params(params, _uri, socket) do
  socket
  |> assign(:params, params)
  |> assign_country_filter(params)
  |> ...
  |> then(&{:ok, &1})
end

def render(assigns) do
  ~H"""
  ...
  <div id="country-filter">
    <.link :for={country <- @all_countries} patch={~p"/my-live-view?#{Map.merge(@params, %{"country" => country.iso})"}>{country.name}</.link>
  </div>
  ...
  """
end

This approach works, but has the big downside of creating huge diffs over the wire, re-sending each link whenever any parameter on the page changes.

So I’m not sure yet about overloading phx-value-* for JS.patch/navigate, but I’m definitely in favor for a way to manage query parameters without having to deal with the whole URL!

2 Likes