├── _config.yml ├── index.js ├── source ├── global.js ├── store.js ├── state.js ├── util.js ├── modelConstructor.js ├── md.js ├── collection.js └── baseModel.js ├── .npmignore ├── test ├── www │ ├── bundles │ │ └── chai │ │ │ ├── index.js │ │ │ └── chai-plugins.bundle.js │ ├── test-md.store.js │ ├── test-ModelLodash.js │ ├── index.html │ ├── test-State.js │ ├── test-redraw.js │ ├── test-CollectionLodash.js │ ├── configure.js │ ├── test-md.js │ ├── test-ModelConstructor.js │ ├── test-md.config.js │ └── test-Model.js └── profile │ ├── index.html │ └── app.js ├── .travis.yml ├── .gitignore ├── webpack-config.js ├── bower.json ├── .eslintrc.json ├── LICENSE ├── gulpfile.js ├── package.json ├── examples ├── model-and-collection.html └── model-redraw.html ├── README.md └── mithril-data.min.js /_config.yml: -------------------------------------------------------------------------------- 1 | theme: jekyll-theme-minimal -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./source/md.js'); -------------------------------------------------------------------------------- /source/global.js: -------------------------------------------------------------------------------- 1 | 2 | exports.config = {}; 3 | 4 | exports.modelConstructors = {}; -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # === IGNORE ALL === 2 | /* 3 | 4 | # === EXCEPT === 5 | !/*.js 6 | !/source -------------------------------------------------------------------------------- /test/www/bundles/chai/index.js: -------------------------------------------------------------------------------- 1 | window.chaiPromise = require('chai-as-promised'); 2 | window.chaiSubset = require('chai-subset'); -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - stable 4 | before_script: 5 | - npm install -g mocha-phantomjs 6 | branches: 7 | only: 8 | - master 9 | sudo: false -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # === IGNORE ALL === 3 | /* 4 | 5 | # === EXCEPT === 6 | !/.gitignore 7 | !/.npmignore 8 | !/.travis.yml 9 | !/*.json 10 | !/*.txt 11 | !/*.md 12 | !/*.js 13 | !/LICENSE 14 | 15 | !/source 16 | !/test 17 | !/examples 18 | 19 | **/.DS_Store -------------------------------------------------------------------------------- /webpack-config.js: -------------------------------------------------------------------------------- 1 | var webpack = require('webpack'), 2 | pkg = require('./package.json'); 3 | 4 | var bannerText = `${pkg.name} v${pkg.version} 5 | ${pkg.description} 6 | ${pkg.homepage||''} 7 | (c) ${new Date().getFullYear()} ${pkg.authorName} 8 | License: ${pkg.license}`; 9 | 10 | module.exports = { 11 | entry: './source/md.js', 12 | output: { 13 | filename: 'mithril-data.js' 14 | }, 15 | externals: { 16 | 'mithril': 'm', 17 | 'lodash': '_' 18 | }, 19 | plugins: [ 20 | new webpack.BannerPlugin(bannerText) 21 | ] 22 | }; -------------------------------------------------------------------------------- /test/profile/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | mithril-data 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mithril-data", 3 | "description": "A rich model library for Mithril javascript framework.", 4 | "main": "index.js", 5 | "authors": [ 6 | "Kevin (https://github.com/rhaldkhein)" 7 | ], 8 | "license": "MIT", 9 | "keywords": [ 10 | "model", 11 | "mithril", 12 | "database", 13 | "data" 14 | ], 15 | "moduleType": [ 16 | "amd", 17 | "globals", 18 | "node" 19 | ], 20 | "homepage": "", 21 | "ignore": [ 22 | "*", 23 | "!LICENSE", 24 | "!README.md", 25 | "!mithril-data.js", 26 | "!mithril-data.min.js", 27 | "!bower.json", 28 | "!package.json" 29 | ] 30 | } 31 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "commonjs": true, 5 | "es6": true 6 | }, 7 | "extends": "eslint:recommended", 8 | "rules": { 9 | "indent": 0, 10 | "linebreak-style": [ 11 | "error", 12 | "unix" 13 | ], 14 | "quotes": [ 15 | "error", 16 | "single" 17 | ], 18 | "semi": [ 19 | "error", 20 | "always" 21 | ] 22 | }, 23 | "globals": { 24 | "m": true, 25 | "md": true, 26 | "Model": true, 27 | "it": true, 28 | "expect": true, 29 | "describe": true 30 | } 31 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Ronald Kevin Villanueva 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /test/www/test-md.store.js: -------------------------------------------------------------------------------- 1 | describe('md.store', function() { 2 | 'use strict'; 3 | 4 | describe('#request()', function() { 5 | 'use strict'; 6 | 7 | it('exist and is a function', function() { 8 | expect(md.store.request).to.be.a('function'); 9 | }); 10 | 11 | it('returns a promise', function() { 12 | var req = md.store.request('/exist'); 13 | expect(req).to.be.instanceof(Promise); 14 | expect(req.then).to.be.a('function'); 15 | expect(req.catch).to.be.a('function'); 16 | }); 17 | 18 | it('reject on http error', function() { 19 | expect(md.store.request('/notexist')).to.be.rejected; 20 | }); 21 | 22 | it('resolve on http success', function() { 23 | var req = md.store.request('/exist'); 24 | expect(req).to.be.fulfilled; 25 | expect(req).to.be.become('ok'); 26 | }); 27 | 28 | }); 29 | 30 | describe('#get(), #post(), #put(), #destroy()', function() { 31 | 'use strict'; 32 | 33 | it('exist and is a function', function() { 34 | expect(md.store.get).to.be.a('function'); 35 | expect(md.store.post).to.be.a('function'); 36 | expect(md.store.put).to.be.a('function'); 37 | expect(md.store.destroy).to.be.a('function'); 38 | }); 39 | 40 | }); 41 | 42 | }); -------------------------------------------------------------------------------- /gulpfile.js: -------------------------------------------------------------------------------- 1 | var gulp = require('gulp'), 2 | uglify = require('gulp-uglify'), 3 | rename = require('gulp-rename'), 4 | header = require('gulp-header'), 5 | replace = require('gulp-replace'), 6 | webpack = require('webpack-stream'), 7 | sequence = require('run-sequence'); 8 | 9 | var package = require('./package.json'); 10 | 11 | gulp.task('version', function() { 12 | return gulp.src('source/md.js') 13 | .pipe(replace(/v.+\/\/version/g, 'v' + package.version + '\';//version')) 14 | .pipe(gulp.dest('source')); 15 | }); 16 | 17 | gulp.task('bundle', function() { 18 | return gulp.src('') 19 | .pipe(webpack(require('./webpack-config.js'))) 20 | .pipe(gulp.dest('')); 21 | }); 22 | 23 | gulp.task('minify', function() { 24 | return gulp.src('mithril-data.js') 25 | .pipe(uglify()) 26 | .pipe(rename({ 27 | suffix: '.min' 28 | })) 29 | .pipe(header(['/**', 30 | ' * <%= pkg.name %> v<%= pkg.version %>', 31 | ' * <%= pkg.description %>', 32 | ' * <%= pkg.homepage %>', 33 | ' * (c) ' + new Date().getFullYear() + ' <%= pkg.authorName %>', 34 | ' * License: <%= pkg.license %>', 35 | ' */', 36 | '' 37 | ].join('\n'), { 38 | pkg: package 39 | })) 40 | .pipe(gulp.dest('')); 41 | }); 42 | 43 | gulp.task('release', function(callback) { 44 | sequence('version', 'bundle', 'minify', callback); 45 | }); -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mithril-data", 3 | "version": "0.4.9", 4 | "description": "A rich data model library for Mithril javascript framework.", 5 | "homepage": "https://github.com/rhaldkhein/mithril-data", 6 | "main": "index.js", 7 | "scripts": { 8 | "test": "mocha-phantomjs test/www/index.html" 9 | }, 10 | "keywords": [ 11 | "model", 12 | "mithril", 13 | "database", 14 | "data" 15 | ], 16 | "author": "Kevin (https://github.com/rhaldkhein)", 17 | "authorName": "Kevin Villanueva", 18 | "license": "MIT", 19 | "repository": { 20 | "type": "git", 21 | "url": "https://github.com/rhaldkhein/mithril-data.git" 22 | }, 23 | "devDependencies": { 24 | "body-parser": "^1.15.1", 25 | "chai": "^3.5.0", 26 | "chai-as-promised": "^6.0.0", 27 | "chai-subset": "^1.4.0", 28 | "express": "^4.13.4", 29 | "gulp": "^3.9.1", 30 | "gulp-header": "^1.8.2", 31 | "gulp-rename": "^1.2.2", 32 | "gulp-replace": "^0.5.4", 33 | "gulp-uglify": "^3.0.0", 34 | "mocha": "^2.4.5", 35 | "run-sequence": "^1.2.2", 36 | "shortid": "^2.2.6", 37 | "webpack-stream": "^3.2.0" 38 | }, 39 | "dependencies": { 40 | "lodash": "^4.12.0", 41 | "mithril": "^1.0.1" 42 | }, 43 | "files": [ 44 | "source/*", 45 | "index.js", 46 | "mithril-data.js", 47 | "mithril-data.min.js" 48 | ] 49 | } 50 | -------------------------------------------------------------------------------- /test/www/test-ModelLodash.js: -------------------------------------------------------------------------------- 1 | describe('Model.', function() { 2 | 'use strict'; 3 | 4 | it('has()', function() { 5 | var user = new Model.User(); 6 | expect(user.has).to.exist; 7 | expect(user.has('name')).to.equal(true); 8 | }) 9 | 10 | it('keys()', function() { 11 | var user = new Model.User(); 12 | expect(user.keys).to.exist; 13 | var props = Model.User.modelOptions.props; 14 | var keys = user.keys(); 15 | for (var i = 0; i < props.length; i++) { 16 | expect(keys).to.contain(props[i]); 17 | } 18 | }) 19 | 20 | it('values()', function() { 21 | var user = new Model.User(); 22 | expect(user.values).to.exist; 23 | user.name('Test'); 24 | var props = Model.User.modelOptions.props; 25 | var values = user.values(); 26 | for (var i = 0; i < props.length; i++) { 27 | expect(values).to.contain(user[props[i]]()); 28 | } 29 | }) 30 | 31 | it('pick()', function() { 32 | var user = new Model.User(); 33 | expect(user.pick).to.exist; 34 | user.name('Name'); 35 | user.profile('Profile'); 36 | var picked = user.pick(['profile']); 37 | expect(picked.profile).to.exist; 38 | expect(picked.profile).to.equal(user.profile()); 39 | }) 40 | 41 | it('omit()', function() { 42 | var user = new Model.User(); 43 | expect(user.omit).to.exist; 44 | user.name('Name'); 45 | user.profile('Profile'); 46 | var omited = user.omit(['profile']); 47 | expect(omited).to.not.equal(user.getJson()); 48 | expect(omited.name).to.exist.and.to.equal('Name'); 49 | expect(omited.profile).to.not.exist; 50 | }) 51 | 52 | }); -------------------------------------------------------------------------------- /test/profile/app.js: -------------------------------------------------------------------------------- 1 | window.onload = function() { 2 | 3 | // Model 4 | var Model = window.Model = {}; 5 | 6 | Model.User = md.model({ 7 | name: 'User', 8 | props: ['name', 'profile', 'data'] 9 | }); 10 | 11 | Model.Note = md.model({ 12 | name: 'Note', 13 | props: ['foo', 'bar'], 14 | defaults: { 15 | title: 'Default Title', 16 | body: 'Default Note Body', 17 | author: new Model.User({ 18 | name: 'User Default' 19 | }) 20 | }, 21 | refs: { 22 | author: 'User' 23 | } 24 | }); 25 | 26 | // console.dir(Model.User); 27 | // console.dir(Model.Note); 28 | 29 | var Demo = { 30 | // Controller 31 | controller: function() { 32 | var self = this; 33 | this.collection = Model.User.createCollection(); 34 | this.add = function() { 35 | console.log('Add'); 36 | self.collection.add(new Model.User({ 37 | name: 'Foo_' + self.collection.size() 38 | })); 39 | }; 40 | this.remove = function() { 41 | console.log('Remove'); 42 | self.collection.remove(self.collection.last()); 43 | }; 44 | // this.add(); 45 | }, 46 | // View 47 | view: function(ctrl) { 48 | return m('div', [ 49 | m('button', { 50 | onclick: ctrl.add 51 | }, 'Add'), 52 | m('button', { 53 | onclick: ctrl.remove 54 | }, 'Remove'), 55 | m('div', ctrl.collection.map(function(model, i) { 56 | return m('ul', [ 57 | m('li', 'Model ' + i), 58 | m('li', 'name: ' + model.name()), 59 | m('hr') 60 | ]); 61 | })) 62 | ]); 63 | } 64 | }; 65 | 66 | //initialize 67 | m.mount(document.body, Demo); 68 | 69 | }; -------------------------------------------------------------------------------- /source/store.js: -------------------------------------------------------------------------------- 1 | var _ = require('lodash'); 2 | var config = require('./global').config; 3 | var BaseModel = require('./baseModel'); 4 | 5 | function __config(xhr, xhrOptions) { 6 | if (config.storeConfigXHR) 7 | config.storeConfigXHR(xhr, xhrOptions); 8 | xhr.setRequestHeader('Content-Type', 'application/json'); 9 | } 10 | 11 | function __extract(xhr, xhrOptions) { 12 | if (config.storeExtract) { 13 | return config.storeExtract(xhr, xhrOptions); 14 | } else if (xhr.responseText.length) { 15 | return __deserializer(xhr.responseText); 16 | } else { 17 | return null; 18 | } 19 | } 20 | 21 | function __serializer(data) { 22 | // data = data instanceof BaseModel ? data.getCopy() : data; 23 | __dereference(data); 24 | if (config.storeSerializer) 25 | return config.storeSerializer(data); 26 | else 27 | return JSON.stringify(data); 28 | } 29 | 30 | function __deserializer(data) { 31 | if (config.storeDeserializer) { 32 | return config.storeDeserializer(data); 33 | } else { 34 | try { 35 | return JSON.parse(data); 36 | } catch (e) { 37 | return data; 38 | } 39 | } 40 | } 41 | 42 | function __dereference(data) { 43 | var value; 44 | for (var key in data) { 45 | value = data[key]; 46 | if (_.isObject(value)) { 47 | data[key] = value[config.keyId] || value; 48 | } 49 | } 50 | } 51 | 52 | module.exports = _.create(null, { 53 | request: function(url, method, data, options) { 54 | var _options = { 55 | method: method || 'GET', 56 | url: url, 57 | data: (data instanceof BaseModel ? data.getCopy() : data) || {}, 58 | background: !!config.storeBackground, 59 | serialize: __serializer, 60 | deserialize: __deserializer, 61 | config: __config, 62 | extract: __extract 63 | }; 64 | if (options) _options = _.defaultsDeep(options, _options); 65 | if (config.storeConfigOptions) config.storeConfigOptions(_options); 66 | return config.store(_options); 67 | }, 68 | get: function(url, data, opt) { 69 | return this.request(url, 'GET', data, opt); 70 | }, 71 | post: function(url, data, opt) { 72 | return this.request(url, 'POST', data, opt); 73 | }, 74 | put: function(url, data, opt) { 75 | return this.request(url, 'PUT', data, opt); 76 | }, 77 | destroy: function(url, data, opt) { 78 | return this.request(url, 'DELETE', data, opt); 79 | } 80 | }); 81 | -------------------------------------------------------------------------------- /test/www/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | mithril-data - Test Suites 6 | 7 | 8 | 13 | 14 | 15 |
16 |
17 |

18 | Redraw 19 |

20 |
21 |
22 |
23 |
24 |

25 | Test Results 26 |

27 |
28 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 66 | 67 | 68 | -------------------------------------------------------------------------------- /source/state.js: -------------------------------------------------------------------------------- 1 | /** 2 | * State 3 | */ 4 | var _ = require('lodash'); 5 | var config = require('./global').config; 6 | var defaultKey = '__key__'; 7 | var privateKeys = ['factory', 'toJson', '_options']; 8 | 9 | function _toJson() { 10 | var json = {}; 11 | for (var prop in this) { 12 | if (_.indexOf(privateKeys, prop) === -1) { 13 | json[prop] = this[prop](); 14 | } 15 | } 16 | return json; 17 | } 18 | 19 | function createState(signature, state, options, factoryKey) { 20 | var propVal; 21 | var store = options && options.store ? options.store : config.stream; 22 | for (var propKey in signature) { 23 | if (_.indexOf(privateKeys, propKey) > -1) 24 | throw new Error('State key `' + propKey + '` is not allowed.'); 25 | propVal = signature[propKey]; 26 | state[propKey] = _.isFunction(propVal) ? propVal : store(propVal, propKey, factoryKey, options); 27 | } 28 | return state; 29 | } 30 | 31 | // Class 32 | function State(signature, options) { 33 | if (_.isArray(signature)) { 34 | this.signature = _.invert(signature); 35 | for (var prop in this.signature) { 36 | this.signature[prop] = undefined; 37 | } 38 | } else { 39 | this.signature = signature; 40 | } 41 | this._options = options; 42 | this.map = {}; 43 | } 44 | 45 | // Exports 46 | module.exports = State; 47 | 48 | // Single state 49 | State.create = function(signature, options) { 50 | return createState(signature, { 51 | toJson: _toJson 52 | }, options); 53 | }; 54 | 55 | // Assign state 56 | State.assign = function(object, signature, options) { 57 | createState(signature, object, options); 58 | }; 59 | 60 | // Prototype 61 | State.prototype = { 62 | set: function(key) { 63 | if (!key) 64 | key = defaultKey; 65 | if (!this.map[key]) { 66 | this.map[key] = createState(this.signature, { 67 | factory: config.stream(this), 68 | toJson: _toJson 69 | }, this._options, key); 70 | } 71 | return this.map[key]; 72 | }, 73 | get: function(key) { 74 | if (!key) 75 | key = defaultKey; 76 | if (!this.map[key]) { 77 | this.set(key); 78 | } 79 | return this.map[key]; 80 | }, 81 | remove: function(key) { 82 | if (!key) 83 | key = defaultKey; 84 | if (this.map[key]) { 85 | var b, keys = _.keys(this.map[key]); 86 | for (b = 0; b < keys.length; b++) { 87 | this.map[key][keys[b]] = null; 88 | } 89 | delete this.map[key]; 90 | } 91 | }, 92 | dispose: function() { 93 | var keysThis = _.keys(this); 94 | var keysMap = _.keys(this.map); 95 | var keySignature = _.keys(this.signature); 96 | var a, b; 97 | for (a = 0; a < keysMap.length; a++) { 98 | for (b = 0; b < keySignature.length; b++) { 99 | this.map[keysMap[a]][keySignature[b]] = null; 100 | } 101 | this.map[keysMap[a]] = null; 102 | } 103 | for (a = 0; a < keysThis.length; a++) { 104 | this[keysThis[a]] = null; 105 | } 106 | } 107 | }; 108 | -------------------------------------------------------------------------------- /examples/model-and-collection.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | model-and-collection 6 | 7 | 8 | 9 |
10 |
11 | 12 | 13 | 14 | 99 | 100 | 101 | -------------------------------------------------------------------------------- /examples/model-redraw.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | model-and-collection 6 | 7 | 8 | 9 |
10 |
11 | 12 | 13 | 14 | 113 | 114 | 115 | -------------------------------------------------------------------------------- /test/www/test-State.js: -------------------------------------------------------------------------------- 1 | describe('State', function() { 2 | 'use strict'; 3 | 4 | var customStoreData = {}; 5 | var customStore = function(initVal, key, factorykey, options) { 6 | var prefix = options.prefix || ''; 7 | factorykey = factorykey ? (factorykey + '.') : ''; 8 | key = prefix + factorykey + key; 9 | var prop = function(valNew) { 10 | if (arguments.length) { 11 | customStoreData[key] = valNew; 12 | } else { 13 | return customStoreData[key]; 14 | } 15 | }; 16 | if (!(key in customStoreData)) 17 | prop(initVal); 18 | return prop; 19 | }; 20 | 21 | it('Single instance', function() { 22 | var state = md.State.create({ 23 | isEditing: false, 24 | test: 'Foo' 25 | }); 26 | expect(state.isEditing()).to.be.false; 27 | expect(state.test()).to.be.equal('Foo'); 28 | state.isEditing(true); 29 | state.test('Bar'); 30 | expect(state.isEditing()).to.be.true; 31 | expect(state.test()).to.be.equal('Bar'); 32 | }); 33 | 34 | it('Assign to existing object', function() { 35 | var obj = { 36 | name: 'Foo' 37 | }; 38 | md.State.assign(obj, { 39 | isEditing: false, 40 | test: 'Bar' 41 | }); 42 | expect(obj.name).to.be.equal('Foo'); 43 | expect(obj.isEditing()).to.be.false; 44 | expect(obj.test()).to.be.equal('Bar'); 45 | obj.isEditing(true); 46 | obj.test('Baz'); 47 | expect(obj.isEditing()).to.be.true; 48 | expect(obj.test()).to.be.equal('Baz'); 49 | }); 50 | 51 | it('Accepts fuctions initialized with md.stream()', function() { 52 | var _isDone = md.stream(false); 53 | var _test = md.stream('Foo'); 54 | var state = md.State.create({ 55 | isDone: _isDone, 56 | test: _test 57 | }); 58 | expect(state.isDone()).to.be.false; 59 | expect(state.test()).to.be.equal('Foo'); 60 | state.isDone(true); 61 | state.test('Bar'); 62 | expect(state.isDone()).to.be.true; 63 | expect(state.test()).to.be.equal('Bar'); 64 | expect(state.isDone.constructor).to.be.equal(md.stream); 65 | 66 | }); 67 | 68 | it('`toJson()` method', function() { 69 | var _isDone = md.stream(false); 70 | var _test = md.stream('Foo'); 71 | var state = md.State.create({ 72 | isDone: _isDone, 73 | test: _test 74 | }); 75 | var json = state.toJson(); 76 | expect(json.isDone).to.be.false; 77 | expect(json.test).to.be.equal('Foo'); 78 | }); 79 | 80 | it('Custom store / prop (non factory)', function() { 81 | var state = md.State.create({ 82 | name: 'Foo', 83 | age: 25, 84 | active: false 85 | }, { 86 | store: customStore 87 | }); 88 | expect(state.name()).to.equal(customStoreData.name).and.to.equal('Foo'); 89 | expect(state.age()).to.equal(customStoreData.age).and.to.equal(25); 90 | expect(state.active()).to.equal(customStoreData.active).and.to.equal(false); 91 | }); 92 | 93 | it('Custom store / prop with prefix (non factory)', function() { 94 | var state = md.State.create({ 95 | name: 'Foo', 96 | age: 25 97 | }, { 98 | prefix: 'pref.', 99 | store: customStore 100 | }); 101 | expect(state.name()).to.equal(customStoreData['pref.name']).and.to.equal('Foo'); 102 | expect(state.age()).to.equal(customStoreData['pref.age']).and.to.equal(25); 103 | }); 104 | 105 | it('Custom store / prop with prefix (factory)', function() { 106 | var stateFactory = new md.State({ 107 | name: 'Foo', 108 | age: 25 109 | }, { 110 | prefix: 'pref.', 111 | store: customStore 112 | }); 113 | var stateA = stateFactory.set('a'); 114 | expect(stateA.name()).to.equal(customStoreData['pref.a.name']).and.to.equal('Foo'); 115 | expect(stateA.age()).to.equal(customStoreData['pref.a.age']).and.to.equal(25); 116 | }); 117 | 118 | }); -------------------------------------------------------------------------------- /source/util.js: -------------------------------------------------------------------------------- 1 | var _ = require('lodash'); 2 | var slice = Array.prototype.slice; 3 | var BaseModel = require('./baseModel'); 4 | var hasWindow = typeof window !== 'undefined'; 5 | 6 | function resolveWrapper(func, property) { 7 | return function(argA, argB, argC, argD) { 8 | return func(argA ? (argA[property] || argA) : argA, argB ? (argB[property] || argB) : argB, argC, argD); 9 | }; 10 | } 11 | 12 | function resolveArguments(args, property) { 13 | var i = args.length - 1; 14 | var arg; 15 | for (; i >= 0; i--) { 16 | arg = args[i]; 17 | if (_.isFunction(arg)) 18 | args[i] = resolveWrapper(arg, property); 19 | else if (arg instanceof BaseModel) 20 | args[i] = arg.__json; 21 | } 22 | return args; 23 | } 24 | 25 | function resolveResult(result, collection, property) { 26 | if (result === collection) { 27 | return result; 28 | } else { 29 | if (_.isArray(result)) { 30 | var i = result.length - 1; 31 | var value; 32 | for (; i >= 0; i--) { 33 | value = result[i]; 34 | if (value && value[property]) 35 | result[i] = value[property]; 36 | } 37 | return result; 38 | } else { 39 | return result ? (result[property] || result) : result; 40 | } 41 | } 42 | } 43 | 44 | function getNextTickMethod() { 45 | if (hasWindow && window.setImmediate) { 46 | return window.setImmediate; 47 | } else if (typeof process === 'object' && typeof process.nextTick === 'function') { 48 | return process.nextTick; 49 | } 50 | return function(fn) { 51 | setTimeout(fn, 0); 52 | }; 53 | } 54 | 55 | module.exports = _.create(null, { 56 | isBrowser: hasWindow, 57 | nextTick: getNextTickMethod(), 58 | clearObject: function(obj) { 59 | for (var member in obj) 60 | delete obj[member]; 61 | }, 62 | hasValueOfType: function(obj, type) { 63 | var keys = _.keys(obj); 64 | for (var i = 0; i < keys.length; i++) { 65 | if (obj[keys[i]] instanceof type) { 66 | return true; 67 | } 68 | } 69 | return false; 70 | }, 71 | isConflictExtend: function(objSource, objInject) { 72 | var keys = _.keys(objInject); 73 | var i = 0; 74 | for (; i < keys.length; i++) { 75 | if (_.hasIn(objSource, keys[i])) { 76 | return keys[i]; 77 | } 78 | } 79 | return false; 80 | }, 81 | strictExtend: function(objSource, objInject) { 82 | var isConflict = this.isConflictExtend(objSource, objInject); 83 | if (isConflict) 84 | throw new Error('`' + isConflict + '` method / property is not allowed.'); 85 | else 86 | _.extend(objSource, objInject); 87 | }, 88 | addMethods: function(dist, src, methods, distProp, retProp) { 89 | // Need to use _.each loop. To retain value of methods' arguments. 90 | _.each(methods, function(length, method) { 91 | if (src[method]) { 92 | switch (length) { 93 | case 0: 94 | dist[method] = function() { 95 | return resolveResult(src[method](this[distProp]), this[distProp], retProp); 96 | }; 97 | break; 98 | case 1: 99 | dist[method] = function(valueA) { 100 | if (_.isFunction(valueA)) 101 | valueA = resolveWrapper(valueA, retProp); 102 | else if (valueA instanceof BaseModel) 103 | valueA = valueA.__json; 104 | return resolveResult(src[method](this[distProp], valueA), this[distProp], retProp); 105 | }; 106 | break; 107 | case 2: 108 | dist[method] = function(valueA, valueB) { 109 | if (_.isFunction(valueA)) 110 | valueA = resolveWrapper(valueA, retProp); 111 | else if (valueA instanceof BaseModel) 112 | valueA = valueA.__json; 113 | if (_.isFunction(valueB)) 114 | valueB = resolveWrapper(valueB, retProp); 115 | else if (valueB instanceof BaseModel) 116 | valueB = valueB.__json; 117 | return resolveResult(src[method](this[distProp], valueA, valueB), this[distProp], retProp); 118 | }; 119 | break; 120 | default: 121 | dist[method] = function() { 122 | var args = resolveArguments(slice.call(arguments), retProp); 123 | args.unshift(this[distProp]); 124 | return resolveResult(src[method].apply(src, args), this[distProp], retProp); 125 | }; 126 | } 127 | } 128 | }); 129 | } 130 | }); 131 | -------------------------------------------------------------------------------- /source/modelConstructor.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Model Constructor 3 | */ 4 | 5 | var _ = require('lodash'); 6 | // var m = require('mithril'); 7 | var store = require('./store'); 8 | var config = require('./global').config; 9 | var Collection = require('./collection'); 10 | 11 | function ModelConstructor() {} 12 | 13 | // Export class. 14 | module.exports = ModelConstructor; 15 | 16 | // Prototype methods. 17 | ModelConstructor.prototype = { 18 | __init: function(options) { 19 | if (this.__options) 20 | return; 21 | // Set defaults 22 | this.__options = { 23 | redraw: false, 24 | cache: config.cache === true 25 | }; 26 | // Inject schema level options to '__options' 27 | if (options) 28 | this.opt(options); 29 | // Check cache enabled 30 | if (this.__options.cache) { 31 | this.__cacheCollection = new Collection({ 32 | model: this, 33 | _cache: true 34 | }); 35 | if (!this.__options.cacheLimit) 36 | this.__options.cacheLimit = config.cacheLimit; 37 | } 38 | }, 39 | __flagSaved: function(models) { 40 | for (var i = 0; i < models.length; i++) 41 | models[i].__saved = true; 42 | }, 43 | opt: function(key, value) { 44 | if (!this.__options) 45 | this.__init(); 46 | if (_.isPlainObject(key)) 47 | _.assign(this.__options, key); 48 | else 49 | this.__options[key] = _.isUndefined(value) ? true : value; 50 | }, 51 | // Creates a model. Comply with parsing and caching. 52 | create: function(values, options) { 53 | if (values == null) values = {}; 54 | if (!_.isPlainObject(values)) 55 | throw new Error('Plain object required'); 56 | var cachedModel; 57 | if (this.modelOptions.parser) { 58 | values = this.modelOptions.parser(values); 59 | } 60 | if (this.__options.cache && values[config.keyId]) { 61 | cachedModel = this.__cacheCollection.get(values); 62 | if (!cachedModel) { 63 | cachedModel = new this(values, options); 64 | this.__cacheCollection.add(cachedModel); 65 | if (this.__cacheCollection.size() > this.__options.cacheLimit) { 66 | this.__cacheCollection.shift(); 67 | } 68 | } 69 | } else { 70 | cachedModel = new this(values, options); 71 | } 72 | return cachedModel; 73 | }, 74 | createCollection: function(options) { 75 | return new Collection(_.assign({ 76 | model: this 77 | }, options)); 78 | }, 79 | createModels: function(data, options) { 80 | if (!_.isArray(data)) 81 | data = [data]; 82 | var models = []; 83 | for (var i = 0; i < data.length; i++) { 84 | models[i] = this.create(data[i], options); 85 | } 86 | return models; 87 | }, 88 | pull: function(url, data, options, callback) { 89 | if (_.isFunction(data)) { 90 | callback = data; 91 | data = undefined; 92 | } else if (_.isFunction(options)) { 93 | callback = options; 94 | options = data; 95 | data = undefined; 96 | } 97 | var self = this; 98 | return store.get(url, data, options) 99 | .then(function(data) { 100 | // `data` can be either array of model or object with 101 | // additional information (like total result and pagination) 102 | // and a property with value of array of models 103 | var models = self.createModels(options && options.path ? _.get(data, options.path) : data, options); 104 | self.__flagSaved(models); 105 | // Resolve the raw data from server as it might contain additional information 106 | if (_.isFunction(callback)) callback(null, data, models); 107 | return models; 108 | }, function(err) { 109 | if (_.isFunction(callback)) callback(err); 110 | throw err; 111 | }); 112 | } 113 | }; -------------------------------------------------------------------------------- /test/www/test-redraw.js: -------------------------------------------------------------------------------- 1 | (function() { 2 | 3 | var redrawCount = 0; 4 | 5 | var PostA = md.model({ 6 | name: 'PostA', 7 | props: ['name'], 8 | redraw: true 9 | }); 10 | 11 | var PostB = md.model({ 12 | name: 'PostB', 13 | props: ['name'] 14 | }); 15 | 16 | var redrawComponent = { 17 | count: 0, 18 | modelA1: new PostA(), 19 | modelA2: new PostA(), 20 | modelB1: new PostB(), 21 | modelB2: new PostB(), 22 | collection: PostB.createCollection(), 23 | view: function() { 24 | // console.info('Redraw ' + (++redrawCount)); 25 | return m('div', [ 26 | m('h2', 'count = ' + (++this.count)), 27 | m('h2', [ 28 | 'modelA1.name (schema:PostA, instance:1) = ', 29 | m('span', { 30 | id: 'modela1-name' 31 | }, '' + this.modelA1.name()) 32 | ]), 33 | m('h2', [ 34 | 'modelA2.name (schema:PostA, instance:2) = ', 35 | m('span', { 36 | id: 'modela2-name' 37 | }, '' + this.modelA2.name()) 38 | ]), 39 | m('h2', [ 40 | 'modelB1.name (schema:PostB, instance:1) = ', 41 | m('span', { 42 | id: 'modelb1-name' 43 | }, '' + this.modelB1.name()) 44 | ]), 45 | m('h2', [ 46 | 'modelB2.name (schema:PostB, instance:2) = ', 47 | m('span', { 48 | id: 'modelb2-name' 49 | }, '' + this.modelB2.name()) 50 | ]), 51 | m('div', this.collection.map(function(model, i) { 52 | i++; 53 | return m('h2', [ 54 | 'collection.modelB' + i + '.name (schema:PostB, instance:' + i + ') = ', 55 | m('span', { 56 | id: 'col-modelb' + i + '-name' 57 | }, '' + model.name()) 58 | ]) 59 | })) 60 | ]); 61 | } 62 | }; 63 | 64 | var elRedraw = document.getElementById('redraw'); 65 | m.mount(elRedraw, redrawComponent); 66 | 67 | describe('Auto Redraw', function() { 68 | 'use strict'; 69 | 70 | 71 | describe('Model', function() { 72 | it('should redraw ALL instances - if redraw=true is set in `schema` option', function(done) { 73 | // Open `test-redraw.js` to see schema configuration. 74 | 75 | // First instance. 76 | var elemA1 = document.getElementById('modela1-name'); 77 | var modelA1 = redrawComponent.modelA1; 78 | expect(elemA1.innerHTML).to.equal('undefined'); 79 | 80 | // Second instance. 81 | var elemA2 = document.getElementById('modela2-name'); 82 | var modelA2 = redrawComponent.modelA2; 83 | expect(elemA2.innerHTML).to.equal('undefined'); 84 | 85 | modelA1.name('Foo'); 86 | modelA2.name('Bar'); 87 | 88 | // Need to delay and wait for DOM update. 89 | setTimeout(function() { 90 | expect(elemA1.innerHTML).to.equal('Foo'); 91 | expect(elemA2.innerHTML).to.equal('Bar'); 92 | done(); 93 | }, 200); 94 | 95 | 96 | }); 97 | 98 | it('should redraw ONLY centain instances - if redraw=true is set in `instance` option', function(done) { 99 | // Open `test-redraw.js` to see schema configuration. 100 | 101 | // First instance. 102 | var modelB1 = redrawComponent.modelB1; 103 | var elemB1 = document.getElementById('modelb1-name'); 104 | expect(elemB1.innerHTML).to.equal('undefined'); 105 | 106 | modelB1.name('Foo'); 107 | 108 | // Need to delay and wait for DOM update. 109 | setTimeout(function() { 110 | expect(elemB1.innerHTML).to.equal('undefined'); 111 | 112 | // Second instance. 113 | var modelB2 = redrawComponent.modelB2; 114 | var elemB2 = document.getElementById('modelb2-name'); 115 | expect(elemB2.innerHTML).to.equal('undefined'); 116 | 117 | // Set option here. 118 | modelB2.opt('redraw', true); 119 | modelB2.name('Bar'); 120 | 121 | setTimeout(function() { 122 | expect(elemB2.innerHTML).to.equal('Bar'); 123 | done(); 124 | }, 200); 125 | }, 200); 126 | }); 127 | }); 128 | 129 | describe('Collection', function() { 130 | it('should redraw - even if contained model has false redraw', function(done) { 131 | var col = redrawComponent.collection; 132 | col.opt('redraw', true); 133 | 134 | var postB1 = new PostB(); 135 | postB1.opt('redraw', false); 136 | col.add(postB1); 137 | 138 | var postB2 = new PostB(); 139 | postB2.opt('redraw', false); 140 | col.add(postB2); 141 | 142 | postB1.name('Foo'); 143 | postB2.name('Bar'); 144 | 145 | setTimeout(function() { 146 | var elemB1 = document.getElementById('col-modelb1-name'); 147 | expect(elemB1.innerHTML).to.equal('Foo'); 148 | var elemB2 = document.getElementById('col-modelb2-name'); 149 | expect(elemB2.innerHTML).to.equal('Bar'); 150 | // Change model value 151 | postB1.name('Test'); 152 | setTimeout(function() { 153 | var elemB1x = document.getElementById('col-modelb1-name'); 154 | expect(elemB1x.innerHTML).to.equal('Test'); 155 | done(); 156 | }, 200); 157 | }, 200); 158 | 159 | }); 160 | }); 161 | 162 | }); 163 | 164 | })(); -------------------------------------------------------------------------------- /test/www/test-CollectionLodash.js: -------------------------------------------------------------------------------- 1 | describe('Collection.', function() { 2 | 'use strict'; 3 | 4 | it('size', function() { 5 | var col = new md.Collection(); 6 | expect(col.size()).to.equal(0); 7 | var user = new Model.User(); 8 | col.add(user); 9 | col.add(user); 10 | expect(col.size()).to.equal(1); 11 | col.remove(user); 12 | expect(col.size()).to.equal(0); 13 | }); 14 | 15 | it('forEach', function() { 16 | var col = new md.Collection(); 17 | var user = new Model.User(); 18 | col.add(user); 19 | col.add(new Model.User()); 20 | col.add(new Model.User()); 21 | var loopCount = 0; 22 | var result = col.forEach(function(model) { 23 | expect(model).to.be.instanceof(Model.User); 24 | loopCount++; 25 | }); 26 | expect(loopCount).to.be.equal(3); 27 | expect(result).to.be.a('array'); 28 | expect(result.length).to.be.equal(3); 29 | expect(result[0]).to.be.equal(user.getJson()); 30 | }); 31 | 32 | it('map', function() { 33 | var col = new md.Collection(); 34 | var userA = new Model.User(); 35 | var userB = new Model.User(); 36 | var userC = new Model.User(); 37 | col.add(userA); 38 | col.add(userB); 39 | col.add(userC); 40 | var loopCount = 0; 41 | var result = col.map(function(model) { 42 | expect(model).to.be.instanceof(Model.User); 43 | loopCount++; 44 | return model.lid(); 45 | }); 46 | expect(loopCount).to.be.equal(3); 47 | expect(result).to.be.a('array'); 48 | expect(result.length).to.be.equal(3); 49 | expect(result[0]).to.be.equal(userA.lid()); 50 | expect(result[1]).to.be.equal(userB.lid()); 51 | expect(result[2]).to.be.equal(userC.lid()); 52 | }); 53 | 54 | it('find', function() { 55 | var col = new md.Collection(); 56 | var userA = new Model.User(); 57 | userA.name('Foo'); 58 | userA.active(false); 59 | col.add(userA); 60 | var userB = new Model.User(); 61 | userB.name('Bar'); 62 | userB.active(false); 63 | col.add(userB); 64 | var userC = new Model.User(); 65 | userC.name('Baz'); 66 | userC.active(true); 67 | col.add(userC); 68 | var result = col.find(['name', 'Foo']); 69 | expect(result).to.be.equal(userA); 70 | result = col.find('active'); 71 | expect(result).to.be.equal(userC); 72 | }); 73 | 74 | it('filter', function() { 75 | var col = new md.Collection(); 76 | var userA = new Model.User(); 77 | userA.name('Foo'); 78 | userA.active(true); 79 | col.add(userA); 80 | var userB = new Model.User(); 81 | userB.name('Bar'); 82 | userB.active(false); 83 | col.add(userB); 84 | var userC = new Model.User(); 85 | userC.name('Baz'); 86 | userC.active(true); 87 | col.add(userC); 88 | var result = col.filter('active'); 89 | expect(result).to.be.a('array'); 90 | expect(result.length).to.equal(2); 91 | expect(result[0]).to.be.equal(userA); 92 | expect(result[1]).to.be.equal(userC); 93 | }); 94 | 95 | it('reduce', function() { 96 | var col = new md.Collection(); 97 | var userA = new Model.User(); 98 | userA.name('Foo'); 99 | userA.active(true); 100 | col.add(userA); 101 | var userB = new Model.User(); 102 | userB.name('Bar'); 103 | userB.active(false); 104 | col.add(userB); 105 | var userC = new Model.User(); 106 | userC.name('Baz'); 107 | userC.active(true); 108 | col.add(userC); 109 | var result = col.reduce(function(arr, item) { 110 | if (item.active()) { 111 | arr.push(item); 112 | } 113 | return arr; 114 | }, []); 115 | expect(result).to.be.a('array'); 116 | expect(result.length).to.equal(2); 117 | expect(result[0]).to.be.equal(userA); 118 | expect(result[1]).to.be.equal(userC); 119 | }); 120 | 121 | it('partition', function() { 122 | var col = new md.Collection(); 123 | var userA = new Model.User(); 124 | userA.name('Foo'); 125 | userA.active(true); 126 | col.add(userA); 127 | var userB = new Model.User(); 128 | userB.name('Bar'); 129 | userB.active(false); 130 | col.add(userB); 131 | var userC = new Model.User(); 132 | userC.name('Baz'); 133 | userC.active(true); 134 | col.add(userC); 135 | var result = col.partition('active'); 136 | expect(result).to.be.a('array'); 137 | expect(result.length).to.equal(2); 138 | expect(result[0][0].__model).to.be.equal(userA); 139 | expect(result[0][1].__model).to.be.equal(userC); 140 | expect(result[1][0].__model).to.be.equal(userB); 141 | }); 142 | 143 | it('slice', function() { 144 | var col = new md.Collection(); 145 | var userA = new Model.User(); 146 | userA.name('Foo'); 147 | userA.active(true); 148 | col.add(userA); 149 | var userB = new Model.User(); 150 | userB.name('Bar'); 151 | userB.active(false); 152 | col.add(userB); 153 | var userC = new Model.User(); 154 | userC.name('Baz'); 155 | userC.active(true); 156 | col.add(userC); 157 | var slicedA = col.slice(0, 2); 158 | expect(slicedA).to.be.a('array'); 159 | expect(slicedA.length).to.equal(2); 160 | expect(slicedA[0]).to.be.equal(userA); 161 | expect(slicedA[1]).to.be.equal(userB); 162 | var slicedB = col.slice(1, 3); 163 | expect(slicedB).to.be.a('array'); 164 | expect(slicedB.length).to.equal(2); 165 | expect(slicedB[0]).to.be.equal(userB); 166 | expect(slicedB[1]).to.be.equal(userC); 167 | }); 168 | 169 | }); -------------------------------------------------------------------------------- /test/www/configure.js: -------------------------------------------------------------------------------- 1 | (function() { 2 | 3 | window.Model = {}; 4 | 5 | var STORE; 6 | 7 | function reset() { 8 | window.STORE = STORE = {}; 9 | STORE.user = {}; 10 | STORE.note = {}; 11 | STORE.folder = {}; 12 | STORE.cacheuser = {}; 13 | } 14 | 15 | reset(); 16 | 17 | 18 | md.defaultConfig({ 19 | store: function(rawData) { 20 | return new Promise(function(resolve, reject) { 21 | setTimeout(function() { 22 | // console.log(rawData.method + ':' + rawData.url, rawData.data); 23 | var model = rawData.url.replace('/', ''); 24 | // console.log(model); 25 | switch (rawData.method + ':' + rawData.url) { 26 | case 'GET:/exist': 27 | resolve('ok'); 28 | break; 29 | case 'GET:/clear': 30 | reset(); 31 | resolve('ok'); 32 | break; 33 | case 'POST:/user': 34 | case 'POST:/cacheuser': 35 | case 'POST:/folder': 36 | var data = _.assign({ 37 | id: _.uniqueId('serverid') 38 | }, JSON.parse(rawData.serialize(rawData.data))); 39 | STORE[model][data.id] = data; 40 | resolve(rawData.deserialize(JSON.stringify(STORE[model][data.id]))); 41 | break; 42 | case 'PUT:/user': 43 | case 'PUT:/cacheuser': 44 | case 'PUT:/folder': 45 | var data = JSON.parse(rawData.serialize(rawData.data)); 46 | if (_.has(STORE[model], data.id)) { 47 | STORE[model][data.id] = data; 48 | resolve(rawData.deserialize(JSON.stringify(STORE[model][data.id]))); 49 | } else if (data.id) { 50 | var data = _.assign({}, JSON.parse(rawData.serialize(rawData.data))); 51 | STORE[model][data.id] = data; 52 | resolve(rawData.deserialize(JSON.stringify(STORE[model][data.id]))); 53 | } else { 54 | reject(new Error('Model does not exist!')); 55 | } 56 | break; 57 | case 'GET:/user': 58 | case 'GET:/cacheuser': 59 | case 'GET:/folder': 60 | var data = rawData.data; 61 | if (_.isPlainObject(data) && data.id) { 62 | // Single user 63 | if (_.has(STORE[model], data.id)) { 64 | resolve(rawData.deserialize(STORE[model][data.id])); 65 | } else { 66 | reject(new Error('Model ' + data.id + ' does not exist!')); 67 | } 68 | } else { 69 | if (!_.isEmpty(data)) { 70 | // Selected users 71 | resolve(rawData.deserialize( 72 | JSON.stringify( 73 | _.transform(data, function(result, id) { 74 | if (STORE[model][id]) 75 | result.push(STORE[model][id]); 76 | }, [])))); 77 | } else { 78 | // Return all users 79 | resolve( 80 | rawData.deserialize( 81 | JSON.stringify( 82 | _.values(STORE[model])))); 83 | } 84 | } 85 | break; 86 | case 'DELETE:/user': 87 | case 'DELETE:/cacheuser': 88 | case 'DELETE:/folder': 89 | var data = JSON.parse(rawData.serialize(rawData.data)); 90 | if (data && data.id) { 91 | delete STORE[model][data.id]; 92 | resolve( 93 | rawData.deserialize( 94 | JSON.stringify({ 95 | err: false 96 | }))); 97 | } else { 98 | reject(new Error('ID is required!')); 99 | } 100 | break; 101 | case 'GET:/users/wrap': 102 | // Return all users 103 | resolve({ 104 | outer: { 105 | inner: { 106 | items: rawData.deserialize(JSON.stringify(_.values(STORE.user))) 107 | } 108 | } 109 | }); 110 | break; 111 | case 'GET:/undefined': 112 | resolve(undefined); 113 | break; 114 | default: 115 | reject(new Error('Unknown request')); 116 | } 117 | }, 100); 118 | }); 119 | } 120 | }); 121 | 122 | })(); -------------------------------------------------------------------------------- /source/md.js: -------------------------------------------------------------------------------- 1 | var _ = require('lodash'); 2 | var m = require('mithril'); 3 | var config = require('./global').config; 4 | var modelConstructors = require('./global').modelConstructors; 5 | var BaseModel = require('./baseModel'); 6 | var ModelConstructor = require('./modelConstructor'); 7 | var util = require('./util'); 8 | var Collection = require('./collection'); 9 | 10 | Object.setPrototypeOf = Object.setPrototypeOf || function(obj, proto) { 11 | obj.__proto__ = proto; 12 | return obj; 13 | }; 14 | 15 | /** 16 | * `this.__options` is instance options, registered in `new Model(, <__options>)`. 17 | * `this.options` is schema options, registered in `m.model(schema)`. 18 | */ 19 | 20 | function createModelConstructor(schema) { 21 | // Resolve model options. Mutates the object. 22 | resolveSchemaOptions(schema); 23 | // The model constructor. 24 | function Model(vals, opts) { 25 | // Calling parent class. 26 | BaseModel.call(this, opts); 27 | // Local variables. 28 | var data = (this.__options.parse ? this.options.parser(vals) : vals) || {}; 29 | var props = schema.props; 30 | // var initial; 31 | // Make user id is in prop; 32 | if (_.indexOf(props, config.keyId) === -1) { 33 | props.push(config.keyId); 34 | } 35 | // Adding props. 36 | for (var i = 0, value; i < props.length; i++) { 37 | value = props[i]; 38 | // 1. Must not starts with '__'. 39 | // 2. Omit id in data if you configure different id field. 40 | if (!this.__isProp(value) || (value === 'id' && value !== config.keyId)) 41 | return; 42 | // Make sure that it does not create conflict with 43 | // internal reserved keywords. 44 | if (!_.hasIn(this, value) || value === 'id') { 45 | // Use default if data is not available. Only `undefined` should change to default. 46 | // In order to accept other falsy value. Like, `false` and `0`. 47 | this[value] = this.__gettersetter(_.isUndefined(data[value]) ? schema.defaults[value] : data[value], value); 48 | } else { 49 | throw new Error('`' + value + '` prop is not allowed.'); 50 | } 51 | } 52 | } 53 | // Make sure that it custom methods and statics does not create conflict with internal ones. 54 | var confMethods = util.isConflictExtend(BaseModel.prototype, schema.methods); 55 | if (confMethods) throw new Error('`' + confMethods + '` method is not allowed.'); 56 | // Attach the options to model constructor. 57 | Model.modelOptions = schema; 58 | // Extend from base model prototype. 59 | Model.prototype = _.create(BaseModel.prototype, _.assign(schema.methods || {}, { 60 | constructor: Model, 61 | options: schema, 62 | })); 63 | // Link model controller prototype. 64 | Object.setPrototypeOf(Model, ModelConstructor.prototype); 65 | // Attach statics for model. 66 | if (!_.isEmpty(schema.statics)) { 67 | var confStatics = util.isConflictExtend(Model, schema.statics); 68 | if (confStatics) throw new Error('`' + confStatics + '` method is not allowed for statics.'); 69 | _.assign(Model, schema.statics); 70 | } 71 | // Return the model. 72 | return Model; 73 | } 74 | 75 | // Default parser, if user did not specify 76 | function defaultParser(data) { return data; } 77 | 78 | // Make sure options got correct properties 79 | function resolveSchemaOptions(options) { 80 | options.defaults = options.defaults || {}; 81 | options.props = _.union(options.props || [], _.keys(options.defaults)); 82 | options.refs = options.refs || {}; 83 | options.parser = options.parser || defaultParser; 84 | } 85 | 86 | /** 87 | * Exports 88 | */ 89 | 90 | // Return the current version. 91 | exports.version = function() { 92 | return 'v0.4.9';//version 93 | }; 94 | 95 | // Export class BaseModel 96 | exports.BaseModel = BaseModel; 97 | 98 | // Export class Collection. 99 | exports.Collection = require('./collection'); 100 | 101 | // Export class State. 102 | exports.State = require('./state'); 103 | 104 | // Export our own store controller. 105 | exports.store = require('./store'); 106 | 107 | // Export Mithril's Stream 108 | exports.stream = null; 109 | 110 | // Helper to convert any value to stream 111 | exports.toStream = function(value) { 112 | return (value && value.constructor === exports.stream) ? value : exports.stream(value); 113 | }; 114 | 115 | // Export model instantiator. 116 | exports.model = function(schemaOptions, ctrlOptions) { 117 | schemaOptions = schemaOptions || {}; 118 | ctrlOptions = ctrlOptions || {}; 119 | if (!schemaOptions.name) 120 | throw new Error('Model name must be set.'); 121 | var modelConstructor = modelConstructors[schemaOptions.name] = createModelConstructor(schemaOptions); 122 | modelConstructor.__init(ctrlOptions); 123 | return modelConstructor; 124 | }; 125 | 126 | // A way to get a constructor from this scope 127 | exports.model.get = function(name) { 128 | return modelConstructors[name]; 129 | }; 130 | 131 | // Export configurator 132 | var defaultConfig = {}; 133 | exports.config = function(userConfig) { 134 | // Configure prototypes. 135 | if (userConfig.modelMethods) 136 | util.strictExtend(BaseModel.prototype, userConfig.modelMethods); 137 | if (userConfig.constructorMethods) 138 | util.strictExtend(ModelConstructor.prototype, userConfig.constructorMethods); 139 | if (userConfig.collectionMethods) 140 | util.strictExtend(Collection.prototype, userConfig.collectionMethods); 141 | if (userConfig.stream) 142 | exports.stream = userConfig.stream; 143 | // Clear 144 | userConfig.modelMethods = null; 145 | userConfig.constructorMethods = null; 146 | userConfig.collectionMethods = null; 147 | // Assign configuration. 148 | _.assign(config, userConfig); 149 | }; 150 | 151 | // Option to reset to first initial config. 152 | exports.resetConfig = function() { 153 | util.clearObject(config); 154 | exports.config(defaultConfig); 155 | }; 156 | 157 | // Add config to default config. Does not overwrite the old config. 158 | exports.defaultConfig = function(defaults, silent) { 159 | _.assign(defaultConfig, defaults); 160 | if (!silent) 161 | exports.resetConfig(); 162 | }; 163 | 164 | // Set config defaults. 165 | exports.defaultConfig({ 166 | baseUrl: '', 167 | keyId: 'id', 168 | store: m.request, 169 | stream: require('mithril/stream'), 170 | redraw: false, 171 | storeBackground: false, 172 | cache: false, 173 | cacheLimit: 100, 174 | placeholder: null 175 | }); 176 | 177 | // Export for AMD & browser's global. 178 | if (typeof define === 'function' && define.amd) { 179 | define(function() { 180 | return exports; 181 | }); 182 | } 183 | 184 | // Export for browser's global. 185 | if (typeof window !== 'undefined') { 186 | var oldObject; 187 | // Return back old md. 188 | exports.noConflict = function() { 189 | if (oldObject) { 190 | window.md = oldObject; 191 | oldObject = null; 192 | } 193 | return window.md; 194 | }; 195 | // Export private objects for unit testing. 196 | if (window.__TEST__ && window.mocha && window.chai) { 197 | exports.__TEST__ = { 198 | config: config, 199 | BaseModel: BaseModel, 200 | ModelConstructor: ModelConstructor 201 | }; 202 | } 203 | if (window.md) 204 | oldObject = window.md; 205 | window.md = exports; 206 | } -------------------------------------------------------------------------------- /test/www/test-md.js: -------------------------------------------------------------------------------- 1 | // Root before - Run before all tests. 2 | before(function(done) { 3 | 4 | it('create sample schemas for test', function(done) { 5 | 6 | window.Model.User = md.model({ 7 | name: 'User', 8 | props: ['name', 'profile', 'age', 'active'], 9 | statics: { 10 | hello: function() { 11 | return 'Hello World'; 12 | } 13 | } 14 | }); 15 | 16 | window.Model.Folder = md.model({ 17 | name: 'Folder', 18 | props: ['name', 'size', 'thumbnail'], 19 | defaults: { 20 | name: 'Untitled Folder', 21 | thumbnail: 'thumbnail.jpg' 22 | }, 23 | placehold: ['name'] 24 | }); 25 | 26 | window.Model.Note = md.model({ 27 | name: 'Note', 28 | defaults: { 29 | title: 'Default Title', 30 | body: 'Default Note Body', 31 | author: new Model.User({ 32 | name: 'User Default' 33 | }), 34 | folder: new Model.Folder({ 35 | name: 'Folder Default' 36 | }) 37 | }, 38 | refs: { 39 | author: 'User', 40 | folder: 'Folder' 41 | }, 42 | parser: function(data) { 43 | if (data && data.wrap) { 44 | return { 45 | title: data.wrap.title, 46 | body: data.wrap.inner.body, 47 | author: data.wrap.inner.author 48 | }; 49 | } 50 | return data; 51 | } 52 | }); 53 | 54 | // This model simulate undefined result 55 | window.Model.Alarm = md.model({ 56 | name: 'Alarm', 57 | url: '/undefined', 58 | props: ['title', 'time'], 59 | defaults: { 60 | title: 'Default Alarm Title', 61 | time: '8:00 AM' 62 | } 63 | }); 64 | 65 | expect(window.Model.User).to.exist; 66 | expect(window.Model.Folder).to.exist; 67 | expect(window.Model.Note).to.exist; 68 | 69 | Promise.all([ 70 | (new Model.Folder({ 71 | id: 'fold001', 72 | name: 'System' 73 | })).save(), 74 | (new Model.Folder({ 75 | id: 'fold002', 76 | name: 'Admin' 77 | })).save(), 78 | (new Model.User({ 79 | id: 'user001', 80 | name: 'UserFoo', 81 | age: 1 82 | })).save(), 83 | (new Model.User({ 84 | id: 'user002', 85 | name: 'UserBar', 86 | age: 2 87 | })).save(), 88 | ]).then(function() { 89 | done(); 90 | }); 91 | 92 | }); 93 | 94 | // Clear all test data in server. 95 | md.store.get('/clear').then(function() { 96 | done(); 97 | }, function(err) { 98 | done(err); 99 | }); 100 | 101 | }); 102 | 103 | describe('md', function() { 104 | 'use strict'; 105 | 106 | it('exists', function() { 107 | expect(md).to.exists; 108 | }); 109 | 110 | it('is an object', function() { 111 | expect(md).to.be.a('object'); 112 | }); 113 | 114 | describe('#version()', function() { 115 | 'use strict'; 116 | 117 | it('exists', function() { 118 | expect(md.version).to.be.a('function'); 119 | }); 120 | 121 | it('is a string', function() { 122 | expect(md.version()).to.be.a('string'); 123 | }); 124 | 125 | }); 126 | 127 | describe('#stream()', function() { 128 | 'use strict'; 129 | 130 | it('exists', function() { 131 | expect(md.stream).to.be.a('function'); 132 | }); 133 | 134 | it('real stream', function() { 135 | var sInt = md.stream(123); 136 | expect(sInt.constructor).to.be.equal(md.stream); 137 | expect(sInt.name).to.be.equal('stream'); 138 | }); 139 | 140 | }); 141 | 142 | describe('#toStream()', function() { 143 | 'use strict'; 144 | 145 | it('exists', function() { 146 | expect(md.toStream).to.be.a('function'); 147 | }); 148 | 149 | it('any value', function() { 150 | var sInt = md.toStream(123); 151 | var sString = md.toStream('Foo'); 152 | var sUndefined = md.toStream(); 153 | expect(sInt()).to.be.a('number'); 154 | expect(sInt()).to.be.equal(123); 155 | expect(sString()).to.be.a('string'); 156 | expect(sString()).to.be.equal('Foo'); 157 | expect(sUndefined()).to.be.undefined; 158 | }); 159 | 160 | it('stream value', function() { 161 | var sInt = md.toStream(123); 162 | var newStream = md.toStream(sInt); 163 | expect(newStream()).to.be.a('number'); 164 | expect(newStream()).to.be.equal(123); 165 | }); 166 | 167 | it('real stream', function() { 168 | var sInt = md.toStream(123); 169 | expect(sInt.constructor).to.be.equal(md.stream); 170 | expect(sInt.name).to.be.equal('stream'); 171 | }); 172 | 173 | }); 174 | 175 | describe('#noConflict()', function() { 176 | 'use strict'; 177 | 178 | it('exists', function() { 179 | expect(md.noConflict).to.be.a('function'); 180 | }); 181 | 182 | }); 183 | 184 | describe('#config()', function() { 185 | 'use strict'; 186 | 187 | it('exists', function() { 188 | expect(md.config).to.be.a('function'); 189 | }); 190 | 191 | }); 192 | 193 | describe('#resetConfig()', function() { 194 | 'use strict'; 195 | 196 | after(function() { 197 | md.resetConfig(); 198 | }) 199 | 200 | it('exists', function() { 201 | expect(md.resetConfig).to.be.a('function'); 202 | }); 203 | 204 | it('return back to defaults', function() { 205 | var fn = function() {}; 206 | md.config({ 207 | keyId: 'customId', 208 | storeExtract: fn 209 | }); 210 | expect(md.__TEST__.config.storeExtract).to.equal(fn); 211 | expect(md.__TEST__.config.keyId).to.equal('customId'); 212 | md.resetConfig(); 213 | expect(md.__TEST__.config.storeExtract).to.be.undefined; 214 | expect(md.__TEST__.config.keyId).to.equal('id'); 215 | }); 216 | 217 | }); 218 | 219 | describe('#model()', function() { 220 | 'use strict'; 221 | 222 | it('exists', function() { 223 | expect(md.model).to.exist; 224 | }); 225 | 226 | it('is a function', function() { 227 | expect(md.model).to.be.a('function'); 228 | }); 229 | 230 | it('returns a Model Constructor', function() { 231 | var User = window.Model.User; 232 | expect(User.prototype.__proto__).to.equal(md.__TEST__.BaseModel.prototype); 233 | expect(User).to.be.instanceof(md.__TEST__.ModelConstructor); 234 | }); 235 | 236 | it('throw error on missing name', function() { 237 | expect(function() { 238 | var ModelA = md.model({ 239 | props: ['name'] 240 | }); 241 | }).to.throw(Error); 242 | }); 243 | 244 | it('throw error on restricted props', function() { 245 | expect(function() { 246 | var ModelB = md.model({ 247 | name: 'ModelB', 248 | props: ['url'] // `url` is reserved 249 | }); 250 | var mdl = new ModelB(); 251 | }).to.throw(Error); 252 | }); 253 | 254 | it('correct url', function() { 255 | var ModelB = md.model({ 256 | name: 'ModelB', 257 | url: '/modelb' 258 | }); 259 | var mdl = new ModelB(); 260 | expect(mdl.url()).to.equal('/modelb'); 261 | }); 262 | 263 | it('get model constructor from different scope', function() { 264 | // In scope 'A' 265 | md.model({ 266 | name: 'ModelC' 267 | }); 268 | // In scope 'B' 269 | var mdl = new(md.model.get('ModelC')); 270 | expect(mdl).to.be.instanceof(md.__TEST__.BaseModel); 271 | }); 272 | 273 | }); 274 | 275 | }); -------------------------------------------------------------------------------- /test/www/test-ModelConstructor.js: -------------------------------------------------------------------------------- 1 | describe('Model Constructor', function() { 2 | 'use strict'; 3 | 4 | it('is a constructor function', function() { 5 | var User = Model.User; 6 | expect(User).to.be.a('function'); 7 | }); 8 | 9 | it('has `modelOptions` property', function() { 10 | var User = Model.User; 11 | expect(User).to.have.property('modelOptions'); 12 | }); 13 | 14 | it('`modelOptions` has `name` property with type of string', function() { 15 | var User = Model.User; 16 | expect(User.modelOptions).to.have.property('name'); 17 | expect(User.modelOptions.name).to.be.a('string'); 18 | }); 19 | 20 | it('`modelOptions` has `props` property with type of array of strings', function() { 21 | var User = Model.User; 22 | expect(User.modelOptions).to.have.property('props'); 23 | expect(User.modelOptions.props).to.be.a('array'); 24 | expect(User.modelOptions.props[0]).to.be.a('string'); 25 | }); 26 | 27 | describe('#createCollection()', function() { 28 | 'use strict'; 29 | 30 | it('exist', function() { 31 | var User = Model.User; 32 | expect(User).to.have.property('createCollection'); 33 | }); 34 | 35 | it('returns new instance of `md.Collection`', function() { 36 | var User = Model.User; 37 | var userCollection = User.createCollection(); 38 | expect(userCollection).to.be.an.instanceof(md.Collection); 39 | expect(userCollection.__options.model).to.exist; 40 | expect(userCollection.__options.model).to.equal(User); 41 | }); 42 | 43 | }); 44 | 45 | describe('#createModels()', function() { 46 | 'use strict'; 47 | 48 | it('exist', function() { 49 | expect(Model.User.createModels).to.be.a.function; 50 | }); 51 | 52 | it('convert array of object-data to array of models', function() { 53 | var models = Model.User.createModels([{ 54 | name: 'Foo' 55 | }, { 56 | name: 'Bar' 57 | }]); 58 | expect(models[0].name()).to.equal('Foo'); 59 | expect(models[1].name()).to.equal('Bar'); 60 | }); 61 | 62 | it('create with parser', function() { 63 | var models = Model.Note.createModels([{ 64 | wrap: { 65 | title: 'Foo', 66 | inner: { 67 | body: 'Bar', 68 | author: 'Baz' 69 | } 70 | } 71 | }]); 72 | expect(models[0].title()).to.equal('Foo'); 73 | expect(models[0].body()).to.equal('Bar'); 74 | expect(models[0].author()).to.equal('Baz'); 75 | }); 76 | 77 | it('cache and cache limit', function() { 78 | var CacheNoteModel = md.model({ 79 | name: 'CacheNoteModel', 80 | props: ['title', 'body'] 81 | }, { 82 | cache: true, 83 | cacheLimit: 2 84 | }); 85 | var modelsA = CacheNoteModel.createModels([{ 86 | id: '123', 87 | name: 'Foo', 88 | body: 'Bar' 89 | }, { 90 | id: '456', 91 | name: 'Foo', 92 | body: 'Bar' 93 | }]); 94 | var modelsB = CacheNoteModel.createModels([{ 95 | id: '123', 96 | name: 'Foo', 97 | body: 'Bar' 98 | }, { 99 | id: '456', 100 | name: 'Baz', 101 | body: 'Tes' 102 | }, { 103 | id: '789', 104 | name: 'Zzz', 105 | body: 'Xxx' 106 | }]); 107 | expect(modelsA.length).to.equal(2); 108 | expect(modelsB.length).to.equal(3); 109 | expect(modelsB[0]).to.equal(modelsA[0]); 110 | expect(modelsB[1]).to.equal(modelsA[1]); 111 | expect(modelsB[0].lid()).to.equal(modelsA[0].lid()); 112 | expect(modelsB[1].lid()).to.equal(modelsA[1].lid()); 113 | expect(modelsB[2].id()).to.equal('789'); 114 | expect(CacheNoteModel.__cacheCollection.size()).to.equal(2); 115 | expect(CacheNoteModel.__cacheCollection.contains('123')).to.be.false; 116 | expect(CacheNoteModel.__cacheCollection.contains('789')).to.be.true; 117 | }); 118 | 119 | 120 | }); 121 | 122 | describe('#create()', function() { 123 | 'use strict'; 124 | 125 | var CacheUser; 126 | var tempIdA; 127 | var tempIdB; 128 | 129 | before(function() { 130 | CacheUser = md.model({ 131 | name: 'CacheUser', 132 | props: ['name', 'age'] 133 | }, { 134 | cache: true, 135 | }); 136 | }); 137 | 138 | it('exist', function() { 139 | expect(CacheUser.create).to.be.a.function; 140 | }); 141 | 142 | it('instantiate model *1', function(done) { 143 | // Create a model and add to cache but without saving to store 144 | CacheUser.create({ 145 | id: 'mcid001', 146 | name: 'Fooz' 147 | }); 148 | // Create a model, saving to store and add to cache 149 | var userB = CacheUser.create(); 150 | expect(userB).to.be.instanceof(CacheUser); 151 | userB.name('Barz'); 152 | // By new keyword 153 | var userC = new CacheUser({ 154 | name: 'Jazz' 155 | }); 156 | // userC.save(); 157 | Promise.all([ 158 | userB.save().then(function() { 159 | // We now have id generated by server 160 | expect(userB.id()).to.exist; 161 | tempIdA = userB.id(); 162 | }), 163 | userC.save().then(function() { 164 | expect(userC.id()).to.exist; 165 | tempIdB = userC.id(); 166 | }) 167 | ]).then(function() { 168 | done(); 169 | }); 170 | }); 171 | 172 | it('get from cache (should not create new id)', function(done) { 173 | // Get get model that was create without saving 174 | var userA = CacheUser.create({ 175 | // User id was created in *1 176 | id: 'mcid001' 177 | }); 178 | expect(userA.name()).to.equal('Fooz'); 179 | // Get get model that was create WITH saving 180 | var userB = CacheUser.create({ 181 | // User id was created in *1 182 | id: tempIdA 183 | }); 184 | expect(userB.name()).to.equal('Barz'); 185 | var userC = CacheUser.create({ 186 | // User id was created in *1 187 | id: tempIdB 188 | }); 189 | expect(userC.name()).to.equal('Jazz'); 190 | var userCA = new CacheUser({ 191 | id: tempIdB 192 | }); 193 | userCA.fetch().then(function() { 194 | expect(userCA.name()).to.equal('Jazz'); 195 | done(); 196 | }); 197 | }); 198 | 199 | }); 200 | 201 | describe('#pull()', function() { 202 | 'use strict'; 203 | 204 | var _ids = []; 205 | var _models = {}; 206 | 207 | before(function(done) { 208 | var len = 5; 209 | var cnt = 0; 210 | var user; 211 | var fn = function(model) { 212 | _ids.push(model.id()); 213 | _models[model.id()] = model; 214 | cnt++; 215 | if (cnt >= len) { 216 | done(); 217 | return; 218 | } 219 | }; 220 | for (var i = len; i > 0; i--) { 221 | user = new Model.User(); 222 | user.name('Foo' + i); 223 | user.age(111); 224 | user.save().then(fn); 225 | } 226 | }); 227 | 228 | it('exist', function() { 229 | expect(Model.User.pull).to.exist.and.be.a.function; 230 | }); 231 | 232 | it('should pull (1) - pull by array (of ids)', function(done) { 233 | // url: /user?0=idabc&1=idxyz 234 | // if you prefer /user/idabc/idxyz 235 | // url is parsable through md.config { storeConfigOptions } 236 | Model.User.pull('/user', [_ids[1], _ids[2]]).then(function(models) { 237 | try { 238 | expect(models.length).to.equal(2); 239 | var m0 = models[0]; 240 | expect(m0.name()).to.equal(_models[m0.id()].name()); 241 | var m1 = models[1]; 242 | expect(m1.name()).to.equal(_models[m1.id()].name()); 243 | done(); 244 | } catch (e) { 245 | done(e); 246 | } 247 | }, function(err) { 248 | done(err); 249 | }) 250 | }); 251 | 252 | it('should pull (2) - pull by object (query)', function(done) { 253 | // url: /user?name=Test&age=111 254 | // if you prefere /user/Test/111 255 | // url is parsable through md.config { storeConfigOptions } 256 | Model.User.pull('/user', { 257 | name: 'Test', 258 | age: 111 259 | }).then(function(models) { 260 | try { 261 | done(); 262 | } catch (e) { 263 | done(e); 264 | } 265 | }, function(err) { 266 | done(err); 267 | }) 268 | }); 269 | 270 | it('should pull (3) - pull without data', function(done) { 271 | Model.User.pull('/user').then(function(models) { 272 | try { 273 | expect(models.length).to.above(1); 274 | done(); 275 | } catch (e) { 276 | done(e); 277 | } 278 | }, function(err) { 279 | done(err); 280 | }) 281 | }); 282 | 283 | it('pull using callback function', function(done) { 284 | Model.User.pull('/user', function(err, response, models) { 285 | if (err) { 286 | done(err); 287 | return; 288 | } 289 | try { 290 | expect(response).to.exist; 291 | expect(models).to.exist; 292 | done(); 293 | } catch (e) { 294 | done(e); 295 | } 296 | }); 297 | }); 298 | 299 | it('pull with path', function(done) { 300 | Model.User.pull('/users/wrap', { 301 | path: 'outer.inner.items' 302 | }, function(err, response, models) { 303 | if (err) { 304 | done(err) 305 | return; 306 | } 307 | try { 308 | expect(response.outer.inner.items).to.exist; 309 | expect(models.length).to.be.above(3); 310 | expect(models[0].id()).to.be.not.empty; 311 | expect(models[0].name()).to.be.not.empty; 312 | expect(models[0].age()).to.be.not.empty; 313 | done(); 314 | } catch (e) { 315 | done(e); 316 | } 317 | }); 318 | }); 319 | 320 | }); 321 | 322 | }); 323 | -------------------------------------------------------------------------------- /source/collection.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Collection 3 | */ 4 | 5 | var _ = require('lodash'); 6 | var m = require('mithril'); 7 | var util = require('./util'); 8 | var config = require('./global').config; 9 | var BaseModel = require('./baseModel'); 10 | var State = require('./state'); 11 | 12 | function Collection(options) { 13 | this.models = []; 14 | this.__options = { 15 | redraw: false, 16 | _cache: false 17 | }; 18 | if (options) 19 | this.opt(options); 20 | var state = this.__options.state; 21 | if (state) { 22 | if (!(state instanceof State)) { 23 | state = new State(state); 24 | } 25 | this.__state = state; 26 | } 27 | _.bindAll(this, _.union(collectionBindMethods, config.collectionBindMethods)); 28 | this.__working = false; 29 | } 30 | 31 | // Export class. 32 | module.exports = Collection; 33 | 34 | // Prototype methods. 35 | Collection.prototype = { 36 | opt: function(key, value) { 37 | if (_.isPlainObject(key)) 38 | _.assign(this.__options, key); 39 | else 40 | this.__options[key] = _.isUndefined(value) ? true : value; 41 | }, 42 | add: function(model, unshift, silent) { 43 | if (!(model instanceof BaseModel) || (this.__options.model && !(model instanceof this.__options.model))) 44 | throw new Error('Must be a model or an instance of set model'); 45 | var existingModel = this.get(model); 46 | var added = false; 47 | if (existingModel) { 48 | existingModel.set(model); 49 | } else { 50 | if (unshift) 51 | this.models.unshift(model.getJson()); 52 | else 53 | this.models.push(model.getJson()); 54 | model.attachCollection(this); 55 | if (this.__state) 56 | this.__state.set(model.lid()); 57 | added = true; 58 | } 59 | if (added && !silent) 60 | this.__update(); 61 | return added; 62 | }, 63 | addAll: function(models, unshift, silent) { 64 | if (!_.isArray(models)) 65 | models = [models]; 66 | var added = false; 67 | var i = 0; 68 | for (; i < models.length; i++) { 69 | if (this.add(models[i], unshift, true)) 70 | added = true; 71 | } 72 | if (added && !silent) 73 | this.__update(); 74 | return added; 75 | }, 76 | create: function(data, opts) { 77 | if (!_.isArray(data)) 78 | data = [data]; 79 | var newModels = []; 80 | var existingModel; 81 | var modelData; 82 | var i = 0; 83 | for (; i < data.length; i++) { 84 | modelData = data[i]; 85 | if (!_.isPlainObject(modelData)) 86 | throw new Error('Plain object required'); 87 | existingModel = this.get(modelData); 88 | if (existingModel) { 89 | existingModel.set(modelData, true); 90 | } else { 91 | // TODO: Check to use cache 92 | if (this.__options.model) 93 | newModels.push(this.__options.model.create(modelData, opts)); 94 | // newModels.push(new this.__options.model(modelData)); 95 | } 96 | } 97 | this.addAll(newModels); 98 | return newModels; 99 | }, 100 | get: function(mixed) { 101 | // mixed can be id-number, id-string, plain-object or model. 102 | // NOTE: check if model/object contains id and use it instead. 103 | // returns a model. 104 | var jsonModel; 105 | if (mixed instanceof BaseModel) { 106 | // mixed is a model and is in this collection. 107 | return this.contains(mixed) ? mixed : undefined; 108 | } else if (_.isObject(mixed)) { 109 | // Use `isObject` to include functions. 110 | if (mixed[config.keyId]) 111 | mixed = mixed[config.keyId]; 112 | else 113 | return this.find(mixed) || undefined; 114 | } 115 | jsonModel = this.find([config.keyId, mixed]); 116 | return jsonModel || undefined; 117 | }, 118 | getAll: function(mixed, falsy) { 119 | // Note that this will not get all matched. 120 | // Will only get the first match of each array item. 121 | if (!_.isArray(mixed)) 122 | mixed = [mixed]; 123 | var models = []; 124 | var i = 0; 125 | var exist; 126 | for (; i < mixed.length; i++) { 127 | exist = this.get(mixed[i]); 128 | if (exist || falsy) { 129 | models.push(exist); 130 | } 131 | } 132 | return models; 133 | }, 134 | remove: function(mixed, silent) { 135 | // mixed can be array of id-number, id-string, plain-object or model. 136 | if (!_.isArray(mixed)) 137 | mixed = [mixed]; 138 | var lastLength = this.size(); 139 | var removedModels = []; 140 | var matchMix; 141 | var mix; 142 | var i; 143 | if (!lastLength) 144 | return; 145 | for (i = 0; i < mixed.length; i++) { 146 | mix = mixed[i]; 147 | if (!mix) 148 | throw new Error('Can\'t remove from collection. Argument must be set.'); 149 | if (mix instanceof BaseModel) { 150 | removedModels.push.apply(removedModels, _.remove(this.models, function(value) { 151 | return _.eq(value, mix.getJson()); 152 | })); 153 | } else if (_.isObjectLike(mix)) { 154 | removedModels.push.apply(removedModels, _.remove(this.models, function(value) { 155 | return _.isMatch(value, mix); 156 | })); 157 | } else { 158 | removedModels.push.apply(removedModels, _.remove(this.models, function(value) { 159 | matchMix = {}; 160 | matchMix[config.keyId] = mix; 161 | return _.isMatch(value, matchMix); 162 | })); 163 | } 164 | } 165 | var model; 166 | for (i = 0; i < removedModels.length; i++) { 167 | model = removedModels[i].__model; 168 | model.detachCollection(this); 169 | if (this.__state) 170 | this.__state.remove(model.lid()); 171 | } 172 | if (lastLength !== this.size()) { 173 | if (!silent) 174 | this.__update(); 175 | return true; 176 | } 177 | return false; 178 | }, 179 | push: function(models, silent) { 180 | return this.addAll(models, silent); 181 | }, 182 | unshift: function(models, silent) { 183 | return this.addAll(models, true, silent); 184 | }, 185 | shift: function(silent) { 186 | var model = this.first(); 187 | this.remove(model, silent); 188 | return model; 189 | }, 190 | pop: function(silent) { 191 | var model = this.last(); 192 | this.remove(model, silent); 193 | return model; 194 | }, 195 | clear: function(silent) { 196 | var i, item, items = this.toArray(), 197 | len = items.length; 198 | for (i = 0; i < len; i++) { 199 | item = items[i]; 200 | item.detachCollection(this); 201 | if (this.__state) this.__state.remove(item.lid()); 202 | } 203 | if (len !== this.size()) { 204 | if (!silent) this.__update(); 205 | return true; 206 | } 207 | return false; 208 | }, 209 | pluck: function(key, filter) { 210 | var plucked = [], 211 | isId = (key === 'id'); 212 | for (var i = 0, models = this.models; i < models.length; i++) { 213 | if (filter && !filter(models[i].__model)) continue; 214 | plucked.push(isId ? models[i].__model[key]() : models[i][key]); 215 | } 216 | return plucked; 217 | }, 218 | dispose: function() { 219 | var keys = _.keys(this); 220 | var i = 0; 221 | if (this.__options.model) 222 | this.__options.model = null; 223 | if (this.__state) 224 | this.__state.dispose(); 225 | for (; i < keys.length; i++) { 226 | this[keys[i]] = null; 227 | } 228 | }, 229 | destroy: function() { 230 | this.clear(true); 231 | this.dispose(); 232 | }, 233 | stateOf: function(mixed) { 234 | if (this.__state) { 235 | var model = this.get(mixed); 236 | if (model) 237 | return this.__state.get(model.lid()); 238 | } 239 | }, 240 | contains: function(mixed) { 241 | if (mixed instanceof BaseModel) { 242 | // mixed is a model and is in this collection. 243 | return this.indexOf(mixed.getJson()) > -1; 244 | } else if (_.isObject(mixed)) { 245 | // Use `isObject` to include functions. 246 | // If mixed contains `keyId` then search by id 247 | if (mixed[config.keyId]) 248 | mixed = mixed[config.keyId]; 249 | else 250 | return this.findIndex(mixed) > -1; 251 | } 252 | return this.findIndex([config.keyId, mixed]) > -1; 253 | }, 254 | sort: function(fields, orders) { 255 | if (!_.isArray(fields)) 256 | fields = [fields]; 257 | var sorted; 258 | if (orders) { 259 | if (!_.isArray(orders)) 260 | orders = [orders]; 261 | sorted = this.orderBy(fields, orders); 262 | } else { 263 | sorted = this.orderBy(fields); 264 | } 265 | this.__replaceModels(sorted); 266 | }, 267 | sortByOrder: function(order, path) { 268 | if (!path) path = config.keyId; 269 | this.__replaceModels(this.sortBy([function(item) { 270 | return order.indexOf(_.get(item, path)); 271 | }])); 272 | }, 273 | randomize: function() { 274 | this.__replaceModels(this.shuffle()); 275 | }, 276 | hasModel: function() { 277 | return !!this.__options.model; 278 | }, 279 | model: function() { 280 | return this.__options.model; 281 | }, 282 | url: function(noBase) { 283 | var url = noBase ? '' : config.baseUrl; 284 | if (this.__options.url) 285 | url += this.__options.url; 286 | else if (this.hasModel()) 287 | url += this.model().modelOptions.url || '/' + this.model().modelOptions.name.toLowerCase(); 288 | return url; 289 | }, 290 | fetch: function(query, options, callback) { 291 | if (_.isFunction(options)) { 292 | callback = options; 293 | options = undefined; 294 | } 295 | var self = this; 296 | self.__working = true; 297 | if (self.hasModel()) { 298 | options = options || {}; 299 | return self.model().pull(self.url(), query, options, function(err, response, models) { 300 | self.__working = false; 301 | if (err) { 302 | if (_.isFunction(callback)) callback(err); 303 | } else { 304 | if (options.clear) 305 | self.clear(true); 306 | self.addAll(models); 307 | if (_.isFunction(callback)) callback(null, response, models); 308 | } 309 | }); 310 | } else { 311 | self.__working = false; 312 | var err = new Error('Collection must have a model to perform fetch'); 313 | if (_.isFunction(callback)) callback(err); 314 | return Promise.reject(err); 315 | } 316 | }, 317 | isWorking: function() { 318 | return this.__working; 319 | }, 320 | isFetching: function() { 321 | return this.isWorking(); 322 | }, 323 | __replaceModels: function(models) { 324 | for (var i = models.length - 1; i >= 0; i--) { 325 | this.models[i] = models[i].__json; 326 | } 327 | }, 328 | __update: function(fromModel) { 329 | // Levels: instance || global 330 | if (!this.__options._cache && (this.__options.redraw || config.redraw)) { 331 | // If `fromModel` is specified, means triggered by contained model. 332 | // Otherwise, triggered by collection itself. 333 | if (!fromModel) { 334 | // console.log('Redraw', 'Collection'); 335 | m.redraw(); 336 | } 337 | return true; 338 | } 339 | } 340 | }; 341 | 342 | 343 | // Method to bind to Collection object. Use by _.bindAll(). 344 | var collectionBindMethods = []; 345 | 346 | // Lodash methods to add. 347 | var collectionMethods = { 348 | difference: 1, 349 | every: 1, 350 | find: 1, 351 | findIndex: 1, 352 | findLastIndex: 1, 353 | filter: 1, 354 | first: 0, 355 | forEach: 1, 356 | indexOf: 2, 357 | initial: 0, 358 | invoke: 3, 359 | groupBy: 1, 360 | last: 0, 361 | lastIndexOf: 2, 362 | map: 1, 363 | maxBy: 1, 364 | minBy: 1, 365 | nth: 1, 366 | orderBy: 2, 367 | partition: 1, 368 | reduce: -1, 369 | reject: 1, 370 | reverse: 0, 371 | sample: 0, 372 | shuffle: 0, 373 | size: 0, 374 | slice: 2, 375 | sortBy: 1, 376 | some: 1, 377 | transform: 2, 378 | toArray: 0, 379 | without: 1 380 | }; 381 | 382 | 383 | // Inject lodash method. 384 | util.addMethods(Collection.prototype, _, collectionMethods, 'models', '__model'); -------------------------------------------------------------------------------- /test/www/test-md.config.js: -------------------------------------------------------------------------------- 1 | describe('md.config()', function() { 2 | 'use strict'; 3 | 4 | afterEach(function() { 5 | md.resetConfig(); 6 | }); 7 | 8 | describe('baseUrl', function() { 9 | 'use strict'; 10 | 11 | it('for model', function() { 12 | md.config({ 13 | baseUrl: '/base' 14 | }); 15 | var user = new Model.User(); 16 | expect(user.url()).to.equal('/base/user'); 17 | }); 18 | 19 | it('for model with custom url', function() { 20 | md.config({ 21 | baseUrl: '/base' 22 | }); 23 | var ModelConfigBaseUrlA = md.model({ 24 | name: 'ModelConfigBaseUrlA', 25 | url: '/modelurl' 26 | }); 27 | var mdl = new ModelConfigBaseUrlA(); 28 | expect(mdl.url()).to.equal('/base/modelurl'); 29 | }); 30 | 31 | it('for collection without model', function() { 32 | md.config({ 33 | baseUrl: '/base' 34 | }); 35 | var colA = new md.Collection(); 36 | expect(colA.url()).to.equal('/base'); 37 | }); 38 | 39 | it('for collection without model but with custom url', function() { 40 | md.config({ 41 | baseUrl: '/base' 42 | }); 43 | var colA = new md.Collection({ 44 | url: '/collectionurl' 45 | }); 46 | expect(colA.url()).to.equal('/base/collectionurl'); 47 | }); 48 | 49 | it('for collection with model', function() { 50 | md.config({ 51 | baseUrl: '/base' 52 | }); 53 | var colB = new md.Collection({ 54 | model: Model.User 55 | }); 56 | expect(colB.url()).to.equal('/base/user'); 57 | }); 58 | 59 | it('for collection with custom url', function() { 60 | md.config({ 61 | baseUrl: '/base' 62 | }); 63 | var colC = new md.Collection({ 64 | model: Model.User, 65 | url: '/collectionurl' 66 | }); 67 | expect(colC.url()).to.equal('/base/collectionurl'); 68 | }); 69 | 70 | 71 | it('for collection with model that is with custom url', function() { 72 | md.config({ 73 | baseUrl: '/base' 74 | }); 75 | var ModelConfigBaseUrlB = md.model({ 76 | name: 'ModelConfigBaseUrlB', 77 | url: '/modelurl' 78 | }); 79 | var colD = new md.Collection({ 80 | model: ModelConfigBaseUrlB 81 | }); 82 | expect(colD.url()).to.equal('/base/modelurl'); 83 | }); 84 | 85 | }); 86 | 87 | describe('keyId', function() { 88 | 'use strict'; 89 | 90 | it('exist in model and is a function', function() { 91 | md.config({ 92 | keyId: 'customKey' 93 | }); 94 | var ModelCustomKeyIdA = md.model({ 95 | name: 'ModelCustomKeyIdA', 96 | props: ['name'] 97 | }); 98 | var mdl = new ModelCustomKeyIdA(); 99 | expect(mdl.customKey).to.be.a('function'); 100 | }); 101 | 102 | it('set and get correct value through method `id()`', function() { 103 | md.config({ 104 | keyId: 'customKey' 105 | }); 106 | var ModelCustomKeyIdB = md.model({ 107 | name: 'ModelCustomKeyIdB', 108 | props: ['name'] 109 | }); 110 | var mdl = new ModelCustomKeyIdB(); 111 | mdl.id('test123'); 112 | expect(mdl.id()).to.equal('test123'); 113 | expect(mdl.__json.customKey).to.equal('test123'); 114 | expect(mdl.__json.id).to.not.exist; 115 | }); 116 | 117 | }); 118 | 119 | describe('constructorMethods', function() { 120 | 'use strict'; 121 | 122 | it('exist in model and is a function', function() { 123 | md.config({ 124 | constructorMethods: { 125 | customConsMethod: function() { 126 | expect(this).to.be.a('function'); 127 | } 128 | } 129 | }); 130 | var ConsCustomMethodsA = md.model({ 131 | name: 'ConsCustomMethodsA' 132 | }); 133 | var ConsCustomMethodsB = md.model({ 134 | name: 'ConsCustomMethodsA' 135 | }); 136 | expect(ConsCustomMethodsA.customConsMethod).to.be.a('function'); 137 | ConsCustomMethodsA.customConsMethod(); 138 | expect(ConsCustomMethodsB.customConsMethod).to.be.a('function'); 139 | ConsCustomMethodsB.customConsMethod(); 140 | }); 141 | 142 | }); 143 | 144 | describe('modelMethods', function() { 145 | 'use strict'; 146 | 147 | it('exist in model and is a function', function() { 148 | md.config({ 149 | modelMethods: { 150 | customModelMethod: function() { 151 | expect(this).to.be.instanceof(md.__TEST__.BaseModel); 152 | } 153 | } 154 | }); 155 | var ModelCustomMethodsA = md.model({ 156 | name: 'ModelCustomMethodsA' 157 | }); 158 | // var ModelCustomMethodsB = md.model({ 159 | // name: 'ModelCustomMethodsB' 160 | // }); 161 | var mdlA = new ModelCustomMethodsA(); 162 | expect(mdlA.customModelMethod).to.be.a('function'); 163 | mdlA.customModelMethod(); 164 | var mdlB = new ModelCustomMethodsA(); 165 | expect(mdlB.customModelMethod).to.be.a('function'); 166 | mdlB.customModelMethod(); 167 | }); 168 | 169 | }); 170 | 171 | describe('collectionMethods', function() { 172 | 'use strict'; 173 | 174 | it('exist in model and is a function', function() { 175 | md.config({ 176 | collectionMethods: { 177 | customColMethod: function() { 178 | expect(this).to.be.instanceof(md.Collection); 179 | } 180 | } 181 | }); 182 | var ColA = new md.Collection(); 183 | var ColB = new md.Collection(); 184 | expect(ColA.customColMethod).to.be.a('function'); 185 | ColA.customColMethod(); 186 | expect(ColB.customColMethod).to.be.a('function'); 187 | ColB.customColMethod(); 188 | }); 189 | 190 | }); 191 | 192 | describe('modelBindMethods', function() { 193 | 'use strict'; 194 | 195 | it('direct own property', function() { 196 | md.config({ 197 | modelBindMethods: ['save', 'destroy'] 198 | }); 199 | var ModelBindMethodsA = md.model({ 200 | name: 'ModelBindMethodsA' 201 | }); 202 | var mdl = new ModelBindMethodsA(); 203 | expect(mdl.hasOwnProperty('save')).to.be.true; 204 | expect(mdl.hasOwnProperty('destroy')).to.be.true; 205 | }); 206 | 207 | }); 208 | 209 | describe('collectionBindMethods', function() { 210 | 'use strict'; 211 | 212 | it('direct own property', function() { 213 | md.config({ 214 | collectionBindMethods: ['fetch'] 215 | }); 216 | var col = new md.Collection(); 217 | expect(col.hasOwnProperty('fetch')).to.be.true; 218 | }); 219 | 220 | }); 221 | 222 | describe('storeConfigOptions', function() { 223 | 'use strict'; 224 | 225 | it('added to md config', function() { 226 | var fn = function() {}; 227 | md.config({ 228 | storeConfigOptions: fn 229 | }); 230 | expect(md.__TEST__.config.storeConfigOptions).to.equal(fn); 231 | }); 232 | 233 | it('called with correct arguments', function(done) { 234 | var fn = function(options) { 235 | expect(options.method).to.equal('GET'); 236 | expect(options.url).to.equal('/exist'); 237 | done(); 238 | }; 239 | md.config({ 240 | storeConfigOptions: fn 241 | }); 242 | md.store.request('/exist'); 243 | }); 244 | 245 | }); 246 | 247 | describe('storeConfigXHR', function() { 248 | 'use strict'; 249 | 250 | it('added to md config', function() { 251 | var fn = function() {}; 252 | md.config({ 253 | storeConfigXHR: fn 254 | }); 255 | expect(md.__TEST__.config.storeConfigXHR).to.equal(fn); 256 | }); 257 | 258 | }); 259 | 260 | describe('storeExtract', function() { 261 | 'use strict'; 262 | // This test case suite will create error 263 | // storeExtract must be configured before everthing else 264 | it('added to md config', function() { 265 | var fn = function() {}; 266 | md.config({ 267 | storeExtract: fn 268 | }); 269 | expect(md.__TEST__.config.storeExtract).to.equal(fn); 270 | }); 271 | 272 | }); 273 | 274 | describe('store', function() { 275 | 'use strict'; 276 | 277 | it('added to md config', function() { 278 | var fn = function() {}; 279 | md.config({ 280 | store: fn 281 | }); 282 | expect(md.__TEST__.config.store).to.equal(fn); 283 | }); 284 | 285 | it('called with correct arguments', function(done) { 286 | var fn = function(data) { 287 | expect(data.method).to.equal('GET'); 288 | expect(data.url).to.equal('/exist'); 289 | expect(data.data).to.exist; 290 | done(); 291 | }; 292 | md.config({ 293 | store: fn 294 | }); 295 | md.store.request('/exist'); 296 | }); 297 | 298 | }); 299 | 300 | describe('stream', function() { 301 | 'use strict'; 302 | 303 | it('added to config', function() { 304 | var fn = function() {}; 305 | md.config({ 306 | stream: fn 307 | }); 308 | expect(md.__TEST__.config.stream).to.equal(fn); 309 | }); 310 | 311 | it('added to md', function() { 312 | var fn = function() {}; 313 | md.config({ 314 | stream: fn 315 | }); 316 | expect(md.stream).to.equal(fn); 317 | }); 318 | 319 | }); 320 | 321 | describe('placeholder', function() { 322 | 'use strict'; 323 | 324 | it('added to config', function() { 325 | md.config({ 326 | placeholder: 'Fooing...' 327 | }); 328 | expect(md.__TEST__.config.placeholder).to.equal('Fooing...'); 329 | }); 330 | 331 | it('defaults to null', function() { 332 | expect(md.__TEST__.config.placeholder).to.be.null; 333 | var folder = new Model.Folder({ 334 | id: 'fold001' 335 | }); 336 | folder.fetch(); 337 | expect(folder.name()).to.equal('Untitled Folder'); 338 | }); 339 | 340 | it('should be working', function(done) { 341 | md.config({ 342 | placeholder: 'Baring...' 343 | }); 344 | 345 | var folder = new Model.Folder({ 346 | id: 'fold001' 347 | }); 348 | folder.fetch().then(function() { 349 | expect(folder.name()).to.equal('System'); 350 | expect(folder.thumbnail()).to.equal('thumbnail.jpg'); 351 | done(); 352 | }, done); 353 | expect(folder.name()).to.equal('Baring...'); 354 | expect(folder.thumbnail()).to.equal('thumbnail.jpg'); 355 | expect(folder.size()).to.be.undefined; 356 | }); 357 | 358 | }); 359 | 360 | }); -------------------------------------------------------------------------------- /source/baseModel.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Base Model 3 | */ 4 | 5 | var _ = require('lodash'); 6 | var m = require('mithril'); 7 | var config = require('./global').config; 8 | var modelConstructors = require('./global').modelConstructors; 9 | 10 | function BaseModel(opts) { 11 | this.__options = { 12 | redraw: false, 13 | parse: true 14 | }; 15 | this.__collections = []; 16 | this.__lid = _.uniqueId('model'); 17 | this.__saved = false; 18 | this.__modified = false; 19 | this.__working = 0; // 0:idle, 1: fetch, 2: save, 3: destroy 20 | this.__json = { 21 | __model: this 22 | }; 23 | _.bindAll(this, _.union(modelBindMethods, config.modelBindMethods)); 24 | if (opts) this.opt(opts); 25 | } 26 | 27 | // Export class. 28 | module.exports = BaseModel; 29 | 30 | // Need to require after export. To fix the circular dependencies issue. 31 | var store = require('./store'); 32 | var util = require('./util'); 33 | var Collection = require('./collection'); 34 | 35 | // Method to bind to Model object. Use by _.bindAll(). 36 | var modelBindMethods = []; 37 | 38 | // Lodash methods to add. 39 | var objectMethods = { 40 | has: 1, 41 | keys: 0, 42 | values: 0, 43 | pick: 1, 44 | omit: 1 45 | }; 46 | 47 | // Prototype methods. 48 | BaseModel.prototype = { 49 | opt: function(key, value) { 50 | if (_.isPlainObject(key)) _.assign(this.__options, key); 51 | else this.__options[key] = _.isUndefined(value) ? true : value; 52 | }, 53 | // Get or set id of model. 54 | id: function(id) { 55 | return id ? this[config.keyId](id, true) : this[config.keyId](); 56 | }, 57 | lid: function() { 58 | return this.__lid; 59 | }, 60 | // Get the full url for store. 61 | url: function() { 62 | return config.baseUrl + (this.options.url || '/' + this.options.name.toLowerCase()); 63 | }, 64 | // Add this model to collection. 65 | attachCollection: function(collection) { 66 | if (!(collection instanceof Collection)) 67 | throw new Error('Argument `collection` must be instance of Collection.'); 68 | var model = collection.get(this); 69 | if (model && _.indexOf(this.__collections, collection) === -1) { 70 | // This model exist in collection. 71 | // Add collection to model's local reference. 72 | this.__collections.push(collection); 73 | } else { 74 | collection.add(this); 75 | } 76 | }, 77 | // Remove this model from collection. 78 | detachCollection: function(collection) { 79 | if (!(collection instanceof Collection)) 80 | throw new Error('Argument `collection` must be instance of Collection.'); 81 | // Remove this model from collection first. 82 | if (collection.get(this)) collection.remove(this); 83 | // Remove that collection from model's collection. 84 | if (_.indexOf(this.__collections, collection) > -1) 85 | _.pull(this.__collections, collection); 86 | }, 87 | // Sets all or a prop values from passed data. 88 | set: function(key, value, silent, saved) { 89 | if (_.isString(key)) this[key](value, silent); 90 | else this.setObject(key, silent, saved); 91 | }, 92 | // Sets props by object. 93 | setObject: function(obj, silent, saved) { 94 | var isModel = obj instanceof BaseModel; 95 | if (!isModel && !_.isPlainObject(obj)) 96 | throw new Error('Argument `obj` must be a model or plain object.'); 97 | var _obj = (!isModel && this.__options.parse) ? this.options.parser(obj) : obj; 98 | var keys = _.keys(_obj); 99 | for (var i = keys.length - 1, key, val; i >= 0; i--) { 100 | key = keys[i]; 101 | val = _obj[key]; 102 | if (!this.__isProp(key) || !_.isFunction(this[key])) 103 | continue; 104 | if (isModel && _.isFunction(val)) { 105 | this[key](val(), true, saved); 106 | } else { 107 | this[key](val, true, saved); 108 | } 109 | } 110 | if (!silent) // silent 111 | this.__update(); 112 | }, 113 | // Get all or a prop values in object format. Creates a copy. 114 | get: function(key) { 115 | return key ? this[key]() : this.getCopy(); 116 | }, 117 | // Retrieve json representation. Including private properties. 118 | getJson: function() { 119 | return this.__json; 120 | }, 121 | // Get a copy of json representation. Removing private properties. 122 | getCopy: function(deep, depopulate) { 123 | var copy = {}; 124 | var keys = _.keys(this.__json); 125 | for (var i = 0, key, value; i < keys.length; i++) { 126 | key = keys[i]; 127 | value = this.__json[key]; 128 | if (this.__isProp(key)) { 129 | if (value && value.__model instanceof BaseModel) 130 | copy[key] = depopulate ? value.__model.id() : value.__model.getCopy(deep, true); 131 | else 132 | copy[key] = value; 133 | } 134 | } 135 | return deep ? _.cloneDeep(copy) : copy; 136 | }, 137 | save: function(options, callback) { 138 | if (_.isFunction(options)) { 139 | callback = options; 140 | options = undefined; 141 | } 142 | var self = this; 143 | var req = this.id() ? store.put : store.post; 144 | this.__working = 2; 145 | return req.call(store, this.url(), this, options).then(function(data) { 146 | self.set(options && options.path ? _.get(data, options.path) : data, null, null, true); 147 | self.__saved = !!self.id(); 148 | // Add to cache, if enabled 149 | self.__addToCache(); 150 | self.__working = 0; 151 | if (_.isFunction(callback)) callback(null, data, self); 152 | return self; 153 | }, function(err) { 154 | self.__working = 0; 155 | if (_.isFunction(callback)) callback(err); 156 | throw err; 157 | }); 158 | }, 159 | fetch: function(options, callback) { 160 | if (_.isFunction(options)) { 161 | callback = options; 162 | options = undefined; 163 | } 164 | this.__working = 1; 165 | var self = this, 166 | data = this.__getDataId(), 167 | id = _.trim(data[config.keyId]); 168 | if (id && id != 'undefined' && id != 'null') { 169 | return store.get(this.url(), data, options).then(function(data) { 170 | if (data) { 171 | self.set(options && options.path ? _.get(data, options.path) : data, null, null, true); 172 | self.__saved = !!self.id(); 173 | } else { 174 | self.__saved = false; 175 | } 176 | self.__working = 0; 177 | self.__addToCache(); 178 | if (_.isFunction(callback)) callback(null, data, self); 179 | return self; 180 | }, function(err) { 181 | self.__working = 0; 182 | if (_.isFunction(callback)) callback(err); 183 | throw err; 184 | }); 185 | } else { 186 | this.__working = 0; 187 | var err = new Error('Model must have an id to fetch') 188 | if (_.isFunction(callback)) callback(err); 189 | return Promise.reject(err); 190 | } 191 | }, 192 | populate: function(options, callback) { 193 | if (_.isFunction(options)) { 194 | callback = options; 195 | options = undefined; 196 | } 197 | var self = this; 198 | return new Promise(function(resolve, reject) { 199 | var refs = self.options.refs; 200 | var error; 201 | var countFetch = 0; 202 | _.forEach(refs, function(refName, refKey) { 203 | var value = self.__json[refKey]; 204 | if (_.isString(value) || _.isNumber(value)) { 205 | var data = {}; 206 | data[config.keyId] = value; 207 | var model = modelConstructors[refName].create(data); 208 | if (model.isSaved()) { 209 | // Ok to link reference 210 | self[refKey](model); 211 | } else { 212 | // Fetch and link 213 | countFetch++; 214 | model.fetch( 215 | (options && options.fetchOptions && options.fetchOptions[refKey] ? options.fetchOptions[refKey] : null), 216 | function(err, data, mdl) { 217 | countFetch--; 218 | if (err) { 219 | error = err; 220 | } else { 221 | self[refKey](mdl); 222 | } 223 | if (!countFetch) { 224 | if (error) { 225 | reject(error); 226 | if (_.isFunction(callback)) callback(null, error); 227 | } else { 228 | // All fetched 229 | resolve(self); 230 | if (_.isFunction(callback)) callback(null, self); 231 | } 232 | } 233 | } 234 | ).catch(reject); 235 | } 236 | } 237 | }); 238 | if (!countFetch) { 239 | resolve(self); 240 | if (_.isFunction(callback)) callback(null, self); 241 | } 242 | }); 243 | }, 244 | destroy: function(options, callback) { 245 | if (_.isFunction(options)) { 246 | callback = options; 247 | options = undefined; 248 | } 249 | // Destroy the model. Will sync to store. 250 | var self = this; 251 | var data = this.__getDataId(), 252 | id = _.trim(data[config.keyId]); 253 | this.__working = 3; 254 | if (id && id != 'undefined' && id != 'null') { 255 | return store.destroy(this.url(), data, options).then(function() { 256 | self.detach(); 257 | self.__working = 0; 258 | if (_.isFunction(callback)) callback(null); 259 | self.dispose(); 260 | }, function(err) { 261 | self.__working = 0; 262 | if (_.isFunction(callback)) callback(err); 263 | throw err; 264 | }); 265 | } else { 266 | var err = new Error('Model must have an id to destroy'); 267 | this.__working = 0; 268 | if (_.isFunction(callback)) callback(err); 269 | return Promise.reject(err); 270 | } 271 | }, 272 | remove: function() { 273 | this.detach(); 274 | this.dispose(); 275 | }, 276 | detach: function() { 277 | // Detach this model to all collection. 278 | var clonedCollections = _.clone(this.__collections); 279 | for (var i = 0; i < clonedCollections.length; i++) { 280 | clonedCollections[i].remove(this); 281 | clonedCollections[i] = null; 282 | } 283 | }, 284 | dispose: function() { 285 | var keys = _.keys(this); 286 | var props = this.options.props; 287 | var i; 288 | this.__json.__model = null; 289 | for (i = 0; i < props.length; i++) { 290 | this[props[i]](null); 291 | } 292 | for (i = 0; i < keys.length; i++) { 293 | this[keys[i]] = null; 294 | } 295 | }, 296 | isSaved: function() { 297 | // Fresh from store 298 | return this.__saved; 299 | }, 300 | isNew: function() { 301 | return !this.__saved; 302 | }, 303 | isModified: function() { 304 | // When a prop is modified 305 | return this.__modified; 306 | }, 307 | isDirty: function() { 308 | return !this.isSaved() || this.isModified(); 309 | }, 310 | isWorking: function() { 311 | return this.__working > 0; 312 | }, 313 | isFetching: function() { 314 | return this.__working === 1; 315 | }, 316 | isSaving: function() { 317 | return this.__working === 2; 318 | }, 319 | isDestroying: function() { 320 | return this.__working === 3; 321 | }, 322 | __update: function() { 323 | var redraw; 324 | // Propagate change to model's collections. 325 | for (var i = 0; i < this.__collections.length; i++) { 326 | if (this.__collections[i].__update(true)) { 327 | redraw = true; 328 | } 329 | } 330 | // Levels: instance || schema || global 331 | if (redraw || this.__options.redraw || this.options.redraw || config.redraw) { 332 | // console.log('Redraw', 'Model'); 333 | m.redraw(); 334 | } 335 | }, 336 | __isProp: function(key) { 337 | return _.indexOf(this.options.props, key) > -1; 338 | }, 339 | __getDataId: function() { 340 | var dataId = {}; 341 | dataId[config.keyId] = this.id(); 342 | return dataId; 343 | }, 344 | __addToCache: function() { 345 | if (this.constructor.__options.cache && !this.constructor.__cacheCollection.contains(this) && this.__saved) { 346 | this.constructor.__cacheCollection.add(this); 347 | } 348 | }, 349 | __gettersetter: function(initial, key) { 350 | var _stream = config.stream(); 351 | var self = this; 352 | // Wrapper 353 | function prop() { 354 | var value, defaultVal; 355 | // arguments[0] is value 356 | // arguments[1] is silent 357 | // arguments[2] is saved (from store) 358 | // arguments[3] is isinitial 359 | if (arguments.length) { 360 | // Write 361 | value = arguments[0]; 362 | var ref = self.options.refs[key]; 363 | if (ref) { 364 | var refConstructor = modelConstructors[ref]; 365 | if (_.isPlainObject(value)) { 366 | value = refConstructor.create(value); 367 | } else if ((_.isString(value) || _.isNumber(value)) && refConstructor.__cacheCollection) { 368 | // Try to find the model in the cache 369 | value = refConstructor.__cacheCollection.get(value) || value; 370 | } 371 | } 372 | if (value instanceof BaseModel) { 373 | value.__saved = value.__saved || !!(arguments[2] && value.id()); 374 | value = value.getJson(); 375 | } 376 | _stream(value); 377 | self.__modified = arguments[2] ? false : !arguments[3] && self.__json[key] !== _stream._state.value; 378 | self.__json[key] = _stream._state.value; 379 | if (!arguments[1]) 380 | self.__update(key); 381 | return value; 382 | } 383 | 384 | value = _stream(); 385 | defaultVal = self.options && self.options.defaults[key]; 386 | if (_.isNil(value)) { 387 | if (!_.isNil(defaultVal)) value = defaultVal; 388 | } else { 389 | if (value.__model instanceof BaseModel) { 390 | value = value.__model; 391 | } else if (_.isPlainObject(value) && defaultVal instanceof BaseModel) { 392 | // Fix invalid value of stream, might be due deleted reference instance model 393 | _stream(null); 394 | value = defaultVal; 395 | } 396 | } 397 | return (config.placeholder && 398 | self.isFetching() && 399 | key !== config.keyId && 400 | _.indexOf(self.options.placehold, key) > -1 ? 401 | config.placeholder : value); 402 | } 403 | prop.stream = _stream; 404 | prop.call(this, initial, true, undefined, true); 405 | return prop; 406 | } 407 | }; 408 | 409 | // Inject lodash methods. 410 | util.addMethods(BaseModel.prototype, _, objectMethods, '__json'); -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # mithril-data 2 | 3 | A rich data model library for Mithril javascript framework. 4 | 5 | [![Build Status](https://travis-ci.org/rhaldkhein/mithril-data.svg?branch=master)](https://travis-ci.org/rhaldkhein/mithril-data) 6 | 7 | #### Features 8 | 9 | * Create brilliant application with **Schema**-based **Model** and **Collection** 10 | * Enriched with **Lodash** methods, integrated into Model & Collection 11 | * **Auto Redraw** on Model & Collection changes 12 | * **State** (View-Model) using Mithril's stream 13 | * Extensible, Configurable and Customizable 14 | * And many more ... 15 | 16 | #### Dependencies 17 | 18 | - [Mithril](http://mithril.js.org/) (>= 1.0.0) 19 | - [Lodash](http://lodash.com/) (>= 4.12.0) - Many Lodash methods are attached to Model and Collection 20 | 21 | - - - - 22 | 23 | ## Schema 24 | 25 | ```javascript 26 | var userSchema = { 27 | name : 'User', 28 | props : ['name', 'age'] 29 | } 30 | 31 | var noteSchema = { 32 | name : 'Note', 33 | props : ['title', 'body', 'author'], 34 | defaults : { 35 | title: 'Default Title', 36 | body: 'Default Body' 37 | }, 38 | refs : { 39 | author: 'User' 40 | }, 41 | url : '/customurl', 42 | redraw : true 43 | } 44 | ``` 45 | 46 | All available schema options: 47 | * **name** - (string, **required**) name of the model 48 | * **props** - (string array, **required**) list of model props 49 | * **defaults** - (object {prop:value}) default value of props 50 | * **refs** - (object {prop:model}) list of references to other models 51 | * **url** - (string) the specific url of the model. defaults to model's `name` 52 | * **redraw** - (boolean) trigger a redraw when a model with this schema is updated. defaults to `false` 53 | * **methods** - (object {name:function}) add custom methods to model instances (by schema) 54 | * **parser** - (function) add data parser to automatically parse data storing to model 55 | 56 | Additional option information: 57 | 58 | **parser** - An option to parse different data objects. 59 | 60 | ```javascript 61 | // Parsers for Notes schema 62 | parser : function(obj) { 63 | if(obj.kind === '3rd#party') { 64 | return { 65 | title : obj.wrap.title, 66 | body : obj.wrap.inner.body, 67 | author : obj.wrap.inner.author 68 | } 69 | } else { 70 | // Another source 71 | } 72 | } 73 | // This auto parse wrapped data. Also parsed with `setObject()`. 74 | var note = new Note({wrapped: 'data'}) 75 | note.setObject({wrapped: 'data'}) 76 | // To disable parsing. Set `parse: false` in the options. 77 | var note = new Note({unwrapped: 'data'}, {parse: false}) 78 | ``` 79 | 80 | - - - - 81 | 82 | ## Model 83 | ```javascript 84 | var User = md.model(userSchema) 85 | var Note = md.model(noteSchema) 86 | 87 | var userA = new User() 88 | userA.name('Foo') 89 | userA.age(123) 90 | userA.save(function (err) { 91 | if (!err) 92 | console.log('Saved') 93 | }) 94 | 95 | var noteA = new Note({ 96 | title: 'My Notes' 97 | }, { 98 | redraw : true 99 | }) 100 | noteA.body('A note content') 101 | noteA.author(userA) 102 | noteA.save().then(fnResolve, fnReject) 103 | ``` 104 | 105 | #### new \([initials, options]) 106 | Creates an instance of model. 107 | * **initials** - (object {prop:value}) initial values of props 108 | * **options** - (object) specific options to model instance 109 | * **redraw** - (boolean) redraw 110 | * **parse** - (boolean) set `false` to disable parsing. defaults to `true` 111 | 112 | #### \#\([value, silent]) 113 | Get or set value of prop. If auto-redraw is enabled, pass `true` at the end to set without auto redrawing. This uses the basic usage of stream, and to get the stream itself, use `.stream`. 114 | ```javascript 115 | user.name('Foo') // Sets the name to `Foo` 116 | var n = user.name() // Get the name... returns `Foo` 117 | user.name('Bar', true) // Silently sets the name to `Bar` (without redrawing) 118 | var s = user.name.stream.map(callback) // Get the stream object with `.stream` 119 | ``` 120 | 121 | #### \#opt(key[, value]) 122 | Sets an option(s) specific to the model. See `ModelConstructor` for list of options. 123 | 124 | #### \#id([strId]) 125 | Get or set the ID of model regardless of real ID prop. This is useful if you have different id prop like `_id` (mongodb). 126 | ```javascript 127 | // Assumes that you configure `keyId` to `_id` 128 | user._id('Bar') // Sets the id to `Bar` 129 | var id = user.id() // Returns `Bar` 130 | // user.id('Bar') // You can also use this 131 | ``` 132 | 133 | #### \#lid() 134 | Get the local ID of model. This ID is generated automatically when model is created. 135 | 136 | #### \#url() 137 | Get the url. It return the combination of `baseUrl` and the models' `url`. 138 | 139 | #### \#set(key[, value, silent]) 140 | Set a value to a prop of the model. 141 | ```javascript 142 | user.set('name', 'Foo') // Sets single prop 143 | // Silent set, will NOT trigger the auto redraw 144 | user.set('age', 34, true) // Pass true at the end 145 | ``` 146 | 147 | #### \#setObject(obj[, silent]) 148 | Set multiple values at once using object. 149 | ```javascript 150 | user.set({name: 'Bar', age: 12}) // Sets multiple props using object 151 | user.set(existingModelInstance) // Sets multiple props user existing model instance 152 | // Silent set, will NOT trigger the auto redraw 153 | user.set({age: 32}, true) // Pass true at the end 154 | ``` 155 | 156 | #### \#get(key) 157 | Get a value or a copy of all values in object literal format. 158 | ```javascript 159 | user.get('name') // Returns the value of name 160 | user.get() // Returns an object (copy) with all props and values. e.g. {name: "Foo", age: 12} 161 | ``` 162 | 163 | #### \#getCopy([deep]) 164 | Get a copy model in object literal format. Additionally, you can set deep to `true` to copy all props recursively. 165 | 166 | #### \#attachCollection(collection) 167 | Attach the model to a collection. 168 | 169 | #### \#detachCollection(collection) 170 | Detach the model from a collection. 171 | 172 | #### \#detach() 173 | Detach the model from ALL associated collections. 174 | 175 | #### \#remove() 176 | Triggers `detach` and also `dispose` the object. Make sure you're not using the model anymore. 177 | 178 | #### \#isSaved() 179 | True if it contains id and fresh from store (server or local storage). 180 | 181 | #### \#isModified() 182 | True when a prop is modified. 183 | 184 | #### \#isDirty() 185 | True if the model is modified or not saved. 186 | 187 | #### \#save([options, callback]) 188 | Saves the model to data store. To check for result, you can use either `callback` or `then`. Callback arguments are `(err, response, model)`. Properties for `options` is the same with `m.request`'s options but with additional `path` string property. `path` is the path to actual value for the model in the response object. Like in `response:{outer:{model:{}}}` will be `"outer.model"`. 189 | ```javascript 190 | user.save() 191 | .then( 192 | function (model) { console.log('Saved') }, 193 | function (err) { console.log(err) } 194 | ) 195 | .catch( 196 | function(catchErr) { console.log(catchErr) } 197 | ) 198 | ``` 199 | 200 | #### \#fetch([options, callback]) 201 | Fetches the model from data store. Model ID required to fetch. This method also accept `callback` or `then`. Properties for `options` is the same with `#save()`'s options. If `placeholder` is given in the configuration, the returned value will be that placeholder string. 202 | ```javascript 203 | user.id('abc123') 204 | user.fetch().then( function (model) { /* Success! model now have other prop values */ } ); 205 | // If placeholder is set as 'Loading...' 206 | user.name(); // Returns `Loading...`, until the fetch completed 207 | ``` 208 | 209 | #### \#destroy([options, callback]) 210 | Destroys the model from data store and triggers `remove` method. Also accept `callback` or `then`. Parameter `options` is the same with `#save()`'s options. 211 | 212 | #### \#populate() 213 | Populates all references. This will trigger fetch if necessary. 214 | 215 | #### \#\() 216 | Model includes few methods of Lodash. `has`, `keys`, `values`, `pick`, and `omit`. See **Lodash** for info. 217 | ```javascript 218 | userA.pick(['name', 'age']) 219 | // Returns an object with only two properties `name` and `age`, excluding others. 220 | ``` 221 | 222 | - - - - 223 | 224 | ## Collection 225 | ```javascript 226 | var userCollection = new md.Collection({ 227 | model : User, 228 | url : '/usercollectionurl', 229 | redraw : true 230 | }) 231 | userCollection.add(new User()) 232 | ``` 233 | 234 | #### new Collection([options]) 235 | 236 | All available collection options: 237 | * **model** - (model constructor) the associated model to the collection 238 | * **url** - (string) the specific url of the collection. defaults to associated model's `name` 239 | * **redraw** - (boolean) trigger a redraw when the collection is updated. Defaults to `false` 240 | * **state** - (State | object | array) set a state factory (View-Model) for the collection. See `#stateOf()` method and `md.State()` for more info. 241 | 242 | > A collection with redraw = `true` will always trigger a `redraw` even though the contained model has redraw = `false`. 243 | 244 | > Omitted `model` in option is allowed and will make the collection `generic`. Therefore, some methods will NOT be available, like `create` and `fetch`. 245 | 246 | #### \#opt(key[, value]) 247 | Sets an option(s) to the collection. See `Collection` for list of options. 248 | 249 | #### \#add(model[, unshift, silent]) 250 | Adds a model to the collection. Optionally, you can add at the beginning with `unshift` = `true` and silently with `silent` = `true`. 251 | 252 | #### \#addAll(models[, unshift, silent]) 253 | Adds an array of models to the collection. Optionally, you can set `unshift` and `silent` as well. 254 | 255 | #### \#create(objects) 256 | Create and add multiple models to the collection from passed array of objects. 257 | ```javascript 258 | userCollection.create([ {name:'Foo'}, {name:'Bar'} ]) 259 | ``` 260 | 261 | #### \#get(mixed) 262 | Get a model from collection. Argument `mixed` can be a `number`, `string`, `object` or `model` instance. Returns the first matched only otherwise `undefined`. 263 | ```javascript 264 | userCollection.get('abc') 265 | userCollection.get(123) // If string or number, it will find by Id 266 | userCollection.get({name:'Foo'}) // Will match the first model with name equal to `Foo` 267 | userCollection.get(model) // Will find by model instance, compared with Lodash's `indexOf` 268 | ``` 269 | 270 | #### \#getAll(mixedArr[, falsy]) 271 | Get multiple models from collection. Array can contain `mixed` type, same with `get()`. Returns an array of first matched only of each array element. Argument `falsy` will include falsy value like `undifened` in the list, instead of omitting. 272 | 273 | #### \#remove(mixed[, silent]) 274 | Removes a model from collection. `mixed` can be same with `get()`'s mixed argument. 275 | 276 | #### \#push(model[, silent]) 277 | Adds a model or array of models at the end. 278 | 279 | #### \#unshift(model[, silent]) 280 | Adds a model or array of models at the beginning. 281 | 282 | #### \#shift([silent]) 283 | Removes the model at the beginning. 284 | 285 | #### \#pop([silent]) 286 | Removes the model at the end. 287 | 288 | #### \#clear([silent]) 289 | Removes ALL models. 290 | 291 | #### \#sort(props[, orders]) 292 | Sort the collection. Argument `props` is an array of props to sort and `orders` is an array of `asc` or `desc`. Optioanally, if you're sorting only single prop, you can pass a string instread of array. 293 | ```javascript 294 | // Sort the collection by `name` in default order `asc` 295 | userCollection.sort('name') 296 | // Sort the collection by `name` in `desc` order 297 | userCollection.sort('name', 'desc') 298 | // Sort the collection by `age` first starting from old (desc) and then `name` 299 | userCollection.sort(['age', 'name'], ['desc', 'asc']) 300 | ``` 301 | 302 | #### \#sortByOrder(order , path ) 303 | Sort the collection by giver order. 304 | 305 | #### \#pluck() 306 | Pluck a prop from each model. 307 | ```javascript 308 | userCollection.pluck('id') 309 | // Returns [123, 456, 789] 310 | ``` 311 | 312 | #### \#contains(mixed) 313 | Returns `true` if the model contains in the collection, otherwise `false`. Argument `mixed` is the same with `get()` method. 314 | 315 | #### \#reserve() 316 | Reverse the order of the collection. 317 | 318 | #### \#randomize() 319 | Randomize the order of the collection. 320 | 321 | #### \#model() 322 | Get the associated model constructor. 323 | 324 | #### \#stateOf(mixed) 325 | Get the state of a model in the collection. Argument `mixed` is the same with `get()` method. 326 | ```javascript 327 | // Set state signature on creating collection 328 | var col = new md.Collection({ 329 | state : { 330 | isEditing: true, 331 | isLoading: false 332 | } 333 | }) 334 | // Create user 335 | var user = new User() 336 | // Add user to collection 337 | col.add(user); 338 | // Retrieving state value 339 | col.stateOf(user).isEditing() // Returns `true` 340 | // Setting state 341 | col.stateOf(user).isEditing(false) // Sets and returns `false` 342 | ``` 343 | 344 | #### \#url() 345 | Get the url. 346 | 347 | #### \#fetch(query[, options, callback]) 348 | Query to data store and populate the collection. Callback arguments are `(err, response, models)`. Properties for `options` is the same with `m.request`'s options but with additional `path` and `clear` property. `path` is the path to actual array of items for the collection in the response object. Like in `response:{outer:{items:[]}}` will be `"outer.items"`. And `clear` will clear the collection before placing the fetched data. 349 | ```javascript 350 | userCollection.fetch({ age : 30 }).then(function (){ 351 | // Success! `userCollection` now have models with age 30 352 | }) 353 | ``` 354 | 355 | #### \#hasModel() 356 | Returns `true` if the collection has associated model, otherwise `false`. 357 | 358 | #### \#destroy() 359 | Destroys the collection. Trigger `clear` and `dispose`. 360 | 361 | #### \#isFetching() 362 | Checks if the collection is fetching. 363 | 364 | #### \#\() 365 | Collection includes several methods of Lodash. `forEach`, `map`, `find`, `findIndex`, `findLastIndex`, `filter`, `reject`, `every`, `some`, `invoke`, `maxBy`, `minBy`, `sortBy`, `groupBy`, `shuffle`, `size`, `initial`, `without`, `indexOf`, `lastIndexOf`, `difference`, `sample`, `reverse`, `nth`, `first`, `last`, `toArray`, `slice`, `orderBy`, `transform`. See **Lodash** for info. 366 | ```javascript 367 | var filtered = userCollection.filter({age: 30}) 368 | // Returns an array of models with age of 30. 369 | ``` 370 | 371 | - - - - 372 | 373 | ## State 374 | Also known as View-Model. See Mithril's view-model description for more info. 375 | ```javascript 376 | var _isEditing = md.stream(false) 377 | // Create state factory 378 | var stateFactory = new md.State({ 379 | isLoading: false, 380 | isEditing: _isEditing // Add exisiting stream created somewhere 381 | }) 382 | // Creating states 383 | stateFactory.set('A'); 384 | stateFactory.set('B'); 385 | // Using states 386 | var component = { 387 | controller: function() { 388 | this.stateA = stateFactory.get('A') 389 | this.stateA.isEditing(true) 390 | var s = this.stateA.isEditing.map(callback) // Using with stream 391 | }, 392 | view: function(ctrl) { 393 | return m('div', 'Is editing ' + ctrl.stateA.isEditing()) // Displays `Is editing true` 394 | } 395 | } 396 | ``` 397 | 398 | #### new State(signature[, options]) 399 | Creates a new State factory. `signature` can be object or array. 400 | 401 | All available state options: 402 | * **store** - (function) the custom store function (it must return a function). defaults to `stream` that was set in `md.config` 403 | * **prefix** - (string) the string prefix for custom store. 404 | 405 | #### \#set(key) 406 | Internally creates a new state by `key`. 407 | 408 | #### \#get(key) 409 | Get the state by `key`. 410 | 411 | #### \#remove(key) 412 | Removes the state by `key`. 413 | 414 | #### md.State.create(signature[, options]) 415 | Creates a state without instantiating new State factory. `options` is same with state factory constructor. 416 | ```javascript 417 | // Create state factory 418 | var state = md.State.create({ 419 | isWorking: false 420 | }) 421 | state.isWorking() // Get state. => false 422 | state.isWorking(true) // Set state. => true 423 | ``` 424 | 425 | #### md.State.assign(object, signature[, options]) 426 | Same with `create` but assigns to given `object`, instead of creating new object. 427 | ```javascript 428 | // Existing object 429 | var obj = {}; 430 | // Create state factory 431 | md.State.assign(obj, { 432 | isWorking: false 433 | }) 434 | obj.isWorking() 435 | ``` 436 | 437 | - - - - 438 | 439 | ## Configure & Customize 440 | Configuration must be set before using any of `mithril-data`'s functionality. 441 | ```javascript 442 | md.config({ 443 | baseUrl : '/baseurl', 444 | keyId : '_id', // mongodb 445 | store : customStoreFunction 446 | }) 447 | ``` 448 | All available config options: 449 | * **baseUrl** - (string) the base url 450 | * **keyId** - (string) the custom ID of the model. defaults to `id` 451 | * **redraw** - (boolean) the global redraw flag. default to `false` 452 | * **cache** - (boolean) enable caching the models created by a collection. defaults to `false` 453 | * **modelMethods** - (object { methodName : `function()` }) additional methods to bind to `model`'s prototype 454 | * **collectionMethods** - (object { methodName : `function()` }) additional methods to bind to `collection`'s prototype 455 | * **modelBindMethods** - (string array) model's methods to bind to itself. see Lodash `bindAll()` 456 | * **collectionBindMethods** - (string array) collection's methods to bind to itself 457 | * **storeConfigOptions** - (function) a function to `manipulate the options` before sending data to data-store 458 | * **storeConfigXHR** - (function) a function to `manipulate XHR` before sending data to data-store. see Mithril's `m.request` for more info 459 | * **storeExtract** - (function) a function to trigger after receiving data from data-store. see Mithril's `m.request` for more info 460 | * **storeSerializer** - (function) a function that overrides data-store serializer. see Mithril's `m.request` for more info 461 | * **storeDeserializer** - (function) a function that overrides data-store deserializer. see Mithril's `m.request` for more info 462 | * **store** - (function) a function that handles the storing of data. defaults to `m.request` 463 | * **stream** - (function) a function that handles the model props as well as md's State class. defaults to Mithril's `Stream` 464 | * **cache** - (boolean) should use cache or not in all collections. defaults to `false` 465 | * **cacheLimit** - (number) limit of cache. defaults to `100` 466 | * **placeholder** - (string) the string to return by prop when the model fetching or collection through `isFetching` method 467 | 468 | #### storeConfigOptions 469 | This is useful when you want to modify the `options` object before sending to data-store. One scenario is to create custom url instead of default generated url. 470 | ```javascript 471 | user.id('abc123') 472 | user.fetch() 473 | // The default url is `/user?id=abc123` but we want `/user/abc123 474 | md.config({ storeConfigOptions : function (options) { 475 | options.url = options.url + '/' + options.data.id 476 | options.data = null // clear the data as we've used it already 477 | }}) 478 | ``` 479 | 480 | #### store 481 | A function responsible for storing the data (defaults to `m.request`). An example is, if you want to store data using local storage. 482 | ```javascript 483 | var fnLocalStorage = function (data) { 484 | if (data.method === 'POST') { /* Writing data... */ } 485 | else if (data.method === 'GET') { /* Reading data... */ } 486 | else if (data.method === 'DELETE') { /* Deleting data... */ } 487 | else { /* Do something with other methods */ } 488 | } 489 | md.config({ store : fnLocalStorage}) 490 | ``` 491 | > Just make sure that your custom store should return a Promise. 492 | 493 | - - - - 494 | 495 | ## More 496 | 497 | #### md.store 498 | A handy tool that handles request to data-store. The result is through `then` / `catch`. 499 | * **request(url[, method, data, opt])** - creates a request to data-store. the `opt` will override the options when storing to data-store 500 | * **get(url[, data, opt])** - calls`request` with `GET` method, passing the `data` and `opt` 501 | * **post(url[, data, opt])** - calls`request` with `POST` method, passing the `data` and `opt` 502 | * **destroy(url[, data, opt])** - calls`request` with `DELETE` method, passing the `data` and `opt` 503 | 504 | #### md.stream 505 | Expose Mithril's Stream (unmodified and only bundled). 506 | 507 | #### md.toStream 508 | A helper function to convert any value to stream. 509 | 510 | #### md.model.get(name) 511 | A way to get a model constructor from other scope. Argument `name` is the model name. 512 | 513 | #### md.defaultConfig(config) 514 | Overrides the default config. 515 | 516 | #### md.resetConfig() 517 | Resets the config to default. If `defaultConfig()` is used, it will reset to that config. 518 | 519 | #### md.noConflict() 520 | Return the old reference to `md`. 521 | 522 | #### md.version() 523 | Return the current version. 524 | 525 | - - - - 526 | 527 | ## Installation 528 | ```sh 529 | # NPM 530 | npm install mithril-data 531 | # Bower 532 | bower install mithril-data 533 | ``` 534 | Node / CommonJS: 535 | ```javascript 536 | var md = require('mithril-data'); 537 | ``` 538 | HTML: (`md` is automatically exposed to browser's global scope) 539 | ```html 540 | 541 | 542 | 543 | 546 | ``` 547 | 548 | - - - - 549 | 550 | ### License 551 | MIT 552 | -------------------------------------------------------------------------------- /mithril-data.min.js: -------------------------------------------------------------------------------- 1 | /** 2 | * mithril-data v0.4.9 3 | * A rich data model library for Mithril javascript framework. 4 | * https://github.com/rhaldkhein/mithril-data 5 | * (c) 2018 Kevin Villanueva 6 | * License: MIT 7 | */ 8 | !function(t){function e(i){if(n[i])return n[i].exports;var o=n[i]={exports:{},id:i,loaded:!1};return t[i].call(o.exports,o,o.exports,e),o.loaded=!0,o.exports}var n={};return e.m=t,e.c=n,e.p="",e(0)}([function(t,e,n){function i(t){function e(e,n){l.call(this,n);var i=(this.__options.parse?this.options.parser(e):e)||{},o=t.props;a.indexOf(o,c.keyId)===-1&&o.push(c.keyId);for(var r,s=0;s-1&&o.pull(this.__collections,t)},set:function(t,e,n,i){o.isString(t)?this[t](e,n):this.setObject(t,n,i)},setObject:function(t,e,n){var r=t instanceof i;if(!r&&!o.isPlainObject(t))throw new Error("Argument `obj` must be a model or plain object.");for(var s,a,u=!r&&this.__options.parse?this.options.parser(t):t,c=o.keys(u),h=c.length-1;h>=0;h--)s=c[h],a=u[s],this.__isProp(s)&&o.isFunction(this[s])&&(r&&o.isFunction(a)?this[s](a(),!0,n):this[s](a,!0,n));e||this.__update()},get:function(t){return t?this[t]():this.getCopy()},getJson:function(){return this.__json},getCopy:function(t,e){for(var n,r,s={},a=o.keys(this.__json),u=0;u0},isFetching:function(){return 1===this.__working},isSaving:function(){return 2===this.__working},isDestroying:function(){return 3===this.__working},__update:function(){for(var t,e=0;e-1},__getDataId:function(){var t={};return t[s.keyId]=this.id(),t},__addToCache:function(){this.constructor.__options.cache&&!this.constructor.__cacheCollection.contains(this)&&this.__saved&&this.constructor.__cacheCollection.add(this)},__gettersetter:function(t,e){function n(){var t,n;if(arguments.length){t=arguments[0];var c=u.options.refs[e];if(c){var h=a[c];o.isPlainObject(t)?t=h.create(t):(o.isString(t)||o.isNumber(t))&&h.__cacheCollection&&(t=h.__cacheCollection.get(t)||t)}return t instanceof i&&(t.__saved=t.__saved||!(!arguments[2]||!t.id()),t=t.getJson()),r(t),u.__modified=!arguments[2]&&(!arguments[3]&&u.__json[e]!==r._state.value),u.__json[e]=r._state.value,arguments[1]||u.__update(e),t}return t=r(),n=u.options&&u.options.defaults[e],o.isNil(t)?o.isNil(n)||(t=n):t.__model instanceof i?t=t.__model:o.isPlainObject(t)&&n instanceof i&&(r(null),t=n),s.placeholder&&u.isFetching()&&e!==s.keyId&&o.indexOf(u.options.placehold,e)>-1?s.placeholder:t}var r=s.stream(),u=this;return n.stream=r,n.call(this,t,!0,void 0,!0),n}},c.addMethods(i.prototype,o,f,"__json")},function(t,e,n){function i(t,e){c.storeConfigXHR&&c.storeConfigXHR(t,e),t.setRequestHeader("Content-Type","application/json")}function o(t,e){return c.storeExtract?c.storeExtract(t,e):t.responseText.length?s(t.responseText):null}function r(t){return a(t),c.storeSerializer?c.storeSerializer(t):JSON.stringify(t)}function s(t){if(c.storeDeserializer)return c.storeDeserializer(t);try{return JSON.parse(t)}catch(e){return t}}function a(t){var e;for(var n in t)e=t[n],u.isObject(e)&&(t[n]=e[c.keyId]||e)}var u=n(1),c=n(3).config,h=n(4);t.exports=u.create(null,{request:function(t,e,n,a){var l={method:e||"GET",url:t,data:(n instanceof h?n.getCopy():n)||{},background:!!c.storeBackground,serialize:r,deserialize:s,config:i,extract:o};return a&&(l=u.defaultsDeep(a,l)),c.storeConfigOptions&&c.storeConfigOptions(l),c.store(l)},get:function(t,e,n){return this.request(t,"GET",e,n)},post:function(t,e,n){return this.request(t,"POST",e,n)},put:function(t,e,n){return this.request(t,"PUT",e,n)},destroy:function(t,e,n){return this.request(t,"DELETE",e,n)}})},function(t,e,n){(function(e){function i(t,e){return function(n,i,o,r){return t(n?n[e]||n:n,i?i[e]||i:i,o,r)}}function o(t,e){for(var n,o=t.length-1;o>=0;o--)n=t[o],a.isFunction(n)?t[o]=i(n,e):n instanceof c&&(t[o]=n.__json);return t}function r(t,e,n){if(t===e)return t;if(a.isArray(t)){for(var i,o=t.length-1;o>=0;o--)i=t[o],i&&i[n]&&(t[o]=i[n]);return t}return t?t[n]||t:t}function s(){return h&&window.setImmediate?window.setImmediate:"object"==typeof e&&"function"==typeof e.nextTick?e.nextTick:function(t){setTimeout(t,0)}}var a=n(1),u=Array.prototype.slice,c=n(4),h="undefined"!=typeof window;t.exports=a.create(null,{isBrowser:h,nextTick:s(),clearObject:function(t){for(var e in t)delete t[e]},hasValueOfType:function(t,e){for(var n=a.keys(t),i=0;i1)for(var n=1;n-1;if(o.isObject(t)){if(!t[a.keyId])return this.findIndex(t)>-1;t=t[a.keyId]}return this.findIndex([a.keyId,t])>-1},sort:function(t,e){o.isArray(t)||(t=[t]);var n;e?(o.isArray(e)||(e=[e]),n=this.orderBy(t,e)):n=this.orderBy(t),this.__replaceModels(n)},sortByOrder:function(t,e){e||(e=a.keyId),this.__replaceModels(this.sortBy([function(n){return t.indexOf(o.get(n,e))}]))},randomize:function(){this.__replaceModels(this.shuffle())},hasModel:function(){return!!this.__options.model},model:function(){return this.__options.model},url:function(t){var e=t?"":a.baseUrl;return this.__options.url?e+=this.__options.url:this.hasModel()&&(e+=this.model().modelOptions.url||"/"+this.model().modelOptions.name.toLowerCase()),e},fetch:function(t,e,n){o.isFunction(e)&&(n=e,e=void 0);var i=this;if(i.__working=!0,i.hasModel())return e=e||{},i.model().pull(i.url(),t,e,function(t,r,s){i.__working=!1,t?o.isFunction(n)&&n(t):(e.clear&&i.clear(!0),i.addAll(s),o.isFunction(n)&&n(null,r,s))});i.__working=!1;var r=new Error("Collection must have a model to perform fetch");return o.isFunction(n)&&n(r),Promise.reject(r)},isWorking:function(){return this.__working},isFetching:function(){return this.isWorking()},__replaceModels:function(t){for(var e=t.length-1;e>=0;e--)this.models[e]=t[e].__json},__update:function(t){if(!this.__options._cache&&(this.__options.redraw||a.redraw))return t||r.redraw(),!0}};var h=[],l={difference:1,every:1,find:1,findIndex:1,findLastIndex:1,filter:1,first:0,forEach:1,indexOf:2,initial:0,invoke:3,groupBy:1,last:0,lastIndexOf:2,map:1,maxBy:1,minBy:1,nth:1,orderBy:2,partition:1,reduce:-1,reject:1,reverse:0,sample:0,shuffle:0,size:0,slice:2,sortBy:1,some:1,transform:2,toArray:0,without:1};s.addMethods(i.prototype,o,l,"models","__model")},function(t,e,n){function i(){var t={};for(var e in this)s.indexOf(c,e)===-1&&(t[e]=this[e]());return t}function o(t,e,n,i){var o,r=n&&n.store?n.store:a.stream;for(var u in t){if(s.indexOf(c,u)>-1)throw new Error("State key `"+u+"` is not allowed.");o=t[u],e[u]=s.isFunction(o)?o:r(o,u,i,n)}return e}function r(t,e){if(s.isArray(t)){this.signature=s.invert(t);for(var n in this.signature)this.signature[n]=void 0}else this.signature=t;this._options=e,this.map={}}var s=n(1),a=n(3).config,u="__key__",c=["factory","toJson","_options"];t.exports=r,r.create=function(t,e){return o(t,{toJson:i},e)},r.assign=function(t,e,n){o(e,t,n)},r.prototype={set:function(t){return t||(t=u),this.map[t]||(this.map[t]=o(this.signature,{factory:a.stream(this),toJson:i},this._options,t)),this.map[t]},get:function(t){return t||(t=u),this.map[t]||this.set(t),this.map[t]},remove:function(t){if(t||(t=u),this.map[t]){var e,n=s.keys(this.map[t]);for(e=0;ethis.__options.cacheLimit&&this.__cacheCollection.shift())):n=new this(t,e),n},createCollection:function(t){return new a(o.assign({model:this},t))},createModels:function(t,e){o.isArray(t)||(t=[t]);for(var n=[],i=0;i0&&arguments[0]!==x&&i(t,arguments[0]),t._state.value}return n(t),arguments.length>0&&arguments[0]!==x&&i(t,arguments[0]),t}function n(t){t.constructor=e,t._state={id:O++,value:void 0,state:0,derive:void 0,recover:void 0,deps:{},parents:[],endStream:void 0,unregister:void 0},t.map=t["fantasy-land/map"]=l,t["fantasy-land/ap"]=f,t["fantasy-land/of"]=e,t.valueOf=d,t.toJSON=_,t.toString=d,Object.defineProperties(t,{end:{get:function(){if(!t._state.endStream){var n=e();n.map(function(e){return e===!0&&(h(t),n._state.unregister=function(){h(n)}),e}),t._state.endStream=n}return t._state.endStream}}})}function i(t,e){o(t,e);for(var n in t._state.deps)r(t._state.deps[n],!1);null!=t._state.unregister&&t._state.unregister(),s(t)}function o(t,e){t._state.value=e,t._state.changed=!0,2!==t._state.state&&(t._state.state=1)}function r(t,e){var n=t._state,i=n.parents;if(i.length>0&&i.every(m)&&(e||i.some(v))){var r=t._state.derive();if(r===x)return!1;o(t,r)}}function s(t){t._state.changed=!1;for(var e in t._state.deps)t._state.deps[e]._state.changed=!1}function a(t,n){if(!n.every(p))throw new Error("Ensure that each item passed to stream.combine/stream.merge is a stream");return u(e(),n,function(){return t.apply(this,n.concat([n.filter(v)]))})}function u(t,e,n){var i=t._state;return i.derive=n,i.parents=e.filter(g),c(t,i.parents),r(t,!0),t}function c(t,e){for(var n=0;n-1&&o._state.parents.splice(r,1)}t._state.state=2,t._state.deps={}}function l(t){return a(function(e){return t(e())},[this])}function f(t){return a(function(t,e){return t()(e())},[t,this])}function d(){return this._state.value}function _(){return null!=this._state.value&&"function"==typeof this._state.value.toJSON?this._state.value.toJSON():this._state.value}function p(t){return t._state}function m(t){return 1===t._state.state}function v(t){return t._state.changed}function g(t){return 2!==t._state.state}function y(t){return a(function(){return t.map(function(t){return t()})},t)}function w(t,e,n){var i=a(function(n){return e=t(e,n._state.value)},[n]);return 0===i._state.state&&i(e),i}function k(t,e){var n=t.map(function(t){var e=t[0];return 0===e._state.state&&e(void 0),e}),i=a(function(){var i=arguments[arguments.length-1];return n.forEach(function(n,o){i.indexOf(n)>-1&&(e=t[o][1](e,n._state.value))}),e},n);return i}var O=0,x={};e["fantasy-land/of"]=e,e.merge=y,e.combine=a,e.scan=w,e.scanMerge=k,e.HALT=x,t.exports=e}()}).call(e,n(13)(t))},function(t,e){t.exports=function(t){return t.webpackPolyfill||(t.deprecate=function(){},t.paths=[],t.children=[],t.webpackPolyfill=1),t}}]); -------------------------------------------------------------------------------- /test/www/bundles/chai/chai-plugins.bundle.js: -------------------------------------------------------------------------------- 1 | (function e(t,n,r){function s(o,u){if(!n[o]){if(!t[o]){var a=typeof require=="function"&&require;if(!u&&a)return a(o,!0);if(i)return i(o,!0);var f=new Error("Cannot find module '"+o+"'");throw f.code="MODULE_NOT_FOUND",f}var l=n[o]={exports:{}};t[o][0].call(l.exports,function(e){var n=t[o][1][e];return s(n?n:e)},l,l.exports,e,t,n,r)}return n[o].exports}var i=typeof require=="function"&&require;for(var o=0;o 462 | * MIT Licensed 463 | */ 464 | 465 | /** 466 | * ### .checkError 467 | * 468 | * Checks that an error conforms to a given set of criteria and/or retrieves information about it. 469 | * 470 | * @api public 471 | */ 472 | 473 | /** 474 | * ### .compatibleInstance(thrown, errorLike) 475 | * 476 | * Checks if two instances are compatible (strict equal). 477 | * Returns false if errorLike is not an instance of Error, because instances 478 | * can only be compatible if they're both error instances. 479 | * 480 | * @name compatibleInstance 481 | * @param {Error} thrown error 482 | * @param {Error|ErrorConstructor} errorLike object to compare against 483 | * @namespace Utils 484 | * @api public 485 | */ 486 | 487 | function compatibleInstance(thrown, errorLike) { 488 | return errorLike instanceof Error && thrown === errorLike; 489 | } 490 | 491 | /** 492 | * ### .compatibleConstructor(thrown, errorLike) 493 | * 494 | * Checks if two constructors are compatible. 495 | * This function can receive either an error constructor or 496 | * an error instance as the `errorLike` argument. 497 | * Constructors are compatible if they're the same or if one is 498 | * an instance of another. 499 | * 500 | * @name compatibleConstructor 501 | * @param {Error} thrown error 502 | * @param {Error|ErrorConstructor} errorLike object to compare against 503 | * @namespace Utils 504 | * @api public 505 | */ 506 | 507 | function compatibleConstructor(thrown, errorLike) { 508 | if (errorLike instanceof Error) { 509 | // If `errorLike` is an instance of any error we compare their constructors 510 | return thrown.constructor === errorLike.constructor || thrown instanceof errorLike.constructor; 511 | } else if (errorLike.prototype instanceof Error || errorLike === Error) { 512 | // If `errorLike` is a constructor that inherits from Error, we compare `thrown` to `errorLike` directly 513 | return thrown.constructor === errorLike || thrown instanceof errorLike; 514 | } 515 | 516 | return false; 517 | } 518 | 519 | /** 520 | * ### .compatibleMessage(thrown, errMatcher) 521 | * 522 | * Checks if an error's message is compatible with a matcher (String or RegExp). 523 | * If the message contains the String or passes the RegExp test, 524 | * it is considered compatible. 525 | * 526 | * @name compatibleMessage 527 | * @param {Error} thrown error 528 | * @param {String|RegExp} errMatcher to look for into the message 529 | * @namespace Utils 530 | * @api public 531 | */ 532 | 533 | function compatibleMessage(thrown, errMatcher) { 534 | var comparisonString = typeof thrown === 'string' ? thrown : thrown.message; 535 | if (errMatcher instanceof RegExp) { 536 | return errMatcher.test(comparisonString); 537 | } else if (typeof errMatcher === 'string') { 538 | return comparisonString.indexOf(errMatcher) !== -1; // eslint-disable-line no-magic-numbers 539 | } 540 | 541 | return false; 542 | } 543 | 544 | /** 545 | * ### .getFunctionName(constructorFn) 546 | * 547 | * Returns the name of a function. 548 | * This also includes a polyfill function if `constructorFn.name` is not defined. 549 | * 550 | * @name getFunctionName 551 | * @param {Function} constructorFn 552 | * @namespace Utils 553 | * @api private 554 | */ 555 | 556 | var functionNameMatch = /\s*function(?:\s|\s*\/\*[^(?:*\/)]+\*\/\s*)*([^\(\/]+)/; 557 | function getFunctionName(constructorFn) { 558 | var name = ''; 559 | if (typeof constructorFn.name === 'undefined') { 560 | // Here we run a polyfill if constructorFn.name is not defined 561 | var match = String(constructorFn).match(functionNameMatch); 562 | if (match) { 563 | name = match[1]; 564 | } 565 | } else { 566 | name = constructorFn.name; 567 | } 568 | 569 | return name; 570 | } 571 | 572 | /** 573 | * ### .getConstructorName(errorLike) 574 | * 575 | * Gets the constructor name for an Error instance or constructor itself. 576 | * 577 | * @name getConstructorName 578 | * @param {Error|ErrorConstructor} errorLike 579 | * @namespace Utils 580 | * @api public 581 | */ 582 | 583 | function getConstructorName(errorLike) { 584 | var constructorName = errorLike; 585 | if (errorLike instanceof Error) { 586 | constructorName = getFunctionName(errorLike.constructor); 587 | } else if (typeof errorLike === 'function') { 588 | // If `err` is not an instance of Error it is an error constructor itself or another function. 589 | // If we've got a common function we get its name, otherwise we may need to create a new instance 590 | // of the error just in case it's a poorly-constructed error. Please see chaijs/chai/issues/45 to know more. 591 | constructorName = getFunctionName(errorLike).trim() || 592 | getFunctionName(new errorLike()); // eslint-disable-line new-cap 593 | } 594 | 595 | return constructorName; 596 | } 597 | 598 | /** 599 | * ### .getMessage(errorLike) 600 | * 601 | * Gets the error message from an error. 602 | * If `err` is a String itself, we return it. 603 | * If the error has no message, we return an empty string. 604 | * 605 | * @name getMessage 606 | * @param {Error|String} errorLike 607 | * @namespace Utils 608 | * @api public 609 | */ 610 | 611 | function getMessage(errorLike) { 612 | var msg = ''; 613 | if (errorLike && errorLike.message) { 614 | msg = errorLike.message; 615 | } else if (typeof errorLike === 'string') { 616 | msg = errorLike; 617 | } 618 | 619 | return msg; 620 | } 621 | 622 | module.exports = { 623 | compatibleInstance: compatibleInstance, 624 | compatibleConstructor: compatibleConstructor, 625 | compatibleMessage: compatibleMessage, 626 | getMessage: getMessage, 627 | getConstructorName: getConstructorName, 628 | }; 629 | 630 | },{}],4:[function(require,module,exports){ 631 | window.chaiPromise = require('chai-as-promised'); 632 | window.chaiSubset = require('chai-subset'); 633 | },{"chai-as-promised":1,"chai-subset":2}]},{},[4]); 634 | -------------------------------------------------------------------------------- /test/www/test-Model.js: -------------------------------------------------------------------------------- 1 | describe('Model Instance', function() { 2 | 'use strict'; 3 | 4 | it('is instance of `BaseModel`', function() { 5 | var user = new Model.User(); 6 | expect(user).to.be.instanceof(md.__TEST__.BaseModel); 7 | }); 8 | 9 | it('create instance with DEFAULT prop values', function() { 10 | var note = new Model.Note({ 11 | title: 'Foo' 12 | }); 13 | expect(note.title()).to.be.equal('Foo'); 14 | expect(note.body()).to.be.equal(Model.Note.modelOptions.defaults.body); 15 | expect(note.author()).to.be.equal(Model.Note.modelOptions.defaults.author); 16 | }); 17 | 18 | it('create instance with defined prop values', function() { 19 | var user = new Model.User({ 20 | name: 'Foo', 21 | age: 0, 22 | active: false 23 | }); 24 | expect(user.name()).to.be.equal('Foo'); 25 | expect(user.profile()).to.be.undefined; 26 | expect(user.age()).to.be.equal(0); 27 | expect(user.active()).to.be.equal(false); 28 | }); 29 | 30 | it('create instance with defined prop values and reference model', function() { 31 | var noteA = new Model.Note({ 32 | title: 'Foo', 33 | author: { 34 | name: 'Bar' 35 | } 36 | }); 37 | expect(noteA.title()).to.be.equal('Foo'); 38 | expect(noteA.author()).to.be.instanceof(md.__TEST__.BaseModel); 39 | expect(noteA.author().name()).to.be.equal('Bar'); 40 | 41 | var userB = new Model.User({ 42 | name: 'TestUser' 43 | }); 44 | var noteB = new Model.Note({ 45 | title: 'TestNote', 46 | author: userB 47 | }); 48 | expect(noteB.title()).to.be.equal('TestNote'); 49 | expect(noteB.author()).to.be.instanceof(md.__TEST__.BaseModel); 50 | expect(noteB.author().name()).to.be.equal('TestUser'); 51 | }); 52 | 53 | it('create instance with parser', function() { 54 | var noteA = new Model.Note({ 55 | wrap: { 56 | title: 'Title', 57 | inner: { 58 | body: 'Body', 59 | author: 'Author' 60 | } 61 | } 62 | }, { 63 | parse: true 64 | }); 65 | expect(noteA.title()).to.equal('Title'); 66 | expect(noteA.body()).to.equal('Body'); 67 | expect(noteA.author()).to.equal('Author'); 68 | }); 69 | 70 | it('prop defaults to `undefined`', function() { 71 | var user = new Model.User(); 72 | expect(user.profile()).to.be.undefined; 73 | }); 74 | 75 | it('prop sets `null`', function() { 76 | var user = new Model.User(); 77 | user.name(null); 78 | expect(user.name()).to.be.null; 79 | }); 80 | 81 | it('prop sets `undefined`', function() { 82 | var user = new Model.User(); 83 | user.name(undefined); 84 | expect(user.name()).to.be.undefined; 85 | }); 86 | 87 | it('prop set and read the correct value', function() { 88 | var user = new Model.User(); 89 | user.name('test'); 90 | expect(user.name()).to.equal('test'); 91 | }); 92 | 93 | it('with stream property', function() { 94 | var user = new Model.User(); 95 | user.name('Foo'); 96 | expect(user.name).to.have.property('stream'); 97 | expect(user.name.stream.constructor).to.be.equal(md.stream); 98 | }); 99 | 100 | it('have all properties that was set in `props` with values of type function', function() { 101 | var props = Model.User.modelOptions.props; 102 | var user = new Model.User(); 103 | for (var i = 0; i < props.length; i++) { 104 | expect(user).to.have.property(props[i]).and.to.be.a('function'); 105 | } 106 | }); 107 | 108 | it('create instance with options', function() { 109 | var user = new Model.User({ 110 | name: 'Foo' 111 | }, { 112 | test: 'test' 113 | }); 114 | expect(user).to.be.instanceof(Model.User); 115 | expect(user.__options.test).to.be.equal('test'); 116 | }); 117 | 118 | it('return the default value set in schema if value of prop is `undefined` or `null`', function() { 119 | var ModelUserTest = md.model({ 120 | name: 'ModelUserTest', 121 | props: ['test'], 122 | defaults: { 123 | test: 'Foo' 124 | } 125 | }); 126 | var user = new ModelUserTest(); 127 | expect(user.test()).to.be.equal('Foo'); 128 | user.test(undefined); 129 | expect(user.test()).to.be.equal('Foo'); 130 | user.test(null); 131 | expect(user.test()).to.be.equal('Foo'); 132 | }); 133 | 134 | it('finds the reference if value is string or number (only for enabled cache)', function() { 135 | 136 | var CacheSenderModel = md.model({ 137 | name: 'CacheSenderModel', 138 | props: ['name', 'age'] 139 | }, { 140 | cache: true 141 | }); 142 | 143 | var CacheEmailModel = md.model({ 144 | name: 'CacheEmailModel', 145 | props: ['subject', 'body', 'sender'], 146 | refs: { 147 | sender: 'CacheSenderModel' 148 | } 149 | }, { 150 | cache: true 151 | }); 152 | 153 | var modelsSender = CacheSenderModel.createModels([{ 154 | id: 123, 155 | name: 'Foo' 156 | }, { 157 | id: '456', 158 | name: 'Bar' 159 | }]); 160 | 161 | var modelsEmail = CacheEmailModel.createModels([{ 162 | subject: 'Baz', 163 | sender: '456' 164 | }, { 165 | subject: 'Test', 166 | sender: 123 167 | }]); 168 | 169 | // Note that the `first email` is referencing the `second sender`. Check the ID! 170 | expect(modelsEmail[0].sender()).to.be.equal(modelsSender[1]); 171 | expect(modelsEmail[1].sender()).to.be.equal(modelsSender[0]); 172 | expect(modelsEmail[0].sender().name()).to.be.equal('Bar'); 173 | expect(modelsEmail[1].sender().name()).to.be.equal('Foo'); 174 | 175 | }); 176 | 177 | }); 178 | 179 | describe('Model.', function() { 180 | 'use strict'; 181 | 182 | it('`__lid` exist with value type of string', function() { 183 | var user = new Model.User(); 184 | expect(user).to.have.property('__lid').and.to.be.a('string'); 185 | }); 186 | 187 | it('`__collections` exist with value type of array', function() { 188 | var user = new Model.User(); 189 | expect(user).to.have.property('__collections').and.to.be.a('array'); 190 | }); 191 | 192 | it('`__options` exist with value type of object', function() { 193 | var user = new Model.User(); 194 | expect(user).to.have.property('__options').and.to.be.a('object'); 195 | }); 196 | 197 | it('`__json` exist - *** the object representation of the model ***', function() { 198 | var user = new Model.User(); 199 | expect(user).to.have.property('__json').and.to.be.a('object'); 200 | }); 201 | 202 | it('`__json` has `__model` property with value referencing to its model instance', function() { 203 | var user = new Model.User(); 204 | expect(user.__json.__model).to.be.equal(user); 205 | }); 206 | 207 | 208 | it('`__json` have all model properties (props)', function() { 209 | var props = Model.User.modelOptions.props; 210 | var user = new Model.User(); 211 | for (var i = 0; i < props.length; i++) { 212 | expect(user.__json).to.have.property(props[i]); 213 | } 214 | }); 215 | 216 | it('`__json` properties are equal to its model properties', function() { 217 | var props = Model.User.modelOptions.props; 218 | var user = new Model.User(); 219 | user.profile(false); 220 | for (var i = 0; i < props.length; i++) { 221 | expect(user[props[i]]()).to.equal(user.__json[props[i]]); 222 | } 223 | }); 224 | 225 | it('`__json` will update on change of prop', function() { 226 | var user = new Model.User(); 227 | user.name('New Value'); 228 | expect(user.__json.name).to.equal('New Value'); 229 | }); 230 | 231 | it('`__json` will update through `set()` or `setAll()`', function() { 232 | var user = new Model.User(); 233 | user.set('name', 'foo'); 234 | expect(user.__json.name).to.equal('foo'); 235 | user.set({ 236 | name: 'bar', 237 | profile: 'baz' 238 | }); 239 | expect(user.__json.name).to.equal('bar'); 240 | expect(user.__json.profile).to.equal('baz'); 241 | }); 242 | 243 | }); 244 | 245 | describe('Model.', function() { 246 | 'use strict'; 247 | 248 | describe('#opt()', function() { 249 | 'use strict'; 250 | 251 | it('set options by plain object', function() { 252 | var user = new Model.User(); 253 | user.opt({ 254 | test: 'test' 255 | }); 256 | expect(user.__options.test).to.be.equal('test'); 257 | }); 258 | 259 | it('set options by key/value`', function() { 260 | var user = new Model.User(); 261 | user.opt('key', 'value'); 262 | expect(user.__options.key).to.be.equal('value'); 263 | }); 264 | 265 | it('defaults to boolean true', function() { 266 | var user = new Model.User(); 267 | user.opt('key'); 268 | expect(user.__options.key).to.be.equal(true); 269 | }); 270 | 271 | it('can set falsy except `undefined`', function() { 272 | var user = new Model.User(); 273 | user.opt('key', false); 274 | expect(user.__options.key).to.be.false; 275 | }); 276 | 277 | }); 278 | 279 | describe('#lid()', function() { 280 | 'use strict'; 281 | 282 | it('returns a string with value of its `__lid`', function() { 283 | var user = new Model.User(); 284 | expect(user.lid()).to.be.a('string').and.equal(user.__lid); 285 | }); 286 | 287 | }); 288 | 289 | describe('#url()', function() { 290 | 'use strict'; 291 | 292 | it('returns a string', function() { 293 | var user = new Model.User(); 294 | expect(user.url()).to.be.a('string'); 295 | }); 296 | 297 | }); 298 | 299 | describe('#attachCollection()', function() { 300 | 'use strict'; 301 | 302 | it('property `__collections` contains the collection', function() { 303 | var collection = new md.Collection(); 304 | var user = new Model.User(); 305 | 306 | user.attachCollection(collection); 307 | expect(user.__collections).to.contain(collection); 308 | }); 309 | 310 | it('property `__collections` has correct size', function() { 311 | var collection = new md.Collection(); 312 | var user = new Model.User(); 313 | var size = user.__collections.length; 314 | 315 | user.attachCollection(collection); 316 | expect(user.__collections.length).to.be.equal(++size); 317 | 318 | user.attachCollection(collection); 319 | expect(user.__collections.length).to.be.equal(size); 320 | }); 321 | 322 | }); 323 | 324 | describe('#detachCollection()', function() { 325 | 'use strict'; 326 | 327 | it('property `__collections` does NOT contain the collection', function() { 328 | var collection = new md.Collection(); 329 | var user = new Model.User(); 330 | 331 | user.attachCollection(collection); 332 | expect(user.__collections).to.contain(collection); 333 | 334 | user.detachCollection(collection); 335 | expect(user.__collections).to.not.contain(collection); 336 | 337 | }); 338 | 339 | it('property `__collections` has correct size', function() { 340 | var collection = new md.Collection(); 341 | var user = new Model.User(); 342 | 343 | user.attachCollection(collection); 344 | expect(user.__collections.length).to.be.equal(1); 345 | 346 | user.attachCollection(collection); 347 | expect(user.__collections.length).to.be.equal(1); 348 | 349 | user.detachCollection(collection); 350 | expect(user.__collections.length).to.be.equal(0); 351 | }); 352 | 353 | }); 354 | 355 | describe('#set()', function() { 356 | 'use strict'; 357 | 358 | it('set by object', function() { 359 | var note = new Model.Note(); 360 | var user = new Model.User(); 361 | note.set({ 362 | title: 'Foo', 363 | body: 'Bar', 364 | author: user 365 | }); 366 | expect(note.title()).to.equal('Foo'); 367 | expect(note.body()).to.equal('Bar'); 368 | expect(note.author()).to.equal(user); 369 | }); 370 | 371 | it('set by object with child object (reference model)', function() { 372 | var note = new Model.Note(); 373 | note.set({ 374 | title: 'Foo', 375 | body: 'Bar', 376 | author: { 377 | name: 'Test' 378 | } 379 | }); 380 | expect(note.title()).to.equal('Foo'); 381 | expect(note.body()).to.equal('Bar'); 382 | expect(note.author()).to.instanceof(md.__TEST__.BaseModel); 383 | expect(note.author().name()).to.equal('Test'); 384 | }); 385 | 386 | it('set by object with parser', function() { 387 | var note = new Model.Note(null, { 388 | parse: true 389 | }); 390 | note.set({ 391 | wrap: { 392 | title: 'Foo', 393 | inner: { 394 | body: 'Bar', 395 | author: 'Authx' 396 | } 397 | } 398 | }); 399 | expect(note.title()).to.equal('Foo'); 400 | expect(note.body()).to.equal('Bar'); 401 | expect(note.author()).to.equal('Authx'); 402 | }); 403 | 404 | it('set by key & value', function() { 405 | var note = new Model.Note(); 406 | var user = new Model.User(); 407 | note.set('title', 'Foo'); 408 | note.set('body', 'Bar'); 409 | note.set('author', user); 410 | expect(note.title()).to.equal('Foo'); 411 | expect(note.body()).to.equal('Bar'); 412 | expect(note.author()).to.equal(user); 413 | }); 414 | 415 | it('set to undefined if no value is set', function() { 416 | var user = new Model.User(); 417 | user.set({ 418 | name: 'Foo', 419 | }); 420 | user.set('profile'); 421 | expect(user.name()).to.equal('Foo'); 422 | expect(user.profile()).to.be.undefined; 423 | }); 424 | 425 | it('set falsy value like `false` and `0` ', function() { 426 | var user = new Model.User(); 427 | user.set({ 428 | name: 'Foo', 429 | age: 0 430 | }); 431 | user.set('active', false); 432 | expect(user.name()).to.equal('Foo'); 433 | expect(user.age()).to.equal(0); 434 | expect(user.active()).to.equal(false); 435 | }); 436 | 437 | it('throw error if key is invalid', function() { 438 | var user = new Model.User(); 439 | expect(function() { 440 | user.set('noprop', 'Foo'); 441 | }).to.throw(Error); 442 | }); 443 | 444 | }); 445 | 446 | describe('#setObject()', function() { 447 | 'use strict'; 448 | 449 | it('successful set ', function() { 450 | var user = new Model.User(); 451 | user.setObject({ 452 | name: 'Foo', 453 | age: 32 454 | }); 455 | expect(user.name()).to.equal('Foo'); 456 | expect(user.age()).to.equal(32); 457 | }); 458 | 459 | it('successful set with parser', function() { 460 | var note = new Model.Note(null, { 461 | parse: true 462 | }); 463 | note.setObject({ 464 | wrap: { 465 | title: 'Foo', 466 | inner: { 467 | body: 'Bar', 468 | author: 'Auth' 469 | } 470 | } 471 | }); 472 | expect(note.title()).to.equal('Foo'); 473 | expect(note.body()).to.equal('Bar'); 474 | expect(note.author()).to.equal('Auth'); 475 | }); 476 | 477 | }); 478 | 479 | describe('#get()', function() { 480 | 'use strict'; 481 | 482 | it('return correct value', function() { 483 | var note = new Model.Note(); 484 | var user = new Model.User(); 485 | note.set({ 486 | title: 'Foo', 487 | body: 'Bar', 488 | author: user 489 | }); 490 | expect(note.get('title')).to.equal('Foo'); 491 | expect(note.get('body')).to.equal('Bar'); 492 | expect(note.get('author')).to.equal(user); 493 | }); 494 | 495 | it('return a copy if NO key is specified (alias of .getCopy())', function() { 496 | var note = new Model.Note(); 497 | var user = new Model.User(); 498 | note.set({ 499 | title: 'Foo', 500 | body: 'Bar', 501 | author: user 502 | }); 503 | var copy = note.get(); 504 | expect(copy).to.not.equal(note.getJson()); 505 | expect(copy.title).to.equal('Foo'); 506 | expect(copy.body).to.equal('Bar'); 507 | expect(copy.author).to.eql(user.get()); 508 | }); 509 | 510 | }); 511 | 512 | describe('#getJson()', function() { 513 | 'use strict'; 514 | 515 | it('return correct value', function() { 516 | var user = new Model.User(); 517 | user.set({ 518 | name: 'Foo', 519 | profile: 'Bar' 520 | }); 521 | expect(user.getJson()).to.equal(user.__json); 522 | expect(user.getJson().name).to.equal('Foo'); 523 | expect(user.getJson().profile).to.equal('Bar'); 524 | }); 525 | 526 | }); 527 | 528 | describe('#getCopy()', function() { 529 | 'use strict'; 530 | 531 | it('really return a copy', function() { 532 | var user = new Model.User(); 533 | user.set({ 534 | name: 'Foo', 535 | profile: 'Bar' 536 | }); 537 | var copy = user.getCopy(); 538 | expect(copy).to.not.equal(user.getJson()); 539 | }); 540 | 541 | it('values are equal to the original', function() { 542 | var note = new Model.Note(); 543 | var user = new Model.User(); 544 | note.set({ 545 | title: 'Foo', 546 | body: 'Bar', 547 | author: user 548 | }); 549 | var copy = note.getCopy(); 550 | expect(copy.title).to.equal(note.title()); 551 | expect(copy.body).to.equal(note.body()); 552 | expect(copy.author).to.eql(note.author().getCopy()); 553 | }); 554 | 555 | it('copy should not set the original or vice versa', function() { 556 | var note = new Model.Note(); 557 | var user = new Model.User(); 558 | note.set({ 559 | title: 'Foo', 560 | body: 'Bar', 561 | author: user 562 | }); 563 | var copy = note.getCopy(); 564 | copy.title = 'Baz'; 565 | expect(copy.title).to.not.equal(note.title()); 566 | note.title('Test'); 567 | expect(note.title()).to.not.equal(copy.title); 568 | }); 569 | 570 | it('depopulate', function() { 571 | var note = new Model.Note(); 572 | var user = new Model.User({ 573 | id: 'id001' 574 | }); 575 | note.set({ 576 | title: 'Foo', 577 | body: 'Bar', 578 | author: user 579 | }); 580 | // Default 581 | var copyA = note.getCopy(); 582 | expect(copyA.author).to.be.object; 583 | // Depopulate 584 | var copyB = note.getCopy(false, true); 585 | expect(copyB.author).to.equal('id001'); 586 | }); 587 | 588 | }); 589 | 590 | 591 | describe('#detach()', function() { 592 | 'use strict'; 593 | 594 | it('detached from all collections', function() { 595 | var user = new Model.User(); 596 | var collA = new md.Collection(); 597 | var collB = new md.Collection(); 598 | collA.add(user); 599 | user.attachCollection(collB); 600 | expect(user.__collections).to.contain(collA); 601 | expect(user.__collections).to.contain(collB); 602 | var arr = user.__collections; 603 | user.detach(); 604 | expect(arr.length).to.be.equal(0); 605 | }); 606 | 607 | }); 608 | 609 | describe('#dispose()', function() { 610 | 'use strict'; 611 | 612 | it('disposed', function() { 613 | var user = new Model.User(); 614 | var keys = _.keys(user); 615 | user.dispose(); 616 | for (var i = 0; i < keys.length; i++) { 617 | expect(user[keys[i]]).to.be.null; 618 | } 619 | }); 620 | 621 | }); 622 | 623 | describe('#remove()', function() { 624 | 'use strict'; 625 | 626 | it('detached from all collections', function() { 627 | var user = new Model.User(); 628 | var collA = new md.Collection(); 629 | var collB = new md.Collection(); 630 | collA.add(user); 631 | user.attachCollection(collB); 632 | expect(user.__collections).to.contain(collA); 633 | expect(user.__collections).to.contain(collB); 634 | var arr = user.__collections; 635 | user.remove(); 636 | expect(arr.length).to.be.equal(0); 637 | }); 638 | 639 | it('disposed', function() { 640 | var user = new Model.User(); 641 | var keys = _.keys(user); 642 | user.remove(); 643 | for (var i = 0; i < keys.length; i++) { 644 | expect(user[keys[i]]).to.be.null; 645 | } 646 | }); 647 | 648 | }); 649 | 650 | describe('#save()', function() { 651 | 'use strict'; 652 | 653 | it('successful save (create)', function(done) { 654 | var user = new Model.User(); 655 | user.name('Create'); 656 | user.age(123); 657 | user.active(false); 658 | user.save().then(function(model) { 659 | try { 660 | expect(model).to.be.equal(user); 661 | expect(user.id().length).to.be.above(0); 662 | expect(user.name()).to.equal('Create'); 663 | expect(user.profile()).to.be.undefined; 664 | expect(user.age()).to.equal(123); 665 | expect(user.active()).to.equal(false); 666 | expect(user.isSaved()).to.equal(true); 667 | done(); 668 | } catch (e) { 669 | done(e); 670 | } 671 | }, function(err) { 672 | done(err) 673 | }); 674 | }); 675 | 676 | it('successful save (update)', function(done) { 677 | var user = new Model.User(); 678 | user.name('Update'); 679 | user.age(123); 680 | user.active(false); 681 | user.save().then(function(model) { 682 | try { 683 | expect(model).to.be.equal(user); 684 | expect(user.id().length).to.be.above(0); 685 | expect(user.name()).to.equal('Update'); 686 | expect(user.age()).to.equal(123); 687 | user.name('Updated!'); 688 | user.age(456); 689 | return user.save(); 690 | } catch (e) { 691 | done(e); 692 | } 693 | }, function(err) { 694 | done(err) 695 | }).then(function(model) { 696 | try { 697 | expect(model).to.be.equal(user); 698 | expect(user.id().length).to.be.above(0); 699 | expect(user.name()).to.equal('Updated!'); 700 | expect(user.age()).to.equal(456); 701 | expect(user.active()).to.equal(false); 702 | expect(user.profile()).to.be.undefined; 703 | done(); 704 | } catch (e) { 705 | done(e); 706 | } 707 | }, function(err) { 708 | done(err) 709 | }); 710 | }); 711 | 712 | it('save result through callback', function(done) { 713 | var user = new Model.User(); 714 | user.name('Callback'); 715 | user.save(function(err, response, model) { 716 | if (err) { 717 | done(err); 718 | return; 719 | } 720 | try { 721 | expect(response.id.length).to.be.above(0); 722 | expect(response.name).to.equal('Callback'); 723 | expect(model).to.be.equal(user); 724 | expect(user.id().length).to.be.above(0); 725 | expect(user.name()).to.equal('Callback'); 726 | done(); 727 | } catch (e) { 728 | done(e); 729 | } 730 | }); 731 | }); 732 | 733 | }); 734 | 735 | describe('#fetch()', function() { 736 | 'use strict'; 737 | 738 | var user; 739 | 740 | before(function(done) { 741 | user = new Model.User(); 742 | user.name('Hello'); 743 | user.profile('World'); 744 | user.save().then(function(model) { 745 | done() 746 | }, function(err) { 747 | done(err); 748 | }) 749 | }); 750 | 751 | it('successful fetch', function(done) { 752 | var existingUser = new Model.User(); 753 | existingUser.id(user.id()); 754 | existingUser.fetch().then(function(model) { 755 | // Fetch result : resolve. 756 | try { 757 | expect(model.id()).to.be.equal(user.id()); 758 | expect(model.name()).to.equal('Hello'); 759 | expect(model.profile()).to.equal('World'); 760 | expect(model.isSaved()).to.equal(true); 761 | expect(model.age()).to.be.undefined; 762 | expect(model.active()).to.be.undefined; 763 | done(); 764 | } catch (e) { 765 | done(e); 766 | } 767 | }, function(err) { 768 | // Fetch result : reject. 769 | done(err); 770 | }); 771 | }); 772 | 773 | it('fetch result through callback', function(done) { 774 | var existingUser = new Model.User(); 775 | existingUser.id(user.id()); 776 | existingUser.fetch(function(err, response, model) { 777 | if (err) { 778 | done(err); 779 | return; 780 | } 781 | try { 782 | expect(response.id.length).to.be.above(0); 783 | expect(response.name).to.equal('Hello'); 784 | expect(response.profile).to.equal('World'); 785 | expect(model.id()).to.be.equal(user.id()); 786 | expect(model.name()).to.equal('Hello'); 787 | expect(model.profile()).to.equal('World'); 788 | expect(model.isSaved()).to.equal(true); 789 | expect(model.age()).to.be.undefined; 790 | expect(model.active()).to.be.undefined; 791 | done(); 792 | } catch (e) { 793 | done(e); 794 | } 795 | }); 796 | }); 797 | 798 | it('react to undefined result', function(done) { 799 | // Alarm model is configured to return `undefined` in `test-md.js` 800 | var undefAlarm = new Model.Alarm(); 801 | undefAlarm.id('123'); 802 | undefAlarm.fetch().then(function(mdl) { 803 | // Values are default values from model 804 | expect(mdl.title()).to.equal('Default Alarm Title'); 805 | expect(mdl.time()).to.equal('8:00 AM'); 806 | expect(mdl.isSaved()).to.be.false; 807 | done(); 808 | }); 809 | }); 810 | 811 | it('falsy id (missing, string `undefined` and `null`', function(done) { 812 | var falsyIdAlarm = new Model.Alarm(); 813 | // Fetching wihtout id should reject 814 | falsyIdAlarm.fetch() 815 | .then(function() { done('Must not fullfill'); }) 816 | .catch(function(err) { 817 | falsyIdAlarm.id('undefined'); 818 | // Fetching with string id `undefined` should reject 819 | return falsyIdAlarm.fetch(); 820 | }) 821 | .then(function() { done('Must not fullfill'); }) 822 | .catch(function(err) { 823 | falsyIdAlarm.id(' null '); 824 | // Fetching with string id `null` should reject. Including space padd 825 | return falsyIdAlarm.fetch(); 826 | }) 827 | .then(function() { done('Must not fullfill'); }) 828 | .catch(function(err) { 829 | done(); 830 | }); 831 | }); 832 | 833 | }); 834 | 835 | describe('#destroy()', function() { 836 | 'use strict'; 837 | 838 | it('successful destroy', function(done) { 839 | var user = new Model.User(); 840 | user.name('Destroy'); 841 | user.save().then(function(model) { 842 | // Save successful. Destroying now... 843 | return model.destroy(); 844 | }, function(err) { 845 | done(err); 846 | }).then(function() { 847 | // Model `user` should be desposed after then block. 848 | setTimeout(function() { 849 | try { 850 | expect(user.__json).to.be.null; 851 | expect(user.id).to.be.null; 852 | done(); 853 | } catch (e) { 854 | done(e); 855 | } 856 | }, 0); 857 | }, function(err) { 858 | done(err) 859 | }); 860 | }); 861 | 862 | it('destroy result through callback', function(done) { 863 | var user = new Model.User(); 864 | user.name('Destroy'); 865 | user.save().then(function(model) { 866 | // Save successful. Destroying now... 867 | model.destroy(function(err) { 868 | if (err) { 869 | done(err); 870 | return; 871 | } 872 | setTimeout(function() { 873 | try { 874 | expect(user.__json).to.be.null; 875 | expect(user.id).to.be.null; 876 | done(); 877 | } catch (e) { 878 | done(e); 879 | } 880 | }, 0); 881 | }); 882 | }, function(err) { 883 | done(err); 884 | }); 885 | }); 886 | 887 | }); 888 | 889 | describe('#isSaved()', function() { 890 | 'use strict'; 891 | 892 | it('default to false', function() { 893 | var user = new Model.User(); 894 | expect(user.isSaved()).to.be.false; 895 | }); 896 | 897 | it('default to false even modified', function() { 898 | var user = new Model.User(); 899 | user.name('Modified Foo'); 900 | expect(user.isSaved()).to.be.false; 901 | }); 902 | 903 | it('false even id is set', function() { 904 | var user = new Model.User({ 905 | id: '001' 906 | }); 907 | expect(user.isSaved()).to.be.false; 908 | }); 909 | 910 | it('mark saved through save', function(done) { 911 | var user = new Model.User(); 912 | user.name('IsSaved'); 913 | if (user.isSaved()) 914 | done('Model must not be saved yet.') 915 | user.save().then(function(model) { 916 | try { 917 | expect(user.isSaved()).to.be.true; 918 | done(); 919 | } catch (e) { 920 | done(e); 921 | } 922 | }, function(err) { 923 | done(err); 924 | }); 925 | }); 926 | 927 | it('mark saved through fetch', function(done) { 928 | var user = new Model.User(); 929 | user.name('IsSaved'); 930 | if (user.isSaved()) 931 | done('Model must not be saved yet.') 932 | user.save().then(function(model) { 933 | try { 934 | expect(user.isSaved()).to.be.true; 935 | var existingUser = new Model.User(); 936 | existingUser.id(model.id()); 937 | expect(existingUser.isSaved()).to.be.false; 938 | return existingUser.fetch(); 939 | } catch (e) { 940 | done(e); 941 | } 942 | }, function(err) { 943 | done(err); 944 | }).then(function(existingUser) { 945 | try { 946 | expect(existingUser.isSaved()).to.be.true; 947 | done(); 948 | } catch (e) { 949 | done(e); 950 | } 951 | }, function(err) { 952 | done(err); 953 | }); 954 | }); 955 | 956 | }); 957 | 958 | describe('#isNew()', function() { 959 | 'use strict'; 960 | 961 | it('new if fresh instance', function(done) { 962 | try { 963 | var user = new Model.User(); 964 | user.name('IsNew'); 965 | expect(user.isNew()).to.be.true; 966 | user.id('notexist'); 967 | expect(user.isNew()).to.be.true; 968 | done(); 969 | } catch (e) { 970 | done(e); 971 | } 972 | }); 973 | 974 | it('not new if saved', function(done) { 975 | var user = new Model.User(); 976 | try { 977 | user.name('IsNew'); 978 | expect(user.isNew()).to.be.true; 979 | } catch (e) { 980 | done(e); 981 | } 982 | user.save(function() { 983 | try { 984 | expect(user.isNew()).to.be.false; 985 | done(); 986 | } catch (e) { 987 | done(e); 988 | } 989 | }, function(err) { 990 | done(err); 991 | }); 992 | }); 993 | 994 | }); 995 | 996 | describe('#isModified()', function() { 997 | 'use strict'; 998 | 999 | it('default to false', function() { 1000 | var user = new Model.User(); 1001 | expect(user.isModified()).to.be.false; 1002 | }); 1003 | 1004 | it('true if a prop is changed', function() { 1005 | var user = new Model.User(); 1006 | user.name('New Foo'); 1007 | expect(user.isModified()).to.be.true; 1008 | }); 1009 | 1010 | }); 1011 | 1012 | describe('#isDirty()', function() { 1013 | 'use strict'; 1014 | 1015 | it('true if modified', function() { 1016 | var user = new Model.User(); 1017 | user.name('New Foo'); 1018 | expect(user.isDirty()).to.be.true; 1019 | }); 1020 | 1021 | it('true if not saved', function() { 1022 | var user = new Model.User(); 1023 | expect(user.isDirty()).to.be.true; 1024 | }); 1025 | 1026 | it('false if saved and not modified', function(done) { 1027 | var user = new Model.User(); 1028 | user.save(function() { 1029 | try { 1030 | expect(user.isDirty()).to.be.false; 1031 | user.name('New Foo'); 1032 | expect(user.isDirty()).to.be.true; 1033 | done(); 1034 | } catch (e) { 1035 | done(e); 1036 | } 1037 | }, function(err) { 1038 | done(err); 1039 | }); 1040 | }); 1041 | 1042 | }); 1043 | 1044 | describe('#populate()', function() { 1045 | 'use strict'; 1046 | 1047 | it('successful populate', function(done) { 1048 | var note = new Model.Note({ 1049 | folder: 'fold001', 1050 | author: 'user001' 1051 | }); 1052 | var populate = note.populate(); 1053 | populate.then(function(model) { 1054 | expect(model).to.containSubset({ 1055 | __json: { 1056 | folder: { 1057 | name: 'System' 1058 | } 1059 | } 1060 | }); 1061 | expect(model).to.containSubset({ 1062 | __json: { 1063 | author: { 1064 | name: 'UserFoo' 1065 | } 1066 | } 1067 | }); 1068 | done(); 1069 | }).catch(function(err) { 1070 | done(err); 1071 | }); 1072 | expect(populate).to.be.fulfilled; 1073 | expect(populate).to.eventually.deep.equal(note); 1074 | }); 1075 | 1076 | }); 1077 | 1078 | }); --------------------------------------------------------------------------------