Merge pull request #601 from metosin/feature/update-query

Fix #600: Add frontend function to update query-params for current path
This commit is contained in:
Juho Teperi 2023-03-24 14:01:06 +02:00 committed by GitHub
commit 2a3e382df1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 233 additions and 35 deletions

View file

@ -2,8 +2,16 @@
Reitit includes two browser history integrations. Reitit includes two browser history integrations.
Functions follow HTML5 History API: `push-state` to change route, `replace-state` Main functions are `navigate` and `set-query`. Navigate is used to navigate
to change route without leaving previous entry in browser history. to named routes, and the options parameter can be used to control all
parameters and if `pushState` or `replaceState` should be used to control
browser history stack. The `set-query` function can be used to change
or modify query parameters for the current route, it takes either map of
new query params or function from old params to the new params.
There are also secondary functions following HTML5 History API:
`push-state` to navigate to new route adding entry to the history and
`replace-state` to change route without leaving previous entry in browser history.
## Fragment router ## Fragment router

View file

@ -19,10 +19,17 @@
[:ul [:ul
[:li [:a {:href (rfe/href ::item {:id 1})} "Item 1"]] [:li [:a {:href (rfe/href ::item {:id 1})} "Item 1"]]
[:li [:a {:href (rfe/href ::item {:id 2} {:foo "bar"})} "Item 2"]]] [:li [:a {:href (rfe/href ::item {:id 2} {:foo "bar"})} "Item 2"]]]
(if id (when id
[:h2 "Selected item " id]) [:h2 "Selected item " id])
(if (:foo query) [:p "Query params: " [:pre (pr-str query)]]
[:p "Optional foo query param: " (:foo query)])])) [:ul
[:li [:a {:on-click #(rfe/set-query {:a 1})} "set a=1"]]
[:li [:a {:on-click #(rfe/set-query {:a 2} {:replace true})} "set a=2 and replaceState"]]
[:li [:a {:on-click (fn [_] (rfe/set-query #(assoc % :foo "zzz")))} "add foo=zzz"]]]
[:button
{:on-click #(rfe/navigate ::item {:path-params {:id 3}
:query-params {:foo "aaa"}})}
"Navigate example, go to item 3"]]))
(defonce match (r/atom nil)) (defonce match (r/atom nil))
@ -31,9 +38,8 @@
[:ul [:ul
[:li [:a {:href (rfe/href ::frontpage)} "Frontpage"]] [:li [:a {:href (rfe/href ::frontpage)} "Frontpage"]]
[:li [:li
[:a {:href (rfe/href ::item-list)} "Item list"] [:a {:href (rfe/href ::item-list)} "Item list"]]]
]] (when @match
(if @match
(let [view (:view (:data @match))] (let [view (:view (:data @match))]
[view @match])) [view @match]))
[:pre (with-out-str (fedn/pprint @match))]]) [:pre (with-out-str (fedn/pprint @match))]])
@ -63,7 +69,8 @@
["/:id" ["/:id"
{:name ::item {:name ::item
:parameters {:path {:id s/Int} :parameters {:path {:id s/Int}
:query {(s/optional-key :foo) s/Keyword}} :query {(s/optional-key :a) s/Int
(s/optional-key :foo) s/Keyword}}
:controllers [{:parameters {:path [:id]} :controllers [{:parameters {:path [:id]}
:start (fn [{:keys [path]}] :start (fn [{:keys [path]}]
(js/console.log "start" "item controller" (:id path))) (js/console.log "start" "item controller" (:id path)))

View file

@ -1,25 +1,41 @@
(ns reitit.frontend (ns reitit.frontend
(:require [clojure.set :as set] (:require [clojure.set :as set]
[reitit.coercion :as coercion] [reitit.coercion :as coercion]
[reitit.core :as r]) [reitit.core :as r]
(:import goog.Uri goog.Uri
goog.Uri.QueryData)) goog.Uri.QueryData))
(defn- query-param [^QueryData q k] (defn- query-param [^goog.uri.QueryData q k]
(let [vs (.getValues q k)] (let [vs (.getValues q k)]
(if (< (alength vs) 2) (if (< (alength vs) 2)
(aget vs 0) (aget vs 0)
(vec vs)))) (vec vs))))
(defn query-params (defn query-params
"Given goog.Uri, read query parameters into Clojure map." "Given goog.Uri, read query parameters into a Clojure map."
[^Uri uri] [^goog.Uri uri]
(let [q (.getQueryData uri)] (let [q (.getQueryData uri)]
(->> q (->> q
(.getKeys) (.getKeys)
(map (juxt keyword #(query-param q %))) (map (juxt keyword #(query-param q %)))
(into {})))) (into {}))))
(defn set-query-params
"Given Reitit-frontend path, update the query params
with given function and arguments.
Note: coercion is not applied to the query params"
[path new-query-or-update-fn]
(let [^goog.Uri uri (goog.Uri/parse path)
new-query (if (fn? new-query-or-update-fn)
(new-query-or-update-fn (query-params uri))
new-query-or-update-fn)]
;; NOTE: Differences to reitit.impl/query-string?
;; reitit fn adds "=" even if value is empty string
;; reitit encodes " " as "+" while browser and goog.Uri encode as "%20"
(.setQueryData uri (goog.Uri.QueryData/createFromMap (clj->js new-query)))
(.toString uri)))
(defn match-by-path (defn match-by-path
"Given routing tree and current path, return match with possibly "Given routing tree and current path, return match with possibly
coerced parameters. Return nil if no match found. coerced parameters. Return nil if no match found.
@ -27,7 +43,7 @@
:on-coercion-error - a sideeffecting fn of `match exception -> nil`" :on-coercion-error - a sideeffecting fn of `match exception -> nil`"
([router path] (match-by-path router path nil)) ([router path] (match-by-path router path nil))
([router path {:keys [on-coercion-error]}] ([router path {:keys [on-coercion-error]}]
(let [uri (.parse Uri path) (let [uri (.parse goog.Uri path)
coerce! (if on-coercion-error coerce! (if on-coercion-error
(fn [match] (fn [match]
(try (coercion/coerce! match) (try (coercion/coerce! match)

View file

@ -101,3 +101,44 @@
(rfh/replace-state @history name path-params nil)) (rfh/replace-state @history name path-params nil))
([name path-params query-params] ([name path-params query-params]
(rfh/replace-state @history name path-params query-params))) (rfh/replace-state @history name path-params query-params)))
;; This duplicates previous two, but the map parameter will be easier way to
;; extend the functions, e.g. to work with fragment string. Toggling push vs
;; replace can be also simpler with a flag.
;; Navigate and set-query are also similer to react-router API.
(defn
^{:see-also ["reitit.frontend.history/navigate"]}
navigate
"Updates the browser location and either pushes new entry to the history stack
or replaces the latest entry in the the history stack (controlled by
`replace` option) using URL built from a route defined by name given
parameters.
Will also trigger on-navigate callback on Reitit frontend History handler.
Note: currently collections in query-parameters are encoded as field-value
pairs separated by &, i.e. \"?a=1&a=2\", if you want to encode them
differently, convert the collections to strings first.
See also:
https://developer.mozilla.org/en-US/docs/Web/API/History/pushState
https://developer.mozilla.org/en-US/docs/Web/API/History/replaceState"
([name]
(rfh/navigate @history name))
([name {:keys [path-params query-params replace] :as opts}]
(rfh/navigate @history name opts)))
(defn
^{:see-also ["reitit.frontend.history/set-query"]}
set-query
"Update query parameters for the current route.
New query params can be given as a map, or a function taking
the old params and returning the new modified params.
Note: The query parameter values aren't coereced, so the
update fn will see string values for all query params."
([new-query-or-update-fn]
(rfh/set-query @history new-query-or-update-fn))
([new-query-or-update-fn {:keys [replace] :as opts}]
(rfh/set-query @history new-query-or-update-fn opts)))

View file

@ -3,15 +3,15 @@
events." events."
(:require [goog.events :as gevents] (:require [goog.events :as gevents]
[reitit.core :as reitit] [reitit.core :as reitit]
[reitit.frontend :as rf]) [reitit.frontend :as rf]
(:import goog.Uri)) goog.Uri))
(defprotocol History (defprotocol History
(-init [this] "Create event listeners") (-init [this] "Create event listeners")
(-stop [this] "Remove event listeners") (-stop [this] "Remove event listeners")
(-on-navigate [this path]) (-on-navigate [this path] "Find a match for current routing path and call on-navigate callback")
(-get-path [this]) (-get-path [this] "Get the current routing path")
(-href [this path])) (-href [this path] "Converts given routing path to browser location"))
;; This version listens for both pop-state and hash-change for ;; This version listens for both pop-state and hash-change for
;; compatibility for old browsers not supporting History API. ;; compatibility for old browsers not supporting History API.
@ -78,7 +78,7 @@
the page location is updated using History API." the page location is updated using History API."
[router e el uri] [router e el uri]
(let [current-domain (if (exists? js/location) (let [current-domain (if (exists? js/location)
(.getDomain (.parse Uri js/location)))] (.getDomain (.parse goog.Uri js/location)))]
(and (or (and (not (.hasScheme uri)) (not (.hasDomain uri))) (and (or (and (not (.hasScheme uri)) (not (.hasDomain uri)))
(= current-domain (.getDomain uri))) (= current-domain (.getDomain uri)))
(not (.-altKey e)) (not (.-altKey e))
@ -109,7 +109,7 @@
ignore-anchor-click (fn [e] ignore-anchor-click (fn [e]
;; Returns the next matching ancestor of event target ;; Returns the next matching ancestor of event target
(when-let [el (closest-by-tag (event-target e) "a")] (when-let [el (closest-by-tag (event-target e) "a")]
(let [uri (.parse Uri (.-href el))] (let [uri (.parse goog.Uri (.-href el))]
(when (ignore-anchor-click-predicate router e el uri) (when (ignore-anchor-click-predicate router e el uri)
(.preventDefault e) (.preventDefault e)
(let [path (str (.getPath uri) (let [path (str (.getPath uri)
@ -177,7 +177,9 @@
(if history (if history
(-stop history))) (-stop history)))
(defn href (defn
^{:see-also ["reitit.core/match->path"]}
href
"Generate a URL for a route defined by name, with given path-params and query-params. "Generate a URL for a route defined by name, with given path-params and query-params.
The URL is formatted using Reitit frontend history handler, so using it with The URL is formatted using Reitit frontend history handler, so using it with
@ -219,7 +221,9 @@
(.pushState js/window.history nil "" (-href history path)) (.pushState js/window.history nil "" (-href history path))
(-on-navigate history path)))) (-on-navigate history path))))
(defn replace-state (defn
^{:see-also ["reitit.core/match->path"]}
replace-state
"Updates the browser location and replaces latest entry in the history stack "Updates the browser location and replaces latest entry in the history stack
using URL built from a route defined by name, with given path-params and using URL built from a route defined by name, with given path-params and
query-params. query-params.
@ -241,3 +245,50 @@
path (reitit/match->path match query-params)] path (reitit/match->path match query-params)]
(.replaceState js/window.history nil "" (-href history path)) (.replaceState js/window.history nil "" (-href history path))
(-on-navigate history path)))) (-on-navigate history path))))
(defn
^{:see-also ["reitit.core/match->path"]}
navigate
"Updates the browser location and either pushes new entry to the history stack
or replaces the latest entry in the the history stack (controlled by
`replace` option) using URL built from a route defined by name given
parameters.
Will also trigger on-navigate callback on Reitit frontend History handler.
Note: currently collections in query-parameters are encoded as field-value
pairs separated by &, i.e. \"?a=1&a=2\", if you want to encode them
differently, convert the collections to strings first.
See also:
https://developer.mozilla.org/en-US/docs/Web/API/History/pushState
https://developer.mozilla.org/en-US/docs/Web/API/History/replaceState"
([history name]
(navigate history name nil))
([history name {:keys [path-params query-params replace] :as opts}]
(let [match (rf/match-by-name! (:router history) name path-params)
path (reitit/match->path match query-params)]
(if replace
(.replaceState js/window.history nil "" (-href history path))
(.pushState js/window.history nil "" (-href history path)))
(-on-navigate history path))))
(defn
^{:see-also ["reitit.frontend/set-query-params"]}
set-query
"Update query parameters for the current route.
New query params can be given as a map, or a function taking
the old params and returning the new modified params.
Note: The query parameter values aren't coereced, so the
update fn will see string values for all query params."
([history new-query-or-update-fn]
(set-query history new-query-or-update-fn nil))
([history new-query-or-update-fn {:keys [replace] :as opts}]
(let [current-path (-get-path history)
new-path (rf/set-query-params current-path new-query-or-update-fn)]
(if replace
(.replaceState js/window.history nil "" (-href history new-path))
(.pushState js/window.history nil "" (-href history new-path)))
(-on-navigate history new-path))))

View file

@ -6,7 +6,21 @@
[schema.core :as s] [schema.core :as s]
[reitit.coercion.schema :as rcs] [reitit.coercion.schema :as rcs]
[reitit.coercion.malli :as rcm] [reitit.coercion.malli :as rcm]
[reitit.frontend.test-utils :refer [capture-console]])) [reitit.frontend.test-utils :refer [capture-console]]
[reitit.impl :as impl]))
(deftest query-params-test
(is (= {:foo "1"}
(rf/query-params (.parse goog.Uri "?foo=1"))))
(is (= {:foo "1" :bar "aaa"}
(rf/query-params (.parse goog.Uri "?foo=1&bar=aaa"))))
(is (= {:foo ""}
(rf/query-params (.parse goog.Uri "?foo="))))
(is (= {:foo ""}
(rf/query-params (.parse goog.Uri "?foo")))))
(defn m [x] (defn m [x]
(assoc x :data nil :result nil)) (assoc x :data nil :result nil))
@ -227,3 +241,44 @@
:token_type "bearer" :token_type "bearer"
:expires_in 3600}}}) :expires_in 3600}}})
(m (rf/match-by-path router "/5?mode=foo#access_token=foo&refresh_token=bar&provider_token=baz&token_type=bearer&expires_in=3600")))))))) (m (rf/match-by-path router "/5?mode=foo#access_token=foo&refresh_token=bar&provider_token=baz&token_type=bearer&expires_in=3600"))))))))
(deftest set-query-params-test
(is (= "foo?bar=1"
(rf/set-query-params "foo" {:bar 1})
(rf/set-query-params "foo" #(assoc % :bar 1))
;; Also compare to reitit.impl version which is used by match->path (and history fns)
(str "foo?" (impl/query-string {:bar 1}))))
(testing "Encoding"
(is (= "foo?bar=foo%20bar"
(rf/set-query-params "foo" {:bar "foo bar"})
(rf/set-query-params "foo" #(assoc % :bar "foo bar"))
;; FIXME: Reitit.impl encodes space as "+"
; (str "foo?" (impl/query-string {:bar "foo bar"}))
)))
(testing "Keep fragment"
(is (= "foo?bar=1&zzz=2#aaa"
(rf/set-query-params "foo?bar=1#aaa" #(assoc % :zzz 2)))))
(is (= "foo?asd=1&bar=1"
(rf/set-query-params "foo?asd=1" #(assoc % :bar 1))))
(is (= "foo?bar=1"
(rf/set-query-params "foo?asd=1&bar=1" #(dissoc % :asd))))
(is (= "foo?bar"
(rf/set-query-params "foo?asd=1&bar" #(dissoc % :asd))))
(is (= "foo?bar"
(rf/set-query-params "foo" #(assoc % :bar ""))
;; FIXME: Reitit.impl adds "=" for empty string values
; (str "foo?" (impl/query-string {:bar ""}))
))
(is (= "foo"
(rf/set-query-params "foo?asd=1" #(dissoc % :asd))))
(testing "Need to coerce current values manually"
(is (= "foo?foo=2"
(rf/set-query-params "foo?foo=1" (fn [q] (update q :foo #(inc (js/parseInt %)))))))))

View file

@ -30,33 +30,53 @@
(is (= "/" url) (is (= "/" url)
"start at root") "start at root")
(rfe/push-state ::foo)) (rfe/push-state ::foo))
;; 0. /
;; 1. /foo
2 (do (is (= "/foo" url) 2 (do (is (= "/foo" url)
"push-state") "push-state")
(.back js/window.history)) (.back js/window.history))
;; 0. /
3 (do (is (= "/" url) 3 (do (is (= "/" url)
"go back") "go back")
(rfe/push-state ::bar {:id 1})) (rfe/navigate ::bar {:path-params {:id 1}}))
;; 0. /
;; 1. /bar/1
4 (do (is (= "/bar/1" url) 4 (do (is (= "/bar/1" url)
"push-state 2") "push-state 2")
(rfe/replace-state ::bar {:id 2})) (rfe/replace-state ::bar {:id 2}))
;; 0. /
;; 1. /bar/2
5 (do (is (= "/bar/2" url) 5 (do (is (= "/bar/2" url)
"replace-state") "replace-state")
(.back js/window.history)) (rfe/set-query {:a 1}))
6 (do (is (= "/" url) ;; 0. /
"go back after replace state") ;; 1. /bar/2
;; 2. /bar/2?a=1
6 (do (is (= "/bar/2?a=1" url)
"update-query with map")
(rfe/set-query #(assoc % :b "foo") {:replace true}))
;; 0. /
;; 1. /bar/2
;; 2. /bar/2?a=1&b=foo
7 (do (is (= "/bar/2?a=1&b=foo" url)
"update-query with fn")
(.go js/window.history -2))
;; 0. /
8 (do (is (= "/" url)
"go back two events")
;; Reset to ensure old event listeners aren't called ;; Reset to ensure old event listeners aren't called
(rfe/start! router (rfe/start! router
(fn on-navigate [match history] (fn on-navigate [match history]
(let [url (rfh/-get-path history)] (let [url (rfh/-get-path history)]
(case (swap! n inc) (case (swap! n inc)
7 (do (is (= "/" url) 9 (do (is (= "/" url)
"start at root") "start at root")
(rfe/push-state ::foo)) (rfe/push-state ::foo))
8 (do (is (= "/foo" url) 10 (do (is (= "/foo" url)
"push-state") "push-state")
(rfh/stop! @rfe/history) (rfh/stop! @rfe/history)
(done)) (done))
(do (do
(is false (str "extra event 2" {:n @n, :url url})) (is false (str "extra event 2" {:n @n, :url url}))
(done))))) (done)))))