` 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 |
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 | setEditing(true)}>
46 | {text}
47 |
48 | remove(id)}/>
49 |
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 |
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 |
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 | setEditing(true)}>
44 | {text}
45 |
46 | remove(id)}/>
47 |
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 |
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 |
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 | setEditing(true)}>
44 | {text}
45 |
46 | remove(id)}/>
47 |
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 |
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 | modifySelection(e.target.value)}>
47 | Counter
48 | Name
49 |
50 | Add
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 | modifySelection(e.target.value)}>
49 | Counter
50 | Name
51 |
52 | Add
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 | modifySelection(e.target.value)}>
52 | Counter
53 | Name
54 |
55 | Add
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 |
--------------------------------------------------------------------------------