diff --git a/CHANGELOG.md b/CHANGELOG.md index 9086fc55..25c79d3a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,12 @@ +## Unreleased + +* Frontend controllers: + * Controller `:params` function has been deprecated + * Controller `:identity` function works the same as `:params` + * New `:parameters` option can be used to declare which parameters + controller is interested in, as data, which should cover most + use cases: `{:start start-fn, :parameters {:path [:foo-id]}}` + ## 0.2.13 (2019-01-26) * Don't throw `StringIndexOutOfBoundsException` with empty path lookup on wildcard paths, fixes [#209](https://github.com/metosin/reitit/issues/209) @@ -25,7 +34,7 @@ * new options `:inject-match?` and `:inject-router?` on `reitit.http/ring-handler` and `reitit.http/routing-interceptor` to optionally not to inject `Router` and `Match` into the request. See [performance guide](https://metosin.github.io/reitit/performance.html#faster!) for details. -### dependencies +### dependencies * updated: @@ -250,7 +259,7 @@ * Swagger enchancements * Better spec coercion via `st/coerce` using spec walking & inference: many simple specs (core predicates, `spec-tools.core/spec`, `s/and`, `s/or`, `s/coll-of`, `s/keys`, `s/map-of`, `s/nillable` and `s/every`) can be transformed without needing spec to be wrapped. Fallbacks to old conformed based approach. * [example app](https://github.com/metosin/reitit/blob/master/examples/ring-spec-swagger/src/example/server.clj). - + * updated deps: ```clj @@ -396,7 +405,7 @@ Sample apps demonstraing the current status of `reitit`: * when Keywords are used in place of middleware / interceptor, a lookup is done into Router option `::middleware/registry` (or `::interceptor/registry`) with the key. Fails fast with missing registry entries. * fixes [#32](https://github.com/metosin/reitit/issues/32). * full documentation [here](https://metosin.github.io/reitit/ring/middleware_registry.html). - + ```clj (require '[reitit.ring :as ring]) (require '[reitit.middleware :as middleware]) diff --git a/doc/frontend/basics.md b/doc/frontend/basics.md index d7330883..42e0e5e5 100644 --- a/doc/frontend/basics.md +++ b/doc/frontend/basics.md @@ -1,12 +1,30 @@ # Frontend basics -* https://github.com/metosin/reitit/tree/master/examples/frontend +Reitit frontend integration is built from multiple layers: + +- Core functions with some additional browser oriented features +- [Browser integration](./browser.md) for attaching Reitit to hash-change or HTML +history events +- Stateful wrapper for easy use of history integration +- Optional [controller extension](./controllers.md) + +## Core functions `reitit.frontend` provides few useful functions wrapping core functions: -- `match-by-path` version which parses a URI using JavaScript, including -query-string, and also coerces the parameters. -- `router` which compiles coercers by default -- `match-by-name` and `match-by-name!` with optional `path-paramers` and -logging errors to `console.warn` instead of throwing errors (to prevent -React breaking due to errors). +`match-by-path` version which parses a URI using JavaScript, including +query-string, and also [coerces the parameters](../coercion/coercion.md). +Coerced parameters are stored in match `:parameters` property. If coercion +is not enabled, the original parameters are stored in the same property, +to allow the same code to read parameters regardless if coercion is +enabled. + +`router` which compiles coercers by default. + +`match-by-name` and `match-by-name!` with optional `path-paramers` and +logging errors to `console.warn` instead of throwing errors to prevent +React breaking due to errors. + +## Next + +[Browser integration](./browser.md) diff --git a/doc/frontend/browser.md b/doc/frontend/browser.md index 599f2765..210e5711 100644 --- a/doc/frontend/browser.md +++ b/doc/frontend/browser.md @@ -21,5 +21,7 @@ Check examples for simple Ring handler example. ## Easy Reitit frontend routers require storing the state somewhere and passing it to -all the calls. Wrapper (`reitit.frontend.easy`) is provided which manages -router instance and passes the instance to all calls. +all the calls. Wrapper `reitit.frontend.easy` is provided which manages +a router instance and passes the instance to all calls. This should +allow easy use in most applications, as browser anyway can only have single +event handler for page change events. diff --git a/doc/frontend/controllers.md b/doc/frontend/controllers.md index 95a0b90b..4ee8de14 100644 --- a/doc/frontend/controllers.md +++ b/doc/frontend/controllers.md @@ -9,32 +9,34 @@ Controllers run code when a route is entered and left. This can be useful to: ## How controllers work -A controller consists of three functions: +A controller map can contain these properties: -* `params` which takes a Match and returns an arbitrary value. -* `start` which takes the result of params and whose return value is discarded. -* `stop` which takes the result of params and whose return value is discarded. +* `identity` function which takes a Match and returns an arbitrary value, +* or `parameters` value, which declares which parameters should affect +controller identity +* `start` & `stop` functions, which are called with controller identity -When you navigate to a route that has a controller, `params` gets called first -and then `start` is called with its return value. When you exit that route, -`stop` is called with the return value of `params.` +When you navigate to a route that has a controller, controller identity +is first resolved by calling `identity` function, or by using `parameters` +declaration, or if neither is set, the identity is `nil`. Next controller +is initialized by calling `start` is called with the identity value. +When you exit that route, `stop` is called with the return value of `params.` -If you navigate to the same route with different parameters, `params` gets -called again. If the return value changes from the previous return value, `stop` -and `start` get called again. +If you navigate to the same route with different match, identity gets +resolved again. If the identity changes from the previous value, controller +is reinitialized: `stop` and `start` get called again. You can add controllers to a route by adding them to the route data in the `:controllers` vector. For example: -```clojure +```cljs ["/item/:id" - {:controllers [{:params (fn [match] (get-in match [:path-params :id])) - :start (fn [item-id] (js/console.log :start item-id)) - :stop (fn [item-id] (js/console.log :stop item-id))}]}] + {:controllers [{:parameters {:path [:id]} + :start (fn [parameters] (js/console.log :start (-> parameters :path :id))) + :stop (fn [parameters] (js/console.log :stop (-> parameters :path :id)))}]}] ``` -If you leave out `params`, `start` and `stop` get called with `nil`. You can -leave out `start` or `stop` if you do not need both of them. +You can leave out `start` or `stop` if you do not need both of them. ## Enabling controllers @@ -44,8 +46,7 @@ call the URL changes. You can call it from the `on-navigate` callback of `reitit.frontend.easy`: -```clojure - +```cljs (ns frontend.core (:require [reitit.frontend.easy :as rfe] [reitit.frontend.controllers :as rfc])) @@ -70,10 +71,10 @@ See also [the full example](https://github.com/metosin/reitit/tree/master/exampl ## Nested controllers -When you nest routes in the route tree, the controllers get nested as well. -Consider this route tree: +When you nest routes in the route tree, the controllers get concatenated when +route data is merged. Consider this route tree: -```clojure +```cljs ["/" {:controllers [{:start (fn [_] (js/console.log "root start"))}]} ["/item/:id" {:controllers [{:params (fn [match] (get-in match [:path-params :id])) @@ -90,20 +91,22 @@ Consider this route tree: started with the parameter `something-else`. The root controller stays on the whole time since its parameters do not change. -## Authentication +## Tips + +### Authentication Controllers can be used to load resources from a server. If and when your API requires authentication you will need to implement logic to prevent controllers trying to do requests if user isn't authenticated yet. -### Run controllers and check authentication +#### Run controllers and check authentication If you have both unauthenticated and authenticated resources, you can run the controllers always and then check the authentication status on controller code, or on the code called from controllers (e.g. re-frame event handler). -### Disable controllers until user is authenticated +#### Disable controllers until user is authenticated If all your resources require authentication an easy way to prevent bad requests is to enable controllers only after authentication is done. @@ -119,7 +122,6 @@ Similar solution could be used to describe required resources as data (maybe even GraphQL query) per route, and then have code automatically load missing resources. - ## Controllers elsewhere * [Controllers in Keechma](https://keechma.com/guides/controllers/) diff --git a/examples/frontend-controllers/src/frontend/core.cljs b/examples/frontend-controllers/src/frontend/core.cljs index f279c249..50986e76 100644 --- a/examples/frontend-controllers/src/frontend/core.cljs +++ b/examples/frontend-controllers/src/frontend/core.cljs @@ -65,12 +65,11 @@ {:name ::item :parameters {:path {:id s/Int} :query {(s/optional-key :foo) s/Keyword}} - :controllers [{:params (fn [match] - (:path (:parameters match))) - :start (fn [params] - (js/console.log "start" "item controller" (:id params))) - :stop (fn [params] - (js/console.log "stop" "item controller" (:id params)))}]}]]] + :controllers [{:parameters {:path [:id]} + :start (fn [{:keys [path]}] + (js/console.log "start" "item controller" (:id path))) + :stop (fn [{:keys [path]}] + (js/console.log "stop" "item controller" (:id path)))}]}]]] {:data {:controllers [{:start (log-fn "start" "root-controller") :stop (log-fn "stop" "root controller")}] :coercion rsc/coercion}})) diff --git a/modules/reitit-frontend/src/reitit/frontend/controllers.cljs b/modules/reitit-frontend/src/reitit/frontend/controllers.cljs index 8556314d..d3d19778 100644 --- a/modules/reitit-frontend/src/reitit/frontend/controllers.cljs +++ b/modules/reitit-frontend/src/reitit/frontend/controllers.cljs @@ -1,29 +1,56 @@ -(ns reitit.frontend.controllers) +(ns reitit.frontend.controllers + "Provides apply-controllers function") (defn- pad-same-length [a b] (concat a (take (- (count b) (count a)) (repeat nil)))) -(defn get-params - "Get controller parameters given match. If controller provides :params - function that will be called with the match. Default is nil." - [controller match] - (if-let [f (:params controller)] - (f match))) +(def ^:private params-warning + (delay (js/console.warn "Reitit-frontend controller :params is deprecated. Replace with :identity or :parameters option."))) + +(defn get-identity + "Get controller identity given controller and match. + + To select interesting properties from Match :parameters option can be set. + Value should be param-type => [param-key] + Resulting value is map of param-type => param-key => value. + + For other uses, :identity option can be used to provide function from + Match to identity. + + Default value is nil, i.e. controller identity doesn't depend on Match." + [{:keys [identity parameters params]} match] + (assert (not (and identity parameters)) + "Use either :identity or :parameters for controller, not both.") + (when params + @params-warning) + (cond + parameters + (into {} (for [[param-type ks] parameters] + [param-type (select-keys (get (:parameters match) param-type) ks)])) + + identity + (identity match) + + ;; Support deprecated :params for transition period. Can be removed later. + params + (params match) + + :else nil)) (defn apply-controller "Run side-effects (:start or :stop) for controller. - The side-effect function is called with controller params." + The side-effect function is called with controller identity value." [controller method] (when-let [f (get controller method)] - (f (::params controller)))) + (f (::identity controller)))) (defn apply-controllers "Applies changes between current controllers and - those previously enabled. Resets controllers whose - parameters have changed." + those previously enabled. Reinitializes controllers whose + identity has changed." [old-controllers new-match] (let [new-controllers (mapv (fn [controller] - (assoc controller ::params (get-params controller new-match))) + (assoc controller ::identity (get-identity controller new-match))) (:controllers (:data new-match))) changed-controllers (->> (map (fn [old new] ;; different controllers, or params changed @@ -33,7 +60,7 @@ (pad-same-length new-controllers old-controllers)) (keep identity) vec)] - (doseq [controller (map :old changed-controllers)] + (doseq [controller (reverse (map :old changed-controllers))] (apply-controller controller :stop)) (doseq [controller (map :new changed-controllers)] (apply-controller controller :start)) diff --git a/modules/reitit-frontend/src/reitit/frontend/history.cljs b/modules/reitit-frontend/src/reitit/frontend/history.cljs index 35d8a091..4ec295fa 100644 --- a/modules/reitit-frontend/src/reitit/frontend/history.cljs +++ b/modules/reitit-frontend/src/reitit/frontend/history.cljs @@ -1,4 +1,6 @@ (ns reitit.frontend.history + "Provides integration to hash-change or HTML5 History + events." (:require [reitit.core :as reitit] [reitit.core :as r] [reitit.frontend :as rf] diff --git a/test/cljs/reitit/frontend/controllers_test.cljs b/test/cljs/reitit/frontend/controllers_test.cljs index b4d2238c..70fbcd9c 100644 --- a/test/cljs/reitit/frontend/controllers_test.cljs +++ b/test/cljs/reitit/frontend/controllers_test.cljs @@ -15,15 +15,15 @@ :stop (fn [_] (swap! log conj :stop-2))} controller-3 {:start (fn [{:keys [foo]}] (swap! log conj [:start-3 foo])) :stop (fn [{:keys [foo]}] (swap! log conj [:stop-3 foo])) - :params (fn [match] - {:foo (-> match :parameters :path :foo)})}] + :identity (fn [match] + {:foo (-> match :parameters :path :foo)})}] (testing "single controller started" (swap! controller-state rfc/apply-controllers {:data {:controllers [controller-1]}}) (is (= [:start-1] @log)) - (is (= [(assoc controller-1 ::rfc/params nil)] @controller-state)) + (is (= [(assoc controller-1 ::rfc/identity nil)] @controller-state)) (reset! log [])) (testing "second controller started" @@ -31,8 +31,8 @@ {:data {:controllers [controller-1 controller-2]}}) (is (= [:start-2] @log)) - (is (= [(assoc controller-1 ::rfc/params nil) - (assoc controller-2 ::rfc/params nil)] + (is (= [(assoc controller-1 ::rfc/identity nil) + (assoc controller-2 ::rfc/identity nil)] @controller-state)) (reset! log [])) @@ -42,8 +42,8 @@ :parameters {:path {:foo 5}}}) (is (= [:stop-2 [:start-3 5]] @log)) - (is (= [(assoc controller-1 ::rfc/params nil) - (assoc controller-3 ::rfc/params {:foo 5})] + (is (= [(assoc controller-1 ::rfc/identity nil) + (assoc controller-3 ::rfc/identity {:foo 5})] @controller-state)) (reset! log [])) @@ -53,8 +53,8 @@ :parameters {:path {:foo 1}}}) (is (= [[:stop-3 5] [:start-3 1]] @log)) - (is (= [(assoc controller-1 ::rfc/params nil) - (assoc controller-3 ::rfc/params {:foo 1})] + (is (= [(assoc controller-1 ::rfc/identity nil) + (assoc controller-3 ::rfc/identity {:foo 1})] @controller-state)) (reset! log [])) @@ -62,7 +62,47 @@ (swap! controller-state rfc/apply-controllers {:data {:controllers []}}) - (is (= [:stop-1 [:stop-3 1]] @log)) + (is (= [[:stop-3 1] :stop-1] @log)) (is (= [] @controller-state)) + (reset! log [])))) + +(deftest controller-data-parameters + (let [log (atom []) + controller-state (atom []) + static {:start (fn [params] (swap! log conj [:start-static])) + :stop (fn [params] (swap! log conj [:stop-static]))} + controller {:start (fn [params] (swap! log conj [:start params])) + :stop (fn [params] (swap! log conj [:stop params])) + :parameters {:path [:foo]}}] + + (testing "init" + (swap! controller-state rfc/apply-controllers + {:data {:controllers [static controller]} + :parameters {:path {:foo 1}}}) + + (is (= [[:start-static] + [:start {:path {:foo 1}}]] @log)) + (is (= [(assoc static ::rfc/identity nil) + (assoc controller ::rfc/identity {:path {:foo 1}})] + @controller-state)) (reset! log [])) - )) + + (testing "params change" + (swap! controller-state rfc/apply-controllers + {:data {:controllers [static controller]} + :parameters {:path {:foo 5}}}) + + (is (= [[:stop {:path {:foo 1}}] + [:start {:path {:foo 5}}]] @log)) + (is (= [(assoc static ::rfc/identity nil) + (assoc controller ::rfc/identity {:path {:foo 5}})] + @controller-state)) + (reset! log [])) + + (testing "stop" + (swap! controller-state rfc/apply-controllers + {:data {:controllers []}}) + + (is (= [[:stop {:path {:foo 5}}] + [:stop-static]] @log)) + (reset! log []))))