├── examples ├── counters │ ├── 1 │ │ ├── index.html │ │ └── main.js │ ├── 2 │ │ ├── index.html │ │ ├── counter.js │ │ └── main.js │ ├── 3 │ │ ├── index.html │ │ ├── counter.js │ │ └── main.js │ ├── 4 │ │ ├── index.html │ │ ├── counter.js │ │ └── main.js │ ├── build.js │ ├── README.md │ ├── package.json │ └── action.js ├── file-uploader │ ├── .gitignore │ ├── js │ │ ├── svg.js │ │ ├── main.js │ │ ├── uploader.js │ │ ├── app.js │ │ ├── list.js │ │ ├── upload.js │ │ └── test.js │ ├── package.json │ ├── index.html │ ├── server.js │ └── README.md ├── autocomplete │ ├── .gitignore │ ├── js │ │ ├── helpers │ │ │ ├── nofx.js │ │ │ ├── targetvalue.js │ │ │ ├── erroror.js │ │ │ ├── ismaybe.js │ │ │ ├── throwor.js │ │ │ ├── emptytonothing.js │ │ │ └── casekey.js │ │ ├── test.js │ │ ├── main.js │ │ ├── menu.js │ │ ├── test-unit-menu.js │ │ ├── autocomplete.js │ │ ├── test-app.js │ │ ├── app.js │ │ └── test-unit-autocomplete.js │ ├── testem.json │ ├── package.json │ ├── Makefile │ ├── index.html │ ├── README.md │ └── config.js ├── modal │ ├── readme.md │ ├── index.html │ ├── js │ │ ├── modal.js │ │ └── app.js │ └── package.json ├── zip-codes-future │ ├── .jshintrc │ ├── index.html │ ├── package.json │ ├── README.md │ ├── main.js │ └── app.js ├── hot-module-reloading │ ├── .jshintrc │ ├── screencast.gif │ ├── style.css │ ├── index.html │ ├── webpack.config.js │ ├── package.json │ ├── counter.js │ ├── main.js │ ├── counters.js │ └── README.md ├── todo │ ├── css │ │ └── app.css │ ├── package.json │ ├── app-readme.md │ ├── index.html │ ├── readme.md │ └── js │ │ ├── task.js │ │ └── task-list.js ├── zip-codes │ ├── js │ │ ├── main.js │ │ └── app.js │ ├── index.html │ └── package.json ├── counters-no-frp │ ├── 1 │ │ ├── index.html │ │ └── main.js │ ├── 2 │ │ ├── index.html │ │ ├── counter.js │ │ └── main.js │ ├── 3 │ │ ├── index.html │ │ ├── counter.js │ │ └── main.js │ ├── 4 │ │ ├── index.html │ │ ├── counter.js │ │ └── main.js │ ├── package.json │ └── README.md ├── who-to-follow │ ├── README.md │ ├── package.json │ ├── index.html │ └── main.js └── nesting │ ├── js │ ├── modal.js │ ├── switch.js │ ├── list.js │ └── app.js │ ├── package.json │ ├── index.html │ └── readme.md ├── helpers ├── targetvalue.js └── ifenter.js ├── .gitignore ├── LICENSE ├── router.js └── README.md /examples/counters/build.js: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/file-uploader/.gitignore: -------------------------------------------------------------------------------- 1 | uploads/* 2 | -------------------------------------------------------------------------------- /examples/autocomplete/.gitignore: -------------------------------------------------------------------------------- 1 | jspm_packages 2 | -------------------------------------------------------------------------------- /examples/modal/readme.md: -------------------------------------------------------------------------------- 1 | # Modal/dialog/popup example 2 | -------------------------------------------------------------------------------- /examples/zip-codes-future/.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "asi": true, 3 | "esnext": true 4 | } 5 | -------------------------------------------------------------------------------- /examples/hot-module-reloading/.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "asi": true, 3 | "esnext": true 4 | } 5 | -------------------------------------------------------------------------------- /helpers/targetvalue.js: -------------------------------------------------------------------------------- 1 | module.exports = function(ev) { 2 | return ev.target.value; 3 | }; 4 | -------------------------------------------------------------------------------- /examples/autocomplete/js/helpers/nofx.js: -------------------------------------------------------------------------------- 1 | module.exports = function noFx(s){ return [s,[]]; } 2 | 3 | -------------------------------------------------------------------------------- /examples/autocomplete/js/helpers/targetvalue.js: -------------------------------------------------------------------------------- 1 | var path = require('ramda/src/path'); 2 | module.exports = path(['target', 'value']); 3 | 4 | -------------------------------------------------------------------------------- /examples/todo/css/app.css: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | app-template.css overrides 4 | 5 | remove this comment if used 6 | remove this file if not 7 | 8 | */ 9 | -------------------------------------------------------------------------------- /helpers/ifenter.js: -------------------------------------------------------------------------------- 1 | var R = require('ramda'); 2 | 3 | module.exports = R.curry(function(fn, val, ev) { 4 | if (ev.keyCode === 13) return fn(val); 5 | }); 6 | -------------------------------------------------------------------------------- /examples/hot-module-reloading/screencast.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paldepind/functional-frontend-architecture/HEAD/examples/hot-module-reloading/screencast.gif -------------------------------------------------------------------------------- /examples/autocomplete/js/test.js: -------------------------------------------------------------------------------- 1 | 2 | require('source-map-support').install(); 3 | 4 | require('./test-unit-menu'); 5 | require('./test-unit-autocomplete'); 6 | require('./test-app'); 7 | -------------------------------------------------------------------------------- /examples/autocomplete/js/helpers/erroror.js: -------------------------------------------------------------------------------- 1 | module.exports = function errorOr(fn){ 2 | return function(val){ 3 | return val instanceof Error ? val : fn(val); 4 | } 5 | } 6 | 7 | -------------------------------------------------------------------------------- /examples/autocomplete/js/helpers/ismaybe.js: -------------------------------------------------------------------------------- 1 | var Maybe = require('ramda-fantasy/src/Maybe'); 2 | module.exports = function isMaybe(val){ return Maybe.isNothing(val) || Maybe.isJust(val); } 3 | 4 | -------------------------------------------------------------------------------- /examples/zip-codes/js/main.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var app = require('./app'); 4 | 5 | window.addEventListener('DOMContentLoaded', function(){ 6 | app(document.querySelector('#container')); 7 | }); 8 | -------------------------------------------------------------------------------- /examples/autocomplete/js/helpers/throwor.js: -------------------------------------------------------------------------------- 1 | module.exports = function throwOr(fn){ 2 | return function(val){ 3 | if (val instanceof Error) { throw val; } 4 | return fn(val); 5 | } 6 | } 7 | 8 | 9 | -------------------------------------------------------------------------------- /examples/autocomplete/js/helpers/emptytonothing.js: -------------------------------------------------------------------------------- 1 | var Maybe = require('ramda-fantasy/src/Maybe'); 2 | 3 | module.exports = function emptyToNothing(val){ 4 | return val.length === 0 ? Maybe.Nothing() : Maybe(val) ; 5 | } 6 | -------------------------------------------------------------------------------- /examples/file-uploader/js/svg.js: -------------------------------------------------------------------------------- 1 | const h = require('snabbdom/h'); 2 | 3 | module.exports = function svg(...args){ 4 | const vnode = h(...args); 5 | vnode.data.ns = 'http://www.w3.org/2000/svg'; 6 | return vnode; 7 | } 8 | 9 | -------------------------------------------------------------------------------- /examples/hot-module-reloading/style.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: sans-serif; 3 | text-align: center; 4 | } 5 | 6 | .counter { 7 | margin: 1em; 8 | } 9 | 10 | button { 11 | font-size: 1.2em; 12 | padding: .1em .4em; 13 | } 14 | 15 | .nr { 16 | font-size: 1.2em; 17 | padding: .8em .6em .5em .6em; 18 | } 19 | -------------------------------------------------------------------------------- /examples/modal/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Dialog 6 | 7 | 8 | 9 |
10 | 11 | 12 | -------------------------------------------------------------------------------- /examples/counters/1/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Counter 1 6 | 7 | 8 | 9 |
10 | 11 | 12 | -------------------------------------------------------------------------------- /examples/counters/2/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Counter 2 6 | 7 | 8 | 9 |
10 | 11 | 12 | -------------------------------------------------------------------------------- /examples/counters/3/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Counter 3 6 | 7 | 8 | 9 |
10 | 11 | 12 | -------------------------------------------------------------------------------- /examples/counters/4/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Counter 4 6 | 7 | 8 | 9 |
10 | 11 | 12 | -------------------------------------------------------------------------------- /examples/counters-no-frp/1/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Counter 1 6 | 7 | 8 | 9 |
10 | 11 | 12 | -------------------------------------------------------------------------------- /examples/counters-no-frp/2/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Counter 2 6 | 7 | 8 | 9 |
10 | 11 | 12 | -------------------------------------------------------------------------------- /examples/counters-no-frp/3/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Counter 3 6 | 7 | 8 | 9 |
10 | 11 | 12 | -------------------------------------------------------------------------------- /examples/counters-no-frp/4/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Counter 4 6 | 7 | 8 | 9 |
10 | 11 | 12 | -------------------------------------------------------------------------------- /examples/hot-module-reloading/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Counter 1 6 | 7 | 8 | 9 |
10 | 11 | 12 | -------------------------------------------------------------------------------- /examples/zip-codes/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Zip Codes 6 | 7 | 9 | 10 | 11 |
12 | 13 | 14 | -------------------------------------------------------------------------------- /examples/who-to-follow/README.md: -------------------------------------------------------------------------------- 1 | # Who to follow 2 | 3 | Based on [the 4 | example](https://github.com/paldepind/flyd/tree/master/examples/who-to-follow) 5 | from flyd's repo, which is based on http://jsfiddle.net/staltz/8jFJH/48/. 6 | 7 | ## Building 8 | 9 | ```sh 10 | npm install 11 | npm run build 12 | ``` 13 | 14 | ## Automatic reload 15 | 16 | ```sh 17 | npm install -g watchify browser-sync 18 | npm run watch 19 | ``` 20 | -------------------------------------------------------------------------------- /examples/hot-module-reloading/webpack.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | entry: './main.js', 3 | output: { 4 | path: __dirname, 5 | filename: 'build.js', 6 | }, 7 | module: { 8 | loaders: [ 9 | { 10 | test: /\.js?$/, 11 | exclude: /(node_modules)/, 12 | loader: 'babel' 13 | }, { 14 | test: /\.css$/, 15 | loader: "style!css" 16 | }, 17 | ], 18 | }, 19 | } 20 | -------------------------------------------------------------------------------- /examples/autocomplete/testem.json: -------------------------------------------------------------------------------- 1 | { 2 | "launchers": { 3 | "Node": { 4 | "command": "node js/build/test-build.js", 5 | "protocol": "tap" 6 | }, 7 | "TAP": { 8 | "command": "node js/build/test-build.js" 9 | } 10 | }, 11 | "framework": "tap", 12 | "src_files": [ 13 | "js/*.js" 14 | ], 15 | "serve_files": [ 16 | "js/build/test-build.js" 17 | ], 18 | "before_tests": "make test-build", 19 | "launch_in_dev": ["Node","TAP","PhantomJS"] 20 | 21 | } 22 | -------------------------------------------------------------------------------- /examples/autocomplete/js/helpers/casekey.js: -------------------------------------------------------------------------------- 1 | var curry = require('ramda/src/curry'); 2 | var noop = function(){}; 3 | 4 | module.exports = curry( function caseKey(handlers,e) { 5 | var k = e.key || e.keyCode; 6 | var mapHandlers = handlers.reduce(function(o,handler){ 7 | for (var i=0;i 9 | // `close$` is currently unused, but it would be needed if the modal included 10 | // default ways to close itself. Like handling escape, a close button, etc. 11 | attachTo(document.body, h('div.modal', {}, [ 12 | h('div.modal-content', vnodeArray), 13 | h('div.modal-backdrop'), 14 | ]))) 15 | 16 | module.exports = {view} 17 | -------------------------------------------------------------------------------- /examples/nesting/js/modal.js: -------------------------------------------------------------------------------- 1 | /* jshint esnext: true */ 2 | const R = require('ramda'); 3 | const h = require('snabbdom/h'); 4 | const attachTo = require('snabbdom/helpers/attachto'); 5 | 6 | // View 7 | 8 | const view = R.curry((close$, vnodeArray) => 9 | // `close$` is currently unused, but it would be needed if the modal included 10 | // default ways to close itself. Like handling escape, a close button, etc. 11 | attachTo(document.body, h('div.modal', {}, [ 12 | h('div.modal-content', vnodeArray), 13 | h('div.modal-backdrop'), 14 | ]))) 15 | 16 | module.exports = {view} 17 | -------------------------------------------------------------------------------- /examples/zip-codes-future/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Zip Codes 6 | 7 | 18 | 19 | 20 |
21 | 22 | 23 | -------------------------------------------------------------------------------- /examples/zip-codes-future/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "zip-codes-future", 3 | "version": "0.0.1", 4 | "description": "Zip code checker using futures", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "author": "Simon Friis Vindum", 10 | "license": "MIT", 11 | "devDependencies": { 12 | "babel": "^5.8.21", 13 | "babelify": "^6.1.1" 14 | }, 15 | "dependencies": { 16 | "ramda": "^0.17.1", 17 | "ramda-fantasy": "^0.4.0", 18 | "snabbdom": "^0.2.4", 19 | "union-type": "^0.1.6" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /examples/who-to-follow/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "who-to-follow", 3 | "version": "1.0.0", 4 | "private": "true", 5 | "description": "", 6 | "main": "script.js", 7 | "scripts": { 8 | "watch": "watchify main.js -v -t babelify --outfile build.js & browser-sync start --server --files='index.html, build.js'", 9 | "build": "browserify main.js --outfile build.js" 10 | }, 11 | "author": "", 12 | "license": "MIT", 13 | "devDependencies": { 14 | "babelify": "^6.1.2", 15 | "flyd": "^0.1.13", 16 | "snabbdom": "^0.2.0", 17 | "union-type": "^0.1.6", 18 | "whatwg-fetch": "^0.9.0" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /examples/modal/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dialog-example", 3 | "version": "0.0.1", 4 | "description": "Dialog example", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "author": "Simon Friis Vindum", 10 | "license": "MIT", 11 | "devDependencies": { 12 | "babelify": "^6.1.1" 13 | }, 14 | "dependencies": { 15 | "flyd-forwardto": "^0.0.1", 16 | "ramda": "^0.14.0", 17 | "snabbdom": "^0.2.0", 18 | "flyd": "^0.1.4", 19 | "union-type": "^0.1.0" 20 | }, 21 | "jshintConfig": { 22 | "esnext": true, 23 | "asi": true 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /examples/nesting/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dialog-example", 3 | "version": "0.0.1", 4 | "description": "Dialog example", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "author": "Simon Friis Vindum", 10 | "license": "MIT", 11 | "devDependencies": { 12 | "babelify": "^6.1.1" 13 | }, 14 | "dependencies": { 15 | "flyd-forwardto": "^0.0.1", 16 | "ramda": "^0.14.0", 17 | "snabbdom": "^0.2.0", 18 | "flyd": "^0.1.4", 19 | "union-type": "^0.1.0" 20 | }, 21 | "jshintConfig": { 22 | "esnext": true, 23 | "asi": true 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /examples/todo/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "counter-example", 3 | "version": "0.0.1", 4 | "description": "Counters app with Flyd and Snabbdom", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "author": "Simon Friis Vindum", 10 | "license": "MIT", 11 | "devDependencies": { 12 | "babelify": "^6.1.1" 13 | }, 14 | "dependencies": { 15 | "flyd-forwardto": "^0.0.1", 16 | "ramda": "^0.14.0", 17 | "snabbdom": "^0.1.0", 18 | "flyd": "^0.1.4", 19 | "union-type": "^0.1.0" 20 | }, 21 | "jshintConfig": { 22 | "esnext": true, 23 | "asi": true 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | 5 | # Runtime data 6 | pids 7 | *.pid 8 | *.seed 9 | 10 | # Directory for instrumented libs generated by jscoverage/JSCover 11 | lib-cov 12 | 13 | # Coverage directory used by tools like istanbul 14 | coverage 15 | 16 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 17 | .grunt 18 | 19 | # node-waf configuration 20 | .lock-wscript 21 | 22 | # Compiled binary addons (http://nodejs.org/api/addons.html) 23 | build/Release 24 | 25 | # Dependency directory 26 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git 27 | node_modules 28 | jspm_packages 29 | 30 | *.swp 31 | -------------------------------------------------------------------------------- /examples/hot-module-reloading/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hot-reloading-example", 3 | "version": "0.0.1", 4 | "description": "Counters app with hot reloading of components", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "author": "Simon Friis Vindum", 10 | "license": "MIT", 11 | "devDependencies": { 12 | "babel-core": "^5.8.22", 13 | "babel-loader": "^5.3.2", 14 | "css-loader": "^0.16.0", 15 | "style-loader": "^0.12.3", 16 | "webpack": "^1.11.0" 17 | }, 18 | "dependencies": { 19 | "ramda": "^0.17.1", 20 | "snabbdom": "^0.2.6", 21 | "union-type": "^0.1.6" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /examples/zip-codes/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "zip-codes-example", 3 | "version": "0.0.1", 4 | "description": "Zip codes example (http requests)", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1", 8 | "build": "browserify js/main.js -t babelify --outfile js/build.js" 9 | }, 10 | "author": "Eric Gjertsen", 11 | "license": "MIT", 12 | "devDependencies": { 13 | "babelify": "^6.1.1" 14 | }, 15 | "dependencies": { 16 | "flyd-forwardto": "^0.0.1", 17 | "ramda": "^0.14.0", 18 | "snabbdom": "^0.2.0", 19 | "flyd": "^0.1.4", 20 | "union-type": "^0.1.0" 21 | }, 22 | "jshintConfig": { 23 | "esnext": true, 24 | "asi": true 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /examples/zip-codes-future/README.md: -------------------------------------------------------------------------------- 1 | # Zip Code 2 | 3 | This example implements a US zip code checker. It demonstrates how to do 4 | asynchronous side effects such as fetching JSON data in a controlled way 5 | that does not corrupt the purity of the application. 6 | 7 | It uses futures from [ramda-fantasy](https://github.com/ramda/ramda-fantasy) 8 | for the asynchron requests. 9 | 10 | # How to build it 11 | 12 | Install the dependencies. 13 | 14 | ```javascript 15 | npm install 16 | ``` 17 | 18 | Then build the code with 19 | 20 | ```javascript 21 | browserify main.js -t babelify --outfile build.js 22 | ``` 23 | 24 | With live reloading, 25 | 26 | ```javascript 27 | npm install -g watchify browser-sync 28 | watchify main.js -v -t babelify --outfile build.js \ 29 | & browser-sync start --server --files="index.html, build.js" 30 | ``` 31 | -------------------------------------------------------------------------------- /examples/todo/app-readme.md: -------------------------------------------------------------------------------- 1 | # Framework Name • [TodoMVC](http://todomvc.com) 2 | 3 | > Official description of the framework (from its website) 4 | 5 | 6 | ## Resources 7 | 8 | - [Website]() 9 | - [Documentation]() 10 | - [Used by]() 11 | - [Blog]() 12 | - [FAQ]() 13 | 14 | ### Articles 15 | 16 | - [Interesting article]() 17 | 18 | ### Support 19 | 20 | - [StackOverflow](http://stackoverflow.com/questions/tagged/__) 21 | - [Google Groups]() 22 | - [Twitter](http://twitter.com/__) 23 | - [Google+]() 24 | 25 | *Let us [know](https://github.com/tastejs/todomvc/issues) if you discover anything worth sharing.* 26 | 27 | 28 | ## Implementation 29 | 30 | How was the app created? Anything worth sharing about the process of creating the app? Any spec violations? 31 | 32 | 33 | ## Credit 34 | 35 | Created by [Your Name](http://your-website.com) 36 | -------------------------------------------------------------------------------- /examples/hot-module-reloading/counter.js: -------------------------------------------------------------------------------- 1 | /* jshint esnext: true */ 2 | const R = require('ramda'); 3 | const Type = require('union-type'); 4 | const h = require('snabbdom/h'); 5 | 6 | 7 | // Model 8 | 9 | const init = (n) => n; 10 | 11 | 12 | // Update 13 | 14 | const Action = Type({Increment: [], Decrement: []}); 15 | 16 | const update = Action.caseOn({ 17 | Increment: R.add(100), 18 | Decrement: R.add(-10), 19 | }) 20 | 21 | 22 | // View 23 | 24 | const view = R.curry((context, model) => 25 | h('div.counter', {style: {}}, [ 26 | h('button', {on: {click: [context.actions, Action.Decrement()]}}, '-'), ' ', 27 | h('span.nr', model), ' ', 28 | h('button', {on: {click: [context.actions, Action.Increment()]}}, '+'), ' ', 29 | h('button', {on: {click: [context.remove]}}, 'X'), 30 | ])); 31 | 32 | 33 | module.exports = {init, Action, update, view}; 34 | -------------------------------------------------------------------------------- /examples/nesting/js/switch.js: -------------------------------------------------------------------------------- 1 | /* jshint esnext: true */ 2 | const R = require('ramda'); 3 | const Type = require('union-type'); 4 | const h = require('snabbdom/h'); 5 | 6 | 7 | // Model 8 | const init = (n) => n 9 | 10 | // Update 11 | const Action = Type({Toggle: []}); 12 | 13 | const update = Action.caseOn({ 14 | Toggle: R.ifElse(R.eq(0), R.always(1), R.always(0)), 15 | }) 16 | 17 | // View 18 | const view = R.curry((actions$, model) => 19 | h('div.switch', {on: {click: [actions$, Action.Toggle()]}}, [ 20 | h('span', {class: {active: model === 0}}, 'On'), 21 | h('span', {class: {active: model === 1}}, 'Off') 22 | ])) 23 | 24 | const countStyle = {fontSize: '20px', 25 | fontFamily: 'monospace', 26 | width: '50px', 27 | textAlign: 'center'} 28 | 29 | module.exports = {init, Action, update, view} 30 | -------------------------------------------------------------------------------- /examples/autocomplete/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "devDependencies": { 3 | "testem": "^0.9.3" 4 | }, 5 | "jspm": { 6 | "directories": {}, 7 | "dependencies": { 8 | "flyd": "npm:flyd@^0.1.14", 9 | "flyd-aftersilence": "npm:flyd-aftersilence@^0.1.0", 10 | "flyd-forwardto": "npm:flyd-forwardto@^0.0.2", 11 | "ramda": "npm:ramda@^0.17.1", 12 | "ramda-fantasy": "npm:ramda-fantasy@^0.4.0", 13 | "snabbdom": "npm:snabbdom@^0.2.6", 14 | "source-map-support": "npm:source-map-support@^0.3.2", 15 | "union-type": "npm:union-type@^0.1.6" 16 | }, 17 | "devDependencies": { 18 | "babel": "npm:babel-core@^5.8.22", 19 | "babel-runtime": "npm:babel-runtime@^5.8.20", 20 | "core-js": "npm:core-js@^1.1.0", 21 | "tape": "npm:tape@^4.0.1" 22 | } 23 | }, 24 | "jshintConfig": { 25 | "esnext": true, 26 | "asi": true, 27 | "laxcomma": true 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /examples/file-uploader/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "file-uploader-example", 3 | "version": "0.0.1", 4 | "description": "File uploader example", 5 | "main": "index.js", 6 | "scripts": { 7 | "pretest": "browserify js/test.js --debug -t babelify --outfile js/test_build.js", 8 | "test": "node js/test_build.js | tap-spec", 9 | "build": "browserify js/main.js --debug -t babelify --outfile js/build.js" 10 | }, 11 | "author": "Eric Gjertsen", 12 | "license": "MIT", 13 | "devDependencies": { 14 | "babelify": "^6.1.1", 15 | "tape": "^4.2.0", 16 | "tap-spec": "^4.1.0", 17 | "multiparty": "^4.1.0" 18 | }, 19 | "dependencies": { 20 | "ramda": "^0.17.0", 21 | "ramda-fantasy": "^0.4.0", 22 | "snabbdom": "^0.2.0", 23 | "union-type": "^0.1.0" 24 | }, 25 | "jshintConfig": { 26 | "esnext": true, 27 | "undef": true, 28 | "unused": true, 29 | "asi": true 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /examples/counters/action.js: -------------------------------------------------------------------------------- 1 | var R = require('ramda'); 2 | 3 | function Action(group, name, nrOfValues) { 4 | var constructor = R.curryN(nrOfValues, function() { 5 | var args = Array.prototype.slice.call(arguments); 6 | args.name = name; 7 | args.of = group; 8 | //args.is = constructor; 9 | return args; 10 | }); 11 | return constructor; 12 | } 13 | 14 | var of = R.curry(function(group, act) { 15 | return group === act.of; 16 | }); 17 | 18 | var is = R.curry(function(constructor, act) { 19 | return constructor === act.of[act.name]; 20 | }); 21 | 22 | var caze = R.curry(function(action, cases) { 23 | return cases[action.name].call(undefined, action); 24 | }); 25 | 26 | function create(desc) { 27 | var obj = {}; 28 | for (var key in desc) { 29 | obj[key] = Action(obj, key, desc[key]); 30 | } 31 | return obj; 32 | } 33 | 34 | module.exports = {create: create, is: is, of: of, case: caze}; 35 | 36 | -------------------------------------------------------------------------------- /examples/file-uploader/js/main.js: -------------------------------------------------------------------------------- 1 | /* globals: document, window */ 2 | 3 | const map = require('ramda/src/map'); 4 | const patch = require('snabbdom').init([ 5 | require('snabbdom/modules/class'), 6 | require('snabbdom/modules/style'), 7 | require('snabbdom/modules/props'), 8 | require('snabbdom/modules/attributes'), 9 | require('snabbdom/modules/eventlisteners') 10 | ]); 11 | 12 | const app = require('./app'); 13 | 14 | let state = app.init(), asyncActions, vnode 15 | 16 | const render = () => { 17 | vnode = patch(vnode, app.view({action$: update}, state)); 18 | }; 19 | 20 | const update = (action) => { 21 | [state, asyncActions] = app.update(action, state); 22 | map((a) => a.fork((err) => {throw err}, update), asyncActions); 23 | console.log(state); 24 | render(); 25 | }; 26 | 27 | window.addEventListener('DOMContentLoaded', () => { 28 | vnode = document.getElementById('container'); 29 | render(); 30 | }); 31 | 32 | -------------------------------------------------------------------------------- /examples/counters-no-frp/3/counter.js: -------------------------------------------------------------------------------- 1 | /* jshint esnext: true */ 2 | const R = require('ramda'); 3 | const Type = require('union-type'); 4 | const h = require('snabbdom/h'); 5 | 6 | 7 | // Model 8 | const init = (n) => n; 9 | 10 | // Update 11 | const Action = Type({Increment: [], Decrement: []}); 12 | 13 | const update = Action.caseOn({ 14 | Increment: R.inc, 15 | Decrement: R.dec, 16 | }); 17 | 18 | // View 19 | const view = R.curry((actions, model) => 20 | h('div', {style: countStyle}, [ 21 | h('button', {on: {click: [actions, Action.Decrement()]}}, '–'), 22 | h('div', {style: countStyle}, model), 23 | h('button', {on: {click: [actions, Action.Increment()]}}, '+'), 24 | ])); 25 | 26 | const countStyle = {fontSize: '20px', 27 | fontFamily: 'monospace', 28 | width: '50px', 29 | textAlign: 'center'}; 30 | 31 | module.exports = {init, Action, update, view}; 32 | -------------------------------------------------------------------------------- /examples/counters/2/counter.js: -------------------------------------------------------------------------------- 1 | /* jshint esnext: true */ 2 | const R = require('ramda'); 3 | const Type = require('union-type'); 4 | const h = require('snabbdom/h'); 5 | 6 | 7 | // Model 8 | const init = (n) => n; 9 | 10 | // Update 11 | const Action = Type({Increment: [], Decrement: []}); 12 | 13 | const update = R.curry((model, action) => 14 | Action.case({ 15 | Increment: () => model + 1, 16 | Decrement: () => model - 1, 17 | }, action)); 18 | 19 | // View 20 | const view = R.curry((actions$, model) => 21 | h('div', {style: countStyle}, [ 22 | h('button', {on: {click: [actions$, Action.Decrement()]}}, '–'), 23 | h('div', {style: countStyle}, model), 24 | h('button', {on: {click: [actions$, Action.Increment()]}}, '+'), 25 | ])); 26 | 27 | const countStyle = {fontSize: '20px', 28 | fontFamily: 'monospace', 29 | width: '50px', 30 | textAlign: 'center'}; 31 | 32 | module.exports = {init, Action, update, view}; 33 | -------------------------------------------------------------------------------- /examples/counters/3/counter.js: -------------------------------------------------------------------------------- 1 | /* jshint esnext: true */ 2 | const R = require('ramda'); 3 | const Type = require('union-type'); 4 | const h = require('snabbdom/h'); 5 | 6 | 7 | // Model 8 | const init = (n) => n; 9 | 10 | // Update 11 | const Action = Type({Increment: [], Decrement: []}); 12 | 13 | const update = R.curry((model, action) => 14 | Action.case({ 15 | Increment: () => model + 1, 16 | Decrement: () => model - 1, 17 | }, action)); 18 | 19 | // View 20 | const view = R.curry((actions$, model) => 21 | h('div', {style: countStyle}, [ 22 | h('button', {on: {click: [actions$, Action.Decrement()]}}, '–'), 23 | h('div', {style: countStyle}, model), 24 | h('button', {on: {click: [actions$, Action.Increment()]}}, '+'), 25 | ])); 26 | 27 | const countStyle = {fontSize: '20px', 28 | fontFamily: 'monospace', 29 | width: '50px', 30 | textAlign: 'center'}; 31 | 32 | module.exports = {init, Action, update, view}; 33 | -------------------------------------------------------------------------------- /examples/zip-codes-future/main.js: -------------------------------------------------------------------------------- 1 | const R = require('ramda') 2 | const Type = require('union-type') 3 | const patch = require('snabbdom').init([ 4 | require('snabbdom/modules/class'), 5 | require('snabbdom/modules/props'), 6 | require('snabbdom/modules/eventlisteners'), 7 | require('snabbdom/modules/style'), 8 | ]); 9 | const h = require('snabbdom/h') 10 | const Future = require('ramda-fantasy/src/Future') 11 | 12 | 13 | let component = require('./app.js') 14 | let state = component.init(), asyncActions 15 | let vnode 16 | 17 | const render = () => vnode = patch(vnode, component.view(update, state)) 18 | 19 | const update = (action) => { 20 | [state, asyncActions] = component.update(action, state) 21 | R.map((a) => a.fork((err) => { throw err }, update), asyncActions) 22 | console.log(state) 23 | render() 24 | } 25 | 26 | // Begin rendering when the DOM is ready 27 | window.addEventListener('DOMContentLoaded', () => { 28 | vnode = document.getElementById('container') 29 | render() 30 | }) 31 | -------------------------------------------------------------------------------- /examples/counters-no-frp/2/counter.js: -------------------------------------------------------------------------------- 1 | /* jshint esnext: true */ 2 | const R = require('ramda'); 3 | const Type = require('union-type'); 4 | const h = require('snabbdom/h'); 5 | 6 | 7 | // Model 8 | const init = (n) => n; 9 | 10 | // Update 11 | const Action = Type({Increment: [], Decrement: []}); 12 | 13 | const update = R.curry((model, action) => 14 | Action.case({ 15 | Increment: () => model + 1, 16 | Decrement: () => model - 1, 17 | }, action)); 18 | 19 | 20 | // View 21 | const view = R.curry((actions$, model) => 22 | h('div', {style: countStyle}, [ 23 | h('button', {on: {click: [actions$, Action.Decrement()]}}, '–'), 24 | h('div', {style: countStyle}, model), 25 | h('button', {on: {click: [actions$, Action.Increment()]}}, '+'), 26 | ])); 27 | 28 | const countStyle = {fontSize: '20px', 29 | fontFamily: 'monospace', 30 | width: '50px', 31 | textAlign: 'center'}; 32 | 33 | module.exports = {init, Action, update, view}; 34 | -------------------------------------------------------------------------------- /examples/autocomplete/Makefile: -------------------------------------------------------------------------------- 1 | SOURCE = $(wildcard ./js/*.js) 2 | BUILD = js/main 3 | TEST_SOURCE = $(wildcard ./js/test*.js) 4 | TEST_BUILD = js/test 5 | LIB_SOURCE = $(wildcard ../../helpers/*.js) 6 | BUILD_DIR = ./js/build 7 | 8 | 9 | build: jshint $(BUILD_DIR)/build.js 10 | 11 | test-build: jshint $(BUILD_DIR)/test-build.js 12 | 13 | $(BUILD_DIR)/build.js: $(BUILD_DIR) $(SOURCE) $(LIB_SOURCE) 14 | jspm bundle $(BUILD) $@ 15 | 16 | $(BUILD_DIR)/test-build.js: $(BUILD_DIR) $(SOURCE) $(LIB_SOURCE) $(TEST_SOURCE) 17 | jspm bundle-sfx $(TEST_BUILD) $@ 18 | 19 | $(BUILD_DIR): 20 | @mkdir -p $@ 21 | 22 | jshint: 23 | @jshint $(SOURCE) || true 24 | 25 | clean: 26 | @rm -fr $(BUILD_DIR) 27 | 28 | clean-deps: 29 | @rm -fr node_modules jspm_packages 30 | 31 | init: 32 | @jspm init . 33 | 34 | init-deps: 35 | @jspm install npm:ramda npm:ramda-fantasy npm:union-type npm:snabbdom npm:flyd npm:flyd-forwardto npm:flyd-aftersilence 36 | 37 | .PHONY: init init-deps init-test-deps clean-deps clean jshint build test-build test-server 38 | -------------------------------------------------------------------------------- /examples/todo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Template • TodoMVC 7 | 8 | 9 | 10 | 11 | 12 | 13 |
14 |
15 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Simon Friis Vindum 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 | -------------------------------------------------------------------------------- /router.js: -------------------------------------------------------------------------------- 1 | var flyd = require('flyd'); 2 | var ryter = require('ryter'); 3 | var Type = require('union-type'); 4 | 5 | function any() { return true; } 6 | 7 | var Change = Type({Change: [any]}); 8 | 9 | function createFn(constr, str, fn) { 10 | return function() { 11 | str(constr(fn.apply(null, arguments))); 12 | }; 13 | } 14 | 15 | function createType(routes) { 16 | var i, arr, n, url, typeSpec = {}; 17 | for (url in routes) { 18 | n = url.split('*').length - 1; // Occurences of '*' 19 | arr = []; 20 | for (i = 0; i < n; ++i) arr[i] = any; 21 | arr.push(Object); 22 | typeSpec[routes[url]] = arr; 23 | } 24 | return Type(typeSpec); 25 | } 26 | 27 | function init(spec) { 28 | var url; 29 | var stream = spec.stream || flyd.stream(); 30 | var type = createType(spec.routes); 31 | for (url in spec.routes) { 32 | spec.routes[url] = createFn(spec.constr, stream, type[spec.routes[url]]); 33 | } 34 | var r = ryter.init(spec); 35 | r.stream = stream; 36 | r.Action = type; 37 | return r; 38 | } 39 | 40 | module.exports = { 41 | init: init, 42 | navigate: ryter.navigate, 43 | destroy: ryter.destroy, 44 | Change: Change, 45 | }; 46 | -------------------------------------------------------------------------------- /examples/autocomplete/js/main.js: -------------------------------------------------------------------------------- 1 | /* globals: window, document */ 2 | 3 | import flip from 'ramda/src/flip' 4 | 5 | import flyd from 'flyd' 6 | 7 | import snabbdom from 'snabbdom' 8 | import cl from 'snabbdom/modules/class' 9 | import pr from 'snabbdom/modules/props' 10 | import at from 'snabbdom/modules/attributes' 11 | import ev from 'snabbdom/modules/eventlisteners' 12 | import st from 'snabbdom/modules/style' 13 | const patch = snabbdom.init([cl,pr,at,ev,st]); 14 | 15 | import app from './app' 16 | import throwOr from './helpers/throwor' 17 | 18 | const update = (action, state) => { 19 | const [state1,tasks] = app.update(action,state); 20 | tasks.map((t) => t.fork( throwOr(action$), action$) ); 21 | return state1; 22 | } 23 | 24 | const action$ = flyd.stream(); 25 | const state$ = flyd.scan( flip(update), app.init(), action$); 26 | const vnode$ = flyd.map( app.view({action$}), state$); 27 | 28 | // enable this for debugging 29 | flyd.on( console.log.bind(console), state$ ); 30 | 31 | 32 | // Begin rendering when the DOM is ready 33 | window.addEventListener('DOMContentLoaded', () => { 34 | const el = document.getElementById('container'); 35 | flyd.scan(patch, el, vnode$); 36 | }) 37 | 38 | -------------------------------------------------------------------------------- /examples/todo/readme.md: -------------------------------------------------------------------------------- 1 | # TodoMVC App Template 2 | 3 | > Template used for creating [TodoMVC](http://todomvc.com) apps 4 | 5 | ![](https://github.com/tastejs/todomvc-app-css/raw/master/screenshot.png) 6 | 7 | 8 | ## Getting started 9 | 10 | - Read the [Application Specification](https://github.com/tastejs/todomvc/blob/master/app-spec.md) before touching the template. 11 | 12 | - Delete this file and rename `app-readme.md` to `readme.md` and fill it out. 13 | 14 | - Clone this repo and install the dependencies with [npm](https://npmjs.com) by running: `npm install`. 15 | 16 | 17 | ## License 18 | 19 | Creative Commons License
This work by TasteJS is licensed under a Creative Commons Attribution 4.0 International License. 20 | -------------------------------------------------------------------------------- /examples/hot-module-reloading/main.js: -------------------------------------------------------------------------------- 1 | const R = require('ramda') 2 | const Type = require('union-type') 3 | const patch = require('snabbdom').init([ 4 | require('snabbdom/modules/class'), 5 | require('snabbdom/modules/props'), 6 | require('snabbdom/modules/eventlisteners'), 7 | require('snabbdom/modules/style'), 8 | ]); 9 | const h = require('snabbdom/h') 10 | 11 | require('./style.css') 12 | 13 | 14 | let component = require('./counters.js') 15 | let state = component.init() 16 | let vnode 17 | 18 | const render = () => vnode = patch(vnode, component.view(update, state)) 19 | 20 | // If hot module replacement is enabled 21 | if (module.hot) { 22 | // We accept updates to the top component 23 | module.hot.accept('./counters.js', (comp) => { 24 | // Mutate the variable holding our component 25 | component = require('./counters.js') 26 | // Render view in the case that any view functions has changed 27 | render() 28 | }) 29 | } 30 | 31 | const update = (action) => { 32 | state = component.update(action, state) 33 | render() 34 | } 35 | 36 | // Begin rendering when the DOM is ready 37 | window.addEventListener('DOMContentLoaded', () => { 38 | vnode = document.getElementById('container') 39 | render() 40 | }) 41 | -------------------------------------------------------------------------------- /examples/counters-no-frp/README.md: -------------------------------------------------------------------------------- 1 | # Counters example without FRP 2 | 3 | This is a counters example implemented with Snabbdom, union-type and Ramda. It 4 | is similair to the [counters example](../counters) expect it doesn't use FRP to 5 | bootstrap the architecture. Instead it uses an 6 | [asynchronously recursive main function](1/main.js#L36-L42) as described in the 7 | article [React-less Virtual DOM with Snabbdom](https://medium.com/@yelouafi/react-less-virtual-dom-with-snabbdom-functions-everywhere-53b672cb2fe3). 8 | 9 | If you want to see the difference between the FRP dependent counters example 10 | and this one you can run: 11 | 12 | ``` 13 | git diff --no-index -- ../counters/2/main.js 2/main.js 14 | ``` 15 | 16 | The only changes are in the main file and replacing `forwardTo` with plain 17 | function composition. 18 | 19 | # How to build it 20 | 21 | Install the dependencies. 22 | 23 | ```javascript 24 | npm install 25 | ``` 26 | 27 | Then go to either of the subdirectories and build the code with 28 | 29 | ```javascript 30 | browserify main.js -t babelify --outfile build.js 31 | ``` 32 | 33 | With live reloading, 34 | 35 | ```javascript 36 | npm install -g watchify browser-sync 37 | watchify main.js -v -t babelify --outfile build.js \ 38 | & browser-sync start --server --files="index.html, build.js" 39 | ``` 40 | -------------------------------------------------------------------------------- /examples/counters-no-frp/4/counter.js: -------------------------------------------------------------------------------- 1 | /* jshint esnext: true */ 2 | const R = require('ramda'); 3 | const Type = require('union-type'); 4 | const h = require('snabbdom/h'); 5 | 6 | 7 | // Model 8 | const init = (n) => n; 9 | 10 | // Update 11 | const Action = Type({Increment: [], Decrement: []}); 12 | 13 | const update = Action.caseOn({ 14 | Increment: R.inc, 15 | Decrement: R.dec, 16 | }); 17 | 18 | // View 19 | const view = R.curry((actions, model) => 20 | h('div', {style: countStyle}, [ 21 | h('button', {on: {click: [actions, Action.Decrement()]}}, '–'), 22 | h('div', {style: countStyle}, model), 23 | h('button', {on: {click: [actions, Action.Increment()]}}, '+'), 24 | ])); 25 | 26 | const viewWithRemoveButton = R.curry((context, model) => 27 | h('div', {style: countStyle}, [ 28 | h('button', {on: {click: [context.actions, Action.Decrement()]}}, '–'), ' ', 29 | h('span', {style: countStyle}, model), ' ', 30 | h('button', {on: {click: [context.actions, Action.Increment()]}}, '+'), ' ', 31 | h('button', {on: {click: [context.remove]}}, 'X'), 32 | ])); 33 | 34 | const countStyle = {fontSize: '20px', 35 | fontFamily: 'monospace', 36 | width: '50px', 37 | textAlign: 'center'}; 38 | 39 | module.exports = {init, Action, update, view, viewWithRemoveButton}; 40 | -------------------------------------------------------------------------------- /examples/counters/4/counter.js: -------------------------------------------------------------------------------- 1 | /* jshint esnext: true */ 2 | const R = require('ramda'); 3 | const Type = require('union-type'); 4 | const h = require('snabbdom/h'); 5 | 6 | 7 | // Model 8 | const init = (n) => n; 9 | 10 | // Update 11 | const Action = Type({Increment: [], Decrement: []}); 12 | 13 | const update = Action.caseOn({ 14 | Increment: R.inc, 15 | Decrement: R.dec, 16 | }); 17 | 18 | // View 19 | const view = R.curry((actions$, model) => 20 | h('div', {style: countStyle}, [ 21 | h('button', {on: {click: [actions$, Action.Decrement()]}}, '–'), 22 | h('div', {style: countStyle}, model), 23 | h('button', {on: {click: [actions$, Action.Increment()]}}, '+'), 24 | ])); 25 | 26 | const viewWithRemoveButton = R.curry((context, model) => 27 | h('div', {style: countStyle}, [ 28 | h('button', {on: {click: [context.actions$, Action.Decrement()]}}, '–'), ' ', 29 | h('span', {style: countStyle}, model), ' ', 30 | h('button', {on: {click: [context.actions$, Action.Increment()]}}, '+'), ' ', 31 | h('button', {on: {click: [context.remove$]}}, 'X'), 32 | ])); 33 | 34 | const countStyle = {fontSize: '20px', 35 | fontFamily: 'monospace', 36 | width: '50px', 37 | textAlign: 'center'}; 38 | 39 | module.exports = {init, Action, update, view, viewWithRemoveButton}; 40 | -------------------------------------------------------------------------------- /examples/nesting/js/list.js: -------------------------------------------------------------------------------- 1 | /* jshint esnext: true */ 2 | const R = require('ramda') 3 | const Type = require('union-type') 4 | const h = require('snabbdom/h') 5 | const forwardTo = require('flyd-forwardto') 6 | 7 | const treis = require('treis') 8 | 9 | module.exports = function(Component) { 10 | 11 | // Model 12 | const init = () => ({ 13 | items: [Component.init(0), Component.init(1)], 14 | }) 15 | 16 | // Actions 17 | const Action = Type({ 18 | Add: [], 19 | Reverse: [], 20 | Modify: [Number, Component.Action], 21 | }) 22 | 23 | // Update 24 | const update = Action.caseOn({ // action -> model -> model 25 | Add: (model) => R.evolve({items: R.append(Component.init(0))}, model), 26 | Reverse: R.evolve({items: R.reverse}), 27 | Modify: (idx, counterAction, model) => R.evolve({ 28 | items: R.adjust(Component.update(counterAction), idx) 29 | }, model) 30 | }) 31 | 32 | // View 33 | const view = R.curry((action$, model) => 34 | h('div.list', [ 35 | h('button', {on: {click: [action$, Action.Reverse()]}}, 'Reverse'), 36 | h('button', {on: {click: [action$, Action.Add()]}}, 'Add'), 37 | h('ul', R.mapIndexed((item, idx) => 38 | h('li', [Component.view(forwardTo(action$, Action.Modify(idx)), item)]), model.items)) 39 | ])) 40 | 41 | return {init, Action, update, view} 42 | }; 43 | 44 | -------------------------------------------------------------------------------- /examples/counters-no-frp/1/main.js: -------------------------------------------------------------------------------- 1 | /* jshint esnext: true */ 2 | const R = require('ramda'); 3 | const Type = require('union-type'); 4 | const patch = require('snabbdom').init([ 5 | require('snabbdom/modules/class'), 6 | require('snabbdom/modules/props'), 7 | require('snabbdom/modules/eventlisteners'), 8 | require('snabbdom/modules/style'), 9 | ]); 10 | const h = require('snabbdom/h'); 11 | 12 | 13 | // Update 14 | const Action = Type({Increment: [], Decrement: []}); 15 | 16 | const update = (model, action) => Action.case({ 17 | Increment: () => model + 1, 18 | Decrement: () => model - 1, 19 | }, action); 20 | 21 | // View 22 | const view = R.curry((actions$, model) => 23 | h('div', {style: countStyle}, [ 24 | h('button', {on: {click: [actions$, Action.Decrement()]}}, '–'), 25 | h('div', {style: countStyle}, model), 26 | h('button', {on: {click: [actions$, Action.Increment()]}}, '+'), 27 | ])); 28 | 29 | const countStyle = { 30 | fontSize: '20px', 31 | fontFamily: 'monospace', 32 | width: '50px', 33 | textAlign: 'center', 34 | }; 35 | 36 | const main = (oldState, oldVnode, {view, update}) => { 37 | const newVnode = view((action) => { 38 | const newState = update(oldState, action); 39 | main(newState, newVnode, {view, update}); 40 | }, oldState); 41 | patch(oldVnode, newVnode); 42 | }; 43 | 44 | // Begin rendering when the DOM is ready 45 | window.addEventListener('DOMContentLoaded', () => { 46 | const container = document.getElementById('container'); 47 | main(0, container, {view, update}); 48 | }); 49 | -------------------------------------------------------------------------------- /examples/file-uploader/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | File Uploader 6 | 7 | 59 | 60 | 61 |

File Uploader example

62 |
63 | 64 | 65 | -------------------------------------------------------------------------------- /examples/nesting/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Dialog 6 | 7 | 60 | 61 | 62 |
63 | 64 | 65 | -------------------------------------------------------------------------------- /examples/hot-module-reloading/counters.js: -------------------------------------------------------------------------------- 1 | const R = require('ramda') 2 | const Type = require('union-type') 3 | const h = require('snabbdom/h') 4 | 5 | const counter = require('./counter.js') 6 | 7 | 8 | // Model 9 | 10 | const init = () => ({ 11 | counters: [], 12 | nextId: 0, 13 | }) 14 | 15 | // Update 16 | 17 | const Action = Type({ 18 | Insert: [], 19 | Remove: [Number], 20 | Modify: [Number, counter.Action], 21 | }) 22 | 23 | const update = Action.caseOn({ 24 | Insert: (model) => 25 | R.evolve({nextId: R.inc, counters: R.append([model.nextId, counter.init(4)])}, model), 26 | Remove: (id, model) => 27 | R.evolve({counters: R.reject((c) => c[0] === id)}, model), 28 | Modify: (id, counterAction, model) => 29 | R.evolve({counters: R.map((c) => { 30 | const [counterId, counterModel] = c 31 | return counterId === id ? [counterId, counter.update(counterAction, counterModel)] : c 32 | })}, model) 33 | }) 34 | 35 | // View 36 | 37 | const viewCounter = R.curry((actions, [id, model]) => 38 | counter.view({ 39 | actions: R.compose(actions, Action.Modify(id)), 40 | remove: R.compose(actions, R.always(Action.Remove(id))), 41 | }, model)) 42 | 43 | const addBtn = (actions) => 44 | h('button.add', { 45 | on: {click: [actions, Action.Insert()]} 46 | }, 'Add counter') 47 | 48 | const view = R.curry((actions, model) => { 49 | const counters = R.map(viewCounter(actions), model.counters) 50 | return h('div', R.prepend(addBtn(actions), counters)) 51 | }) 52 | 53 | 54 | module.exports = {init, Action, update, view} 55 | -------------------------------------------------------------------------------- /examples/nesting/readme.md: -------------------------------------------------------------------------------- 1 | # Nesting example 2 | 3 | [View example here](http://paldepind.github.io/functional-frontend-architecture/examples/nesting/) 4 | 5 | This example demonstrates two things: 6 | 7 | ## How to create 'module constructors' 8 | 9 | In this example `List` is not a module with the typical module exports (`init`, 10 | `update`, `view` and `Actions`) it is instead a function that creates an object 11 | with these properties. The function takes another module and returns a list that 12 | contains items of that module. 13 | 14 | `List` only makes an assumption on how its content should be initialized but is 15 | otherwise completly agnostic of what is contains. This is example shows how it 16 | completely unmodified can contain the `Counter` module from the counter example 17 | as well as a new `Switch` component. 18 | 19 | ## How deeper nesting looks using the architecture 20 | 21 | This application demonstates a three level nesting. The top level handles tabs 22 | of lists of counters/switches. Notice how the top level is completely unaware 23 | about whether or not the content of the tabs contains nesting as well. 24 | 25 | Each level is only concerned with any levels _directly_ beneath it and not at 26 | all concerned with levels above it. This ensures that additional nesting can be 27 | added to a module without affecting it's parent and that a module can be nested 28 | deeply inside other modules without it having any knowledge about it. 29 | 30 | The above should ensure that nesting to arbitrary levels is completely 31 | straightforward without the routing of actions getting out of control. 32 | 33 | -------------------------------------------------------------------------------- /examples/counters/1/main.js: -------------------------------------------------------------------------------- 1 | /* jshint esnext: true */ 2 | const R = require('ramda'); 3 | const Type = require('union-type'); 4 | const flyd = require('flyd'); 5 | const stream = flyd.stream; 6 | const patch = require('snabbdom').init([ 7 | require('snabbdom/modules/class'), 8 | require('snabbdom/modules/props'), 9 | require('snabbdom/modules/eventlisteners'), 10 | require('snabbdom/modules/style'), 11 | ]); 12 | const h = require('snabbdom/h'); 13 | 14 | 15 | // Update 16 | const Action = Type({Increment: [], Decrement: []}); 17 | 18 | const update = (model, action) => Action.case({ 19 | Increment: () => model + 1, 20 | Decrement: () => model - 1, 21 | }, action); 22 | 23 | // View 24 | const view = R.curry((actions$, model) => 25 | h('div', {style: countStyle}, [ 26 | h('button', {on: {click: [actions$, Action.Decrement()]}}, '–'), 27 | h('div', {style: countStyle}, model), 28 | h('button', {on: {click: [actions$, Action.Increment()]}}, '+'), 29 | ])); 30 | 31 | const countStyle = { 32 | fontSize: '20px', 33 | fontFamily: 'monospace', 34 | width: '50px', 35 | textAlign: 'center', 36 | }; 37 | 38 | // Streams 39 | const actions$ = stream(); // All modifications to the state originate here 40 | const model$ = flyd.scan(update, 0, actions$); // Contains the entire state of the application 41 | const vnode$ = flyd.map(view(actions$), model$); // Stream of virtual nodes to render 42 | 43 | // flyd.map(console.log.bind(console), model$); // Uncomment to log state on every update 44 | 45 | // Begin rendering when the DOM is ready 46 | window.addEventListener('DOMContentLoaded', () => { 47 | const container = document.getElementById('container'); 48 | flyd.scan(patch, container, vnode$); 49 | }); 50 | -------------------------------------------------------------------------------- /examples/who-to-follow/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Who to follow 6 | 7 | 8 | 56 | 57 | 58 |
59 |

60 | See the source code 61 |

62 | 63 | 64 | -------------------------------------------------------------------------------- /examples/hot-module-reloading/README.md: -------------------------------------------------------------------------------- 1 | # Counters example with hot component reloading 2 | 3 | This example implements a counters example with hot reloading of components 4 | using webpack. This means that you can modify components/modules and the 5 | changes will be loaded into your browser without a page refresh. 6 | 7 | This example implemented with Snabbdom, union-type and Ramda. It 8 | is similair to the [counters example](../counters-no-frp) expect the pure 9 | recursive asynchronous main function has been replaced with an imperative 10 | variant to facilitate the how swapping. 11 | 12 | The code responsible for the hot swapping is in [main.js](./main.js). The 13 | implementation is simple, even trivial since components in the architecture 14 | consists of pure function only and does not carry private state. 15 | 16 | # Demonstration 17 | 18 | In the recording I first create a few counters, then I increment them, after 19 | which I edit the increment and decrement amount, afterwards the counters 20 | immediately change with the new amount. After that I modify the color of the 21 | numbers and the initial count of counters. All changes are applied immediately 22 | without a browser reload. 23 | 24 | ![Screencast](./screencast.gif) 25 | 26 | # How run it 27 | 28 | Install the dependencies. 29 | 30 | ```javascript 31 | npm install 32 | ``` 33 | 34 | You must have the webpack dev server installed 35 | 36 | ```javascript 37 | npm install -g webpack-dev-server 38 | ``` 39 | 40 | Then start it 41 | 42 | ```js 43 | webpack-dev-server --hot --inline 44 | ``` 45 | 46 | Then open: [http://localhost:8080/](http://localhost:8080/) 47 | 48 | You can now make changes to [counters.js](./counters.js) and 49 | [counter.js](./counter.js). Any changes will be hot swapped into any browsers 50 | with the page open. 51 | -------------------------------------------------------------------------------- /examples/autocomplete/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Zip Codes: Autocomplete 6 | 7 | 8 | 9 | 12 | 62 | 63 | 64 |
65 | 66 | 67 | 68 | -------------------------------------------------------------------------------- /examples/counters-no-frp/2/main.js: -------------------------------------------------------------------------------- 1 | /* jshint esnext: true */ 2 | const R = require('ramda'); 3 | const Type = require('union-type'); 4 | const patch = require('snabbdom').init([ 5 | require('snabbdom/modules/class'), 6 | require('snabbdom/modules/props'), 7 | require('snabbdom/modules/eventlisteners'), 8 | require('snabbdom/modules/style'), 9 | ]); 10 | const h = require('snabbdom/h'); 11 | 12 | 13 | const counter = require('./counter.js'); 14 | 15 | 16 | // Model 17 | const init = (top, bottom) => ({ 18 | topCounter: counter.init(top), 19 | bottomCounter: counter.init(bottom) 20 | }); 21 | 22 | // Update 23 | const Action = Type({ 24 | Reset: [], 25 | Top: [counter.Action], 26 | Bottom: [counter.Action], 27 | }); 28 | 29 | const update = (model, action) => 30 | Action.case({ 31 | Reset: () => init(0, 0), 32 | Top: (act) => R.evolve({topCounter: counter.update(R.__, act)}, model), 33 | Bottom: (act) => R.evolve({bottomCounter: counter.update(R.__, act)}, model), 34 | }, action); 35 | 36 | // View 37 | const view = R.curry((actions$, model) => 38 | h('div', [ 39 | counter.view(R.compose(actions$, Action.Top), model.topCounter), 40 | counter.view(R.compose(actions$, Action.Bottom), model.bottomCounter), 41 | h('button', {on: {click: [actions$, Action.Reset()]}}, 'RESET'), 42 | ])); 43 | 44 | const main = (oldState, oldVnode, {view, update}) => { 45 | const newVnode = view((action) => { 46 | const newState = update(oldState, action); 47 | main(newState, newVnode, {view, update}); 48 | }, oldState); 49 | patch(oldVnode, newVnode); 50 | }; 51 | 52 | // Begin rendering when the DOM is ready 53 | window.addEventListener('DOMContentLoaded', () => { 54 | const container = document.getElementById('container'); 55 | main(init(0, 0), container, {view, update}); 56 | }); 57 | -------------------------------------------------------------------------------- /examples/counters/2/main.js: -------------------------------------------------------------------------------- 1 | /* jshint esnext: true */ 2 | const R = require('ramda'); 3 | const flyd = require('flyd'); 4 | const stream = flyd.stream; 5 | const forwardTo = require('flyd-forwardto'); 6 | const Type = require('union-type'); 7 | const patch = require('snabbdom').init([ 8 | require('snabbdom/modules/class'), 9 | require('snabbdom/modules/props'), 10 | require('snabbdom/modules/eventlisteners'), 11 | require('snabbdom/modules/style'), 12 | ]); 13 | const h = require('snabbdom/h'); 14 | 15 | 16 | const counter = require('./counter.js'); 17 | 18 | 19 | // Model 20 | const init = (top, bottom) => ({ 21 | topCounter: counter.init(top), 22 | bottomCounter: counter.init(bottom) 23 | }); 24 | 25 | // Update 26 | const Action = Type({ 27 | Reset: [], 28 | Top: [counter.Action], 29 | Bottom: [counter.Action], 30 | }); 31 | 32 | const update = (model, action) => 33 | Action.case({ 34 | Reset: () => init(0, 0), 35 | Top: (act) => R.evolve({topCounter: counter.update(R.__, act)}, model), 36 | Bottom: (act) => R.evolve({bottomCounter: counter.update(R.__, act)}, model), 37 | }, action); 38 | 39 | // View 40 | const view = R.curry((actions$, model) => 41 | h('div', [ 42 | counter.view(forwardTo(actions$, Action.Top), model.topCounter), 43 | counter.view(forwardTo(actions$, Action.Bottom), model.bottomCounter), 44 | h('button', {on: {click: [actions$, Action.Reset()]}}, 'RESET'), 45 | ])); 46 | 47 | // Streams 48 | const actions$ = flyd.stream(); 49 | const model$ = flyd.scan(update, init(0, 0), actions$); 50 | const vnode$ = flyd.map(view(actions$), model$); 51 | 52 | // flyd.map((model) => console.log(model), model$); // Uncomment to log state on every update 53 | 54 | // Begin rendering when the DOM is ready 55 | window.addEventListener('DOMContentLoaded', () => { 56 | const container = document.getElementById('container'); 57 | flyd.scan(patch, container, vnode$); 58 | }); 59 | -------------------------------------------------------------------------------- /examples/file-uploader/js/uploader.js: -------------------------------------------------------------------------------- 1 | /* globals XMLHttpRequest, FormData */ 2 | 3 | const compose = require('ramda/src/compose') 4 | , __ = require('ramda/src/__') 5 | , curry = require('ramda/src/curry') 6 | , always = require('ramda/src/always') 7 | ; 8 | const Type = require('union-type'); 9 | const Future = require('ramda-fantasy/src/Future'); 10 | 11 | const identity = (x) => x ; 12 | 13 | const Result = Type({ 14 | OK: [Object], 15 | NotFound: [Object], 16 | Error: [Object], 17 | Abort: [Object], 18 | Unknown: [Object], 19 | Progress: [Function, Object] 20 | }); 21 | 22 | 23 | const upload = curry( (headers, url, files) => { 24 | headers = headers || {}; 25 | 26 | return new Future( (rej,res) => { 27 | const xhr = new XMLHttpRequest(); 28 | const getxhr = always(xhr); 29 | const abort = xhr.abort.bind(xhr) 30 | xhr.addEventListener("load", compose(res, deriveResult, getxhr), false); 31 | xhr.addEventListener("abort", compose(res, Result.Abort, getxhr), false); 32 | xhr.addEventListener("error", compose(res, Result.Error, getxhr), false); 33 | 34 | xhr.upload.addEventListener("progress", 35 | compose(res, Result.Progress(abort)), false); 36 | 37 | xhr.open("post", url, true); 38 | for (k in headers){ 39 | xhr.setRequestHeader(k, headers[k]); 40 | } 41 | xhr.send(formdata(files)); 42 | }); 43 | }); 44 | 45 | module.exports = {upload, Result} 46 | 47 | 48 | function deriveResult(xhr){ 49 | return (xhr.status < 400 ? Result.OK : 50 | xhr.status >= 400 && xhr.status < 500 ? Result.NotFound : 51 | xhr.status >= 500 ? Result.Error : 52 | Result.Unknown 53 | )(xhr); 54 | } 55 | 56 | function formdata(files){ 57 | const data = new FormData(); 58 | for (let i=0; i ({ 20 | modalOpen: false, 21 | }); 22 | 23 | // Actions 24 | 25 | const Action = Type({ 26 | OpenModal: [], 27 | CloseModal: [], 28 | }); 29 | 30 | // Update 31 | 32 | // action -> model -> newModel 33 | const update = Action.caseOn({ 34 | OpenModal: R.assoc('modalOpen', true), 35 | CloseModal: R.assoc('modalOpen', false), 36 | }) 37 | 38 | // View 39 | 40 | const view = R.curry((action$, model) => { 41 | return h('div', [ 42 | 'Press the button below to open the modal', h('br'), 43 | h('button', {on: {click: [action$, Action.OpenModal()]}}, 'Open modal'), 44 | model.modalOpen ? Modal.view(forwardTo(action$, Action.CloseModal), [ 45 | 'This is inside the modal', h('br'), 46 | 'The modal is attached to the body', h('br'), 47 | h('button', {on: {click: [action$, Action.CloseModal()]}}, 'Close'), 48 | ]) 49 | : h('span') 50 | ]) 51 | }) 52 | 53 | // Streams 54 | const action$ = flyd.stream(); 55 | const model$ = flyd.scan(R.flip(update), init(), action$) 56 | const vnode$ = flyd.map(view(action$), model$) 57 | 58 | // flyd.map((model) => console.log(model), model$); // Uncomment to log state on every update 59 | 60 | window.addEventListener('DOMContentLoaded', function() { 61 | const container = document.querySelector('#container') 62 | flyd.scan(patch, container, vnode$) 63 | }) 64 | -------------------------------------------------------------------------------- /examples/file-uploader/js/app.js: -------------------------------------------------------------------------------- 1 | 2 | const Type = require('union-type'); 3 | const T = require('ramda/src/T') 4 | , assoc = require('ramda/src/assoc') 5 | , curry = require('ramda/src/curry') 6 | , compose = require('ramda/src/compose') 7 | , map = require('ramda/src/map') 8 | , invoker = require('ramda/src/invoker') 9 | ; 10 | const h = require('snabbdom/h'); 11 | 12 | const uploadList = require('./list'); 13 | const uploader = require('./uploader'); 14 | 15 | 16 | // app constants 17 | 18 | const UPLOAD_URL = '/upload' 19 | const UPLOAD_HEADERS = {} 20 | 21 | 22 | // action 23 | 24 | const listUpdate = (listAction,model) => { 25 | const [state, tasks] = uploadList.update(listAction, model.uploads); 26 | return [ assoc('uploads', state, model), 27 | tasks.map( map(Action.Route) ) 28 | ]; 29 | } 30 | 31 | const Action = Type({ 32 | Create: [T, T], 33 | Route: [uploadList.Action] 34 | }); 35 | 36 | const update = Action.caseOn({ 37 | Create: (up,files,model) => ( 38 | listUpdate( uploadList.Action.Create(up,files), model ) 39 | ), 40 | 41 | Route: listUpdate 42 | }); 43 | 44 | 45 | // model 46 | 47 | const init = () => { return { uploads: uploadList.init() }; } 48 | 49 | // view 50 | 51 | const view = curry( ({action$}, model) => { 52 | 53 | const up = uploader.upload(UPLOAD_HEADERS, UPLOAD_URL); 54 | 55 | return ( 56 | h('div.uploading', {}, [ 57 | form(action$, up), 58 | uploadList.view(model.uploads) 59 | ]) 60 | ); 61 | }); 62 | 63 | const form = (action$, up) => ( 64 | h('form', {on: {submit: preventDefault} }, [ 65 | h('input', 66 | { props: {type: 'file', multiple: true}, 67 | on: { 68 | change: compose(action$, Action.Create(up), getTarget('files')) 69 | } 70 | } 71 | ) 72 | ] 73 | ) 74 | ); 75 | 76 | 77 | const getTarget = curry( (key,e) => e.target[key] ); 78 | const preventDefault = invoker(0, 'preventDefault'); 79 | 80 | 81 | module.exports = { init, update, Action, view } 82 | 83 | -------------------------------------------------------------------------------- /examples/file-uploader/js/list.js: -------------------------------------------------------------------------------- 1 | const Type = require('union-type'); 2 | const T = require('ramda/src/T') 3 | , adjust = require('ramda/src/adjust') 4 | , append = require('ramda/src/append') 5 | , curry = require('ramda/src/curry') 6 | ; 7 | const h = require('snabbdom/h'); 8 | 9 | const upload = require('./upload'); 10 | const uploader = require('./uploader'); 11 | 12 | const noFx = (s) => [s, []]; 13 | 14 | // note: prefer to check if iterable, 15 | // but FileList.prototype doesn't seem to have Symbol.iterator cross-browser? 16 | const isFileList = (x) => x.length !== undefined 17 | 18 | // action 19 | 20 | const Action = Type({ 21 | Create: [Function, isFileList], 22 | Result: [Number, uploader.Result] 23 | }); 24 | 25 | const update = Action.caseOn({ 26 | 27 | Create: (up,files,model) => { 28 | const idx = nextIndex(model); 29 | const task = up(files); 30 | const taskAction = Action.Result(idx); 31 | const newState = append( upload.init(files), model); 32 | return [newState, [task.map(taskAction)]]; 33 | }, 34 | 35 | Result: (i,result,model) => { 36 | const finish = (type) => () => { 37 | return adjust(upload.update(upload.Action[type]()), i, model); 38 | }; 39 | return noFx( 40 | uploader.Result.case({ 41 | OK: finish('Uploaded'), 42 | NotFound: finish('Error'), 43 | Error: finish('Error'), 44 | Abort: finish('Abort'), 45 | Progress: (abort,p) => { 46 | return adjust(upload.update(upload.Action.Progress(abort,p)), i, model); 47 | } 48 | }, result) 49 | ); 50 | } 51 | 52 | }); 53 | 54 | 55 | // model 56 | 57 | const init = () => [] 58 | const nextIndex = (model) => model.length; 59 | 60 | // view 61 | 62 | const view = (model) => h('ul', {style: style.ul}, model.map( listItemView )) 63 | 64 | const listItemView = (item, i) => ( 65 | h('li', {style: style.li}, [ 66 | upload.view( 67 | { progress: { height: 20, width: 200 } }, 68 | item 69 | ) 70 | ]) 71 | ) 72 | 73 | 74 | const style = { 75 | ul: {'list-style': 'none'}, 76 | li: { } 77 | } 78 | 79 | 80 | module.exports = { init, update, Action, view } 81 | 82 | -------------------------------------------------------------------------------- /examples/todo/js/task.js: -------------------------------------------------------------------------------- 1 | /* jshint esnext: true */ 2 | const R = require('ramda'); 3 | const Type = require('union-type'); 4 | const h = require('snabbdom/h'); 5 | 6 | const targetValue = require('../../../helpers/targetvalue'); 7 | const ifEnter = require('../../../helpers/ifenter'); 8 | 9 | // Model 10 | 11 | const init = (id, title) => ({ 12 | title, 13 | done: false, 14 | editing: false, 15 | id: id, 16 | }) 17 | 18 | // Actions 19 | 20 | const Action = Type({ 21 | ToggleDone: [], 22 | SetDone: [], 23 | UnsetDone: [], 24 | ToggleEditing: [], 25 | Remove: [], 26 | ChangeTitle: [String], 27 | }) 28 | 29 | // Update 30 | 31 | const update = Action.caseOn({ // action -> model -> model 32 | ToggleDone: R.evolve({done: R.not}), 33 | SetDone: R.evolve({done: R.T}), 34 | UnsetDone: R.evolve({done: R.F}), 35 | ToggleEditing: R.evolve({editing: R.not}), 36 | ChangeTitle: R.assoc('title'), 37 | }) 38 | 39 | // View 40 | 41 | function focus(oldVnode, vnode) { 42 | if (oldVnode.data.class.editing === false && 43 | vnode.data.class.editing === true) { 44 | vnode.elm.querySelector('input.edit').focus(); 45 | } 46 | } 47 | 48 | const blurTarget = R.curry((_, ev) => { 49 | ev.target.blur() 50 | }) 51 | 52 | const view = R.curry((context, model) => 53 | h('li', { 54 | class: {completed: model.done && !model.editing, 55 | editing: model.editing}, 56 | hook: {update: focus}, 57 | key: model.id, 58 | }, [ 59 | h('div.view', [ 60 | h('input.toggle', { 61 | props: {checked: model.done, type: 'checkbox'}, 62 | on: {click: [context.action$, Action.ToggleDone()]}, 63 | }), 64 | h('label', { 65 | on: {dblclick: [context.action$, Action.ToggleEditing()]} 66 | }, model.title), 67 | h('button.destroy', {on: {click: [context.remove$, undefined]}}), 68 | ]), 69 | h('input.edit', { 70 | props: {value: model.title}, 71 | on: { 72 | blur: [context.action$, Action.ToggleEditing()], 73 | keydown: ifEnter(blurTarget, undefined), 74 | input: R.compose(context.action$, Action.ChangeTitle, targetValue), 75 | }, 76 | }), 77 | ])) 78 | 79 | module.exports = {init, Action, update, view} 80 | -------------------------------------------------------------------------------- /examples/counters-no-frp/3/main.js: -------------------------------------------------------------------------------- 1 | /* jshint esnext: true */ 2 | const R = require('ramda'); 3 | const Type = require('union-type'); 4 | const patch = require('snabbdom').init([ 5 | require('snabbdom/modules/class'), 6 | require('snabbdom/modules/props'), 7 | require('snabbdom/modules/eventlisteners'), 8 | require('snabbdom/modules/style'), 9 | ]); 10 | const h = require('snabbdom/h'); 11 | 12 | const counter = require('./counter.js'); 13 | 14 | 15 | // Model 16 | const init = () => ({ 17 | counters: [], 18 | nextId: 0, 19 | }); 20 | 21 | // Update 22 | const Action = Type({ 23 | Insert: [], 24 | Remove: [], 25 | Modify: [Number, counter.Action], 26 | }); 27 | 28 | // View 29 | 30 | const update = (action, model) => 31 | Action.case({ 32 | Insert: () => R.evolve({nextId: R.add(1), 33 | counters: R.append([model.nextId, counter.init(0)])}, model), 34 | Remove: () => R.evolve({counters: R.tail}, model), 35 | Modify: (id, counterAction) => 36 | R.evolve({counters: R.map((c) => { 37 | const [counterId, counterModel] = c; 38 | return counterId === id ? [counterId, counter.update(counterAction, counterModel)] : c; 39 | })}, model) 40 | }, action); 41 | 42 | // View 43 | 44 | const viewCounter = R.curry((actions, c) => { 45 | const [id, model] = c; 46 | return counter.view(R.compose(actions, Action.Modify(id)), model); 47 | }); 48 | 49 | const view = R.curry(function (actions, model) { 50 | const counters = R.map(viewCounter(actions), model.counters); 51 | return h('div', 52 | R.concat([h('button.rm', {on: {click: [actions, Action.Remove()]}}, 'Remove'), 53 | h('button.add', {on: {click: [actions, Action.Insert()]}}, 'Add')], counters) 54 | ); 55 | }); 56 | 57 | const main = (oldState, oldVnode, view, update) => { 58 | const newVnode = view((action) => { 59 | const newState = update(action, oldState); 60 | main(newState, newVnode, view, update); 61 | }, oldState); 62 | patch(oldVnode, newVnode); 63 | }; 64 | 65 | // Begin rendering when the DOM is ready 66 | window.addEventListener('DOMContentLoaded', () => { 67 | const container = document.getElementById('container'); 68 | main(init(0, 0), container, view, update); 69 | }); 70 | -------------------------------------------------------------------------------- /examples/counters/3/main.js: -------------------------------------------------------------------------------- 1 | /* jshint esnext: true */ 2 | const R = require('ramda'); 3 | const flyd = require('flyd'); 4 | const stream = flyd.stream; 5 | const forwardTo = require('flyd-forwardto'); 6 | const Type = require('union-type'); 7 | const patch = require('snabbdom').init([ 8 | require('snabbdom/modules/class'), 9 | require('snabbdom/modules/props'), 10 | require('snabbdom/modules/eventlisteners'), 11 | require('snabbdom/modules/style'), 12 | ]); 13 | const h = require('snabbdom/h'); 14 | 15 | const counter = require('./counter.js'); 16 | 17 | 18 | // Model 19 | const init = () => ({ 20 | counters: [], 21 | nextId: 0, 22 | }); 23 | 24 | // Update 25 | const Action = Type({ 26 | Insert: [], 27 | Remove: [], 28 | Modify: [Number, counter.Action], 29 | }); 30 | 31 | // View 32 | 33 | const update = (model, action) => 34 | Action.case({ 35 | Insert: () => R.evolve({nextId: R.add(1), 36 | counters: R.append([model.nextId, counter.init(0)])}, model), 37 | Remove: () => R.evolve({counters: R.tail}, model), 38 | Modify: (id, counterAction) => 39 | R.evolve({counters: R.map((c) => { 40 | const [counterId, counterModel] = c; 41 | return counterId === id ? [counterId, counter.update(counterModel, counterAction)] : c; 42 | })}, model) 43 | }, action); 44 | 45 | // View 46 | 47 | const viewCounter = R.curry((actions$, c) => { 48 | const [id, model] = c; 49 | return counter.view(forwardTo(actions$, Action.Modify(id)), model); 50 | }); 51 | 52 | const view = R.curry(function (actions$, model) { 53 | const counters = R.map(viewCounter(actions$), model.counters); 54 | return h('div', 55 | R.concat([h('button.rm', {on: {click: [actions$, Action.Remove()]}}, 'Remove'), 56 | h('button.add', {on: {click: [actions$, Action.Insert()]}}, 'Add')], counters) 57 | ); 58 | }); 59 | 60 | // Streams 61 | const actions$ = flyd.stream(); 62 | const model$ = flyd.scan(update, init(), actions$); 63 | const vnode$ = flyd.map(view(actions$), model$); 64 | 65 | // flyd.map((model) => console.log(model), model$); // Uncomment to log state on every update 66 | 67 | window.addEventListener('DOMContentLoaded', function() { 68 | const container = document.getElementById('container'); 69 | flyd.scan(patch, container, vnode$); 70 | }); 71 | -------------------------------------------------------------------------------- /examples/counters-no-frp/4/main.js: -------------------------------------------------------------------------------- 1 | /* jshint esnext: true */ 2 | const R = require('ramda'); 3 | const Type = require('union-type'); 4 | const patch = require('snabbdom').init([ 5 | require('snabbdom/modules/class'), 6 | require('snabbdom/modules/props'), 7 | require('snabbdom/modules/eventlisteners'), 8 | require('snabbdom/modules/style'), 9 | ]); 10 | const h = require('snabbdom/h'); 11 | 12 | const counter = require('./counter.js'); 13 | 14 | 15 | // Model 16 | 17 | const init = (top, bottom) => ({ 18 | counters: [], 19 | nextId: 0, 20 | }); 21 | 22 | // Update 23 | 24 | const Action = Type({ 25 | Insert: [], 26 | Remove: [Number], 27 | Modify: [Number, counter.Action], 28 | }); 29 | 30 | const update = Action.caseOn({ 31 | Insert: (model) => 32 | R.evolve({nextId: R.inc, counters: R.append([model.nextId, counter.init(0)])}, model), 33 | Remove: (id, model) => R.evolve({counters: R.reject((c) => c[0] === id)}, model), 34 | Modify: (id, counterAction, model) => 35 | R.evolve({counters: R.map((c) => { 36 | const [counterId, counterModel] = c; 37 | return counterId === id ? [counterId, counter.update(counterAction, counterModel)] : c; 38 | })}, model) 39 | }); 40 | 41 | // View 42 | 43 | const viewCounter = R.curry((actions, c) => { 44 | const [id, model] = c; 45 | console.log('viewCounter', id, model); 46 | return counter.viewWithRemoveButton({ 47 | actions: R.compose(actions, Action.Modify(id)), 48 | remove: R.compose(actions, R.always(Action.Remove(id))), 49 | }, model); 50 | }); 51 | 52 | const view = R.curry((actions$, model) => { 53 | const counters = R.map(viewCounter(actions$), model.counters); 54 | return h('div', 55 | R.prepend(h('button.add', {on: {click: [actions$, Action.Insert()]}}, 'Add'), 56 | counters) 57 | ); 58 | }); 59 | 60 | const main = (oldState, oldVnode, view, update) => { 61 | const newVnode = view((action) => { 62 | const newState = update(action, oldState); 63 | main(newState, newVnode, view, update); 64 | }, oldState); 65 | patch(oldVnode, newVnode); 66 | }; 67 | 68 | // Begin rendering when the DOM is ready 69 | window.addEventListener('DOMContentLoaded', () => { 70 | const container = document.getElementById('container'); 71 | main(init(0, 0), container, view, update); 72 | }); 73 | -------------------------------------------------------------------------------- /examples/counters/4/main.js: -------------------------------------------------------------------------------- 1 | /* jshint esnext: true */ 2 | const R = require('ramda'); 3 | const flyd = require('flyd'); 4 | const stream = flyd.stream; 5 | const forwardTo = require('flyd-forwardto'); 6 | const Type = require('union-type'); 7 | const patch = require('snabbdom').init([ 8 | require('snabbdom/modules/class'), 9 | require('snabbdom/modules/props'), 10 | require('snabbdom/modules/eventlisteners'), 11 | require('snabbdom/modules/style'), 12 | ]); 13 | const h = require('snabbdom/h'); 14 | 15 | const counter = require('./counter.js'); 16 | 17 | 18 | // Model 19 | 20 | const init = (top, bottom) => ({ 21 | counters: [], 22 | nextId: 0, 23 | }); 24 | 25 | // Update 26 | 27 | const Action = Type({ 28 | Insert: [], 29 | Remove: [Number], 30 | Modify: [Number, counter.Action], 31 | }); 32 | 33 | const update = Action.caseOn({ 34 | Insert: (model) => 35 | R.evolve({nextId: R.inc, counters: R.append([model.nextId, counter.init(0)])}, model), 36 | Remove: (id, model) => R.evolve({counters: R.reject((c) => c[0] === id)}, model), 37 | Modify: (id, counterAction, model) => 38 | R.evolve({counters: R.map((c) => { 39 | const [counterId, counterModel] = c; 40 | return counterId === id ? [counterId, counter.update(counterAction, counterModel)] : c; 41 | })}, model) 42 | }); 43 | 44 | // View 45 | 46 | const viewCounter = R.curry((actions$, c) => { 47 | const [id, model] = c; 48 | console.log('viewCounter', id, model); 49 | return counter.viewWithRemoveButton({ 50 | actions$: forwardTo(actions$, Action.Modify(id)), 51 | remove$: forwardTo(actions$, R.always(Action.Remove(id))), 52 | }, model); 53 | }); 54 | 55 | const view = R.curry((actions$, model) => { 56 | const counters = R.map(viewCounter(actions$), model.counters); 57 | return h('div', 58 | R.prepend(h('button.add', {on: {click: [actions$, Action.Insert()]}}, 'Add'), 59 | counters) 60 | ); 61 | }); 62 | 63 | // Streams 64 | 65 | const actions$ = flyd.stream(); 66 | const model$ = flyd.scan(R.flip(update), init(0, 0), actions$); 67 | const vnode$ = flyd.map(view(actions$), model$); 68 | 69 | // flyd.map((model) => console.log(model), model$); // Uncomment to log state on every update 70 | 71 | window.addEventListener('DOMContentLoaded', function() { 72 | const container = document.getElementById('container'); 73 | flyd.scan(patch, container, vnode$); 74 | }); 75 | -------------------------------------------------------------------------------- /examples/nesting/js/app.js: -------------------------------------------------------------------------------- 1 | /* jshint esnext: true */ 2 | const R = require('ramda'); 3 | const flyd = require('flyd'); 4 | const stream = flyd.stream; 5 | const forwardTo = require('flyd-forwardto'); 6 | const Type = require('union-type'); 7 | const patch = require('snabbdom/snabbdom.js').init([ 8 | require('snabbdom/modules/class'), 9 | require('snabbdom/modules/style'), 10 | require('snabbdom/modules/props'), 11 | require('snabbdom/modules/eventlisteners'), 12 | ]); 13 | const h = require('snabbdom/h') 14 | 15 | const Modal = require('./modal') 16 | const List = require('./list') 17 | const Counter = require('../../counters/4/counter') 18 | const Switch = require('./switch') 19 | const CounterList = List(Counter); 20 | const SwitchList = List(Switch); 21 | 22 | const Tabs = [CounterList, SwitchList, CounterList]; 23 | 24 | // Model 25 | 26 | const init = () => ({ 27 | tabs: [CounterList.init(), CounterList.init(), CounterList.init()], 28 | activeTab: 0, 29 | }); 30 | 31 | // Actions 32 | 33 | const Action = Type({ 34 | TabAction: [Number, R.T], 35 | ChangeTab: [Number], 36 | }); 37 | 38 | // Update 39 | 40 | // action -> model -> newModel 41 | const update = Action.caseOn({ 42 | TabAction: (idx, tabAction, model) => R.evolve({ 43 | tabs: R.adjust(Tabs[idx].update(tabAction), idx) 44 | }, model), 45 | ChangeTab: R.assoc('activeTab'), 46 | }) 47 | 48 | // View 49 | 50 | const view = R.curry((action$, model) => { 51 | const idx = model.activeTab, 52 | tab = model.tabs[model.activeTab] 53 | return h('div', [ 54 | h('h1', 'Counters'), 55 | h('ul.tabs', [ 56 | h('li', {on: {click: [action$, Action.ChangeTab(0)]}, 57 | class: {active: idx === 0}}, 'Counters'), 58 | h('li', {on: {click: [action$, Action.ChangeTab(1)]}, 59 | class: {active: idx === 1}}, 'Switches'), 60 | h('li', {on: {click: [action$, Action.ChangeTab(2)]}, 61 | class: {active: idx === 2}}, 'Counters'), 62 | ]), 63 | Tabs[idx].view(forwardTo(action$, Action.TabAction(idx)), tab), 64 | ]) 65 | }) 66 | 67 | // Streams 68 | const action$ = flyd.stream(); 69 | const model$ = flyd.scan(R.flip(update), init(), action$) 70 | const vnode$ = flyd.map(view(action$), model$) 71 | 72 | flyd.map((model) => console.log(model), model$); // Uncomment to log state on every update 73 | 74 | window.addEventListener('DOMContentLoaded', function() { 75 | const container = document.querySelector('#container') 76 | flyd.scan(patch, container, vnode$) 77 | }) 78 | -------------------------------------------------------------------------------- /examples/who-to-follow/main.js: -------------------------------------------------------------------------------- 1 | require('whatwg-fetch'); 2 | const {always, flip, curry, map, take, addIndex, evolve, update: updateIndex, tap, pipeP} = require('ramda'); 3 | const flyd = require('flyd'); 4 | const Type = require('union-type') 5 | const h = require('snabbdom/h'); 6 | const patch = require('snabbdom').init([ 7 | require('snabbdom/modules/class'), 8 | require('snabbdom/modules/props'), 9 | require('snabbdom/modules/eventlisteners'), 10 | ]); 11 | 12 | const actions$ = flyd.stream(); 13 | const Action = Type({ 14 | Remove: [Number], 15 | Loaded: [Array], 16 | Refresh: [] 17 | }); 18 | 19 | const fetchUsers = () => { 20 | const randomOffset = Math.floor(Math.random() * 500); 21 | return fetch('https://api.github.com/users?since=' + randomOffset, { 22 | // Set a personal token after getting rate-limited 23 | // headers: { 'Authorization': 'token YOUR_TOKEN' } 24 | }) 25 | .then((res) => res.json()) 26 | }; 27 | 28 | const refreshUsers = pipeP(fetchUsers, Action.Loaded, actions$); 29 | const mapIndexed = addIndex(map); 30 | const sample = (xs) => xs[Math.floor(Math.random() * xs.length)]; 31 | 32 | const update = Action.caseOn({ 33 | Loaded: (loaded, model) => ({loaded, suggested: take(3, loaded)}), 34 | Remove: (idx, model) => evolve({suggested: updateIndex(idx, sample(model.loaded))}, model), 35 | Refresh: tap(refreshUsers) 36 | }); 37 | 38 | const init = always({ 39 | suggested: [], 40 | loaded: [] 41 | }); 42 | 43 | const viewUser = curry((actions$, user, idx) => { 44 | return h('li', {}, [ 45 | h('img', {props: {src: user.avatar_url}}), 46 | h('a.username', {props: {href: user.html_url}}, user.login), 47 | h('a.close', {props: {href: '#'}, on: {click: [actions$, Action.Remove(idx)]}}, 'x') 48 | ]); 49 | }) 50 | 51 | const view = curry((actions$, model) => 52 | h('div', {}, [ 53 | h('div.header', {}, [ 54 | h('h2', {}, 'Who to follow'), 55 | h('a#refresh', { 56 | props: {href: '#'}, 57 | on: {click: [actions$, Action.Refresh()]} 58 | }, 'Refresh') 59 | ]), 60 | h('ul.suggestions', {}, mapIndexed(viewUser(actions$), model.suggested)) 61 | ])); 62 | 63 | const model$ = flyd.scan(flip(update), init(), actions$); 64 | const vnode$ = flyd.map(view(actions$), model$); 65 | 66 | // actions$.map((it) => console.log('action', it)) // Uncomment to log every action 67 | // model$.map((it) => console.log('model', it)); // Uncomment to log state on every update 68 | 69 | window.addEventListener('DOMContentLoaded', function() { 70 | const container = document.getElementById('container'); 71 | flyd.scan(patch, container, vnode$); 72 | }); 73 | 74 | refreshUsers(); 75 | -------------------------------------------------------------------------------- /examples/zip-codes-future/app.js: -------------------------------------------------------------------------------- 1 | const R = require('ramda') 2 | const Type = require('union-type') 3 | const h = require('snabbdom/h') 4 | const Future = require('ramda-fantasy/src/Future') 5 | const treis = require('treis') 6 | 7 | 8 | // Utils 9 | 10 | const c = R.compose // tiny alias to make the code leaner and more readable 11 | const promToFut = (prom) => Future((rej, res) => prom.then(res, rej)) 12 | const getJSON = R.invoker(0, 'json') 13 | const targetValue = R.path(['target', 'value']) 14 | const getUrl = (url) => promToFut(fetch(new Request(url, {method: 'GET'}))) 15 | const respIsOk = (r) => r.ok === true 16 | 17 | // Model 18 | 19 | const USState = Type({ 20 | Loading: [], 21 | Names: [Array], 22 | NotFound: [], 23 | Invalid: [], 24 | }) 25 | 26 | const init = () => ({ 27 | zipCode: '', 28 | state: USState.Invalid(), 29 | }) 30 | 31 | // Update 32 | 33 | const Action = Type({ 34 | ChangeZipCode: [String], 35 | ChangeState: [USState], 36 | }) 37 | 38 | const fetchZip = (zipCode) => getUrl(`http://api.zippopotam.us/us/${zipCode}`) 39 | 40 | const formatPlace = c(R.join(', '), R.props(['place name', 'state'])) 41 | const places = c(R.map(formatPlace), R.prop('places')) 42 | const isZip = c(R.not, R.isEmpty, R.match(/^\d{5}$/)) 43 | const createChangeStateAction = c(Action.ChangeState, USState.Names, places) 44 | 45 | const updateStateFromResp = c(R.map(createChangeStateAction), promToFut, getJSON) 46 | const updateStateToNotFound = c(Future.of, Action.ChangeState, USState.NotFound) 47 | const lookupZipCode = c(R.chain(R.ifElse(respIsOk, updateStateFromResp, updateStateToNotFound)), fetchZip) 48 | 49 | const zipLens = R.lensProp('zipCode') 50 | const stateLens = R.lensProp('state') 51 | 52 | const update = Action.caseOn({ 53 | ChangeZipCode: (newZip, model) => { 54 | const validZip = isZip(newZip) 55 | const newState = validZip ? USState.Loading() : USState.Invalid() 56 | const newModel = c(R.set(zipLens, newZip), R.set(stateLens, newState))(model) 57 | return [newModel, validZip ? [lookupZipCode(newZip)] : []] 58 | }, 59 | ChangeState: c(R.prepend(R.__, [[]]), R.set(stateLens)) 60 | }) 61 | 62 | // View 63 | 64 | const view = (actions, model) => { 65 | const field = h('input', { 66 | props: {placeholder: 'Zip Code', value: model.zipCode}, 67 | on: {input: c(actions, Action.ChangeZipCode, targetValue)} 68 | }) 69 | const messages = USState.case({ 70 | Invalid: () => [h('div', 'Please type a valid US zip code!')], 71 | Loading: () => [h('div', 'Loading ...')], 72 | NotFound: () => [h('div', 'Not found :(')], 73 | Names: R.map(R.partial(h, 'div')), 74 | }, model.state); 75 | return h('div', R.prepend(field, messages)); 76 | } 77 | 78 | module.exports = {init, Action, update, view} 79 | -------------------------------------------------------------------------------- /examples/autocomplete/README.md: -------------------------------------------------------------------------------- 1 | # Autocomplete 2 | 3 | This example implements postal code lookup in the form of an [autocomplete][wp] 4 | menu. You select the country and type in a location and state/province/ 5 | department etc., and the postal codes matching that location are presented in a 6 | menu. (Lookups done via [zippopotam.us][zippo]). 7 | 8 | The primary aims of this example are to demonstrate: 9 | 10 | - How to do nested asynchronous side effects in a controlled way. [Flyd][flyd] 11 | streams are used here to manage state, for a simpler method see the 12 | [zip-codes-future][zip] example, which this example builds upon. 13 | 14 | - How error conditions can be routed to actions, even to nested components, 15 | using `Future.bimap` (using the [ramda-fantasy implementation][fut]). Contrast 16 | this with the [file-uploader example][fup], which makes use of a (more 17 | Elm-like) Result type for routing. 18 | 19 | - Composition of a complex Future chain for processing input into query results. 20 | 21 | - [Maybe][maybe] instead of null values. 22 | 23 | - Use of parameterized sub-components within an application. 24 | 25 | - Handling contextual keydown events. 26 | 27 | - Using snabbdom hooks to reposition the (absolute-positioned) menu relative to 28 | the input. 29 | 30 | - Input debouncing using [flyd-aftersilence][debounce]. (Impure, but easy!) 31 | 32 | - Build toolchain using `make`, [jspm][jspm], and [tape][tape], [testem][testem] 33 | with [source-map-support][sms] for tests. 34 | 35 | 36 | ## How to build it 37 | 38 | Install the dependencies. 39 | 40 | ``` 41 | npm install -g jspm 42 | jspm install 43 | ``` 44 | 45 | Then build the code with 46 | 47 | ``` 48 | make build 49 | ``` 50 | 51 | Run the tests 52 | 53 | ``` 54 | testem 55 | ``` 56 | 57 | _Note that the app tests currently fail in Node and Phantomjs because of lack of 58 | support for `fetch`. I'm sure there's a way around this but for now, open your 59 | browser(s) while running `testem` to see test results._ 60 | 61 | 62 | ## How to use it 63 | 64 | ``` 65 | python -m SimpleHTTPServer 8080 # or your favorite static file server 66 | ``` 67 | 68 | Browse to `http://localhost:8080/index.html`. 69 | 70 | 71 | 72 | [wp]: https://en.wikipedia.org/wiki/Autocomplete 73 | [zip]: https://github.com/paldepind/functional-frontend-architecture/tree/master/examples/zip-codes-future 74 | [fup]: https://github.com/paldepind/functional-frontend-architecture/tree/master/examples/file-uploader 75 | [zippo]: http://zippopotam.us 76 | [fut]: https://github.com/ramda/ramda-fantasy/tree/master/src/Future.js 77 | [maybe]: https://github.com/ramda/ramda-fantasy/tree/master/src/Maybe.js 78 | [flyd]: https://github.com/paldepind/flyd 79 | [debounce]: https://github.com/paldepind/flyd-aftersilence 80 | [jspm]: http://jspm.io 81 | [tape]: https://github.com/substack/tape 82 | [testem]: https://github.com/airportyh/testem 83 | [sms]: https://github.com/evanw/node-source-map-support 84 | 85 | -------------------------------------------------------------------------------- /examples/file-uploader/server.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var http = require('http'); 4 | var fs = require('fs'); 5 | var path = require('path'); 6 | var url = require('url'); 7 | var noop = function(){}; 8 | 9 | var multipart = require('multiparty'); 10 | 11 | var UPLOADDIR = 'uploads'; 12 | var PORT = process.argv[2] || 8080; 13 | 14 | createServer(function(req,res){ 15 | 16 | req.on('abort', function(){ 17 | res.on('error', noop); 18 | respond(400, 'Error receiving', res); 19 | }); 20 | 21 | if (req.url == '/upload' && req.method == 'POST'){ 22 | var form = new multipart.Form(); 23 | 24 | form.on('part', function(part){ 25 | if (!part.filename) return; 26 | var fsOut = path.join(__dirname, UPLOADDIR, part.filename); 27 | 28 | part.on('error', function(){ 29 | console.error('-| ' + fsOut); 30 | res.on('error', noop); 31 | respond(400, 'Error receiving', res); 32 | }); 33 | 34 | try { 35 | var out = fs.createWriteStream(fsOut); 36 | part.pipe(out); 37 | console.log('-> ' + fsOut); 38 | } catch (e) { 39 | console.error('-X ' + fsOut); 40 | console.error("Upload error: " + e.message); 41 | respond(500, e.message, res); 42 | } 43 | 44 | }); 45 | 46 | form.on('error', function(){ 47 | res.on('error', noop); 48 | respond(400, 'Unable to parse as multipart', res); 49 | }); 50 | 51 | form.on('close', function(){ 52 | respond(200, 'OK', res); 53 | }); 54 | 55 | form.parse(req); 56 | 57 | return; 58 | } 59 | 60 | // otherwise, static file serving 61 | 62 | if (req.method == 'GET') { 63 | var requestUrl = url.parse(req.url); 64 | var fsPath = path.join( __dirname, requestUrl.pathname); 65 | 66 | fs.exists(fsPath, function(exists) { 67 | try { 68 | if(exists) { 69 | console.log('<- ' + fsPath); 70 | res.writeHead(200) 71 | fs.createReadStream(fsPath).pipe(res); 72 | } else { 73 | console.error("X- " + fsPath); 74 | respond(404, "Not Found", res); 75 | } 76 | } catch (e) { 77 | res.end(); 78 | } 79 | }); 80 | 81 | } else { 82 | respond(404, "Not Found", res); 83 | } 84 | 85 | 86 | function errhand(e){ 87 | respond(500, e.message, res); 88 | }; 89 | 90 | }).listen(PORT); 91 | 92 | 93 | 94 | function createServer(app){ 95 | return http.createServer( wrap(app) ); 96 | } 97 | 98 | function wrap(app){ 99 | return function(req,res){ 100 | try { 101 | app(req,res); 102 | } 103 | catch (e) { 104 | respond(500, e.message + "\n" + e.stack, res) 105 | } 106 | } 107 | } 108 | 109 | function respond(status, msg, res){ 110 | res.writeHead(status, {'content-type': 'text/plain'}); 111 | res.write(msg); 112 | res.end(); 113 | } 114 | -------------------------------------------------------------------------------- /examples/file-uploader/README.md: -------------------------------------------------------------------------------- 1 | # File Uploader 2 | 3 | This example is a browser-based file-upload component, i.e. it renders a list 4 | of chosen files, shows their upload progress, and allows users to cancel 5 | uploads. 6 | 7 | Similar to the [zip-code-futures][zip] example, it demonstrates a way to do 8 | asynchronous side effects in a controlled way. The top-level update function 9 | (in `main.js`) was copied from that example. It features a convention for making 10 | synchronous and asynchronous updates together which is very similar to Elm's 11 | `effects`. You can read more about this in the [Elm architecture tutorial][elm]. 12 | 13 | It also features routing of nested updates, both synchronous (which 'bubble up' 14 | the component tree in the familiar way, based on user events) and asynchronous 15 | (which are constructed via future chains of actions to 'dive down' the component 16 | tree, based on server responses). 17 | 18 | The uploader wraps XMLHttpRequest in a [ramda-fantasy][rf] Future, which 19 | repeatedly resolves on XHR progress events. 20 | 21 | 22 | ## How to build it 23 | 24 | Install the dependencies. 25 | 26 | ``` 27 | npm install 28 | ``` 29 | 30 | Then build the code with 31 | 32 | ``` 33 | npm run build 34 | ``` 35 | 36 | Run the tests 37 | 38 | ``` 39 | npm run test 40 | ``` 41 | 42 | 43 | ## How to use it 44 | 45 | Create an uploads folder, if it doesn't already exist: 46 | 47 | ``` 48 | mkdir -p uploads 49 | ``` 50 | 51 | Start the test server 52 | 53 | ``` 54 | node server.js 8080 55 | ``` 56 | 57 | Then open your browser to `http://localhost:8080/index.html`, and choose some 58 | files to upload. 59 | 60 | 61 | ## Other notes 62 | 63 | - The progress bar is rendered as SVG using @yelouafi 's [builder][svg] (not yet 64 | part of snabbdom, but it works great!) 65 | 66 | - The example is intended to be a step towards a realistic stand-alone 67 | file uploader, i.e. a 'widget' or reusable component. So `main` and `app` 68 | give a picture of how you would integrate it into a real app. Comments 69 | welcome on the app interface to the 'widget' (the `list` and `uploader`), 70 | I am sure it could be improved. 71 | 72 | - 'Structural' styling is done via javacript, while app-specific styles can be 73 | applied via CSS. 74 | 75 | - The XHR `abort` method is not handled in a pure way. This seems impossible, 76 | at least using the Future interface. So there is a bit of a 'wormhole' 77 | exposing this method back to the app and into the model, where it can be 78 | hooked up to a click handler. Other suggestions welcome. 79 | 80 | - The tests include a simple dummy uploader for running unit tests independent 81 | of XHR and the browser, an example of how easy it is to test using this 82 | architecture. 83 | 84 | 85 | 86 | [zip]: https://github.com/paldepind/functional-frontend-architecture/tree/master/examples/zip-codes-future 87 | [elm]: https://github.com/evancz/elm-architecture-tutorial#example-5-random-gif-viewer 88 | [rf]: https://github.com/ramda/ramda-fantasy 89 | [svg]: https://github.com/paldepind/snabbdom/issues/4 90 | 91 | -------------------------------------------------------------------------------- /examples/zip-codes/js/app.js: -------------------------------------------------------------------------------- 1 | /* jshint esnext: true */ 2 | /* globals require, window, Request */ 3 | 'use strict'; 4 | 5 | /* This is a translation of Elm's zip-codes example, designed to show how 6 | to use asyncronous tasks, specifically http requests. ES6 Promises are used 7 | here as stand-ins for Elm's Tasks. 8 | 9 | Original: http://elm-lang.org/examples/zip-codes 10 | */ 11 | 12 | const R = require('ramda'); 13 | const flyd = require('flyd'); 14 | const stream = flyd.stream; 15 | const Type = require('union-type'); 16 | const patch = require('snabbdom').init([ 17 | require('snabbdom/modules/class'), 18 | require('snabbdom/modules/style'), 19 | require('snabbdom/modules/props'), 20 | require('snabbdom/modules/eventlisteners'), 21 | ]); 22 | const h = require('snabbdom/h'); 23 | 24 | const formatPlace = R.pipe( R.props(['place name', 'state']), R.join(', ') ); 25 | const places = R.pipe( JSON.parse, R.prop('places'), R.map(formatPlace) ); 26 | 27 | // view 28 | 29 | const view = (model, result) => { 30 | const field = h('input', { 31 | props: { placeholder: 'Zip Code', value: model }, 32 | style: myStyle, 33 | on: { input: R.pipe(targetValue, query$) } 34 | }, 35 | [] 36 | ); 37 | 38 | const messages = Result.case({ 39 | Ok: R.map((city) => h('div', { style: myStyle }, city)), 40 | Err: (msg) => [ h('div', { style: myStyle }, msg) ] 41 | }, result); 42 | 43 | return h('div', {}, R.prepend(field, messages)); 44 | } 45 | 46 | const myStyle = { 47 | 'width': '100%' 48 | , 'height': '40px' 49 | , 'padding': '10px 0' 50 | , 'font-size': '2em' 51 | , 'text-align': 'center' 52 | }; 53 | 54 | // wiring 55 | 56 | const Result = Type({ 57 | Ok: [Array], 58 | Err: [String] 59 | }); 60 | 61 | const query$ = stream(''); 62 | const result$ = stream(Result.Err('A valid US zip code is 5 numbers.')); 63 | 64 | const vnode$ = map2( view, query$, result$ ); 65 | 66 | const request$ = flyd.map( 67 | R.pipe( lookupZipCode, result$ ), 68 | query$ 69 | ); 70 | 71 | module.exports = function start(container){ 72 | flyd.scan( patch, container, vnode$ ); 73 | }; 74 | 75 | 76 | 77 | function lookupZipCode(query){ 78 | const makeRequest = function(){ 79 | if (query.match(/^\d{5}$/)) { 80 | return window.fetch( new Request('http://api.zippopotam.us/us/' + query, {method: 'GET'}) ); 81 | } else { 82 | return Promise.reject( 'Give me a valid US zip code!' ); 83 | } 84 | }; 85 | 86 | return makeRequest() 87 | .then( responseStatus ) 88 | .then( R.invoker(0, 'text') ) 89 | .then( places ) 90 | .then( Result.Ok, Result.Err ); 91 | } 92 | 93 | // utils 94 | 95 | function targetValue(e){ return e.target.value; } 96 | 97 | 98 | // cf. http://updates.html5rocks.com/2015/03/introduction-to-fetch 99 | function responseStatus(response){ 100 | if (!response.ok) return Promise.reject('Not found :('); 101 | return Promise.resolve(response); 102 | } 103 | 104 | function map2(fn, s1, s2){ 105 | return flyd.stream([s1,s2], function(){ 106 | return fn(s1(), s2()); 107 | }); 108 | } 109 | -------------------------------------------------------------------------------- /examples/autocomplete/js/menu.js: -------------------------------------------------------------------------------- 1 | 2 | import curry from 'ramda/src/curry' 3 | import assoc from 'ramda/src/assoc' 4 | import merge from 'ramda/src/merge' 5 | import map from 'ramda/src/map' 6 | import toString from 'ramda/src/toString' 7 | 8 | import Type from 'union-type' 9 | import Maybe from 'ramda-fantasy/src/Maybe' 10 | import h from 'snabbdom/h' 11 | 12 | import isMaybe from './helpers/ismaybe' 13 | 14 | const identity = (x) => x 15 | 16 | export default function menu(itemComponent,valueAccessor){ 17 | 18 | // model 19 | 20 | const init = (items=[]) => ({ 21 | selected: Maybe.Nothing(), 22 | selectedValue: Maybe.Nothing(), 23 | items: map(itemComponent.init, items) 24 | }); 25 | 26 | const nextIndex = (model) => { 27 | const idx = model.selected 28 | , n = model.items.length; 29 | return idx.isNothing() ? (n === 0 ? Maybe.Nothing() : Maybe.Just(0)) 30 | : map((i) => ((i + 1) % n), idx) ; 31 | } 32 | 33 | const prevIndex = (model) => { 34 | const idx = model.selected 35 | , n = model.items.length; 36 | return idx.isNothing() ? (n === 0 ? Maybe.Nothing() : Maybe.Just(n-1)) 37 | : map((i) => ((n + (i-1)) % n), idx) ; 38 | } 39 | 40 | // update 41 | 42 | const Action = Type({ 43 | Select: [isMaybe], 44 | SelectNext: [], 45 | SelectPrev: [], 46 | Refresh: [Array], 47 | Clear: [] 48 | }); 49 | 50 | 51 | const update = Action.caseOn({ 52 | 53 | Select: (idx,model) => { 54 | const val = map((i) => valueAccessor(model.items[i]), idx); 55 | return assoc('selectedValue', val, assoc('selected', idx, model)); 56 | }, 57 | 58 | SelectNext: (model) => { 59 | const idx = nextIndex(model); 60 | return update( Action.Select(idx,model), model ); 61 | }, 62 | 63 | SelectPrev: (model) => { 64 | const idx = prevIndex(model); 65 | return update( Action.Select(idx,model), model ); 66 | }, 67 | 68 | Refresh: init, 69 | 70 | Clear: (_) => init([]) 71 | 72 | }); 73 | 74 | 75 | // view 76 | 77 | const view = curry( ({style=initStyle(), action$}, model) => { 78 | style.ul = merge(style.ul || {}, fixedStyle.ul); 79 | style.li = merge(style.li || {}, fixedStyle.li); 80 | 81 | return ( 82 | h('ul', 83 | {style: style.ul}, 84 | model.items.map( itemView(action$, style.li, model) ) 85 | ) 86 | ); 87 | }); 88 | 89 | const itemView = curry( (action$, style, model, item, i) => { 90 | const subview = itemComponent.view(item); 91 | return ( 92 | h('li', { class: {selected: model.selected.equals(Maybe(i))}, 93 | style: style, 94 | on: { click: [action$, Action.Select(Maybe(i))] } 95 | }, 96 | typeof subview == 'string' ? subview : [subview] 97 | ) 98 | ); 99 | }); 100 | 101 | // styles 102 | 103 | const initStyle = () => { return {li: {}, ul: {}}; } 104 | const fixedStyle = { 105 | ul: { 106 | 'list-style': 'none', 107 | 'padding': '0', 108 | 'margin-top': '0', 109 | 'margin-bottom': '0' 110 | }, 111 | li: { 112 | 'cursor': 'pointer', 113 | } 114 | }; 115 | 116 | 117 | return { init, update, Action, view }; 118 | 119 | } 120 | 121 | -------------------------------------------------------------------------------- /examples/autocomplete/js/test-unit-menu.js: -------------------------------------------------------------------------------- 1 | 2 | import test from 'tape' 3 | import menu from './menu' 4 | 5 | import prop from 'ramda/src/prop' 6 | import map from 'ramda/src/map' 7 | import Maybe from 'ramda-fantasy/src/Maybe' 8 | 9 | const identity = (x) => x 10 | 11 | const confirmSelected = (assert) => { 12 | return (inits, exps, subj) => { 13 | let actual = subj.init(inits); 14 | for (var i=0;i { assert.equal(act, i, "selected index updated: " + i); }, 18 | actual.selected); 19 | map((act) => { assert.equal(act, exps[i], "selected value updated"); }, 20 | actual.selectedValue); 21 | } 22 | } 23 | } 24 | 25 | const confirmNothingSelectedAct = (assert) => { 26 | return (act, inits, subj) => { 27 | let actual = subj.init(inits); 28 | actual = subj.update( act(), actual); 29 | console.log(JSON.stringify(actual)); 30 | assert.ok(actual.selected.isNothing(), 'selected index is Nothing'); 31 | assert.ok(actual.selectedValue.isNothing(), 'selected value is Nothing'); 32 | } 33 | } 34 | 35 | const confirmSelectedAct = (assert) => { 36 | return (act, inits, exps, subj) => { 37 | let actual = subj.init(inits); 38 | for (var i=0;i { assert.equal(act, exps[i], "selected index updated: " + exps[i]); }, 42 | actual.selected); 43 | } 44 | } 45 | } 46 | 47 | test('menu select action, simple menu item component', (assert) => { 48 | assert.plan(3 * 2); 49 | 50 | const subj = menu({view: identity, init: identity}, identity); 51 | 52 | confirmSelected(assert)(["one","two","three"], 53 | ["one","two","three"], subj); 54 | 55 | }); 56 | 57 | test('menu select action, complex menu item component', (assert) => { 58 | assert.plan(3 * 2); 59 | 60 | const subj = menu({init: (v) => { return {value: v} }, 61 | view: (o) => o.value + " little piggy"}, 62 | prop('value') 63 | ); 64 | 65 | confirmSelected(assert)(["one","two","three"], 66 | ["one","two","three"], subj); 67 | 68 | 69 | }); 70 | 71 | test('menu select-next action', (assert) => { 72 | assert.plan(10+2); 73 | 74 | const subj = menu({view: identity, init: identity}, identity); 75 | 76 | confirmSelectedAct(assert)(subj.Action.SelectNext, 77 | ["one","two","three","four","five"], 78 | [0,1,2,3,4,0,1,2,3,4], 79 | subj); 80 | 81 | const emptySubj = menu({view: identity, init: identity}, identity); 82 | 83 | confirmNothingSelectedAct(assert)(emptySubj.Action.SelectNext, [], emptySubj); 84 | 85 | }); 86 | 87 | test('menu select-prev action', (assert) => { 88 | assert.plan(10+2); 89 | 90 | const subj = menu({view: identity, init: identity}, identity); 91 | 92 | confirmSelectedAct(assert)(subj.Action.SelectPrev, 93 | ["one","two","three","four","five"], 94 | [4,3,2,1,0,4,3,2,1,0], 95 | subj); 96 | 97 | const emptySubj = menu({view: identity, init: identity}, identity); 98 | 99 | confirmNothingSelectedAct(assert)(emptySubj.Action.SelectPrev, [], emptySubj); 100 | 101 | }); 102 | 103 | test('menu refresh action', (assert) => { 104 | assert.plan(3); 105 | 106 | const subj = menu({view: identity, init: identity}, identity); 107 | 108 | let actual = subj.init([]); 109 | actual = subj.update(subj.Action.Refresh(["four","five"]), actual); 110 | 111 | assert.ok(actual.selected.isNothing(), "selected is Nothing after refresh"); 112 | assert.ok(actual.selectedValue.isNothing(), "selectedValue is Nothing after refresh"); 113 | assert.deepEqual(actual.items, ["four","five"], "items are changed after refresh"); 114 | 115 | }); 116 | -------------------------------------------------------------------------------- /examples/file-uploader/js/upload.js: -------------------------------------------------------------------------------- 1 | const Type = require('union-type'); 2 | 3 | const map = require('ramda/src/map') 4 | , reduce = require('ramda/src/reduce') 5 | , curry = require('ramda/src/curry') 6 | , contains = require('ramda/src/contains') 7 | , always = require('ramda/src/always') 8 | , merge = require('ramda/src/merge') 9 | , evolve = require('ramda/src/evolve') 10 | , dissoc = require('ramda/src/dissoc') 11 | ; 12 | 13 | const h = require('snabbdom/h') 14 | , s = require('./svg'); 15 | 16 | const noop = function(){}; 17 | 18 | // model 19 | 20 | const init = (files) => ( 21 | { 22 | status: 'initial', 23 | progress: {}, 24 | abort: noop, 25 | title: (files.length === 1 26 | ? files[0].name 27 | : '(' + files.length + ' files)' ), 28 | files: map(initFile, files) 29 | } 30 | ) 31 | 32 | const initFile = ({name,lastModifiedDate,size,type}) => ( 33 | {name,lastModifiedDate,size,type} 34 | ) 35 | 36 | const size = (model) => ( 37 | reduce( (tot,file) => tot + (file.size || 0), 0, model.files ) 38 | ) 39 | 40 | const status = curry( (s, model) => model.status == s ); 41 | const uploading = status('uploading'); 42 | 43 | const aborted = (model) => model.status == 'aborted' 44 | 45 | const abortable = (model) => ( 46 | !!model.abort && contains(model.status, ['uploading']) 47 | ) 48 | 49 | const hasProgressData = (x) => ( 50 | !(x.loaded === undefined || x.total === undefined) 51 | ) 52 | 53 | const percentProgress = (p) => { 54 | if (!hasProgressData(p)) return null; 55 | return p.loaded / p.total; 56 | } 57 | 58 | 59 | // action 60 | 61 | // NOTE: no side-effects initiated, so all updates simply return changed state 62 | 63 | const Action = Type({ 64 | Progress: [Function, hasProgressData], 65 | Uploaded: [], 66 | Error: [], 67 | Abort: [] 68 | }); 69 | 70 | const update = Action.caseOn({ 71 | Progress: (abort,{loaded,total},model) => { 72 | return evolve({ status: always(loaded < total ? 'uploading' : 'processing'), 73 | progress: always({loaded, total}), 74 | abort: always(abort) 75 | })(model); 76 | }, 77 | Uploaded: evolve({status: always('uploaded')}), 78 | Error: evolve({status: always('error')}), 79 | Abort: evolve({status: always('abort')}) 80 | }); 81 | 82 | 83 | // view 84 | 85 | const view = curry( ({progress},model) => { 86 | 87 | progress = merge({width: 200, height: 20}, progress || {}); 88 | 89 | return ( 90 | h('div', { attrs: { 'class': 'upload ' + model.status }, 91 | style: style.upload 92 | }, [ 93 | h('div.title', {style: style.div}, [ renderTitle(model) ]), 94 | h('div.size', {style: style.div}, [ ''+size(model) ]), 95 | h('div.progress', {style: style.div}, [ renderProgress(model,progress) ]), 96 | h('div.status', {style: style.div}, [ renderStatus(model) ]), 97 | h('div.abort', {style: dissoc('margin-right', style.div)}, 98 | [ renderAbort(model) ]) 99 | ]) 100 | ); 101 | 102 | }); 103 | 104 | const renderTitle = (model) => ( 105 | model.url 106 | ? h('a', { attrs: {'href': model.url, 107 | 'target': '_blank' 108 | } 109 | }, [ model.title ]) 110 | 111 | : h('span', {}, [ model.title ]) 112 | ) 113 | 114 | 115 | const renderProgress = (model,specs) => { 116 | const barwidth = percentProgress(model.progress) * specs.width; 117 | const linespecs = { x1: specs.width, x2: specs.width, 118 | y1: 0, y2: specs.height }; 119 | 120 | const rect = ( 121 | s('rect', { attrs: { height: specs.height, 122 | width: barwidth, 123 | class: 'bar' 124 | } 125 | }) 126 | ); 127 | 128 | const line = ( 129 | s('line', { attrs: merge(linespecs, {class: 'end'}) } ) 130 | ); 131 | 132 | return ( 133 | s('svg', {attrs: specs}, [ 134 | s('g', {}, (barwidth > 0) ? [rect,line] : []) 135 | ]) 136 | ); 137 | 138 | } 139 | 140 | const renderStatus = (model) => h('span', {}, statusLabel(model)) 141 | 142 | 143 | const renderAbort = (model) => ( 144 | h('a', { style: merge(visible(abortable, model), {cursor: 'pointer'}), 145 | on: { click: model.abort } }, 146 | actionLabel('abort') 147 | ) 148 | ) 149 | 150 | 151 | const statusLabel = (model) => ( 152 | { 153 | 'initial': null, 154 | 'uploading': 'uploading', 155 | 'processing': 'processing', 156 | 'uploaded': 'done', 157 | 'error': 'error', 158 | 'abort': 'stopped' 159 | }[model.status] || null 160 | ) 161 | 162 | const actionLabel = (action) => ( 163 | { 164 | 'abort': '×' 165 | }[action] || null 166 | ) 167 | 168 | 169 | // view styles 170 | 171 | const style = { 172 | upload: { 'display': 'inline-block' }, 173 | div: { 'display': 'inline-block', 174 | 'vertical-align': 'top', 175 | 'margin-right': '1rem' 176 | } 177 | } 178 | 179 | 180 | // view utils 181 | 182 | const visible = (pred,model) => ( 183 | { display: pred(model) ? null : 'none' } 184 | ) 185 | 186 | 187 | module.exports = {init, Action, update, view}; 188 | 189 | 190 | -------------------------------------------------------------------------------- /examples/autocomplete/js/autocomplete.js: -------------------------------------------------------------------------------- 1 | 2 | import curry from 'ramda/src/curry' 3 | import compose from 'ramda/src/compose' 4 | import map from 'ramda/src/map' 5 | import always from 'ramda/src/always' 6 | import T from 'ramda/src/T' 7 | import F from 'ramda/src/F' 8 | import assoc from 'ramda/src/assoc' 9 | import merge from 'ramda/src/merge' 10 | import last from 'ramda/src/last' 11 | 12 | import Type from 'union-type' 13 | import Future from 'ramda-fantasy/src/Future' 14 | import Maybe from 'ramda-fantasy/src/Maybe' 15 | import flyd from 'flyd' 16 | import forwardTo from 'flyd-forwardto' 17 | import debounce from 'flyd-aftersilence' 18 | import h from 'snabbdom/h' 19 | 20 | import noFx from './helpers/nofx' 21 | import isMaybe from './helpers/ismaybe' 22 | import emptyToNothing from './helpers/emptytonothing' 23 | import targetValue from './helpers/targetvalue' 24 | import caseKey from './helpers/casekey' 25 | 26 | const noop = function(){}; 27 | 28 | 29 | export default function autocomplete(menu){ 30 | 31 | // model 32 | 33 | const init = (value=null) => ({ 34 | menu: menu.init(), 35 | isEditing: false, 36 | value: Maybe(value) 37 | }); 38 | 39 | const showMenu = (model) => model.isEditing && model.menu.items.length > 0; 40 | 41 | const selectedOrInputValue = (model) => ( 42 | model.menu.selectedValue.getOrElse(model.value.getOrElse('')) 43 | ); 44 | 45 | // update 46 | 47 | const Action = Type({ 48 | Input: [Function, isMaybe], 49 | RefreshMenu: [Array], 50 | ClearMenu: [], 51 | UpdateMenu: [menu.Action], 52 | ShowMenu: [], 53 | HideMenu: [] 54 | }) 55 | 56 | const update = Action.caseOn({ 57 | 58 | Input: (query, str, model) => { 59 | const tasks = [ query(str,model) ]; 60 | return [ 61 | assoc('isEditing', true, assoc('value', str, model)) , 62 | tasks 63 | ]; 64 | }, 65 | 66 | RefreshMenu: (items, model) => ( 67 | update( Action.UpdateMenu( menu.Action.Refresh(items) ), model ) 68 | ), 69 | 70 | ClearMenu: (model) => ( 71 | update( Action.UpdateMenu( menu.Action.Clear() ), model ) 72 | ), 73 | 74 | UpdateMenu: (action, model) => ( 75 | noFx( assoc('menu', menu.update(action, model.menu), model) ) 76 | ), 77 | 78 | ShowMenu: compose(noFx, assoc('isEditing', true)), 79 | 80 | HideMenu: compose(noFx, assoc('isEditing', false)) 81 | 82 | }); 83 | 84 | 85 | // view 86 | 87 | const view = curry( ({query, action$, delay=0}, model) => { 88 | const menuAction$ = forwardTo(action$, Action.UpdateMenu); 89 | const inputAction$ = flyd.stream(); 90 | flyd.on( action$, map(last, debounce(delay, inputAction$)) ); 91 | 92 | const mview = menu.view({action$: menuAction$}); 93 | 94 | const input = inputView(action$, inputAction$, menuAction$, query, model); 95 | const menudiv = menuView(mview, style.menu, model.menu); 96 | 97 | return h('div.autocomplete', showMenu(model) ? [input, menudiv] : [input] ); 98 | 99 | }); 100 | 101 | const inputView = (action$, inputAction$, menuAction$, query, model) => { 102 | 103 | const handleEsc = compose( action$, always(Action.HideMenu()) ); 104 | const handleEnter = handleEsc; 105 | const handleDown = compose( menuAction$, always(menu.Action.SelectNext()) ); 106 | const handleUp = compose( menuAction$, always(menu.Action.SelectPrev()) ); 107 | 108 | return ( 109 | h('input', { 110 | on: { 111 | input: compose(inputAction$, 112 | Action.Input(query), 113 | emptyToNothing, 114 | targetValue 115 | ), 116 | keydown: !model.isEditing ? noop 117 | : caseKey([ 118 | [['Esc','Escape', 0x1B], handleEsc], 119 | [['Enter', 0x0A, 0x0D], handleEnter], 120 | [['Down','DownArrow',0x28], handleDown], 121 | [['Up','UpArrow',0x26], handleUp] 122 | ]), 123 | blur: [action$, Action.HideMenu()] 124 | }, 125 | props: { type: 'text', 126 | value: selectedOrInputValue(model) 127 | } 128 | }) 129 | ); 130 | } 131 | 132 | const menuView = (mview, style, model) => ( 133 | h('div.menu', { 134 | style: style, 135 | hook: { insert: positionUnder('input'), 136 | postpatch: repositionUnder('input') 137 | } 138 | }, 139 | [ mview(model) ] 140 | ) 141 | ); 142 | 143 | // styles 144 | 145 | const style = { 146 | menu: { 147 | position: 'absolute', 148 | 'z-index': '100', 149 | opacity: '1', 150 | transition: 'opacity 0.2s', 151 | remove: { opacity: '0' } 152 | } 153 | }; 154 | 155 | return {init, update, Action, view}; 156 | } 157 | 158 | 159 | 160 | // hooks 161 | 162 | const positionUnder = curry( (selector, vnode) => { 163 | let elm = vnode.elm, 164 | targetElm = elm.parentNode.querySelector(selector); 165 | if (!(elm && targetElm)) return; 166 | const rect = targetElm.getBoundingClientRect(); 167 | elm.style.top = "" + (rect.top + rect.height + 1) + "px"; 168 | elm.style.left = "" + rect.left + "px"; 169 | return; 170 | }); 171 | 172 | const repositionUnder = curry( (selector, oldVNode, vnode) => ( 173 | positionUnder(selector,vnode) 174 | )); 175 | 176 | 177 | 178 | -------------------------------------------------------------------------------- /examples/todo/js/task-list.js: -------------------------------------------------------------------------------- 1 | /* jshint esnext: true */ 2 | const R = require('ramda'); 3 | const flyd = require('flyd'); 4 | const stream = flyd.stream; 5 | const forwardTo = require('flyd-forwardto'); 6 | const Type = require('union-type'); 7 | const Router = require('../../../router'); 8 | const patch = require('snabbdom').init([ 9 | require('snabbdom/modules/class'), 10 | require('snabbdom/modules/style'), 11 | require('snabbdom/modules/props'), 12 | require('snabbdom/modules/eventlisteners'), 13 | ]); 14 | const treis = require('treis'); 15 | const h = require('snabbdom/h') 16 | 17 | const targetValue = require('../../../helpers/targetvalue'); 18 | const ifEnter = require('../../../helpers/ifenter'); 19 | 20 | const Todo = require('./task') 21 | 22 | // Model 23 | 24 | const init = () => ({ 25 | todos: [], 26 | newTitle: '', 27 | view: 'all', 28 | nextId: 0, 29 | }); 30 | 31 | // Actions 32 | 33 | const Action = Type({ 34 | ChangeNewTitle: [String], 35 | Create: [], 36 | Remove: [Object], 37 | Modify: [Object, Todo.Action], 38 | ToggleAll: [], 39 | ClearDone: [], 40 | ChangePage: [R.T], 41 | }); 42 | 43 | // Update 44 | 45 | // action -> model -> newModel 46 | const update = Action.caseOn({ 47 | ChangeNewTitle: R.assoc('newTitle'), 48 | Create: (model) => R.evolve({todos: R.append(Todo.init(model.nextId, model.newTitle)), 49 | nextId: R.inc, 50 | newTitle: R.always('')}, model), 51 | Remove: (todo, model) => R.evolve({todos: R.reject(R.eq(todo))}, model), 52 | Modify: (todo, action, model) => { 53 | const idx = R.indexOf(todo, model.todos) 54 | return R.evolve({todos: R.adjust(Todo.update(action), idx)}, model) 55 | }, 56 | ToggleAll: (model) => { 57 | const left = R.length(R.reject(R.prop('done'), model.todos)), 58 | todoAction = left === 0 ? Todo.Action.UnsetDone() : Todo.Action.SetDone(); 59 | return R.evolve({todos: R.map(Todo.update(todoAction))}, model); 60 | }, 61 | ClearDone: R.evolve({todos: R.reject(R.prop('done'))}), 62 | ChangePage: MyRouter.Action.caseOn({ 63 | ViewAll: (_, model) => R.assoc('view', 'all', model), 64 | ViewActive: (_, model) => treis(R.assoc)('view', 'active', model), 65 | ViewCompleted: (_, model) => treis(R.assoc)('view', 'complete', model), 66 | }) 67 | }) 68 | 69 | // View 70 | 71 | const viewTodo = R.curry((action$, todo) => { 72 | return Todo.view({ 73 | action$: forwardTo(action$, Action.Modify(todo)), 74 | remove$: forwardTo(action$, R.always(Action.Remove(todo))), 75 | }, todo) 76 | }) 77 | 78 | const view = R.curry((action$, model) => { 79 | const hasTodos = model.todos.length > 0, 80 | left = R.length(R.reject(R.prop('done'), model.todos)), 81 | filteredTodos = model.view === 'all' ? model.todos 82 | : model.view === 'active' ? R.reject(R.prop('done'), model.todos) 83 | : R.filter(R.prop('done'), model.todos) 84 | return h('section.todoapp', [ 85 | h('header.header', [ 86 | h('h1', 'todos'), 87 | h('input.new-todo', { 88 | props: {placeholder: 'What needs to be done?', 89 | value: model.newTitle}, 90 | on: {input: R.compose(action$, Action.ChangeNewTitle, targetValue), 91 | keydown: ifEnter(action$, Action.Create())}, 92 | }), 93 | ]), 94 | h('section.main', { 95 | style: {display: hasTodos ? 'block' : 'none'} 96 | }, [ 97 | h('input.toggle-all', {props: {type: 'checkbox'}, on: {click: [action$, Action.ToggleAll()]}}), 98 | h('ul.todo-list', R.map(viewTodo(action$), filteredTodos)), 99 | ]), 100 | h('footer.footer', { 101 | style: {display: hasTodos ? 'block' : 'none'} 102 | }, [ 103 | h('span.todo-count', [h('strong', left), ` item${left === 1 ? '' : 's'} left`]), 104 | h('ul.filters', [ 105 | h('li', [h('a', {class: {selected: model.view === 'all'}, props: {href: '#/'}}, 'All')]), 106 | h('li', [h('a', {class: {selected: model.view === 'active'}, props: {href: '#/active'}}, 'Active')]), 107 | h('li', [h('a', {class: {selected: model.view === 'completed'}, props: {href: '#/completed'}}, 'Completed')]), 108 | ]), 109 | h('button.clear-completed', {on: {click: [action$, Action.ClearDone()]}}, 'Clear completed'), 110 | ]) 111 | ]) 112 | }) 113 | 114 | // Router 115 | const MyRouter = Router.init({ 116 | history: false, 117 | constr: Action.ChangePage, 118 | routes: { 119 | '/': 'ViewAll', 120 | '/active': 'ViewActive', 121 | '/completed': 'ViewCompleted', 122 | }, 123 | }); 124 | 125 | // Persistence 126 | const restoreState = () => { 127 | const restored = JSON.parse(localStorage.getItem('state')); 128 | return restored === null ? init() : restored; 129 | }; 130 | 131 | const saveState = (model) => { 132 | localStorage.setItem('state', JSON.stringify(model)); 133 | }; 134 | 135 | // Streams 136 | const action$ = flyd.merge(MyRouter.stream, flyd.stream()); 137 | const model$ = flyd.scan(R.flip(update), restoreState(), action$) 138 | const vnode$ = flyd.map(view(action$), model$) 139 | 140 | flyd.map(saveState, model$); 141 | 142 | // flyd.map((model) => console.log(model), model$); // Uncomment to log state on every update 143 | 144 | window.addEventListener('DOMContentLoaded', function() { 145 | const container = document.querySelector('.todoapp') 146 | flyd.scan(patch, container, vnode$) 147 | }) 148 | -------------------------------------------------------------------------------- /examples/autocomplete/js/test-app.js: -------------------------------------------------------------------------------- 1 | import test from 'tape' 2 | import flyd from 'flyd' 3 | 4 | import equals from 'ramda/src/equals' 5 | import reduce from 'ramda/src/reduce' 6 | import prepend from 'ramda/src/prepend' 7 | import append from 'ramda/src/append' 8 | 9 | import Maybe from 'ramda-fantasy/src/Maybe' 10 | 11 | import app from './app' 12 | 13 | 14 | const throwOr = (fn) => { 15 | return (x) => { 16 | if (x instanceof Error) throw x; 17 | return fn(x); 18 | } 19 | } 20 | 21 | const inspectAction = (a) => { 22 | const parts = reduce((acc,p) => ( 23 | append( (Array.isArray(p) && p['name'] ? inspectAction(p) : p), acc ) 24 | ), [], a); 25 | return prepend(a.name, parts); 26 | } 27 | 28 | const start = (action$, snapshots, init) => { 29 | const state$ = flyd.map( (act) => { 30 | console.log("-->" + JSON.stringify(inspectAction(act))); 31 | const [s1,tasks] = app.update(act,state$()); 32 | tasks.map((t) => t.fork(throwOr(action$), action$)); 33 | return s1; 34 | }, action$); 35 | flyd.on( (s) => { 36 | console.log("<--" + JSON.stringify(s)); 37 | snapshots.push(s); 38 | }, state$); 39 | 40 | state$(init); 41 | return state$; 42 | } 43 | 44 | 45 | /////////////////////////////////////////////////////////////////////////////// 46 | test('set-country action', (assert) => { 47 | 48 | assert.plan(2); 49 | 50 | const action$ = flyd.stream(); 51 | let snapshots = []; 52 | start(action$, snapshots, app.init()); 53 | 54 | action$(app.Action.SetCountry(Maybe('DE'))); 55 | 56 | assert.equal(snapshots.length, 2, "one state change plus initial"); 57 | assert.ok(snapshots[1].country.equals(Maybe('DE')), "set country to Just expected"); 58 | 59 | }); 60 | 61 | test('search action, success', (assert) => { 62 | 63 | assert.plan(2); 64 | 65 | const action$ = flyd.stream(); 66 | let snapshots = []; 67 | const state$ = start(action$, snapshots, app.init()); 68 | 69 | action$(app.Action.SetCountry(Maybe('US'))); 70 | 71 | const searchAction = app.search.Action.Input(app.query(state$()), 72 | Maybe('Philadelphia, PA')); 73 | action$(app.Action.Search(searchAction)); 74 | 75 | setTimeout( () => { 76 | assert.equal(snapshots.length, 4, "three state changes plus initial"); 77 | assert.ok(snapshots[3].search.menu.items.length > 0, "at least one search result displayed in menu"); 78 | }, 2000); 79 | 80 | }); 81 | 82 | test('search action, failure (404 not found)', (assert) => { 83 | 84 | assert.plan(2); 85 | 86 | const action$ = flyd.stream(); 87 | let snapshots = []; 88 | const state$ = start(action$, snapshots, app.init()); 89 | 90 | action$(app.Action.SetCountry(Maybe('US'))); 91 | 92 | const searchAction = app.search.Action.Input(app.query(state$()), 93 | Maybe('Flooby, MA')); 94 | action$(app.Action.Search(searchAction)); 95 | 96 | setTimeout( () => { 97 | assert.equal(snapshots.length, 4, "three state changes plus initial"); 98 | assert.equal(snapshots[3].search.menu.items.length, 0, "no search results displayed in menu"); 99 | }, 2000); 100 | 101 | }); 102 | 103 | test('search action, failure (no country set)', (assert) => { 104 | 105 | assert.plan(2); 106 | 107 | const action$ = flyd.stream(); 108 | let snapshots = []; 109 | const state$ = start(action$, snapshots, app.init()); 110 | 111 | const searchAction = app.search.Action.Input(app.query(state$()), 112 | Maybe('Philadelphia, PA')); 113 | action$(app.Action.Search(searchAction)); 114 | 115 | setTimeout( () => { 116 | assert.equal(snapshots.length, 3, "two state changes plus initial"); 117 | assert.equal(snapshots[2].search.menu.items.length, 0, "no search results displayed in menu"); 118 | }, 1000); 119 | 120 | }); 121 | 122 | test('search action, failure (place and state not parsed)', (assert) => { 123 | 124 | assert.plan(2); 125 | 126 | const action$ = flyd.stream(); 127 | let snapshots = []; 128 | const state$ = start(action$, snapshots, app.init()); 129 | 130 | action$(app.Action.SetCountry(Maybe('US'))); 131 | 132 | const searchAction = app.search.Action.Input(app.query(state$()), 133 | Maybe('Philadelphia PA')); 134 | action$(app.Action.Search(searchAction)); 135 | 136 | setTimeout( () => { 137 | assert.equal(snapshots.length, 4, "three state changes plus initial"); 138 | assert.equal(snapshots[3].search.menu.items.length, 0, "no search results displayed in menu"); 139 | }, 1000); 140 | 141 | }); 142 | 143 | test('search action, failure (place blank)', (assert) => { 144 | 145 | assert.plan(2); 146 | 147 | const action$ = flyd.stream(); 148 | let snapshots = []; 149 | const state$ = start(action$, snapshots, app.init()); 150 | 151 | action$(app.Action.SetCountry(Maybe('US'))); 152 | 153 | const searchAction = app.search.Action.Input(app.query(state$()), 154 | Maybe(', PA')); 155 | action$(app.Action.Search(searchAction)); 156 | 157 | setTimeout( () => { 158 | assert.equal(snapshots.length, 4, "three state changes plus initial"); 159 | assert.equal(snapshots[3].search.menu.items.length, 0, "no search results displayed in menu"); 160 | }, 1000); 161 | 162 | }); 163 | 164 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # functional-frontend-architecture 2 | 3 | This repository is meant to document and explore the implementation of what is 4 | known as "the Elm architecture". A simple functional architecture for building 5 | frontend applications. 6 | 7 | # High level overview 8 | 9 | The entire state is contained in a single data structure. Things can happen 10 | and the state should change accordingly. The number of things that can happen 11 | is described as a set of _actions_. Actions flow unidirectionally down the 12 | application. Actions are handled by pure _update_ functions. Such a function 13 | takes an action and a state and returns a new state. The state is handed to a 14 | _view_ function that returns a virtual DOM representation. A module is an 15 | encapsulated set of actions, an update function and a view function. Modules 16 | can be nested inside other modules and modules can contain other modules. This 17 | makes the architecture nestable and modular. 18 | 19 | # Features/goals/ideas 20 | 21 | * As few mutations in application code as possible. The vast majority of your 22 | application can be completely pure. 23 | * Time changing values and interactions with the world is introduced in a 24 | controlled manner through FRP. 25 | * Testing should be easy! And nothing is easier to test than pure side-effect 26 | free functions. 27 | * State should be easily inspectable for debugging and serialization. Also, 28 | time travel. 29 | * Minimalism and simplicity are center pieces in every used library. 30 | * Actions should be expressed as [union types](https://github.com/paldepind/union-type). 31 | * Everything should be modular and nestable. 32 | * [Simple live code editing](examples/hot-module-reloading) thanks to hot 33 | module replacement. 34 | 35 | # Documentation 36 | 37 | * [React-less Virtual DOM with Snabbdom: functions 38 | everywhere!](https://medium.com/@yelouafi/react-less-virtual-dom-with-snabbdom-functions-everywhere-53b672cb2fe3) 39 | – An article that introduces the virtual DOM library Snabbdom and step by 40 | step implements the Elm architecture with it. 41 | 42 | # Examples 43 | 44 | * [Counters example without FRP](examples/counters-no-frp) – This is several 45 | versions of a counters application. It starts out very simple and then 46 | gradualy increases in complexity. This is implemented with Snabbdom, union-type and Ramda. 47 | * [Counters example with FRP](examples/counters) – This is similair to the above example but 48 | the architecture is bootstraped with Flyd as and FRP library. 49 | * [Who to follow](examples/who-to-follow) – A small who to follow box using 50 | GitHubs API. It can be compared with a [plain FRP version using 51 | Flyd](https://github.com/paldepind/flyd/tree/master/examples/who-to-follow) 52 | and [one using Rx](http://jsfiddle.net/staltz/8jFJH/48/). 53 | * [Zip codes](examples/zip-codes) – This is a translation of Elm's zip-codes 54 | example, designed to show how to use asyncronous tasks, specifically http 55 | requests. ES6 Promises are used here as stand-ins for Elm's Tasks. 56 | * [TodoMVC](examples/todo) – A TodoMVC implementation built with Snabbdom, 57 | union-type, Ramda and Flyd. 58 | * [File Uploader](examples/file-uploader) – Implements a file-upload component, 59 | i.e. it renders a list of chosen files, shows their upload progress, and 60 | allows users to cancel uploads. It demonstrates asynchronous side effects 61 | and a realistic stand-alone reuseable component or 'widget'. 62 | * [Hot module reloading](examples/hot-module-reloading) – Demonstrates how 63 | simple it is to use webpack's hot module replacement to achieve live code 64 | editing without browser refreshes. 65 | * [Modal](examples/modal) – Demonstrates a technique for implementing modals. 66 | * [Nesting](examples/nesting) – A simple application that demonstrates three 67 | level nesting of components. This is intended to show how the action-routing 68 | scales. 69 | 70 | # Libraries 71 | 72 | The architecture is implementation independent. It can be implemented with 73 | varied combinations of libraries. It only requires a virtual DOM library and a 74 | way to update JavaScript data structures without mutations. Having a nice 75 | representation of actions is also useful. 76 | 77 | ## Virtual DOM libraries 78 | 79 | The view layer in the architecture consists of pure functions that takes part 80 | of the applications state and returns a description of it's view. Such a description 81 | will typically be a virtual DOM representation that will later be rendered with a virtual 82 | DOM library. A number of options exists. 83 | 84 | * [Snabbdom](https://github.com/paldepind/snabbdom) – A small modular and 85 | extensible virtual DOM library with splendid performance. 86 | * [virtual-dom](https://github.com/Matt-Esch/virtual-dom) – A popular virtual 87 | DOM library. 88 | * [React](http://facebook.github.io/react/) – Mature and widely used. It 89 | supports JSX which many people like. It is however a bulky library. It 90 | supports stateful components which should not be used together with the 91 | architecture. 92 | 93 | ## Updating data structures 94 | 95 | When handling actions inside `update` functions it is necessary to update ones 96 | state without mutating it. Libraries can provide a help with regards to this. 97 | 98 | * [Ramda](http://ramdajs.com/) – Ramda provides a huge amount of functions for 99 | working with native JavaScript data structures in a purely functional way. 100 | 101 | ## Representing actions 102 | 103 | * [union-type](https://github.com/paldepind/union-type) 104 | -------------------------------------------------------------------------------- /examples/autocomplete/js/app.js: -------------------------------------------------------------------------------- 1 | /* globals window */ 2 | 3 | import curry from 'ramda/src/curry' 4 | import compose from 'ramda/src/compose' 5 | import map from 'ramda/src/map' 6 | import chain from 'ramda/src/chain' 7 | import identity from 'ramda/src/identity' 8 | import unary from 'ramda/src/unary' 9 | import invoker from 'ramda/src/invoker' 10 | import ifElse from 'ramda/src/ifElse' 11 | import path from 'ramda/src/path' 12 | import props from 'ramda/src/props' 13 | import prop from 'ramda/src/prop' 14 | import assoc from 'ramda/src/assoc' 15 | import equals from 'ramda/src/equals' 16 | import prepend from 'ramda/src/prepend' 17 | import insert from 'ramda/src/insert' 18 | import join from 'ramda/src/join' 19 | import allPass from 'ramda/src/allPass' 20 | 21 | import Type from 'union-type' 22 | import Future from 'ramda-fantasy/src/Future' 23 | import Maybe from 'ramda-fantasy/src/Maybe' 24 | import forwardTo from 'flyd-forwardto' 25 | import h from 'snabbdom/h' 26 | 27 | import autocomplete from './autocomplete' 28 | import menu from './menu' 29 | 30 | // utils 31 | 32 | import isMaybe from './helpers/ismaybe' 33 | import emptyToNothing from './helpers/emptytonothing' 34 | import errorOr from './helpers/erroror' 35 | import targetValue from './helpers/targetvalue' 36 | import noFx from './helpers/nofx' 37 | 38 | const rejectFut = (val) => Future((rej,res) => rej(val)) 39 | const promToFut = (prom) => Future((rej, res) => prom.then(res, rej)) 40 | const getJSON = compose( promToFut, invoker(0, 'json')) 41 | const getUrl = (url) => promToFut(window.fetch(new window.Request(url, {method: 'GET'}))) 42 | const respIsOK = (r) => !!r.ok 43 | 44 | //////////////////////////////////////////////////////////////////////////////// 45 | // app constants 46 | 47 | const searchItem = { // mini-component 48 | init: identity, 49 | view: ([place, state, post]) => { 50 | return h('div', [ h('span.place', `${place}, ${state}` ) , 51 | h('span.post', post) ] ); 52 | } 53 | } 54 | 55 | const searchItemValue = ([place, state, post]) => `${place}, ${state} ${post}` 56 | 57 | const searchMenu = menu(searchItem, searchItemValue); 58 | const search = autocomplete(searchMenu); 59 | 60 | 61 | //////////////////////////////////////////////////////////////////////////////// 62 | // autocomplete query 63 | 64 | // Object -> String -> Future (String, Array (Array String)) 65 | const query = (model) => ( 66 | compose( 67 | chain( ifElse(respIsOK, parseResult, fetchFail) ), 68 | chain(fetchZips), 69 | unary(toParams(model)) 70 | ) 71 | ); 72 | 73 | const getZipsAndPlaces = (data) => { 74 | const placeAndZips = map( props(['place name', 'post code']), data.places); 75 | return map( insert(1,data['state abbreviation']), placeAndZips); 76 | } 77 | 78 | // Response -> Future ((), Array (Array String)) 79 | const parseResult = compose( map(getZipsAndPlaces), getJSON); 80 | 81 | // Response -> Future (String, ()) 82 | const fetchFail = (resp) => rejectFut("Not found, check your spelling."); 83 | 84 | // Array String -> Future ((), Response) 85 | const fetchZips = ([country, state, place]) => { 86 | return getUrl(`http://api.zippopotam.us/${country}/${state}/${place}`); 87 | } 88 | 89 | // Object -> Maybe String -> Future (String, Array String) 90 | const toParams = (model) => (str) => { 91 | return new Future( (rej, res) => { 92 | const stateAndPlace = parseInput(str); 93 | const country = model.country; 94 | if (Maybe.isNothing(stateAndPlace)) { 95 | rej("Enter place name and state or province, separated by a comma"); return; 96 | } 97 | if (Maybe.isNothing(country)) { 98 | rej("Select a country"); return; 99 | } 100 | map((c) => 101 | map((s) => 102 | res(prepend(c,s)), 103 | stateAndPlace 104 | ), 105 | country 106 | ); 107 | return; 108 | }); 109 | } 110 | 111 | const parseInput = (str) => ( 112 | chain( 113 | validateStateAndPlace, 114 | map(parseStateAndPlace, str) 115 | ) 116 | ); 117 | 118 | const parseStateAndPlace = (s) => ( 119 | s.split(',') 120 | .map(invoker(0,'trim')) 121 | .reverse() 122 | ); 123 | 124 | const validateStateAndPlace = (parts) => ( 125 | (parts.length < 2 || 126 | parts.some((p) => p.length === 0)) ? Maybe.Nothing() 127 | : Maybe(parts.slice(0,2)) 128 | ); 129 | 130 | 131 | 132 | /////////////////////////////////////////////////////////////////////////////// 133 | // model 134 | 135 | const init = () => ({ 136 | message: Maybe(initMessage), 137 | country: Maybe.Nothing(), 138 | search: search.init() 139 | }); 140 | 141 | const initMessage = "Select a country, and enter a place name." 142 | 143 | const headerMessage = (model) => ( 144 | model.message.getOrElse(initMessage) 145 | ) 146 | 147 | // update 148 | 149 | const Action = Type({ 150 | SetCountry: [isMaybe], 151 | Search: [search.Action], 152 | SearchResultOk: [Array], 153 | SearchResultErr: [String] 154 | }); 155 | 156 | const update = Action.caseOn({ 157 | 158 | SetCountry: (str,model) => ( 159 | noFx( assoc('message', (str.isNothing() ? Maybe(initMessage) : model.message), 160 | assoc('country', str, model) 161 | )) 162 | ), 163 | 164 | Search: (action,model) => { 165 | const [s,tasks] = search.update(action, model.search); 166 | return [ 167 | assoc('search', s, model), 168 | map( (t) => t.bimap( errorOr(Action.SearchResultErr), Action.SearchResultOk), 169 | tasks 170 | ) 171 | ]; 172 | }, 173 | 174 | SearchResultOk: (results, model) => { 175 | const count = results.length; 176 | const [s,_] = search.update(search.Action.RefreshMenu(results), model.search); 177 | return noFx(assoc('message', Maybe.Just(`${count} postal codes found.`), 178 | assoc('search', s, model) 179 | )); 180 | }, 181 | 182 | SearchResultErr: (message, model) => { 183 | const [s,_] = search.update(search.Action.ClearMenu(), model.search); 184 | return noFx(assoc('message', Maybe.Just(message), 185 | assoc('search', s, model) 186 | )); 187 | } 188 | 189 | }); 190 | 191 | // view 192 | 193 | const view = curry( ({action$}, model) => ( 194 | h('div#app', [ 195 | h('h1', 'Postal codes autocomplete example'), 196 | h('h2', headerMessage(model)), 197 | h('div.country', [ 198 | h('label', {attrs: {'for': 'country'}}, 'Country'), 199 | countryMenuView(action$, ['', 'DE','ES','FR','US']) 200 | ]), 201 | search.view( 202 | { action$: forwardTo(action$, Action.Search), 203 | query: query(model), 204 | delay: 500 205 | }, 206 | model.search 207 | ) 208 | ]) 209 | )); 210 | 211 | 212 | const countryMenuView = (action$, codes) => ( 213 | h('select', { 214 | on: { 215 | change: compose(action$, Action.SetCountry, emptyToNothing, targetValue) 216 | } 217 | }, 218 | map( (code) => h('option',code) , codes) 219 | ) 220 | ); 221 | 222 | // note: extra exports for testing 223 | 224 | export default {init, update, Action, view, search, searchMenu, query} 225 | -------------------------------------------------------------------------------- /examples/autocomplete/js/test-unit-autocomplete.js: -------------------------------------------------------------------------------- 1 | import test from 'tape' 2 | import flyd from 'flyd' 3 | import Future from 'ramda-fantasy/src/Future' 4 | import Maybe from 'ramda-fantasy/src/Maybe' 5 | import prop from 'ramda/src/prop' 6 | import map from 'ramda/src/map' 7 | import compose from 'ramda/src/compose' 8 | 9 | import menu from './menu' 10 | import autocomplete from './autocomplete' 11 | 12 | const identity = (x) => x 13 | const T = (_) => true 14 | 15 | 16 | const throwOr = (fn) => { 17 | return (x) => { 18 | if (x instanceof Error) throw x; 19 | return fn(x); 20 | } 21 | } 22 | 23 | const mockTaskCalls = (rets,parse=identity,guard=T) => { 24 | let i = -1; 25 | return (str,model) => { 26 | return map(parse, new Future( (rej, res) => { 27 | i = i+1; 28 | if (i > rets.length-1){ 29 | rej(new Error('Too many calls')); 30 | } else { 31 | if (guard(str,model)) { 32 | res(rets[i]) ; 33 | } else { 34 | rej(rets[i]) ; 35 | } 36 | } 37 | })); 38 | } 39 | } 40 | 41 | const start = (action$, snapshots, subj, init) => { 42 | 43 | // note this mimics the app action flow above the autocomplete component 44 | const refreshMenu = compose(action$, subj.Action.RefreshMenu) 45 | const clearMenu = compose(action$, subj.Action.ClearMenu) 46 | 47 | const state$ = flyd.map( (act) => { 48 | const [s1,tasks] = subj.update(act,state$()); 49 | tasks.map((t) => t.fork(throwOr(clearMenu), refreshMenu)); 50 | return s1; 51 | }, action$); 52 | flyd.on( (s) => { 53 | console.log(s); 54 | snapshots.push(s); 55 | }, state$); 56 | 57 | state$(init); 58 | } 59 | 60 | test('autocomplete hide-menu action', (assert) => { 61 | 62 | const subjMenu = menu({view: identity, init: identity}, identity); 63 | const subj = autocomplete( subjMenu ); 64 | const query = mockTaskCalls([["world"]]); 65 | 66 | const action$ = flyd.stream(); 67 | const snapshots = []; 68 | start(action$, snapshots, subj, subj.init() ); 69 | 70 | action$( subj.Action.Input(query, Maybe('hello')) ); 71 | action$( subj.Action.HideMenu() ); 72 | 73 | assert.equal(snapshots.length, 4, "four state changes (including initial)"); 74 | assert.equal(snapshots[1].isEditing, true, " at 1: is editing"); 75 | assert.equal(snapshots[3].isEditing, false, " at 3: is not editing"); 76 | 77 | assert.end(); 78 | }); 79 | 80 | test('autocomplete input action, with guard failing', (assert) => { 81 | 82 | const subjMenu = menu({view: identity, init: identity}, identity); 83 | const subj = autocomplete( subjMenu ); 84 | const guard = (str) => str.getOrElse('').length >= 3 85 | const query = mockTaskCalls(["high"],identity,guard); 86 | 87 | const action$ = flyd.stream(); 88 | const snapshots = []; 89 | start(action$, snapshots, subj, subj.init() ); 90 | 91 | action$( subj.Action.Input(query, Maybe('hi')) ); 92 | 93 | assert.equal(snapshots.length, 3, "three state changes (including initial)"); 94 | assert.ok(snapshots[1].value.equals(Maybe('hi')), "value changed to input value"); 95 | assert.equal(snapshots[1].menu.items.length, 0, "menu not populated"); 96 | assert.equal(snapshots[2].menu.items.length, 0, "menu not populated after query"); 97 | 98 | assert.end(); 99 | }); 100 | 101 | test('autocomplete input action, with guard passing', (assert) => { 102 | 103 | const subjMenu = menu({view: identity, init: identity}, identity); 104 | const subj = autocomplete( subjMenu ) ; 105 | const guard = (str) => str.getOrElse('').length >= 3 106 | const query = mockTaskCalls([["hum","humor","human"]],identity,guard); 107 | 108 | const action$ = flyd.stream(); 109 | const snapshots = []; 110 | start(action$, snapshots, subj, subj.init() ); 111 | 112 | action$( subj.Action.Input(query, Maybe('hum')) ); 113 | 114 | assert.equal(snapshots.length, 3, 115 | "three state changes (including initial)"); 116 | 117 | assert.ok(snapshots[1].value.equals(Maybe('hum')), 118 | " at 1: value changed to input value"); 119 | assert.equal(snapshots[1].menu.items.length, 0, 120 | " at 1: menu not populated"); 121 | 122 | assert.ok(snapshots[2].value.equals(Maybe('hum')), 123 | " at 2: value equals input value"); 124 | assert.equal(snapshots[2].isEditing, true, 125 | " at 2: is editing"); 126 | assert.deepEqual(snapshots[2].menu.items, ["hum","humor","human"], 127 | " at 2: menu items populated"); 128 | 129 | assert.end(); 130 | 131 | }); 132 | 133 | test('autocomplete input action, multiple transitions', (assert) => { 134 | 135 | const calls = ['', 136 | '', 137 | ["hum","humor","human","humid"], 138 | ["humor"], 139 | [], 140 | ["home"], 141 | ["home","hominid"], 142 | '' 143 | ]; 144 | 145 | const subjMenu = menu({view: identity, init: identity}, identity); 146 | const subj = autocomplete( subjMenu ); 147 | const guard = (str) => str.getOrElse('').length >= 3 148 | const query = mockTaskCalls(calls,identity,guard); 149 | 150 | const action$ = flyd.stream(); 151 | const snapshots = []; 152 | start(action$, snapshots, subj, subj.init() ); 153 | 154 | action$( subj.Action.Input(query, Maybe('h')) ); 155 | action$( subj.Action.Input(query, Maybe('hu')) ); 156 | action$( subj.Action.Input(query, Maybe('hum')) ); 157 | action$( subj.Action.Input(query, Maybe('humo')) ); 158 | action$( subj.Action.Input(query, Maybe('hume')) ); 159 | action$( subj.Action.Input(query, Maybe('home')) ); 160 | action$( subj.Action.Input(query, Maybe('hom')) ); 161 | action$( subj.Action.Input(query, Maybe.Nothing()) ); 162 | 163 | const exp = 1 + (2 + 2 + 2 + 2 + 2 + 2 + 2 + 2) ; 164 | assert.equal(snapshots.length, exp, 165 | "" + exp + " state changes (including initial)"); 166 | 167 | for (let i=0;i { 184 | 185 | const calls = [[{value: "hum"},{value: "humor"},{value: "human"}]]; 186 | 187 | const subjMenu = menu({view: identity, init: identity}, identity); 188 | const subj = autocomplete( subjMenu ); 189 | const parse = map(prop('value')); 190 | const query = mockTaskCalls(calls,parse,T) ; 191 | 192 | const action$ = flyd.stream(); 193 | const snapshots = []; 194 | start(action$, snapshots, subj, subj.init() ); 195 | 196 | action$( subj.Action.Input(query, Maybe('hum')) ); 197 | 198 | assert.equal(snapshots.length, 3, 199 | "three state changes (including initial)"); 200 | 201 | assert.ok(snapshots[2].value.equals(Maybe('hum')), 202 | " at 2: value equals input value"); 203 | assert.deepEqual(snapshots[2].menu.items, parse(calls[0]), 204 | " at 2: menu items populated"); 205 | 206 | assert.end(); 207 | }); 208 | 209 | -------------------------------------------------------------------------------- /examples/autocomplete/config.js: -------------------------------------------------------------------------------- 1 | System.config({ 2 | baseURL: "/", 3 | defaultJSExtensions: true, 4 | transpiler: "babel", 5 | babelOptions: { 6 | "optional": [ 7 | "runtime", 8 | "optimisation.modules.system" 9 | ] 10 | }, 11 | paths: { 12 | "helpers/*": "../../helpers/*.js", 13 | "github:*": "jspm_packages/github/*", 14 | "npm:*": "jspm_packages/npm/*" 15 | }, 16 | 17 | map: { 18 | "babel": "npm:babel-core@5.8.23", 19 | "babel-runtime": "npm:babel-runtime@5.8.20", 20 | "core-js": "npm:core-js@1.1.3", 21 | "flyd": "npm:flyd@0.1.14", 22 | "flyd-aftersilence": "npm:flyd-aftersilence@0.1.0", 23 | "flyd-forwardto": "npm:flyd-forwardto@0.0.2", 24 | "ramda": "npm:ramda@0.17.1", 25 | "ramda-fantasy": "npm:ramda-fantasy@0.4.0", 26 | "snabbdom": "npm:snabbdom@0.2.6", 27 | "source-map-support": "npm:source-map-support@0.3.2", 28 | "tape": "npm:tape@4.2.0", 29 | "union-type": "npm:union-type@0.1.6", 30 | "github:jspm/nodelibs-assert@0.1.0": { 31 | "assert": "npm:assert@1.3.0" 32 | }, 33 | "github:jspm/nodelibs-buffer@0.1.0": { 34 | "buffer": "npm:buffer@3.4.3" 35 | }, 36 | "github:jspm/nodelibs-events@0.1.1": { 37 | "events": "npm:events@1.0.2" 38 | }, 39 | "github:jspm/nodelibs-http@1.7.1": { 40 | "Base64": "npm:Base64@0.2.1", 41 | "events": "github:jspm/nodelibs-events@0.1.1", 42 | "inherits": "npm:inherits@2.0.1", 43 | "stream": "github:jspm/nodelibs-stream@0.1.0", 44 | "url": "github:jspm/nodelibs-url@0.1.0", 45 | "util": "github:jspm/nodelibs-util@0.1.0" 46 | }, 47 | "github:jspm/nodelibs-path@0.1.0": { 48 | "path-browserify": "npm:path-browserify@0.0.0" 49 | }, 50 | "github:jspm/nodelibs-process@0.1.1": { 51 | "process": "npm:process@0.10.1" 52 | }, 53 | "github:jspm/nodelibs-querystring@0.1.0": { 54 | "querystring": "npm:querystring@0.2.0" 55 | }, 56 | "github:jspm/nodelibs-stream@0.1.0": { 57 | "stream-browserify": "npm:stream-browserify@1.0.0" 58 | }, 59 | "github:jspm/nodelibs-url@0.1.0": { 60 | "url": "npm:url@0.10.3" 61 | }, 62 | "github:jspm/nodelibs-util@0.1.0": { 63 | "util": "npm:util@0.10.3" 64 | }, 65 | "npm:amdefine@1.0.0": { 66 | "fs": "github:jspm/nodelibs-fs@0.1.2", 67 | "module": "github:jspm/nodelibs-module@0.1.0", 68 | "path": "github:jspm/nodelibs-path@0.1.0", 69 | "process": "github:jspm/nodelibs-process@0.1.1" 70 | }, 71 | "npm:assert@1.3.0": { 72 | "util": "npm:util@0.10.3" 73 | }, 74 | "npm:babel-runtime@5.8.20": { 75 | "process": "github:jspm/nodelibs-process@0.1.1" 76 | }, 77 | "npm:brace-expansion@1.1.0": { 78 | "balanced-match": "npm:balanced-match@0.2.0", 79 | "concat-map": "npm:concat-map@0.0.1" 80 | }, 81 | "npm:buffer@3.4.3": { 82 | "base64-js": "npm:base64-js@0.0.8", 83 | "ieee754": "npm:ieee754@1.1.6", 84 | "is-array": "npm:is-array@1.0.1" 85 | }, 86 | "npm:core-js@1.1.3": { 87 | "fs": "github:jspm/nodelibs-fs@0.1.2", 88 | "process": "github:jspm/nodelibs-process@0.1.1", 89 | "systemjs-json": "github:systemjs/plugin-json@0.1.0" 90 | }, 91 | "npm:core-util-is@1.0.1": { 92 | "buffer": "github:jspm/nodelibs-buffer@0.1.0" 93 | }, 94 | "npm:flyd-aftersilence@0.1.0": { 95 | "flyd": "npm:flyd@0.1.14" 96 | }, 97 | "npm:flyd-forwardto@0.0.2": { 98 | "flyd": "npm:flyd@0.1.14" 99 | }, 100 | "npm:flyd@0.1.14": { 101 | "ramda": "npm:ramda@0.14.0" 102 | }, 103 | "npm:glob@5.0.14": { 104 | "assert": "github:jspm/nodelibs-assert@0.1.0", 105 | "events": "github:jspm/nodelibs-events@0.1.1", 106 | "fs": "github:jspm/nodelibs-fs@0.1.2", 107 | "inflight": "npm:inflight@1.0.4", 108 | "inherits": "npm:inherits@2.0.1", 109 | "minimatch": "npm:minimatch@2.0.10", 110 | "once": "npm:once@1.3.2", 111 | "path": "github:jspm/nodelibs-path@0.1.0", 112 | "path-is-absolute": "npm:path-is-absolute@1.0.0", 113 | "process": "github:jspm/nodelibs-process@0.1.1", 114 | "util": "github:jspm/nodelibs-util@0.1.0" 115 | }, 116 | "npm:has@1.0.1": { 117 | "function-bind": "npm:function-bind@1.0.2" 118 | }, 119 | "npm:inflight@1.0.4": { 120 | "once": "npm:once@1.3.2", 121 | "process": "github:jspm/nodelibs-process@0.1.1", 122 | "wrappy": "npm:wrappy@1.0.1" 123 | }, 124 | "npm:inherits@2.0.1": { 125 | "util": "github:jspm/nodelibs-util@0.1.0" 126 | }, 127 | "npm:minimatch@2.0.10": { 128 | "brace-expansion": "npm:brace-expansion@1.1.0", 129 | "path": "github:jspm/nodelibs-path@0.1.0" 130 | }, 131 | "npm:once@1.3.2": { 132 | "wrappy": "npm:wrappy@1.0.1" 133 | }, 134 | "npm:path-browserify@0.0.0": { 135 | "process": "github:jspm/nodelibs-process@0.1.1" 136 | }, 137 | "npm:path-is-absolute@1.0.0": { 138 | "process": "github:jspm/nodelibs-process@0.1.1" 139 | }, 140 | "npm:punycode@1.3.2": { 141 | "process": "github:jspm/nodelibs-process@0.1.1" 142 | }, 143 | "npm:ramda-fantasy@0.4.0": { 144 | "ramda": "npm:ramda@0.17.1" 145 | }, 146 | "npm:ramda@0.14.0": { 147 | "process": "github:jspm/nodelibs-process@0.1.1" 148 | }, 149 | "npm:ramda@0.15.1": { 150 | "process": "github:jspm/nodelibs-process@0.1.1" 151 | }, 152 | "npm:ramda@0.17.1": { 153 | "process": "github:jspm/nodelibs-process@0.1.1" 154 | }, 155 | "npm:readable-stream@1.1.13": { 156 | "buffer": "github:jspm/nodelibs-buffer@0.1.0", 157 | "core-util-is": "npm:core-util-is@1.0.1", 158 | "events": "github:jspm/nodelibs-events@0.1.1", 159 | "inherits": "npm:inherits@2.0.1", 160 | "isarray": "npm:isarray@0.0.1", 161 | "process": "github:jspm/nodelibs-process@0.1.1", 162 | "stream-browserify": "npm:stream-browserify@1.0.0", 163 | "string_decoder": "npm:string_decoder@0.10.31" 164 | }, 165 | "npm:resumer@0.0.0": { 166 | "process": "github:jspm/nodelibs-process@0.1.1", 167 | "through": "npm:through@2.3.8" 168 | }, 169 | "npm:snabbdom@0.2.6": { 170 | "process": "github:jspm/nodelibs-process@0.1.1" 171 | }, 172 | "npm:source-map-support@0.3.2": { 173 | "assert": "github:jspm/nodelibs-assert@0.1.0", 174 | "buffer": "github:jspm/nodelibs-buffer@0.1.0", 175 | "child_process": "github:jspm/nodelibs-child_process@0.1.0", 176 | "fs": "github:jspm/nodelibs-fs@0.1.2", 177 | "http": "github:jspm/nodelibs-http@1.7.1", 178 | "path": "github:jspm/nodelibs-path@0.1.0", 179 | "process": "github:jspm/nodelibs-process@0.1.1", 180 | "querystring": "github:jspm/nodelibs-querystring@0.1.0", 181 | "source-map": "npm:source-map@0.1.32" 182 | }, 183 | "npm:source-map@0.1.32": { 184 | "amdefine": "npm:amdefine@1.0.0", 185 | "fs": "github:jspm/nodelibs-fs@0.1.2", 186 | "path": "github:jspm/nodelibs-path@0.1.0", 187 | "process": "github:jspm/nodelibs-process@0.1.1" 188 | }, 189 | "npm:stream-browserify@1.0.0": { 190 | "events": "github:jspm/nodelibs-events@0.1.1", 191 | "inherits": "npm:inherits@2.0.1", 192 | "readable-stream": "npm:readable-stream@1.1.13" 193 | }, 194 | "npm:string_decoder@0.10.31": { 195 | "buffer": "github:jspm/nodelibs-buffer@0.1.0" 196 | }, 197 | "npm:tape@4.2.0": { 198 | "deep-equal": "npm:deep-equal@1.0.1", 199 | "defined": "npm:defined@0.0.0", 200 | "events": "github:jspm/nodelibs-events@0.1.1", 201 | "fs": "github:jspm/nodelibs-fs@0.1.2", 202 | "function-bind": "npm:function-bind@1.0.2", 203 | "glob": "npm:glob@5.0.14", 204 | "has": "npm:has@1.0.1", 205 | "inherits": "npm:inherits@2.0.1", 206 | "object-inspect": "npm:object-inspect@1.0.2", 207 | "path": "github:jspm/nodelibs-path@0.1.0", 208 | "process": "github:jspm/nodelibs-process@0.1.1", 209 | "resumer": "npm:resumer@0.0.0", 210 | "through": "npm:through@2.3.8" 211 | }, 212 | "npm:through@2.3.8": { 213 | "process": "github:jspm/nodelibs-process@0.1.1", 214 | "stream": "github:jspm/nodelibs-stream@0.1.0" 215 | }, 216 | "npm:union-type@0.1.6": { 217 | "ramda": "npm:ramda@0.15.1" 218 | }, 219 | "npm:url@0.10.3": { 220 | "assert": "github:jspm/nodelibs-assert@0.1.0", 221 | "punycode": "npm:punycode@1.3.2", 222 | "querystring": "npm:querystring@0.2.0", 223 | "util": "github:jspm/nodelibs-util@0.1.0" 224 | }, 225 | "npm:util@0.10.3": { 226 | "inherits": "npm:inherits@2.0.1", 227 | "process": "github:jspm/nodelibs-process@0.1.1" 228 | } 229 | } 230 | }); 231 | -------------------------------------------------------------------------------- /examples/file-uploader/js/test.js: -------------------------------------------------------------------------------- 1 | const tape = require('tape') 2 | , test = tape.test; 3 | 4 | const compose = require('ramda/src/compose'); 5 | const curry = require('ramda/src/curry'); 6 | const map = require('ramda/src/map'); 7 | const all = require('ramda/src/all'); 8 | const zip = require('ramda/src/zip'); 9 | 10 | const Future = require('ramda-fantasy/src/Future'); 11 | 12 | const upload = require('./upload'); 13 | const uploadList = require('./list'); 14 | const uploader = require('./uploader'); 15 | 16 | const noop = function(){} 17 | 18 | /******************************************************************************/ 19 | const progressUpload = (total, loaded, data) => { 20 | return upload.update( upload.Action.Progress(noop, {total,loaded}), data ); 21 | } 22 | 23 | const finishUpload = upload.update(upload.Action.Uploaded()); 24 | 25 | test('upload component, single file', (assert) => { 26 | 27 | let subject = upload.init([{ name: 'GreatAmericanNovel.pdf', 28 | type: 'application/pdf'} 29 | ]); 30 | assert.equal(subject.status, 'initial', 'initial status'); 31 | assert.equal(subject.title, 'GreatAmericanNovel.pdf', 'title'); 32 | 33 | subject = progressUpload(100, 1, subject); 34 | assert.equal(subject.status, 'uploading', 35 | 'uploading status after progress action 1'); 36 | assert.equal(subject.progress.loaded, 1, 'loaded'); 37 | assert.equal(subject.progress.total, 100, 'total'); 38 | 39 | subject = progressUpload(100, 65.4321, subject); 40 | assert.equal(subject.status, 'uploading', 41 | 'uploading status after progress action 65.4321'); 42 | assert.equal(subject.progress.loaded, 65.4321, 'loaded non-integer'); 43 | assert.equal(subject.progress.total, 100, 'total'); 44 | 45 | subject = progressUpload(100, 100, subject); 46 | assert.equal(subject.status, 'processing', 47 | 'processing status after progress action 100'); 48 | 49 | subject = finishUpload(subject); 50 | assert.equal(subject.status, 'uploaded', 51 | 'uploaded status after uploaded action'); 52 | 53 | assert.end(); 54 | }); 55 | 56 | test('upload component, multiple files', (assert) => { 57 | 58 | let subject = upload.init([ 59 | { name: 'chapter1.txt', type: 'text/plain' }, 60 | { name: 'chapter2.txt', type: 'text/plain' }, 61 | { name: 'chapter3.txt', type: 'text/plain' } 62 | ]); 63 | 64 | assert.equal(subject.title, '(3 files)', 'title indicates 3 files'); 65 | 66 | assert.end(); 67 | }); 68 | 69 | 70 | /******************************************************************************/ 71 | 72 | const dummyUploader = curry( (total, steps, abort, final, files) => { 73 | return new Future( (rej, res) => { 74 | 75 | const progress = (loaded, delay) => { 76 | delay = delay + ( Math.random() * 2000 ); 77 | setTimeout( 78 | () => res(uploader.Result.Progress(abort, {total,loaded})), 79 | delay 80 | ); 81 | return delay; 82 | } 83 | 84 | const delay = steps.reduce( (delay,step) => progress(step,delay), 0); 85 | 86 | if (final){ 87 | setTimeout( 88 | () => res(final({})), 89 | delay + ( Math.random() * 2000 ) 90 | ); 91 | } 92 | 93 | }); 94 | }); 95 | 96 | 97 | test('upload list component, add file list, progress', (assert) => { 98 | assert.plan(4 + 4); 99 | 100 | let subject = uploadList.init(), tasks, snapshots = []; 101 | 102 | const update = (action) => { 103 | [subject, tasks] = uploadList.update(action, subject); 104 | map((a) => a.fork((err) => {console.error(err); throw err}, update), tasks); 105 | snapshots.push(subject); 106 | console.log(subject); 107 | } 108 | 109 | const steps = [1,3,10,19,100]; 110 | const up = dummyUploader(100, steps, noop, null); 111 | 112 | update( 113 | uploadList.Action.Create(up, [ 114 | { name: 'chapter1.txt', type: 'text/plain' }, 115 | { name: 'chapter2.txt', type: 'text/plain' }, 116 | { name: 'chapter3.txt', type: 'text/plain' } 117 | ]) 118 | ); 119 | 120 | assert.equal(subject.length, 1, 'upload list has 1 file list'); 121 | assert.equal(subject[0].files.length, 3, '3 files in group'); 122 | assert.equal(subject[0].title, '(3 files)', 'title indicates 3 files'); 123 | assert.equal(subject[0].status, 'initial', 'initial status'); 124 | 125 | setTimeout( () => { 126 | 127 | assert.equal(snapshots.length, steps.length + 1, 128 | '1 initial transition + 1 per expected progress step'); 129 | 130 | assert.ok( all( (s) => s[0].status == 'uploading', snapshots.slice(1,-1)), 131 | 'uploading status after each progress step except the last'); 132 | 133 | assert.ok( all( (s) => s[0].status == 'processing', snapshots.slice(-1)), 134 | 'processing status after loaded 100% of total (last step)'); 135 | 136 | assert.ok( all( ([step,s]) => s[0].progress.loaded === step, 137 | zip(steps, snapshots.slice(1))), 138 | 'expected loading progress at each progress step'); 139 | }, 140 | (steps.length)*2000 // max time for steps to complete 141 | ); 142 | 143 | }); 144 | 145 | test('upload list component, add two file lists, progress independently', (assert) => { 146 | assert.plan(1); 147 | 148 | let subject = uploadList.init(), tasks, snapshots = []; 149 | 150 | const update = (action) => { 151 | [subject, tasks] = uploadList.update(action, subject); 152 | map((a) => a.fork((err) => {console.error(err); throw err}, update), tasks); 153 | snapshots.push(subject); 154 | console.log(subject); 155 | } 156 | 157 | const trigger = (up, files, delay) => { 158 | setTimeout( () => { 159 | update( 160 | uploadList.Action.Create(up, files) 161 | ); 162 | }, 163 | delay 164 | ); 165 | } 166 | 167 | const steps1 = [1, 23,34,45,56,67,78,99,100]; 168 | const steps2 = [22,34,46,59,78,100]; 169 | 170 | const up1 = dummyUploader(100, steps1, noop, uploader.Result.OK); 171 | const up2 = dummyUploader(100, steps2, noop, uploader.Result.OK); 172 | 173 | trigger( up1, 174 | [ {name: 'first.png', type: 'image/png'} ], 175 | Math.random() * 2000 ); 176 | 177 | trigger( up2, 178 | [ {name: 'second.png', type: 'image/png'} ], 179 | Math.random() * 2000 ); 180 | 181 | setTimeout( () => { 182 | const exp = 2 + steps1.length + steps2.length + 2; 183 | assert.equal(snapshots.length, exp, 184 | '' + exp + ' == ' + 185 | '1 initial transition per file list + ' + 186 | '1 per expected progress step + ' + 187 | '1 final transition per file list'); 188 | 189 | }, 190 | (4 + Math.max(steps1.length,steps2.length)) * 2000 // max time for steps to complete 191 | ); 192 | 193 | }); 194 | 195 | test('upload list component, add file list, result ok', (assert) => { 196 | assert.plan(2); 197 | 198 | let subject = uploadList.init(), tasks, snapshots = []; 199 | 200 | const update = (action) => { 201 | [subject, tasks] = uploadList.update(action, subject); 202 | map((a) => a.fork((err) => {console.error(err); throw err}, update), tasks); 203 | snapshots.push(subject); 204 | console.log(subject); 205 | } 206 | 207 | const steps = []; 208 | const up = dummyUploader(100, steps, noop, uploader.Result.OK); 209 | 210 | update( 211 | uploadList.Action.Create(up, [ 212 | { name: 'chapter1.txt', type: 'text/plain' }, 213 | { name: 'chapter2.txt', type: 'text/plain' }, 214 | { name: 'chapter3.txt', type: 'text/plain' } 215 | ]) 216 | ); 217 | 218 | setTimeout( () => { 219 | 220 | assert.equal(snapshots.length, 1 + 1, 221 | '1 initial transition + 1 for result'); 222 | 223 | assert.equal(snapshots[1][0].status, 'uploaded', 224 | 'status uploaded after result OK'); 225 | 226 | }, 227 | 2000 228 | ); 229 | 230 | }); 231 | 232 | test('upload list component, add file list, result error', (assert) => { 233 | assert.plan(2); 234 | 235 | let subject = uploadList.init(), tasks, snapshots = []; 236 | 237 | const update = (action) => { 238 | [subject, tasks] = uploadList.update(action, subject); 239 | map((a) => a.fork((err) => {console.error(err); throw err}, update), tasks); 240 | snapshots.push(subject); 241 | console.log(subject); 242 | } 243 | 244 | const steps = []; 245 | const up = dummyUploader(100, steps, noop, uploader.Result.Error); 246 | 247 | update( 248 | uploadList.Action.Create(up, [ 249 | { name: 'chapter1.txt', type: 'text/plain' }, 250 | { name: 'chapter2.txt', type: 'text/plain' }, 251 | { name: 'chapter3.txt', type: 'text/plain' } 252 | ]) 253 | ); 254 | 255 | setTimeout( () => { 256 | 257 | assert.equal(snapshots.length, 1 + 1, 258 | '1 initial transition + 1 for result'); 259 | 260 | assert.equal(snapshots[1][0].status, 'error', 261 | 'status uploaded after result error'); 262 | 263 | }, 264 | 2000 265 | ); 266 | 267 | }); 268 | 269 | 270 | 271 | 272 | --------------------------------------------------------------------------------