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:
- Add
:paramsas a way to pass query params toJS.patchandJS.navigate - Add
:values_as_paramsto control whichphx-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:
- Use callbacks functions to generate the JS commands, passing in whatever data is relevant (as mentioned above)
- Use
JS.dispatchalong with a custom event listener inapp.jsto implement the suggested functionality. - We could choose to support custom JS commands instead, something like
JS.customwhere 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
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
Upvote this effort. Wanted just this today.
steffend
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.
Popular in Proposals: Ideas
Other popular topics
Categories:
Sub Categories:
Forums
Popular Tags
- #ecto
- #liveview
- #troubleshooting
- #learning-elixir
- #deployment
- #library
- #erlang
- #testing
- #genserver
- #mix
- #absinthe
- #remote-other
- #otp
- #plug
- #how-to-question
- #macros
- #postgres
- #channels
- #elixirconf
- #exunit
- #discussion
- #code-sync
- #javascript
- #podcasts
- #onsite
- #dialyzer
- #docker
- #authentication
- #umbrella
- #full-time-contract
- #podcasts-by-brainlid
- #ecto-query
- #elixir-ls
- #phoenix_html
- #iex
- #blog-post
- #graphql
- #genstage
- #ai
- #websockets
- #supervisor
- #advent-of-code
- #elixirconf-us
- #distillery
- #processes
- #forms
- #api
- #metaprogramming
- #security
- #performance









