jakeprem

jakeprem

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?)

Most Liked

steffend

steffend

Phoenix Core Team

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!

travisgriggs

travisgriggs

Upvote this effort. Wanted just this today.

steffend

steffend

Phoenix Core Team

I did not invest the time to send an official proposal (or just do a PR) yet. It’s not that urgent any more as we now have an official way to change the URL from JS using liveSocket.js().patch() / liveSocket.js().navigate(), so the patch_params approach I sent earlier can be done without using private API like this:

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.js().patch(href, { replace: e.detail?.replace });
  } else if (type == "navigate") {
    liveSocket.js().navigate(href, { replace: e.detail?.replace });
  } else {
    document.location.href = href;
  }
});

Now, since the idea of that script is to serialize the operations to do in the JS command, passing a function for map/filter is not something we can do. Instead, you could extend the snippet I shared with a new option, e.g. drop_matching: ["sort[]", "first_col"] and then add extra JS to handle that.

Where Next?

Popular in Proposals: Ideas Top

beno
There appears to be no way to escape whitespace in word lists, so sigil_w (and sigil_W) can only be used for lists of single words. Of co...
New
iaguirre88
Hi, everyone! Sometimes, you might want to build your app using Phoenix just for the backend and use another tool for the frontend (such...
New
boriscy
Hello I really love phoenix 1.8 and I think the new magic link generator is great but I find the remember me function unintuitive. I have...
New
hst337
Elixir compiler and language specification Purpose of the proposal Elixir language is in mature state and no breaking or heavy changes ar...
New
MUSTDOS
Hello all! assign(socket, :name, “Elixir”) Why can’t we have assign/stream take a group of atoms and maps/structs as a default to r...
New
shahryarjb
When proposing or suggesting something please consider: As my experience implementing getBoundingClientRect as Phoenix.LiveView.JS funct...
New
wceolin
I have two proposals - not sure if I should split them into different threads but I think they’re related: Add a new JS.call/2 function...
New
bamorim
Story behind Recently, I gave a talk on a meetup about improving performance of Phoenix applications and the example app was a LiveView ...
New
dvartic
Would there be interest to implement a link/1 that gives to option to operate on other events? So I implemented a simple link/1 function...
New
dibok
Hi, I’m trying to use phoenix.js in my Qt QML project which has it’s own buildin JavaScript engine. Problem is that (what I googled so f...
New

Other popular topics Top

sen
Hi All, I set a environment variables in dev.exs , like below code. when i start server, how can i set the ${enable} value? thanks. d...
New
malloryerik
Hi, this is for people who, like me, have had some friction using .html.heex templates in VSCode. The solution seems to be, in a hyphena...
New
Harrisonl
We have an ECS cluster with 4 services, where each task joins a single cluster, via discovery ECS discovery service. Currently when I de...
New
mcarvalho
What is the difference between System.get_env and Application.get_env? For example, what are best practices to use one versus another.
New
Nvim
Anybody knows a comprehensive comparison of Django and Phoenix, thanks for the help. Where are they similar? Where do they differ the m...
New
JeremM34
Hello, how can I check the Phoenix version ? Thanks !
New
shahryarjb
Hello, I have map which I want to convert it to string like this: the map: %{last_name: "tavakkoli", name: "shahryar"} the string I ne...
New
stefanluptak
Hello everybody, usually, I use a 29" ultra-wide monitor for VSCode which can easily accomodate explorer (files panel) + file with code ...
New
alice
Hey, Just curious what are the main benefits of Elixir compared to Clojure? When is Elixir more useful than Clojure and vice versa? Th...
New
svb
Hi! Currently I want to submit a form by pressing the Enter key. However, since my input field is of type “textarea” this is just adds a...
New

Latest on Elixir Forum

Elixir Forum

We're in Beta

About us Mission Statement