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!

steffend

steffend

Phoenix Core Team

I mean - a prototype kind of exists with the code I shared - the main thing is coming up with a nice API that makes sense next to the existing JS.navigate and JS.patch functions. If you want to have a go at that, I’m happy to comment on whatever you propose!

GrammAcc

GrammAcc

Yeah, sorry, that’s what I meant by prototype. Something that could be reviewed for api compatibility and all that. I’ll give it a shot. Thanks!

travisgriggs

travisgriggs

Upvote this effort. Wanted just this today.

GrammAcc

GrammAcc

@steffend Was this implemented or is it still in the proposal phase? Would you be willing to accept a PR if the design has been approved? This would be a very helpful feature for a project I’m working on.

Specifically, I am working with array-valued keys in the query string for sorting a table with dynamic order, so the query string has something like sort[]=first_col-asc&sort[]=second_col-desc in it along with other params for things like pagination and dynamic filters. I think in addition to the :query and :drop_query options in your mockup, something like JS.patch(filter_query: fn {key, val} -> key == "sort[]" and !String.starts_with?(val, "first_col") end) would be useful for my use-case. Perhaps also a map_query option that takes a similar function over the {key, value} tuple. That would allow for specifying a filter and a map function to do things like sort order cycling on-click:

JS.patch(
  filter_query: fn {key, val} ->
    key == "sort[] and String.starts_with?(val, "first_col") and !String.ends_with?(val, "desc")
  end,
  map_query: fn {key, val} ->
    if key == "sort[]" and String.starts_with?(val, "first_col") and String.ends_with?(val, "asc") do
      {key, String.replace_suffix(val, "asc", "desc")
    else
      {key, val}
    end
  end,
)

I’m sure there’s a more efficient way to do this kind of thing, but the map/filter approach would probably be useful for other situations as well like removing all params with a common prefix that have been added dynamically by a component.

If that kind of api is not desired, I would still be happy to work on whatever the Phoenix team wants to implement for this feature if no one else has already picked it up. :slight_smile:

Where Next?

Popular in Proposals: Ideas Top

martosaur
TL;DR Logger.Translator acts as a global filter and swallows structure of some OTP reports, which some logger handlers could benefit from...
New
kip
Sumary of proposal DateTime.from_iso8601/3 adjusted to: set all numeric fields to the values as parsed (not shifted to UTC), preservi...
New
dimitarvp
To @jonatanklosko and @the-mikedavis: I see that there is a Rust crate at crates.io: Rust Package Registry but it is pointing at https:/...
New
markevans
Hi! I’m excited about everything that’s going on re. gradual typing and am really pleased to see that Jose and the team seem to be think...
New
sodapopcan
So after complaining about this for the third or fourth time on this forum, I figured I should make a proposal. TL;DR with can be hard t...
New
nhpip
So the other day I was rearranging the supervisor hierarchy of our product and I had a thought. In addition to creating a child spec with...
New
bartblast
This could resolve to {[a: 1, b: 2]}. Was it ever considered to allow such syntax? Notice this: {:abc, a: 1, b: 2} and this: my_fun(:abc,...
New
cevado
IEx is a very powerfull shell and it would be awesome to have all this power integrated inside a code editor. Clojure enables something l...
New
cheerfulstoic
I feel like Elixir is getting big enough and old enough that I’m starting to experience problems with conflicting dependencies. An examp...
New
sezaru
When writing my code, I always find __MODULE__ very useful to use as alias of that module “inner dependencies”, ex: alias __MODULE__.{Im...
New

Other popular topics Top

senggen
Erlang/OTP 25 [erts-13.2.2] [source] [64-bit] [smp:8:8] [ds:8:8:10] [async-threads:1] 15:22:35.803 [error] gen_event {lager_file_backend...
New
bsollish-terakeet
Credo is smart enough to check for (something like) this: assert length(the_list) == 0 with this response: Checking if an enum is empt...
New
JorisKok
I have a server on AWS, and was running a load test using artillery. When looking at the Phoenix dashboard I see the Ports going to 100% ...
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
KronicDeth
Elixir plugin for JetBrain’s IntelliJ Platform (including Rubymine) This is a plugin that adds support for Elixir to JetBrains IntelliJ...
289 35421 110
New
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
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
belgoros
I’m not a pro in using Regex and can’t figure out why the following behaviour happens, especially if we take into account the difference ...
New
Qqwy
Original source of discussion: This topic on the Pragmatic Programmers' Functional Web Development with Elixir, OTP, and Phoenix forum. ...
New

We're in Beta

About us Mission Statement