diff --git a/src/main/shadow/grove.cljs b/src/main/shadow/grove.cljs index 86d7ed9..1ef48d8 100644 --- a/src/main/shadow/grove.cljs +++ b/src/main/shadow/grove.cljs @@ -24,7 +24,10 @@ (set! *warn-on-infer* false) -(defn dispatch-up! [{::comp/keys [^not-native parent] :as env} ev-map] +(defn dispatch-up! + "Use within a component event handler to propagate the event `ev-map` up the + component tree. `env` is the environment map available in event handlers." + [{::comp/keys [^not-native parent] :as env} ev-map] {:pre [(map? env) (map? ev-map) (qualified-keyword? (:e ev-map))]} @@ -32,6 +35,24 @@ (gp/handle-event! parent ev-map nil env)) (defn query-ident + "Queries the db starting from `ident`. + * `query` - Optional. EQL query. Defaults to ident lookup if not provided. + * `config` - Optional. Any kind of config that may come up. + Changes to idents accessed by the query (including inside `eql/attr`) during + transactions will cause the query to re-un. + --- + Example + ```clojure + (defmethod eql/attr ::contains + [env db {:dir/keys [files dirs] :as current} query-part params] + (cond->> (concat dirs files) + (not (::show-hidden? db)) + (filterv (fn [ident] (not (::hidden? (get db ident))))))) + (bind {:as query-result + :dir/keys [name open?] + ::keys [contains]} + (sg/query-ident ident [:dir/name :dir/open? ::contains])) + ```" ;; shortcut for ident lookups that can skip EQL queries ([ident] (impl/hook-query ident nil {})) @@ -42,16 +63,45 @@ (impl/hook-query ident query config))) (defn query-root + "Queries from the root of the db. + * `query` - EQL query. + * `config` - Optional. Any kind of config that may come up. + Changes to idents accessed by the query (including inside `eql/attr`) during + transactions will cause the query to re-un. + --- + Example + ```clojure + (defmethod eql/attr :products-in-stock [env db _ _] + (->> (db/all-of :product) + (filter #(pos? (:stock %))) + (mapv :db/ident))) + (defc ui-homepage [] + (bind {:keys [products-in-stock a-root-key]} + (sg/query-root [:products-in-stock :a-root-key])) + ```" ([query] (impl/hook-query nil query {})) ([query config] (impl/hook-query nil query config))) (defn run-tx + "Use inside a component event handler. Runs transaction `tx`, e.g. + `{:e ::some-event :data ...}`. `env` is the component environment map + available in event handlers. + --- + Example + ```clojure + (event :hide! [env ev-map event] + (when (.-ctrlKey event) + (sg/run-tx env ev-map))) + ```" [{::rt/keys [runtime-ref] :as env} tx] (impl/process-event runtime-ref tx env)) -(defn run-tx! [runtime-ref tx] +(defn run-tx! + "Runs the transaction `tx`, e.g. `{:e ::some-event :data ...}`, outside of the + component context." + [runtime-ref tx] (assert (rt/ref? runtime-ref) "expected runtime ref?") (let [{::rt/keys [scheduler]} @runtime-ref] (gp/run-now! scheduler #(impl/process-event runtime-ref tx nil) ::run-tx!))) @@ -63,11 +113,23 @@ (js-delete root-el "sg$env"))) (defn watch - "hook that watches an atom and triggers an update on change - accepts an optional path-or-fn arg that can be used for quick diffs + "Hook that watches `the-atom` and updates when the atom's value changes. + Accepts an optional `path-or-fn` arg that can be used to 'watch' a portion of + `the-atom`, enabling quick diffs. + * 'path' – as in `(get-in @the-atom path)` + * 'fn' - similar to above, defines how to access the relevant parts of + `the-atom`. Takes [old-state new-state] of `the-atom` and returns the actual + value stored in the hook. Example: `(fn [_ new] (get-in new [:id :name]))`. + + **Use strongly discouraged** in favor of the normalized central db. + + --- + Examples + ```clojure (watch the-atom [:foo]) - (watch the-atom (fn [old new] ...))" + (watch the-atom (fn [old new] ...)) + ```" ([the-atom] (watch the-atom (fn [old new] new))) ([the-atom path-or-fn] @@ -76,6 +138,7 @@ (atoms/AtomWatch. the-atom path-or-fn nil nil)))) (defn env-watch + "Similar to [[watch]], but for atoms inside the component env." ([key-to-atom] (env-watch key-to-atom [] nil)) ([key-to-atom path] @@ -85,13 +148,46 @@ (vector? path)]} (atoms/EnvWatch. key-to-atom path default nil nil nil))) -(defn suspense [opts vnode] +(defn suspense + "See [docs](https://github.com/thheller/shadow-experiments/blob/master/doc/async.md)." + [opts vnode] (suspense/SuspenseInit. opts vnode)) -(defn simple-seq [coll render-fn] +(defn simple-seq + "Creates a collection of DOM elements by applying `render-fn` to each item + in `coll`. `render-fn` can be a function or component. + + Makes no attempts to minimize DOM operations required for updates. Efficient + with colls which change infrequently or colls updated at the tail. Otherwise, + consider using [[keyed-seq]]. + --- + Example: + ```clojure + (sg/simple-seq + (range 5) + (fn [num] + (<< [:div \"inline-item: \" num]))) + ```" + [coll render-fn] (sc/simple-seq coll render-fn)) -(defn keyed-seq [coll key-fn render-fn] +(defn keyed-seq + "Creates a keyed collection of DOM elements by applying `render-fn` to each + item in `coll`. + * `key-fn` is used to extract a unique key from items in `coll`. + * `render-fn` can be a function or component. + Uses the key to minimize DOM updates. Consider using instead of (the more + lightweight) [[simple-seq]] when `coll` changes frequently. + + --- + Examples: + ```clojure + ;; ident used as key + (keyed-seq [[::ident 1] ...] identity component) + (keyed-seq [{:id 1 :data ...} ...] :id + (fn [item] (<< [:div.id (:data item) ...]))) + ```" + [coll key-fn render-fn] (sc/keyed-seq coll key-fn render-fn)) (deftype TrackChange @@ -132,20 +228,25 @@ (volatile! nil)) (defn effect - "calls (callback env) after render when provided deps argument changes - callback can return a function which will be called if cleanup is required" + "Calls `(callback env)` after render when `deps` changes. (*Note*: will be + called on mount too.) `callback` may return a cleanup function which is + called on component unmount *and* just before whenever callback would be + called." [deps callback] {:pre [(fn? callback)]} (comp/EffectHook. deps callback nil true nil)) (defn render-effect - "call (callback env) after every render" + "Calls `(callback env)` after every render. `callback` may return a cleanup + function which is called on component unmount *and* after each render before + callback." [callback] {:pre [(fn? callback)]} (comp/EffectHook. :render callback nil true nil)) (defn mount-effect - "call (callback env) on mount once" + "Calls `(callback env)` on mount. `callback` may return a cleanup function + which is called on unmount." [callback] {:pre [(fn? callback)]} (comp/EffectHook. :mount callback nil true nil)) @@ -220,7 +321,12 @@ (set! (.-sg$env root-el) new-env) ::started))) -(defn render [rt-ref ^js root-el root-node] +(defn render + "Renders the UI root. Call on init and `^:dev/after-load`. + * `rt-ref` – runtime atom + * `root-el` – DOM element, e.g. `(js/document.getElementById \"app\")`. + * `root-node` – root fn/component (e.g. defined with `defc`)." + [rt-ref ^js root-el root-node] {:pre [(rt/ref? rt-ref)]} (gp/run-now! ^not-native (::rt/scheduler @rt-ref) #(render* rt-ref root-el root-node) ::render)) @@ -320,6 +426,19 @@ (goog-define TRACE false) (defn prepare + "Initialises the runtime atom. + * `init` – Optional. A map. + * `data-ref` – Ref to the grove db atom. + * `runtime-id` + + --- + Example: + + ```clojure + (defonce rt-ref + (-> {::rt/tx-reporter (fn [report] (tap> report))} + (rt/prepare data-ref ::my-rt))) + ```" ([data-ref runtime-id] (prepare {} data-ref runtime-id)) ([init data-ref runtime-id] @@ -335,19 +454,19 @@ (js/Map.)] (reset! rt-ref - (assoc init - ::rt/rt true - ::rt/scheduler root-scheduler - ::rt/runtime-id runtime-id - ::rt/data-ref data-ref - ::rt/event-config {} - ::rt/fx-config {} - ::rt/active-queries-map active-queries-map - ::rt/key-index-seq (atom 0) - ::rt/key-index-ref (atom {}) - ::rt/query-index-map (js/Map.) - ::rt/query-index-ref (atom {}) - ::rt/env-init [])) + (assoc init + ::rt/rt true + ::rt/scheduler root-scheduler + ::rt/runtime-id runtime-id + ::rt/data-ref data-ref + ::rt/event-config {} + ::rt/fx-config {} + ::rt/active-queries-map active-queries-map + ::rt/key-index-seq (atom 0) + ::rt/key-index-ref (atom {}) + ::rt/query-index-map (js/Map.) + ::rt/query-index-ref (atom {}) + ::rt/env-init [])) (when ^boolean js/goog.DEBUG (swap! rt/known-runtimes-ref assoc runtime-id rt-ref)) @@ -357,15 +476,80 @@ (defn vec-conj [x y] (if (nil? x) [y] (conj x y))) -(defn queue-fx [env fx-id fx-val] +(defn queue-fx + "Used inside an event handler, it queues up the registered handler of `fx-id` + to run at the end of the transaction. The handler will be called with + `fx-val`. + --- + Example: + ```clojure + (sg/reg-event rt-ref ::toggle-show-hidden! + (fn [tx-env {:keys [show?] :as event}] + (-> tx-env + (assoc-in [:db ::show-hidden?] show?) ;; modify the db + (sg/queue-fx ::alert! event)))) ;; schedule an fx + (sg/reg-fx rt-ref ::alert! + (fn [fx-env {:keys [show?] :as fx-val}] + (js/alert (str \"Will \" (when-not show? \"not\") \" show hidden files.\")))) + ```" + [env fx-id fx-val] (update env ::rt/fx vec-conj [fx-id fx-val])) -(defn reg-event [rt-ref ev-id handler-fn] +(defn reg-event + "Registers the `handler-fn` for event `ev-id`. `handler-fn` will be called + with `{:as tx-env :keys [db]} event-map` and should return the modified + `tx-env`. + + There is an alternative approach to registering event handlers, see examples. + + --- + Example: + ```clojure + (sg/reg-event rt-ref ::complete! + (fn [tx-env {:keys [checked ident] :as ev}] + (assoc-in tx-env [:db ident :completed?] checked))) + + ;; metadata approach + (defn complete! {::ev/handle ::complete!} + [tx-env {:keys [checked ident] :as ev}] + (assoc-in tx-env [:db ident :completed?] checked)) + + ;; use `{:dev/always true}` in namespaces utilising the metadata approach. + ```" + [rt-ref ev-id handler-fn] + {:pre [(keyword? ev-id) (ifn? handler-fn)]} (swap! rt-ref assoc-in [::rt/event-config ev-id] handler-fn) rt-ref) -(defn reg-fx [rt-ref fx-id handler-fn] +(defn reg-fx + "Registers the `handler-fn` for fx `fx-id`. fx is used for side effects, so + `handler-fn` shouldn't modify the db. + + --- + Examples: + ```clojure + (sg/reg-event rt-ref ::toggle-show-hidden! + (fn [tx-env {:keys [show?] :as event}] + (-> tx-env + (assoc-in [:db ::show-hidden?] show?) ;; modify the db + (sg/queue-fx ::alert! event)))) ;; schedule an fx + + (sg/reg-fx rt-ref ::alert! + (fn [fx-env {:keys [show?] :as fx-data}] + (js/alert (str \"Will \" (when-not show? \"not\") \" show hidden files.\")))) + ``` + + `(:transact! fx-env)` allows fx to schedule another transaction, but it + should be an async call: + ```clojure + (sg/reg-fx rt-ref :ui/redirect! + (fn [{:keys [transact!] :as env} {:keys [token title]}] + (let [tokens (str/split (subs token 1) #\"/\")] + ;; forcing the transaction to be async + (js/setTimeout #(transact! {:e :ui/route! :token token :tokens tokens}) 0)))) + ```" + [rt-ref fx-id handler-fn] (swap! rt-ref assoc-in [::rt/fx-config fx-id] handler-fn) rt-ref) diff --git a/src/main/shadow/grove/db.cljc b/src/main/shadow/grove/db.cljc index 3a0c9fd..aa62102 100644 --- a/src/main/shadow/grove/db.cljc +++ b/src/main/shadow/grove/db.cljc @@ -590,6 +590,24 @@ (if (instance? GroveDB db) @db db))) (defn configure + "Returns a [[GroveDB]] instance with associated `spec` as its schema used for + normalization operations. You may optionally initialize the db to an `init-db` + map. This will not normalize data from `init-db` in any way. + + --- + Example: + ``` + (def schema + {::folder + {:type :entity + :primary-key :id + :attrs {} + :joins {:folder/contains [:many ::file]}}}) + + (defonce data-ref + (-> (db/configure schema) + (atom))) + ```" ([spec] (configure {} spec)) ([init-db spec] @@ -691,6 +709,10 @@ imports)) (defn merge-seq + "Normalizes `coll` of items of `entity-type` into `data` (a [[configure]]d + [[GroveDB]] instance). The vector of idents normalized can be inserted at + target-path, replacing what's present. Alternatively, you can specify a target-fn + which will be called with `data normalized-idents` and should return updated `data`." ([data entity-type coll] (merge-seq data entity-type coll nil)) ([data entity-type coll target-path-or-fn] @@ -715,14 +737,33 @@ (-> data (merge-imports imports) (cond-> - (vector? target-path-or-fn) + (vector? target-path-or-fn) (assoc-in target-path-or-fn idents) (fn? target-path-or-fn) - (target-path-or-fn idents)) - )))) + (target-path-or-fn idents)))))) (defn add + "Normalizes the `item` of `entity-type` into `data` at `target-path`. + * `data` - this should be a [[configure]]d [[GroveDB]] instance. + * `entity-type` of `item` being added. Should be present in db schema. + * `item` - a *map* of data to add. (For collections, see [[merge-seq]].) + * `target-path` - where to `conj` the ident of `item`. + --- + Examples: + ```clojure + ;; inside event handler + (update tx-env :db db/add ::node data-to-add [:root]) + ;; repl testing + (def schema + {::node + {:type :entity + :primary-key :id + :attrs {} + :joins {:children [:many ::node]}}}) + (-> (db/configure schema) + (db/add ::node {:id 0 :children [{:id 1} {:id 2}]} [::root])) + ```" ([data entity-type item] (add data entity-type item nil)) ([data entity-type item target-path] @@ -745,12 +786,15 @@ (-> data (merge-imports imports) (cond-> - (fn? target-path) + (fn? target-path) (target-path ident) (vector? target-path) (update-in target-path conj ident)))))) -(defn update-entity [data entity-type id update-fn & args] +(defn update-entity + "Updates entity `(make-ident entity-type id)` in `data` (db) with + `(update-fn entity & args)`." + [data entity-type id update-fn & args] ;; FIXME: validate that both entity-type is defined and id matches type (update data (make-ident entity-type id) #(apply update-fn % args))) @@ -764,7 +808,12 @@ ;; keep this as the very last thing since we excluded clojure remove ;; don't want to write code that assumes it uses core remove -(defn remove [data thing] +(defn remove + "Removes `thing` from `data` root. `thing` can be either an ident or a map + like `{:db/ident ident ...}`. When used on a [[GroveDB]] instance, `thing` will + be removed from the set of all entities of `thing`'s type. Will *not* remove + any other references to `thing`." + [data thing] (cond (ident? thing) (dissoc data thing) @@ -775,6 +824,7 @@ :else (throw (ex-info "don't know how to remove thing" {:thing thing})))) -(defn remove-idents [data idents] +(defn remove-idents + "Given a coll of `idents`, [[remove]]s all corresponding entities from `data`." + [data idents] (reduce remove data idents)) - diff --git a/src/main/shadow/grove/eql_query.cljc b/src/main/shadow/grove/eql_query.cljc index ac138e4..31746a1 100644 --- a/src/main/shadow/grove/eql_query.cljc +++ b/src/main/shadow/grove/eql_query.cljc @@ -38,6 +38,44 @@ ;; but is the easiest to use with hot-reload in mind (defmulti attr + "Define 'computed attributes'. The attribute is the dispatch-fn of the method. + The return of this function will be included in query results for that + attribute. *Must not return lazy seq.* + The methods are called with the following args: + 1. `env` - component env. + 2. `db` - the db map. + 3. `current` - the entity which is the root of the query. For `query-ident` + this is the entity corresponding to the ident, for `query-root` it is the + whole db map. + 4. `query-part` – the dispatch-fn of the method, i.e. an EQL attribute. + 5. `params` - optional parameters specified with EQL attributes. + Called when building query results for each EQL attribute. See [[::default]]. + Idents accessed within the method will be 'observed': the query will re-run + if said idents are modified. + + --- + Example: + + ```clojure + ;; in a component + (bind query-result + (sg/query-ident ident [::foo '(::bar {:test 1})])) + ;; elsewhere + (defmethod attr ::foo + [env db current query-part params] + ;; the ::default impl + (get current query-part :db/undefined)) + ;; {:test 1} available in `params` for + (defmethod attr ::bar + [_ _ _ _ params]) + + ;; another example + (defmethod eql/attr ::m/num-active + [env db current _ params] + (->> (db/all-of db ::m/todo) + (remove ::m/completed?) + (count))) + ```" (fn [env db current query-part params] query-part) :default ::default) @@ -229,4 +267,4 @@ (comment (query {} {:hello {:world 1 :foo true}} - [{:hello [:world]}])) \ No newline at end of file + [{:hello [:world]}])) diff --git a/src/main/shadow/grove/history.cljs b/src/main/shadow/grove/history.cljs index f3de765..d00fc39 100644 --- a/src/main/shadow/grove/history.cljs +++ b/src/main/shadow/grove/history.cljs @@ -7,12 +7,57 @@ [shadow.arborist.attributes :as attr])) (defn init! + "Use to intercept clicks on 'internal' links and HTML `History` modifications to: + 1. update the state of `History` (e.g. update the URL), + 2. trigger the `:ui/route!` event (for which the handler is defined by the user). + `config` map: + * `:start-token` – replaces the \"/\" URL on init. Defaults to `\"/\"`. + Should start – but not end – with `/`. + * `:path-prefix` – a common URL base to use. Defaults to `\"\"`. + Should start – but not end – with `/`. + * `:use-fragment` – pass `true` if you want to use hash-based URLs. + Defaults to `false`. + * `:root-el` - optional DOM node to set the click listener on. + Defaults to `document.body`. + ### Usage + - This function should be called before component env is initialised (e.g. + before [[shadow.grove/render]]). *Note*: the `:ui/route!` event + will be triggered on call. + - Set the `:ui/href` attribute on anchors for internal links. (Unlike regular + `href`, will handle of `path-prefix` and `use-fragment`.) + - Register handler for the event `{:e :ui/route! :token token :tokens tokens}`. + - `token` is \"/a/b\" + - `tokens` is `[\"a\", \"b\"]`. + - Intercepts only pure clicks with main mouse button (no keyboard modifiers). + - The `:ui/redirect!` *fx* handler will be registered and can be 'called' with: + * `:token` – URL to redirect to (a string starting with `/`) + * `:title` – optional. `title` arg for `history.pushState` + + --- + Example: + ```clojure + ;; in a component + [:a {:ui/href (str \"/\" id)} \"internal link\"] + ;; handle clicks + (sg/reg-event rt-ref :ui/route! + (fn [env {:keys [token tokens]}] + (let [ident (db/make-ident ::entity (first tokens))] + (-> env + (assoc-in [:db ::main-root] ident) + (sg/queue-fx :ui/set-window-title! {:title (get-title ident)}))))) + ;; calling init + (defn ^:dev/after-load start [] + (sg/render rt-ref root-el (ui-root))) + (defn init [] + (history/init! rt-ref {}) + (start)) + ```" [rt-ref {:keys [start-token path-prefix use-fragment root-el] - :or {start-token "/" - path-prefix "" - use-fragment false} - :as config}] + :or {start-token "/" + path-prefix "" + use-fragment false} + :as config}] {:pre [(or (= "" path-prefix) (and (string? path-prefix) @@ -58,34 +103,34 @@ (get-token)] (attr/add-attr :ui/href - (fn [env node oval nval] - (when nval - (when-not (str/starts-with? nval "/") - (throw (ex-info (str ":ui/href must start with / got " nval) - {:val nval}))) - - (set! node -href - (if use-fragment - (str "#" path-prefix nval) - (str path-prefix - (if-not (str/ends-with? path-prefix "/") - nval - (subs nval 1)))))))) + (fn [env node oval nval] + (when nval + (when-not (str/starts-with? nval "/") + (throw (ex-info (str ":ui/href must start with / got " nval) + {:val nval}))) + + (set! node -href + (if use-fragment + (str "#" path-prefix nval) + (str path-prefix + (if-not (str/ends-with? path-prefix "/") + nval + (subs nval 1)))))))) (ev/reg-fx rt-ref :ui/redirect! - (fn [{:keys [transact!] :as env} {:keys [token title]}] - {:pre [(str/starts-with? token "/")]} + (fn [{:keys [transact!] :as env} {:keys [token title]}] + {:pre [(str/starts-with? token "/")]} - (js/window.history.pushState - nil - (or title js/document.title) - (str path-prefix token)) + (js/window.history.pushState + nil + (or title js/document.title) + (str path-prefix token)) - (let [tokens (str/split (subs token 1) #"/")] - ;; FIXME: there needs to be cleaner way to start another tx from fx - ;; currently forcing them to be async so the initial tx can conclude - (js/setTimeout #(transact! {:e :ui/route! :token token :tokens tokens}) 0) - ))) + (let [tokens (str/split (subs token 1) #"/")] + ;; FIXME: there needs to be cleaner way to start another tx from fx + ;; currently forcing them to be async so the initial tx can conclude + (js/setTimeout #(transact! {:e :ui/route! :token token :tokens tokens}) 0) + ))) ;; immediately trigger initial route when this is initialized ;; don't wait for first env-init, thats problematic with multiple roots @@ -95,43 +140,43 @@ first-token)) (swap! rt-ref - (fn [rt] - (-> rt - (assoc ::config config) - (update ::rt/env-init conj - (fn [env] - ;; fragment uses hashchange event so we can skip checking clicks - (when-not use-fragment - (.addEventListener (or root-el js/document.body) "click" - (fn [^js e] - (when (and (zero? (.-button e)) - (not (or (.-shiftKey e) (.-metaKey e) (.-ctrlKey e) (.-altKey e)))) - (when-let [a (some-> e .-target (.closest "a"))] - - (let [href (.getAttribute a "href") - a-target (.getAttribute a "target")] - - (when (and href (seq href) (str/starts-with? href path-prefix) (nil? a-target)) - (.preventDefault e) - - (js/window.history.pushState nil js/document.title href) - - (trigger-route!) - ))))))) - - (when (and (= "/" first-token) (seq start-token)) - (js/window.history.replaceState - nil - js/document.title - (str (when use-fragment "#") path-prefix start-token))) - - (js/window.addEventListener "popstate" - (fn [e] - (trigger-route!))) - - (when use-fragment - (js/window.addEventListener "hashchange" - (fn [e] - (trigger-route!)))) - - env))))))) \ No newline at end of file + (fn [rt] + (-> rt + (assoc ::config config) + (update ::rt/env-init conj + (fn [env] + ;; fragment uses hashchange event so we can skip checking clicks + (when-not use-fragment + (.addEventListener (or root-el js/document.body) "click" + (fn [^js e] + (when (and (zero? (.-button e)) + (not (or (.-shiftKey e) (.-metaKey e) (.-ctrlKey e) (.-altKey e)))) + (when-let [a (some-> e .-target (.closest "a"))] + + (let [href (.getAttribute a "href") + a-target (.getAttribute a "target")] + + (when (and href (seq href) (str/starts-with? href path-prefix) (nil? a-target)) + (.preventDefault e) + + (js/window.history.pushState nil js/document.title href) + + (trigger-route!) + ))))))) + + (when (and (= "/" first-token) (seq start-token)) + (js/window.history.replaceState + nil + js/document.title + (str (when use-fragment "#") path-prefix start-token))) + + (js/window.addEventListener "popstate" + (fn [e] + (trigger-route!))) + + (when use-fragment + (js/window.addEventListener "hashchange" + (fn [e] + (trigger-route!)))) + + env))))))) diff --git a/src/main/shadow/grove/http_fx.cljs b/src/main/shadow/grove/http_fx.cljs index 5bf8852..ded708c 100644 --- a/src/main/shadow/grove/http_fx.cljs +++ b/src/main/shadow/grove/http_fx.cljs @@ -256,7 +256,61 @@ (defn merge-right [left right] (merge right left)) -(defn make-handler [config] +(defn make-handler + "Returns an fx handler which will initiate an XMLHttpRequest. The request itself + is defined in the event handler queuing the fx, i.e. as `fx-val` arg of + [[shadow.grove/queue-fx]]. + ### Request definition + The request definition is a map with the following keys: + * `:request` – possible formats: + - `[method uri body opts]` + - `{:method , :uri , :body , :request-format ,}`. `:method` defaults to `POST` + if `:body` is present, `GET` otherwise. `:uri` defaults to empty string. + - `uri` string, same as `[:GET uri nil {}]`. + `uri` example: + ```clojure + [\"foo\" \"bar\" {:age 42 :name \"bob\"}] ;; \"foo/bar?age=42&name=bob\" + ``` + Keys allowed in `opts` (or in the map): `:request-format`, `:timeout` (see below). + * `:on-success` - event (map) to trigger if the request is successful + (status code < 400). The transformed (per `:response-formats`, see below) + response will be available in the event map under `:result`. + * `:request-many` – optional coll of requests. Only allowed instead of `:request`. + * `:on-error` – optional. See below. + ### `config` + `config` is a map with the following options: + * `:request-format` – sets the `content-type` of request header and transforms + request body. Value can be: + - `:edn` + - `:transit` (the `transit-str` needs to be available, see `grove.transit` ns.) + - a function `(request-format env body opts)` returning `[request-header request-body]`. + + Can be specified in: + 1. `opts` of the request definition + 2. `config` of `make-handler` + 3. `env` under `::http-fx/request-format` – the `fx-env` of fx where `make-handler` is used + * `:response-formats` - a map associating `content-type` of the response to + transformation functions handling the body of the response. The defaults can + be found in [[default-response-formats]]. Users can specify their own in + `config` of [[make-handler]]. + * `:on-error` – event (map) to trigger if the request returns an error + (status code >= 400). The response, status and the request itself will be + available in the event map under `:result`, `:status` and `:sent-request`. + Can be specified in: + 1. request definition + 2. `config` of `make-handler` + 3. `env` under `::http-fx/on-error` – the `fx-env` where `make-handler` is used. + * `:base-url` – part of the url common to all requests initiated by the handler. + * `:timeout` - sets `XMLHttpRequest.timeout`. Specified in `:opts` of the request. + * `:with-credentials` - boolean for `XMLHttpRequest.withCredentials`. Can be + specified in `:opts` of the request or `config` of [[make-handler]]. + + If using `transit`, add the following to your `init` fn: + ```clojure + (shadow.grove.transit/init! rt-ref) + ``` + There's also `shadow.grove.edn`." + [config] ;; FIXME: deep-merge, so maps are merged properly (let [config (update config :response-formats merge-right default-response-formats)] (fn http-fx-handler [env request-def] @@ -268,32 +322,30 @@ request (do-request config env request - (fn [result status sent-request xhr-req] - (if (request-error? status) - (handle-error config env request-def result status sent-request xhr-req) - (handle-success config env request-def result)))) + (fn [result status sent-request xhr-req] + (if (request-error? status) + (handle-error config env request-def result status sent-request xhr-req) + (handle-success config env request-def result)))) request-many (let [results-ref (atom {})] (reduce-kv (fn [_ key request] (do-request config env request - (fn [result status sent-request xhr-req] - (if (request-error? status) - ;; FIXME: should also support :on-partial-success (when at least on of the requests succeeds) - (handle-error config env request-def result status sent-request xhr-req) - (do (swap! results-ref assoc key result) - ;; FIXME: should also support :on-progress (for intermediate completion) - (when (= (count request-many) - (count @results-ref)) - (handle-success config env request-def @results-ref))))) - )) + (fn [result status sent-request xhr-req] + (if (request-error? status) + ;; FIXME: should also support :on-partial-success (when at least on of the requests succeeds) + (handle-error config env request-def result status sent-request xhr-req) + (do (swap! results-ref assoc key result) + ;; FIXME: should also support :on-progress (for intermediate completion) + (when (= (count request-many) + (count @results-ref)) + (handle-success config env request-def @results-ref))))))) nil request-many)) :else - (throw (ex-info "missing :request OR :request-many, need one" {:request-def request-def})) - ))) + (throw (ex-info "missing :request OR :request-many, need one" {:request-def request-def}))))) env))) (comment @@ -303,4 +355,4 @@ (handler env {:request ["foo" {:id 1}] :on-error [::error!] - :on-success [::success!]}))) \ No newline at end of file + :on-success [::success!]}))) diff --git a/src/main/shadow/grove/protocols.cljs b/src/main/shadow/grove/protocols.cljs index 73aba72..2684a16 100644 --- a/src/main/shadow/grove/protocols.cljs +++ b/src/main/shadow/grove/protocols.cljs @@ -25,16 +25,45 @@ (defprotocol IHook - (hook-init! [this component-handle]) - (hook-ready? [this]) - (hook-value [this]) + (hook-init! [this component-handle] + "used to create a hook managed by `component`. called on component mount. + example: + `(bind foo (+ bar 1))` will create a hook named `foo`, whose value + is initialised to `(+ bar 1)` on component mount. + specifically, `foo` will be (SimpleVal. (+ bar 1)). + (this is the default implementation of this protocol, + found in grove.components.)") + (hook-ready? [this] + "called once on mount. used for suspense/async. when false the component will + stop and wait until the hook signals ready. then it will continue mounting + and call the remaining hooks and render.") + (hook-value [this] + "return the value of the hook") ;; true-ish return if component needs further updating - (hook-deps-update! [this val]) - (hook-update! [this]) - (hook-destroy! [this])) + (hook-deps-update! [this val] + "called when the deps of the hook change. + example: `(bind foo (+ bar))`, this method is called when `bar` changes. + if true-ish value returned, hooks depending on this hook will update. + + `val` corresponds to the result of evaluating the body of the hook + (with updated deps). e.g. result of `(+ bar)` in example above. + If bind body returns a hook, val will be that hook (a custom type).") + (hook-update! [this] + "called after the hook's value becomes invalidated. + (e.g. with `comp/hook-invalidate!`) + if true-ish value returned, hooks depending on this hook will update. + hook-invalidate! marks the hook as dirty and will add the component to the work set. + then the work set bubbles up to the root and starts to work there, + working off all pending work and calling hook-update! for all 'dirty' hooks. + if that returns true it'll make all hooks dirty that depend on the hook-value, + eventually reaching render if anything in render was dirty, then proceeding + down the tree.") + (hook-destroy! [this] + "called on component unmount")) (defprotocol IHookDomEffect - (hook-did-update! [this did-render?])) + (hook-did-update! [this did-render?] + "called after component render")) (defprotocol IComponentHookHandle @@ -58,4 +87,4 @@ events]) (defprotocol IQuery - (query-refresh! [this])) \ No newline at end of file + (query-refresh! [this]))