├── 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 |
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 | 
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 | 
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 | 
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 |
--------------------------------------------------------------------------------