Merge pull request #118 from metosin/frontend-routing-3

Frontend routing 3
This commit is contained in:
Juho Teperi 2018-07-24 14:02:53 +03:00 committed by GitHub
commit ba10470fbb
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 262 additions and 166 deletions

View file

@ -84,8 +84,7 @@
(swap! match (fn [old-match] (swap! match (fn [old-match]
(if new-match (if new-match
(assoc new-match :controllers (rfc/apply-controllers (:controllers old-match) new-match)))))) (assoc new-match :controllers (rfc/apply-controllers (:controllers old-match) new-match))))))
{:use-fragment true {:use-fragment true})
:path-prefix "/"})
(r/render [current-page] (.getElementById js/document "app"))) (r/render [current-page] (.getElementById js/document "app")))
(init!) (init!)

View file

@ -10,7 +10,17 @@
(defn home-page [] (defn home-page []
[:div [:div
[:h2 "Welcome to frontend"]]) [:h2 "Welcome to frontend"]
[:button
{:type "button"
:on-click #(rfe/push-state ::item {:id 3})}
"Item 3"]
[:button
{:type "button"
:on-click #(rfe/replace-state ::item {:id 4})}
"Replace State Item 4"] ])
(defn about-page [] (defn about-page []
[:div [:div
@ -62,8 +72,7 @@
(defn init! [] (defn init! []
(rfe/start! routes (rfe/start! routes
(fn [m] (reset! match m)) (fn [m] (reset! match m))
{:use-fragment true {:use-fragment true})
:path-prefix "/"})
(r/render [current-page] (.getElementById js/document "app"))) (r/render [current-page] (.getElementById js/document "app")))
(init!) (init!)

View file

@ -6,7 +6,20 @@
(defonce history (atom nil)) (defonce history (atom nil))
;; Doc-strings from reitit.frontend.history
;; remember to update both!
(defn start! (defn start!
"This registers event listeners on HTML5 history and hashchange events.
When using with development workflow like Figwheel, rememeber to
remove listeners using stop! call before calling start! again.
Parameters:
- router The Reitit routing tree.
- on-navigate Function to be called when route changes. Takes two parameters, ´token´ and ´history´ object.
Options:
- :use-fragment (default true) If true, onhashchange and location hash are used to store the token."
[routes on-navigate opts] [routes on-navigate opts]
(swap! history (fn [old-history] (swap! history (fn [old-history]
(rfh/stop! old-history) (rfh/stop! old-history)
@ -14,26 +27,26 @@
(defn href (defn href
([k] ([k]
(rfh/href @history k)) (rfh/href @history k nil nil))
([k params] ([k params]
(rfh/href @history k params)) (rfh/href @history k params nil))
([k params query] ([k params query]
(rfh/href @history k params query))) (rfh/href @history k params query)))
(defn set-token (defn push-state
"Sets the new route, leaving previous route in history." "Sets the new route, leaving previous route in history."
([k] ([k]
(rfh/set-token @history k)) (rfh/push-state @history k nil nil))
([k params] ([k params]
(rfh/set-token @history k params)) (rfh/push-state @history k params nil))
([k params query] ([k params query]
(rfh/set-token @history k params query))) (rfh/push-state @history k params query)))
(defn replace-token (defn replace-state
"Replaces current route. I.e. current route is not left on history." "Replaces current route. I.e. current route is not left on history."
([k] ([k]
(rfh/replace-token @history k)) (rfh/replace-state @history k nil nil))
([k params] ([k params]
(rfh/replace-token @history k params)) (rfh/replace-state @history k params nil))
([k params query] ([k params query]
(rfh/replace-token @history k params query))) (rfh/replace-state @history k params query)))

View file

@ -4,41 +4,69 @@
[goog.events :as e] [goog.events :as e]
[reitit.core :as r] [reitit.core :as r]
[reitit.frontend :as rf] [reitit.frontend :as rf]
[reitit.impl :as impl]) [reitit.impl :as impl]
(:import goog.history.Html5History [goog.events :as gevents])
goog.Uri)) (:import goog.Uri))
;; Token is for Closure HtmlHistory (defprotocol History
;; Path is for reitit (-init [this] "Create event listeners")
(-stop [this] "Remove event listeners")
(-on-navigate [this path])
(-get-path [this])
(-href [this path]))
(defn- token->path [history token] ;; This version listens for both pop-state and hash-change for
(if (.-useFragment_ history) ;; compatibility for old browsers not supporting History API.
;; If no fragment at all, default to "/" (defrecord FragmentHistory [on-navigate router listen-key last-fragment]
;; If fragment is present, the token already is prefixed with "/" History
(if (= "" token) (-init [this]
(.getPathPrefix history) ;; Link clicks and e.g. back button trigger both events, if fragment is same as previous ignore second event.
token) ;; For old browsers only the hash-change event is triggered.
(str (.getPathPrefix history) token))) (let [last-fragment (atom nil)
this (assoc this :last-fragment last-fragment)
handler (fn [e]
(let [path (-get-path this)]
(when (or (= goog.events.EventType.POPSTATE (.-type e))
(not= @last-fragment path))
(-on-navigate this path))))]
(-on-navigate this (-get-path this))
(assoc this
:listen-key (gevents/listen js/window
#js [goog.events.EventType.POPSTATE goog.events.EventType.HASHCHANGE]
handler
false))))
(-stop [this]
(gevents/unlistenByKey listen-key))
(-on-navigate [this path]
(reset! last-fragment path)
(on-navigate (rf/match-by-path router path) this))
(-get-path [this]
;; Remove #
;; "" or "#" should be same as "#/"
(let [fragment (subs (.. js/window -location -hash) 1)]
(if (= "" fragment)
"/"
fragment)))
(-href [this path]
(if path
(str "#" path))))
(defn- path->token [history path] (defrecord Html5History [on-navigate router listen-key click-listen-key]
(subs path (if (.-useFragment_ history) History
1 (-init [this]
(count (.getPathPrefix history))))) (let [handler
(fn [e]
(-on-navigate this (-get-path this)))
(defn- token->href [history token] current-domain
(if token (if (exists? js/location)
(str (if (.-useFragment_ history) (.getDomain (.parse Uri js/location)))
(str "#"))
(.getPathPrefix history)
token)))
(def ^:private current-domain (if (exists? js/location) ;; Prevent document load when clicking a elements, if the href points to URL that is part
(.getDomain (.parse Uri js/location)))) ;; of the routing tree."
ignore-anchor-click
(defn ignore-anchor-click (fn ignore-anchor-click
"Ignore click events from a elements, if the href points to URL that is part [e]
of the routing tree."
[router history e]
;; Returns the next matching anchestor of event target ;; Returns the next matching anchestor of event target
(when-let [el (.closest (.-target e) "a")] (when-let [el (.closest (.-target e) "a")]
(let [uri (.parse Uri (.-href el))] (let [uri (.parse Uri (.-href el))]
@ -53,105 +81,82 @@
(= 0 (.-button e)) (= 0 (.-button e))
(reitit/match-by-path router (.getPath uri))) (reitit/match-by-path router (.getPath uri)))
(.preventDefault e) (.preventDefault e)
(.setToken history (path->token history (str (.getPath uri) (let [path (str (.getPath uri)
(if (seq (.getQuery uri)) (if (seq (.getQuery uri))
(str "?" (.getQuery uri)))))))))) (str "?" (.getQuery uri))))]
(.pushState js/window.history nil "" path)
(impl/goog-extend (-on-navigate this path))))))]
^{:jsdoc ["@constructor" (-on-navigate this (-get-path this))
"@extends {Html5History.TokenTransformer}"]} (assoc this
TokenTransformer :listen-key (gevents/listen js/window goog.events.EventType.POPSTATE handler false)
Html5History.TokenTransformer :click-listen-key (e/listen js/document e/EventType.CLICK ignore-anchor-click))))
([] (-on-navigate [this path]
(this-as this (on-navigate (rf/match-by-path router path) this))
(.call Html5History.TokenTransformer this))) (-stop [this]
(retrieveToken [path-prefix location] (gevents/unlistenByKey listen-key)
(subs (.-pathname location) (count path-prefix))) (gevents/unlistenByKey click-listen-key))
(createUrl [token path-prefix location] (-get-path [this]
;; Code in Closure also adds current query params (.. js/window -location -pathname))
;; from location. (-href [this path]
(str path-prefix token))) path))
(defn start! (defn start!
"This registers event listeners on either haschange or HTML5 history. "This registers event listeners on HTML5 history and hashchange events.
When using with development workflow like Figwheel, rememeber to When using with development workflow like Figwheel, rememeber to
remove listeners using stop! call before calling start! again. remove listeners using stop! call before calling start! again.
Parameters: Parameters:
- router The reitit routing tree. - router The Reitit routing tree.
- on-navigate Function to be called when route changes. - on-navigate Function to be called when route changes. Takes two parameters, ´match´ and ´history´ object.
Options: Options:
- :use-fragment (default true) If true, onhashchange and location hash are used to store the token. - :use-fragment (default true) If true, onhashchange and location hash are used to store current route."
- :path-prefix (default \"/\") If :use-fragment is false, this is prepended to all tokens, and is ([router on-navigate]
removed from start of the token before matching the route." (start! router on-navigate nil))
[router ([router
on-navigate on-navigate
{:keys [path-prefix use-fragment] {:keys [use-fragment]
:or {path-prefix "/" :or {use-fragment true}}]
use-fragment true}}] (let [opts {:router router
(let [history :on-navigate on-navigate}]
(doto (Html5History. nil (TokenTransformer.)) (-init (if use-fragment
(.setEnabled true) (map->FragmentHistory opts)
(.setPathPrefix path-prefix) (map->Html5History opts))))))
(.setUseFragment use-fragment))
event-key (defn stop! [history]
(e/listen history goog.history.EventType.NAVIGATE (if history
(fn [e] (-stop history)))
(on-navigate (rf/match-by-path router (token->path history (.getToken history))))))
click-listen-key
(if-not use-fragment
(e/listen js/document e/EventType.CLICK
(partial ignore-anchor-click router history)))]
;; Trigger navigate event for current route
(on-navigate (rf/match-by-path router (token->path history (.getToken history))))
{:router router
:history history
:close-fn (fn []
(e/unlistenByKey event-key)
(e/unlistenByKey click-listen-key)
(.dispose history))}))
(defn stop! [{:keys [close-fn]}]
(if close-fn
(close-fn)))
(defn- match->token [history match k params query]
(some->> (r/match->path match query)
(path->token history)))
(defn href (defn href
([state k] ([history k]
(href state k nil)) (href history k nil))
([state k params] ([history k params]
(href state k params nil)) (href history k params nil))
([{:keys [router history]} k params query] ([history k params query]
(let [match (rf/match-by-name! router k params) (let [match (rf/match-by-name! (:router history) k params)]
token (match->token history match k params query)] (-href history (r/match->path match query)))))
(token->href history token))))
(defn set-token (defn push-state
"Sets the new route, leaving previous route in history." "Sets the new route, leaving previous route in history."
([state k] ([history k]
(set-token state k nil)) (push-state history k nil nil))
([state k params] ([history k params]
(set-token state k params nil)) (push-state history k params nil))
([{:keys [router history]} k params query] ([history k params query]
(let [match (rf/match-by-name! router k params) (let [match (rf/match-by-name! (:router history) k params)
token (match->token history match k params query)] path (r/match->path match query)]
(.setToken history token)))) ;; pushState and replaceState don't trigger popstate event so call on-navigate manually
(.pushState js/window.history nil "" (-href history path))
(-on-navigate history path))))
(defn replace-token (defn replace-state
"Replaces current route. I.e. current route is not left on history." "Replaces current route. I.e. current route is not left on history."
([state k] ([history k]
(replace-token state k nil)) (replace-state history k nil nil))
([state k params] ([history k params]
(replace-token state k params nil)) (replace-state history k params nil))
([{:keys [router history]} k params query] ([history k params query]
(let [match (rf/match-by-name! router k params) (let [match (rf/match-by-name! (:router history) k params)
token (match->token history match k params query)] path (r/match->path match query)]
(.replaceToken history token)))) (.replaceState js/window.history nil "" (-href history path))
(-on-navigate history path))))

View file

@ -1,21 +1,24 @@
(ns reitit.frontend.history-test (ns reitit.frontend.history-test
(:require [clojure.test :refer [deftest testing is are]] (:require [clojure.test :refer [deftest testing is are async]]
[reitit.core :as r] [reitit.core :as r]
[reitit.frontend :as rf]
[reitit.frontend.history :as rfh] [reitit.frontend.history :as rfh]
[reitit.frontend.test-utils :refer [capture-console]])) [reitit.frontend.test-utils :refer [capture-console]]
[goog.events :as gevents]))
(def browser (exists? js/window)) (def browser (exists? js/window))
(deftest fragment-history-test (def router (r/router ["/"
(when browser
(let [router (r/router ["/"
["" ::frontpage] ["" ::frontpage]
["foo" ::foo] ["foo" ::foo]
["bar/:id" ::bar]]) ["bar/:id" ::bar]]))
history (rfh/start! router
(fn [_]) (deftest fragment-history-test
{:use-fragment true (when browser
:path-prefix "/"})] (gevents/removeAll js/window goog.events.EventType.POPSTATE)
(gevents/removeAll js/window goog.events.EventType.HASHCHANGE)
(let [history (rfh/start! router (fn [_]) {:use-fragment true})]
(testing "creating urls" (testing "creating urls"
(is (= "#/foo" (is (= "#/foo"
@ -30,18 +33,49 @@
(is (= nil value)) (is (= nil value))
(is (= [{:type :warn (is (= [{:type :warn
:message ["missing route" ::asd]}] :message ["missing route" ::asd]}]
messages))))))) messages))))
(rfh/stop! history))))
(deftest fragment-history-routing-test
(when browser
(gevents/removeAll js/window goog.events.EventType.POPSTATE)
(gevents/removeAll js/window goog.events.EventType.HASHCHANGE)
(async done
(let [n (atom 0)
history (rfh/start! router
(fn [match history]
(let [url (rfh/-get-path history)]
(case (swap! n inc)
1 (do (is (= "/" url)
"start at root")
(rfh/push-state history ::foo))
2 (do (is (= "/foo" url)
"push-state")
(.back js/window.history))
3 (do (is (= "/" url)
"go back")
(rfh/push-state history ::bar {:id 1}))
4 (do (is (= "/bar/1" url)
"push-state 2")
(rfh/replace-state history ::bar {:id 2}))
5 (do (is (= "/bar/2" url)
"replace-state")
(.back js/window.history))
6 (do (is (= "/" url)
"go back after replace state")
(rfh/stop! history)
(done))
(do (is false "extra event")))))
{:use-fragment true})]))))
(deftest html5-history-test (deftest html5-history-test
(when browser (when browser
(let [router (r/router ["/" (gevents/removeAll js/window goog.events.EventType.POPSTATE)
["" ::frontpage] (gevents/removeAll js/window goog.events.EventType.HASHCHANGE)
["foo" ::foo]
["bar/:id" ::bar]]) (let [history (rfh/start! router (fn [_]) {:use-fragment false})]
history (rfh/start! router
(fn [_])
{:use-fragment false
:path-prefix "/"})]
(testing "creating urls" (testing "creating urls"
(is (= "/foo" (is (= "/foo"
@ -56,4 +90,40 @@
(is (= nil value)) (is (= nil value))
(is (= [{:type :warn (is (= [{:type :warn
:message ["missing route" ::asd]}] :message ["missing route" ::asd]}]
messages))))))) messages))))
(rfh/stop! history))))
(deftest html5-history-routing-test
(when browser
(gevents/removeAll js/window goog.events.EventType.POPSTATE)
(gevents/removeAll js/window goog.events.EventType.HASHCHANGE)
(async done
(let [n (atom 0)
history (rfh/start! router
(fn [match history]
(let [url (rfh/-get-path history)]
(case (swap! n inc)
1 (do (rfh/push-state history ::frontpage))
2 (do (is (= "/" url)
"start at root")
(rfh/push-state history ::foo))
3 (do (is (= "/foo" url)
"push-state")
(.back js/window.history))
4 (do (is (= "/" url)
"go back")
(rfh/push-state history ::bar {:id 1}))
5 (do (is (= "/bar/1" url)
"push-state 2")
(rfh/replace-state history ::bar {:id 2}))
6 (do (is (= "/bar/2" url)
"replace-state")
(.back js/window.history))
7 (do (is (= "/" url)
"go back after replace state")
(rfh/stop! history)
(done))
(do (is false "extra event")))))
{:use-fragment false})]))))