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

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
Asd
Process.resolve/1 What it does Takes any possible name of the process and returns a pid of the process or port (or nil if none found). Si...
New
jakeprem
Goal: To make JS.patch and JS.navigate more interoperable with JS.push. Scenario: Imagine making a reusable Phoenix component and you wa...
New
mudasobwa
I am not sure it deserves to be discussed in the mailing list, so I’d start here. assert/1 and/or refute/1 macros print the following er...
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
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
pejrich
I propose adding compact_map/2 to the Enum module. What is it? Sometimes you want to map over a collection, but sometimes you want to ma...
New
Oliver
One common problem we face in constructing lists is that there is (AFAIK) no support for conditionally inserting members into list declar...
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
dkuku
This is a proposal to make the map key mismatch errors a bit better: Every time I have a typo It’s very challenging for me even when I u...
New

Other popular topics Top

TunkShif
This post is an instruction guide to help you setup your Neovim for Elixir development from scratch. It includes general information on h...
273 38985 115
New
stefanchrobot
What’s the safe way to decode a JSON string into a struct? I want to avoid calling String.to_atom. Jason.decode can give me a map with st...
New
peerreynders
Manning 2016 Halloween weekend sale via Deal of the Day Friday, October 28 - Half off all MEAPs - code WM102816LT Saturday, October 29 ...
326 29600 154
New
dotdotdotPaul
Okay, I'm having a heck of a time trying to figure out how to best handle the validation of belongs_to associations in Ecto. I'm sure I'...
New
danschultzer
None of the current solutions worked well for me, so I went ahead and built a user management system from scratch. This project took far...
548 27727 240
New
aadeshere1
I have a another noob question about loop. Since elixir is immutable, while loop is not directly possible. total = 10 while total != 0 ...
New
vonH
When I run the Plug and I recompile I wind up having to use Ctrl C to quit iex and start again. Witht the help of rlwrap I can use the cu...
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
msaraiva
Surface is an experimental library built on top of Phoenix LiveView and its new LiveComponent API that aims to provide a more declarative...
564 42633 214
New
electic
Hi, I am new to Elixir. I am trying to use the DateTime component to insert a date into MySQL however the there seems to be no way to fo...
New

We're in Beta

About us Mission Statement