├── .babelrc ├── .editorconfig ├── .eslintrc.json ├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── book.json ├── dist ├── trux.js ├── trux.js.map └── trux.min.js ├── docs ├── .gitignore ├── README.md ├── SUMMARY.md ├── about │ ├── differences-to-backbone.md │ ├── differences-to-redux.md │ ├── examples.md │ └── origins.md ├── api │ ├── README.md │ ├── collection │ │ ├── README.md │ │ ├── methods.md │ │ └── properties.md │ ├── model │ │ ├── README.md │ │ ├── methods.md │ │ └── properties.md │ └── store │ │ ├── README.md │ │ ├── collection.md │ │ ├── methods.md │ │ └── properties.md ├── book.css ├── stores │ ├── README.md │ ├── collection.md │ ├── model.md │ └── store.md └── usage │ ├── connectors-nodes.md │ ├── extending.md │ ├── graphql.md │ ├── optimism-vs-pessimism.md │ ├── react.md │ ├── rest.md │ ├── stores-module.md │ ├── structure.md │ └── vue.md ├── examples ├── .gitkeep ├── basics │ ├── README.md │ ├── connecting │ │ ├── README.md │ │ └── index.html │ ├── counter │ │ ├── README.md │ │ └── index.html │ └── disconnecting │ │ ├── README.md │ │ └── index.html ├── react │ ├── README.md │ └── todomvc │ │ ├── .eslintrc │ │ ├── .gitignore │ │ ├── README.md │ │ ├── package.json │ │ ├── public │ │ ├── favicon.ico │ │ └── index.html │ │ ├── src │ │ ├── components │ │ │ ├── App.js │ │ │ ├── Info.js │ │ │ ├── connectors │ │ │ │ ├── TodoApp.js │ │ │ │ └── index.js │ │ │ └── nodes │ │ │ │ └── TodoApp │ │ │ │ ├── Footer │ │ │ │ ├── Clear.js │ │ │ │ └── index.js │ │ │ │ ├── Header │ │ │ │ ├── New.js │ │ │ │ └── index.js │ │ │ │ ├── Main │ │ │ │ ├── Edit.js │ │ │ │ ├── Item.js │ │ │ │ ├── List.js │ │ │ │ └── index.js │ │ │ │ └── index.js │ │ ├── index.js │ │ ├── stores │ │ │ ├── collections │ │ │ │ ├── Todos.js │ │ │ │ └── index.js │ │ │ ├── index.js │ │ │ └── models │ │ │ │ ├── Todo.js │ │ │ │ └── index.js │ │ └── utils │ │ │ ├── index.js │ │ │ └── keys.js │ │ └── yarn.lock └── vue │ └── README.md ├── package.json ├── src ├── Collection.js ├── Model.js ├── Store.js └── index.js ├── test ├── collection.spec.js ├── model.spec.js ├── server.js └── store.spec.js ├── webpack.config.js └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015"], 3 | "plugins": ["babel-plugin-add-module-exports"] 4 | } 5 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: http://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | # Unix-style newlines with a newline ending every file 7 | [*] 8 | end_of_line = lf 9 | insert_final_newline = true 10 | 11 | # Matches multiple files with brace expansion notation 12 | # Set default charset 13 | [*.{js,py}] 14 | charset = utf-8 15 | 16 | # 2 space indentation 17 | [*.js] 18 | indent_style = space 19 | indent_size = 2 20 | 21 | [*.md] 22 | indent_style = space 23 | indent_size = 2 24 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es6": true 5 | }, 6 | "extends": "eslint:recommended", 7 | "parserOptions": { 8 | "sourceType": "module" 9 | }, 10 | "rules": { 11 | "indent": [ 12 | "error", 13 | 2 14 | ], 15 | "linebreak-style": [ 16 | "error", 17 | "unix" 18 | ], 19 | "quotes": [ 20 | "error", 21 | "single" 22 | ], 23 | "semi": [ 24 | "error", 25 | "always" 26 | ], 27 | "no-console": 0 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled source # 2 | ################### 3 | *.com 4 | *.class 5 | *.dll 6 | *.exe 7 | *.o 8 | *.so 9 | 10 | # Packages # 11 | ############ 12 | # it's better to unpack these files and commit the raw source 13 | # git has its own built in compression methods 14 | *.7z 15 | *.dmg 16 | *.gz 17 | *.iso 18 | *.jar 19 | *.rar 20 | *.tar 21 | *.zip 22 | 23 | # Logs and databases # 24 | ###################### 25 | *.log 26 | *.sql 27 | *.sqlite 28 | 29 | # OS generated files # 30 | ###################### 31 | .DS_Store 32 | .DS_Store? 33 | ._* 34 | .Spotlight-V100 35 | .Trashes 36 | ehthumbs.db 37 | Thumbs.db 38 | 39 | node_modules 40 | coverage 41 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "node" 4 | script: 5 | - yarn run test 6 | - yarn run lint 7 | - yarn run coverage:generate 8 | - yarn run coverage:publish 9 | branches: 10 | only: 11 | - master 12 | - dev 13 | cache: 14 | yarn: true 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016 Rohan Deshpande 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 7 | of the Software, and to permit persons to whom the Software is furnished to do 8 | so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # [Trux](https://github.com/rohan-deshpande/trux) 2 | 3 | ### `API ⇆ Trux ➝ UI` 4 | 5 | Unidirectional data layer for reactive user interfaces. 6 | 7 | [![Build Status](https://travis-ci.org/rohan-deshpande/trux.svg?branch=master)](https://travis-ci.org/rohan-deshpande/trux) 8 | [![Coverage Status](https://coveralls.io/repos/github/rohan-deshpande/trux/badge.svg?branch=master&v=3e8eb2e)](https://coveralls.io/github/rohan-deshpande/trux?branch=master) 9 | [![Dependency Status](https://david-dm.org/rohan-deshpande/trux.svg)](https://david-dm.org/rohan-deshpande/trux) 10 | [![npm version](https://badge.fury.io/js/trux.svg)](https://badge.fury.io/js/trux) 11 | 12 | ## Introduction 13 | 14 | Trux is an easy-to-use, lightweight and effective way of managing mutable data for your client side JavaScript app. 15 | 16 | With its focus placed on enabling the creation of fully customisable bridges between your API and UI, Trux provides convenient and safe ways to mutate data and synchronise these mutations with your components. 17 | 18 | **With Trux, your data stores become the sources of truth for your app's data driven user interfaces.** All you need to do is create some stores, connect components to them and let it do the work. 19 | 20 | While it was designed with [React](https://rohan-deshpande.gitbooks.io/trux/content/usage/react.html) and a REST API in mind, Trux can also be used with other view libraries and API systems such as [Vue](https://rohan-deshpande.gitbooks.io/trux/content/usage/vue.html 21 | ) and [GraphQL](https://rohan-deshpande.gitbooks.io/trux/content/usage/graphql.html). 22 | 23 | Want to learn more? Checkout the [quickstart](#quickstart) guide below or get an in-depth look by reading the [docs](https://rohan-deshpande.gitbooks.io/trux/content/). 24 | 25 | ## Installation 26 | 27 | ```bash 28 | npm i -S trux 29 | ``` 30 | 31 | #### Polyfills 32 | 33 | In order to support older browsers you'll need some polyfills 34 | 35 | * [fetch](https://github.com/github/fetch) 36 | * [Promise](https://github.com/taylorhakes/promise-polyfill) 37 | 38 | ## Quickstart 39 | 40 | In Trux, your client side data is kept in **stores** called **models** or **collections**. You `connect` components to these stores and ask the stores to perform data changes. Your stores can `persist` these changes to their connected components. You can choose to make these updates either **optimistic** or **pessimistic**. 41 | 42 | Here's the basic gist, without considering requests to an API 43 | 44 | ```js 45 | import { Model } from 'trux'; 46 | 47 | // First we're going to create a Counter model with some starting data. 48 | // By extending the Trux Model class, we get all the functionality we need plus we can add custom methods, 49 | // like the increment and decrement methods which mutate the state of the model. 50 | class Counter extends Model { 51 | constructor() { 52 | super({ value: 0 }); 53 | } 54 | 55 | increment() { 56 | this.data.value++; 57 | return this; 58 | } 59 | 60 | decrement() { 61 | this.data.value--; 62 | return this; 63 | } 64 | } 65 | 66 | // Instantiate the store 67 | const store = new Counter(); 68 | 69 | // Now we are going to create a mock component to connect to our store. 70 | // We need to declare a unique truxid and a storeDidUpdate method to receive updates from the store. 71 | const component = { 72 | truxid: 'TICKER', 73 | storeDidUpdate: () => { 74 | console.log(model.data.value); 75 | } 76 | }; 77 | 78 | // connect the component to the store. 79 | store.connect(component); 80 | // call the increment and decrement methods then chain persist to see the new value get logged. 81 | store.increment().persist(); // 1 82 | store.increment().persist(); // 2 83 | store.decrement().persist(); // 1 84 | ``` 85 | -------------------------------------------------------------------------------- /book.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": "./docs", 3 | "plugins": [ "theme-default", "github" ], 4 | "pluginsConfig": { 5 | "fontSettings": { 6 | "theme": "night" 7 | }, 8 | "github": { 9 | "url": "https://github.com/rohan-deshpande/trux" 10 | } 11 | }, 12 | "theme-default": { 13 | "styles": { 14 | "website": "docs/book.css" 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /dist/trux.min.js: -------------------------------------------------------------------------------- 1 | !function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define("trux",[],t):"object"==typeof exports?exports.trux=t():e.trux=t()}(this,function(){return function(e){function t(r){if(n[r])return n[r].exports;var o=n[r]={i:r,l:!1,exports:{}};return e[r].call(o.exports,o,o.exports,t),o.l=!0,o.exports}var n={};return t.m=e,t.c=n,t.i=function(e){return e},t.d=function(e,n,r){t.o(e,n)||Object.defineProperty(e,n,{configurable:!1,enumerable:!0,get:r})},t.n=function(e){var n=e&&e.__esModule?function(){return e.default}:function(){return e};return t.d(n,"a",n),n},t.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},t.p="",t(t.s=4)}([function(e,t,n){"use strict";function r(e,t){if(!(e instanceof t))throw new TypeError("Cannot call a class as a function")}Object.defineProperty(t,"__esModule",{value:!0});var o=function(){function e(e,t){for(var n=0;n1&&void 0!==arguments[1]?arguments[1]:i.DEFAULTS;if(!e)throw new Error("You must provide a url resource to fetch");return(0,i.json)(e,t)}}]),e}();t.default=s,e.exports=t.default}])})},function(e,t,n){"use strict";function r(e){return e&&e.__esModule?e:{default:e}}function o(e,t){if(!(e instanceof t))throw new TypeError("Cannot call a class as a function")}function i(e,t){if(!e)throw new ReferenceError("this hasn't been initialised - super() hasn't been called");return!t||"object"!=typeof t&&"function"!=typeof t?e:t}function s(e,t){if("function"!=typeof t&&null!==t)throw new TypeError("Super expression must either be null or a function, not "+typeof t);e.prototype=Object.create(t&&t.prototype,{constructor:{value:e,enumerable:!1,writable:!0,configurable:!0}}),t&&(Object.setPrototypeOf?Object.setPrototypeOf(e,t):e.__proto__=t)}Object.defineProperty(t,"__esModule",{value:!0});var u="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e},a=function(){function e(e,t){for(var n=0;n0&&void 0!==arguments[0]?arguments[0]:"";return h.default.json(""+this.GET+t,{method:"GET",headers:this.requestHeaders}).then(function(t){return e.wasFetched=!0,e.fill(t.json).persist(),Promise.resolve(t)}).catch(function(t){return e.wasFetched=!1,Promise.reject(t)})}}],[{key:"extend",value:function(e,n){var r=function(e){function t(e){o(this,t);var r=i(this,(t.__proto__||Object.getPrototypeOf(t)).call(this,e));return"function"==typeof n&&n(r),r}return s(t,e),t}(t);if("object"===(void 0===e?"undefined":u(e)))for(var a in e)e.hasOwnProperty(a)&&(r.prototype[a]=e[a]);return r}},{key:"modify",value:function(e){if("object"!==(void 0===e?"undefined":u(e)))throw new TypeError("You must modify Collection with a properties object");for(var n in e)e.hasOwnProperty(n)&&(t.prototype[n]=e[n])}}]),t}(f.default);t.default=d,e.exports=t.default},function(e,t,n){"use strict";function r(e){return e&&e.__esModule?e:{default:e}}function o(e,t){if(!(e instanceof t))throw new TypeError("Cannot call a class as a function")}function i(e,t){if(!e)throw new ReferenceError("this hasn't been initialised - super() hasn't been called");return!t||"object"!=typeof t&&"function"!=typeof t?e:t}function s(e,t){if("function"!=typeof t&&null!==t)throw new TypeError("Super expression must either be null or a function, not "+typeof t);e.prototype=Object.create(t&&t.prototype,{constructor:{value:e,enumerable:!1,writable:!0,configurable:!0}}),t&&(Object.setPrototypeOf?Object.setPrototypeOf(e,t):e.__proto__=t)}Object.defineProperty(t,"__esModule",{value:!0});var u="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e},a=function(){function e(e,t){for(var n=0;n0&&void 0!==arguments[0])||arguments[0])&&this.collection&&this.collection.emitChangeEvent(),this.emitChangeEvent(),this}},{key:"fetch",value:function(){var e=this,t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:"";return h.default.json(""+this.GET+t,{method:"GET",headers:this.requestHeaders}).then(function(t){return e.wasFetched=!0,e.fill(t.json).persist(),Promise.resolve(t)}).catch(function(t){return e.wasFetched=!1,Promise.reject(t)})}},{key:"create",value:function(e){var t=this;return h.default.json(this.POST,{method:"POST",headers:this.requestHeaders,body:e}).then(function(e){return t.wasCreated=!0,t.fill(e.json).persist(),Promise.resolve(e)}).catch(function(e){return t.wasCreated=!1,Promise.reject(e)})}},{key:"update",value:function(){var e=this,t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{},n=t.data||this.data,r=t.method||"PUT",o=t.optimistic||!1,i=t.collection||!0;return o&&this.persist(i),h.default.json(this[r],{method:r,headers:this.requestHeaders,body:n}).then(function(t){return e.wasUpdated=!0,e.fill(t.json).persist(i),Promise.resolve(t)}).catch(function(t){return e.wasUpdated=!1,e.restore().persist(),Promise.reject(t)})}},{key:"destroy",value:function(){var e=this;return h.default.json(this.DELETE,{method:"DELETE",headers:this.requestHeaders}).then(function(t){return e.wasDestroyed=!0,e.purge().close(),Promise.resolve(t)}).catch(function(t){return e.wasDestroyed=!1,e.restore().persist(),Promise.reject(t)})}},{key:"purge",value:function(){return this.data=null,this}},{key:"wasCreated",set:function(e){this._wasCreated=!!e,this._wasCreatedAt=e?this.getUnixTimestamp():this.wasCreatedAt},get:function(){return this._wasCreated}},{key:"wasCreatedAt",get:function(){return this._wasCreatedAt}},{key:"wasUpdated",set:function(e){this._wasUpdated=!!e,this._wasUpdatedAt=e?this.getUnixTimestamp():this.wasUpdatedAt},get:function(){return this._wasUpdated}},{key:"wasUpdatedAt",get:function(){return this._wasUpdatedAt}},{key:"wasDestroyed",set:function(e){this._wasDestroyed=!!e,this._wasDestroyedAt=e?this.getUnixTimestamp():this.wasDestroyedAt},get:function(){return this._wasDestroyed}},{key:"wasDestroyedAt",get:function(){return this._wasDestroyedAt}}],[{key:"extend",value:function(e,n){var r=function(e){function t(e){o(this,t);var r=i(this,(t.__proto__||Object.getPrototypeOf(t)).call(this,e));return"function"==typeof n&&n(r),r}return s(t,e),t}(t);if("object"===(void 0===e?"undefined":u(e)))for(var a in e)e.hasOwnProperty(a)&&(r.prototype[a]=e[a]);return r}},{key:"modify",value:function(e){if("object"!==(void 0===e?"undefined":u(e)))throw new TypeError("You must modify Model with a properties object");for(var n in e)e.hasOwnProperty(n)&&(t.prototype[n]=e[n])}}]),t}(f.default);t.default=d,e.exports=t.default},function(e,t,n){"use strict";function r(e){return e&&e.__esModule?e:{default:e}}Object.defineProperty(t,"__esModule",{value:!0}),t.Collection=t.Model=void 0;var o=n(3),i=r(o),s=n(2),u=r(s);t.Model=i.default,t.Collection=u.default},function(e,t,n){var r;!function(t){"use strict";function o(){}function i(e,t){for(var n=e.length;n--;)if(e[n].listener===t)return n;return-1}function s(e){return function(){return this[e].apply(this,arguments)}}function u(e){return"function"==typeof e||e instanceof RegExp||!(!e||"object"!=typeof e)&&u(e.listener)}var a=o.prototype,c=t.EventEmitter;a.getListeners=function(e){var t,n,r=this._getEvents();if(e instanceof RegExp){t={};for(n in r)r.hasOwnProperty(n)&&e.test(n)&&(t[n]=r[n])}else t=r[e]||(r[e]=[]);return t},a.flattenListeners=function(e){var t,n=[];for(t=0;t { 74 | console.log(model.data.value); 75 | } 76 | }; 77 | 78 | // connect the component to the store. 79 | store.connect(component); 80 | // call the increment and decrement methods then chain persist to see the new value get logged. 81 | store.increment().persist(); // 1 82 | store.increment().persist(); // 2 83 | store.decrement().persist(); // 1 84 | ``` 85 | -------------------------------------------------------------------------------- /docs/SUMMARY.md: -------------------------------------------------------------------------------- 1 | # Summary 2 | 3 | ## About 4 | 5 | * [Summary](/SUMMARY.md) 6 | * [Introduction](README.md) 7 | * [Origins](about/origins.md) 8 | * [Differences to Redux](about/differences-to-redux.md) 9 | * [Differences to Backbone](about/differences-to-backbone.md) 10 | * [Examples](about/examples.md) 11 | 12 | ## Stores 13 | 14 | * [Store](stores/store.md) 15 | * [Model](stores/model.md) 16 | * [Collection](stores/collection.md) 17 | 18 | ## Usage 19 | 20 | * [Extending](usage/extending.md) 21 | * [Stores Module](usage/stores-module.md) 22 | * [Connectors & Nodes](usage/connectors-nodes.md) 23 | * [Optimism vs Pessimism](usage/optimism-vs-pessimism.md) 24 | * [Structure](usage/structure.md) 25 | * [React](usage/react.md) 26 | * [Vue](usage/vue.md) 27 | * [REST](usage/rest.md) 28 | * [GraphQL](usage/graphql.md) 29 | 30 | ## API Reference 31 | 32 | * [Store](api/store/README.md) 33 | * [properties](api/store/properties.md) 34 | * [methods](api/store/methods.md) 35 | * [Model](api/model/README.md) 36 | * [properties](api/model/properties.md) 37 | * [methods](api/model/methods.md) 38 | * [Collection](api/collection/README.md) 39 | * [properties](api/collection/properties.md) 40 | * [methods](api/collection/methods.md) 41 | -------------------------------------------------------------------------------- /docs/about/differences-to-backbone.md: -------------------------------------------------------------------------------- 1 | # Differences to Backbone.js 2 | 3 | > Models? Collections? Isn't this just Backbone? 4 | 5 | When I published the first version of Trux, someone pointed out that it looked similar to [Backbone.js](http://backbonejs.org/). I had actually never used Backbone (Trux was mainly inspired by [Laravel's](https://www.laravel.com) Eloquent), so naturally I went over to check it out and noticed some similarities, but also some key differences. 6 | 7 | Yes, Trux is a multi-store solution ([sort of](/usage/stores-module)) that uses models and collections and asks you to extend these for your own use cases. Its stores are also designed to be linked to their remote equivalents (although this is not a requirement). In regards to these aspects, it is similar to Backbone, however, a core difference is that **Trux was developed from the ground up with component driven architecture in mind**. 8 | 9 | What this means is that it has been built as a data layer to service modern view libraries. To enable this it comes packed with 10 | 11 | * Component connecting & disconnecting to ensure your UI gets updated when needed 12 | * Smarts to restore your data to its previous state when bad things happen 13 | * Built in ways to perform [optimistic and pessimistic updates](/usage/optimisim-vs-pessimism) 14 | * [Fetch API](https://developer.mozilla.org/en/docs/Web/API/Fetch_API) based requests using `promises` rather than AJAX 15 | * A _suggested_ [way](/usage/connectors-nodes.md) to [structure](/usage/structure.md) your app which aligns with component driven architecture 16 | * A modern, ES6 based, more streamlined approach that defers to your view library (React, Vue etc.,) for rendering and routing solutions 17 | 18 | These are what I see as the major differences between Trux and Backbone. There may be others but I think ultimately the focus on a more modern and component driven approach is what sets them apart. Checkout the [examples](/about/examples.md) to get a better understanding of how Trux works. 19 | -------------------------------------------------------------------------------- /docs/about/differences-to-redux.md: -------------------------------------------------------------------------------- 1 | # Differences to Redux 2 | 3 | Trux is quite different to Redux, so much so that it literally breaks all of its [three core principles](http://redux.js.org/docs/introduction/ThreePrinciples.html). Before you spit your ristretto out in disgust, let me say that I really, really like Redux. I think it is an elegant, awesome solution to application state management and **if you are already invested in it, Trux might not be the right choice for you**. 4 | 5 | I do believe however that Redux and immutable data forces developers to rethink solutions to things in a way that may, at times, seem slightly daunting at first. In order to work with it you may need extra libraries, such as [`react-router-redux`](https://github.com/reactjs/react-router-redux) if you wish to use React-Router and [`redux-form`](http://redux-form.com/6.6.3/) to manage the state of your forms with Redux. Asynchronous functionality added on top brings extra complexity with libraries such as [`redux-thunk`](https://github.com/gaearon/redux-thunk) and [`redux-saga`](https://github.com/redux-saga/redux-saga) to consider. There is also a nontrivial amount of boilerplate you need to write. You have to truly **invest** in Redux. 6 | 7 | Trux offers an alternative approach that is geared towards simplicity and speed. You won't need any extra libraries or tools to get it working and conceptually, I feel it is quite easy to grasp. I also feel the investment carries a few less things to manage, learn and keep up to date with. 8 | 9 | ## Multiple stores (sort of) 10 | 11 | In Trux, you will typically have multiple stores for data, albeit [kept in a single module](/usage/stores-module.md). Usually these stores are a representation of remote data in the client side of your app. For example, for a blog, you may have a `User` model, a `Post` model and a `Comment` model. Likewise you may also have a `Users` collection, `Posts` collection and `Comments` collection. These stores could each have various components connected to them. 12 | 13 | **Stores** **are** **still the single source of truth** **for the data driven parts of your app**. However, Trux is fine with self managed state for certain components, such as forms. 14 | 15 | ## Protected mutability 16 | 17 | Trux stores are mutable, but there's a catch - any time you mutate a store, it is expected that a validation occurs in your system to let you know if this change is allowed or not. 18 | 19 | ```js 20 | console.log(User.name) // logs Frodo 21 | 22 | Component.truxid = 'PROFILE'; // set the truxid for the component 23 | Component.connect(User); // connect a component to the User store 24 | 25 | User.name = 'Sam'; 26 | User.update(); // attempt to update the user's name in the remote store 27 | ``` 28 | 29 | It is expected here that the update request would hit some sort of validator on the server. If this validation fails, you will receive an error and Trux will immediately `restore` your model to its previous state. Connected components will re render back to their state before the mutation. 30 | 31 | If you are not working with an external API, you can simply override the default `update` method for your own uses and call a model's `restore` method when something invalid happens. 32 | 33 | ## Internal store changes 34 | 35 | Changes to a model or collection's data should only ever happen through interactions with the store itself. Let's look at a simple `User` model 36 | 37 | ```js 38 | class User extends Model { 39 | constructor(data) { 40 | super(data); 41 | } 42 | 43 | get name() { 44 | return this.data.name; 45 | } 46 | 47 | set name(name) { 48 | if (!name || !name.length) { 49 | throw new Error('You must supply a valid name'); 50 | } 51 | 52 | this.data.name = name; 53 | } 54 | } 55 | ``` 56 | 57 | In this example, you may change the `name` property of a `User` anywhere in your app by calling `User.name = 'new name'` and this will call the internal `set name` method of the model. Notice that you have customisable, context aware ways of ensuring that bad data does get injected into your store. Again, its recommended that your API always perform validation on any mutations as well. 58 | -------------------------------------------------------------------------------- /docs/about/examples.md: -------------------------------------------------------------------------------- 1 | # Examples 2 | 3 | There's more examples to come but for the time being you can check them out [here](https://github.com/rohan-deshpande/trux/tree/master/examples) 4 | 5 | ## Basics 6 | 7 | A few short and simple examples to get the basics of Trux. These examples all use pure JavaScript with ES5 syntax. 8 | 9 | Checkout the [Basics](https://github.com/rohan-deshpande/trux/tree/master/examples/basics) examples: 10 | 11 | ``` 12 | git clone https://github.com/rohan-deshpande/trux.git 13 | cd examples/basics 14 | 15 | open connecting/index.html 16 | ``` 17 | 18 | ## React 19 | 20 | ### TodoMVC 21 | 22 | A [`todomvc`](http://todomvc.com/) implementation written using Trux and bootstrapped with [Create React App](https://github.com/facebookincubator/create-react-app). 23 | 24 | Checkout the [React TodoMVC](https://github.com/rohan-deshpande/trux/tree/master/examples/react/todomvc) example: 25 | 26 | ``` 27 | git clone https://github.com/rohan-deshpande/trux.git 28 | cd examples/react/todomvc 29 | 30 | npm install 31 | npm run start 32 | ``` 33 | -------------------------------------------------------------------------------- /docs/about/origins.md: -------------------------------------------------------------------------------- 1 | # Origins 2 | 3 | **Trux** was developed for my task management & analytics SPA, **Tr**akktion and was inspired by [Fl**ux**](https://facebook.github.io/flux/). After hashing out the main concepts and developing a functional prototype, I felt it was working quite nicely and thought others might find it useful, so I decided to open source it. 4 | 5 | Development started a while ago and at the time [Redux](http://redux.js.org/) was only just emerging as a possible option for state management amongst others such as [Altjs](http://alt.js.org/) and [Relay](https://facebook.github.io/relay/) to name a few. Unsure which to go with I had a quick read over the concepts of Flux and decided to roll my own solution and Trux was the result. It worked well for me, was simple to understand, easy to set up and powerful enough to do what I needed so I stuck with it. 6 | 7 | Designed for a component driven architecture, Trux was originally developed with React and a REST API in mind. However, it's no longer limited to these, it can easily be used with other libraries or API systems as well. 8 | 9 | -------------------------------------------------------------------------------- /docs/api/README.md: -------------------------------------------------------------------------------- 1 | * [Store](/api/store/README.md) 2 | * [Properties](/api/store/properties.md) 3 | * [Methods](/api/store/methods.md) 4 | * Model 5 | * Properties 6 | * Methods 7 | * Collection 8 | * Properties 9 | * Methods 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /docs/api/collection/README.md: -------------------------------------------------------------------------------- 1 | # Collection 2 | 3 | * [Properties](/api/collection/properties.md) 4 | * [Methods](/api/collection/methods.md) 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /docs/api/collection/methods.md: -------------------------------------------------------------------------------- 1 | # Collection methods 2 | 3 | [`@see - Store methods`](/api/store/methods.md) 4 | 5 | # `fill(models)` 6 | 7 | ``` 8 | @param {array} models - array of model data objects 9 | @return {object} Collection 10 | ``` 11 | 12 | Fills the collection with models. 13 | Instantiates a Model for each data item contained with in the passed array 14 | and appends these models to the collection. 15 | 16 | # `append(model)` 17 | 18 | ``` 19 | @param {object} model - a model, must be an instance of this.model 20 | @return {object} Collection 21 | ``` 22 | 23 | Appends a model to the collection's models. 24 | 25 | # `prepend(model)` 26 | 27 | ``` 28 | @param {object} model - a model, must be an instance of this.model 29 | @return {object} Collection 30 | ``` 31 | 32 | Prepends a model to the collection's models. 33 | 34 | # `purge()` 35 | 36 | ``` 37 | @return void 38 | ``` 39 | 40 | Purges the collection of its models. 41 | 42 | # `persist()` 43 | 44 | ``` 45 | @return {object} Collection 46 | ``` 47 | 48 | Broadcasts changes to connected components. 49 | 50 | # `fetch(query = '')` 51 | 52 | ``` 53 | @param {string} [query] - optional query string to append to GET endpoint 54 | @return {object} Promise 55 | ``` 56 | 57 | Gets the collection from its remote resource. 58 | 59 | # `static extend(props, setup)` 60 | 61 | ``` 62 | @deprecated 63 | @param {object} props - custom props for the new class 64 | @param {function|undefined} setup - an optional function to run within the new class' constructor 65 | @return {function} Extension - the extended class 66 | ``` 67 | 68 | Extends Collection and returns the constructor for the new class. 69 | _This is a convenience method for ES5, it will me removed in the future._ 70 | 71 | # `static modify(props)` 72 | 73 | ``` 74 | @deprecated 75 | @param {object} props - the props to add to the Collection class 76 | @return void 77 | ``` 78 | 79 | Modifies the Collection class with the passed properties. 80 | This will enable all custom collections to inherit the properties passed to this method. 81 | _This is a convenience method for ES5, it will me removed in the future._ -------------------------------------------------------------------------------- /docs/api/collection/properties.md: -------------------------------------------------------------------------------- 1 | # Collection properties 2 | 3 | [`@see - Store properties`](/api/store/properties.md) 4 | 5 | ## `model` 6 | 7 | ``` 8 | @prop {function} 9 | ``` 10 | 11 | The model constructor for this collection. Defines what type of model this collection contains. 12 | 13 | ## `models` 14 | 15 | ``` 16 | @prop {array} 17 | ``` 18 | 19 | The models contained in this collection. 20 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /docs/api/model/README.md: -------------------------------------------------------------------------------- 1 | # Model 2 | 3 | * [Properties](/api/model/properties.md) 4 | * [Methods](/api/model/methods.md) 5 | -------------------------------------------------------------------------------- /docs/api/model/methods.md: -------------------------------------------------------------------------------- 1 | # Model methods 2 | 3 | [`@see - Store methods`](/api/store/methods.md) 4 | 5 | # `fill(data)` 6 | 7 | ``` 8 | @param {object} data - the data that defines this model 9 | @return {object} Model 10 | ``` 11 | 12 | Fills the model with data and sets the private backup for the model. 13 | 14 | # `restore()` 15 | 16 | ``` 17 | @return {object} Model 18 | ``` 19 | 20 | Restores the model's data to its previous state. 21 | 22 | # `persist(collection = true)` 23 | 24 | ``` 25 | @param {boolean} [collection] - optionally ensure that if the model belongs to a collection, it is persisted instead. Defaults to true. 26 | @return {object} Model 27 | ``` 28 | 29 | Persits the model's data throughout its connected components. If this model belongs to a collection, 30 | the collection's connected components are updated instead by default. 31 | 32 | # `fetch(query = '')` 33 | 34 | ``` 35 | @param {string} [query] - optional query string to append to GET endpoint 36 | @return {Object} Promise 37 | ``` 38 | 39 | Fetches the remote data for the model, then fills the model with the JSON response. 40 | 41 | # `create(data)` 42 | 43 | ``` 44 | @param {object} data - the data for the new model 45 | @return {object} Promise 46 | ``` 47 | Creates a new model in the remote data store. 48 | 49 | # `update(options)` 50 | 51 | ``` 52 | @param {object} [options] - configuration options 53 | @param {object} [options.data] - the data to update the model with, defaults to the current model data 54 | @param {string} [options.method] - the method to use, should be either PUT or PATCH, defaults to PUT 55 | @param {boolean} [options.optimistic] - boolean to determine if this update was already persisted optimistically 56 | @param {boolean} [options.collection] - collection argument for the persist method 57 | @return {object} Promise 58 | ``` 59 | 60 | Updates the model in the remote data store and fills the model with the response payload. 61 | 62 | # `destroy()` 63 | 64 | ``` 65 | @return {object} Promise 66 | ``` 67 | 68 | Sends a request to delete from the remote data store, then purges and disconnects all components from the model. 69 | 70 | # `purge()` 71 | 72 | ``` 73 | @return {object} Model 74 | ``` 75 | 76 | Purges the model of its data. 77 | 78 | # `static extend(props, setup)` 79 | 80 | ``` 81 | @deprecated 82 | @param {object} props - custom props for the new class 83 | @param {function|undefined} setup - an optional function to run within the new class' constructor 84 | @return {function} Extension - the extended class 85 | ``` 86 | Extends Model and returns the constructor for the new class. _This is a convenience method for ES5, it will me removed in the future._ 87 | 88 | # `static modify(props)` 89 | 90 | ``` 91 | @deprecated 92 | @param {object} props - the props to add to the Trux.Model class 93 | @return void 94 | ``` 95 | 96 | Modifies the Model class with the passed properties. 97 | This will enable all custom models to inherit the properties passed to this method. _This is a convenience method for ES5, it will me removed in the future._ 98 | -------------------------------------------------------------------------------- /docs/api/model/properties.md: -------------------------------------------------------------------------------- 1 | # Model properties 2 | 3 | [`@see - Store properties`](/api/store/properties.md) 4 | 5 | # `data` 6 | 7 | ``` 8 | @prop {object|null} 9 | ``` 10 | The data which defines the model. Defaults to null. 11 | 12 | # `collection` 13 | 14 | ``` 15 | @prop {boolean|object} 16 | ``` 17 | 18 | The collection the model belongs to. Defaults to false. 19 | 20 | # `wasCreated` 21 | 22 | ``` 23 | @prop {boolean} 24 | ``` 25 | 26 | Boolean to determine if the model has been created remotely. 27 | 28 | # `wasCreatedAt` 29 | 30 | ``` 31 | @prop {number|undefined} 32 | ``` 33 | 34 | Timstamp to determine when the store was created, `undefined` if `wasCreated` is false. 35 | 36 | 37 | # `wasUpdated` 38 | 39 | ``` 40 | @prop {boolean} 41 | ``` 42 | 43 | Boolean to determine if the model has been updated locally and remotely. 44 | 45 | # `wasUpdatedAt` 46 | 47 | ``` 48 | @prop {number|undefined} 49 | ``` 50 | 51 | Timstamp to determine when the store was updated, `undefined` if `wasUpdated` is false. 52 | 53 | # `wasDestroyed` 54 | 55 | ``` 56 | @prop {boolean} 57 | ``` 58 | 59 | Boolean to determine if the model has been destroyed locally and remotely. 60 | 61 | # `wasDestroyedAt` 62 | 63 | ``` 64 | @prop {number|undefined} 65 | ``` 66 | 67 | Timstamp to determine when the store was destroyed, `undefined` if `wasDestroyed` is false. 68 | -------------------------------------------------------------------------------- /docs/api/store/README.md: -------------------------------------------------------------------------------- 1 | # Store 2 | 3 | * [Properties](/api/store/properties.md) 4 | * [Methods](/api/store/methods.md) 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /docs/api/store/collection.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rohan-deshpande/trux/9697f5ce85dc83401016b4896949dee1370abca1/docs/api/store/collection.md -------------------------------------------------------------------------------- /docs/api/store/methods.md: -------------------------------------------------------------------------------- 1 | # Store methods 2 | 3 | ## `connect(component)` 4 | 5 | ``` 6 | @param {object} component - the component to connect to this store 7 | @throws ReferenceError - if component.truxid is undefined 8 | @return void 9 | ``` 10 | 11 | Connects a component to the store and ensures the component receives updates via broadcast. Throws a `ReferenceError` if the component does not have a `truxid` defined and triggers a console warning if the component does not have a `storeDidUpdate` method. 12 | 13 | **Note!** For React, this should be called within the component's `componentDidMount` method. 14 | 15 | ## `disconnect(component)` 16 | 17 | ``` 18 | @param {object} component - the component to disconnect from this store 19 | @throws ReferenceError - if component.truxid is undefined 20 | @return void 21 | ``` 22 | 23 | Disconnects a component from the store, stopping it from receiving updates. 24 | 25 | **Note!** For React, this should be called within the component's `componentWillUnmount` method. 26 | 27 | ## `close()` 28 | 29 | ``` 30 | @return {object} Store 31 | ``` 32 | 33 | Disconnects all components from the store. 34 | 35 | ## `addRequestHeader(key, value)` 36 | 37 | ``` 38 | @param {string} key - the key for the header 39 | @param {mixed} value - the value for the header 40 | @return {object} Store 41 | ``` 42 | 43 | Adds a request header. 44 | 45 | ## `deleteRequestHeader(key)` 46 | 47 | ``` 48 | @param {string} key - the key for the header to delete 49 | @return {object} Store 50 | ``` 51 | 52 | Deletes a request header. 53 | 54 | ## `set requestHeaders(headers)` 55 | 56 | ``` 57 | @param {object} headers - headers object 58 | @return void 59 | ``` 60 | 61 | Set the store's request headers. 62 | 63 | ## `get requestHeaders()` 64 | 65 | ``` 66 | @return {object} 67 | ``` 68 | 69 | Gets the store's request headers. 70 | 71 | -------------------------------------------------------------------------------- /docs/api/store/properties.md: -------------------------------------------------------------------------------- 1 | # Store properties 2 | 3 | ## `components` 4 | 5 | ``` 6 | @prop {object} 7 | ``` 8 | 9 | Each store has a `components` object which contains the store's connected components which are keyed by `truxid`. When connecting a component to a store, it is required that the component you pass to the `connect` method has a `truxid` property set. This property is used to broadcast changes to the component and also to remove it from the store's `components` object when `disconnect` is called. 10 | 11 | ## `emitter` 12 | 13 | ``` 14 | @prop {object} 15 | ``` 16 | 17 | The `emitter` for a store is used to emit and listen for `change` events. When a `change` event is fired, a store will update its connected components via their `storeDidUpdate` method. You should not need to interact with the `emitter` directly, as these interactions are abstracted away by a store's `persist` method. 18 | 19 | ## `requestHeaders` 20 | 21 | ``` 22 | @prop {object} 23 | ``` 24 | 25 | These are the `Headers` sent with every API request if your stores communicate with a remote resource or entity graph. The single default header is `Content-Type: 'application/json'`. Headers can added, deleted or set via the `addRequestHeader`, `deleteRequestHeader` or `setRequestHeaders` methods. 26 | 27 | ## `GET` 28 | 29 | ``` 30 | @prop {string} 31 | ``` 32 | 33 | This is the `GET` route for the store and is applicable to both models and collections. This is the route used by the `fetch` method of either store. You can set the `GET` property manually or in the constructor of a store extension. 34 | 35 | ## `POST` 36 | 37 | ``` 38 | @prop {string} 39 | ``` 40 | 41 | This is the `POST` route for the store and is applicable to both models and collections. This is the route used by the `create` method of either store. You can set the `POST` property manually or in the constructor of a store extension. 42 | 43 | ## `PUT` 44 | 45 | ``` 46 | @prop {string} 47 | ``` 48 | 49 | This is the `PUT` route for the store and is applicable to only models. This is the default route used by the `update` method of a model. You can set the `PUT` property manually or in the constructor of a store extension. 50 | 51 | ## `PATCH` 52 | 53 | ``` 54 | @prop {string} 55 | ``` 56 | 57 | This is the `PATCH` route for the store and is applicable to only models. This is the optional route used by the `update` method of a model. You can set the `PATCH` property manually or in the constructor of a store extension. 58 | 59 | ## `DELETE` 60 | 61 | ``` 62 | @prop {string} 63 | ``` 64 | 65 | This is the `DELETE` route for the store and is applicable to only models. This is the route used by the `destroy` method of a model. You can set the `DELETE` property manually or in the constructor of a store extension. 66 | 67 | ## `wasBroadcast` 68 | 69 | ``` 70 | @prop {boolean} 71 | ``` 72 | 73 | Boolean to determine if changes to the store has been broadcast. 74 | 75 | ## `wasBroadcastAt` 76 | 77 | ``` 78 | @prop {number|undefined} 79 | ``` 80 | 81 | Timstamp to determine when the store was broadcast, `undefined` if `wasBroadcast` is false. 82 | 83 | ## `wasFetched` 84 | 85 | ``` 86 | @prop {boolean} 87 | ``` 88 | 89 | Boolean to determine if the store has been fetched from the remote resource. 90 | 91 | ## `wasFetchedAt` 92 | 93 | ``` 94 | @prop {number|undefined} 95 | ``` 96 | 97 | Timstamp to determine when the store was fetched, `undefined` if `wasFetched` is false. 98 | -------------------------------------------------------------------------------- /docs/book.css: -------------------------------------------------------------------------------- 1 | .gitbook-link { 2 | display: none !important; 3 | } 4 | 5 | ul.summary li:first-child{ 6 | display: none; 7 | } -------------------------------------------------------------------------------- /docs/stores/README.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rohan-deshpande/trux/9697f5ce85dc83401016b4896949dee1370abca1/docs/stores/README.md -------------------------------------------------------------------------------- /docs/stores/collection.md: -------------------------------------------------------------------------------- 1 | # Collection 2 | 3 | A Trux `Collection` is a store for an array of models. They are containers for a many items of data and thus are perfect for rendering out lists of components such as cards, todos and posts etc.,. 4 | 5 | Collections can only store one kind of model and you must tell it which model you plan to store within it upon instantiation. Like models, they inherit all properties and methods from the `Store` class but add a few extra methods as well. 6 | 7 | Let's take a quick look at how to use a `Collection` 8 | 9 | ```js 10 | import { Model, Collection } from 'trux'; 11 | import React, { Component, PropTypes } from 'react' 12 | import { render } from 'react-dom'; 13 | 14 | // posts data 15 | const posts = [ 16 | { 17 | 'id': 1, 18 | 'title': 'Taters', 19 | 'content': 'Boil \'em mash \'em stick \'em in a stew', 20 | 'author': 'Samwise Gamgee' 21 | }, 22 | { 23 | 'id': 2, 24 | 'title': 'Balrog', 25 | 'content': 'You shall not pass!', 26 | 'author': 'Gandalf Greyhame', 27 | }, 28 | { 29 | 'id': 3, 30 | 'title': 'Precious', 31 | 'content': 'They stoles it from us! Filthy little Hobbitses!' 32 | }, 33 | ]; 34 | 35 | // create a custom model to pass to the collection 36 | class Post extends Model { 37 | constructor(data) { 38 | super(data); 39 | } 40 | 41 | get id() { 42 | return this.data.id; 43 | } 44 | 45 | get title() { 46 | return this.data.title; 47 | } 48 | 49 | get content() { 50 | return this.data.content; 51 | } 52 | 53 | get author() { 54 | return this.data.author; 55 | } 56 | } 57 | 58 | // create a React component to render the data 59 | class PostsList extends Component { 60 | static propTypes = { 61 | posts: PropTypes.object.isRequired 62 | } 63 | 64 | componentDidMount() { 65 | this.truxid = 'POSTS_LIST'; 66 | this.props.posts.connect(this); 67 | } 68 | 69 | componentWillUnmount() { 70 | this.props.posts.disconnect(this); 71 | } 72 | 73 | storeDidUpdate() { 74 | this.forceUpdate(); 75 | } 76 | 77 | render() { 78 | return this.posts.models.map((post, i) => { 79 | return ( 80 |
81 |
82 | {post.title} 83 |
84 |

85 | {post.content} 86 |

87 | 88 | {post.author} 89 | 90 |
91 | ); 92 | }); 93 | } 94 | } 95 | 96 | // create a collection and set its model constructor 97 | const Posts = new Collection(Post); 98 | 99 | // fill the collection with data to be turned into models, the fill method will auto instantiate these 100 | // objects, passing each of them to the model constructor passed to the constructor and appending them 101 | // to the collection's models array. 102 | Posts.fill(posts); 103 | 104 | // render the PostsList component into the #blog div and pass in the Posts collection as the posts prop. 105 | render( a.created_at - b.created_at) 127 | .thenBy((a, b) => a.modified_at - b.modified_at); 128 | ); 129 | 130 | return this; 131 | } 132 | } 133 | ``` 134 | 135 | ## Learn more 136 | 137 | * [Collection properties](/api/collection/properties.md) 138 | * [Collection methods](/api/collection/methods.md) 139 | 140 | 141 | 142 | -------------------------------------------------------------------------------- /docs/stores/model.md: -------------------------------------------------------------------------------- 1 | # Model 2 | 3 | A `Model` is a store that contains a single data object that is mutable. Mutations are broadcast to one or many connected components via the `persist` or `update` method. Through extending, your model can also contain various custom methods such as `getters`, `setters` and pretty much anything you like. 4 | 5 | Trux models extend the base `Store` class and therefore inherit a number of methods and properties which make synchronising them with your UI very easy. 6 | 7 | In this very simple example using ES5 syntax and plain JavaScript, we'll take a simple look at how models and connected components interact with one another 8 | 9 | ```js 10 | var app = document.createElement('div'); 11 | // instantiate a model 12 | var character = new trux.Model({ name: 'Frodo' }); 13 | // create a 'component' 14 | var hobbit = { 15 | truxid: 'HOBBIT', 16 | storeDidUpdate: function () { 17 | document.getElementById('app').innerHTML = character.data.name; 18 | } 19 | } 20 | var hobbits = [ 21 | 'Frodo', 22 | 'Sam', 23 | 'Pippin', 24 | 'Merry' 25 | ]; 26 | 27 | app.id = 'app'; 28 | document.body.appendChild(app); 29 | // connect the component to the store 30 | character.connect(hobbit); 31 | 32 | setInterval(function() { 33 | // mutate the store's data 34 | character.data.name = hobbits[Math.floor(Math.random() * hobbits.length)]; 35 | // persist the mutation 36 | character.persist(); 37 | }, 1000); 38 | ``` 39 | 40 | In this example, the `innerHTML` of the `#app` element will be updated to a random item of the `hobbits` array every second. You can see a working version of this example here. 41 | 42 | **Note!** You should avoid mutating store data directly where possible, this is done here for the sake of brevity for this example. See [internal store changes](/about/differences-to-redux.md#internal-store-changes) for more info. 43 | 44 | ## Learn more 45 | 46 | * [Model properties](/api/model/properties.md) 47 | * [Model methods](/api/model/methods.md) 48 | 49 | 50 | 51 | -------------------------------------------------------------------------------- /docs/stores/store.md: -------------------------------------------------------------------------------- 1 | # Store 2 | 3 | The `Store` class is not exported by Trux as it is not designed to be used directly, it is, however, the parent class to both `Model` and `Collection` so understanding some of its core properties and methods is essential. Every `Model` and `Collection` inherits all of the properties and methods of `Store`. 4 | 5 | ## Learn more 6 | 7 | * [Store Properties](/api/store/properties.md) 8 | 9 | * [Store Methods](/api/store/methods.md) 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /docs/usage/connectors-nodes.md: -------------------------------------------------------------------------------- 1 | # Connectors & Nodes 2 | 3 | I feel like the word **component** has become kind of confusing in recent times. To me **views**, **containers**, **components**, **smart components**, **dumb components** etc., are all just _**types**_ of components. Taking that into consideration, I think we need clearer, more declarative terms to describe what our components actually do. 4 | 5 | The words `connector` and `node` are how I like to think of my Trux components. It's totally a personal choice and you can rename them to `container` and `component` or `foo` and `bar` if you like, it's totally up to you. 6 | 7 | ## Connectors 8 | 9 | A `connector` is a UI component that is connected to a Trux `store`. If you've had a look at Redux, you might say they are similar to _containers_ or _smart components_. Typically, connectors will contain nodes and pass data to them via `props`. 10 | 11 | A `connector` is the most appropriate candidate to use as a top level route component. Since they are connected to a store, they will be able to communicate with a remote resource via the store on demand. This isn't a requirement however, you can still have connectors within other connectors if your use case demands it. 12 | 13 | ## Nodes 14 | 15 | In the Trux sense, a `node` is a UI object that can receive data from a `connector` via `props`, you might know them as _dumb components_ or just _components_. They are not connected to a store. 16 | 17 | Typically a `connector` component would have one or many `node` components as children. A `node` **can** have local state if you wish - a form is a good example of a component that may not need to connect to a store but would need local state to function properly. 18 | 19 | -------------------------------------------------------------------------------- /docs/usage/extending.md: -------------------------------------------------------------------------------- 1 | # Extending 2 | 3 | The Trux `Model` and `Collection` classes are fairly barebones and are designed to be extended for your own use cases. By themselves they really just provide an architectural pattern for changing data and broadcasting these changes to your components. 4 | 5 | The power of Trux lies in extending these classes. Let's look at an example of how to do this 6 | 7 | ```javascript 8 | class User extends Model { 9 | constructor(data) { 10 | super(data); 11 | } 12 | 13 | get firstname() { 14 | return this.data.firstname; 15 | } 16 | 17 | get lastname() { 18 | return this.data.lastname; 19 | } 20 | 21 | get fullname() { 22 | return `${this.firstname} ${this.lastname}`; 23 | } 24 | } 25 | ``` 26 | 27 | Now you can instantiate your `User` model and connect components to it 28 | 29 | ```javascript 30 | const user = new User({ 31 | firstname: 'Bilbo', 32 | lastname: 'Baggins' 33 | }); 34 | 35 | console.log(user.fullname); // logs Bilbo Baggins 36 | ``` 37 | 38 | This allows to also do things like the following when you expect that models will all share some common kinds of data: 39 | 40 | ```javascript 41 | class Model extends Model { 42 | constructor(data) { 43 | super(data); 44 | } 45 | 46 | get id() { 47 | return this.data.id; 48 | } 49 | 50 | get createdAt() { 51 | return this.data.created_at; 52 | } 53 | 54 | get modifiedAt() { 55 | return this.data.modified_at; 56 | } 57 | } 58 | 59 | class User extends Model { 60 | //... 61 | } 62 | 63 | class Post extends Model { 64 | //... 65 | } 66 | ``` 67 | 68 | **Note!** If you are using ES5 syntax then there is a static method provided on both `Model` and `Collection` - `extend(props, setup)` which can be used for convenience like so: 69 | 70 | ```javascript 71 | var User = Model.extend({ 72 | getId: function () { 73 | return this.data.id; 74 | } 75 | }, function(User) { 76 | // called within User constructor after the parent has been constructed 77 | User.GET = `'http://example.com/api/profile/'${User.getId()}`; 78 | }); 79 | ``` 80 | 81 | 82 | 83 | -------------------------------------------------------------------------------- /docs/usage/graphql.md: -------------------------------------------------------------------------------- 1 | # GraphQL 2 | 3 | Even though Trux was originally designed with [REST](/usage/REST.md) in mind, it will work just as well out of the box with [GraphQL](http://graphql.org/) [served over HTTP](http://graphql.org/learn/serving-over-http/). 4 | 5 | ## GET and POST only 6 | 7 | Since GraphQL server only supports `GET` and `POST` requests, some of the Trux methods for interacting with remote data won't really work as intended. 8 | 9 | For requesting model and collection data, you will still be able to use the `fetch` method, simply pass your prepared `query` as an argument. 10 | 11 | For example: 12 | 13 | ```js 14 | user.fetch(encodeURI('?query=user(id: "1")'); 15 | ``` 16 | 17 | For model mutations, you'll need to use the `update` method with the `method` option set to `POST` like so 18 | 19 | ```js 20 | user.update({ 21 | data: { 22 | "query": "...", 23 | "operationName": "...", 24 | "variables": { "myVariable": "someValue", ... } 25 | }, 26 | method: 'POST' 27 | }); 28 | ``` -------------------------------------------------------------------------------- /docs/usage/optimism-vs-pessimism.md: -------------------------------------------------------------------------------- 1 | # Optimism vs Pessimism 2 | 3 | Before we get into how Trux handles optimistic and pessimistic changes, let's establish what I mean by these terms. 4 | 5 | An **optimistic change** is a state change that you apply immediately after triggering it, optimistically expecting that the change will be successful. 6 | 7 | In contrast to this, a **pessimistic change** is a state change you apply only after you learn that the the change has succeeded. 8 | 9 | Both types of changes have their uses and drawbacks. 10 | 11 | Optimistic changes are incredibly handy for enhancing the user experience of your app. Why should we make users wait when we know that the state will be identical in the end anyway? 12 | 13 | On the other hand, there may be times where continuing in the app would be foolish without confirmation that a change has actually been successful. 14 | 15 | There is also a catch to optimistic updates, what do we do if the change was **not** successful? 16 | 17 | Trux provides ways to handle both situations so let's have a look at how to do this with a very simple example 18 | 19 | ```js 20 | class Character extends Model { 21 | constructor(data) { 22 | super(data); 23 | 24 | this.PUT = 'https://lotr.com/api'; 25 | } 26 | 27 | set name(name) { 28 | this.data.name = name; 29 | } 30 | 31 | get name() { 32 | return this.data.name; 33 | } 34 | } 35 | 36 | const character = new Character({ name: 'Frodo' }); 37 | const hobbit = { 38 | truxid: 'HOBBIT', 39 | storeDidUpdate: () => { 40 | document.getElementById('app').innerHTML = character.name; 41 | } 42 | } 43 | 44 | character.connect(hobbit); 45 | 46 | // optimistic: 47 | 48 | character.name = 'Sam'; 49 | character 50 | .update({ optimistic: true }) 51 | .catch(console.warn); 52 | 53 | // pessimistic: 54 | 55 | character.name = 'Pippin'; 56 | character 57 | .update() 58 | .catch(console.warn); 59 | ``` 60 | 61 | In this very simple example, the optimistic change will immediately update the `innerHTML` of `#app` to `Sam`, then send a request to the API to update the character's name remotely. 62 | 63 | In this case, if the request fails, `character` will be restored to its previous state and will in turn revert `#app` back to displaying `Frodo`. 64 | 65 | On the other hand, the pessimistic change will first attempt to send the request to the API and only broadcast the change when the request is successful. 66 | 67 | Since `update` always returns a `Promise` you are free to chain `then` or `catch` methods off it to do custom success or error handling when needed. 68 | 69 | -------------------------------------------------------------------------------- /docs/usage/react.md: -------------------------------------------------------------------------------- 1 | # React 2 | 3 | Trux was designed with [React](https://facebook.github.io/react/) in mind, so working it into your app should be very straight forward. There are a few things you will need to do in your components to get it working as intended however, let's take a look at those now. 4 | 5 | ## Connecting 6 | 7 | Connecting a React component to a Trux store must occur within the `componentDidMount` lifecycle method. Within this method there are two things you are required to do in order to wire up your component correctly; set a `truxid` and `connect` the component to the store. 8 | 9 | ### Set a `truxid` for your component 10 | 11 | You must set a `truxid` for your component. This must be a **unique** **identifier** and will be how the component will be found for disconnection later. I recommend using the same syntax rule you would use for constants. 12 | 13 | ```js 14 | componentDidMount() { 15 | this.truxid = 'MY_COMPONENT'; 16 | } 17 | ``` 18 | 19 | ### `connect` your component to a store 20 | 21 | Likewise you also need to `connect` the component to the store to ensure it receives updates 22 | 23 | ```js 24 | componentDidMount() { 25 | this.truxid = 'MY_COMPONENT'; // set the truxid first 26 | this.props.store.connect(this); // connect the component to the store 27 | } 28 | ``` 29 | 30 | You must do this **after** setting the `truxid` or Trux will throw a `ReferenceError`. 31 | 32 | ### Setting your component's `storeDidUpdate` method 33 | 34 | Each component which you connect to a store needs a `storeDidUpdate` method in order to receive broadcasted updates. In React, you can simply set this as one of your component's methods 35 | 36 | ```js 37 | storeDidUpdate() { 38 | this.forceUpdate(); // see note about this below 39 | } 40 | ``` 41 | 42 | Now your component will receive updates from the store when required. It's up to you to choose how you wish to handle these changes inside your `storeDidUpdate` method. 43 | 44 | **Note!** Yes, the above example uses `forceUpdate`, no you do not **have** to do it this way but honestly? **It's fine**, [React will still only update the DOM if the markup changes](https://facebook.github.io/react/docs/react-component.html#forceupdate). Just be aware that if you want to use `shouldComponentUpdate()` to stop re-renders, then calling `forceUpdate` will bypass this flag. 45 | 46 | Another way of achieving this would be to set the relevant properties in the state of the component and update only these properties from `this.props.store` within `storeDidUpdate` via `setState()` 47 | 48 | ```js 49 | class Example extends Component { 50 | constructor(props) { 51 | super(props); 52 | 53 | this.state = { 54 | some: 'thing' 55 | } 56 | } 57 | } 58 | 59 | storeDidUpdate() { 60 | this.setState({ 61 | some: this.props.store.data.some 62 | }); 63 | } 64 | ``` 65 | 66 | ## Disconnecting 67 | 68 | If a component is going to be unmounted from the DOM, you must disconnect it from any stores it is connected to within the `componentWillUnmount` lifecycle method. If you fail to do this, updating a store at a point in time after unmounting will cause React to throw errors because the connected component no longer exists. 69 | 70 | Since you set a `truxid` for your component when it mounted, disconnecting is as simple as just calling a single method from the store 71 | 72 | ```js 73 | componentWillUnmount() { 74 | this.props.store.disconnect(this); 75 | } 76 | ``` 77 | 78 | ## Example 79 | 80 | Here's an example of how this might work in a real life situation: 81 | 82 | ```js 83 | import { User } from './stores/models'; 84 | import React, { Component, PropTypes } from 'react'; 85 | 86 | class Profile extends Component { 87 | 88 | static propTypes = { 89 | userStore: PropTypes.object.isRequired 90 | } 91 | 92 | constructor(props) { 93 | super(props); 94 | 95 | this.state = { ready: false } 96 | } 97 | 98 | componentDidMount() { 99 | this.truxid = 'PROFILE'; 100 | 101 | this.props.userStore.connect(this); 102 | this.props.userStore.fetch() 103 | .then(() => { 104 | this.setState({ ready: true }); 105 | }) 106 | .catch(console.log); 107 | } 108 | 109 | componentWillUnmount() { 110 | this.props.userStore.disconnect(this); 111 | } 112 | 113 | storeDidUpdate() { 114 | this.forceUpdate(); 115 | } 116 | 117 | render() { 118 | if (!this.state.ready) return null; 119 | 120 | const user = this.props.userStore; 121 | 122 | return ( 123 |
124 | 125 | 126 | 127 |
128 | ); 129 | } 130 | } 131 | ``` 132 | 133 | 134 | 135 | -------------------------------------------------------------------------------- /docs/usage/rest.md: -------------------------------------------------------------------------------- 1 | # REST 2 | 3 | Trux was designed with a REST API in mind and is already wired up to work with one out of the box. All you will need to do is define your resource routes for your stores and you should be ready to go. 4 | 5 | ## JSON based 6 | 7 | Trux is designed to work with a JSON based API, if your API does not produce/consume JSON, you might need to build custom request methods into your stores. 8 | 9 | ## Properties 10 | 11 | Trux models and collections both define `GET`, `POST`, `PUT`, `PATCH`, and `DELETE` REST endpoint properties in their constructors. By default, these are just empty strings, but you can easily set these at any time. Here's an example 12 | 13 | ```js 14 | import stores from './stores'; 15 | import { URL } from './api'; 16 | 17 | stores.posts.GET = `${URL}/posts/${stores.user.id}`; // resource for all of the user's posts 18 | store.posts.fetch(); 19 | ``` 20 | 21 | This will fetch all of the user's posts and fill the `posts` collection with `post` models. This isn't really a good real world example though, so for more on this see [handling dependencies](#handling-dependencies) below. 22 | 23 | You can also override the REST endpoints in your constructor like so 24 | 25 | ```js 26 | import { URL } from '../api'; 27 | 28 | class Post extends Model { 29 | constructor(data) { 30 | super(data); 31 | 32 | this.GET = `${URL}/post/${this.id}`; 33 | // ... you can define other resource routes too 34 | } 35 | 36 | get id() { 37 | return this.data.id; 38 | } 39 | } 40 | ``` 41 | 42 | This way, when you fill a collection with models, the endpoints will all be set up for you, automatically. 43 | 44 | ## Methods 45 | 46 | All stores expose a `fetch` method which will use the store's `GET` endpoint to fetch the store from its remote location. In addition to this models expose the `create`, `update` & `destroy` methods which use the other REST properties to perform requests and update state both remotely and locally. For more information checkout the [api docs](/api/model/methods.md). 47 | 48 | **Note!** All requests use [Fetch](https://developer.mozilla.org/en/docs/Web/API/Fetch_API) via [`rd-fetch`](https://github.com/rohan-deshpande/rd-fetch). If you plan to support older browsers you will need both a [Fetch](https://github.com/github/fetch) and a [Promise](https://github.com/taylorhakes/promise-polyfill) polyfill. 49 | 50 | 51 | 52 | -------------------------------------------------------------------------------- /docs/usage/stores-module.md: -------------------------------------------------------------------------------- 1 | # Stores Module 2 | 3 | A stores module is basically a store for your stores. Technically speaking, you could think of it as your **super** store, or your _single source of truth_. It's a good idea to have this so that you have a centralised place where all your models and collections live. Your stores module should live at `stores/index.js` and might look something like this 4 | 5 | ```js 6 | import { User, Post, Comment } from './models'; 7 | import { Users, Posts, Comments } from './collections'; 8 | 9 | export default { 10 | user: new User(), 11 | post: new Post(), 12 | comment: new Comment(), 13 | users: new Users(User), 14 | posts: new Posts(Post), 15 | comments: new Comments(Comment) 16 | }; 17 | ``` 18 | 19 | Then you can simply `import` this module whenever you need access to a store; 20 | 21 | ```js 22 | import stores from './stores'; 23 | 24 | stores.posts.fill([ 25 | { 26 | 'id': 1, 27 | 'title': 'Taters', 28 | 'content': 'Boil \'em mash \'em stick \'em in a stew', 29 | 'author': 'Samwise Gamgee' 30 | }, 31 | { 32 | 'id': 2, 33 | 'title': 'Balrog', 34 | 'content': 'You shall not pass!', 35 | 'author': 'Gandalf Greyhame', 36 | }, 37 | { 38 | 'id': 3, 39 | 'title': 'Precious', 40 | 'content': 'They stoles it from us! Filthy little Hobbitses!' 41 | }, 42 | ]); 43 | ``` 44 | -------------------------------------------------------------------------------- /docs/usage/structure.md: -------------------------------------------------------------------------------- 1 | # Structure 2 | 3 | Here is what the structure for a blog built with Trux might look like: 4 | 5 | ```bash 6 | stores/ 7 | models/ 8 | User.js 9 | Post.js 10 | Comment.js 11 | index.js 12 | collections/ 13 | Users.js 14 | Posts.js 15 | Comments.js 16 | index.js 17 | index.js 18 | components/ 19 | connectors/ 20 | index.js 21 | User.js 22 | Users.js 23 | Post.js 24 | Posts.js 25 | Comment.js 26 | Comments.js 27 | nodes/ 28 | User/ 29 | Name.js 30 | Bio.js 31 | List.js 32 | Post/ 33 | Title.js 34 | Body.js 35 | Date.js 36 | Form.js 37 | List.js 38 | Comment/ 39 | Body.js 40 | Date.js 41 | Form.js 42 | List.js 43 | ProfilePic.js 44 | Header.js 45 | Footer.js 46 | App.js 47 | index.js 48 | ``` 49 | 50 | Some notes about this structure: 51 | 52 | * `components` is where **all** your components should sit, we break them down into categories within this directory 53 | * `components/connectors` is where any component that needs to receive store updates should exist. The `index.js` file here should export all your connectors. 54 | * `components/nodes` is where any component that receives store data as props should sit. The `index.js` file here should export all your nodes 55 | * `stores/models/index.js` is your `models` module and should export all model classes 56 | * `stores/collections/index.js` is your `collections` module and should export all collection classes 57 | * `stores/index.js` is your [stores module](/usage/stores-module.md) it should export instantiated stores 58 | -------------------------------------------------------------------------------- /docs/usage/vue.md: -------------------------------------------------------------------------------- 1 | # Vue 2 | 3 | Even though Trux was developed with React in mind, getting it working with [Vue](https://vuejs.org/) is just as simple. Like with React, you'll need to add a few things to your components to wire it up, let's take a look. 4 | 5 | ## Connecting 6 | 7 | Connecting a Vue component to a Trux store must occur in the `mounted` lifecycle method. Within this method there are two things you'll have to do. 8 | 9 | ### Set a `truxid` for your component 10 | 11 | You must set a `truxid` for your component. This must be a **unique** identifier and will be how the component will be found for disconnection later. 12 | 13 | ```js 14 | mounted() { 15 | this.truxid = 'MY_COMPONENT' 16 | } 17 | ``` 18 | 19 | ### `connect` your component to a store 20 | 21 | ```js 22 | mounted() { 23 | this.truxid = 'MY_COMPONENT' // set the truxid first 24 | this.store.connect(this); // connect this component to the store which should be passed in via props 25 | } 26 | ``` 27 | 28 | You must do this **after** setting the `truxid` or Trux will throw a `ReferenceError`. 29 | 30 | ### Setting your component's `storeDidUpdate` method 31 | 32 | Each component which you connect to a store needs a `storeDidUpdate` method in order to receive broadcasted updates. In Vue, you can simply set this as one of your component's methods 33 | 34 | ```js 35 | methods: { 36 | data: { 37 | name: 'Frodo' 38 | }, 39 | storeDidUpdate: () => { 40 | this.name = this.store.name; 41 | } 42 | } 43 | ``` 44 | 45 | Now your component will receive updates from the store when required. It's up to you to choose how you wish to handle these changes inside your `storeDidUpdate` method. 46 | 47 | ## Disconnecting 48 | 49 | If a component is going to be unmounted from the DOM, you must disconnect it from any stores it is connected to within the `destroyed` lifecycle method. If you do not, then if you update the store at a later time, Vue will throw errors because the connected component will no longer exist. 50 | 51 | Since you set a `truxid` for your component when it mounted, disconnecting is as simple as just calling a single method from the store 52 | 53 | ```js 54 | destroyed() { 55 | this.store.disconnect(this); 56 | } 57 | ``` 58 | 59 | 60 | 61 | -------------------------------------------------------------------------------- /examples/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rohan-deshpande/trux/9697f5ce85dc83401016b4896949dee1370abca1/examples/.gitkeep -------------------------------------------------------------------------------- /examples/basics/README.md: -------------------------------------------------------------------------------- 1 | # Basics 2 | 3 | A few short and simple examples to get the basics of Trux. These examples all use pure JavaScript with ES5 syntax. 4 | 5 | Check out the React and Vue examples for more real world examples using view libraries. -------------------------------------------------------------------------------- /examples/basics/connecting/README.md: -------------------------------------------------------------------------------- 1 | # Connecting 2 | 3 | Simple example to understand the basics of connecting components to stores. 4 | -------------------------------------------------------------------------------- /examples/basics/connecting/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Connecting 7 | 8 | 9 | 10 | 11 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /examples/basics/counter/README.md: -------------------------------------------------------------------------------- 1 | # Counter 2 | 3 | Simple example to show tying controls to a store with a connected component. -------------------------------------------------------------------------------- /examples/basics/counter/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Counter 7 | 8 | 9 | 10 | 11 | 57 | 58 | 59 | -------------------------------------------------------------------------------- /examples/basics/disconnecting/README.md: -------------------------------------------------------------------------------- 1 | # Disconnecting 2 | 3 | Simple example to understand the basics of disconnecting components from stores. 4 | -------------------------------------------------------------------------------- /examples/basics/disconnecting/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Disconnecting 7 | 8 | 9 | 10 | 11 | 52 | 53 | 54 | -------------------------------------------------------------------------------- /examples/react/README.md: -------------------------------------------------------------------------------- 1 | # React 2 | 3 | ## [TodoMVC](todomvc/README.md) 4 | 5 | A [`todomvc`](http://todomvc.com/) implementation written using Trux and bootstrapped with [Create React App](https://github.com/facebookincubator/create-react-app). 6 | 7 | Checkout the [React TodoMVC](https://github.com/rohan-deshpande/trux/tree/master/examples/react/todomvc) example: 8 | 9 | ``` 10 | git clone https://github.com/rohan-deshpande/trux.git 11 | cd examples/react/todomvc 12 | 13 | npm install 14 | npm run start 15 | ``` 16 | -------------------------------------------------------------------------------- /examples/react/todomvc/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "babel-eslint", 3 | "extends": "airbnb", 4 | "rules": { 5 | "arrow-body-style": "off", 6 | "no-console": "off", 7 | "no-continue": "off", 8 | "object-shorthand": "off", 9 | "class-methods-use-this": "off", 10 | "no-param-reassign": ["error", { "props": false }], 11 | "react/jsx-filename-extension": [1, { "extensions": [".js"] }], 12 | "react/forbid-prop-types": 0, 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /examples/react/todomvc/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | 6 | # testing 7 | /coverage 8 | 9 | # production 10 | /build 11 | 12 | # misc 13 | .DS_Store 14 | .env 15 | npm-debug.log* 16 | yarn-debug.log* 17 | yarn-error.log* 18 | 19 | -------------------------------------------------------------------------------- /examples/react/todomvc/README.md: -------------------------------------------------------------------------------- 1 | # Trux React TodoMVC 2 | 3 | A [`todomvc`](http://todomvc.com/) implementation written using Trux and bootstrapped with [Create React App](https://github.com/facebookincubator/create-react-app). 4 | -------------------------------------------------------------------------------- /examples/react/todomvc/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "todomvc", 3 | "version": "0.1.0", 4 | "private": true, 5 | "devDependencies": { 6 | "babel-eslint": "^7.2.3", 7 | "eslint": "^3.19.0", 8 | "eslint-config-airbnb": "^14.1.0", 9 | "eslint-plugin-import": "^2.2.0", 10 | "eslint-plugin-jsx-a11y": "^4.0.0", 11 | "eslint-plugin-react": "^6.10.3", 12 | "react-scripts": "0.9.5" 13 | }, 14 | "dependencies": { 15 | "prop-types": "^15.5.8", 16 | "react": "^15.5.4", 17 | "react-dom": "^15.5.4", 18 | "react-router-dom": "^4.1.1", 19 | "todomvc-app-css": "^2.1.0", 20 | "todomvc-common": "^1.0.3", 21 | "trux": "^3.0.3" 22 | }, 23 | "scripts": { 24 | "start": "react-scripts start", 25 | "build": "react-scripts build", 26 | "test": "react-scripts test --env=jsdom", 27 | "eject": "react-scripts eject" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /examples/react/todomvc/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rohan-deshpande/trux/9697f5ce85dc83401016b4896949dee1370abca1/examples/react/todomvc/public/favicon.ico -------------------------------------------------------------------------------- /examples/react/todomvc/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 16 | React App 17 | 18 | 19 |
20 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /examples/react/todomvc/src/components/App.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { BrowserRouter as Router, Route, Switch } from 'react-router-dom'; 3 | import { TodoApp } from './connectors'; 4 | import Info from './Info'; 5 | 6 | export default function App() { 7 | return ( 8 |
9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 |
18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /examples/react/todomvc/src/components/Info.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default function Info() { 4 | return ( 5 | 12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /examples/react/todomvc/src/components/connectors/TodoApp.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { Header, Main, Footer } from '../nodes/TodoApp'; 4 | import stores from '../../stores'; 5 | 6 | /** 7 | * @const {object} store - the store for this connector. 8 | */ 9 | const store = stores.todos; 10 | 11 | /** 12 | * TodoApp connector component. 13 | * 14 | * @class 15 | * @extends Component 16 | */ 17 | export default class TodoApp extends Component { 18 | 19 | static propTypes = { 20 | match: PropTypes.object.isRequired, 21 | } 22 | 23 | /** 24 | * Construct the component and set the initial state. 25 | * 26 | * @param {object} props 27 | * @return void 28 | */ 29 | constructor(props) { 30 | super(props); 31 | 32 | this.state = { 33 | count: store.count, 34 | countComplete: store.countComplete, 35 | countActive: store.countActive, 36 | areComplete: store.areComplete, 37 | }; 38 | } 39 | 40 | /** 41 | * Connect TodoApp to the store. 42 | * 43 | * @return void 44 | */ 45 | componentDidMount() { 46 | this.truxid = 'TODO_APP'; 47 | store.connect(this); 48 | } 49 | 50 | /** 51 | * Disconnect TodoApp from the store. 52 | * 53 | * @return void 54 | */ 55 | componentWillUnmount() { 56 | store.disconnect(this); 57 | } 58 | 59 | /** 60 | * Receive broadcasted changes and update the state. 61 | * 62 | * @return void 63 | */ 64 | storeDidUpdate() { 65 | this.setState({ 66 | count: store.count, 67 | countComplete: store.countComplete, 68 | countActive: store.countActive, 69 | areComplete: store.areComplete, 70 | }); 71 | } 72 | 73 | /** 74 | * Render the component. 75 | * 76 | * @return {object} 77 | */ 78 | render() { 79 | const state = this.state; 80 | 81 | return ( 82 |
83 |
store.add(title)} 85 | /> 86 |
store.toggle(complete)} 90 | todos={store.filter(this.props.match.path)} 91 | /> 92 |
store.clear()} 97 | /> 98 |
99 | ); 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /examples/react/todomvc/src/components/connectors/index.js: -------------------------------------------------------------------------------- 1 | import TodoApp from './TodoApp'; 2 | 3 | export { 4 | TodoApp, 5 | }; 6 | -------------------------------------------------------------------------------- /examples/react/todomvc/src/components/nodes/TodoApp/Footer/Clear.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | /** 5 | * Clear node - renders the button to clear all completed todos. 6 | * Will not render if there are no completed todos. 7 | * 8 | * @function 9 | * @param {object} props 10 | * @param {integer} props.countComplete - the number of completed todos 11 | * @param {function} props.clearComplete - the function to clear all todos 12 | * @return {object|null} 13 | */ 14 | export default function Clear({ 15 | countComplete = 0, 16 | clearComplete, 17 | }) { 18 | /** 19 | * Handles the click event for the button. 20 | * 21 | * @private 22 | * @param {object} e - click event 23 | * @return void 24 | */ 25 | function handleClick(e) { 26 | e.preventDefault(); 27 | clearComplete(); 28 | } 29 | 30 | return (!countComplete) ? null : ( 31 | 34 | ); 35 | } 36 | 37 | Clear.propTypes = { 38 | countComplete: PropTypes.number.isRequired, 39 | clearComplete: PropTypes.func.isRequired, 40 | }; 41 | -------------------------------------------------------------------------------- /examples/react/todomvc/src/components/nodes/TodoApp/Footer/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { NavLink } from 'react-router-dom'; 4 | import Button from './Clear'; 5 | 6 | /** 7 | * Footer node - renders the footer component with route links for filters. 8 | * Will not render if there are no todos. 9 | * 10 | * @param {object} props 11 | * @param {integer} props.count - the total number of todos 12 | * @param {integer} props.countComplete - the number of completed todos 13 | * @param {integer} props.countActive - the number of active todos 14 | * @param {function} props.clearComplete - function to clear completed todos 15 | * @return {object|null} 16 | */ 17 | export default function Footer({ 18 | count = 0, 19 | countComplete = 0, 20 | countActive = 0, 21 | clearComplete, 22 | }) { 23 | const items = (countComplete === 1) ? 'item' : 'items'; 24 | 25 | return (!count) ? null : ( 26 |
27 | {countActive} {items} left 28 |
    29 |
  • 30 | All 31 |
  • 32 |
  • 33 | Active 34 |
  • 35 |
  • 36 | Completed 37 |
  • 38 |
39 |
44 | ); 45 | } 46 | 47 | Footer.propTypes = { 48 | count: PropTypes.number.isRequired, 49 | countComplete: PropTypes.number.isRequired, 50 | countActive: PropTypes.number.isRequired, 51 | clearComplete: PropTypes.func.isRequired, 52 | }; 53 | -------------------------------------------------------------------------------- /examples/react/todomvc/src/components/nodes/TodoApp/Header/New.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { ESCAPE, ENTER } from '../../../../utils'; 4 | 5 | /** 6 | * New node - renders the input for creating new todos. 7 | * 8 | * @class 9 | * @extends Component 10 | */ 11 | export default class New extends Component { 12 | 13 | static propTypes = { 14 | addTodo: PropTypes.func.isRequired, 15 | } 16 | 17 | /** 18 | * Constructs the component and sets its initial state. 19 | * Titles will always be blank by default. 20 | * 21 | * @param {object} props 22 | * @param {function} props.addTodo - function for adding todos 23 | * @return void 24 | */ 25 | constructor(props) { 26 | super(props); 27 | 28 | this.state = { 29 | title: '', 30 | }; 31 | } 32 | 33 | /** 34 | * Handles the input change event. 35 | * 36 | * @augments this.state 37 | * @param {object} e - change event 38 | * @return void 39 | */ 40 | handleChange = (e) => { 41 | this.setState({ 42 | title: e.target.value, 43 | }); 44 | } 45 | 46 | /** 47 | * Handles key down events when the input has focus. 48 | * 49 | * @augments this.state 50 | * @param {object} e - key down event 51 | * @return void 52 | */ 53 | handleKeyDown = (e) => { 54 | const title = this.state.title; 55 | 56 | if (e.which === ESCAPE) { 57 | this.setState({ title: '' }); 58 | } else if (e.which === ENTER) { 59 | this.setState({ title: '' }, () => { 60 | this.props.addTodo(title); 61 | }); 62 | } 63 | } 64 | 65 | /** 66 | * Renders the component. 67 | * 68 | * @return {object} 69 | */ 70 | render() { 71 | return ( 72 | 80 | ); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /examples/react/todomvc/src/components/nodes/TodoApp/Header/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import New from './New'; 4 | 5 | export default function Header({ addTodo }) { 6 | return ( 7 |
8 |

todos

9 | 10 |
11 | ); 12 | } 13 | 14 | Header.propTypes = { 15 | addTodo: PropTypes.func.isRequired, 16 | }; 17 | -------------------------------------------------------------------------------- /examples/react/todomvc/src/components/nodes/TodoApp/Main/Edit.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { ESCAPE, ENTER } from '../../../../utils'; 4 | 5 | /** 6 | * Edit node - renders the editing input for todo titles. 7 | * 8 | * @class 9 | * @extends Component 10 | */ 11 | export default class Edit extends Component { 12 | 13 | static propTypes = { 14 | onBlur: PropTypes.func.isRequired, 15 | show: PropTypes.bool.isRequired, 16 | todo: PropTypes.object.isRequired, 17 | }; 18 | 19 | /** 20 | * Construct the component and set its initial state. 21 | * 22 | * @param {object} props 23 | * @return void 24 | */ 25 | constructor(props) { 26 | super(props); 27 | 28 | this.state = { 29 | value: this.props.todo.title, 30 | }; 31 | } 32 | 33 | /** 34 | * Handles the input blur event. 35 | * 36 | * @return void 37 | */ 38 | handleBlur = () => { 39 | this.save(); 40 | this.props.onBlur(); 41 | } 42 | 43 | /** 44 | * Handles the input change event. 45 | * 46 | * @augments this.state 47 | * @param {object} e - change event 48 | * @return void 49 | */ 50 | handleChange = (e) => { 51 | this.setState({ value: e.target.value }); 52 | } 53 | 54 | /** 55 | * Handles key down when the input has focus. Escape will cancel and revert, 56 | * Enter will save. 57 | * 58 | * @augments this.state 59 | * @param {object} e - keyDown event 60 | * @return void 61 | */ 62 | handleKeyDown = (e) => { 63 | if (e.which === ESCAPE) { 64 | this.setState({ value: this.props.todo.title }, this.props.onBlur); 65 | } else if (e.which === ENTER) { 66 | this.handleBlur(); 67 | } 68 | } 69 | 70 | /** 71 | * Sets the todo's title and persists this change. 72 | * 73 | * @return void 74 | */ 75 | save() { 76 | this.props.todo.title = this.state.value; 77 | this.props.todo.persist(); 78 | } 79 | 80 | /** 81 | * Renders the component, will render null if this.props.show is false. 82 | * 83 | * @return {null|object} 84 | */ 85 | render() { 86 | return (!this.props.show) ? null : ( 87 | 96 | ); 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /examples/react/todomvc/src/components/nodes/TodoApp/Main/Item.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import Edit from './Edit'; 4 | 5 | /** 6 | * Item node - renders individual todo models. 7 | * 8 | * @class 9 | * @extends Component 10 | */ 11 | export default class Item extends Component { 12 | 13 | static propTypes = { 14 | todo: PropTypes.object.isRequired, 15 | } 16 | 17 | /** 18 | * Constructs the component and sets its initial state. 19 | * 'editing' defaults to false. 20 | * 21 | * @param {object} props 22 | * @return void 23 | */ 24 | constructor(props) { 25 | super(props); 26 | 27 | this.state = { 28 | editing: false, 29 | }; 30 | } 31 | 32 | /** 33 | * Handles the completing of a todo 34 | * Sets the todo's complete status and persists it. 35 | * 36 | * @param {object} e - change event 37 | * @return void 38 | */ 39 | handleComplete = (e) => { 40 | this.props.todo.complete = e.target.checked; 41 | this.props.todo.persist(); 42 | } 43 | 44 | /** 45 | * Handles the destroying of a todo. 46 | * 47 | * @param {object} e - click event 48 | * @return void 49 | */ 50 | handleDestroy = (e) => { 51 | e.preventDefault(); 52 | this.props.todo.destroy(); 53 | } 54 | 55 | /** 56 | * Enables editing mode. 57 | * 58 | * @augments this.state 59 | * @param {object} e - double click event 60 | * @return void 61 | */ 62 | handleEdit = (e) => { 63 | e.preventDefault(); 64 | this.setState({ editing: true }); 65 | } 66 | 67 | /** 68 | * Disables editing mode. 69 | * 70 | * @augments this.state 71 | * @param {object} e - blur event 72 | */ 73 | handleEditBlur = () => { 74 | this.setState({ editing: false }); 75 | } 76 | 77 | /** 78 | * Derives the correct className for the item. 79 | * 80 | * @return {string} 81 | */ 82 | get className() { 83 | const classes = []; 84 | 85 | if (this.state.editing) { 86 | classes.push('editing'); 87 | } 88 | 89 | if (this.props.todo.complete) { 90 | classes.push('completed'); 91 | } 92 | 93 | return classes.join(' '); 94 | } 95 | 96 | /** 97 | * Renders the component. 98 | * 99 | * @return {object} 100 | */ 101 | render() { 102 | const todo = this.props.todo; 103 | 104 | return ( 105 |
  • 106 |
    107 | 114 | 117 |
    119 | 124 |
  • 125 | ); 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /examples/react/todomvc/src/components/nodes/TodoApp/Main/List.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import Item from './Item'; 4 | 5 | /** 6 | * List node - renders a list of todo models. 7 | * 8 | * @function 9 | * @param {object} props 10 | * @param {array} props.todos - an array of todo models to render 11 | * @return {function} 12 | */ 13 | export default function List({ todos }) { 14 | return ( 15 |
      16 | { 17 | todos.map((todo) => { 18 | return ( 19 | 23 | ); 24 | }) 25 | } 26 |
    27 | ); 28 | } 29 | 30 | List.propTypes = { 31 | todos: PropTypes.array.isRequired, 32 | }; 33 | -------------------------------------------------------------------------------- /examples/react/todomvc/src/components/nodes/TodoApp/Main/index.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import List from './List'; 4 | 5 | /** 6 | * Main node - renders the main section of the app. 7 | * 8 | * @class 9 | * @extends Component 10 | */ 11 | export default class Main extends Component { 12 | 13 | static propTypes = { 14 | count: PropTypes.number.isRequired, 15 | areComplete: PropTypes.bool.isRequired, 16 | todos: PropTypes.array.isRequired, 17 | toggle: PropTypes.func.isRequired, 18 | } 19 | 20 | /** 21 | * Constructs the component and sets its initial state. 22 | * The 'mark all complete' checkbox will be checked if all todos are complete. 23 | * 24 | * @param {object} props 25 | * @param {boolean} props.areComplete - bool to determine if all todos are complete 26 | */ 27 | constructor(props) { 28 | super(props); 29 | 30 | this.state = { 31 | checked: props.areComplete, 32 | }; 33 | } 34 | 35 | /** 36 | * Resets the state when new props are received. 37 | * 38 | * @augments this.state 39 | * @param {object} props 40 | * @return void 41 | */ 42 | componentWillReceiveProps(props) { 43 | this.setState({ 44 | checked: props.areComplete, 45 | }); 46 | } 47 | 48 | /** 49 | * Handles toggling all todos' complete status. 50 | * 51 | * @augments this.state 52 | * @param {object} e - change event 53 | * @return void 54 | */ 55 | handleToggle = (e) => { 56 | const checked = e.target.checked; 57 | 58 | this.setState({ checked: checked }, () => this.props.toggle(checked)); 59 | } 60 | 61 | /** 62 | * Renders the component there are todos. 63 | * 64 | * @return {null|object} 65 | */ 66 | render() { 67 | return (!this.props.count) ? null : ( 68 |
    69 | 77 | 78 | 79 |
    80 | ); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /examples/react/todomvc/src/components/nodes/TodoApp/index.js: -------------------------------------------------------------------------------- 1 | import Header from './Header'; 2 | import Main from './Main'; 3 | import Footer from './Footer/'; 4 | 5 | export { Header, Main, Footer }; 6 | -------------------------------------------------------------------------------- /examples/react/todomvc/src/index.js: -------------------------------------------------------------------------------- 1 | import 'todomvc-app-css/index.css'; 2 | import React from 'react'; 3 | import ReactDOM from 'react-dom'; 4 | import App from './components/App'; 5 | 6 | ReactDOM.render( 7 | , 8 | document.getElementById('root'), 9 | ); 10 | -------------------------------------------------------------------------------- /examples/react/todomvc/src/stores/collections/Todos.js: -------------------------------------------------------------------------------- 1 | import { Collection } from 'trux'; 2 | import { Todo } from '../models'; 3 | import { uuid } from '../../utils'; 4 | import { STORAGE_KEY } from './'; 5 | 6 | /** 7 | * Todos collection. 8 | * 9 | * @class 10 | * @extends Collection 11 | */ 12 | export default class Todos extends Collection { 13 | 14 | constructor() { 15 | super(Todo); 16 | 17 | // ensure that whenever a change event is fired, the collection is stored in localStorage. 18 | this.emitter.addListener('change', () => this.store()); 19 | } 20 | 21 | /** 22 | * Stores the collection's models' data in local storage. 23 | * 24 | * @return void 25 | */ 26 | store() { 27 | localStorage.setItem( 28 | STORAGE_KEY, 29 | JSON.stringify(this.models.map(todo => todo.data), 30 | )); 31 | } 32 | 33 | /** 34 | * Adds a model to the collection and persists the collection. 35 | * 36 | * @param {string} title - the title of the todo 37 | * @return {object} Todos 38 | */ 39 | add(title) { 40 | this.prepend(new Todo({ 41 | id: uuid(), 42 | title: title.trim(), 43 | complete: false, 44 | })); 45 | 46 | return this.persist(); 47 | } 48 | 49 | /** 50 | * Removes a model by its id from the collection and persists the collection. 51 | * 52 | * @param {number} id - the id of the model to remove 53 | * @return {object} Todos 54 | */ 55 | remove(id) { 56 | this.models = this.models.filter(todo => todo.id !== id); 57 | 58 | return this.persist(); 59 | } 60 | 61 | /** 62 | * Clears completed todos from the collection and persists the collection. 63 | * 64 | * @return {object} Todos 65 | */ 66 | clear() { 67 | this.models = this.models.filter(todo => !todo.complete); 68 | 69 | return this.persist(); 70 | } 71 | 72 | /** 73 | * Toggles the completion of todos. 74 | * 75 | * @param {boolean} [complete] 76 | */ 77 | toggle(complete = true) { 78 | this.models = this.models.map((todo) => { 79 | todo.complete = complete; 80 | return todo; 81 | }); 82 | 83 | return this.persist(); 84 | } 85 | 86 | /** 87 | * Filters the collection's models based on the filter passed and returns the filtered models. 88 | * 89 | * @param {string} filter - the filter 90 | * @return {array} 91 | */ 92 | filter(filter) { 93 | switch (filter) { 94 | case '/': 95 | return this.models; 96 | case '/active': 97 | return this.models.filter(todo => !todo.complete); 98 | case '/completed': 99 | return this.models.filter(todo => todo.complete); 100 | default: 101 | return this.models; 102 | } 103 | } 104 | 105 | /** 106 | * Gets the current models count. 107 | * 108 | * @return {integer} 109 | */ 110 | get count() { 111 | return this.models.length; 112 | } 113 | 114 | /** 115 | * Gets the current completed todos count. 116 | * 117 | * @return {integer} 118 | */ 119 | get countComplete() { 120 | return this.models.filter(todo => todo.complete).length; 121 | } 122 | 123 | /** 124 | * Gets the incomplete todos count. 125 | * 126 | * @return {integer} 127 | */ 128 | get countActive() { 129 | return this.count - this.countComplete; 130 | } 131 | 132 | /** 133 | * Boolean to determine if all todos are complete or not. 134 | * 135 | * @return {boolean} 136 | */ 137 | get areComplete() { 138 | return this.count === this.countComplete; 139 | } 140 | 141 | /** 142 | * Returns a boolean to define if the collection is empty or not. 143 | * 144 | * @return {boolean} 145 | */ 146 | get isEmpty() { 147 | return this.models.length === 0; 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /examples/react/todomvc/src/stores/collections/index.js: -------------------------------------------------------------------------------- 1 | import Todos from './Todos'; 2 | 3 | /** 4 | * @const STORAGE_KEY - the todomvc key for localStorage. 5 | */ 6 | const STORAGE_KEY = 'todos-trux'; 7 | 8 | export { 9 | Todos, 10 | STORAGE_KEY, 11 | }; 12 | -------------------------------------------------------------------------------- /examples/react/todomvc/src/stores/index.js: -------------------------------------------------------------------------------- 1 | import { Todo } from './models'; 2 | import { Todos, STORAGE_KEY } from './collections'; 3 | 4 | const todos = new Todos(); 5 | const stored = localStorage.getItem(STORAGE_KEY); 6 | 7 | if (stored) { 8 | todos.fill(JSON.parse(stored)); 9 | } else { 10 | todos.add('Task one').add('Task two'); 11 | } 12 | 13 | export default { 14 | todo: new Todo(), 15 | todos: todos, 16 | }; 17 | -------------------------------------------------------------------------------- /examples/react/todomvc/src/stores/models/Todo.js: -------------------------------------------------------------------------------- 1 | import { Model } from 'trux'; 2 | 3 | export default class Todo extends Model { 4 | 5 | /** 6 | * Destroy the Todo by removing it from the collection. 7 | * 8 | * @return void 9 | */ 10 | destroy() { 11 | this.collection.remove(this.id); 12 | } 13 | 14 | /** 15 | * Get the id of the Todo. 16 | * 17 | * @return {number} 18 | */ 19 | get id() { 20 | return this.data.id; 21 | } 22 | 23 | /** 24 | * Set the title of the Todo. 25 | * 26 | * @param {string} title - the title of the Todo 27 | * @return void 28 | */ 29 | set title(title) { 30 | this.data.title = title.trim(); 31 | } 32 | 33 | /** 34 | * Get the title of the Todo. 35 | * 36 | * @return {string} 37 | */ 38 | get title() { 39 | return this.data.title; 40 | } 41 | 42 | /** 43 | * Get the complete status of the Todo. 44 | * 45 | * @return {boolean} 46 | */ 47 | get complete() { 48 | return this.data.complete; 49 | } 50 | 51 | /** 52 | * Sets the complete status of the Todo. 53 | * 54 | * @param {boolean} complete 55 | * @throws TypeError - if the argument passed is not of type boolean 56 | * @return void 57 | */ 58 | set complete(complete) { 59 | if (typeof complete !== 'boolean') { 60 | throw new TypeError(); 61 | } 62 | 63 | this.data.complete = complete; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /examples/react/todomvc/src/stores/models/index.js: -------------------------------------------------------------------------------- 1 | import Todo from './Todo'; 2 | 3 | export { 4 | Todo 5 | }; 6 | -------------------------------------------------------------------------------- /examples/react/todomvc/src/utils/index.js: -------------------------------------------------------------------------------- 1 | export { ESCAPE, ENTER } from './keys'; 2 | 3 | export function uuid() { 4 | return performance.now(); 5 | } 6 | 7 | export function objectIsEmpty(obj) { 8 | return Object.getOwnPropertyNames(obj).length > 0; 9 | } 10 | -------------------------------------------------------------------------------- /examples/react/todomvc/src/utils/keys.js: -------------------------------------------------------------------------------- 1 | export const ESCAPE = 27; 2 | export const ENTER = 13; 3 | -------------------------------------------------------------------------------- /examples/vue/README.md: -------------------------------------------------------------------------------- 1 | # Vue 2 | 3 | > Apologies the Vue examples are coming very soon! 4 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "trux", 3 | "description": "Unidirectional data layer for reactive user interfaces\"", 4 | "version": "3.0.6", 5 | "homepage": "https://github.com/rohan-deshpande/trux", 6 | "repository": "git://github.com/rohan-deshpande/trux", 7 | "author": "Rohan Deshpande (http://rohandeshpande.com/)", 8 | "license": "MIT", 9 | "scripts": { 10 | "build": "webpack --env build", 11 | "dev": "webpack --progress --colors --watch --env dev", 12 | "test": "mocha --compilers js:babel-core/register --colors ./test/*.spec.js", 13 | "test:watch": "mocha --compilers js:babel-core/register --colors -w ./test/*.spec.js", 14 | "lint": "eslint src test build", 15 | "coverage:generate": "./node_modules/.bin/istanbul cover ./node_modules/.bin/_mocha test/*.js -- --require babel-register", 16 | "coverage:publish": "./node_modules/.bin/istanbul-coveralls" 17 | }, 18 | "files": [ 19 | "dist" 20 | ], 21 | "keywords": [ 22 | "react", 23 | "reactjs", 24 | "vue", 25 | "flux", 26 | "unidirectional" 27 | ], 28 | "dependencies": { 29 | "rd-fetch": "^1.0.1", 30 | "wolfy87-eventemitter": "^5.1.0" 31 | }, 32 | "devDependencies": { 33 | "babel": "^6.23.0", 34 | "babel-core": "^6.24.1", 35 | "babel-eslint": "^7.2.1", 36 | "babel-loader": "^6.4.1", 37 | "babel-plugin-add-module-exports": "^0.2.1", 38 | "babel-preset-es2015": "^6.24.1", 39 | "chai": "^3.5.0", 40 | "eslint": "^3.19.0", 41 | "eslint-loader": "^1.7.1", 42 | "istanbul": "1.1.0-alpha.1", 43 | "istanbul-coveralls": "^1.0.3", 44 | "jsdoc": "~3.4.0", 45 | "json-server": "^0.9.6", 46 | "minami": "~1.1.1", 47 | "mocha": "^3.2.0", 48 | "node-fetch": "^1.6.3", 49 | "sinon": "^2.1.0", 50 | "webpack": "^2.3.3", 51 | "yargs": "^7.0.2" 52 | }, 53 | "main": "dist/trux.js" 54 | } 55 | -------------------------------------------------------------------------------- /src/Collection.js: -------------------------------------------------------------------------------- 1 | import Store from './Store'; 2 | import Fetch from 'rd-fetch'; 3 | 4 | export default class Collection extends Store { 5 | 6 | /** 7 | * A store for many models. 8 | * 9 | * @param {function} model - the constructor for the model which this collection contains 10 | * @return {object} Collection 11 | */ 12 | constructor(model) { 13 | super(); 14 | 15 | if (typeof model !== 'function') { 16 | throw new TypeError('You must supply a model constructor to a collection'); 17 | } 18 | 19 | /** 20 | * The model constructor for this collection. Defines what type of model this collection contains. 21 | * 22 | * @prop {function} 23 | */ 24 | this.model = model; 25 | 26 | /** 27 | * The models contained in this collection. 28 | * 29 | * @prop {array} 30 | */ 31 | this.models = []; 32 | 33 | return this; 34 | } 35 | 36 | /** 37 | * Fills the collection with models. 38 | * Instantiates a Model for each data item contained with in the passed array 39 | * and appends these models to the collection. 40 | * 41 | * @param {array} models - array of model data objects 42 | * @return {object} Collection 43 | */ 44 | fill(models) { 45 | const length = models.length; 46 | 47 | if (!Array.isArray(models)) { 48 | throw new TypeError('collections can only be filled with arrays of models'); 49 | } 50 | 51 | this.purge(); 52 | 53 | for (let i = 0; i < length; i++) { 54 | this.append(new this.model(models[i])); 55 | } 56 | 57 | return this; 58 | } 59 | 60 | /** 61 | * Appends a model to the collection's models. 62 | * 63 | * @param {object} model - a model, must be an instance of this.model 64 | * @return {object} Collection 65 | */ 66 | append(model) { 67 | if (!(model instanceof this.model)) { 68 | throw new Error('collections can only contain one kind of trux model'); 69 | } 70 | 71 | model.collection = this; 72 | this.models.push(model); 73 | 74 | return this; 75 | } 76 | 77 | /** 78 | * Prepends a model to the collection's models. 79 | * 80 | * @param {object} model - a model, must be an instance of this.model 81 | * @return {object} Collection 82 | */ 83 | prepend(model) { 84 | if (!(model instanceof this.model)) { 85 | throw new Error('collections can only contain one kind of trux model'); 86 | } 87 | 88 | model.collection = this; 89 | this.models.unshift(model); 90 | 91 | return this; 92 | } 93 | 94 | /** 95 | * Purges the collection of its models and removes the collection property from each model. 96 | * 97 | * @return void 98 | */ 99 | purge() { 100 | const length = this.models.length; 101 | 102 | for (let i = 0; i < length; i++) { 103 | this.models[i].collection = false; 104 | } 105 | 106 | this.models = []; 107 | } 108 | 109 | /** 110 | * Broadcasts changes to connected components. 111 | * 112 | * @return {object} Collection 113 | */ 114 | persist() { 115 | this.emitChangeEvent(); 116 | 117 | return this; 118 | } 119 | 120 | /** 121 | * Gets the collection from its remote resource. 122 | * 123 | * @param {string} [query] - optional query string to append to GET endpoint 124 | * @return {object} Promise 125 | */ 126 | fetch(query = '') { 127 | return Fetch.json(`${this.GET}${query}`, { 128 | method: 'GET', 129 | headers: this.requestHeaders 130 | }).then((response) => { 131 | this.wasFetched = true; 132 | this.fill(response.json).persist(); 133 | 134 | return Promise.resolve(response); 135 | }).catch((error) => { 136 | this.wasFetched = false; 137 | 138 | return Promise.reject(error); 139 | }); 140 | } 141 | 142 | /** 143 | * Extends Collection and returns the constructor for the new class. 144 | * This is a convenience method for ES5, it will me removed in the future. 145 | * 146 | * @deprecated 147 | * @param {object} props - custom props for the new class 148 | * @param {function|undefined} setup - an optional function to run within the new class' constructor 149 | * @return {function} Extension - the extended class 150 | */ 151 | static extend(props, setup) { 152 | const Extension = class extends Collection { 153 | constructor(model) { 154 | /* istanbul ignore else */ 155 | super(model); 156 | 157 | if (typeof setup === 'function') { 158 | setup(this); 159 | } 160 | } 161 | }; 162 | 163 | /* istanbul ignore else */ 164 | if (typeof props === 'object') { 165 | for (let prop in props) { 166 | /* istanbul ignore else */ 167 | if (props.hasOwnProperty(prop)) { 168 | Extension.prototype[prop] = props[prop]; 169 | } 170 | } 171 | } 172 | 173 | return Extension; 174 | } 175 | 176 | /** 177 | * Modifies the Collection class with the passed properties. 178 | * This will enable all custom collections to inherit the properties passed to this method. 179 | * This is a convenience method for ES5, it will me removed in the future. 180 | * 181 | * @deprecated 182 | * @param {object} props - the props to add to the Collection class 183 | * @return void 184 | */ 185 | static modify(props) { 186 | if (typeof props !== 'object') { 187 | throw new TypeError('You must modify Collection with a properties object'); 188 | } 189 | 190 | for (let prop in props) { 191 | /* istanbul ignore else */ 192 | if (props.hasOwnProperty(prop)) { 193 | Collection.prototype[prop] = props[prop]; 194 | } 195 | } 196 | } 197 | } 198 | -------------------------------------------------------------------------------- /src/Model.js: -------------------------------------------------------------------------------- 1 | import Store from './Store'; 2 | import Fetch from 'rd-fetch'; 3 | 4 | export default class Model extends Store { 5 | 6 | /** 7 | * A client side interface for a remote data Model. 8 | * 9 | * @param {object} data - the data which defines this Model 10 | * @return {object} this - this Model 11 | * @constructor 12 | */ 13 | constructor(data) { 14 | super(); 15 | 16 | let backup = (!data || Object.keys(data).length === 0) ? {} : JSON.parse(JSON.stringify(data)); 17 | 18 | /** 19 | * The data which defines the model. Defaults to null. 20 | * 21 | * @prop {object|null} 22 | */ 23 | this.data = data || null; 24 | 25 | /** 26 | * The collection the model belongs to. Defaults to false. 27 | * 28 | * @prop {boolean|object} 29 | */ 30 | this.collection = false; 31 | 32 | /** 33 | * Boolean to determine if the model has been updated locally and remotely. 34 | * 35 | * @prop {boolean} 36 | */ 37 | this.wasUpdated = false; 38 | 39 | /** 40 | * Boolean to determine if the model has been created remotely. 41 | * 42 | * @prop {boolean} 43 | */ 44 | this.wasCreated = false; 45 | 46 | /** 47 | * Boolean to determine if the model has been destroyed locally and remotely. 48 | * 49 | * @prop {boolean} 50 | */ 51 | this.wasDestroyed = false; 52 | 53 | /** 54 | * Fills the model with data and sets the private backup for the model. 55 | * 56 | * @param {object} data - the data that defines this model 57 | * @return {object} Model 58 | */ 59 | this.fill = (data) => { 60 | this.data = data; 61 | backup = (!data || Object.keys(data).length === 0) ? {} : JSON.parse(JSON.stringify(data)); 62 | 63 | return this; 64 | }; 65 | 66 | /** 67 | * Restores the model's data to its previous state. 68 | * 69 | * @return {object} Model 70 | */ 71 | this.restore = () => { 72 | this.data = (!backup || Object.keys(backup).length === 0) ? {} : JSON.parse(JSON.stringify(backup)); 73 | 74 | return this; 75 | }; 76 | } 77 | 78 | /** 79 | * Persits the model's data throughout its connected components. If this model belongs to a collection, 80 | * the collection's connected components are updated by default. 81 | * 82 | * @param {boolean} [collection] - optionally ensure that even the model belongs to a collection, 83 | * the collection is not persisted. 84 | * @return {object} Model 85 | */ 86 | persist(collection = true) { 87 | if (collection && this.collection) { 88 | this.collection.emitChangeEvent(); 89 | } 90 | 91 | this.emitChangeEvent(); 92 | 93 | return this; 94 | } 95 | 96 | /** 97 | * Fetches the remote data for the model, then fills the model with the JSON response. 98 | * 99 | * @param {string} [query] - optional query string to append to GET endpoint 100 | * @return {object} Promise 101 | */ 102 | fetch(query = '') { 103 | return Fetch.json(`${this.GET}${query}`, { 104 | method: 'GET', 105 | headers: this.requestHeaders 106 | }).then((response) => { 107 | this.wasFetched = true; 108 | this.fill(response.json).persist(); 109 | 110 | return Promise.resolve(response); 111 | }).catch((error) => { 112 | this.wasFetched = false; 113 | 114 | return Promise.reject(error); 115 | }); 116 | } 117 | 118 | /** 119 | * Creates a new model in the remote data store. 120 | * 121 | * @param {object} data - the data for the new model 122 | * @return {object} Promise 123 | */ 124 | create(data) { 125 | return Fetch.json(this.POST, { 126 | method: 'POST', 127 | headers: this.requestHeaders, 128 | body: data 129 | }).then((response) => { 130 | this.wasCreated = true; 131 | this.fill(response.json).persist(); 132 | 133 | return Promise.resolve(response); 134 | }).catch((error) => { 135 | this.wasCreated = false; 136 | 137 | return Promise.reject(error); 138 | }); 139 | } 140 | 141 | /** 142 | * Updates the model in the remote data store and fills the model with the response payload. 143 | * 144 | * @param {object} [options] - configuration options 145 | * @param {object} [options.data] - the data to update the model with, defaults to the current model data 146 | * @param {string} [options.method] - the method to use, should be either PUT or PATCH, defaults to PUT 147 | * @param {boolean} [options.optimistic] - boolean to determine if this update was already persisted optimistically 148 | * @param {boolean} [options.collection] - collection argument for the persist method 149 | * @return {object} Promise 150 | */ 151 | update(options = {}) { 152 | const data = options.data || this.data; 153 | const method = options.method || 'PUT'; 154 | const optimistic = options.optimistic || false; 155 | const collection = options.collection || true; 156 | 157 | if (optimistic) { 158 | this.persist(collection); 159 | } 160 | 161 | return Fetch.json(this[method], { 162 | method: method, 163 | headers: this.requestHeaders, 164 | body: data 165 | }).then((response) => { 166 | this.wasUpdated = true; 167 | // even though we may have already updated optimistically, we need to broadcast once again 168 | // because it is possible that the data set to the remote store is a factor for a computed property 169 | // which the response will contain. 170 | this.fill(response.json).persist(collection); 171 | 172 | return Promise.resolve(response); 173 | }).catch((error) => { 174 | this.wasUpdated = false; 175 | this.restore().persist(); 176 | 177 | return Promise.reject(error); 178 | }); 179 | } 180 | 181 | /** 182 | * Sends a request to delete from the remote data store, then purges and disconnects all components from the model. 183 | * 184 | * @return {object} Promise 185 | */ 186 | destroy() { 187 | return Fetch.json(this.DELETE, { 188 | method: 'DELETE', 189 | headers: this.requestHeaders 190 | }).then((response) => { 191 | this.wasDestroyed = true; 192 | this.purge().close(); 193 | 194 | return Promise.resolve(response); 195 | }).catch((error) => { 196 | this.wasDestroyed = false; 197 | this.restore().persist(); 198 | 199 | return Promise.reject(error); 200 | }); 201 | } 202 | 203 | /** 204 | * Purges the model of its data. 205 | * 206 | * @return {object} Model 207 | */ 208 | purge() { 209 | this.data = null; 210 | 211 | return this; 212 | } 213 | 214 | /** 215 | * Sets the wasCreated and wasCreatedAt properties. 216 | * 217 | * @param {boolean} wasCreated 218 | * @return void 219 | */ 220 | set wasCreated(wasCreated) { 221 | this._wasCreated = (wasCreated) ? true : false; 222 | this._wasCreatedAt = (wasCreated) ? this.getUnixTimestamp() : this.wasCreatedAt; 223 | } 224 | 225 | /** 226 | * Gets the wasCreated property. 227 | * 228 | * @return {boolean} 229 | */ 230 | get wasCreated() { 231 | return this._wasCreated; 232 | } 233 | 234 | /** 235 | * Gets the wasCreatedAt timestamp. 236 | * 237 | * @return {number} 238 | */ 239 | get wasCreatedAt() { 240 | return this._wasCreatedAt; 241 | } 242 | 243 | /** 244 | * Sets the wasUpdated and wasUpdatedAt properties. 245 | * 246 | * @param {boolean} wasUpdated 247 | * @return void 248 | */ 249 | set wasUpdated(wasUpdated) { 250 | this._wasUpdated = (wasUpdated) ? true : false; 251 | this._wasUpdatedAt = (wasUpdated) ? this.getUnixTimestamp() : this.wasUpdatedAt; 252 | } 253 | 254 | /** 255 | * Gets the wasUpdated property. 256 | * 257 | * @return {boolean} 258 | */ 259 | get wasUpdated() { 260 | return this._wasUpdated; 261 | } 262 | 263 | /** 264 | * Gets the wasUpdatedAt property. 265 | * 266 | * @return {number} 267 | */ 268 | get wasUpdatedAt() { 269 | return this._wasUpdatedAt; 270 | } 271 | 272 | /** 273 | * Sets the wasDestroyed and wasDestroyedAt properties. 274 | * 275 | * @param {boolean} wasDestroyed 276 | * @return void 277 | */ 278 | set wasDestroyed(wasDestroyed) { 279 | this._wasDestroyed = (wasDestroyed) ? true : false; 280 | this._wasDestroyedAt = (wasDestroyed) ? this.getUnixTimestamp() : this.wasDestroyedAt; 281 | } 282 | 283 | /** 284 | * Gets the wasDestroyed property. 285 | * 286 | * @return {boolean} 287 | */ 288 | get wasDestroyed() { 289 | return this._wasDestroyed; 290 | } 291 | 292 | /** 293 | * Gets the wasDestroyedAt property. 294 | * 295 | * @return {number} 296 | */ 297 | get wasDestroyedAt() { 298 | return this._wasDestroyedAt; 299 | } 300 | 301 | /** 302 | * Extends Model and returns the constructor for the new class. 303 | * This is a convenience method for ES5, it will me removed in the future. 304 | * 305 | * @deprecated 306 | * @param {object} props - custom props for the new class 307 | * @param {function|undefined} setup - an optional function to run within the new class' constructor 308 | * @return {function} Extension - the extended class 309 | */ 310 | static extend(props, setup) { 311 | const Extension = class extends Model { 312 | constructor(data) { 313 | super(data); 314 | 315 | if (typeof setup === 'function') { 316 | setup(this); 317 | } 318 | } 319 | }; 320 | 321 | /* istanbul ignore else */ 322 | if (typeof props === 'object') { 323 | for (let prop in props) { 324 | /* istanbul ignore else */ 325 | if (props.hasOwnProperty(prop)) { 326 | Extension.prototype[prop] = props[prop]; 327 | } 328 | } 329 | } 330 | 331 | return Extension; 332 | } 333 | 334 | /** 335 | * Modifies the Model class with the passed properties. 336 | * This will enable all custom models to inherit the properties passed to this method. 337 | * This is a convenience method for ES5, it will me removed in the future. 338 | * 339 | * @deprecated 340 | * @param {object} props - the props to add to the Trux.Model class 341 | * @return void 342 | */ 343 | static modify(props) { 344 | if (typeof props !== 'object') { 345 | throw new TypeError('You must modify Model with a properties object'); 346 | } 347 | 348 | for (let prop in props) { 349 | /* istanbul ignore else */ 350 | if (props.hasOwnProperty(prop)) { 351 | Model.prototype[prop] = props[prop]; 352 | } 353 | } 354 | } 355 | } 356 | -------------------------------------------------------------------------------- /src/Store.js: -------------------------------------------------------------------------------- 1 | import EventEmitter from 'wolfy87-eventemitter'; 2 | 3 | export default class Store { 4 | constructor() { 5 | /** 6 | * Private reference to this store 7 | * 8 | * @private 9 | * @prop {object} 10 | */ 11 | const store = this; 12 | 13 | /** 14 | * Reference for connected components 15 | * 16 | * @prop {object} 17 | */ 18 | this.components = {}; 19 | 20 | /** 21 | * The store's Event Emitter 22 | * 23 | * @prop {object} 24 | */ 25 | this.emitter = new EventEmitter(); 26 | 27 | /** 28 | * Headers to be sent with the request 29 | * 30 | * @prop {object} 31 | */ 32 | this.requestHeaders = { 33 | 'Content-Type': 'application/json', 34 | }; 35 | 36 | /** 37 | * The GET route for the store 38 | * 39 | * @prop {string} 40 | */ 41 | this.GET = ''; 42 | 43 | /** 44 | * The POST route for the store 45 | * 46 | * @prop {string} 47 | */ 48 | this.POST = ''; 49 | 50 | /** 51 | * The PUT route for the store 52 | * 53 | * @prop {string} 54 | */ 55 | this.PUT = ''; 56 | 57 | /** 58 | * The PATCH route for the store 59 | * 60 | * @prop {string} 61 | */ 62 | this.PATCH = ''; 63 | 64 | /** 65 | * The DELETE route for the store 66 | * 67 | * @prop {string} 68 | */ 69 | this.DELETE = ''; 70 | 71 | /** 72 | * Boolean to determine if changes to the store has been broadcast. 73 | * 74 | * @prop {boolean} 75 | */ 76 | this.wasBroadcast = false; 77 | 78 | /** 79 | * Boolean to determine if the store has been fetched from the remote resource. 80 | * 81 | * @prop {boolean} 82 | */ 83 | this.wasFetched = false; 84 | 85 | /** 86 | * Broadcast changes to all connected components. 87 | * 88 | * @private 89 | * @return void 90 | */ 91 | function broadcast() { 92 | store.wasBroadcast = true; 93 | 94 | if (!Object.keys(store.components).length) { 95 | return; 96 | } 97 | 98 | for (let prop in store.components) { 99 | if (store.components.hasOwnProperty(prop)) { 100 | store.components[prop].storeDidUpdate(); 101 | } 102 | } 103 | } 104 | 105 | this.emitter.addListener('change', broadcast); 106 | } 107 | 108 | /** 109 | * Connects a component to the store and ensures the component receives updates via broadcast. 110 | * Throws a ReferenceError if the component does not have a truxid defined and triggers a 111 | * console warning if the component does not have a storeDidUpdate method. 112 | * 113 | * @NOTE For React, this should be called within the component's componentDidMount method. 114 | * 115 | * @param {object} component - the component to connect to this store 116 | * @throws ReferenceError - if component.truxid is undefined 117 | * @return void 118 | */ 119 | connect(component) { 120 | if (typeof component.truxid === 'undefined') { 121 | throw new ReferenceError('You must set a truxid on your component before connecting it to a store.'); 122 | } 123 | 124 | this.components[component.truxid] = component; 125 | 126 | if (typeof component.storeDidUpdate !== 'function') { 127 | console.warn('The component you have connected to this store does not contain a storeDidUpdate method.'); 128 | } 129 | } 130 | 131 | /** 132 | * Disconnects a component from the store, stopping it from receiving updates. 133 | * 134 | * @NOTE For React, this should be called within the component's componentWillUnmount method. 135 | * 136 | * @param {object} component - the component to disconnect from this store 137 | * @throws ReferenceError - if component.truxid is undefined 138 | * @return void 139 | */ 140 | disconnect(component) { 141 | if (typeof this.components[component.truxid] === 'undefined') { 142 | throw new ReferenceError('The component you are attempting to disconnect is not connected to this store.'); 143 | } 144 | 145 | delete this.components[component.truxid]; 146 | } 147 | 148 | /** 149 | * Disconnects all components from the store. 150 | * 151 | * @return {object} Store 152 | */ 153 | close() { 154 | for (let truxid in this.components) { 155 | if (this.components.hasOwnProperty(truxid)) { 156 | delete this.components[truxid]; 157 | } 158 | } 159 | 160 | return this; 161 | } 162 | 163 | /** 164 | * Emits a change event from the store. 165 | * 166 | * @fires this.emitter.change 167 | * @return void 168 | */ 169 | emitChangeEvent() { 170 | this.emitter.emitEvent('change'); 171 | } 172 | 173 | /** 174 | * Adds a request header. 175 | * 176 | * @param {string} key - the key for the header 177 | * @param {mixed} value - the value for the header 178 | * @return {object} Store 179 | */ 180 | addRequestHeader(key, value) { 181 | this.requestHeaders[key] = value; 182 | 183 | return this; 184 | } 185 | 186 | /** 187 | * Deletes a request header. 188 | * 189 | * @param {string} key - the key for the header to delete 190 | * @return {object} Store 191 | */ 192 | deleteRequestHeader(key) { 193 | delete this.requestHeaders[key]; 194 | 195 | return this; 196 | } 197 | 198 | /** 199 | * Helper to get the current unix timestamp in ms. 200 | * 201 | * @return {number} 202 | */ 203 | getUnixTimestamp() { 204 | return Date.now(); 205 | } 206 | 207 | /** 208 | * Set the store's request headers. 209 | * 210 | * @param {object} headers - headers object 211 | * @return void 212 | */ 213 | set requestHeaders(headers) { 214 | this._requestHeaders = headers; 215 | } 216 | 217 | /** 218 | * Gets the store's request headers. 219 | * 220 | * @return {object} 221 | */ 222 | get requestHeaders() { 223 | return this._requestHeaders; 224 | } 225 | 226 | /** 227 | * Sets the wasBroadcast boolean and wasBroadcastAt timestamp properties. 228 | * 229 | * @param {boolean} wasBroadcast 230 | * @return void 231 | */ 232 | set wasBroadcast(wasBroadcast) { 233 | this._wasBroadcast = (wasBroadcast) ? true : false; 234 | this._wasBroadcastAt = (wasBroadcast) ? this.getUnixTimestamp() : this.wasBroadcastAt; 235 | } 236 | 237 | /** 238 | * Gets the wasBroadcast property. 239 | * 240 | * @return {boolean} 241 | */ 242 | get wasBroadcast() { 243 | return this._wasBroadcast; 244 | } 245 | 246 | /** 247 | * Gets the wasBroadcastAt property. 248 | * 249 | * @return {number} 250 | */ 251 | get wasBroadcastAt() { 252 | return this._wasBroadcastAt; 253 | } 254 | 255 | /** 256 | * Sets the wasFetched boolean and wasFetchedAt timestamp properties. 257 | * 258 | * @param {boolean} wasFetched 259 | * @return void 260 | */ 261 | set wasFetched(wasFetched) { 262 | this._wasFetched = (wasFetched) ? true : false; 263 | this._wasFetchedAt = (wasFetched) ? this.getUnixTimestamp() : this.wasFetchedAt; 264 | } 265 | 266 | /** 267 | * Gets the wasFetched property. 268 | * 269 | * @return {boolean} 270 | */ 271 | get wasFetched() { 272 | return this._wasFetched; 273 | } 274 | 275 | /** 276 | * Gets the wasFetchedAt property. 277 | * 278 | * @return {number} 279 | */ 280 | get wasFetchedAt() { 281 | return this._wasFetchedAt; 282 | } 283 | } 284 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import Model from './Model'; 2 | import Collection from './Collection'; 3 | 4 | export { Model, Collection }; 5 | -------------------------------------------------------------------------------- /test/collection.spec.js: -------------------------------------------------------------------------------- 1 | /*global describe, it, beforeEach, afterEach */ 2 | 3 | import chai from 'chai'; 4 | import { Model, Collection } from '../src/index.js'; 5 | import { startServer, stopServer, endpoints } from './server.js'; 6 | import fetch from 'node-fetch'; 7 | 8 | chai.expect(); 9 | 10 | global.fetch = fetch; // eslint-disable-line no-undef 11 | 12 | const test = 'Collection'; 13 | const assert = chai.assert; 14 | const users = [{ id: 1, name: 'foo' }, { id: 2, name: 'bar' }, { id: 3, name: 'baz' }]; 15 | const User = Model.extend(); 16 | const Post = Model.extend({ 17 | getTitle: function() { 18 | return this.data.title; 19 | } 20 | }); 21 | 22 | describe(`${test} constructor`, () => { 23 | it('shound throw an error if no model constructor supplied', (done) => { 24 | assert.throws(() => new Collection(), TypeError, 'You must supply a model constructor to a collection'); 25 | done(); 26 | }); 27 | 28 | it('should set the model constructor', (done) => { 29 | const collection = new Collection(User); 30 | 31 | assert.isTrue(collection.model instanceof User.constructor); 32 | done(); 33 | }); 34 | 35 | it('should set the models property as an array', (done) => { 36 | const collection = new Collection(User); 37 | 38 | assert.isTrue(Array.isArray(collection.models)); 39 | done(); 40 | }); 41 | 42 | it('should return an instance of the collection', (done) => { 43 | const collection = new Collection(User); 44 | 45 | assert.isTrue(collection instanceof Collection); 46 | done(); 47 | }); 48 | }); 49 | 50 | describe(`${test} methods`, () => { 51 | it('should have a fill method', (done) => { 52 | const collection = new Collection(User); 53 | 54 | assert.isTrue(typeof collection.fill === 'function'); 55 | done(); 56 | }); 57 | 58 | it('should have a purge method', (done) => { 59 | const collection = new Collection(User); 60 | 61 | assert.isTrue(typeof collection.purge === 'function'); 62 | done(); 63 | }); 64 | 65 | it('should have a fetch method', (done) => { 66 | const collection = new Collection(User); 67 | 68 | assert.isTrue(typeof collection.fetch === 'function'); 69 | done(); 70 | }); 71 | 72 | it('should throw a type error when fill is called with the wrong argument type', (done) => { 73 | const collection = new Collection(User); 74 | 75 | assert.throws(() => collection.fill({ id: 1 }), TypeError, 'collections can only be filled with arrays of models'); 76 | done(); 77 | }); 78 | 79 | it('should throw an error when trying to append or prepend an invalid model', (done) => { 80 | const collection = new Collection(User); 81 | 82 | assert.throws(() => collection.append({ id: 1 }), Error, 'collections can only contain one kind of trux model'); 83 | assert.throws(() => collection.prepend({ id: 1 }), Error, 'collections can only contain one kind of trux model'); 84 | done(); 85 | }); 86 | 87 | it('should fill with models when fill is called and passed an array of models', (done) => { 88 | const collection = new Collection(User); 89 | const usersNum = users.length; 90 | 91 | assert.isTrue(collection.models.length === 0); 92 | collection.fill(users); 93 | assert.isTrue(collection.models.length === usersNum); 94 | done(); 95 | }); 96 | 97 | it('should empty its models when purge is called', (done) => { 98 | const collection = new Collection(User); 99 | collection.fill(users); 100 | 101 | assert.isTrue(collection.models.length > 0); 102 | collection.purge(); 103 | assert.isTrue(collection.models.length === 0); 104 | 105 | done(); 106 | }); 107 | 108 | it('the prepend method should be able to prepend models', (done) => { 109 | const collection = new Collection(User); 110 | const qux = new User({ id: 4, name: 'qux'}); 111 | collection.fill(users); 112 | collection.prepend(qux); 113 | 114 | assert.isTrue(collection.models[0].data.id === 4); 115 | done(); 116 | }); 117 | }); 118 | 119 | describe(`${test} requests`, () => { 120 | beforeEach(() => { 121 | startServer(); 122 | }); 123 | 124 | afterEach(() => { 125 | stopServer(); 126 | }); 127 | 128 | it('should fill the collection with models after the fetch request has resolved', (done) => { 129 | const posts = new Collection(Post); 130 | 131 | posts.GET = endpoints.posts; 132 | posts.fetch() 133 | .then(() => { 134 | assert.isTrue(posts.models.length !== 0); 135 | assert.isTrue(posts.models[0].getTitle() === 'baz'); 136 | done(); 137 | }) 138 | .catch(() => { 139 | done('fetch failed'); 140 | }); 141 | }); 142 | 143 | it('should catch the error when fetch fails', (done) => { 144 | const posts = new Collection(Post); 145 | 146 | posts.GET = endpoints.notfound; 147 | 148 | posts.fetch() 149 | .catch((error) => { 150 | assert.isTrue(error.status === 404); 151 | done(); 152 | }); 153 | }); 154 | }); 155 | 156 | describe(`${test} statics`, () => { 157 | it('should have a static extend method', (done) => { 158 | assert.isTrue(typeof Collection.extend === 'function'); 159 | done(); 160 | }); 161 | 162 | it('should have a static modify method', (done) => { 163 | assert.isTrue(typeof Collection.modify === 'function'); 164 | done(); 165 | }); 166 | 167 | it('static extend method should generate a constructor which is an extension of Collection', (done) => { 168 | const Posts = Collection.extend({ 169 | findById: function (id) { 170 | let post = false; 171 | 172 | this.models.forEach((item) => { 173 | if (item.data.id === id) { 174 | post = item; 175 | return; 176 | } 177 | }); 178 | 179 | return post; 180 | } 181 | }, (collection) => { 182 | collection.GET = endpoints.posts; 183 | }); 184 | const posts = new Posts(Post); 185 | 186 | posts.fill([ 187 | { 188 | 'title': 'baz', 189 | 'author': 'foo', 190 | 'id': 1 191 | }, 192 | { 193 | 'title': 'qux', 194 | 'author': 'bar', 195 | 'id': 1 196 | } 197 | ]); 198 | 199 | assert.isTrue(posts.GET === endpoints.posts); 200 | assert.isTrue(typeof posts.findById === 'function'); 201 | assert.isTrue(typeof posts.findById(1) === 'object'); 202 | done(); 203 | }); 204 | 205 | it('static modify method should modify the Collection class', (done) => { 206 | Collection.modify({ findById: function() {}}); 207 | const modified = new Collection(Post); 208 | 209 | assert.isTrue(typeof modified.findById === 'function'); 210 | done(); 211 | }); 212 | 213 | it('static modify method should throw a TypeError if no props passed', (done) => { 214 | assert.throws(() => Collection.modify(), TypeError, 'You must modify Collection with a properties object'); 215 | done(); 216 | }); 217 | }); 218 | -------------------------------------------------------------------------------- /test/model.spec.js: -------------------------------------------------------------------------------- 1 | /*global describe, it, beforeEach, afterEach */ 2 | 3 | import chai from 'chai'; 4 | import { Model, Collection } from '../src/index.js'; 5 | import { startServer, stopServer, endpoints, token } from './server.js'; 6 | import fetch from 'node-fetch'; 7 | 8 | chai.expect(); 9 | 10 | global.fetch = fetch; // eslint-disable-line no-undef 11 | 12 | const test = 'Model'; 13 | const assert = chai.assert; 14 | const data = { id: 1, name: 'foobar' }; 15 | const model = new Model(data); 16 | 17 | describe(`${test} constructor`, () => { 18 | it('should be filled with the data supplied to the constructor', (done) => { 19 | assert.isTrue(model.data !== null); 20 | assert.isTrue(JSON.stringify(data) === JSON.stringify(model.data)); 21 | done(); 22 | }); 23 | 24 | it('should not belong to a collection yet', (done) => { 25 | assert.isFalse(model.collection); 26 | done(); 27 | }); 28 | 29 | it('should set a fill method', (done) => { 30 | assert.isTrue(typeof model.fill === 'function'); 31 | done(); 32 | }); 33 | 34 | it('should set a restore method', (done) => { 35 | assert.isTrue(typeof model.restore === 'function'); 36 | done(); 37 | }); 38 | 39 | it('should set the wasCreated property to false', (done) => { 40 | assert.isFalse(model.wasCreated); 41 | done(); 42 | }); 43 | 44 | it('should set the wasUpdated property to false', (done) => { 45 | assert.isFalse(model.wasUpdated); 46 | done(); 47 | }); 48 | 49 | it('should set the wasDestroyed property to false', (done) => { 50 | assert.isFalse(model.wasDestroyed); 51 | done(); 52 | }); 53 | }); 54 | 55 | describe(`${test} protoype`, () => { 56 | it('should have a persist method', (done) => { 57 | assert.isTrue(typeof model.persist === 'function'); 58 | done(); 59 | }); 60 | 61 | it('should have a fetch method', (done) => { 62 | assert.isTrue(typeof model.fetch === 'function'); 63 | done(); 64 | }); 65 | 66 | it('should have a create method', (done) => { 67 | assert.isTrue(typeof model.create === 'function'); 68 | done(); 69 | }); 70 | 71 | it('should have an update method', (done) => { 72 | assert.isTrue(typeof model.update === 'function'); 73 | done(); 74 | }); 75 | 76 | it('should have a destroy method', (done) => { 77 | assert.isTrue(typeof model.destroy === 'function'); 78 | done(); 79 | }); 80 | }); 81 | 82 | describe(`${test} methods`, () => { 83 | it('should persist changes to both its collection\'s connected components and its connected components', (done) => { 84 | const collection = new Collection(Model); 85 | 86 | collection.append(model); 87 | model.data.name = 'baz'; 88 | model.persist(); 89 | 90 | assert.isTrue(model.wasBroadcast); 91 | assert.isTrue(collection.wasBroadcast); 92 | done(); 93 | }); 94 | 95 | it('should persist changes to its connected components even when it belongs to a collection if false is passed to persist', (done) => { 96 | const collection = new Collection(Model); 97 | 98 | collection.append(model); 99 | model.data.name = 'baz'; 100 | model.persist(false); 101 | 102 | assert.isTrue(model.wasBroadcast); 103 | assert.isFalse(collection.wasBroadcast); 104 | done(); 105 | }); 106 | 107 | it('should restore the data to its previous state', (done) => { 108 | model.data.name = 'baz'; 109 | model.restore(); 110 | 111 | assert.isTrue(model.data.name === 'foobar'); 112 | done(); 113 | }); 114 | }); 115 | 116 | describe(`${test} requests`, () => { 117 | beforeEach(() => { 118 | startServer(); 119 | }); 120 | 121 | afterEach(() => { 122 | stopServer(); 123 | }); 124 | 125 | it('model.fetch should fill the model with the correct data', (done) => { 126 | const profile = new Model(); 127 | profile.GET = endpoints.profile; 128 | 129 | profile.fetch() 130 | .then(() => { 131 | assert.isTrue(profile.data.name === 'foo'); 132 | done(); 133 | }) 134 | .catch(() => { 135 | done('request failed'); 136 | }); 137 | }); 138 | 139 | it('model.fetch should set the wasFetched and wasFetchedAt properties', (done) => { 140 | const profile = new Model(); 141 | profile.GET = endpoints.profile; 142 | 143 | profile.fetch() 144 | .then(() => { 145 | assert.isTrue(profile.wasFetched); 146 | assert.isTrue(typeof profile.wasFetchedAt !== 'undefined'); 147 | done(); 148 | }) 149 | .catch(() => { 150 | done('fetch failed'); 151 | }); 152 | }); 153 | 154 | it('model.fetch should set wasFetched to false if an error occurs', (done) => { 155 | const profile = new Model(); 156 | profile.GET = endpoints.notfound; 157 | 158 | profile.fetch() 159 | .catch(() => { 160 | assert.isFalse(profile.wasFetched); 161 | assert.isTrue(typeof profile.wasFetchedAt === 'undefined'); 162 | done(); 163 | }); 164 | }); 165 | 166 | it('can chain request methods for custom uses eg., token retrieval from Authorization header', (done) => { 167 | const profile = new Model(); 168 | profile.GET = endpoints.auth; 169 | 170 | profile.fetch() 171 | .then((response) => { 172 | assert.isTrue(response.headers.get('authorization') === token); 173 | done(); 174 | }) 175 | .catch(() => { 176 | done('request failed'); 177 | }); 178 | }); 179 | 180 | it('model.create should create a record and update the component', (done) => { 181 | const comment = new Model(); 182 | const component = { 183 | truxid: 'comment', 184 | storeDidUpdate: () => { 185 | component.body = comment.data.body; 186 | }, 187 | body: '' 188 | }; 189 | 190 | comment.POST = endpoints.comments; 191 | comment.connect(component); 192 | 193 | assert.isTrue(component.body === ''); 194 | 195 | comment.create({ 196 | body: 'foo', 197 | postId: 1 198 | }).then((response) => { 199 | assert.isTrue(JSON.stringify(comment.data) === JSON.stringify(response.json)); 200 | assert.isTrue(component.body === 'foo'); 201 | done(); 202 | }).catch(() => { 203 | done('post failed'); 204 | }); 205 | }); 206 | 207 | it('model.create should set the wasCreated and wasCreatedAt properties', (done) => { 208 | const comment = new Model(); 209 | 210 | comment.POST = endpoints.comments; 211 | 212 | comment.create({ 213 | body: 'bar', 214 | postId: 1 215 | }).then(() => { 216 | assert.isTrue(comment.wasCreated); 217 | assert.isTrue(typeof comment.wasCreatedAt !== 'undefined'); 218 | done(); 219 | }).catch(() => { 220 | done('post failed'); 221 | }); 222 | }); 223 | 224 | it('model.create should set wasCreated to false if an error occurs', (done) => { 225 | const profile = new Model(); 226 | profile.POST = endpoints.notfound; 227 | 228 | profile.create({ 229 | body: 'bar', 230 | postId: 1 231 | }).catch(() => { 232 | assert.isFalse(profile.wasCreated); 233 | done(); 234 | }); 235 | }); 236 | 237 | it('model.update should update the remote data and update the component', (done) => { 238 | const post = new Model(); 239 | const component = { 240 | truxid: 'post', 241 | storeDidUpdate: () => { 242 | component.title = post.data.title; 243 | component.author = post.data.author; 244 | }, 245 | title: '', 246 | author: '' 247 | }; 248 | 249 | post.PUT = `${endpoints.posts}/1`; 250 | post.connect(component); 251 | 252 | assert.isTrue(component.author === ''); 253 | 254 | post.update({ 255 | data: { 256 | 'title': 'foo', 257 | 'author': 'bar' 258 | } 259 | }).then((response) => { 260 | assert.isTrue(JSON.stringify(post.data) === JSON.stringify(response.json)); 261 | assert.isTrue(component.author === 'bar'); 262 | assert.isTrue(component.title === 'foo'); 263 | done(); 264 | }).catch(() => { 265 | done('update failed'); 266 | }); 267 | }); 268 | 269 | it('model.update should set the wasUpdated and wasUpdatedAt properties', (done) => { 270 | const post = new Model(); 271 | 272 | post.PUT = `${endpoints.posts}/1`; 273 | 274 | post.update({data: { title: 'baz' }}) 275 | .then(() => { 276 | assert.isTrue(post.wasUpdated); 277 | assert.isTrue(typeof post.wasUpdatedAt !== 'undefined'); 278 | done(); 279 | }) 280 | .catch(() => { 281 | done('update failed'); 282 | }); 283 | }); 284 | 285 | it('model.update should set the wasBroadcast and wasBroadcastAt properties', (done) => { 286 | const post = new Model(); 287 | 288 | post.PUT = `${endpoints.posts}/1`; 289 | 290 | post.update({data: { title: 'qux' }}) 291 | .then(() => { 292 | assert.isTrue(post.wasBroadcast); 293 | assert.isTrue(typeof post.wasBroadcastAt !== 'undefined'); 294 | done(); 295 | }) 296 | .catch((error) => { 297 | done(error); 298 | }); 299 | }); 300 | 301 | it('model.update should update the component optimistically if desired', (done) => { 302 | const post = new Model(); 303 | const component = { 304 | broadcastCount: 0, 305 | truxid: 'POST', 306 | storeDidUpdate: () => { 307 | component.broadcastCount++; 308 | } 309 | }; 310 | 311 | post.connect(component); 312 | post.PUT = `${endpoints.posts}/1`; 313 | 314 | post.update({ 315 | data: { title: 'qux' }, 316 | optimistic: true 317 | }) 318 | .then(() => { 319 | assert.isTrue(component.broadcastCount === 2); 320 | done(); 321 | }) 322 | .catch((error) => { 323 | done(error); 324 | }); 325 | }); 326 | 327 | it('model.delete should delete the remote record and nullify trux model data', (done) => { 328 | const comment = new Model(); 329 | 330 | comment.DELETE = `${endpoints.comments}/1`; 331 | 332 | comment.destroy() 333 | .then(() => { 334 | assert.isTrue(comment.data === null); 335 | done(); 336 | }) 337 | .catch(() => { 338 | done('delete failed'); 339 | }); 340 | }); 341 | 342 | it('model.delete should disconnect all components from itself', (done) => { 343 | const comment = new Model(); 344 | const body = { 345 | truxid: 'comment', 346 | content: 'foo', 347 | storeDidUpdate: () => { 348 | body.content = 'bar'; 349 | } 350 | }; 351 | 352 | comment.connect(body); 353 | comment.DELETE = `${endpoints.comments}/2`; 354 | 355 | comment.destroy() 356 | .then(() => { 357 | assert.isTrue(Object.keys(comment.components).length === 0); 358 | assert.isTrue(body.content === 'foo'); 359 | done(); 360 | }) 361 | .catch(() => { 362 | done('delete failed'); 363 | }); 364 | }); 365 | 366 | it('model.delete should set the wasDestroyed and wasDestroyedAt properties', (done) => { 367 | const comment = new Model(); 368 | 369 | comment.DELETE = `${endpoints.comments}/3`; 370 | 371 | comment.destroy() 372 | .then(() => { 373 | assert.isTrue(comment.wasDestroyed); 374 | assert.isTrue(typeof comment.wasDestroyedAt !== 'undefined'); 375 | done(); 376 | }) 377 | .catch(() => { 378 | done('delete failed'); 379 | }); 380 | }); 381 | 382 | it('model.delete should set wasDestroyed to false if an error occurs', (done) => { 383 | const profile = new Model(); 384 | profile.DELETE = endpoints.notfound; 385 | 386 | profile.destroy() 387 | .catch(() => { 388 | assert.isFalse(profile.wasDestroyed); 389 | done(); 390 | }); 391 | }); 392 | 393 | it('should restore the model data if a request failed', (done) => { 394 | const comment = new Model({ 395 | id: 5, 396 | body: 'fooqux', 397 | postId: 1 398 | }); 399 | 400 | comment.PUT = endpoints.notfound; 401 | comment.data.body = 'quux'; 402 | 403 | assert.isTrue(comment.data.body === 'quux'); 404 | 405 | comment.update() 406 | .catch(() => { 407 | assert.isTrue(comment.data.body === 'fooqux'); 408 | done(); 409 | }); 410 | }); 411 | }); 412 | 413 | describe(`${test} statics`, () => { 414 | it('should have a static extend method', (done) => { 415 | assert.isTrue(typeof Model.extend === 'function'); 416 | done(); 417 | }); 418 | 419 | it('static extend method should generate a constructor which is an extension of Model', (done) => { 420 | let baz = 1; 421 | const Extension = Model.extend({ 422 | 'foo': 'bar', 423 | 'baz': function() { 424 | return 'qux'; 425 | } 426 | }, 427 | () => { baz = 2; }); 428 | const extended = new Extension(data); 429 | 430 | assert.isTrue(extended.data.id === 1); 431 | assert.isTrue(extended.foo === 'bar'); 432 | assert.isTrue(extended.baz() === 'qux'); 433 | assert.isTrue(baz === 2); 434 | done(); 435 | }); 436 | 437 | it('should have a static modify method', (done) => { 438 | assert.isTrue(typeof Model.modify === 'function'); 439 | done(); 440 | }); 441 | 442 | it('static modify method should modify the Model class', (done) => { 443 | Model.modify({ 'foo': 'bar' }); 444 | const modified = new Model(); 445 | 446 | assert.isTrue(modified.foo === 'bar'); 447 | done(); 448 | }); 449 | 450 | it('static modify method should throw a TypeError if no props passed', (done) => { 451 | assert.throws(() => Model.modify(), TypeError, 'You must modify Model with a properties object'); 452 | done(); 453 | }); 454 | }); 455 | -------------------------------------------------------------------------------- /test/server.js: -------------------------------------------------------------------------------- 1 | const jsonServer = require('json-server'); // eslint-disable-line no-undef 2 | const middlewares = jsonServer.defaults(); 3 | const server = jsonServer.create(); 4 | const protocol = 'http'; 5 | const host = 'localhost'; 6 | const port = 3000; 7 | const endpoint = `${protocol}://${host}:${port}`; 8 | const schema = { 9 | 'posts': [ 10 | { 11 | 'title': 'baz', 12 | 'author': 'foo', 13 | 'id': 1 14 | }, 15 | { 16 | 'title': 'qux', 17 | 'author': 'bar', 18 | 'id': 1 19 | } 20 | ], 21 | 'comments': [ 22 | { 23 | 'id': 1, 24 | 'body': 'foobar', 25 | 'postId': 1 26 | } 27 | ], 28 | 'profile': { 29 | 'name': 'foo' 30 | } 31 | }; 32 | 33 | let connection; 34 | 35 | export const token = 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ'; 36 | 37 | export const endpoints = { 38 | 'profile': `${endpoint}/profile`, 39 | 'posts': `${endpoint}/posts`, 40 | 'comments': `${endpoint}/comments`, 41 | 'auth': `${endpoint}/profile/auth`, 42 | 'notfound': `${endpoint}/notfound` 43 | }; 44 | 45 | export function startServer() { 46 | const router = jsonServer.router(schema); 47 | 48 | connection = server.listen(port, () => { 49 | // set middlewares 50 | server.use(middlewares); 51 | 52 | // setup the body-parser for POST, PUT & PATCH requests 53 | server.use(jsonServer.bodyParser); 54 | 55 | // set test routes 56 | server.get('/profile/auth', (req, res) => { 57 | res.writeHead(200, 'OK', { 'Authorization': token }); 58 | res.end(JSON.stringify(schema.profile)); 59 | }); 60 | 61 | // use router 62 | server.use(router); 63 | }); 64 | } 65 | 66 | export function stopServer() { 67 | connection.close(); 68 | } 69 | -------------------------------------------------------------------------------- /test/store.spec.js: -------------------------------------------------------------------------------- 1 | /*global describe, it, beforeEach, afterEach */ 2 | 3 | import chai from 'chai'; 4 | import Store from '../src/Store.js'; 5 | import sinon from 'sinon'; 6 | 7 | chai.expect(); 8 | 9 | const test = 'Store'; 10 | const expect = chai.expect; 11 | const assert = chai.assert; 12 | const store = new Store(); 13 | let fakes; 14 | 15 | describe(`${test} constructor`, () => { 16 | it('should setup the components object store', (done) => { 17 | assert.isTrue(typeof store.components === 'object'); 18 | done(); 19 | }); 20 | 21 | it('should set up the event emitter', (done) => { 22 | assert.isTrue(typeof store.emitter === 'object'); 23 | assert.isTrue(store.emitter.constructor.name === 'EventEmitter'); 24 | done(); 25 | }); 26 | 27 | it('should set up the default request headers', (done) => { 28 | let store = new Store(); 29 | assert.isTrue(typeof store.requestHeaders === 'object'); 30 | done(); 31 | }); 32 | 33 | it('should set up default REST endpoints', (done) => { 34 | assert.isTrue(typeof store.GET !== 'undefined'); 35 | assert.isTrue(typeof store.POST !== 'undefined'); 36 | assert.isTrue(typeof store.PUT !== 'undefined'); 37 | assert.isTrue(typeof store.PATCH !== 'undefined'); 38 | assert.isTrue(typeof store.DELETE !== 'undefined'); 39 | done(); 40 | }); 41 | 42 | it('should set the wasFetched property to false', (done) => { 43 | assert.isFalse(store.wasFetched); 44 | done(); 45 | }); 46 | 47 | it('should set the wasBroadcast property to false', (done) => { 48 | assert.isFalse(store.wasBroadcast); 49 | done(); 50 | }); 51 | }); 52 | 53 | 54 | describe(`${test} prototype`, () => { 55 | it('should have a connect method', (done) => { 56 | assert.isTrue(typeof store.connect === 'function'); 57 | done(); 58 | }); 59 | 60 | it('should have an disconnect method', (done) => { 61 | assert.isTrue(typeof store.disconnect === 'function'); 62 | done(); 63 | }); 64 | 65 | it('should throw a reference error when attempting to connect a component with no truxid', (done) => { 66 | assert.throws(() => store.connect({}), ReferenceError, 'You must set a truxid on your component before connecting it to a store.'); 67 | done(); 68 | }); 69 | 70 | it('should throw a reference error if the component passed to disconnect is not connected to the store', (done) => { 71 | assert.throws(() => store.disconnect('foo'), ReferenceError, 'The component you are attempting to disconnect is not connected to this store.'); 72 | done(); 73 | }); 74 | 75 | it('should have an emitChangeEvent method', (done) => { 76 | assert.isTrue(typeof store.emitChangeEvent === 'function'); 77 | done(); 78 | }); 79 | 80 | it('should have an addRequestHeader method', (done) => { 81 | assert.isTrue(typeof store.addRequestHeader === 'function'); 82 | done(); 83 | }); 84 | 85 | it('should have an deleteRequestHeader method', (done) => { 86 | assert.isTrue(typeof store.deleteRequestHeader === 'function'); 87 | done(); 88 | }); 89 | }); 90 | 91 | 92 | describe(`${test} connecting & disconnecting`, () => { 93 | beforeEach(() => { 94 | fakes = sinon.collection; 95 | }); 96 | 97 | afterEach(() => { 98 | fakes.restore(); 99 | }); 100 | 101 | it('should bind the component to the store', (done) => { 102 | let component = { truxid: 'foo', storeDidUpdate: () => {} }; 103 | 104 | store.connect(component); 105 | assert.isTrue(typeof store.components[component.truxid] !== 'undefined'); 106 | assert.isTrue(store.components[component.truxid].truxid === 'foo'); 107 | done(); 108 | }); 109 | 110 | it('should log a warning if a component is bound that does not have a storeDidUpdate method', (done) => { 111 | fakes.stub(console, 'warn'); 112 | store.connect({ truxid: 'foo' }); 113 | expect(console.warn.calledWith('The component you have connected to this store does not contain a storeDidUpdate method.')).to.be.true; 114 | done(); 115 | }); 116 | 117 | it('should unbind the component from the store', (done) => { 118 | let component = { truxid: 'foo', storeDidUpdate: () => {} }; 119 | 120 | store.connect(component); 121 | store.disconnect(component); 122 | assert.isTrue(typeof store.components[component.truxid] === 'undefined'); 123 | done(); 124 | }); 125 | 126 | it('should emit changes to bound components', (done) => { 127 | let foo = 1; 128 | let component = { 129 | truxid: 'foo', 130 | storeDidUpdate: () => { 131 | foo = 2; 132 | } 133 | }; 134 | 135 | store.connect(component); 136 | store.emitChangeEvent(); 137 | assert.isTrue(foo === 2); 138 | done(); 139 | }); 140 | }); 141 | 142 | describe(`${test} request headers`, () => { 143 | it('should be able to add header by key, val', (done) => { 144 | store.addRequestHeader('Accept', 'application/json'); 145 | assert.isTrue(store.requestHeaders['Accept'] === 'application/json'); 146 | done(); 147 | }); 148 | 149 | it('should be able to delete header by key', (done) => { 150 | store.deleteRequestHeader('Accept'); 151 | assert.isTrue(typeof store.requestHeaders['Accept'] === 'undefined'); 152 | done(); 153 | }); 154 | }); 155 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | /*global __dirname, require, module*/ 2 | 3 | const webpack = require('webpack'); 4 | const UglifyJsPlugin = webpack.optimize.UglifyJsPlugin; 5 | const path = require('path'); 6 | const env = require('yargs').argv.env; // use --env with webpack 2 7 | 8 | let libraryName = 'trux'; 9 | 10 | let plugins = [], outputFile; 11 | 12 | if (env === 'build') { 13 | plugins.push(new UglifyJsPlugin({ minimize: true })); 14 | outputFile = libraryName + '.min.js'; 15 | } else { 16 | outputFile = libraryName + '.js'; 17 | } 18 | 19 | const config = { 20 | entry: __dirname + '/src/index.js', 21 | devtool: 'source-map', 22 | output: { 23 | path: __dirname + '/dist', 24 | filename: outputFile, 25 | library: libraryName, 26 | libraryTarget: 'umd', 27 | umdNamedDefine: true 28 | }, 29 | module: { 30 | rules: [ 31 | { 32 | test: /(\.jsx|\.js)$/, 33 | loader: 'babel-loader', 34 | exclude: /(node_modules|bower_components)/ 35 | }, 36 | { 37 | test: /(\.jsx|\.js)$/, 38 | loader: 'eslint-loader', 39 | exclude: /node_modules/ 40 | } 41 | ] 42 | }, 43 | resolve: { 44 | modules: [path.resolve('./src'), path.resolve('./node_modules')], 45 | extensions: ['.json', '.js'] 46 | }, 47 | plugins: plugins 48 | }; 49 | 50 | module.exports = config; 51 | --------------------------------------------------------------------------------