Last active
August 29, 2015 14:21
-
-
Save bensu/d1693fdd76162f2c5648 to your computer and use it in GitHub Desktop.
Draft for Event Handling in Om
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| ;; Problem | |
| ;; Event Handling in UIs. We only care about some of the events fired | |
| ;; by the user or IO and it is not always possible to handle them at | |
| ;; the point where we pick them up from the DOM. | |
| ;; Thus a messaging/piping infrastructure is need to take the events | |
| ;; from the place where they are interpreted from the DOM to the place | |
| ;; that handles them. Each application rolls it's own messaging | |
| ;; infrastructure, can we do any better? | |
| ;; Design Constraints | |
| ;; 1. Each component should only know about the events it handles/raises, nothing about it parent. | |
| ;; 2. Each parent can know about the children's events it wants to | |
| ;; handle but allow other components higher in hierarchy to handle the | |
| ;; events it can't. | |
| ;; Solution | |
| ;; Inspired by the Exception Handling analogy I thought that each | |
| ;; component could interpret the browser/IO events it cares about and | |
| ;; then raise the interpreted version for the parent | |
| ;; to handle. Before raising the event, the component tags it with a | |
| ;; `tag-fn` passed at build time by the parent. The parent now | |
| ;; receives the tagged events, queries them, and handles them | |
| ;; appropriately. If it fails to handle them, it tags them, and | |
| ;; raises them again, putting them in the `om-ch` that is globally observable. | |
| (defn raise! [tag-fn om-ch e] | |
| (put! om-ch (tag-fn e))) | |
| ;; As a motivation I'll explore an editable component | |
| ;; similar to the one presented | |
| ;; [here](https://github.com/omcljs/om/wiki/Intermediate-Tutorial). It | |
| ;; has a text input and a trash icon to be deleted. I'll write | |
| ;; "working" om code that (though clumpsy) exhibits the desired semantics. | |
| (defn editable | |
| "Editable knows about three events: | |
| :enter - it should report when \"Enter\" is pressed. | |
| :delete - when clicking the trash icon | |
| :edit - fired with the new contents onChange" | |
| [content owner {:keys [tag-fn om-ch]}] | |
| (reify | |
| (render [_] | |
| (dom/div nil | |
| (dom/i {:class "trash-icon" | |
| :onClick (fn [_] (put! om-ch (tag-fn [:delete nil])))}) | |
| (dom/input #js {:value content | |
| :onChange #(let [v (.. % -target -value)] | |
| (put! om-ch (tag-fn [:edit v]))) | |
| :onKeyDown #(when (= "Enter" (.-key %)) | |
| (put! om-ch (tag-fn [:new nil])))}))))) | |
| ;; editable is used by list-maker which edits a list of strings. | |
| ;; list-maker handles all the events produced by editable and manages | |
| ;; the cursor's state. | |
| (defn list-maker | |
| "Allows users to edit a sequence of strings" | |
| [contents owner {:keys [tag-fn om-ch]}] | |
| (reify | |
| (will-mount [_] | |
| (go-loop [] | |
| (let [e (<! (om/get-state owner :om-ch))] | |
| ;; Query & Handle | |
| (if (= :editable (get-raiser e)) | |
| (case (get-tag e) | |
| :new #(om/transact! contents #(conj % "")) | |
| :edit #(om/update! contents index (get-data e)) | |
| :delete #(om/transact! contents #(drop-nth index %))) | |
| ;; If we can't handle the event, we tag it and bubble it up | |
| (put! om-ch (tag-fn e)))))) | |
| (render [_ {:keys [om-ch]}] | |
| (apply dom/ul nil | |
| (map-indexed #(om/build editable %2 | |
| {:opt {:tag-fn (fn [e] (concat [:editable %1] e)) | |
| :om-ch om-ch}}) | |
| contents))))) | |
| ;; For the current event representation the query helpers would be: | |
| (defn get-data [e] (last e)) | |
| (defn get-raiser [e] (first (take-last 2 e))) | |
| ;; To test this design, let's change the requirements and see how much | |
| ;; things need to be adapted. Now list-maker is needed to | |
| ;; work on some `buffer-contents` and only when a new "Save" button is | |
| ;; pressed should those changes be commited. We can make a higher | |
| ;; component, buffer-handler, that has list-maker and save-button as | |
| ;; children and deal with the coordination. To complicate things, | |
| ;; we'll say that for markup reasons, save-button needs to be under list-maker | |
| (defn save-button | |
| "Cursor contract: none. | |
| Event contract: raises [:save] onclick" | |
| [data owner {:keys [om-ch tag-fn]}] | |
| (reify | |
| (dom/i #js {:class "save-icon" | |
| :onClick (fn [_] (put! om-ch (tag-fn [:save nil])))}))) | |
| (defn list-maker [contents owner opts] | |
| (reify | |
| (will-mount [_] ...) ;; Same code | |
| (render [_ {:keys [om-ch]}] | |
| (dom/div nil | |
| (apply dom/ul nil | |
| (map-indexed #(om/build editable %2 | |
| {:opts {:om-ch om-ch | |
| :tag-fn (fn [e] (concat [:editable %1] e))}}) | |
| contents)) | |
| ;; Added save-button | |
| (om/build save-button {} | |
| {:opts {:om-ch om-ch | |
| :tag-fn (fn [e] (concat [:save-button] e))}}))))) | |
| (defn buffer-handler [contents owner {:keys [om-ch tag-fn]}] | |
| (reify | |
| (init-state [_] | |
| {:buffer-contents @contents}) | |
| (will-mount [_] | |
| (go-loop [] | |
| (let [e (<! om-ch)] | |
| ;; Query | |
| (if (and (= :save-button (get-raiser e)) | |
| (= :save (get-tag e))) | |
| ;; Handle when query matches | |
| (commit-changes! contents (om/get-state owner :buffer-contents)) | |
| ;; Keep raising when no query matches | |
| (put! om-ch (tag-fn e)))))) | |
| (render-state [_ {:keys [buffer-contents om-ch]}] | |
| (om/build list-maker buffer-contents | |
| {:opts {:om-ch om-ch | |
| :tag-fn (fn [e] (concat [:list-maker] e))}})))) | |
| ;; In this example, the problem and solution are somewhat | |
| ;; isomorphic. I'm happy with the semantics but appalled by the | |
| ;; bookkeeping. How should this look? | |
| ;; Following @bbloom's lead, each component registers an event handler | |
| ;; through IHandle. We have a `match` function that runs a query over | |
| ;; the event and bind the results to some symbols. If the results are | |
| ;; non-nil, it executes the body and informs that the event was | |
| ;; handled. Otherwise, it informs that the event was not handled and | |
| ;; execution should proceed: | |
| (defmacro match [bindings query &body] | |
| `(if-let [bindings query] | |
| (do | |
| @body | |
| ::handled) | |
| ::not-handled)) | |
| ;; Rewriting the components: | |
| (defn editabe [content owner] | |
| ;; Needs no change except for (raise! [:delete nil]) | |
| ;; instead of (put! om-ch (tag-fn [:delete nil])) | |
| ) | |
| (defn list-maker [contents owner] | |
| (reify | |
| ;; No will-mount | |
| (render [_ {:keys [om-ch]}] | |
| (dom/div nil | |
| (apply dom/ul nil | |
| (map-indexed #(om/build editable %2 | |
| {:tag-fn (fn [e] (concat [:editable %1] e))}) | |
| contents)) | |
| (om/build save-button {} | |
| {:tag-fn (fn [e] (concat [:save-button] e))}))) | |
| IHandle | |
| (handle [e] | |
| (match [index (query '[:find ?i :where [?e :raiser [:editable ?i]]] e)] | |
| (match [content (query '[:find ?c | |
| :where [?e :tag :edit] | |
| [?e :data ?c]] | |
| e)] | |
| (om/update! contents index content)) | |
| (match [] (query '[:find _ :where [?e :tag :delete]] e) | |
| (om/transact! contents #(drop-nth index %))) | |
| (match [] (query '[:find _ :where [?e :tag :new]] e) | |
| (om/transact! contents #(conj % ""))))))) | |
| (defn save-button [content owner] | |
| ;; Needs no change except for (raise! [:delete nil]) | |
| ;; instead of (put! om-ch (tag-fn [:delete nil])) | |
| ) | |
| (defn buffer-handler [contents owner {:keys [om-ch tag-fn]}] | |
| (reify | |
| (init-state [_] | |
| {:buffer-contents @contents}) | |
| ;; No will-mount | |
| (render-state [_ {:keys [buffer-contents om-ch]}] | |
| (om/build list-maker buffer-contents | |
| {:tag-fn (fn [e] (concat [:list-maker] e))})) | |
| IHandle | |
| (handle [e] | |
| (match [index (query '[:find ?i | |
| :where [?e :raiser :save-button] | |
| [?e :tag :save]] | |
| e)] | |
| (commit-changes! contents (om/get-state owner :buffer-contents)))))) | |
| ;; The new syntax is more declarative but it's still | |
| ;; verbose because it uses Datalog when something simpler | |
| ;; would suffice[2]. The syntax also shows the two things compoennts | |
| ;; need to do: tell their children how to tag events and handle them. | |
| ;; It hides the passing of channels, the loop in each component, the | |
| ;; bubbling up of unhandled events, and simplifies the call to raise. | |
| ;; [1] The pseudo code for match is conceptually flawed. | |
| ;; Though it allows nesting calls it returns ::handled if the top one | |
| ;; matched but the inner ones that should do the handling don't. | |
| ;; [2] TODO: Datalog can query arbitrary graphs, while we just need to | |
| ;; query on a collection. Find that Query Language. |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Feedback from @bbloom in irc #clojurescript on 2015-05-25
Addressing particular points of my proposed design:
core.async? It's a way of implementing the semantics I wanted but shouldn't be part of the final API. In the final version there is nocore.async.(handler [route event])and doing{:route '() :event [:tag :data]}will suffice.raise!from insidematch. Returning the desired event value is more composable.:tag-fn? It's a non-automatic way of defining how should the route be built. When the event starts in the child it has only[tag data]and no route. Each time it bubbles up to a parent, the route gets bigger.:tag-fndefines how. By default itconjs the component type (:list-maker) to the route, but it could be custom in case indexes ([:editable 2]) or unique ids are used to discern between many children of the same type.Bigger points concerning the design assumptions: