├── .eslintrc ├── .gitignore ├── .npmignore ├── .travis.yml ├── LICENSE ├── README.md ├── index.js ├── package.json ├── src ├── createAction.js ├── flatUpdate.js └── startApp.js └── test ├── createActionTest.js ├── flatUpdateTest.js └── startAppTest.js /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "babel-eslint", 3 | "extends": "eslint:recommended", 4 | "rules": { 5 | "no-console": 0, 6 | "semi": [2, "never"], 7 | "quotes": [2, "double"] 8 | }, 9 | "env": { 10 | "node": true, 11 | "browser": true 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | *.iml 3 | node_modules 4 | lib 5 | *.log 6 | npm-debug* 7 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | !lib 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "0.12" 4 | - "0.11" 5 | - "0.10" 6 | - "iojs-v1.0.4" 7 | - "4.0.0" 8 | -------------------------------------------------------------------------------- /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 | # MEGABLOB 2 | 3 | Utilities for React + Bacon MEGABLOB development 4 | 5 | [![npm version](https://badge.fury.io/js/megablob.svg)](http://badge.fury.io/js/megablob) 6 | [![Build Status](https://travis-ci.org/milankinen/megablob.svg)](https://travis-ci.org/milankinen/megablob) 7 | 8 | 9 | ## Motivation 10 | 11 | MEGABLOB architecture is awesome. However, there is some boilerplate 12 | that must be written before the actual development can begin. This 13 | project contains some essential utilities which can deal the 14 | boilerplate and enable rapid development startup. 15 | 16 | ## Installation 17 | 18 | You need to install this package, `react` and `baconjs` 19 | 20 | npm i --save react baconjs megablob 21 | 22 | ## Usage 23 | 24 | MEGABLOB provides a set of functions / React components that can be 25 | used by requiring the package 26 | 27 | ```javascript 28 | const {} = require("megablob") 29 | ``` 30 | 31 | ## API 32 | 33 | ### `createAction` 34 | 35 | Creates a new action that can be called as normal function taking 36 | single argument. Internally this uses `Bacon.Bus` and `.push` and 37 | binds the bus instance to the function so that no `bus.push.bind(bus)` 38 | is needed. 39 | 40 | In addition it detects the calling environment so that in `nodejs` 41 | environment the returned functions are no-op, thus enabling some memory 42 | and performance improvements (because no extra buses are created). 43 | 44 | The underlying bus can be accessed by using `.$` member variable. 45 | 46 | Usage: 47 | 48 | ```javascript 49 | // actions.js 50 | const {createAction} = require("megablob") 51 | export const increment = createAction() 52 | 53 | // state.js 54 | const {increment} = require("./actions") 55 | export default initState({counter = 0}) { 56 | return increment.$.map(1).scan(counter, (state, step) => state + step) 57 | } 58 | 59 | // component.js 60 | const {increment} = require("./actions") 61 | export default React.createClass({ 62 | render() { 63 | return 64 | } 65 | }) 66 | ``` 67 | 68 | ### `startApp` 69 | 70 | (state, Function>, Function) => _ 71 | 72 | This function takes three arguments: 73 | 74 | 1. Initial state of the application 75 | 2. Function which takes state as an input value and returns "application state property" `Bacon.Property` (aka MEGABLOB) 76 | 3. Function which is called every time when the "application state" changes. The new state is given as a parameter 77 | 78 | This function is meant to be used in the entry point of the browser 79 | application (top level). It deals automatically the live reloading / hot 80 | module replacement if such thing is used with Browserify/Webpack. 81 | 82 | **ATTENTION**: At the moment only one `startApp` call per application 83 | is supported! 84 | 85 | Usage: 86 | 87 | ```javascript 88 | // appState.js 89 | export function appState(initialState = {}) { 90 | return Bacon.combineTemplate({ 91 | ...init state... 92 | }) 93 | } 94 | 95 | // entry.js 96 | const {startApp} = require("megablob") 97 | const appState = require("./appState") 98 | 99 | startApp(window.INITIAL_STATE, appState, state => { 100 | React.render(, document.getElementById("app")) 101 | }) 102 | ``` 103 | 104 | ### `flatUpdate` 105 | 106 | flatUpdate :: (state, [Observable+, [Function<(state,args),Observable>, Function<(state, A),newState>]]+) => Observable 107 | 108 | This function is a successor of **[Bacon.update](https://github.com/baconjs/bacon.js/#bacon-update)**. 109 | It is fully backwards compatible with `Bacon.update` syntax but it also enables "2-stage" async state 110 | updating by using two separate functions 111 | 112 | 1. The first function receives the current state and triggering event stream values. 113 | It can return an (asynchronous) stream whose value(s) is processed by the 2nd function. 114 | 2. The second function processes the values from the first stream and synchronously 115 | returns the new state based on those values 116 | 117 | Usage example: 118 | 119 | ```javascript 120 | const stateP = flatUpdate(initialState, 121 | [event1S], (state, newState) => newState, // supports normal Bacon.update 122 | [event2S], (state, newState) => Bacon.later(100, newState) // supports delayed state updating 123 | [event3S], [submitForm, handleSubmitResult] // supports 2-stage state updating 124 | ) 125 | function submitForm(state, event) { 126 | return Bacon.later(1000, doSomethingWith(state, event)) // simulate "server" 127 | } 128 | function handleSubmitResult(state, resultFromServer) { 129 | return {...state, ...resultFromServer} // resultFromServer === doSomething(state, event) 130 | } 131 | ``` 132 | 133 | ## License 134 | 135 | MIT 136 | 137 | ## Contributing 138 | 139 | Any important utility function/component missing that should be 140 | included? Please raise an issue or create a pull request. All 141 | contributions are welcome! 142 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 2 | module.exports = { 3 | startApp: require("./lib/startApp"), 4 | createAction: require("./lib/createAction"), 5 | flatUpdate: require("./lib/flatUpdate") 6 | } 7 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "megablob", 3 | "version": "0.2.0", 4 | "description": "Utilities for React + Bacon MEGABLOB development", 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 | faucet", 10 | "tape": "babel-tape-runner 'test/**/*Test.js'", 11 | "lint": "eslint src test" 12 | }, 13 | "repository": { 14 | "type": "git", 15 | "url": "git@github.com:milankinen/megablob.git" 16 | }, 17 | "keywords": [ 18 | "baconjs", 19 | "react", 20 | "megablob", 21 | "flux" 22 | ], 23 | "author": "Matti Lankinen (https://github.com/milankinen)", 24 | "license": "MIT", 25 | "devDependencies": { 26 | "babel": "5.8.23", 27 | "babel-eslint": "4.1.3", 28 | "babel-tape-runner": "1.2.0", 29 | "baconjs": "0.7.75", 30 | "eslint": "1.6.0", 31 | "faucet": "0.0.1", 32 | "tape": "4.2.1" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/createAction.js: -------------------------------------------------------------------------------- 1 | const Bacon = require("baconjs") 2 | 3 | export default function createAction() { 4 | return process.browser ? createBrowserAction() : createNoopAction() 5 | } 6 | 7 | function createBrowserAction() { 8 | const bus = new Bacon.Bus() 9 | const action = value => bus.push(value) 10 | action.$ = bus 11 | return action 12 | } 13 | 14 | function createNoopAction() { 15 | const action = () => { 16 | console.warn( 17 | "Actions should be called only from browser.", 18 | "Nothing was dispatched" 19 | ) 20 | } 21 | action.$ = Bacon.never() 22 | return action 23 | } 24 | -------------------------------------------------------------------------------- /src/flatUpdate.js: -------------------------------------------------------------------------------- 1 | const Bacon = require("baconjs") 2 | 3 | 4 | export default function flatUpdate(initial, ...patterns) { 5 | const updateBus = new Bacon.Bus(), 6 | lazyUpdate = [updateBus, (state, update) => update(state)] 7 | 8 | const streams = patterns.filter((_, i) => i % 2 === 0), 9 | callbacks = patterns.filter((_, i) => i % 2 !== 0).map(toUpdateHandler), 10 | args = streams.map((_, i) => [streams[i], callbacks[i]]).reduce((memo, pair) => [...memo, ...pair], lazyUpdate) 11 | 12 | return Bacon.update(...[initial, ...args]) 13 | 14 | function toUpdateHandler(cb) { 15 | if (cb instanceof Array) { 16 | if (cb.length !== 2) { 17 | throw new Error("Update pattern must be [Function<(state, ...args), Observable>, Function<(state, A), state>") 18 | } 19 | return (state, ...args) => { 20 | const [fetch, resolve] = cb 21 | const result = fetch(state, ...args) 22 | if (result instanceof Bacon.Observable) { 23 | updateBus.plug(result.map(val => state => resolve(state, val))) 24 | return state 25 | } else { 26 | return result 27 | } 28 | } 29 | } else { 30 | return (state, ...args) => { 31 | const result = cb.apply(null, [state, ...args]) 32 | if (result instanceof Bacon.Observable) { 33 | updateBus.plug(result.map(newState => () => newState)) 34 | return state 35 | } else { 36 | return result 37 | } 38 | } 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/startApp.js: -------------------------------------------------------------------------------- 1 | 2 | const memo = {} 3 | 4 | export default function startApp(initialState, initStateP, onStateChange) { 5 | if (typeof initStateP !== "function" || typeof onStateChange !== "function") { 6 | throw new Error( 7 | "Invalid startApp usage, three arguments are needed: \n" + 8 | " startApp([initialState], [(state) => property], [(state) => ()])>" 9 | ) 10 | } 11 | 12 | if (!process.browser) { 13 | throw new Error("startApp can't be called from node.js environment") 14 | } 15 | 16 | let state = initialState 17 | if (memo.unsubscribe) { 18 | memo.unsubscribe() 19 | state = memo.lastState 20 | } 21 | 22 | memo.unsubscribe = initStateP(state).onValue(state => { 23 | try { 24 | memo.lastState = state 25 | onStateChange(state) 26 | } catch (e) { 27 | console.error("State change callback threw an unexpected error") 28 | console.error(e) 29 | } 30 | }) 31 | 32 | } 33 | -------------------------------------------------------------------------------- /test/createActionTest.js: -------------------------------------------------------------------------------- 1 | const test = require("tape") 2 | 3 | const createAction = require("../src/createAction") 4 | 5 | 6 | test("'createAction' creates a proper action in browser environment", t => { 7 | process.browser = true 8 | const action = createAction() 9 | 10 | action.$ 11 | .bufferWithCount(2) 12 | .onValue(([first, second]) => { 13 | t.equal(first, "a") 14 | t.equal(second, "b") 15 | t.end() 16 | }) 17 | action("a") 18 | action("b") 19 | }) 20 | 21 | 22 | test("'createAction' creates a no-op action in node environment", t => { 23 | delete process.browser 24 | const action = createAction() 25 | 26 | action.$.subscribe(event => { 27 | if (event.isEnd()) { 28 | t.ok("Stream end reached") 29 | t.end() 30 | } else { 31 | t.fail("Not expecting event", event) 32 | } 33 | }) 34 | action("tsers") 35 | }) 36 | -------------------------------------------------------------------------------- /test/flatUpdateTest.js: -------------------------------------------------------------------------------- 1 | const Bacon = require("baconjs"), 2 | test = require("tape") 3 | 4 | const flatUpdate = require("../src/flatUpdate") 5 | 6 | test("flatUpdate", suite => { 7 | 8 | suite.test("makes server integration easier", t => { 9 | const userS = Bacon.once({name: "mla", status: "guest"}), 10 | submitS = Bacon.once({form: "login"}) 11 | 12 | const stateP = flatUpdate({}, 13 | [userS], (state, user) => ({...state, user}), 14 | [submitS], [loginToServer, handleLoginResponse] 15 | ) 16 | 17 | stateP 18 | .changes() 19 | .bufferWithCount(3) 20 | .take(1) 21 | .onValue(states => { 22 | t.deepEqual(states, [ 23 | { user: { name: "mla", status: "guest" } }, // after userS 24 | { user: { name: "mla", status: "guest" } }, // after loginToServer 25 | { user: { name: "mla", status: "member" } } // after handleLoginResponse 26 | ]) 27 | t.end() 28 | }) 29 | 30 | function loginToServer() { 31 | //console.log("logging in user", state.user.name) 32 | return Bacon.later(100, {status: "ok"}) 33 | } 34 | function handleLoginResponse(state, {status}) { 35 | const {user} = state 36 | return status === "ok" ? {...state, user: {...user, status: "member"}} : state 37 | } 38 | }) 39 | 40 | suite.test("works with 'traditional' Bacon.update patterns and multi-stream patterns", t => { 41 | const ready = Bacon.later(300, 2), 42 | set = Bacon.later(100, 1), 43 | go = Bacon.later(200, 4) 44 | 45 | const stateP = flatUpdate(10, 46 | [ready, set, go], [(_, r, s, g) => Bacon.later(100, r + s + g), (state, sum) => state + sum], 47 | [go.map(g => g + 1)], (state, g) => state - g 48 | ) 49 | 50 | stateP 51 | .changes() 52 | .skipDuplicates() 53 | .bufferWithCount(2) 54 | .take(1) 55 | .onValue(([afterGo, afterAll]) => { 56 | t.equal(afterGo, 10 - (4 + 1)) 57 | t.equal(afterAll, 10 - (4 + 1) + 2 + 1 + 4) 58 | t.end() 59 | }) 60 | }) 61 | 62 | suite.test("supports multi-value streams", t => { 63 | const times = Bacon.once(3) 64 | const stateP = flatUpdate([], 65 | [times], [(_, t) => Bacon.interval(10, "tsers").take(t), (state, s) => [...state, s]] 66 | ) 67 | 68 | stateP 69 | .changes() 70 | .bufferWithCount(4) 71 | .take(1) 72 | .onValue(words => { 73 | t.deepEqual(words, [[], ["tsers"], ["tsers", "tsers"], ["tsers", "tsers", "tsers"]]) 74 | t.end() 75 | }) 76 | }) 77 | 78 | }) 79 | -------------------------------------------------------------------------------- /test/startAppTest.js: -------------------------------------------------------------------------------- 1 | const test = require("tape"), 2 | Bacon = require("baconjs") 3 | 4 | const startApp = require("../src/startApp") 5 | 6 | 7 | test("'startApp' throws an exception if it was called from nodejs context", t => { 8 | t.throws(() => { 9 | startApp({msg: "tsers"}, ({msg}) => Bacon.constant({msg}), () => {}) 10 | }) 11 | t.end() 12 | }) 13 | --------------------------------------------------------------------------------