├── .babelrc ├── .eslintignore ├── .eslintrc ├── .gitignore ├── .npmignore ├── .travis.yml ├── LICENSE ├── README.md ├── baconjs.js ├── examples ├── 01-bmi │ ├── .babelrc │ ├── README.md │ ├── bacon.js │ ├── index.html │ ├── kefir.js │ ├── package.json │ └── rx.js ├── 02-todomvc │ ├── .babelrc │ ├── README.md │ ├── bacon.js │ ├── index.html │ ├── kefir.js │ ├── package.json │ ├── rx.js │ └── src │ │ ├── bacon │ │ ├── components │ │ │ ├── footer.js │ │ │ ├── input.js │ │ │ ├── list.js │ │ │ └── toggleAll.js │ │ ├── model.js │ │ └── ui.js │ │ ├── kefir │ │ ├── components │ │ │ ├── footer.js │ │ │ ├── input.js │ │ │ ├── list.js │ │ │ └── toggleAll.js │ │ ├── model.js │ │ └── ui.js │ │ └── rx │ │ ├── components │ │ ├── footer.js │ │ ├── input.js │ │ ├── list.js │ │ └── toggleAll.js │ │ ├── model.js │ │ └── ui.js └── 03-editors │ ├── .babelrc │ ├── README.md │ ├── bacon.js │ ├── index.html │ ├── kefir.js │ ├── package.json │ └── rx.js ├── index.js ├── kefir.js ├── package.json ├── rx.js ├── src ├── Combinator.js ├── bindings │ ├── baconjs.js │ ├── kefir.js │ └── rx.js ├── combineVDOM.js ├── createComponent.js ├── createExports.js └── util.js └── test ├── bmiTest.js ├── editorsTest.js ├── helpers.js └── todomvcTest.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015", "stage-0"] 3 | } 4 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | **/node_modules 2 | **/bundle.js 3 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "babel-eslint", 3 | "extends": "eslint:recommended", 4 | "rules": { 5 | "no-console": 0, 6 | "no-unused-vars": 0, 7 | "semi": [2, "never"], 8 | "quotes": [2, "double"] 9 | }, 10 | "plugins": ["react"], 11 | "env": { 12 | "node": true, 13 | "browser": true 14 | }, 15 | "parserOptions": { 16 | "ecmaFeatures": { 17 | "jsx": true 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | logs 2 | *.log 3 | pids 4 | *.pid 5 | *.seed 6 | lib-cov 7 | coverage 8 | .lock-wscript 9 | node_modules 10 | .idea 11 | *.iml 12 | lib 13 | test/playground 14 | bundle* 15 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | !lib 2 | test 3 | examples 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "4.2.0" 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Matti Lankinen 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React Combinators 2 | 3 | Seamless combination of React and reactive programming with (RxJs / Kefir / Bacon.js). 4 | 5 | [![npm version](https://badge.fury.io/js/react-combinators.svg)](http://badge.fury.io/js/react-combinators) 6 | [![Build Status](https://travis-ci.org/milankinen/react-combinators.svg)](https://travis-ci.org/milankinen/react-combinators) 7 | 8 | **THIS LIBRARY IS NOT MAINTAINED ANYMORE** 9 | 10 | If you are interested in doing applications with React and observables, please see: 11 | 12 | * [`calmm-js`](https://github.com/calmm-js) powerful toolset for state management and rendering with React and observables (Kefir & Bacon atm) 13 | * [`react.reactive`](https://github.com/milankinen/react-reactive-toolkit) calmm-js rendering part extracted and made streaming library agnostic 14 | 15 | ## Motivation 16 | 17 | Modeling your application state with observables gives you powerful tools for 18 | (async) state handling. However, combining those observables with React UIs has been 19 | a difficult: every observable must be subscribed and disposed separately with the 20 | component's lifecycle hooks. Such boilerplate! 21 | 22 | The goal of this project is to enable seamless combination of React and observable 23 | by introducing **React Observable Combinators**. Say hello to truly reactive and 24 | declarative React app development! 25 | 26 | ## Example 27 | 28 | Reddit post search implemented with combinators and `kefir` (counter example 29 | would have been too easy): 30 | 31 | ```javascript 32 | import React from "react" 33 | import Kefir from "kefir" 34 | import {Combinator} from "react-combinators/kefir" 35 | import {render} from "react-dom" 36 | 37 | // lets define our reactive Reddit state model, see Kefir 38 | // docs for more info about pools and other used methods 39 | function Reddit(initial) { 40 | const pool = Kefir.pool() 41 | const setReddit = reddit => pool.plug(Kefir.constant(reddit)) 42 | const reddit = 43 | pool.merge(Kefir.constant(initial)).toProperty() 44 | const posts = 45 | reddit 46 | .flatMapLatest(reddit => Kefir.fromPromise( 47 | fetch(`http://www.reddit.com/r/${reddit}.json`).then(req => req.json()) 48 | )) 49 | .map(json => json.data.children.map(({data}) => data)) 50 | .merge(Kefir.constant([])) // initial value 51 | .toProperty() 52 | const loading = 53 | reddit.map(() => true).merge(posts.map(() => false)).toProperty() 54 | 55 | // yes. the model is just a plain object with a set of actions 56 | // and reactive properties 57 | return { reddit, posts, loading, setReddit } 58 | } 59 | 60 | // no containers are needed! observables and combinators handle that the 61 | // UI syncs with the state 62 | function App({model}) { 63 | const { reddit, posts, loading, setReddit } = model 64 | 65 | // we can derive properties as well 66 | const loadingIndicator = 67 | loading.map(loading => loading ? : null) 68 | 69 | // all you need to do is to surround your JSX with element 70 | return ( 71 | 72 |
73 | Select Reddit: 74 | 79 | {loadingIndicator} 80 |
81 |
    82 | {posts.map(posts => posts.map(post => ( 83 |
  • {post.title}
  • 84 | )))} 85 |
86 |
87 |
88 | ) 89 | } 90 | 91 | const myReddit = Reddit("ReactiveProgramming") 92 | render(, document.getElementById("app")) 93 | ``` 94 | 95 | ## Installation 96 | 97 | npm i --save react react-combinators 98 | 99 | Currently supported FRP libraries are 100 | 101 | * `rx` 102 | * `baconjs` 103 | * `kefir` 104 | 105 | 106 | ## API 107 | 108 | All API functions and components are implemented for each supported FRP 109 | library and they are accessible through: 110 | 111 | ```javascript 112 | const {} = require("react-combinators/") 113 | ``` 114 | 115 | ### `` 116 | 117 | Higher order component that "wraps" the observables from its children and returns 118 | a virtual dom element that gets updated by changes in any of its child elements. 119 | Combinator components should be used at the top-level of your React component. 120 | 121 | Usage: 122 | 123 | ```javascript 124 | import {Combinator} from "react-combinators/" 125 | 126 | function MyApp(observable) { 127 | return ( 128 | 129 | My observable value: {observable} 130 | 131 | ) 132 | } 133 | ``` 134 | 135 | ### `combineVDOM` 136 | 137 | Higher order function that transforms a virtual dom tree containing observables 138 | to an observable that returns the same virtual dom where the observables are 139 | replaced with their values. Same as `combine*` functions in FRP libraries but 140 | this one is optimized for virtual dom. 141 | 142 | Usage (example with Bacon.js): 143 | 144 | ```javascript 145 | const a = Bacon.constant(10) 146 | const b = Bacon.constant(20) 147 | const vdom = combineVDOM( 148 |
{a} + {b} = {Bacon.combineWith(a, b, (a, b) => a + b)}
149 | ) 150 | console.log(vdom instanceof Bacon.Property) // => true 151 | ``` 152 | 153 | ### `createComponent` 154 | 155 | Creates a reactive component which wraps its observables into `React.Component`, 156 | hence it can be mixed with normal react components. 157 | 158 | `createComponent` takes one function which receives the component's properties 159 | as **observables** and returns an **observable containing the rendered virtual 160 | dom**. 161 | 162 | Signature: 163 | 164 | createComponent :: ({propsObservales} => Observable(VDOM)) => React.Component 165 | 166 | Usage (example with Bacon.js): 167 | 168 | ```javascript 169 | const Example = createComponent(({a, b}) => { 170 | const c = Bacon.combineWith(a, b, (a, b) => a + b) 171 | return combineVDOM( 172 |
{a} + {b} = {c}
173 | ) 174 | }) 175 | 176 | // ... somewhere in your app... 177 |
178 | Example: 179 |
180 | ``` 181 | 182 | ## License 183 | 184 | MIT 185 | -------------------------------------------------------------------------------- /baconjs.js: -------------------------------------------------------------------------------- 1 | var createExports = require("./lib/createExports").default 2 | var bindings = require("./lib/bindings/baconjs").default 3 | 4 | module.exports = createExports(bindings) 5 | -------------------------------------------------------------------------------- /examples/01-bmi/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015", "stage-0", "react"] 3 | } 4 | -------------------------------------------------------------------------------- /examples/01-bmi/README.md: -------------------------------------------------------------------------------- 1 | # BMI counter example 2 | 3 | This is the basic example how to use React observable combinators. 4 | It shows how to use `` elements and how to embed observables 5 | to the JSX. Note that `` element is used only in `App` 6 | function because it is the root of the `React.Element`. 7 | 8 | If `renderSlider` functions were replaced with `` elements, then 9 | those elements would require their own `` wrappers. However, 10 | react combinators encourage you to create just "render functions" and 11 | declare the vdom only by using them. 12 | 13 | 14 | Examples codes (per supported FRP library) can be found from files: 15 | 16 | * `rx.js` 17 | * `bacon.js` 18 | * `kefir.js` 19 | 20 | ## Usage 21 | 22 | npm i 23 | npm run rx 24 | npm run baconjs 25 | npm run kefir 26 | 27 | 28 | -------------------------------------------------------------------------------- /examples/01-bmi/bacon.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import Bacon from "baconjs" 3 | import {render} from "react-dom" 4 | import {Combinator} from "react-combinators/baconjs" 5 | 6 | 7 | // lets define our "reactive" model with observables 8 | function model(initialHeight, initialWeight) { 9 | const setHeight = createAction() 10 | const setWeight = createAction() 11 | const height = setHeight.$.toProperty(initialHeight) 12 | const weight = setWeight.$.toProperty(initialWeight) 13 | const bmi = Bacon.combineWith(weight, height, (w, h) => ( 14 | Math.round(w / (h * h * 0.0001)) 15 | )) 16 | 17 | // yeah. model is just a pure factory function that returns plain object 18 | return { setHeight, setWeight, height, weight, bmi } 19 | } 20 | 21 | function App() { 22 | // and here we can use the same object like any other object 23 | const { setHeight, setWeight, height, weight, bmi } = model(180, 80) 24 | return ( 25 | 26 |
27 |

BMI counter example

28 | {renderSlider("Height", height, setHeight, 100, 240)} 29 | {renderSlider("Weight", weight, setWeight, 40, 150)} 30 | {/* and here we can embed the observables directly into the JSX */} 31 | Your BMI is: {bmi} 32 |
33 |
34 | ) 35 | } 36 | 37 | function renderSlider(title, value, setValue, min, max) { 38 | return ( 39 |
40 | {title}: {value}
41 | setValue(e.target.value)} /> 43 |
44 | ) 45 | } 46 | 47 | function createAction() { 48 | const bus = new Bacon.Bus() 49 | const creator = (val) => bus.push(val) 50 | creator.$ = bus 51 | return creator 52 | } 53 | 54 | render(, document.getElementById("app")) 55 | -------------------------------------------------------------------------------- /examples/01-bmi/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | React Combinators - BMI counter example 6 | 7 | 8 |
9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /examples/01-bmi/kefir.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import Kefir from "kefir" 3 | import {render} from "react-dom" 4 | import {Combinator} from "react-combinators/kefir" 5 | 6 | // lets define our "reactive" model with observables 7 | function model(initialHeight, initialWeight) { 8 | const setHeight = createAction() 9 | const setWeight = createAction() 10 | const height = toProp(setHeight.$, initialHeight) 11 | const weight = toProp(setWeight.$, initialWeight) 12 | const bmi = Kefir.combine([weight, height], (w, h) => ( 13 | Math.round(w / (h * h * 0.0001)) 14 | )) 15 | 16 | // yeah. model is just a pure factory function that returns plain object 17 | return { setHeight, setWeight, height, weight, bmi } 18 | } 19 | 20 | function App() { 21 | // and here we can use the same object like any other object 22 | const { setHeight, setWeight, height, weight, bmi } = model(180, 80) 23 | return ( 24 | 25 |
26 |

BMI counter example

27 | {renderSlider("Height", height, setHeight, 100, 240)} 28 | {renderSlider("Weight", weight, setWeight, 40, 150)} 29 | {/* and here we can embed the observables directly into the JSX */} 30 | Your BMI is: {bmi} 31 |
32 |
33 | ) 34 | } 35 | 36 | function renderSlider(title, value, setValue, min, max) { 37 | return ( 38 |
39 | {title}: {value}
40 | setValue(e.target.value)} /> 42 |
43 | ) 44 | } 45 | 46 | function createAction() { 47 | const pool = Kefir.pool() 48 | const creator = (val) => pool.plug(Kefir.constant(val)) 49 | creator.$ = pool 50 | return creator 51 | } 52 | 53 | function toProp(stream, initial) { 54 | return stream.merge(Kefir.constant(initial)).toProperty() 55 | } 56 | 57 | render(, document.getElementById("app")) 58 | -------------------------------------------------------------------------------- /examples/01-bmi/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-combinators-bmi-counter", 3 | "version": "0.0.1", 4 | "author": "Matti Lankinen ", 5 | "license": "MIT", 6 | "private": false, 7 | "scripts": { 8 | "rx": "browserify rx.js -t babelify > bundle.js && open index.html", 9 | "baconjs": "browserify bacon.js -t babelify > bundle.js && open index.html", 10 | "kefir": "browserify kefir.js -t babelify > bundle.js && open index.html", 11 | "watch": "watchify -v -t babelify -o bundle.js" 12 | }, 13 | "dependencies": { 14 | "babel-preset-react": "^6.5.0", 15 | "babelify": "^7.3.0", 16 | "baconjs": "^0.7.84", 17 | "browserify": "^13.0.1", 18 | "kefir": "^3.2.2", 19 | "react": "^15.0.2", 20 | "react-combinators": "file:../..", 21 | "react-dom": "^15.0.2", 22 | "rx": "^4.1.0", 23 | "watchify": "^3.7.0" 24 | }, 25 | "engines": { 26 | "node": "4.2.x" 27 | }, 28 | "devDependencies": { 29 | "babel-preset-es2015": "^6.6.0", 30 | "babel-preset-stage-0": "^6.5.0" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /examples/01-bmi/rx.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import {Subject, Observable} from "rx" 3 | import {render} from "react-dom" 4 | import {Combinator} from "react-combinators/rx" 5 | 6 | // lets define our "reactive" model with observables 7 | function model(initialHeight, initialWeight) { 8 | const setHeight = createAction() 9 | const setWeight = createAction() 10 | const height = setHeight.$.startWith(initialHeight).shareReplay(1) 11 | const weight = setWeight.$.startWith(initialWeight).shareReplay(1) 12 | const bmi = Observable.combineLatest(weight, height, (w, h) => ( 13 | Math.round(w / (h * h * 0.0001)) 14 | )).shareReplay(1) 15 | 16 | // yeah. model is just a pure factory function that returns plain object 17 | return { setHeight, setWeight, height, weight, bmi } 18 | } 19 | 20 | function App() { 21 | // and here we can use the same object like any other object 22 | const { setHeight, setWeight, height, weight, bmi } = model(180, 80) 23 | return ( 24 | 25 |
26 |

BMI counter example

27 | {renderSlider("Height", height, setHeight, 100, 240)} 28 | {renderSlider("Weight", weight, setWeight, 40, 150)} 29 | {/* and here we can embed the observables directly into the JSX */} 30 | Your BMI is: {bmi} 31 |
32 |
33 | ) 34 | } 35 | 36 | function renderSlider(title, value, setValue, min, max) { 37 | return ( 38 |
39 | {title}: {value}
40 | setValue(e.target.value)} /> 42 |
43 | ) 44 | } 45 | 46 | function createAction() { 47 | const subject = new Subject() 48 | const creator = (val) => subject.onNext(val) 49 | creator.$ = subject 50 | return creator 51 | } 52 | 53 | render(, document.getElementById("app")) 54 | -------------------------------------------------------------------------------- /examples/02-todomvc/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015", "stage-0", "react"] 3 | } 4 | -------------------------------------------------------------------------------- /examples/02-todomvc/README.md: -------------------------------------------------------------------------------- 1 | # TodoMVC example 2 | 3 | This example demonstrates the "classical" TodoMVC application with combinators. 4 | In example also demonstrates how to create "sub-applications" to your state by 5 | using `createComponent` function. 6 | 7 | Examples codes (per supported FRP library) can be found from files: 8 | 9 | * `rx.js` 10 | * `bacon.js` 11 | * `kefir.js` 12 | 13 | ## Usage 14 | 15 | npm i 16 | npm run rx 17 | npm run baconjs 18 | npm run kefir 19 | 20 | -------------------------------------------------------------------------------- /examples/02-todomvc/bacon.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import {render} from "react-dom" 3 | import {App} from "./src/bacon/ui" 4 | import {Todos} from "./src/bacon/model" 5 | 6 | const todos = Todos([ 7 | {text: "Tsers!"} 8 | ]) 9 | 10 | render(, document.getElementById("todoapp")) 11 | -------------------------------------------------------------------------------- /examples/02-todomvc/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | React Combinators - TodoMVC example 6 | 7 | 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /examples/02-todomvc/kefir.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import {render} from "react-dom" 3 | import {App} from "./src/kefir/ui" 4 | import {Todos} from "./src/kefir/model" 5 | 6 | const todos = Todos([ 7 | {text: "Tsers!"} 8 | ]) 9 | 10 | render(, document.getElementById("todoapp")) 11 | -------------------------------------------------------------------------------- /examples/02-todomvc/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-combinators-todomvc", 3 | "version": "0.0.1", 4 | "author": "Matti Lankinen ", 5 | "license": "MIT", 6 | "private": false, 7 | "scripts": { 8 | "rx": "browserify rx.js -t babelify > bundle.js && open index.html", 9 | "baconjs": "browserify bacon.js -t babelify > bundle.js && open index.html", 10 | "kefir": "browserify kefir.js -t babelify > bundle.js && open index.html", 11 | "watch": "watchify -v -t babelify -o bundle.js" 12 | }, 13 | "dependencies": { 14 | "babel": "^6.5.2", 15 | "babel-preset-react": "^6.5.0", 16 | "babelify": "^7.3.0", 17 | "bacon.model": "^0.1.12", 18 | "baconjs": "^0.7.84", 19 | "browserify": "^13.0.1", 20 | "classnames": "^2.2.5", 21 | "kefir": "^3.2.2", 22 | "lodash": "^4.12.0", 23 | "react": "^15.0.2", 24 | "react-combinators": "file:../..", 25 | "react-dom": "^15.0.2", 26 | "rx": "^4.1.0", 27 | "todomvc-app-css": "1.x.x", 28 | "todomvc-common": "1.x.x", 29 | "watchify": "^3.7.0" 30 | }, 31 | "engines": { 32 | "node": "4.2.x" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /examples/02-todomvc/rx.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import {render} from "react-dom" 3 | import {App} from "./src/rx/ui" 4 | import {Todos} from "./src/rx/model" 5 | 6 | const todos = Todos([ 7 | {text: "Tsers!"} 8 | ]) 9 | 10 | render(, document.getElementById("todoapp")) 11 | -------------------------------------------------------------------------------- /examples/02-todomvc/src/bacon/components/footer.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import Bacon from "baconjs" 3 | import classNames from "classnames" 4 | import {Combinator, createComponent} from "react-combinators/baconjs" 5 | 6 | export default createComponent(({model}) => 7 | model.map(({filter, resetFilter, itemsLeft, totalItems, clearCompletedItems}) => { 8 | const Btn = (name, id) => 9 | classNames({selected: current === id}))} 10 | onClick={() => resetFilter(id)}> 11 | {name} 12 | 13 | 14 | const somethingToClear = 15 | Bacon.combineWith(totalItems, itemsLeft, (tot, left) => tot - left > 0) 16 | 17 | const itemsText = 18 | itemsLeft.map(left => left === 1 ? "item" : "items") 19 | 20 | return ( 21 | 22 |
23 | 24 | {itemsLeft} {itemsText} left 25 | 26 |
    27 |
  • {Btn("All", "")}
  • 28 |
  • {Btn("Active", "active")}
  • 29 |
  • {Btn("Completed", "completed")}
  • 30 |
31 | {somethingToClear.map(yes => yes ? 32 | : null 37 | )} 38 |
39 |
40 | ) 41 | })) 42 | -------------------------------------------------------------------------------- /examples/02-todomvc/src/bacon/components/input.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import {Model} from "bacon.model" 3 | import {Combinator, createComponent} from "react-combinators/baconjs" 4 | 5 | // in reactive components, passed properties are observables 6 | // and the returned value must be an observable that contains the 7 | // rendered JSX 8 | export default createComponent(({model}) => model.map(({addItem}) => { 9 | // if components must contain their own "state", it can be declared as observables 10 | // in a similar ways you declare your application's state 11 | const text = Model("") 12 | const handleEsc = text.map(txt => e => { 13 | if (e.which === 13 && txt) { 14 | addItem(txt) 15 | text.set("") 16 | } 17 | }) 18 | return ( 19 | 20 | text.set(e.target.value)} 26 | onKeyDown={handleEsc} 27 | /> 28 | 29 | ) 30 | })) 31 | -------------------------------------------------------------------------------- /examples/02-todomvc/src/bacon/components/list.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import Bacon from "baconjs" 3 | import _ from "lodash" 4 | import {Model} from "bacon.model" 5 | import classNames from "classnames" 6 | import {Combinator, createComponent} from "react-combinators/baconjs" 7 | 8 | 9 | export default createComponent(({model}) => model.map(({displayedItems, removeItem}) => ( 10 | 11 |
    12 | {displayedItems.map(items => items.map(it => ( 13 | 14 | )))} 15 |
16 |
17 | ))) 18 | 19 | const Item = createComponent(props => 20 | Bacon.combineTemplate(props).map(({item: {id, fields, setStatus, setText}, remove}) => { 21 | const inputEl = Model(null) 22 | const editing = Model(false) 23 | const isCompleted = fields.map(({status}) => status === "completed") 24 | const text = fields.map(".text") 25 | const classes = Bacon.combineWith( 26 | editing, 27 | isCompleted, 28 | (editing, completed) => classNames({editing, completed}) 29 | ) 30 | const setEditing = setting => { 31 | editing.set(setting) 32 | _.defer(() => inputEl.get().focus()) 33 | } 34 | 35 | return ( 36 | 37 |
  • 38 |
    39 | setStatus(e.target.checked ? "completed" : "active")} 43 | checked={isCompleted} 44 | /> 45 | 48 |
    50 | inputEl.set(el)} 52 | className="edit" 53 | value={text} 54 | onBlur={() => setEditing(false)} 55 | onKeyDown={e => e.which === 13 ? setEditing(false) : null} 56 | onChange={e => setText(e.target.value)} 57 | /> 58 |
  • 59 |
    60 | ) 61 | })) 62 | -------------------------------------------------------------------------------- /examples/02-todomvc/src/bacon/components/toggleAll.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import Bacon from "baconjs" 3 | import _ from "lodash" 4 | import {Combinator, createComponent} from "react-combinators/baconjs" 5 | 6 | 7 | export default createComponent(({model}) => model.map(({items, setStatusForAllItems}) => { 8 | const allCompleted = 9 | items 10 | .map(items => items.map(it => it.fields.map(".status").map(s => s === "completed"))) 11 | .flatMapLatest(Bacon.combineAsArray) 12 | .map(_.every) 13 | 14 | return ( 15 | 16 | setStatusForAllItems(e.target.checked ? "completed" : "active")} 21 | /> 22 | 23 | ) 24 | })) 25 | -------------------------------------------------------------------------------- /examples/02-todomvc/src/bacon/model.js: -------------------------------------------------------------------------------- 1 | import Bacon from "baconjs" 2 | import {Model} from "bacon.model" 3 | 4 | export function Todos(initialItems) { 5 | const items = Model(initialItems.map(Todo)) 6 | const filter = Model("") 7 | 8 | const addItem = text => { 9 | items.modify(todos => [...todos, Todo({text})]) 10 | } 11 | const removeItem = id => { 12 | items.modify(todos => todos.filter(t => t.id !== id)) 13 | } 14 | const setStatusForAllItems = status => { 15 | items.modify(items => { 16 | items.forEach(it => it.setStatus(status)) 17 | return items 18 | }) 19 | } 20 | const resetFilter = newValue => { 21 | filter.set(newValue) 22 | } 23 | const clearCompletedItems = () => { 24 | items.modify(items => items.filter(t => t.fields.get().status !== "completed")) 25 | } 26 | 27 | const itemsWithCurrentStatus = 28 | items.flatMapLatest(items => Bacon.combineAsArray( 29 | items.map(item => item.fields.map(".status").map(status => ({status, item}))) 30 | )).toProperty() 31 | 32 | const displayedItems = Bacon.combineWith( 33 | itemsWithCurrentStatus, 34 | filter, 35 | (todos, f) => todos.filter(({status}) => !f || status === f).map(({item}) => item) 36 | ) 37 | 38 | const totalItems = items.map(".length") 39 | 40 | const itemsLeft = 41 | itemsWithCurrentStatus.map(items => items.filter(({status}) => status === "active").length) 42 | 43 | return { 44 | items, 45 | addItem, 46 | removeItem, 47 | setStatusForAllItems, 48 | clearCompletedItems, 49 | resetFilter, 50 | displayedItems, 51 | totalItems, 52 | itemsLeft, 53 | filter 54 | } 55 | } 56 | 57 | export function Todo({text = "", status = "active", id = Date.now()}) { 58 | const fields = Model({text, status}) 59 | const setText = text => fields.modify(fields => ({...fields, text})) 60 | const setStatus = status => fields.modify(fields => ({...fields, status})) 61 | return { id, fields, setText, setStatus } 62 | } 63 | -------------------------------------------------------------------------------- /examples/02-todomvc/src/bacon/ui.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import {Combinator} from "react-combinators/baconjs" 3 | import Input from "./components/input" 4 | import ToggleAll from "./components/toggleAll" 5 | import List from "./components/list" 6 | import Footer from "./components/footer" 7 | 8 | 9 | export function App({model}) { 10 | return ( 11 | 12 |
    13 | 17 |
    18 | 19 | 20 |
    21 |
    22 |
    23 |
    24 | ) 25 | } 26 | -------------------------------------------------------------------------------- /examples/02-todomvc/src/kefir/components/footer.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import Kefir from "kefir" 3 | import classNames from "classnames" 4 | import {Combinator, createComponent} from "react-combinators/kefir" 5 | 6 | export default createComponent(({model}) => 7 | model.map(({filter, resetFilter, itemsLeft, totalItems, clearCompletedItems}) => { 8 | const Btn = (name, id) => 9 | classNames({selected: current === id}))} 10 | onClick={() => resetFilter(id)}> 11 | {name} 12 | 13 | 14 | const somethingToClear = 15 | Kefir.combine([totalItems, itemsLeft], (tot, left) => tot - left > 0) 16 | 17 | const itemsText = 18 | itemsLeft.map(left => left === 1 ? "item" : "items") 19 | 20 | return ( 21 | 22 |
    23 | 24 | {itemsLeft} {itemsText} left 25 | 26 |
      27 |
    • {Btn("All", "")}
    • 28 |
    • {Btn("Active", "active")}
    • 29 |
    • {Btn("Completed", "completed")}
    • 30 |
    31 | {somethingToClear.map(yes => yes ? 32 | : null 37 | )} 38 |
    39 |
    40 | ) 41 | })) 42 | -------------------------------------------------------------------------------- /examples/02-todomvc/src/kefir/components/input.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import {Model} from "../model" 3 | import {Combinator, createComponent} from "react-combinators/kefir" 4 | 5 | // in reactive components, passed properties are observables 6 | // and the returned value must be an observable that contains the 7 | // rendered JSX 8 | export default createComponent(({model}) => model.map(({addItem}) => { 9 | // if components must contain their own "state", it can be declared as observables 10 | // in a similar ways you declare your application's state 11 | const text = Model("") 12 | const handleEsc = text.map(txt => e => { 13 | if (e.which === 13 && txt) { 14 | addItem(txt) 15 | text.set("") 16 | } 17 | }) 18 | return ( 19 | 20 | text.set(e.target.value)} 26 | onKeyDown={handleEsc} 27 | /> 28 | 29 | ) 30 | })) 31 | -------------------------------------------------------------------------------- /examples/02-todomvc/src/kefir/components/list.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import Kefir from "kefir" 3 | import _ from "lodash" 4 | import {Model} from "../model" 5 | import classNames from "classnames" 6 | import {Combinator, createComponent} from "react-combinators/kefir" 7 | 8 | 9 | export default createComponent(({model}) => model.map(({displayedItems, removeItem}) => ( 10 | 11 |
      12 | {displayedItems.map(items => items.map(it => ( 13 | 14 | )))} 15 |
    16 |
    17 | ))) 18 | 19 | const Item = createComponent(({item, remove}) => 20 | Kefir.combine([item, remove], ({id, fields, setStatus, setText}, remove) => { 21 | const inputEl = Model(null) 22 | const editing = Model(false) 23 | const isCompleted = fields.map(({status}) => status === "completed") 24 | const text = fields.map(f => f.text) 25 | const classes = Kefir.combine([editing, isCompleted], 26 | (editing, completed) => classNames({editing, completed}) 27 | ) 28 | const setEditing = setting => { 29 | editing.set(setting) 30 | _.defer(() => inputEl.get().focus()) 31 | } 32 | 33 | return ( 34 | 35 |
  • 36 |
    37 | setStatus(e.target.checked ? "completed" : "active")} 41 | checked={isCompleted} 42 | /> 43 | 46 |
    48 | inputEl.set(el)} 50 | className="edit" 51 | value={text} 52 | onBlur={() => setEditing(false)} 53 | onKeyDown={e => e.which === 13 ? setEditing(false) : null} 54 | onChange={e => setText(e.target.value)} 55 | /> 56 |
  • 57 |
    58 | ) 59 | })) 60 | -------------------------------------------------------------------------------- /examples/02-todomvc/src/kefir/components/toggleAll.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import Kefir from "kefir" 3 | import _ from "lodash" 4 | import {Combinator, createComponent} from "react-combinators/kefir" 5 | 6 | 7 | export default createComponent(({model}) => model.map(({items, setStatusForAllItems}) => { 8 | const allCompleted = 9 | items 10 | .map(items => items.map(it => it.fields.map(f => f.status === "completed"))) 11 | .flatMapLatest(Kefir.combine) 12 | .map(_.every) 13 | 14 | return ( 15 | 16 | setStatusForAllItems(e.target.checked ? "completed" : "active")} 21 | /> 22 | 23 | ) 24 | })) 25 | -------------------------------------------------------------------------------- /examples/02-todomvc/src/kefir/model.js: -------------------------------------------------------------------------------- 1 | import Kefir from "kefir" 2 | 3 | export function Todos(initialItems) { 4 | const items = Model(initialItems.map(Todo)) 5 | const filter = Model("") 6 | 7 | const addItem = text => { 8 | items.modify(todos => [...todos, Todo({text})]) 9 | } 10 | const removeItem = id => { 11 | items.modify(todos => todos.filter(t => t.id !== id)) 12 | } 13 | const setStatusForAllItems = status => { 14 | items.modify(items => { 15 | items.forEach(it => it.setStatus(status)) 16 | return items 17 | }) 18 | } 19 | const resetFilter = newValue => { 20 | filter.set(newValue) 21 | } 22 | const clearCompletedItems = () => { 23 | items.modify(items => items.filter(t => t.fields.get().status !== "completed")) 24 | } 25 | 26 | const itemsWithCurrentStatus = 27 | items.flatMapLatest(getItemsWithStatus).toProperty() 28 | 29 | const displayedItems = Kefir.combine([itemsWithCurrentStatus, filter], 30 | (todos, f) => todos.filter(({status}) => !f || status === f).map(({item}) => item) 31 | ).toProperty() 32 | 33 | const totalItems = items.map(items => items.length).toProperty() 34 | 35 | const itemsLeft = 36 | itemsWithCurrentStatus.map(items => items.filter(({status}) => status === "active").length).toProperty() 37 | 38 | return { 39 | items, 40 | addItem, 41 | removeItem, 42 | setStatusForAllItems, 43 | clearCompletedItems, 44 | resetFilter, 45 | displayedItems, 46 | totalItems, 47 | itemsLeft, 48 | filter 49 | } 50 | 51 | function getItemsWithStatus(items) { 52 | if (items.length) { 53 | return Kefir.combine(items.map(item => item.fields.map(it => it.status).map(status => ({status, item})))) 54 | } else { 55 | return Kefir.constant([]) 56 | } 57 | } 58 | } 59 | 60 | export function Todo({text = "", status = "active", id = Date.now()}) { 61 | const fields = Model({text, status}) 62 | const setText = text => fields.modify(fields => ({...fields, text})) 63 | const setStatus = status => fields.modify(fields => ({...fields, status})) 64 | return { id, fields, setText, setStatus } 65 | } 66 | 67 | 68 | export function Model(initial) { 69 | let current = undefined 70 | const pool = Kefir.pool() 71 | const model = pool.scan((state, fn) => fn(state), initial).toProperty() 72 | model.onValue(val => current = val) 73 | model.set = val => pool.plug(Kefir.constant(() => val)) 74 | model.get = () => current 75 | model.modify = fn => pool.plug(Kefir.constant(fn)) 76 | return model 77 | } 78 | -------------------------------------------------------------------------------- /examples/02-todomvc/src/kefir/ui.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import {Combinator} from "react-combinators/kefir" 3 | import Input from "./components/input" 4 | import ToggleAll from "./components/toggleAll" 5 | import List from "./components/list" 6 | import Footer from "./components/footer" 7 | 8 | 9 | export function App({model}) { 10 | return ( 11 | 12 |
    13 | 17 |
    18 | 19 | 20 |
    21 |
    22 |
    23 |
    24 | ) 25 | } 26 | -------------------------------------------------------------------------------- /examples/02-todomvc/src/rx/components/footer.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import {Observable} from "rx" 3 | import classNames from "classnames" 4 | import {Combinator, createComponent} from "react-combinators/rx" 5 | 6 | export default createComponent(({model}) => 7 | model.map(({filter, resetFilter, itemsLeft, totalItems, clearCompletedItems}) => { 8 | const Btn = (name, id) => 9 | classNames({selected: current === id}))} 10 | onClick={() => resetFilter(id)}> 11 | {name} 12 | 13 | 14 | const somethingToClear = 15 | Observable.combineLatest(totalItems, itemsLeft, (tot, left) => tot - left > 0) 16 | 17 | const itemsText = 18 | itemsLeft.map(left => left === 1 ? "item" : "items") 19 | 20 | return ( 21 | 22 |
    23 | 24 | {itemsLeft} {itemsText} left 25 | 26 |
      27 |
    • {Btn("All", "")}
    • 28 |
    • {Btn("Active", "active")}
    • 29 |
    • {Btn("Completed", "completed")}
    • 30 |
    31 | {somethingToClear.map(yes => yes ? 32 | : null 37 | )} 38 |
    39 |
    40 | ) 41 | })) 42 | -------------------------------------------------------------------------------- /examples/02-todomvc/src/rx/components/input.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import {Model} from "../model" 3 | import {Combinator, createComponent} from "react-combinators/rx" 4 | 5 | // in reactive components, passed properties are observables 6 | // and the returned value must be an observable that contains the 7 | // rendered JSX 8 | export default createComponent(({model}) => model.map(({addItem}) => { 9 | // if components must contain their own "state", it can be declared as observables 10 | // in a similar ways you declare your application's state 11 | const text = Model("") 12 | const handleEsc = text.map(txt => e => { 13 | if (e.which === 13 && txt) { 14 | addItem(txt) 15 | text.set("") 16 | } 17 | }) 18 | return ( 19 | 20 | text.set(e.target.value)} 26 | onKeyDown={handleEsc} 27 | /> 28 | 29 | ) 30 | })) 31 | -------------------------------------------------------------------------------- /examples/02-todomvc/src/rx/components/list.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import {Observable} from "rx" 3 | import _ from "lodash" 4 | import {Model} from "../model" 5 | import classNames from "classnames" 6 | import {Combinator, createComponent} from "react-combinators/rx" 7 | 8 | 9 | export default createComponent(({model}) => model.map(({displayedItems, removeItem}) => ( 10 | 11 |
      12 | {displayedItems.map(items => items.map(it => ( 13 | 14 | )))} 15 |
    16 |
    17 | ))) 18 | 19 | const Item = createComponent(({item, remove}) => 20 | Observable.combineLatest(item, remove, ({id, fields, setStatus, setText}, remove) => { 21 | const inputEl = Model(null) 22 | const editing = Model(false) 23 | const isCompleted = fields.map(({status}) => status === "completed") 24 | const text = fields.map(f => f.text) 25 | const classes = Observable.combineLatest(editing, isCompleted, 26 | (editing, completed) => classNames({editing, completed}) 27 | ) 28 | const setEditing = setting => { 29 | editing.set(setting) 30 | _.defer(() => inputEl.get().focus()) 31 | } 32 | 33 | return ( 34 | 35 |
  • 36 |
    37 | setStatus(e.target.checked ? "completed" : "active")} 41 | checked={isCompleted} 42 | /> 43 | 46 |
    48 | inputEl.set(el)} 50 | className="edit" 51 | value={text} 52 | onBlur={() => setEditing(false)} 53 | onKeyDown={e => e.which === 13 ? setEditing(false) : null} 54 | onChange={e => setText(e.target.value)} 55 | /> 56 |
  • 57 |
    58 | ) 59 | })) 60 | -------------------------------------------------------------------------------- /examples/02-todomvc/src/rx/components/toggleAll.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import {Observable} from "rx" 3 | import _ from "lodash" 4 | import {Combinator, createComponent} from "react-combinators/rx" 5 | 6 | 7 | export default createComponent(({model}) => model.map(({items, setStatusForAllItems}) => { 8 | const allCompleted = 9 | items 10 | .map(items => items.map(it => it.fields.map(f => f.status === "completed"))) 11 | .flatMapLatest(completed => Observable.combineLatest(...completed).share()) 12 | .map(completed => _.every(completed)) 13 | 14 | return ( 15 | 16 | setStatusForAllItems(e.target.checked ? "completed" : "active")} 21 | /> 22 | 23 | ) 24 | })) 25 | -------------------------------------------------------------------------------- /examples/02-todomvc/src/rx/model.js: -------------------------------------------------------------------------------- 1 | import {Observable, Subject} from "rx" 2 | 3 | export function Todos(initialItems) { 4 | const items = Model(initialItems.map(Todo)) 5 | const filter = Model("") 6 | 7 | const addItem = text => { 8 | items.modify(todos => [...todos, Todo({text})]) 9 | } 10 | const removeItem = id => { 11 | items.modify(todos => todos.filter(t => t.id !== id)) 12 | } 13 | const setStatusForAllItems = status => { 14 | items.modify(items => { 15 | items.forEach(it => it.setStatus(status)) 16 | return items 17 | }) 18 | } 19 | const resetFilter = newValue => { 20 | filter.set(newValue) 21 | } 22 | const clearCompletedItems = () => { 23 | items.modify(items => items.filter(t => t.fields.get().status !== "completed")) 24 | } 25 | 26 | const itemsWithCurrentStatus = 27 | items.flatMapLatest(getItemsWithStatus).shareReplay(1) 28 | 29 | const displayedItems = Observable.combineLatest(itemsWithCurrentStatus, filter, 30 | (todos, f) => todos.filter(({status}) => !f || status === f).map(({item}) => item) 31 | ).share() 32 | 33 | const totalItems = items.map(items => items.length).shareReplay(1) 34 | 35 | const itemsLeft = 36 | itemsWithCurrentStatus.map(items => items.filter(({status}) => status === "active").length).shareReplay(1) 37 | 38 | return { 39 | items, 40 | addItem, 41 | removeItem, 42 | setStatusForAllItems, 43 | clearCompletedItems, 44 | resetFilter, 45 | displayedItems, 46 | totalItems, 47 | itemsLeft, 48 | filter 49 | } 50 | 51 | function getItemsWithStatus(items) { 52 | if (items.length) { 53 | return Observable.combineLatest(...items.map(item => item.fields.map(it => it.status).map(status => ({status, item})))) 54 | } else { 55 | return Observable.just([]) 56 | } 57 | } 58 | } 59 | 60 | export function Todo({text = "", status = "active", id = Date.now()}) { 61 | const fields = Model({text, status}) 62 | const setText = text => fields.modify(fields => ({...fields, text})) 63 | const setStatus = status => fields.modify(fields => ({...fields, status})) 64 | return {id, fields, setText, setStatus} 65 | } 66 | 67 | 68 | export function Model(initial) { 69 | let current = undefined 70 | const subject = new Subject() 71 | const model = subject.startWith(initial).scan((state, fn) => fn(state)).shareReplay(1) 72 | model.subscribe(val => current = val) 73 | model.set = val => subject.onNext(() => val) 74 | model.get = () => current 75 | model.modify = fn => subject.onNext(fn) 76 | return model 77 | } 78 | -------------------------------------------------------------------------------- /examples/02-todomvc/src/rx/ui.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import {Combinator} from "react-combinators/rx" 3 | import Input from "./components/input" 4 | import ToggleAll from "./components/toggleAll" 5 | import List from "./components/list" 6 | import Footer from "./components/footer" 7 | 8 | 9 | export function App({model}) { 10 | return ( 11 | 12 |
    13 | 17 |
    18 | 19 | 20 |
    21 |
    22 |
    23 |
    24 | ) 25 | } 26 | -------------------------------------------------------------------------------- /examples/03-editors/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015", "stage-0", "react"] 3 | } 4 | -------------------------------------------------------------------------------- /examples/03-editors/README.md: -------------------------------------------------------------------------------- 1 | # Editor list 2 | 3 | Polymorphic lists and their state manipulation is challenging with single-reducer 4 | Flux architectures. With observables and combinators, these kind of lists become 5 | trivial to handle: each list element can have its own state model that that is 6 | rendered with combinators. 7 | 8 | In this example, it's possible to add either "counters" or "name editors" to the 9 | editor list. Each counter/name editor have its own state model and render function 10 | so the `App` must only loop through the list and call render function to each 11 | counter/editor. 12 | 13 | Examples codes (per supported FRP library) can be found from files: 14 | 15 | * `rx.js` 16 | * `bacon.js` 17 | * `kefir.js` 18 | 19 | ## Usage 20 | 21 | npm i 22 | npm run rx 23 | npm run baconjs 24 | npm run kefir 25 | 26 | -------------------------------------------------------------------------------- /examples/03-editors/bacon.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import Bacon from "baconjs" 3 | import {render} from "react-dom" 4 | import {Combinator} from "react-combinators/baconjs" 5 | 6 | function Counter(initial) { 7 | const inc = createAction() 8 | const dec = createAction() 9 | const value = inc.$.map(1).merge(dec.$.map(-1)).scan(initial, (state, step) => state + step) 10 | return { value, inc, dec } 11 | } 12 | 13 | function Name(initialFirst, initialLast) { 14 | const setFirst = createAction() 15 | const setLast = createAction() 16 | const first = setFirst.$.toProperty(initialFirst) 17 | const last = setLast.$.toProperty(initialLast) 18 | return { first, last, setFirst, setLast } 19 | } 20 | 21 | function App() { 22 | const modifySelection = createAction() 23 | const addEditor = createAction() 24 | 25 | const selection = 26 | modifySelection.$ 27 | .map(id => ({ 28 | counter: {create: () => Counter(0), render: renderCounter}, 29 | name: {create: () => Name("Foo", "Bar"), render: renderNameEditor} 30 | })[id]) 31 | .startWith({create: () => Counter(0), render: renderCounter}) 32 | 33 | const editors = 34 | selection.sampledBy(addEditor.$) 35 | .scan([], (editors, {create, render}) => [...editors, {editor: create(), render}]) 36 | 37 | const numEditors = 38 | editors.map(".length") 39 | 40 | return ( 41 | 42 |
    43 |

    Editors {numEditors}

    44 |
    45 | Add new editor 46 | 50 | 51 |
    52 |
    53 | {editors.map(editors => editors.map(({editor, render}) => ( 54 | render(editor) 55 | )))} 56 |
    57 |
    58 |
    59 | ) 60 | } 61 | 62 | function renderCounter({value, inc, dec}) { 63 | return ( 64 | 65 |
    66 | {value} 67 | 68 | 69 |
    70 |
    71 | ) 72 | } 73 | 74 | function renderNameEditor({first, last, setFirst, setLast}) { 75 | return ( 76 | 77 |
    78 | First name: setFirst(e.target.value)} />
    79 | Last name: setLast(e.target.value)} /> 80 |
    81 |
    82 | ) 83 | } 84 | 85 | function createAction() { 86 | const bus = new Bacon.Bus() 87 | const creator = (val) => bus.push(val) 88 | creator.$ = bus 89 | return creator 90 | } 91 | 92 | render(, document.getElementById("app")) 93 | -------------------------------------------------------------------------------- /examples/03-editors/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | React Combinators - Editors example 6 | 7 | 8 |
    9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /examples/03-editors/kefir.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import Kefir from "kefir" 3 | import {render} from "react-dom" 4 | import {Combinator} from "react-combinators/kefir" 5 | 6 | function Counter(initial) { 7 | const inc = createAction() 8 | const dec = createAction() 9 | const step = inc.$.map(() => 1).merge(dec.$.map(() => -1)) 10 | const value = step.scan((state, step) => state + step, initial) 11 | return { value, inc, dec } 12 | } 13 | 14 | function Name(initialFirst, initialLast) { 15 | const setFirst = createAction() 16 | const setLast = createAction() 17 | const first = toProp(setFirst.$, initialFirst) 18 | const last = toProp(setLast.$, initialLast) 19 | return { first, last, setFirst, setLast } 20 | } 21 | 22 | function App() { 23 | const modifySelection = createAction() 24 | const addEditor = createAction() 25 | 26 | const selection = toProp( 27 | modifySelection.$ 28 | .map(id => ({ 29 | counter: {create: () => Counter(0), render: renderCounter}, 30 | name: {create: () => Name("Foo", "Bar"), render: renderNameEditor} 31 | })[id]), 32 | {create: () => Counter(0), render: renderCounter} 33 | ) 34 | 35 | const editors = 36 | selection.sampledBy(addEditor.$) 37 | .scan((editors, {create, render}) => [...editors, {editor: create(), render}], []) 38 | 39 | const numEditors = 40 | editors.map(editors => editors.length) 41 | 42 | return ( 43 | 44 |
    45 |

    Editors {numEditors}

    46 |
    47 | Add new editor 48 | 52 | 53 |
    54 |
    55 | {editors.map(editors => editors.map(({editor, render}) => ( 56 | render(editor) 57 | )))} 58 |
    59 |
    60 |
    61 | ) 62 | } 63 | 64 | function renderCounter({value, inc, dec}) { 65 | return ( 66 | 67 |
    68 | {value} 69 | 70 | 71 |
    72 |
    73 | ) 74 | } 75 | 76 | function renderNameEditor({first, last, setFirst, setLast}) { 77 | return ( 78 | 79 |
    80 | First name: setFirst(e.target.value)} />
    81 | Last name: setLast(e.target.value)} /> 82 |
    83 |
    84 | ) 85 | } 86 | 87 | function createAction() { 88 | const pool = Kefir.pool() 89 | const creator = (val) => pool.plug(Kefir.constant(val)) 90 | creator.$ = pool 91 | return creator 92 | } 93 | 94 | function toProp(s, initial) { 95 | return s.merge(Kefir.constant(initial)).toProperty() 96 | } 97 | 98 | render(, document.getElementById("app")) 99 | -------------------------------------------------------------------------------- /examples/03-editors/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-combinators-editors", 3 | "version": "0.0.1", 4 | "author": "Matti Lankinen ", 5 | "license": "MIT", 6 | "private": false, 7 | "scripts": { 8 | "rx": "browserify rx.js -t babelify > bundle.js && open index.html", 9 | "baconjs": "browserify bacon.js -t babelify > bundle.js && open index.html", 10 | "kefir": "browserify kefir.js -t babelify > bundle.js && open index.html", 11 | "watch": "watchify -v -t babelify -o bundle.js" 12 | }, 13 | "dependencies": { 14 | "babel": "^6.5.2", 15 | "babel-preset-react": "^6.5.0", 16 | "babelify": "^7.3.0", 17 | "baconjs": "^0.7.84", 18 | "browserify": "^13.0.1", 19 | "kefir": "^3.2.2", 20 | "react": "^15.0.2", 21 | "react-combinators": "file:../..", 22 | "react-dom": "^15.0.2", 23 | "rx": "^4.1.0", 24 | "watchify": "^3.7.0" 25 | }, 26 | "engines": { 27 | "node": "4.2.x" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /examples/03-editors/rx.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import {Subject} from "rx" 3 | import {render} from "react-dom" 4 | import {Combinator} from "react-combinators/rx" 5 | 6 | function Counter(initial) { 7 | const inc = createAction() 8 | const dec = createAction() 9 | const step = inc.$.map(() => 1).merge(dec.$.map(() => -1)) 10 | const value = step.startWith(initial).scan((state, step) => state + step).shareReplay(1) 11 | return { value, inc, dec } 12 | } 13 | 14 | function Name(initialFirst, initialLast) { 15 | const setFirst = createAction() 16 | const setLast = createAction() 17 | const first = setFirst.$.startWith(initialFirst).shareReplay(1) 18 | const last = setLast.$.startWith(initialLast).shareReplay(1) 19 | return { first, last, setFirst, setLast } 20 | } 21 | 22 | function App() { 23 | const modifySelection = createAction() 24 | const addEditor = createAction() 25 | 26 | const selection = 27 | modifySelection.$ 28 | .map(id => ({ 29 | counter: {create: () => Counter(0), render: renderCounter}, 30 | name: {create: () => Name("Foo", "Bar"), render: renderNameEditor} 31 | })[id]) 32 | .startWith({create: () => Counter(0), render: renderCounter}) 33 | .shareReplay(1) 34 | 35 | const editors = 36 | addEditor.$ 37 | .withLatestFrom(selection, (_, sel) => sel) 38 | .startWith([]) 39 | .scan((editors, {create, render}) => [...editors, {editor: create(), render}]) 40 | .shareReplay(1) 41 | 42 | const numEditors = 43 | editors.map(editors => editors.length) 44 | 45 | return ( 46 | 47 |
    48 |

    Editors {numEditors}

    49 |
    50 | Add new editor 51 | 55 | 56 |
    57 |
    58 | {editors.map(editors => editors.map(({editor, render}) => ( 59 | render(editor) 60 | )))} 61 |
    62 |
    63 |
    64 | ) 65 | } 66 | 67 | function renderCounter({value, inc, dec}) { 68 | return ( 69 | 70 |
    71 | {value} 72 | 73 | 74 |
    75 |
    76 | ) 77 | } 78 | 79 | function renderNameEditor({first, last, setFirst, setLast}) { 80 | return ( 81 | 82 |
    83 | First name: setFirst(e.target.value)} />
    84 | Last name: setLast(e.target.value)} /> 85 |
    86 |
    87 | ) 88 | } 89 | 90 | function createAction() { 91 | const subject = new Subject() 92 | const creator = (val) => subject.onNext(val) 93 | creator.$ = subject 94 | return creator 95 | } 96 | 97 | 98 | render(, document.getElementById("app")) 99 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | throw new Error( 2 | `Select your FRP library implementation by using require("react-combinators/")` 3 | + "\nCurrently supported libraries are:" 4 | + "\n - baconjs" 5 | ) 6 | -------------------------------------------------------------------------------- /kefir.js: -------------------------------------------------------------------------------- 1 | var createExports = require("./lib/createExports").default 2 | var bindings = require("./lib/bindings/kefir").default 3 | 4 | module.exports = createExports(bindings) 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-combinators", 3 | "version": "0.4.0", 4 | "description": "Seamless combination of React and reactive programming", 5 | "main": "index.js", 6 | "scripts": { 7 | "prepublish": "npm run dist", 8 | "dist": "babel src --source-maps inline --out-dir lib", 9 | "test": "npm run lint && npm run tape", 10 | "tape": "babel-tape-runner 'test/**/*Test.js'", 11 | "tape:one": "babel-tape-runner", 12 | "lint": "eslint src test examples" 13 | }, 14 | "repository": { 15 | "type": "git", 16 | "url": "git@github.com:milankinen/react-combinators.git" 17 | }, 18 | "keywords": [ 19 | "baconjs", 20 | "frp", 21 | "react", 22 | "jsx", 23 | "reactive" 24 | ], 25 | "author": "Matti Lankinen (https://github.com/milankinen)", 26 | "license": "MIT", 27 | "devDependencies": { 28 | "babel-cli": "^6.8.0", 29 | "babel-eslint": "^6.0.4", 30 | "babel-preset-es2015": "^6.6.0", 31 | "babel-preset-stage-0": "^6.5.0", 32 | "babel-tape-runner": "^2.0.1", 33 | "baconjs": "^0.7.84", 34 | "bluebird": "^3.3.5", 35 | "eslint": "^2.9.0", 36 | "eslint-plugin-react": "^5.1.1", 37 | "faucet": "^0.0.1", 38 | "kefir": "^3.2.2", 39 | "rx": "^4.1.0", 40 | "shelljs": "^0.7.0", 41 | "tape": "^4.5.1", 42 | "zombie": "^4.2.1" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /rx.js: -------------------------------------------------------------------------------- 1 | var createExports = require("./lib/createExports").default 2 | var bindings = require("./lib/bindings/rx").default 3 | 4 | module.exports = createExports(bindings) 5 | -------------------------------------------------------------------------------- /src/Combinator.js: -------------------------------------------------------------------------------- 1 | 2 | export default (createComponent, combineVDOM, {flatMapLatest}) => ( 3 | createComponent(({children}) => ( 4 | flatMapLatest(children, combineVDOM) 5 | )) 6 | ) 7 | -------------------------------------------------------------------------------- /src/bindings/baconjs.js: -------------------------------------------------------------------------------- 1 | import Bacon from "baconjs" 2 | 3 | export default { 4 | 5 | isObservable(obs) { 6 | return obs && obs instanceof Bacon.Observable 7 | }, 8 | 9 | constant(val) { 10 | return Bacon.constant(val) 11 | }, 12 | 13 | combineAsArray(arr) { 14 | return Bacon.combineAsArray(arr) 15 | }, 16 | 17 | map(obs, fn) { 18 | return obs.map(fn) 19 | }, 20 | 21 | flatMapLatest(obs, fn) { 22 | return obs.flatMapLatest(fn) 23 | }, 24 | 25 | take(obs, num) { 26 | return obs.take(num) 27 | }, 28 | 29 | createEventBus() { 30 | return new Bacon.Bus() 31 | }, 32 | 33 | pushToEventBus(bus, value) { 34 | bus.push(value) 35 | }, 36 | 37 | startWith(obs, val) { 38 | return obs.startWith(val) 39 | }, 40 | 41 | skipDuplicates(obs) { 42 | return obs.skipDuplicates() 43 | }, 44 | 45 | subscribe(obs, onValue) { 46 | return obs.onValue(onValue) 47 | }, 48 | 49 | toProperty(obs) { 50 | return obs.toProperty() 51 | } 52 | 53 | } 54 | -------------------------------------------------------------------------------- /src/bindings/kefir.js: -------------------------------------------------------------------------------- 1 | import Kefir from "kefir" 2 | 3 | export default { 4 | 5 | isObservable(obs) { 6 | return obs && obs instanceof Kefir.Observable 7 | }, 8 | 9 | constant(val) { 10 | return Kefir.constant(val) 11 | }, 12 | 13 | combineAsArray(arr) { 14 | return Kefir.combine(arr) 15 | }, 16 | 17 | map(obs, fn) { 18 | return obs.map(fn) 19 | }, 20 | 21 | flatMapLatest(obs, fn) { 22 | return obs.flatMapLatest(fn) 23 | }, 24 | 25 | take(obs, num) { 26 | return obs.take(num) 27 | }, 28 | 29 | createEventBus() { 30 | return Kefir.pool() 31 | }, 32 | 33 | pushToEventBus(bus, value) { 34 | bus.plug(Kefir.constant(value)) 35 | }, 36 | 37 | startWith(obs, val) { 38 | // TODO: is there better way to do this?? 39 | return obs.merge(Kefir.constant(val)) 40 | }, 41 | 42 | skipDuplicates(obs) { 43 | return obs.skipDuplicates() 44 | }, 45 | 46 | subscribe(obs, onValue) { 47 | obs.onValue(onValue) 48 | return function dispose() { 49 | obs.offValue(onValue) 50 | } 51 | }, 52 | 53 | toProperty(obs) { 54 | return obs.toProperty() 55 | } 56 | 57 | } 58 | -------------------------------------------------------------------------------- /src/bindings/rx.js: -------------------------------------------------------------------------------- 1 | import {Observable, Subject} from "rx" 2 | 3 | export default { 4 | 5 | isObservable(obs) { 6 | return obs && obs instanceof Observable 7 | }, 8 | 9 | constant(val) { 10 | return Observable.just(val).shareReplay() 11 | }, 12 | 13 | combineAsArray(arr) { 14 | return Observable.combineLatest(...arr) 15 | }, 16 | 17 | map(obs, fn) { 18 | return obs.map(fn) 19 | }, 20 | 21 | flatMapLatest(obs, fn) { 22 | return obs.flatMapLatest(fn) 23 | }, 24 | 25 | take(obs, num) { 26 | return obs.take(num) 27 | }, 28 | 29 | createEventBus() { 30 | return new Subject() 31 | }, 32 | 33 | pushToEventBus(bus, value) { 34 | bus.onNext(value) 35 | }, 36 | 37 | startWith(obs, val) { 38 | return obs.startWith(val) 39 | }, 40 | 41 | skipDuplicates(obs) { 42 | return obs.distinctUntilChanged() 43 | }, 44 | 45 | subscribe(obs, onValue) { 46 | const disposable = obs.subscribe(onValue) 47 | return function dispose() { 48 | return disposable.dispose() 49 | } 50 | }, 51 | 52 | toProperty(obs) { 53 | return obs.shareReplay() 54 | } 55 | 56 | } 57 | -------------------------------------------------------------------------------- /src/combineVDOM.js: -------------------------------------------------------------------------------- 1 | import {cloneElement, isValidElement} from "react/lib/ReactElement" 2 | import {contains, isEmpty, isArray, zip, find} from "./util" 3 | import Combinator from "./Combinator" 4 | 5 | 6 | export default ({constant, combineAsArray, isObservable, map}) => { 7 | return function combineVDOM(vdom) { 8 | const obs = resolveObservables(vdom, []) 9 | if (isEmpty(obs)) { 10 | return constant(vdom) 11 | } else { 12 | return map(combineAsArray(obs), values => assignObservableValues(vdom, zip(obs, values))) 13 | } 14 | 15 | function resolveObservables(el, obs) { 16 | const propKeys = Object.keys(el.props || {}) 17 | for (let k = 0 ; k < propKeys.length ; k++) { 18 | const key = propKeys[k] 19 | const prop = el.props[key] 20 | if (key === "children") { 21 | const children = isArray(prop) ? prop : [ prop ] 22 | for (let i = 0 ; i < children.length ; i++) { 23 | const child = children[i] 24 | if (isObservable(child) && !contains(obs, child)) { 25 | obs.push(child) 26 | } else if (isValidElement(child) && !isCombinator(child)) { 27 | resolveObservables(child, obs) 28 | } 29 | } 30 | } else { 31 | if (isObservable(prop) && !contains(obs, prop)) { 32 | obs.push(prop) 33 | } 34 | } 35 | } 36 | return obs 37 | } 38 | 39 | function assignObservableValues(el, obsValues) { 40 | const newProps = {} 41 | 42 | const propKeys = Object.keys(el.props || {}) 43 | for (let k = 0 ; k < propKeys.length ; k++) { 44 | const key = propKeys[k] 45 | const prop = el.props[key] 46 | if (key === "children") { 47 | const children = isArray(prop) ? prop : [ prop ] 48 | const newChildren = [] 49 | for (let i = 0 ; i < children.length ; i++) { 50 | const child = children[i] 51 | if (isObservable(child)) { 52 | newChildren.push(find(obsValues, r => r[0] === child)[1]) 53 | } else if (isValidElement(child) && !isCombinator(child)) { 54 | newChildren.push(assignObservableValues(child, obsValues)) 55 | } else { 56 | newChildren.push(child) 57 | } 58 | } 59 | newProps.children = isArray(prop) ? newChildren : newChildren[0] 60 | } else { 61 | if (isObservable(prop)) { 62 | newProps[key] = find(obsValues, r => r[0] === prop)[1] 63 | } else { 64 | newProps[key] = prop 65 | } 66 | } 67 | } 68 | return cloneElement(el, newProps) 69 | } 70 | } 71 | } 72 | 73 | 74 | function isCombinator(el) { 75 | return el && el.type === Combinator 76 | } 77 | -------------------------------------------------------------------------------- /src/createComponent.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import {keys, values, zip, zipObject} from "./util" 3 | 4 | export default ({createEventBus, pushToEventBus, take, startWith, skipDuplicates, subscribe, toProperty}) => { 5 | return function createComponent(renderFn) { 6 | return React.createClass({ 7 | getInitialState() { 8 | const propsBuses = zipObject(keys(this.props), values(this.props).map(() => createEventBus())) 9 | 10 | const propsS = 11 | zipObject(keys(this.props), zip(values(propsBuses), values(this.props)).map(([bus, initial]) => ( 12 | toProperty(skipDuplicates(startWith(bus, initial))) 13 | ))) 14 | 15 | return { 16 | propsBuses, 17 | vdomS: renderFn(propsS), 18 | vdom: null 19 | } 20 | }, 21 | componentWillMount() { 22 | const updateVDOM = vdom => this.setState({vdom}) 23 | if (process.browser) { 24 | this.setState({ dispose: subscribe(this.state.vdomS, updateVDOM) }) 25 | } else { 26 | subscribe(take(this.state.vdomS, 1), updateVDOM) 27 | } 28 | }, 29 | componentWillReceiveProps(nextProps) { 30 | keys(nextProps).forEach(propName => { 31 | const bus = this.state.propsBuses[propName] 32 | if (!bus) { 33 | console.warn( 34 | `Trying to pass property "${propName}" that is not set during the component creation.`, 35 | "Ignoring this property." 36 | ) 37 | } else { 38 | pushToEventBus(bus, nextProps[propName]) 39 | } 40 | }) 41 | }, 42 | shouldComponentUpdate(nextProps, nextState) { 43 | return nextState.vdom !== this.state.vdom 44 | }, 45 | componentWillUnmount() { 46 | const {dispose} = this.state 47 | if (dispose) { 48 | dispose() 49 | } 50 | }, 51 | render() { 52 | return this.state.vdom 53 | } 54 | }) 55 | 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/createExports.js: -------------------------------------------------------------------------------- 1 | import makeCombineVDOM from "./combineVDOM" 2 | import makeCombinator from "./Combinator" 3 | import makeCreateComponent from "./createComponent" 4 | 5 | 6 | export default bindings => { 7 | const combineVDOM = makeCombineVDOM(bindings) 8 | const createComponent = makeCreateComponent(bindings) 9 | const Combinator = makeCombinator(createComponent, combineVDOM, bindings) 10 | 11 | return { combineVDOM, Combinator, createComponent } 12 | } 13 | -------------------------------------------------------------------------------- /src/util.js: -------------------------------------------------------------------------------- 1 | 2 | export default { 3 | keys, values, zipObject, zip, isArray, isEmpty, contains, find 4 | } 5 | 6 | export function isArray(x) { 7 | return x && x.constructor === Array 8 | } 9 | 10 | export function isEmpty(x) { 11 | return !x || x.length === 0 12 | } 13 | 14 | export function contains(arr, val) { 15 | if (arr) { 16 | for (let i = 0 ; i < arr.length ; i++) { 17 | if (arr[i] === val) return true 18 | } 19 | } 20 | return false 21 | } 22 | 23 | export function find(arr, predicate) { 24 | if (arr) { 25 | for (let i = 0 ; i < arr.length ; i++) { 26 | if (predicate(arr[i])) return arr[i] 27 | } 28 | } 29 | } 30 | 31 | export function keys(obj = {}) { 32 | return Object.keys(obj) 33 | } 34 | 35 | export function values(obj) { 36 | const k = keys(obj) 37 | const result = [] 38 | for (let i = 0 ; i < k.length; i++) { 39 | const key = k[i] 40 | if (obj.hasOwnProperty(key)) { 41 | result.push(obj[key]) 42 | } 43 | } 44 | return result 45 | } 46 | 47 | export function zipObject(keys, values) { 48 | const result = {} 49 | for (let i = 0 ; i < keys.length ; i++) { 50 | result[keys[i]] = values[i] 51 | } 52 | return result 53 | } 54 | 55 | export function zip(...arrays) { 56 | const len = Math.max(...arrays.map(a => a.length)) 57 | const result = [] 58 | for (let i = 0 ; i < len ; i++) { 59 | const elem = [] 60 | for (let j = 0 ; j < arrays.length ; j++) { 61 | elem.push(arrays[j][i]) 62 | } 63 | result.push(elem) 64 | } 65 | return result 66 | } 67 | -------------------------------------------------------------------------------- /test/bmiTest.js: -------------------------------------------------------------------------------- 1 | import {testExample, awaitMs} from "./helpers" 2 | 3 | 4 | testExample("01-bmi", (t, browser) => { 5 | t.comment("test initial values") 6 | browser.assert.input("input.Height", 180) 7 | browser.assert.text(".bmi", "25") 8 | browser.fill("input.Height", 200) 9 | return awaitMs(50).then(() => { 10 | t.comment("test changed values") 11 | browser.assert.input("input.Height", 200) 12 | browser.assert.text(".bmi", "20") 13 | }) 14 | }) 15 | -------------------------------------------------------------------------------- /test/editorsTest.js: -------------------------------------------------------------------------------- 1 | import {testExample, awaitMs} from "./helpers" 2 | 3 | 4 | testExample("03-editors", (t, browser) => { 5 | t.comment("test initial values") 6 | browser.assert.text("h1", "Editors 0") 7 | 8 | t.comment("add two counters") 9 | browser.click("#add") 10 | browser.click("#add") 11 | 12 | return awaitMs(50) 13 | .then(() => { 14 | t.comment("test that counters were created") 15 | browser.assert.text("h1", "Editors 2") 16 | browser.assert.elements(".counter", 2) 17 | browser.assert.text(".counter:nth-child(1) .val", "0") 18 | browser.assert.text(".counter:nth-child(2) .val", "0") 19 | }) 20 | .then(() => { 21 | t.comment("increment counters") 22 | browser.click(".counter:nth-child(1) button.inc") 23 | browser.click(".counter:nth-child(2) button.inc") 24 | browser.click(".counter:nth-child(1) button.inc") 25 | }) 26 | .then(() => browser.wait()) 27 | .then(() => { 28 | t.comment("test that counter values were incremented") 29 | browser.assert.text(".counter:nth-child(1) .val", "2") 30 | browser.assert.text(".counter:nth-child(2) .val", "1") 31 | }) 32 | }) 33 | -------------------------------------------------------------------------------- /test/helpers.js: -------------------------------------------------------------------------------- 1 | import Browser from "zombie" 2 | import test from "tape" 3 | import shell from "shelljs" 4 | import {resolve} from "path" 5 | import Promise from "bluebird" 6 | 7 | 8 | const FRP_LIBS = ["rx", "bacon", "kefir"] 9 | 10 | export function testExample(example, testCase) { 11 | execInExampleDir(example, "rm -rf node_modules/react-combinators") 12 | execInExampleDir(example, "npm i") 13 | 14 | FRP_LIBS.forEach(lib => { 15 | test(`run example "${example}" with "${lib}"`, t => { 16 | t.comment("example initialization") 17 | execInExampleDir(example, `./node_modules/.bin/browserify ${lib}.js -t babelify > bundle.js`) 18 | const browser = polyfillBrowser(new Browser()) 19 | Promise.resolve(browser.visit("file://" + getExampleDir(example) + "/index.html")) 20 | .then(() => browser.assert.success()) 21 | .then(() => awaitMs(500)) 22 | .then(() => testCase(t, browser)) 23 | .finally(() => t.end()) 24 | .done() 25 | }) 26 | }) 27 | } 28 | 29 | export function awaitMs(ms) { 30 | return new Promise(resolve => setTimeout(resolve, ms)) 31 | } 32 | 33 | function polyfillBrowser(browser) { 34 | browser.keyDown = function (targetSelector, keyCode) { 35 | const e = this.window.document.createEvent("HTMLEvents") 36 | e.initEvent("keydown", true, true) 37 | e.which = e.keyCode = keyCode 38 | const target = this.window.document.querySelector(targetSelector) 39 | target && target.dispatchEvent(e) 40 | return this._wait(null) 41 | } 42 | return browser 43 | } 44 | 45 | function execInExampleDir(example, cmd) { 46 | const dir = getExampleDir(example) 47 | const {code} = shell.exec(`cd ${dir} && ${cmd}`) 48 | if (code !== 0) { 49 | throw new Error( 50 | `Command ${cmd} in example ${example} had non-zero exit code` 51 | ) 52 | } 53 | } 54 | 55 | function getExampleDir(example) { 56 | return resolve(__dirname, `../examples/${example}`) 57 | } 58 | -------------------------------------------------------------------------------- /test/todomvcTest.js: -------------------------------------------------------------------------------- 1 | import {testExample} from "./helpers" 2 | 3 | 4 | testExample("02-todomvc", (t, browser) => { 5 | t.comment("test initial values") 6 | browser.assert.input("#new-todo", "") 7 | browser.assert.elements("#todo-list li", 1) 8 | 9 | return addNewItem("some text") 10 | .then(() => browser.wait()) 11 | .then(() => { 12 | t.comment("test that new item was added") 13 | browser.assert.text("#todo-list li:last-child label", "some text") 14 | browser.assert.text("#todo-count", "2 items left") 15 | browser.assert.elements("#todo-list li", 2) 16 | }) 17 | .then(() => { 18 | t.comment("test that toggling item status works") 19 | return browser.check("#todo-list li:first-child input.toggle") 20 | }) 21 | .then(() => browser.wait()) 22 | .then(() => { 23 | browser.assert.hasClass("#todo-list li:first-child", "completed") 24 | browser.assert.text("#todo-count", "1 item left") 25 | browser.assert.elements("#todo-list li", 2) 26 | }) 27 | .then(() => { 28 | t.comment("test that clearing completed items works") 29 | return browser.click("#clear-completed").then(() => browser.wait()) 30 | }) 31 | .then(() => { 32 | t.comment("test that completed item has removed from the list") 33 | browser.assert.elements("#todo-list li", 1) 34 | }) 35 | 36 | 37 | function addNewItem(text) { 38 | t.comment("add new item") 39 | browser.fill("#new-todo", text) 40 | return browser.keyDown("#new-todo", 13) // == enter 41 | } 42 | }) 43 | --------------------------------------------------------------------------------