├── .gitignore ├── .travis.yml ├── README.md ├── doc └── Tutorial.md ├── ex └── oak │ ├── devcards.cljs │ ├── examples │ ├── counters.cljs │ └── github.cljs │ └── experimental │ └── devcards.cljs ├── package.json ├── project.clj ├── resources └── public │ └── index.html ├── src └── oak │ ├── component.cljs │ ├── component │ └── higher_order.cljs │ ├── dom.cljs │ ├── internal │ └── utils.cljs │ ├── oracle.cljs │ ├── oracle │ └── higher_order.cljs │ ├── render.cljs │ └── schema.cljs └── test └── oak ├── core_test.cljs └── test_runner.cljs /.gitignore: -------------------------------------------------------------------------------- 1 | target/ 2 | classes/* 3 | checkouts/* 4 | pom.xml 5 | pom.xml.asc 6 | *.jar 7 | *.class 8 | /.lein-* 9 | /.nrepl-port 10 | .idea 11 | oak.iml 12 | resources/public/js 13 | figwheel_server.log 14 | node_modules/ 15 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: clojure 2 | lein: lein2 3 | before_install: 4 | - npm install 5 | script: lein2 doo phantom test once 6 | cache: 7 | directories: 8 | - $HOME/.m2 9 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # Oak 3 | 4 | *A library for compositional web applications.* 5 | 6 | The fundamental challenges of web application design are state and time 7 | management. Oak tackles these challenges head-on be composition of 8 | standard (Moore-style) state machines. Oak is built atop React and uses 9 | a design very similar to the Elm architecture which influenced 10 | Javascript's `redux` library---so if you know those technologies, this 11 | will look pretty familiar. 12 | 13 | ## Status 14 | 15 | *Alpha*. The Oak API is still under active investigation and is subject 16 | to change. There is not yet a public release. 17 | 18 | ## Try it out! 19 | 20 | This repository is both the library and a Devcards environment of 21 | examples. Download the repo and run `lein figwheel` to access the 22 | examples. 23 | 24 | ## Comparison to similar libraries 25 | 26 | Oak can be compared to other React wrapper libraries in various 27 | languages and React itself to the degree that they take perspectives on 28 | state management. 29 | 30 | - **Elm** Oak steals prodigiously from the 31 | ["Elm Architecture"](http://www.elm-tutorial.org/030_elm_arch/cover.html) 32 | but ignores the notion of signals and mailboxes as those mostly don't 33 | matter for UI composition. Oak uses runtime schema validation instead 34 | of static types to ensure that state and events are proper which is a 35 | strict disadvantage in documentation and safety. Elm has no standard 36 | notion of queries and instead uses "signal ports" to manage global 37 | state. 38 | 39 | - **React** Oak's technology is based atop React although it is largely 40 | agnostic to much of the technology there. Oak is React-compatible so 41 | that you can re-use your other React components if you like, but Oak is 42 | significantly more strict about how state is managed and components 43 | updated. Essentially, Oak uses the props system alone and completely 44 | ignores lifecycles and React component state. 45 | 46 | - **Redux** Oak and Redux are both based on the Elm Architecture and its 47 | simple state-machine based perspective, but Redux splits the state 48 | management and component render components while Oak recognizes that 49 | for a lot of "local" state the state management system is shaped 50 | exactly the same as your component trees and takes advantage of that. 51 | Oak solves asynchronous state without invoking effects within 52 | components the way that Redux's "action creator" standard does. Of 53 | course, Redux is very flexible so there's nothing preventing it from 54 | using a `:query`-like system, too. 55 | 56 | - **Relay** (*Ed.* I'm not as familiar with this one). Oak uses 57 | composable queries but doesn't have a buy-in to a particular query 58 | language. If you wanted to offer GraphQL Oak queries then you could do 59 | this very naturally! 60 | 61 | - **om.next** Oak and om.next both buy in heavily to the notion of having 62 | component-level queries that compose (similar to Relay as well), but 63 | for om.next these are sophisticated concepts tied into notions of 64 | component identity and data normalization. Oak doesn't demand or 65 | expect much at all from its Oracles excepting that they have a notion 66 | of a pure cache and state-machine like update mechanism. 67 | 68 | - **Reagent** Oak has *vastly* simplified state management from the 69 | reactive update style of Reagent. This affords potentially lower 70 | performance, but it turns out that virtual DOM diffing really does 71 | solve this problem much of the time. 72 | 73 | - **Re-frame** Oak relies not at all on global state and has a 74 | simplified flow with similar comparison as to Reagent. Additionally, 75 | it takes advantage of differentiating local and application state to 76 | make it easier to compose local state with similar caveats as when 77 | comparing to Redux. 78 | 79 | ## Concepts 80 | 81 | **Components are rendered as pure functions of some local parameters** 82 | 83 | At the core of any component is a pure function from an immutable value 84 | to a ReactElement. Oak doesn't really care how you construct this 85 | function and is reasonably naive to choice of React wrappers. What's 86 | important is that to the greatest degree possible the entire state of 87 | your component is reflected in this immutable parameter. 88 | 89 | **Applications are state machines, reductions of state over an event stream** 90 | 91 | Ultimately, an application's ("local") function is just a big reduction 92 | step. An "event" is generated from the UI and this triggers a transition 93 | of the current state to the next one. 94 | 95 | **Local state is different from "Application State"** 96 | 97 | Local state and "application state" behave very differently. To draw the 98 | line clearly, local state answers questions like "is my dropdown 99 | expanded?" while application state answers questions like "what is 100 | User #10's first name?". Local state is denormalized, changes 101 | synchronously, and is shaped almost the same as your UI ReactElement 102 | tree. Application state is normalized, updates asynchronously, and is 103 | shaped more like a SQL database. 104 | 105 | **State, events, reducers, and application state queries are all fractal** 106 | 107 | Oak encourages you to build up to the full complexity of your interface 108 | step-by-step by composing smaller fully functional "applications" 109 | one-by-one. An Oak "component" is pretty much exactly that—a small, 110 | fully functional application, and it expects that any other component 111 | which embeds it supports that view. 112 | 113 | As it turns out, it's easy to compose the upward and downward 114 | information flows of nested applications with simple, pure functions. 115 | The wiring burden is highly distributed and each component sees all of 116 | the state of the world it needs. 117 | 118 | ### What is a component? 119 | 120 | A component is a set of 3 functions and 2 schemata. It is a spec for a 121 | fully functional web interface all by itself. 122 | 123 | ```clojure 124 | :state ; a schema describing the local state 125 | :event ; a schema describing the events this component emits 126 | 127 | :query ; a function constructing the application state (different from 128 | ; :state) queries this component demands 129 | ; (this is described in more detail later) 130 | 131 | :step ; a (pure) function (event, state) -> state describing how 132 | ; :events this component emits update the local :state 133 | 134 | :view ; a (pure) function (state, submit-fn) -> ReactElement which 135 | ; interprets the state as a UI view. Here, submit-fn is a 136 | ; callback the UI view uses to submit :events to the :step 137 | ; function updating the :state. 138 | ``` 139 | 140 | When a component is run it's expected that it will be provided with an 141 | initial `:state` to generate the `:view`. It's expected that the 142 | `:query` is satisfied by a third-party "oracle" and used to construct 143 | the `:state`. It is expected that any `:event` submitted in the `:view` 144 | is used by the `:step` function to update the `:state` and `:query` then, 145 | likely, triggering a re-render if the change is substantial. 146 | 147 | From the outside, *all* of the `:state`, `:event`, `:step`, `:query`, 148 | and `:view` ought to be considered private and abstract. This key to 149 | making composition scale. 150 | 151 | When working with components it's a good idea to provide a function to 152 | construct the initial state of your component. Users of your component 153 | are well-recommended to use it, too, in order to maintain the 154 | abstractness of the component `:state`. 155 | 156 | ## A simple example 157 | 158 | An example component using only local state is a counter with increment 159 | and decrement buttons. We use the `oak/make` function to build it. By 160 | default this uses Quiescent to wrap up the `:view` function into a React 161 | component factory letting React lifecycle hooks become available, but you 162 | can choose whatever ReactElement constructor you like (see the 163 | `:build-factory` key) 164 | 165 | ```clojure 166 | (require '[oak.core :as oak]) 167 | (require '[oak.dom :as d]) 168 | (require '[schema.core :as s]) 169 | 170 | (def counter 171 | (oak/make 172 | :name "Counter" 173 | :state s/Int 174 | :event (s/enum :inc :dec) 175 | :step (fn [event state] 176 | (case event 177 | :inc (inc state) 178 | :dec (dec state))) 179 | :view (fn [state submit] 180 | (d/div {} 181 | (d/button {:onClick (fn [_] (submit :dec))} "-") 182 | (d/span {} (str state)) 183 | (d/button {:onClick (fn [_] (submit :inc))} "+"))))) 184 | ``` 185 | 186 | ## Application state and Oracles 187 | 188 | Application state is often a complex case. Oak essentially avoids 189 | committing to anything about application state, but instead offers 190 | another state-machine-like interface for handling application state 191 | called the "Oracle". 192 | 193 | Essentially, an oracle is responsible for maintaining some kind of data 194 | cache (anything from a map to a DataScript database), using it to 195 | substantiate component `:query` requests during render steps, and then, 196 | asynchronously, refreshing it given knowledge of all the queries which 197 | were requested. 198 | 199 | The structure of the queries, the nature of the responses, the design of 200 | the cache, and the mechanism of refreshing it are all up to the design 201 | of the Oracle. 202 | 203 | Some example Oracles might be 204 | 205 | - A simple map where the queries are the keys and refreshing entails 206 | nothing more than updating the cache value from a global atom which is 207 | updated periodically though some other mechanism. 208 | - HTTP requests to a backend API which are cached with a TTL. Queries are 209 | initially substantiated with a placeholder and then refresh steps 210 | replace that placeholder in the cache with an actual successful fetch 211 | or the appropriate error. 212 | - A DataScript database where queries are actual datalog queries 213 | - A combination of all of the above acting in parallel! 214 | 215 | ## System actions 216 | 217 | Missing from the architecture so far is the ability to trigger 218 | system-level state transitions. In other words, if `:query` behaves a 219 | bit like an HTTP `GET` request, what are `POST` and `PUT`? 220 | 221 | Oak has no built-in answer for this! Generally, one might think of 222 | either (a) having an impure top-level `:step` function which interprets 223 | local `:event`s as having global consequences, or (b) passing down a 224 | CSP channel application-event bus as part of the local state which can 225 | be used by some components to submit application-level events. 226 | 227 | # License 228 | 229 | Copyright (c) 2016, Joseph Abrahamson 230 | All rights reserved. 231 | 232 | Redistribution and use in source and binary forms, with or without 233 | modification, are permitted provided that the following conditions are 234 | met: 235 | 236 | 1. Redistributions of source code must retain the above copyright 237 | notice, this list of conditions and the following disclaimer. 238 | 239 | 2. Redistributions in binary form must reproduce the above copyright 240 | notice, this list of conditions and the following disclaimer in the 241 | documentation and/or other materials provided with the distribution. 242 | 243 | 3. Neither the name of the copyright holder nor the names of its 244 | contributors may be used to endorse or promote products derived from 245 | this software without specific prior written permission. 246 | 247 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS 248 | IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED 249 | TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A 250 | PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 251 | HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 252 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED 253 | TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR 254 | PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF 255 | LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING 256 | NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 257 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 258 | -------------------------------------------------------------------------------- /doc/Tutorial.md: -------------------------------------------------------------------------------- 1 | 2 | # Oak Tutorial 3 | 4 | *A library for compositional web applications.* 5 | 6 | The fundamental challenges of web application design are state and time 7 | management. Oak tackles these challenges head-on be composition of 8 | standard (Moore-style) state machines. Oak is built atop React and uses 9 | a design very similar to the Elm architecture which influenced 10 | Javascript's `redux` library---so if you know those technologies, this 11 | will look pretty familiar. 12 | 13 | ## Constant components 14 | 15 | Oak decomposes applications into components. The simplest components are 16 | *constant* components which are nothing more than a view function. 17 | 18 | ```clojure 19 | (require '[oak.core :as oak]) 20 | (require '[oak.dom :as d]) 21 | 22 | (def my-component 23 | (oak/make 24 | {:name "My-Component" 25 | :view 26 | (fn [_ _] 27 | (d/h1 {:class "my-component"} "Hello world"))})) 28 | ``` 29 | 30 | As you can see, you use the `oak/make` function to create components and 31 | then define a `:view` function of two (currently ignored) arguments. 32 | This view function should be pure and returns a virtual dom tree 33 | constructed from functions from `oak.dom` or from the view functions of 34 | other Oak components as we will see now. 35 | 36 | ```clojure 37 | (def my-layout 38 | (oak/make 39 | :name "My-Layout" 40 | :view 41 | (fn [_ context] 42 | (d/section {:class "layout"} 43 | (my-component nil context))))) 44 | ``` 45 | 46 | Here, another constant component is created demonstrating two new ideas. 47 | First, we see that Oak components implement `IFn` and can be called as 48 | regular functions of two arguments. Second, we see that the second 49 | argument is called the "context" and must be passed to the inner 50 | component. 51 | 52 | > Why don't we use dynamic binding to do context passing? This would 53 | > let our components take only one argument, after all! Oak avoids this 54 | > for two reasons, though. First, explicit passing is simpler and makes 55 | > testing easier. Second, we'll see later that it's not at all uncommon 56 | > to *modify* the context before you pass it on to child components--- 57 | > explicit passing makes this more obvious. 58 | 59 | ## Parameterizing components 60 | 61 | Constant components are pretty boring. If `view` must be pure, then we 62 | need to pass in interesting arguments for it to behave nicely! For this 63 | we introduce the *model*. 64 | 65 | ```clojure 66 | (require '[schema.core :as s]) 67 | 68 | (def number-display 69 | (oak/make 70 | {:name "Number-Display" 71 | :model {:name s/Str :value s/Int} 72 | :view 73 | (fn [model context] 74 | (d/div {:class "number-display"} 75 | (d/strong (:name model)) 76 | (str (:value model))))})) 77 | ``` 78 | 79 | Here, we finally see what the first argument to our view function is: 80 | the "model" for this component. "Model" (and "view") here steals from 81 | MVC terminology so the right way to think is that the model describes 82 | the "raw" data substantiating our view. We give our model a schema for 83 | documentation and dev-time checking purposes. 84 | 85 | > Models aren't necessarily business objects. In Oak we take note that 86 | > state within your UI is often relatively denormalized (repeated) and 87 | > may not be easily kept in synch with the domain objects your code is 88 | > dealing in. Think of the view as a very thin layer over the model and 89 | > we'll see how to handle larger scale "domain state" later. 90 | 91 | Finally, we should show how to provide an instance of the model to your 92 | components---we just pass it in next to the context, of course! 93 | 94 | ```clojure 95 | (def scoreboard 96 | (oak/make 97 | {:name "Scoreboard" 98 | :model {:runs (oak/model number-display) 99 | :hits (oak/model number-display) 100 | :errors (oak/model number-display) 101 | :view 102 | (fn [model context] 103 | (d/ul {:class "scoreboard"} 104 | (d/li {} (number-display {:name "Runs" :value (:runs model)})) 105 | (d/li {} (number-display {:name "Hits" :value (:hits model)})) 106 | (d/li {} (number-display {:name "Errors" :value (:errors model)}))))})) 107 | ``` 108 | 109 | As a side note, take notice that we can use `oak/model` to get the model 110 | schema of a component. This is highly recommended when composing 111 | components! It'll protect your schema definition from changes to your 112 | subcomponent models. 113 | 114 | ## States in motion, actions and steps 115 | 116 | So far, we've created a pretty belabored templating system, but UIs need 117 | to support interaction! For this, we introduce the notion of the state 118 | machine. 119 | 120 | First, an example: 121 | 122 | ```clojure 123 | (def counter 124 | (oak/make 125 | {:name "Counter" 126 | :model s/Int 127 | :action (s/enum :inc :dec) 128 | :step 129 | (fn [action model] 130 | (case action 131 | :inc (inc model) 132 | :dec (dec model))) 133 | :view 134 | (fn [model context] 135 | (d/div {:class "counter"} 136 | (d/button {:onClick (fn [_] (oak/act context :inc))} "+") 137 | (d/span {:class "number"} (str model)) 138 | (d/button {:onClick (fn [_] (oak/act context :dec))} "-")))})) 139 | ``` 140 | 141 | What we can see here is that the view consists of a numeric display with 142 | two buttons. On clicking the button we `oak/act` upon the `context` with 143 | a keyword. There's a new definition, the `:step` function which takes in 144 | the action and the model and defines how this action "acts" upon the 145 | model. Finally, we note that the `:action` key defines a schema 146 | describing all possible actions. 147 | 148 | So, the obvious thing to think is that our component is able to send 149 | messages to itself (called "actions") and the `:step` function controls 150 | how these messages cause updates. This is, in fact, exactly the case, 151 | but let's belabor this a bit further. 152 | 153 | ### State machines 154 | 155 | A state machine is a way of describing how some piece of state evolves 156 | over discrete time steps by way of "events". Given a state, `s` and such 157 | an event, `e`, we also need a function `(next e s)` which produces the 158 | next state. 159 | 160 | From here there are lots of things to examine. We can a state machine as 161 | turning a sequence of events into a `trajectory` on the state of spaces 162 | 163 | ```clojure 164 | (defn trajectory [initial-state event-sequence] 165 | (reductions next initial-state event-sequence)) 166 | ``` 167 | 168 | We could also see `next` as giving us a set of "transition functions" on 169 | the state space, one for each action: 170 | 171 | ```clojure 172 | (defn transition-fn [event] 173 | (fn [state] (next event state))) 174 | ``` 175 | 176 | But the real thing to take home is how a state machine forms a 177 | self-contained world atop just a single pure function. Many quite 178 | sophisticated things can be modeled as a state machine and thus we take 179 | them as the basis for Oak. 180 | 181 | Except, we rename `next` to `step`, `event` to `action`, and `state` to 182 | `model` just to have our own nomenclature. 183 | 184 | ## Composing state machines 185 | 186 | Up until this point composing components has been trivial. This is 187 | because (a) we pretty much only had to pass our models "down" the tree 188 | and (b) because we didn't have to worry about changes. 189 | 190 | Once we add in the state machine technology we'll need to account for it 191 | as well when composing components. To do this, let's build a list of 192 | counters: the most sophisticated example so far! 193 | 194 | > It's worth noting that Oak *could* choose do a lot of the following 195 | > composition automatically for you. Why do we leave the boilerplate to 196 | > you? Well, first, combinators exist to automate this for you later, 197 | > and second, these are matters of design and it's better leave them 198 | > explicitly up to you, the user. Oak is almost always going to avoid 199 | > magic as much as possible. 200 | 201 | ```clojure 202 | (def counter-set 203 | (oak/make 204 | {:name "Counter-Set" 205 | :model [(oak/model counter)] 206 | :action (s/cond-pre 207 | :new 208 | (s/pair s/Int 209 | :index 210 | (s/cond-pre 211 | (s/eq :remove) 212 | (oak/action counter) 213 | :inner-action)) 214 | :step ... 215 | :view ...})) 216 | ``` 217 | 218 | Let's stop here and examine what's going on. First, we note that our 219 | model is defined as a vector of `counter` models. We'll represent our 220 | counter set as an ordered set where we can index each counter by the 221 | position of its "sub"-model in this vector. 222 | 223 | We also see that the `:action` schema has gotten a little complex. 224 | Essentially, actions are now either `:new`, presumably generating a new 225 | counter for our vector, or a pair (a two element vector) of an index and 226 | an "inner action". Inner actions are either `:remove` or an action from 227 | a `counter`. 228 | 229 | So it might be clear where we're going with this now, but for the 230 | avoidance of all doubt: the set of actions of a component are (often) 231 | the actions of all of its subcomponents adjoined to its own actions. 232 | 233 | Let's see how this plays out in the `:step` function 234 | 235 | ```clojure 236 | ... 237 | :step 238 | (fn [action model] 239 | (match action 240 | :new (conj model 0)) 241 | [index :remove] (vector-remove-at model index) 242 | [index inner-action] (update model index (oak/stepf counter inner-action)))) 243 | ... 244 | ``` 245 | 246 | We've brought in Clojure's pattern matching macro for a little help 247 | describing how to handle these actions, but there's not a whole lot 248 | going on here. If the action is `:new` we conj on a new counter model. 249 | If it's a pair we'll be acting on the sub-model at the cooresponding 250 | index. If the action is `:remove` then we'll throw that sub-model away 251 | (definition of `vector-remove-at` elided). Otherwise, we pass the 252 | interpretation of the action down to the `step` function of `counter` 253 | using a helper function `stepf` (which generates transition functions 254 | from a component). 255 | 256 | Notably, again, this has the feeling of boilerplate (and indeed can and 257 | is automated in common cases) but it's not too burdensome so long as we 258 | take it little piece by little piece like so. The core concept of Oak is 259 | compositionality and so a lot of the emphasis ends up being on 260 | composition. 261 | 262 | So, finally, let's take a look at what our view looks like. 263 | 264 | ```clojure 265 | ... 266 | :view 267 | (fn [model context] 268 | (let [children (map-indexed 269 | (fn [ix submodel] 270 | (d/div {} 271 | (d/button {:onClick (fn [_] (oak/act context [ix :remove]))} "Remove") 272 | (counter 273 | submodel 274 | (oak/route context (fn [a] [ix a]))))) 275 | model)] 276 | (apply d/div {:class "counter-set"} 277 | (d/button {:onClick (fn [_] (oak/act context :new))} "New!") 278 | children))) 279 | ... 280 | ``` 281 | 282 | There are a few interesting things going on here. First, note that we 283 | build each child dynamically from our model which ends up being vector 284 | of counter models. Second, note that we construct our `:remove` action 285 | by building a pair of the current index and the `:remove` keyword. 286 | Third, and most important note how we use `oak/route` to *modify the 287 | context* so that it prepends the index to *all* child actions. 288 | 289 | To be clear, `oak/route` lets us modify a context by providing a 290 | "pre-processor" function which modifies actions in flight before passing 291 | them on. This is worth analyzing carefully. 292 | 293 | In particular, we note that as we move "down" the tree we see 294 | composition arise in the *model* as us splitting it into little pieces, 295 | extracting submodels and passing them down. It's an act of 296 | decomposition. On the other hand, at the same time we talk about how to 297 | build actions "up", combining them into super actions. 298 | 299 | If you look at it just right, you see that the actions and the models 300 | compose in "opposite directions". This is exactly the nature of 301 | combining state machines since actions are "inputs" and the models are 302 | "outputs". 303 | 304 | ## Running Oak UIs 305 | 306 | tk. 307 | 308 | ## Interface State versus Application State 309 | 310 | -------------------------------------------------------------------------------- /ex/oak/devcards.cljs: -------------------------------------------------------------------------------- 1 | (ns oak.devcards 2 | (:require 3 | [oak.examples.counters] 4 | [oak.examples.github])) -------------------------------------------------------------------------------- /ex/oak/examples/counters.cljs: -------------------------------------------------------------------------------- 1 | (ns oak.examples.counters 2 | (:require 3 | [cljs.core.match :refer-macros [match]] 4 | [devcards.core :as devcards :include-macros true] 5 | [oak.component :as oak] 6 | [oak.experimental.devcards :as oak-devcards] 7 | [oak.dom :as d] 8 | [schema.core :as s])) 9 | 10 | (def counter 11 | (oak/make 12 | :model s/Int 13 | :action (s/enum :inc :dec) 14 | :step 15 | (fn [action model] 16 | (case action 17 | :inc (inc model) 18 | :dec (dec model))) 19 | :view 20 | (fn [{model :model} submit] 21 | (let [clicker (fn clicker [action] (fn [_] (submit action)))] 22 | (d/div {} 23 | (d/button {:onClick (clicker :dec)} "-") 24 | (d/span {} model) 25 | (d/button {:onClick (clicker :inc)} "+")))))) 26 | 27 | (def counter-with-controls 28 | (oak/make 29 | :model (oak/model counter) 30 | :action (s/cond-pre (s/enum :del) (oak/action counter)) 31 | :step (fn [action model] 32 | (case action 33 | :del model 34 | (oak/step counter action model))) 35 | :view (fn [{model :model} submit] 36 | (d/div {:style {:padding "10px 4px"}} 37 | (counter model submit) 38 | (d/button {:onClick (fn [_] (submit :del))} "Delete"))))) 39 | 40 | (defn ^:private delete-from-vec [v n] 41 | (persistent! 42 | (reduce 43 | conj! 44 | (transient (vec (subvec v 0 n))) 45 | (subvec v (inc n))))) 46 | 47 | (def counter-set 48 | (let [counter counter-with-controls] 49 | (oak/make 50 | :model [(oak/model counter)] 51 | :action (s/cond-pre 52 | (s/eq :new) 53 | (s/pair s/Int :index (oak/action counter) :subaction)) 54 | 55 | :step 56 | (fn [action model] 57 | (match action 58 | :new (conj model 0) 59 | [index :del] (delete-from-vec model index) 60 | [index inner-action] (update model index 61 | (oak/step counter inner-action)))) 62 | 63 | :view 64 | (fn [{model :model} submit] 65 | (d/div {} 66 | (d/button {:onClick (fn [_] (submit :new))} "New Counter") 67 | (apply d/div {} 68 | (for [[index submodel] (map-indexed (fn [i v] [i v]) model)] 69 | (counter submodel (fn [ev] (submit [index ev])))))))))) 70 | 71 | (defonce action-queue 72 | (atom {:model #queue []})) 73 | 74 | (declare single-counter) 75 | (devcards/defcard single-counter 76 | (oak-devcards/render counter-set) 77 | {:model [] :cache {}} 78 | {:on-action (fn [ev] 79 | (swap! action-queue update 80 | :model #(oak-devcards/add-new-action % ev)))}) 81 | 82 | (declare action-set) 83 | (devcards/defcard action-set 84 | (oak-devcards/render oak-devcards/action-demo) 85 | action-queue) 86 | -------------------------------------------------------------------------------- /ex/oak/examples/github.cljs: -------------------------------------------------------------------------------- 1 | (ns oak.examples.github 2 | (:require 3 | [cognitect.transit :as transit] 4 | [cljs.core.match :refer-macros [match]] 5 | [devcards.core :as devcards :include-macros true] 6 | [oak.component :as oak] 7 | [oak.experimental.devcards :as oak-devcards] 8 | [oak.dom :as d] 9 | [schema.core :as s] 10 | [httpurr.client :as http] 11 | [httpurr.client.xhr :as xhr] 12 | [promesa.core :as p] 13 | [oak.oracle :as oracle] 14 | [devcards.util.edn-renderer :as edn-rend])) 15 | 16 | (let [json-reader (transit/reader :json)] 17 | (defn json-read [string] 18 | (transit/read json-reader string))) 19 | 20 | (defn search-profile [name] 21 | (http/send! xhr/client 22 | {:method :get 23 | :url (str "https://api.github.com/users/" name)})) 24 | 25 | (def ex 26 | (oak/make 27 | :model {:value s/Str 28 | (s/optional-key :last-query) (s/maybe s/Str)} 29 | :action (s/cond-pre 30 | (s/eq :query!) 31 | (s/pair (s/eq :set) :keyword s/Str :name)) 32 | :step 33 | (fn [action model] 34 | (match action 35 | :query! (do (println "model" model) 36 | (assoc model :last-query (:value model))) 37 | [:set name] (assoc model :value name))) 38 | 39 | :query 40 | (fn [{:keys [last-query]} q] 41 | (if last-query 42 | {:last-query last-query 43 | :result (q [:q last-query])} 44 | {:last-query nil})) 45 | 46 | :view 47 | (fn [{:keys [model result]} submit] 48 | (d/div {} 49 | (d/form {:onSubmit (fn [e] (.preventDefault e) (submit :query!))} 50 | (d/uinput {:value (:value model) 51 | :onChange (fn [e] (submit [:set (.-value (.-target e))]))}) 52 | (d/input {:type "submit" 53 | :value "Search"})) 54 | (let [{:keys [result]} result] 55 | (when result 56 | (if (= 200 (:status result)) 57 | (let [body (json-read (:body result))] 58 | (d/div {} 59 | (edn-rend/html-edn body))) 60 | "Error"))))))) 61 | 62 | (def oracle 63 | (oracle/make 64 | :model {:last-query s/Inst 65 | :memory {s/Str s/Any}} 66 | :step (fn [action model] 67 | (match action 68 | [:queried date] (assoc model :last-query date) 69 | [:set query result] (assoc-in model [:memory query] result))) 70 | :respond (fn [model [_ name]] 71 | (get-in model [:memory name] {:meta :pending})) 72 | :refresh (fn [model queries submit] 73 | (let [last-query (:last-query model) 74 | now (js/Date.) 75 | diff (.abs js/Math (- (.getTime now) (.getTime last-query)))] 76 | (when (< 1000 diff) 77 | (doseq [[_sub query] queries] 78 | (when-not (find (:memory model) query) 79 | (submit [:queried (js/Date.)]) 80 | (p/then (search-profile query) 81 | (fn [result] 82 | (submit [:set query result])))))))))) 83 | 84 | (defn oracle-initial-model [] 85 | {:last-query (js/Date.) 86 | :memory {}}) 87 | 88 | (defonce action-queue 89 | (atom {:model #queue []})) 90 | 91 | (declare github-display) 92 | (devcards/defcard github-display 93 | (oak-devcards/render ex oracle) 94 | {:model {} :cache (oracle-initial-model)} 95 | {:on-action (fn [ev] 96 | (match ev 97 | [:local [:set _]] nil 98 | :else (swap! action-queue update 99 | :model #(oak-devcards/add-new-action % ev))))}) 100 | 101 | (declare action-set) 102 | (devcards/defcard action-set 103 | (oak-devcards/render oak-devcards/action-demo) 104 | action-queue) 105 | -------------------------------------------------------------------------------- /ex/oak/experimental/devcards.cljs: -------------------------------------------------------------------------------- 1 | (ns oak.experimental.devcards 2 | (:require 3 | [devcards.core :as devcards :include-macros true] 4 | [oak.oracle :as oracle] 5 | [oak.component :as oak] 6 | [schema.core :as s] 7 | [oak.dom :as d] 8 | [devcards.util.edn-renderer :as edn-rend] 9 | [promesa.core :as p] 10 | [forest.class-names :as forestcn] 11 | [forest.macros :as forest :include-macros true]) 12 | (:import 13 | (goog.i18n DateTimeFormat))) 14 | 15 | (defn render 16 | "Provided a component (and optionally an oracle) produce Devcard main-obj. 17 | Initial model should be a map of the form {:model model :cache cache} for 18 | the initial component model and initial oracle cache respectively. A 19 | function at the options key :on-action will receive every action from the 20 | system." 21 | ([component] (render component (oracle/make))) 22 | ([component oracle] 23 | (reify devcards/IDevcardOptions 24 | (-devcard-options [_ opts] 25 | (let 26 | [{:keys [on-action] 27 | :or {on-action (fn [_])}} (:options opts)] 28 | (assoc opts 29 | :main-obj 30 | (fn devcard-context [total-model _owner] 31 | (let [{:keys [model cache]} @total-model 32 | {:keys [result queries]} (oracle/substantiate oracle cache component model) 33 | oracle-submit (fn oracle-submit [action] 34 | ; Why use a promise here? 35 | ; We have to get model modifications out 36 | ; of the render cycle. Without tossing a 37 | ; delay on here they'll happen 38 | ; synchronously and this will throw render 39 | ; errors. 40 | (p/then 41 | (p/delay 0) 42 | (fn [_] 43 | (on-action [:oracle action]) 44 | (swap! total-model update 45 | :cache (oracle/step oracle action))))) 46 | local-submit (fn local-submit [action] 47 | (on-action [:local action]) 48 | (swap! total-model update 49 | :model (oak/step component action)))] 50 | (oracle/refresh oracle cache queries oracle-submit) 51 | (component model result local-submit))))))))) 52 | 53 | (declare action-stylesheet 54 | EventDemo DateCell DomainCell DomainIsLocal DomainIsOracle) 55 | (forest/defstylesheet action-stylesheet 56 | [.ActionDemo {:height "300px" 57 | :overflow-y "auto"}] 58 | [.DateCell {:height "20px" 59 | :line-height "20px" 60 | :padding "3px 7px" 61 | :background "#eee" 62 | :font-size "0.6em"}] 63 | [.DomainCell {:height "20px" 64 | :line-height "20px" 65 | :padding "3px 7px" 66 | :font-family "Gill Sans" 67 | :font-size "12px" 68 | :color "#fff" 69 | :text-transform "uppercase"}] 70 | [.DomainIsLocal {:background "#1b6"}] 71 | [.DomainIsOracle {:background "#b16"}]) 72 | 73 | (def action-row 74 | (oak/make 75 | :model {:domain (s/enum :local :oracle) 76 | :as-of s/Inst 77 | :action s/Any 78 | :expanded s/Bool} 79 | :view 80 | (fn [{{:keys [domain] :as model} :model} _submit] 81 | (d/tr {} 82 | (d/td {:className DateCell} 83 | (.format (DateTimeFormat. "KK:mm ss aa") 84 | (:as-of model))) 85 | (d/td {:className (forestcn/class-names 86 | DomainCell 87 | {DomainIsLocal (= domain :local) 88 | DomainIsOracle (= domain :oracle)})} 89 | (case domain 90 | :local "local" 91 | :oracle "oracle")) 92 | (d/td {} 93 | (edn-rend/html-edn {:action (:action model)})))))) 94 | 95 | (def action-demo 96 | (oak/make 97 | :model (s/queue (oak/model action-row)) 98 | :view 99 | (fn [{model :model} _submit] 100 | (let [rows (for [action (reverse model)] 101 | (action-row action))] 102 | (d/div {:className ActionDemo} 103 | (d/table {} 104 | (apply d/tbody {} rows))))))) 105 | 106 | (defn new-action [[domain ev]] 107 | {:domain domain 108 | :as-of (js/Date.) 109 | :action ev 110 | :expanded false}) 111 | 112 | (defn add-new-action [queue ev] 113 | (conj (if (> (count queue) 100) 114 | (pop queue) 115 | queue) 116 | (new-action ev))) 117 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "oak", 3 | "private": true, 4 | "devDependencies": { 5 | "karma": "^0.13.22", 6 | "karma-chrome-launcher": "^0.2.3", 7 | "karma-cli": "^0.1.2", 8 | "karma-cljs-test": "^0.1.0", 9 | "karma-safari-launcher": "^0.1.1" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /project.clj: -------------------------------------------------------------------------------- 1 | (defproject oak "0.1.3-SNAPSHOT" 2 | :description "Drop-dead simple, super-compositional UI components" 3 | :url "http://github.com/tel/cljs-oak" 4 | :license {:name "Eclipse Public License" 5 | :url "http://www.eclipse.org/legal/epl-v10.html"} 6 | :dependencies [[org.clojure/clojure "1.7.0"] 7 | [org.clojure/clojurescript "1.8.40"] 8 | [quiescent/quiescent "0.3.1"] 9 | [prismatic/schema "1.1.0"] 10 | [org.clojure/core.async "0.2.374"]] 11 | :plugins [[lein-figwheel "0.5.1"] 12 | [lein-cljsbuild "1.1.3"] 13 | [lein-doo "0.1.6"]] 14 | :clean-targets ^{:protect false} ["resources/public/js" "target"] 15 | :doo {:paths {:chrome "chrome --no-sandbox" 16 | :karma "node_modules/.bin/karma"}} 17 | :release-tasks [["vcs" "assert-committed"] 18 | ["change" "version" "leiningen.release/bump-version" "release"] 19 | ["vcs" "commit"] 20 | ["vcs" "tag"] 21 | ["deploy" "clojars"] 22 | ["change" "version" "leiningen.release/bump-version"] 23 | ["vcs" "commit"] 24 | ["vcs" "push"]] 25 | 26 | :profiles 27 | {:dev {:dependencies [[devcards "0.2.1-6"] 28 | [org.clojure/core.match "0.3.0-alpha4"] 29 | [funcool/httpurr "0.5.0"] 30 | [datascript "0.15.0"] 31 | [funcool/promesa "1.1.1"] 32 | [forest "0.1.4"] 33 | [com.cognitect/transit-cljs "0.8.237"]]}} 34 | 35 | :cljsbuild 36 | {:builds [{:id "example" 37 | :source-paths ["src" "ex"] 38 | :figwheel {:devcards true} 39 | :compiler {:main oak.devcards 40 | :asset-path "js/out" 41 | :output-to "resources/public/js/example.js" 42 | :output-dir "resources/public/js/out" 43 | :source-map-timestamp true}} 44 | {:id "test" 45 | :source-paths ["src" "test"] 46 | :compiler {:output-to "resources/public/js/testable.js" 47 | :output-dir "resources/public/js/out" 48 | :main oak.test_runner 49 | :optimizations :none}}]}) -------------------------------------------------------------------------------- /resources/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Oak Examples 6 | 7 | 8 |
9 |
10 | 11 | 12 | -------------------------------------------------------------------------------- /src/oak/component.cljs: -------------------------------------------------------------------------------- 1 | (ns oak.component 2 | (:require 3 | [schema.core :as s] 4 | [quiescent.core :as q])) 5 | 6 | ; ----------------------------------------------------------------------------- 7 | ; Protocol and type 8 | 9 | (defprotocol IComponent 10 | (model [this]) 11 | (action [this]) 12 | (queryf [this]) 13 | (stepf [this]) 14 | (factory [this])) 15 | 16 | (deftype Component 17 | [model action stepf queryf factory] 18 | 19 | IComponent 20 | (model [_] model) 21 | (action [_] action) 22 | (queryf [_] queryf) 23 | (stepf [_] stepf) 24 | (factory [_] factory) 25 | 26 | IFn 27 | (-invoke [_] (factory {} (fn [_]))) 28 | (-invoke [_ st] (factory {:model st} (fn [_]))) 29 | (-invoke [_ st submit] (factory {:model st} submit)) 30 | (-invoke [_ st result submit] (factory {:model st :result result} submit))) 31 | 32 | (defn query 33 | [it model q] ((queryf it) model q)) 34 | 35 | (defn step 36 | ([it action] (fn transition-fn [model] (step it action model))) 37 | ([it action model] ((stepf it) action model))) 38 | 39 | ; ----------------------------------------------------------------------------- 40 | ; Introduction 41 | 42 | (def +oak-option-keys+ 43 | [:model :action :step :view :query :disable-validation]) 44 | 45 | (def +default-options+ 46 | {:model s/Any 47 | :action s/Any 48 | :query (fn [_model _q] nil) 49 | :step (fn default-step [_action model] model) 50 | :disable-validation false 51 | 52 | ; By default we use Quiescent, but we're not really married to it in any way. 53 | ; If you can build a factory in any way, e.g. a function from two args, 54 | ; the model and the submit function, then you're good! For best 55 | ; performance, use a Quiescent-style shouldComponentUpdate which assumes 56 | ; the first arg is all of the state. 57 | :build-factory 58 | (fn [{:keys [view] :as options}] 59 | (let [quiescent-options (apply dissoc options +oak-option-keys+)] 60 | (q/component view quiescent-options)))}) 61 | 62 | (defn make* [options] 63 | (let [options (merge +default-options+ options) 64 | {:keys [build-factory model action step query disable-validation]} options 65 | factory (build-factory options) 66 | action-validator (s/validator action) 67 | model-validator (s/validator model) 68 | validated-step (fn validated-step [action model] 69 | (model-validator 70 | (step (action-validator action) model)))] 71 | (Component. 72 | model action 73 | (if disable-validation step validated-step) 74 | query factory))) 75 | 76 | (defn make [& {:as options}] (make* options)) 77 | -------------------------------------------------------------------------------- /src/oak/component/higher_order.cljs: -------------------------------------------------------------------------------- 1 | (ns oak.component.higher-order 2 | "Functions for constructing Components from sub-Components." 3 | (:require 4 | [oak.internal.utils :as util] 5 | [oak.component :as oak] 6 | [oak.dom :as d] 7 | [schema.core :as s] 8 | [oak.schema :as os])) 9 | 10 | ; ----------------------------------------------------------------------------- 11 | ; Higher-order components 12 | 13 | (defn parallel 14 | "Construct a *static* component by gluing several subcomponents together 15 | without interaction. Parallel components are made of a fixed set of named 16 | subcomponents and have dramatically simplified wiring. Use parallel 17 | components whenever you do not need custom state management behavior. 18 | 19 | By default, a static component renders its subcomponents in a div in 20 | arbitrary order. Provide a :view-compositor function to choose how to render 21 | the subviews more specifically. It has a signature like (view-compositor 22 | subviews) where `subviews` is a map with the same keys as your static 23 | components containing ReactElements corresponding to each subcomponent. 24 | 25 | By default, all events generated by a static component are simply routed in 26 | parallel back to the originating subcomponents. Provide a 27 | :routing-transform function to choose how events are routed more 28 | specifically. It has a signature like (routing-transform target event 29 | continue) where `target` is a key in your subcomponent map, `action` is the 30 | event headed for that subcomponent, and `continue` is a callback of two 31 | arguments, the target for the action and the action itself. 32 | 33 | Any other options are forwarded on to `make`." 34 | [subcomponent-map 35 | & {:keys [view-compositor routing-transform] 36 | :or {view-compositor (fn [views] (apply d/div {} (vals views))) 37 | routing-transform (fn [target action cont] (cont target action))} 38 | :as options}] 39 | 40 | (let [core-design 41 | {:model 42 | (util/map-vals oak/model subcomponent-map) 43 | 44 | :action 45 | (apply os/cond-pair 46 | (util/map-vals oak/action subcomponent-map)) 47 | 48 | :step 49 | (fn static-step [[target action] model] 50 | (routing-transform 51 | target action 52 | (fn [target action] 53 | (update 54 | model target 55 | (oak/step 56 | (get subcomponent-map target) 57 | action))))) 58 | 59 | :query 60 | (fn static-query [model q] 61 | (util/map-kvs 62 | (fn [target subc] (oak/query subc (get model target) q)) 63 | subcomponent-map)) 64 | 65 | :view 66 | (fn static-view [{:keys [model result]} submit] 67 | (let [subviews (util/map-kvs 68 | (fn [target subc] 69 | (subc 70 | (get model target) 71 | (get result target) 72 | (fn [ev] (submit [target ev])))) 73 | subcomponent-map)] 74 | (view-compositor subviews)))}] 75 | 76 | ; Let the remaining options override 77 | (oak/make* 78 | (merge core-design 79 | (-> options 80 | (dissoc :view-compositor) 81 | (dissoc :routing-transform)))))) -------------------------------------------------------------------------------- /src/oak/dom.cljs: -------------------------------------------------------------------------------- 1 | (ns oak.dom 2 | (:refer-clojure :exclude [time map meta]) 3 | (:require 4 | [quiescent.factory :as factory] 5 | [quiescent.dom :as dm :include-macros true] 6 | [quiescent.dom.uncontrolled :as dm-u] 7 | [clojure.string :as s])) 8 | 9 | (declare 10 | a abbr address area article aside audio b base bdi bdo big blockquote body br 11 | button canvas caption cite code col colgroup data datalist dd del details dfn 12 | div dl dt em embed fieldset figcaption figure footer form h1 h2 h3 h4 h5 h6 13 | head header hr html i iframe img input ins kbd keygen label legend li link main 14 | map mark menu menuitem meta meter nav noscript object ol optgroup option output 15 | p param pre progress q rp rt ruby s samp script section select small source 16 | span strong style sub summary sup table tbody td textarea tfoot th thead time 17 | title tr track u ul var video wbr circle g line path polygon polyline rect svg 18 | text) 19 | 20 | (dm/define-tags 21 | a abbr address area article aside audio b base bdi bdo big blockquote body br 22 | button canvas caption cite code col colgroup data datalist dd del details dfn 23 | div dl dt em embed fieldset figcaption figure footer form h1 h2 h3 h4 h5 h6 24 | head header hr html i iframe img input ins kbd keygen label legend li link main 25 | map mark menu menuitem meta meter nav noscript object ol optgroup option output 26 | p param pre progress q rp rt ruby s samp script section select small source 27 | span strong style sub summary sup table tbody td textarea tfoot th thead time 28 | title tr track u ul var video wbr circle g line path polygon polyline rect svg 29 | text) 30 | 31 | (def uinput (factory/factory (dm-u/uncontrolled-component "input" "input"))) 32 | (def utextarea (factory/factory (dm-u/uncontrolled-component "textarea" "textarea"))) 33 | (def uoption (factory/factory (dm-u/uncontrolled-component "option" "option"))) 34 | 35 | ; ----------------------------------------------------------------------------- 36 | ; Class name helper 37 | 38 | (defn- expand-as-class-list [it] 39 | (cond 40 | (nil? it) (list) 41 | (false? it) (list) 42 | (string? it) (list it) 43 | (keyword? it) (list (name it)) 44 | (map? it) (into 45 | [] 46 | (comp 47 | (filter val) 48 | (cljs.core/map key) 49 | (mapcat expand-as-class-list)) 50 | it) 51 | (coll? it) (mapcat expand-as-class-list it))) 52 | 53 | (defn class-names [& args] 54 | (s/join " " (expand-as-class-list args))) 55 | 56 | -------------------------------------------------------------------------------- /src/oak/internal/utils.cljs: -------------------------------------------------------------------------------- 1 | (ns oak.internal.utils 2 | "Non-public utility functions needed for the implementation of Oak.") 3 | 4 | (defn map-kvs [f hashmap] 5 | (persistent! 6 | (reduce 7 | (fn [acc [k v]] 8 | (assoc! acc k (f k v))) 9 | (transient {}) 10 | hashmap))) 11 | 12 | (defn map-vals [f hashmap] 13 | (map-kvs (fn [_ v] (f v)) hashmap)) 14 | -------------------------------------------------------------------------------- /src/oak/oracle.cljs: -------------------------------------------------------------------------------- 1 | (ns oak.oracle 2 | "An Oracle is a system for determining, perhaps only eventually, answers to 3 | queries. For instance, a database is naturally a (synchronous) Oracle. So 4 | is a REST API, though this one is asynchronous. 5 | 6 | Oracles differ in the kinds of queries they respond to and the nature of 7 | their responses. They are the same in that they manage state in a way 8 | that's compatible with the explicit nature of Oak. 9 | 10 | In particular, an Oracle operates in stages. During the 'respond' stage, 11 | the Oracle answers queries to the best of its ability atop a fixed 'model' 12 | value (it is, therefore, a kind of 'view'). After the 'respond' stage the 13 | Oracle gets a chance to have a 'research' stage updating the 'model' value 14 | in knowledge of all of the queries it received during the 'respond' stage. 15 | 16 | Notably, an Oracle must usually respond even before doing any research such 17 | that asynchronous Oracles will probably return empty responses at first. 18 | Importantly, the 'respond' stage must be completely pure---no side effects 19 | allowed! All of the side effects occur during the 'research' phase offering a 20 | mechanism for asynchronous data loading." 21 | (:require 22 | [schema.core :as s] 23 | [oak.component :as oak])) 24 | 25 | ; TODO Oracles, being async, would benefit a lot from selective receives in 26 | ; the step function (a la Erlang). The heart of this is that a selective receive 27 | ; can be used to simplify state management when multiple events chain together. 28 | ; The API here is a little tricky since on one hand we'd need to pass in the 29 | ; "receive" function and on the other hand we'd want to use Clojure's pattern 30 | ; matching macro to make it work, but the benefits could be large for complex 31 | ; Oracles. TBI! 32 | 33 | ; ----------------------------------------------------------------------------- 34 | ; Type 35 | 36 | (defprotocol IOracle 37 | (model [this]) 38 | (action [this]) 39 | (query [this]) 40 | (stepf [this]) 41 | (startf [this]) 42 | (stopf [this]) 43 | (respondf [this]) 44 | (refreshf [this])) 45 | 46 | (defn step 47 | ([oracle action] (fn [model] (step oracle action model))) 48 | ([oracle action model] ((stepf oracle) action model))) 49 | 50 | (defn start 51 | [this submit] ((startf this) submit)) 52 | 53 | (defn stop 54 | [this rts] ((stopf this) rts)) 55 | 56 | (defn respond 57 | ([oracle model] (fn respond-to-query [query] (respond oracle model query))) 58 | ([oracle model query] ((respondf oracle) model query))) 59 | 60 | (defn refresh 61 | [oracle model queries submit] 62 | ((refreshf oracle) model queries submit)) 63 | 64 | (defn substantiate 65 | "Given an Oak component, try to construct its query results." 66 | [oracle oracle-model component component-model] 67 | (let [base-responder (respond oracle oracle-model) 68 | query-capture (atom #{}) 69 | q (fn execute-query [query] 70 | (swap! query-capture conj query) 71 | (base-responder query))] 72 | {:result (oak/query component component-model q) 73 | :queries @query-capture})) 74 | 75 | ; ----------------------------------------------------------------------------- 76 | ; Type 77 | 78 | (deftype Oracle 79 | [model action query stepf startf stopf respondf refreshf] 80 | 81 | IOracle 82 | (model [_] model) 83 | (action [_] action) 84 | (query [_] query) 85 | (stepf [_] stepf) 86 | (startf [_] startf) 87 | (stopf [_] stopf) 88 | (respondf [_] respondf) 89 | (refreshf [_] refreshf)) 90 | 91 | ; ----------------------------------------------------------------------------- 92 | ; Intro 93 | 94 | (def +default-options+ 95 | {:model s/Any 96 | :action s/Any 97 | :query s/Any 98 | :step (fn default-step [_action model] model) 99 | :start (fn default-start [_submit]) 100 | :stop (fn default-stop [_rts]) 101 | :respond (fn [_model _query] nil) 102 | :refresh (fn [_model _queries _submit]) 103 | :disable-validation false}) 104 | 105 | (defn make* 106 | [options] 107 | (let [options (merge +default-options+ options) 108 | {:keys [model action query step start stop 109 | respond refresh 110 | disable-validation]} options 111 | model-validator (s/validator model) 112 | action-validator (s/validator action) 113 | query-validator (s/validator query) 114 | validated-respond (fn validated-respond [model q] 115 | (respond model (query-validator q))) 116 | validated-step (fn validated-step [action model] 117 | (model-validator 118 | (step (action-validator action) model)))] 119 | (Oracle. 120 | model action query 121 | (if disable-validation step validated-step) 122 | start stop 123 | (if disable-validation respond validated-respond) 124 | refresh))) 125 | 126 | (defn make [& {:as options}] (make* options)) 127 | 128 | -------------------------------------------------------------------------------- /src/oak/oracle/higher_order.cljs: -------------------------------------------------------------------------------- 1 | (ns oak.oracle.higher-order 2 | "Functions for constructing Oracles from sub-Oracles." 3 | (:require 4 | [oak.oracle :as oracle] 5 | [oak.internal.utils :as util] 6 | [oak.schema :as os])) 7 | 8 | (defn parallel 9 | [oracle-map] 10 | (oracle/make 11 | :model (util/map-vals oracle/model oracle-map) 12 | :action (apply os/cond-pair (util/map-vals oracle/action oracle-map)) 13 | :query (apply os/cond-pair (util/map-vals oracle/query oracle-map)) 14 | 15 | :step 16 | (fn parallel-step [[index action] model] 17 | (update model index (oracle/step (get oracle-map index) action))) 18 | 19 | :start 20 | (fn parallel-start [submit] 21 | (util/map-kvs 22 | (fn [index subo] 23 | (oracle/start subo (fn [action] (submit [index action])))) 24 | oracle-map)) 25 | 26 | :stop 27 | (fn parallel-stop [rts-map] 28 | (util/map-kvs 29 | (fn [index subo] 30 | (oracle/stop subo (get rts-map index))) 31 | oracle-map)) 32 | 33 | :respond 34 | (fn parallel-respond [model [index query]] 35 | (oracle/respond (get oracle-map index) (get model index) query)) 36 | 37 | :refresh 38 | (fn parallel-refresh [model queries submit] 39 | (let [querysets (reduce 40 | (fn [sets [index subquery]] 41 | (update sets index conj subquery)) 42 | {} queries)] 43 | (for [[index local-queries] querysets] 44 | (let [local-oracle (get oracle-map index) 45 | local-model (get model index) 46 | local-submit (fn [action] (submit [index action]))] 47 | (oracle/refresh local-oracle local-model local-queries local-submit))))))) 48 | -------------------------------------------------------------------------------- /src/oak/render.cljs: -------------------------------------------------------------------------------- 1 | (ns oak.render 2 | (:require 3 | [oak.oracle :as oracle] 4 | [quiescent.core :as q] 5 | [oak.component :as oak] 6 | [cljs.core.async :as async]) 7 | (:require-macros 8 | [cljs.core.async.macros :as asyncm]) 9 | (:import goog.async.AnimationDelay)) 10 | 11 | (defn render 12 | [component 13 | & {:keys [oracle target 14 | initial-model initial-omodel 15 | model-atom omodel-atom 16 | intent 17 | on-action] 18 | :or {oracle (oracle/make) 19 | intent (async/chan) 20 | on-action (fn [_target _event]) 21 | target (.-body js/document)}}] 22 | 23 | (let [model (or model-atom (atom initial-model)) 24 | omodel (or omodel-atom (atom initial-omodel)) 25 | result (atom) 26 | kill-chan (async/chan) 27 | 28 | alive? (atom) 29 | current-timer (atom) 30 | dirty? (atom false) 31 | 32 | oracle-rts (atom)] 33 | 34 | (letfn [(dirty! [] (reset! dirty? true)) 35 | 36 | (submit-oracle! [ev] 37 | (async/put! intent [:oracle ev])) 38 | 39 | (submit-local! [ev] 40 | (on-action :local ev) 41 | (let [new-model (oak/step component ev @model)] 42 | (when (not= @model new-model) 43 | (reset! model new-model) 44 | (dirty!)))) 45 | 46 | (update-result! [] 47 | (let [subst (oracle/substantiate oracle @omodel component @model)] 48 | (reset! result (:result subst)) 49 | (oracle/refresh oracle @omodel (:queries subst) submit-oracle!))) 50 | 51 | (force-render! [] 52 | (update-result!) 53 | (q/render (component @model @result submit-local!) target) 54 | (reset! dirty? false)) 55 | 56 | (render! [] (when @dirty? (force-render!))) 57 | 58 | (render-loop! [] 59 | (render!) 60 | (let [timer (doto (AnimationDelay. render-loop!) .start)] 61 | (reset! current-timer timer))) 62 | 63 | (oracle-loop! [] 64 | (asyncm/go-loop [] 65 | (let [[action-pair resolved-chan] (async/alts! [intent kill-chan])] 66 | (when (not= resolved-chan kill-chan) 67 | (when-let [[_oracle-kw action] action-pair] 68 | (on-action :oracle action) 69 | (let [new-omodel (oracle/step oracle action @omodel)] 70 | (when (not= @omodel new-omodel) 71 | (reset! omodel new-omodel) 72 | (dirty!))) 73 | (recur)))))) 74 | 75 | (stop! [] 76 | (reset! alive? false) 77 | (async/put! kill-chan true) 78 | (oracle/stop oracle @oracle-rts) 79 | (q/unmount target) 80 | (when-let [timer @current-timer] 81 | (.stop timer)))] 82 | 83 | (dirty!) 84 | (reset! oracle-rts (oracle/start oracle submit-oracle!)) 85 | (oracle-loop!) 86 | (render-loop!) 87 | (reset! alive? true) 88 | 89 | {:force-render! force-render! 90 | :request-render! dirty! 91 | :submit-local! submit-local! 92 | :submit-oracle! submit-oracle! 93 | :current-timer current-timer 94 | :alive? alive? 95 | :stop! stop!}))) 96 | 97 | -------------------------------------------------------------------------------- /src/oak/schema.cljs: -------------------------------------------------------------------------------- 1 | (ns oak.schema 2 | "Some event and state schemata are standard but not immediately 3 | well-supported by Schema. This namespace provides combinators and schemata 4 | for efficient, convenient descriptions of compositional Oak events and 5 | states. 6 | 7 | NOT YET IMPLEMENTED CORRECTLY! In particular, the pre-conditions that help 8 | this to work with cond-pre are not available yet." 9 | (:require 10 | [schema.core :as s])) 11 | 12 | (defn cmdp 13 | "In the event that you are not using cond-pre, it is convenient to be able 14 | to describe the pre-conditions of a cmd quickly for use in conditional. The 15 | value (cmdp :foo) is the pre-condition for (cmd :foo)." 16 | [name] 17 | (fn cmd-predicate [[the-name & _]] (= name the-name))) 18 | 19 | (defn cmd 20 | "The schema (cmd :foo scm1 scm2 scm3 ...) matches arguments of the form 21 | [:foo x y z ...] where each of x, y, and z must match the cooresponding 22 | schema argument. Moreover, this schema has a two-stage pre-condition that the 23 | datum is first a vector and second begins with :foo enabling the easy use of 24 | cond-pre." 25 | [name & arg-schemata] 26 | (apply vector 27 | (s/one (s/eq name) :name) 28 | (map #(s/one % :argument) arg-schemata))) 29 | 30 | (defn targeted 31 | "The schema (targeted scmT scmE) matches arguments of the form [t e] so it 32 | is similar to (pair scmT :target scmE :event) but targeted is more 33 | convenient to use and works better with cond-pre since it will attempt to 34 | match the target schema as a precondition." 35 | [target-schema payload-schema] 36 | (s/pair target-schema :target payload-schema :payload)) 37 | 38 | (defn cond-pair 39 | "Given an arbitrary number of [a b] vectors as arguments, pair-sum is a schema 40 | which matches pairs [x y] such that first a matches x and then b matches y 41 | for some vector argument. As a shortcut, a may be a keyword which is 42 | interpreted as keyword equality. This provides schema/cond-pre-like semantics to 43 | vector pairs where normally one would need to use schema/conditional." 44 | [& args] 45 | (apply s/conditional 46 | (mapcat (fn [[a b]] 47 | (let [a-schema (if (keyword? a) 48 | (s/eq a) 49 | a) 50 | a-conditional (fn cond-pair-head-precondition [x] 51 | (when (and (vector? x) 52 | (= 2 (count x))) 53 | (let [[fst _snd] x] 54 | (nil? (s/check a-schema fst)))))] 55 | [a-conditional (s/pair a-schema :fst b :snd)])) 56 | args))) -------------------------------------------------------------------------------- /test/oak/core_test.cljs: -------------------------------------------------------------------------------- 1 | (ns oak.core-test 2 | (:require 3 | [cljs.test :as t :include-macros true])) 4 | 5 | (t/deftest stupid-test 6 | (t/is (= true true))) 7 | -------------------------------------------------------------------------------- /test/oak/test_runner.cljs: -------------------------------------------------------------------------------- 1 | (ns oak.test-runner 2 | (:require 3 | [doo.runner :as doo :include-macros true] 4 | [oak.core-test])) 5 | 6 | (doo/doo-tests 7 | 'oak.core-test) 8 | 9 | --------------------------------------------------------------------------------