├── .eslintignore ├── yuidoc.json ├── libs └── yuidoc │ └── themes │ └── default │ └── assets │ └── css │ └── logo.png ├── _scripts ├── .babelrc.commonjs.coverage ├── .babelrc.amd ├── .babelrc.commonjs ├── publish-dev-ci ├── publish ├── test ├── changePackageName.js ├── changePackageDevVersion.js ├── publish-dev ├── coverage └── build ├── .gitignore ├── test ├── context.js └── unit │ ├── .eslintrc │ ├── .jshintrc │ ├── util │ └── Disposable-spec.js │ ├── mvc │ ├── Router-spec.js │ ├── Controller-spec.js │ ├── ViewManager-spec.js │ ├── Route-spec.js │ ├── Model-spec.js │ ├── View-spec.js │ └── Collection-spec.js │ └── events │ └── EventDispatcher-spec.js ├── webpack.config.js ├── .editorconfig ├── karma.coverage.conf.js ├── .jshintrc ├── src ├── util │ ├── uuid.js │ ├── Disposable.js │ ├── extend.js │ └── Cache.js ├── ui │ ├── Widget.js │ └── LoadingIndicator.js ├── env │ └── Device.js ├── net │ ├── Connectivity.js │ └── History.js ├── mvc │ ├── Controller.js │ ├── ChildViewManager.js │ ├── Route.js │ ├── Application.js │ ├── Router.js │ ├── ViewManager.js │ └── Model.js ├── events │ └── EventDispatcher.js └── fx │ ├── Transition.js │ ├── Animation.js │ └── Transform.js ├── webpack.config.coverage.js ├── webpack.config.test.js ├── karma.conf.js ├── .travis.yml ├── README.md ├── MIT-LICENSE.txt ├── CONTRIBUTING.md ├── package.json ├── .eslintrc └── CHANGELOG.md /.eslintignore: -------------------------------------------------------------------------------- 1 | **/*.ejs 2 | **/conic-gradient.js 3 | **/moment-timezone-with-data-2010-2020.js 4 | -------------------------------------------------------------------------------- /yuidoc.json: -------------------------------------------------------------------------------- 1 | { 2 | "options": { 3 | "linkNatives": "true", 4 | "themedir": "libs/yuidoc/themes/default" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /libs/yuidoc/themes/default/assets/css/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mutualmobile/lavaca/HEAD/libs/yuidoc/themes/default/assets/css/logo.png -------------------------------------------------------------------------------- /_scripts/.babelrc.commonjs.coverage: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ 3 | ["transform-es2015-modules-commonjs-simple", { "noMangle": true, "addExports": true }] 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.babelrc 2 | /node_modules 3 | /docs 4 | /build 5 | /test/context-compiled.js 6 | /test/coverage 7 | npm-debug.log 8 | 9 | .tern-port 10 | .DS_Store 11 | .idea 12 | -------------------------------------------------------------------------------- /test/context.js: -------------------------------------------------------------------------------- 1 | global.expect = global.chai.expect; 2 | var context = require.context('./unit', true, /-spec\.js$/); 3 | context.keys().forEach(context); 4 | console.log(context.keys()); 5 | -------------------------------------------------------------------------------- /_scripts/.babelrc.amd: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "es2015-webpack" 4 | ], 5 | "plugins": [ 6 | ["transform-es2015-modules-amd-simple", { "noMangle": true, "addExports": true }] 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | resolve: { 3 | root: __dirname, 4 | extensions: ['', '.js','.html'], 5 | modulesDirectories: ['node_modules', 'src'] 6 | } 7 | }; 8 | -------------------------------------------------------------------------------- /_scripts/.babelrc.commonjs: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "es2015-webpack" 4 | ], 5 | "plugins": [ 6 | ["transform-es2015-modules-commonjs-simple", { "noMangle": true, "addExports": true }] 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /test/unit/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "globals": { 3 | "describe": true, 4 | "it": true, 5 | "expect": true, 6 | "beforeEach": true, 7 | "afterEach": true, 8 | "sinon": true 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /_scripts/publish-dev-ci: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | DIRNAME=$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd ) 4 | 5 | set -e 6 | 7 | npm adduser < package.json 14 | ./_scripts/build --all 15 | mv package.json.bak package.json 16 | 17 | cd "$PROJECT_ROOT/build/amd" && npm publish 18 | cd "$PROJECT_ROOT/build/commonjs" && npm publish 19 | cd "$PROJECT_ROOT/build/es" && npm publish 20 | -------------------------------------------------------------------------------- /src/util/uuid.js: -------------------------------------------------------------------------------- 1 | var _uuidMap = {}; 2 | /** 3 | * Produces a app specific unique identifier 4 | * @class lavaca.util.uuid 5 | */ 6 | /** 7 | * Produces a unique identifier 8 | * @method uuid 9 | * @static 10 | * @param {String} namespace A string served the namespace of a uuid 11 | * 12 | * @return {Number} A number that is unique to this page 13 | */ 14 | export default (namespace) => { 15 | namespace = namespace || '__defaultNS'; 16 | if (typeof _uuidMap[namespace] !== 'number') { 17 | _uuidMap[namespace] = 0; 18 | } 19 | return _uuidMap[namespace]++; 20 | }; -------------------------------------------------------------------------------- /webpack.config.coverage.js: -------------------------------------------------------------------------------- 1 | var webpackConfig = require('./webpack.config.js'); 2 | var path = require('path'); 3 | 4 | webpackConfig.module = { 5 | loaders: [ 6 | { 7 | loader: 'babel-loader', 8 | include: [ 9 | path.resolve(__dirname, 'test/unit') 10 | ], 11 | exclude: /(node_modules)/, 12 | test: /\.js?$/, 13 | query: { 14 | presets: ['es2015-webpack'], 15 | plugins: [ 16 | ['transform-es2015-modules-commonjs-simple', { 'noMangle': true, 'addExports': true }] 17 | ] 18 | } 19 | } 20 | ] 21 | }; 22 | 23 | module.exports = webpackConfig; 24 | -------------------------------------------------------------------------------- /webpack.config.test.js: -------------------------------------------------------------------------------- 1 | var webpackConfig = require('./webpack.config.js'); 2 | var path = require('path'); 3 | 4 | webpackConfig.module = { 5 | loaders: [ 6 | { 7 | loader: 'babel-loader', 8 | include: [ 9 | path.resolve(__dirname, 'src/'), 10 | path.resolve(__dirname, 'test/unit') 11 | ], 12 | exclude: /(node_modules)/, 13 | test: /\.js?$/, 14 | query: { 15 | presets: [], 16 | plugins: [ 17 | ['transform-es2015-modules-commonjs-simple', { 'noMangle': true, 'addExports': true }] 18 | ] 19 | } 20 | } 21 | ] 22 | }; 23 | 24 | module.exports = webpackConfig; 25 | -------------------------------------------------------------------------------- /karma.conf.js: -------------------------------------------------------------------------------- 1 | module.exports = function(config) { 2 | config.set({ 3 | frameworks: ['mocha'], 4 | files: [ 5 | { pattern: 'node_modules/sinon/pkg/sinon-1.17.3.js', watched: false, nocache: true }, 6 | { pattern: 'node_modules/chai/chai.js', watched: false, nocache: true }, 7 | { pattern: 'node_modules/jquery/dist/jquery.js', watched: false, nocache: true }, 8 | { pattern: 'test/context-compiled.js', watched: true, nocache: true } 9 | ], 10 | preprocessors: { 11 | 'test/context-compiled.js': ['sourcemap'] 12 | }, 13 | reporters: ['dots'], 14 | autoWatch: false, 15 | singleRun: false, 16 | client: { 17 | mocha: { 18 | timeout: 500 19 | } 20 | } 21 | }); 22 | }; 23 | -------------------------------------------------------------------------------- /test/unit/.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "globals": { 3 | "$": false, 4 | "define": false, 5 | "require": false, 6 | "Promise": false, 7 | "describe": false, 8 | "expect": false, 9 | "fail": false, 10 | "beforeEach": false, 11 | "afterEach": false, 12 | "spyOn": false, 13 | "it": false, 14 | "xit": false, 15 | "runs": false, 16 | "waitsFor": false, 17 | "jasmine": false 18 | }, 19 | 20 | "browser": true, 21 | "devel": true, 22 | 23 | "sub": true, 24 | "expr": true, 25 | "undef": true, 26 | "unused": true, 27 | "smarttabs": true, 28 | "laxbreak": true, 29 | "curly": true, 30 | "eqeqeq": true, 31 | "nonew": true, 32 | "latedef": true, 33 | "sub": true, 34 | "loopfunc": true 35 | } 36 | -------------------------------------------------------------------------------- /test/unit/util/Disposable-spec.js: -------------------------------------------------------------------------------- 1 | 2 | var Disposable = require('lavaca/util/Disposable'); 3 | 4 | var Type, 5 | ob; 6 | 7 | describe('A Disposable', function() { 8 | beforeEach(function() { 9 | Type = Disposable.extend({ 10 | foo: 'bar', 11 | oof: 'rab' 12 | }); 13 | ob = new Type(); 14 | ob.bool = true; 15 | ob.str = 'A String'; 16 | ob.num = 1; 17 | ob.obj = { 18 | item0: 1, 19 | item1: 2 20 | }; 21 | ob.obj2 = new Type(); 22 | 23 | sinon.spy(ob.obj2, 'dispose'); 24 | }); 25 | it('provides a dispose function', function() { 26 | expect(typeof ob.dispose ==='function').to.equal(true); 27 | }); 28 | it('calls the dispose method of nested objects', function() { 29 | ob.dispose(); 30 | expect(ob.obj2.dispose.called).to.be.true; 31 | }); 32 | }); 33 | 34 | -------------------------------------------------------------------------------- /_scripts/coverage: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | DIRNAME=$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd ) 4 | PROJECT_ROOT=$( cd "$DIRNAME/.." && pwd ) 5 | 6 | cd "$PROJECT_ROOT" 7 | 8 | mkdir -p build/instrumented 9 | ./node_modules/istanbul/lib/cli.js instrument src --output build/instrumented --es-modules 10 | mkdir -p build/commonjs 11 | cp _scripts/.babelrc.commonjs.coverage .babelrc 12 | ./node_modules/babel-cli/bin/babel.js build/instrumented -d build/commonjs 13 | mkdir -p build/commonjs-coverage 14 | ./node_modules/webpack/bin/webpack.js --config webpack.config.coverage.js --resolve-alias 'lavaca=build/commonjs' test/context.js test/context-compiled.js 15 | ./node_modules/karma-cli/bin/karma start karma.coverage.conf.js "$@" 16 | 17 | if [[ $CI == 'true' ]]; then 18 | find ./test -name lcov.info -exec cat "{}" \; | ./node_modules/coveralls/bin/coveralls.js 19 | fi 20 | -------------------------------------------------------------------------------- /src/ui/Widget.js: -------------------------------------------------------------------------------- 1 | import {default as EventDispatcher} from '../events/EventDispatcher'; 2 | import {default as uuid} from '../util/uuid'; 3 | import $ from 'jquery'; 4 | 5 | /** 6 | * Base type for all UI elements 7 | * @class lavaca.ui.Widget 8 | * @extends lavaca.events.EventDispatcher 9 | * 10 | * @constructor 11 | * 12 | * @param {jQuery} el The DOM element that is the root of the widget 13 | */ 14 | var Widget = EventDispatcher.extend(function (el){ 15 | EventDispatcher.call(this); 16 | /** 17 | * The DOM element that is the root of the widget 18 | * @property {jQuery} el 19 | * @default null 20 | */ 21 | this.el = el = $(el); 22 | var id = el.attr('id'); 23 | if (!id) { 24 | id = 'widget-' + uuid(); 25 | } 26 | /** 27 | * The el's ID 28 | * @property {String} id 29 | * @default (Autogenerated) 30 | */ 31 | this.id = id; 32 | }); 33 | 34 | export default Widget; -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: required 2 | dist: trusty 3 | language: node_js 4 | node_js: 5 | - 5.1 6 | script: 7 | - npm install 8 | - npm run-script coverage 9 | - _scripts/publish-dev-ci 10 | before_install: 11 | - wget https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb 12 | - sudo dpkg -i google-chrome*.deb 13 | - export CHROME_BIN=/usr/bin/google-chrome 14 | - export DISPLAY=:99.0 15 | - sudo Xvfb :99 -ac & 16 | env: 17 | global: 18 | - secure: Xx22OLXO01DMA8E+PgJ8uumbWu4LvoXm2vYDvjT6p62iQNBoULI4dGOoeejhn52DEMZo0S+rxl52upFvKkUZJzTWxmoFR0nqwXbEnKQ/jcew3WmTkS7P9anuxxHog0Mv92EYnVLid8oBUKKTkVkrQkt8wW+1CyXPFFARKvnUiXY= 19 | - secure: IytSyueS1ImOlRZghI6wQ0PXRWX6N3UOMXBSOIVoLauVrH0DRZTH50kB4PGbJ8hV/Szf+SB15rFA2vA6Fj4n8SiejlfFETqUZ9nuZvQpRn71nl5A/1jeupEJQtVTZazEszubwO8iEmkQ9SwCFI3fOFH6RQMCr5vslXhzY0E7WqI= 20 | - secure: MOXrNqeggj1UEFqfL41NFshtpqGei0UoavnFIFXPpRRh0d/KJsbYVPCyqSTmWfUrYK02x8YJ0PGG+iP4LkcouWerGBTO/7/DboscVemg+VdEBE5c1ZZvYVG2fsi/CDXb4WXTs2GcAwFDyUOq665l3dnt6n4VMuA1EWt9u2N3rIs= 21 | -------------------------------------------------------------------------------- /src/env/Device.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Static utility type for working with Cordova (aka PhoneGap) and other non-standard native functionality 3 | * @class lavaca.env.Device 4 | */ 5 | 6 | var Device = {}; 7 | 8 | /** 9 | * Indicates whether or not the app is being run through Cordova 10 | * @method isCordova 11 | * @static 12 | * 13 | * @return {Boolean} True if app is being run through Cordova 14 | */ 15 | Device.isCordova = () => !!window.cordova; 16 | 17 | /** 18 | * Executes a callback when the device is ready to be used 19 | * @method init 20 | * @static 21 | * 22 | * @param {Function} callback The handler to execute when the device is ready 23 | */ 24 | Device.init = (callback) => { 25 | if (!Device.isCordova()) { 26 | $(document).ready(callback); 27 | } else if (document.addEventListener) { 28 | // Android fix 29 | document.addEventListener('deviceready', callback, false); 30 | } else { 31 | $(document).on('deviceready', callback); 32 | } 33 | }; 34 | 35 | export default Device; -------------------------------------------------------------------------------- /src/util/Disposable.js: -------------------------------------------------------------------------------- 1 | import {default as extend} from './extend'; 2 | 3 | const _disposeOf = (obj) => { 4 | var n, 5 | o, 6 | i; 7 | for (n in obj) { 8 | if (obj.hasOwnProperty(n)) { 9 | o = obj[n]; 10 | if (o) { 11 | if (typeof o === 'object' && typeof o.dispose === 'function') { 12 | o.dispose(); 13 | } else if (o instanceof Array) { 14 | for (i = o.length - 1; i > -1; i--) { 15 | if (o[i] && typeof o[i].dispose === 'function') { 16 | o[i].dispose(); 17 | } else { 18 | _disposeOf(o[i]); 19 | } 20 | } 21 | } 22 | } 23 | } 24 | } 25 | } 26 | 27 | /** 28 | * Abstract type for types that need to ready themselves for GC 29 | * @class lavaca.util.Disposable 30 | * @constructor 31 | * 32 | */ 33 | var Disposable = extend({ 34 | /** 35 | * Readies the object to be garbage collected 36 | * @method dispose 37 | * 38 | */ 39 | dispose() { 40 | _disposeOf(this); 41 | } 42 | }); 43 | export default Disposable; -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Lavaca 2 | 3 | [![Build Status](https://travis-ci.org/mutualmobile/lavaca.svg?branch=dev)](https://travis-ci.org/mutualmobile/lavaca) 4 | [![Coverage Status](https://coveralls.io/repos/github/mutualmobile/lavaca/badge.svg?branch=dev)](https://coveralls.io/github/mutualmobile/lavaca?branch=dev) 5 | 6 | A curated collection of tools built for mobile 7 | 8 | ## Getting Started 9 | 10 | Pull down a [lavaca-starter 11 | project](https://github.com/mutualmobile/lavaca-starter) or just the source. If 12 | you're going with the source route, Lavaca has three flavors available: 13 | 14 | - `npm install lavaca-amd`: AMD format 15 | - `npm install lavaca-commonjs`: CommonJS format 16 | - `npm install lavaca`: ES6 module format 17 | 18 | ## Documentation 19 | 20 | 21 | 22 | 23 | ## Examples 24 | 25 | 26 | ## Contributing 27 | Refer to CONTRIBUTING.md 28 | 29 | ## Release History 30 | [View Change Log](https://github.com/mutualmobile/lavaca/blob/master/CHANGELOG.md) 31 | 32 | ## License 33 | Copyright (c) 2013 mutualmobile 34 | Licensed under the MIT license. 35 | -------------------------------------------------------------------------------- /test/unit/mvc/Router-spec.js: -------------------------------------------------------------------------------- 1 | 2 | //var $ = require('$'); 3 | var router = require('lavaca/mvc/Router'); 4 | var viewManager = require('lavaca/mvc/ViewManager'); 5 | var Controller = require('lavaca/mvc/Controller'); 6 | 7 | var ob = { 8 | foo: function() {} 9 | }; 10 | 11 | module.exports = describe('A Router', function() { 12 | beforeEach(function(){ 13 | $('body').append('
'); 14 | viewManager = new viewManager.constructor('#view-root'); 15 | router = new router.constructor(viewManager); 16 | sinon.spy(ob, 'foo'); 17 | }); 18 | afterEach(function(){ 19 | $('#view-root').remove(); 20 | ob.foo.restore(); 21 | }); 22 | it('can add routes', function() { 23 | router.add({ 24 | '/foo/{param1}': [Controller, 'foo', {}], 25 | '/bar/{param1}': [Controller, 'bar', {}] 26 | }); 27 | router.add('/foobar/{param1}', Controller, 'foobar', {}); 28 | expect(router.routes.length).to.equal(3); 29 | }); 30 | it('can exec routes that delegate to a controller', function(done) { 31 | var testController = Controller.extend(ob); 32 | 33 | router.add('/foo/{param}', testController, 'foo', {}); 34 | router.exec('/foo/bar', null, {one: 1}).then(function() { 35 | expect(ob.foo.args[0][0].param).to.equal('bar'); 36 | expect(ob.foo.args[0][0].one).to.equal(1); 37 | expect(ob.foo.args[0][1]).to.be.undefined; 38 | done(); 39 | }); 40 | }); 41 | }); 42 | 43 | -------------------------------------------------------------------------------- /src/ui/LoadingIndicator.js: -------------------------------------------------------------------------------- 1 | import {default as Widget} from './Widget'; 2 | import $ from 'jquery'; 3 | 4 | /** 5 | * Type that shows/hides a loading indicator 6 | * @class lavaca.ui.LoadingIndicator 7 | * @extends lavaca.ui.Widget 8 | * 9 | * @constructor 10 | * @param {jQuery} el The DOM element that is the root of the widget 11 | */ 12 | var LoadingIndicator = Widget.extend({ 13 | /** 14 | * Class name applied to the root 15 | * @property {String} className 16 | * @default 'loading' 17 | */ 18 | className: 'loading', 19 | /** 20 | * Activates the loading indicator 21 | * @method show 22 | */ 23 | show() { 24 | this.el.addClass(this.className); 25 | }, 26 | /** 27 | * Deactivates the loading indicator 28 | * @method hide 29 | */ 30 | hide() { 31 | this.el.removeClass(this.className); 32 | } 33 | }); 34 | /** 35 | * Creates a loading indicator and binds it to the document's AJAX events 36 | * @method init 37 | * @static 38 | */ 39 | /** Creates a loading indicator and binds it to the document's AJAX events 40 | * @method init 41 | * @static 42 | * @param {Function} TLoadingIndicator The type of loading indicator to create (should derive from [[Lavaca.ui.LoadingIndicator]]) 43 | */ 44 | LoadingIndicator.init = (TLoadingIndicator) => { 45 | TLoadingIndicator = TLoadingIndicator || LoadingIndicator; 46 | var indicator = new TLoadingIndicator(document.body); 47 | let show = () => indicator.show(); 48 | let hide = () => indicator.hide(); 49 | $(document) 50 | .on('ajaxStart', show) 51 | .on('ajaxStop', hide) 52 | .on('ajaxError', hide); 53 | return indicator; 54 | }; 55 | 56 | export default LoadingIndicator.init(); -------------------------------------------------------------------------------- /MIT-LICENSE.txt: -------------------------------------------------------------------------------- 1 | Lavaca 2 | Copyright (c) 2012 Mutual Mobile 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 5 | 6 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 7 | 8 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 9 | 10 | Contains components from or inspired by: 11 | 12 | Simple Reset 13 | (c) 2011 Eric Meyer 14 | Released to public domain 15 | 16 | jQuery v1.7.1 17 | (c) 2011, John Resig 18 | Dual licensed under the MIT or GPL Version 2 licenses. 19 | 20 | Sizzle.js 21 | (c) 2011, The Dojo Foundation 22 | Released under the MIT, BSD, and GPL licenses. 23 | 24 | Backbone.js 0.9.1 and Underscore.js 1.3.1 25 | (c) 2012, Jeremy Ashkenas, DocumentCloud Inc 26 | Released under the MIT license. 27 | 28 | LinkedIn Fork of Dust.js 1.1 29 | (c) 2010, Aleksander Williams 30 | Released under the MIT license. 31 | 32 | iScroll 4.1.9 33 | (c) 2011 Matteo Spinelli 34 | Released under the MIT license 35 | -------------------------------------------------------------------------------- /_scripts/build: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | DIRNAME=$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd ) 4 | PROJECT_ROOT=$( cd "$DIRNAME/.." && pwd ) 5 | 6 | set -e 7 | 8 | cd "$PROJECT_ROOT/src" 9 | 10 | # generate lavaca.js (shortcuts for importing lavaca modules) 11 | printf '/* GENERATED by `npm run-script build` */\n' > lavaca.js 12 | find . -name '*.js' | \ 13 | while read filepath; do 14 | filename=$( echo "$filepath" | perl -pe 's,.*/(.*?)\.js,\1,g' ) 15 | printf "export { default as %s } from '%s';\n" "$filename" "$filepath" 16 | done >> lavaca.js 17 | 18 | cd "$PROJECT_ROOT" 19 | 20 | set -x 21 | 22 | case "$1" in 23 | es) 24 | mkdir -p build/es 25 | cp README.md build/es/ 26 | cp CONTRIBUTING.md build/es/ 27 | cp -a src/* build/es/ 28 | cat package.json \ 29 | | node _scripts/changePackageName.js lavaca \ 30 | > build/es/package.json 31 | ;; 32 | amd) 33 | mkdir -p build/amd 34 | cp README.md build/amd/ 35 | cp CONTRIBUTING.md build/amd/ 36 | cp _scripts/.babelrc.amd .babelrc 37 | ./node_modules/babel-cli/bin/babel.js src -d build/amd 38 | cat package.json \ 39 | | node _scripts/changePackageName.js lavaca-amd \ 40 | > build/amd/package.json 41 | ;; 42 | commonjs) 43 | mkdir -p build/commonjs 44 | cp README.md build/commonjs/ 45 | cp CONTRIBUTING.md build/commonjs/ 46 | cp _scripts/.babelrc.commonjs .babelrc 47 | ./node_modules/babel-cli/bin/babel.js src -d build/commonjs 48 | cat package.json \ 49 | | node _scripts/changePackageName.js lavaca-commonjs \ 50 | > build/commonjs/package.json 51 | ;; 52 | --all) 53 | ./_scripts/build es 54 | ./_scripts/build amd 55 | ./_scripts/build commonjs 56 | ;; 57 | esac 58 | 59 | # remove generated src/lavaca.js (to make it clear it's generated and 60 | # shouldn't be edited even though it's in src/) 61 | if [[ -e src/lavaca.js ]]; then 62 | rm src/lavaca.js 63 | fi 64 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Getting Started 2 | 3 | Before you run anything else, make sure you have the dependencies pulled down: 4 | 5 | ``` 6 | npm install 7 | ``` 8 | 9 | ## Running unit tests 10 | 11 | `npm run-script test -- ` 12 | 13 | Open a web browser to `http://localhost:9876/debug.html` and use DevTools 14 | (Cmd+Alt+i or Ctrl+Shift+i) to debug. Refreshing the window will re-run the 15 | unit tests. 16 | 17 | Any `` will be passed to the `karma` executable ([full list 18 | here](https://karma-runner.github.io/0.13/config/configuration-file.html)). 19 | Tests are configured to run in the Chrome browser by default. To use a 20 | different browser, download the [karma 21 | launcher](https://karma-runner.github.io/0.13/config/browsers.html) for your 22 | preferred browser and pass the `--browsers` argument, e.g. 23 | 24 | ``` 25 | npm install karma-firefox-launcher 26 | npm run-script test -- --browsers=Firefox 27 | ``` 28 | 29 | If you only want to run the unit tests once on the command line, use `npm 30 | run-script coverage`. 31 | 32 | ## Code coverage 33 | 34 | `npm run-script coverage` 35 | 36 | Open `test/coverage//lcov-report/index.html` in a web browser to view 37 | coverage results. 38 | 39 | ## Building 40 | 41 | `npm run-script ` 42 | 43 | Where `` is one of: 44 | - `amd`: transcompile `src/` to AMD modules and write to `build/amd/` 45 | - `commonjs`: transcompile `src/` to CommonJS modules and write to `build/commonjs/` 46 | - `es`: transcompile `src/` to ES6 modules and write to `build/es/` 47 | - `--all`: transcompile `src/` to all of the above module formats 48 | 49 | ## Publishing to NPM 50 | 51 | `npm run-script publish` 52 | 53 | Lavaca is distributed in three npm packages: 54 | - `lavaca-amd`: lavaca transcompiled to AMD format 55 | - `lavaca-commonjs`: lavaca transcompiled to CommonJS format 56 | - `lavaca-es6`: lavaca in its original ES6 module format 57 | 58 | If you are a package owner of all three, `npm run-script publish` will build 59 | all three flavors of Lavaca and publish each one to the npm registry. 60 | -------------------------------------------------------------------------------- /src/util/extend.js: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * Establishes inheritance between types. After a type is extended, it receives its own static 4 | * convenience method, extend(TSub, overrides). 5 | * @class lavaca.util.extend 6 | */ 7 | /** 8 | * Establishes inheritance between types. After a type is extended, it receives its own static 9 | * convenience method, extend(TSub, overrides). 10 | * @method extend 11 | * @static 12 | * 13 | */ 14 | /** 15 | * Establishes inheritance between types. After a type is extended, it receives its own static 16 | * convenience method, extend(TSub, overrides). 17 | * @method extend 18 | * @static 19 | * @param {Function} TSub The child type which will inherit from superType 20 | * @param {Object} overrides A hash of key-value pairs that will be added to the subType 21 | * @return {Function} The subtype 22 | * 23 | */ 24 | /** 25 | * Establishes inheritance between types. After a type is extended, it receives its own static 26 | * convenience method, extend(TSub, overrides). 27 | * @method extend 28 | * @static 29 | * @param {Function} TSuper The base type to extend 30 | * @param {Function} TSub The child type which will inherit from superType 31 | * @param {Object} overrides A hash of key-value pairs that will be added to the subType 32 | * @return {Function} The subtype 33 | */ 34 | var extend = function (TSuper, TSub, overrides){ 35 | if (typeof TSuper === 'object') { 36 | overrides = TSuper; 37 | TSuper = Object; 38 | TSub = () => {/* Empty*/}; 39 | } else if (typeof TSub === 'object') { 40 | overrides = TSub; 41 | TSub = TSuper; 42 | TSuper = Object; 43 | } 44 | let ctor = function() {/*Empty*/} 45 | ctor.prototype = TSuper.prototype; 46 | TSub.prototype = new ctor; 47 | TSub.prototype.constructor = TSub; 48 | if (overrides) { 49 | for (var name in overrides) { 50 | TSub.prototype[name] = overrides[name]; 51 | } 52 | } 53 | TSub.extend = function (T, overrides){ 54 | if (typeof T === 'object') { 55 | overrides = T; 56 | T = function() { 57 | return TSub.apply(this, arguments); 58 | }; 59 | } 60 | extend(TSub, T, overrides); 61 | return T; 62 | }; 63 | return TSub; 64 | }; 65 | 66 | export default extend; 67 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "lavaca", 3 | "description": "A curated collection of tools built for mobile.", 4 | "version": "4.0.0-alpha-9fbb9b7.0", 5 | "homepage": "https://github.com/mutualmobile/lavaca", 6 | "author": { 7 | "name": "mutualmobile", 8 | "email": "lavaca@mutualmobile.com" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "git://github.com/mutualmobile/lavaca.git" 13 | }, 14 | "bugs": { 15 | "url": "https://github.com/mutualmobile/lavaca/issues" 16 | }, 17 | "licenses": [ 18 | { 19 | "type": "MIT", 20 | "url": "https://github.com/mutualmobile/lavaca/blob/master/LICENSE-MIT" 21 | } 22 | ], 23 | "engines": { 24 | "node": ">= 0.8.0" 25 | }, 26 | "main": "lavaca", 27 | "scripts": { 28 | "build": "_scripts/build", 29 | "publish": "_scripts/publish", 30 | "test": "_scripts/test", 31 | "coverage": "_scripts/coverage", 32 | "doc": "node_modules/yuidocjs/lib/cli.js src --outdir docs" 33 | }, 34 | "devDependencies": { 35 | "babel-core": "^6.0.0", 36 | "babel": "6.5.2", 37 | "babel-cli": "6.5.1", 38 | "babel-loader": "6.2.4", 39 | "babel-plugin-transform-es2015-modules-amd-simple": "6.6.7", 40 | "babel-plugin-transform-es2015-modules-commonjs-simple": "6.7.0", 41 | "babel-preset-es2015-webpack": "6.4.0", 42 | "chai": "3.5.0", 43 | "coveralls": "2.11.9", 44 | "fs-extra": "0.6.3", 45 | "istanbul": "1.0.0-alpha.2", 46 | "jquery": "2.2.3", 47 | "karma": "0.13.22", 48 | "karma-chrome-launcher": "0.1.4", 49 | "karma-cli": "0.1.0", 50 | "karma-coverage": "0.2.4", 51 | "karma-mocha": "0.2.2", 52 | "karma-sourcemap-loader": "0.3.7", 53 | "mocha": "2.4.5", 54 | "mout": "1.0.0", 55 | "sinon": "1.17.3", 56 | "webpack": "1.12.14", 57 | "yuidocjs": "0.10.0" 58 | }, 59 | "keywords": [ 60 | "lavaca", 61 | "javascript", 62 | "lavaca-core", 63 | "mobile", 64 | "web", 65 | "framework", 66 | "touch", 67 | "responsive", 68 | "utils", 69 | "build", 70 | "mvc", 71 | "localStorage", 72 | "events", 73 | "animation" 74 | ] 75 | } 76 | -------------------------------------------------------------------------------- /src/net/Connectivity.js: -------------------------------------------------------------------------------- 1 | import {default as get} from 'mout/object/get'; 2 | import $ from 'jquery'; 3 | /** 4 | * A utility type for working under different network connectivity situations. 5 | * @class lavaca.net.Connectivity 6 | */ 7 | 8 | var _navigatorOnlineSupported = typeof navigator.onLine === 'boolean', 9 | _offlineAjaxHandlers = [], 10 | _offlineErrorCode = 'offline'; 11 | 12 | var _onAjaxError = (arg) => { 13 | if (arg === _offlineErrorCode) { 14 | var i = -1, 15 | callback; 16 | while (!!(callback = _offlineAjaxHandlers[++i])) { 17 | callback(arg); 18 | } 19 | } 20 | }; 21 | 22 | var _isLocalUrl = (url) => url.indexOf('.local') > 0 || url.indexOf('localhost') > 0 || url.substring(0,4) === 'file'; 23 | 24 | var Connectivity = {}; 25 | 26 | /** 27 | * Attempts to detect whether or not the browser is connected 28 | * @method isOffline 29 | * @static 30 | * 31 | * @return {Boolean} True if the browser is offline; false if the browser is online 32 | * or if connection status cannot be determined 33 | */ 34 | Connectivity.isOffline = () => { 35 | var connectionType = get(window, 'navigator.connection.type'); 36 | var none = get(window, 'Connection.NONE'); 37 | if (!!connectionType && !!none) { 38 | return connectionType === none; 39 | } 40 | else if(connectionType && connectionType !== 'none'){ 41 | return false; 42 | } 43 | else { 44 | return _navigatorOnlineSupported ? !navigator.onLine : false; 45 | } 46 | }; 47 | 48 | /** 49 | * Makes an AJAX request if the user is online. If the user is offline, the returned 50 | * promise will be rejected with the string argument "offline" 51 | * @method ajax 52 | * @static 53 | * 54 | * @param {Object} opts jQuery-style AJAX options 55 | * @return {Promise} A promise 56 | */ 57 | Connectivity.ajax = (opts) => { 58 | return Promise.resolve() 59 | .then(() => { 60 | if (Connectivity.isOffline() && !_isLocalUrl(opts.url)) { 61 | throw _offlineErrorCode; 62 | } 63 | }) 64 | .then(() => $.ajax(opts)) 65 | .catch(_onAjaxError); 66 | }; 67 | 68 | /** 69 | * Adds a callback to be executed whenever any Lavaca.net.Connectivity.ajax() call is 70 | * blocked as a result of a lack of internet connection. 71 | * @method registerOfflineAjaxHandler 72 | * @static 73 | * 74 | * @param {Function} callback The callback to execute 75 | */ 76 | Connectivity.registerOfflineAjaxHandler = (callback) => { 77 | _offlineAjaxHandlers.push(callback); 78 | }; 79 | 80 | export default Connectivity; -------------------------------------------------------------------------------- /test/unit/mvc/Controller-spec.js: -------------------------------------------------------------------------------- 1 | 2 | //var $ = require('$'); 3 | var Controller = require('lavaca/mvc/Controller'); 4 | var Model = require('lavaca/mvc/Model'); 5 | var History = require('lavaca/net/History'); 6 | var router = require('lavaca/mvc/Router'); 7 | var viewManager = require('lavaca/mvc/ViewManager'); 8 | var View = require('lavaca/mvc/View'); 9 | 10 | 11 | var testController, 12 | ob = { 13 | foo: function() {} 14 | }; 15 | 16 | module.exports = describe('A Controller', function() { 17 | beforeEach(function(){ 18 | $('body').append('
'); 19 | viewManager = (new viewManager.constructor()).setEl('#view-root'); 20 | router = (new router.constructor()).setViewManager(viewManager); 21 | sinon.spy(ob, 'foo'); 22 | testController = Controller.extend(ob); 23 | router.add({ 24 | '/foo': [testController, 'foo', {}] 25 | }); 26 | }); 27 | afterEach(function(){ 28 | $('#view-root').remove(); 29 | ob.foo.restore(); 30 | }); 31 | it('can be instantiated', function() { 32 | var controller = new testController(router, viewManager); 33 | expect(controller instanceof testController).to.equal(true); 34 | expect(controller.router).to.equal(router); 35 | expect(controller.viewManager).to.equal(viewManager); 36 | }); 37 | describe('can load a view', function() { 38 | var noop = { 39 | success: function() {} 40 | }; 41 | beforeEach(function(){ 42 | sinon.spy(noop, 'success'); 43 | $('body').append(''); 44 | }); 45 | afterEach(function(){ 46 | $('script[data-name="hello-world"]').remove(); 47 | noop.success.restore(); 48 | }); 49 | it('with a view helper method', function(done) { 50 | var controller = new testController(router, viewManager), 51 | myPageView = View.extend({ 52 | template: 'hello-world', 53 | }), 54 | response; 55 | controller.view('myView', myPageView).then(function() { 56 | response = viewManager.pageViews.myView.hasRendered; 57 | expect(response).to.equal(true); 58 | done(); 59 | }); 60 | }); 61 | }); 62 | it('can add a state to the browser history', function() { 63 | var controller = new testController(router, viewManager), 64 | model = new Model(), 65 | history = History.init(), 66 | current; 67 | History.overrideStandardsMode(); 68 | (controller.history(model, 'Home Page', window.location.href))(); 69 | current = history.current(); 70 | expect(current.state).to.equal(model); 71 | expect(current.title).to.equal('Home Page'); 72 | }); 73 | it('can format urls', function() { 74 | var controller = new testController(router, viewManager), 75 | url = '/foo/{0}', 76 | response; 77 | response = controller.url(url, ['bar']); 78 | expect(response).to.equal('/foo/bar'); 79 | }); 80 | describe('can redirect user to another route', function() { 81 | it('directly', function(done) { 82 | var controller = new testController(router, viewManager); 83 | controller.redirect('/foo').then(function() { 84 | expect(ob.foo.called).to.be.true; 85 | done(); 86 | }); 87 | }); 88 | }); 89 | }); 90 | 91 | -------------------------------------------------------------------------------- /src/mvc/Controller.js: -------------------------------------------------------------------------------- 1 | import $ from 'jquery'; 2 | import {default as Model} from './Model'; 3 | import {default as History} from '../net/History'; 4 | import {default as Disposable} from '../util/Disposable'; 5 | import {default as interpolate} from 'mout/string/interpolate'; 6 | 7 | /** 8 | * Base type for controllers 9 | * @class lavaca.mvc.Controller 10 | * @extends lavaca.util.Disposable 11 | * @constructor 12 | * @param {Lavaca.mvc.Controller} other Another controller from which to take context information 13 | * @param {Lavaca.mvc.Router} [router] The application's router 14 | * @param {Lavaca.mvc.ViewManager} [viewManager] The application's view manager 15 | */ 16 | var Controller = Disposable.extend(function Controller(router, viewManager){ 17 | if (router instanceof Controller) { 18 | this.router = router.router; 19 | this.viewManager = router.viewManager; 20 | } else { 21 | this.router = router; 22 | this.viewManager = viewManager; 23 | } 24 | }, { 25 | /** 26 | * The application's router 27 | * @property {Lavaca.mvc.Router} router 28 | * @default null 29 | */ 30 | router: null, 31 | /** 32 | * The application's view manager 33 | * @property {Lavaca.mvc.ViewManager} viewManager 34 | * @default null 35 | */ 36 | viewManager: null, 37 | /** 38 | * Loads a view 39 | * @method view 40 | * 41 | * @param {String} cacheKey The key under which to cache the view 42 | * @param {Function} TView The type of view to load (should derive from [[Lavaca.mvc.View]]) 43 | * @param {Object} model The data object to pass to the view 44 | * @param {Number} layer The integer indicating what UI layer the view sits on 45 | * @return {Promise} A promise 46 | */ 47 | view(cacheKey, TView, model, layer) { 48 | return this.viewManager.load(cacheKey, TView, model, layer); 49 | }, 50 | /** 51 | * Adds a state to the browser history 52 | * @method history 53 | * 54 | * @param {Object} state A data object associated with the page state 55 | * @param {String} title The title of the page state 56 | * @param {String} url The URL of the page state 57 | * @param {Boolean} useReplace The bool to decide if to remove previous history 58 | */ 59 | history(state, title, url, useReplace) { 60 | var needsHistory = !this.state; 61 | return () => { 62 | if (needsHistory) { 63 | History[useReplace ? 'replace' : 'push'](state, title, url); 64 | } 65 | }; 66 | }, 67 | /** 68 | * Convenience method for formatting URLs 69 | * @method url 70 | * 71 | * @param {String} str The URL string 72 | * @param {Array} args Format arguments to insert into the URL 73 | * @return {String} The formatted URL 74 | */ 75 | url(str, args) { 76 | args = args.map(window.encodeURIComponent); 77 | return interpolate(str, args, /\{(.+?)\}/); 78 | }, 79 | /** 80 | * Directs the user to another route 81 | * @method redirect 82 | * 83 | * @param {String} str The URL string 84 | * @return {Promise} A promise 85 | * 86 | */ 87 | /** 88 | * Directs the user to another route 89 | * @method redirect 90 | * @param {String} str The URL string 91 | * @param {Array} args Format arguments to insert into the URL 92 | * @return {Promise} A promise 93 | */ 94 | redirect(str, args, params) { 95 | return this.router.unlock().exec(this.url(str, args || []), null, params); 96 | }, 97 | /** 98 | * Readies the controller for garbage collection 99 | * @method dispose 100 | */ 101 | dispose() { 102 | // Do not dispose of view manager or router 103 | this.router 104 | = this.viewManager 105 | = null; 106 | Disposable.prototype.dispose.apply(this, arguments); 107 | } 108 | }); 109 | 110 | export default Controller; -------------------------------------------------------------------------------- /src/events/EventDispatcher.js: -------------------------------------------------------------------------------- 1 | import {default as Disposable} from '../util/Disposable'; 2 | 3 | /** 4 | * Basic event dispatcher type 5 | * @class lavaca.events.EventDispatcher 6 | * @extends lavaca.util.Disposable 7 | * @constructor 8 | * 9 | */ 10 | var EventDispatcher = Disposable.extend(function EventDispatcher() { 11 | this.callbacks = []; 12 | }, { 13 | /** 14 | * When true, do not fire events 15 | * @property suppressEvents 16 | * @type Boolean 17 | * @default false 18 | * 19 | */ 20 | suppressEvents: false, 21 | /** 22 | * Bind an event handler to this object 23 | * @method on 24 | * 25 | * @param {String} type The name of the event plus optional namespaces e.g. 26 | * "event.namespace" or "event.namespace1.namespace2" 27 | * @param {Function} callback The function to execute when the event occurs 28 | * @return {Lavaca.events.EventDispatcher} This event dispatcher (for chaining) 29 | */ 30 | on(spec, callback) { 31 | let parts = spec.split('.'); 32 | let type = parts[0]; 33 | let namespaces = parts.slice(1); 34 | 35 | this.callbacks.push({ 36 | type: type, 37 | namespaces: namespaces, 38 | fn: callback 39 | }); 40 | 41 | return this; 42 | }, 43 | /** 44 | * Unbinds all event handler from this object 45 | * @method off 46 | * 47 | * @return {Lavaca.events.EventDispatcher} This event dispatcher (for chaining) 48 | */ 49 | /** 50 | * Unbinds all event handlers for an event and/or namespace 51 | * @method off 52 | * 53 | * @param {String} type The name of the event and/or optional namespaces, 54 | * e.g. "event", "event.namespace", or ".namespace" 55 | * @return {Lavaca.events.EventDispatcher} This event dispatcher (for chaining) 56 | */ 57 | /** 58 | * Unbinds a specific event handler 59 | * @method off 60 | * 61 | * @param {String} type The name of the event 62 | * @param {Function} callback The function handling the event 63 | * @return {Lavaca.events.EventDispatcher} This event dispatcher (for chaining) 64 | */ 65 | off(spec, callback) { 66 | let hasCallbackArgument = (arguments.length === 2); 67 | 68 | if (arguments.length === 0) { 69 | this.callbacks = []; 70 | return this; 71 | } 72 | 73 | let parts = spec.split('.'); 74 | let type = parts[0]; 75 | let namespaces = parts.slice(1); 76 | 77 | this.callbacks = this.callbacks.filter((item) => { 78 | let matchesType = (item.type === type); 79 | 80 | if (!type) { 81 | matchesType = true; 82 | } 83 | 84 | let matchesNamespace = namespaces.some((ns) => { 85 | return item.namespaces.indexOf(ns) !== -1; 86 | }); 87 | 88 | if (!namespaces.length) { 89 | matchesNamespace = true; 90 | } 91 | 92 | let matchesCallback = item.fn === callback; 93 | 94 | if (!hasCallbackArgument) { 95 | matchesCallback = true; 96 | } 97 | 98 | return !(matchesType && matchesNamespace && matchesCallback); 99 | }); 100 | 101 | return this; 102 | }, 103 | /** 104 | * Dispatches an event 105 | * @method trigger 106 | * 107 | * @param {String} type The type of event to dispatch 108 | * @return {Lavaca.events.EventDispatcher} This event dispatcher (for chaining) 109 | */ 110 | /** 111 | * Dispactches an event with additional parameters 112 | * @method trigger 113 | * 114 | * @param {String} type The type of event to dispatch 115 | * @param {Object} params Additional data points to add to the event 116 | * @return {Lavaca.events.EventDispatcher} This event dispatcher (for chaining) 117 | */ 118 | trigger(type, params) { 119 | if (this.suppressEvents) { 120 | return this; 121 | } 122 | 123 | this.callbacks.forEach((item) => { 124 | if (item.type === type) { 125 | item.fn(params); 126 | } 127 | }); 128 | 129 | return this; 130 | } 131 | }); 132 | 133 | export default EventDispatcher; 134 | -------------------------------------------------------------------------------- /src/util/Cache.js: -------------------------------------------------------------------------------- 1 | import {default as Disposable} from '../util/Disposable'; 2 | import {default as uuid} from '../util/uuid'; 3 | 4 | /** 5 | * Object for storing data 6 | * @class lavaca.util.Cache 7 | * @extends lavaca.util.Disposable 8 | */ 9 | var Cache = Disposable.extend({ 10 | /** 11 | * 12 | * Retrieves an item from the cache 13 | * @method get 14 | * @param {String} id The key under which the item is stored 15 | * @return {Object} The stored item (or null if no item is stored) 16 | */ 17 | /** 18 | * Retrieves an item from the cache 19 | * @method get 20 | * @param {String} id The key under which the item is stored 21 | * @param {Object} def A default value that will be added, if there is no item stored 22 | * @return {Object} The stored item (or null if no item is stored and no default) 23 | */ 24 | get(id, def) { 25 | var result = this['@' + id]; 26 | if (result === undefined && def !== undefined) { 27 | result = this['@' + id] = def; 28 | } 29 | return result === undefined ? null : result; 30 | }, 31 | /** 32 | * Assigns an item to a key in the cache 33 | * @method set 34 | * 35 | * @param {String} id The key under which the item will be stored 36 | * @param {Object} value The object to store in the cache 37 | */ 38 | set(id, value) { 39 | this['@' + id] = value; 40 | }, 41 | /** 42 | * Adds an item to the cache 43 | * @method add 44 | * 45 | * @param {Object} value The object to store in the cache 46 | * @return {String} The auto-generated ID under which the value was stored 47 | */ 48 | add(value) { 49 | var id = uuid(); 50 | this.set(id, value); 51 | return id; 52 | }, 53 | /** 54 | * Removes an item from the cache (if it exists) 55 | * @method remove 56 | * 57 | * @param {String} id The key under which the item is stored 58 | */ 59 | remove(id) { 60 | delete this['@' + id] 61 | }, 62 | /** 63 | * Executes a callback for each cached item. To stop iteration immediately, 64 | * return false from the callback. 65 | * @method each 66 | * @param {Function} callback A function to execute for each item, callback(key, item) 67 | */ 68 | /** 69 | * Executes a callback for each cached item. To stop iteration immediately, 70 | * return false from the callback. 71 | * @method each 72 | * @param {Function} callback A function to execute for each item, callback(key, item) 73 | * @param {Object} thisp The context of the callback 74 | */ 75 | each(cb, thisp) { 76 | var prop, returned; 77 | for (prop in this) { 78 | if (this.hasOwnProperty(prop) && prop.indexOf('@') === 0) { 79 | returned = cb.call(thisp || this, prop.slice(1), this[prop]); 80 | if (returned === false) { 81 | break; 82 | } 83 | } 84 | } 85 | }, 86 | /** 87 | * Serializes the cache to a hash 88 | * @method toObject 89 | * 90 | * @return {Object} The resulting key-value hash 91 | */ 92 | toObject() { 93 | var result = {}; 94 | this.each((prop, value) => { 95 | result[prop] = (value && typeof value.toObject === 'function') ? value.toObject() : value; 96 | }); 97 | return result; 98 | }, 99 | /** 100 | * Serializes the cache to JSON 101 | * @method toJSON 102 | * 103 | * @return {String} The JSON string 104 | */ 105 | toJSON() { 106 | JSON.stringify(this.toObject()) 107 | }, 108 | /** 109 | * Serializes the cache to an array 110 | * @method toArray 111 | * 112 | * @return {Object} The resulting array with elements being index based and keys stored in an array on the 'ids' property 113 | */ 114 | toArray() { 115 | var results = []; 116 | results['ids'] = []; 117 | this.each((prop, value) => { 118 | results.push(typeof value.toObject === 'function' ? value.toObject() : value); 119 | results['ids'].push(prop); 120 | }); 121 | return results; 122 | }, 123 | 124 | /** 125 | * removes all items from the cache 126 | * @method clear 127 | */ 128 | clear() { 129 | this.each((key, item) => { 130 | this.remove(key); 131 | }, this); 132 | }, 133 | 134 | /** 135 | * returns number of items in cache 136 | * @method count 137 | */ 138 | count() { 139 | var count = 0; 140 | this.each((key, item) => { 141 | count++; 142 | }, this); 143 | return count; 144 | }, 145 | 146 | /** 147 | * Clears all items from the cache on dispose 148 | * @method dispose 149 | */ 150 | dispose() { 151 | this.clear(); 152 | Disposable.prototype.dispose.apply(this, arguments); 153 | } 154 | }); 155 | 156 | export default Cache; -------------------------------------------------------------------------------- /test/unit/mvc/ViewManager-spec.js: -------------------------------------------------------------------------------- 1 | var View = require('lavaca/mvc/View'); 2 | var viewManager = require('lavaca/mvc/ViewManager'); 3 | 4 | describe('A viewManager', function() { 5 | beforeEach(function(){ 6 | $('body').append('
'); 7 | viewManager = new viewManager.constructor(); 8 | viewManager.setEl('#view-root'); 9 | }); 10 | afterEach(function(){ 11 | $('#view-root').remove(); 12 | $('script[data-name="hello-world"]').remove(); 13 | }); 14 | it('can be instantiated via its module constructor', function() { 15 | expect(viewManager instanceof viewManager.constructor).to.equal(true); 16 | }); 17 | it('can load a view', function(done) { 18 | var myPageView = View.extend(function(){View.apply(this, arguments);},{ 19 | template: 'hello-world' 20 | }); 21 | Promise.resolve() 22 | .then(function() { 23 | return viewManager.load('myView', myPageView); 24 | }) 25 | .then(function() { 26 | var response = viewManager.pageViews['myView'].hasRendered; 27 | expect(response).to.equal(true); 28 | done(); 29 | }); 30 | }); 31 | describe('can remove', function() { 32 | it('a view on a layer and all views above', function(done) { 33 | var myPageView = View.extend(function(){View.apply(this, arguments);},{ 34 | template: 'hello-world', 35 | }); 36 | 37 | Promise.resolve() 38 | .then(function() { 39 | return viewManager.load('myView', myPageView); 40 | }) 41 | .then(function() { 42 | return viewManager.load('anotherView', myPageView, null, 1); 43 | }) 44 | .then(function() { 45 | expect($('#view-root').children().length).to.equal(2); 46 | return viewManager.dismiss(0); 47 | }) 48 | .then(function() { 49 | expect($('#view-root').children().length).to.equal(0); 50 | done(); 51 | }); 52 | }); 53 | it('a view on layer without removing views below', function(done) { 54 | var myPageView = View.extend(function(){View.apply(this, arguments);},{ 55 | template: 'hello-world', 56 | }); 57 | Promise.resolve() 58 | .then(function() { 59 | return viewManager.load('myView', myPageView); 60 | }) 61 | .then(function() { 62 | return viewManager.load('anotherView', myPageView, null, 1); 63 | }) 64 | .then(function() { 65 | expect($('#view-root').children().length).to.equal(2); 66 | return viewManager.dismiss(1); 67 | }) 68 | .then(function() { 69 | expect($('#view-root').children().length).to.equal(1); 70 | done(); 71 | }); 72 | }); 73 | it('a layer by an el', function(done) { 74 | var myPageView = View.extend(function(){View.apply(this, arguments);},{ 75 | template: 'hello-world', 76 | className: 'test-view', 77 | }); 78 | Promise.resolve() 79 | .then(function() { 80 | return viewManager.load('myView', myPageView); 81 | }) 82 | .then(function() { 83 | return viewManager.dismiss('.test-view'); 84 | }) 85 | .then(function() { 86 | expect($('#view-root').children().length).to.equal(0); 87 | done(); 88 | }); 89 | }); 90 | it('a layer relative to view object in the cache', function(done) { 91 | var myPageView = View.extend(function(){View.apply(this, arguments);},{ 92 | template: 'hello-world', 93 | className: 'test-view', 94 | }); 95 | Promise.resolve() 96 | .then(function() { 97 | return viewManager.load('myView', myPageView); 98 | }) 99 | .then(function() { 100 | return viewManager.dismiss(viewManager.pageViews['myView']); 101 | }) 102 | .then(function() { 103 | expect($('#view-root').children().length).to.equal(0); 104 | done(); 105 | }); 106 | }); 107 | }); 108 | it('can empty the view cache', function(done) { 109 | var myPageView = View.extend(function(){View.apply(this, arguments);},{ 110 | template: 'hello-world', 111 | }); 112 | 113 | Promise.resolve() 114 | .then(function() { 115 | return viewManager.load('myView', myPageView); 116 | }) 117 | .then(function() { 118 | return viewManager.load('anotherView', myPageView, null, 1); 119 | }) 120 | .then(function() { 121 | return viewManager.dismiss(1); 122 | }) 123 | .then(function() { 124 | viewManager.flush(); 125 | expect(viewManager.pageViews).to.deep.equal({}); 126 | expect(viewManager.layers[0].cacheKey).to.equal('myView'); 127 | done(); 128 | }); 129 | }); 130 | 131 | }); 132 | 133 | 134 | -------------------------------------------------------------------------------- /test/unit/mvc/Route-spec.js: -------------------------------------------------------------------------------- 1 | 2 | var Route = require('lavaca/mvc/Route'); 3 | var Controller = require('lavaca/mvc/Controller'); 4 | 5 | var route; 6 | 7 | module.exports = describe('A Route', function() { 8 | it('can match a route', function() { 9 | var ob; 10 | route = new Route('/foo/{param1}', Controller, '', {}); 11 | ob = route.matches('/foo/bar'); 12 | expect(ob).to.equal(true); 13 | ob = route.matches('/foo/bar/extra/stuff'); 14 | expect(ob).to.equal(false); 15 | }); 16 | it('can parse a {param}', function() { 17 | var ob; 18 | route = new Route('/foo/{param1}', Controller, '', {}); 19 | ob = route.parse('http://sample.com/foo/bar'); 20 | expect(ob.param1).to.equal('bar'); 21 | }); 22 | it('can parse a {param} in a hash url', function() { 23 | var ob; 24 | route = new Route('/foo/{param1}', Controller, '', {}); 25 | ob = route.parse('http://sample.com/#/foo/bar'); 26 | expect(ob.param1).to.equal('bar'); 27 | ob = route.parse('http://sample.com/#foo/bar'); 28 | expect(ob.param1).to.equal('bar'); 29 | }); 30 | it('can parse a multiple {param}/{param2}/p{page}', function() { 31 | var ob; 32 | route = new Route('/foo/{param1}/{param2}/p{page}', Controller, '', {}); 33 | ob = route.parse('http://sample.com/foo/bar/blog/p1'); 34 | expect(ob.param1).to.equal('bar'); 35 | expect(ob.param2).to.equal('blog'); 36 | expect(ob.page).to.equal('1'); 37 | }); 38 | it('can parse a multiple params inside a url segment {param1}-{param2}', function() { 39 | var ob; 40 | route = new Route('/foo/{param1}-{param2}', Controller, '', {}); 41 | ob = route.parse('http://sample.com/foo/bar-blog'); 42 | expect(ob.param1).to.equal('bar'); 43 | expect(ob.param2).to.equal('blog'); 44 | }); 45 | it('can parse {*splat}', function() { 46 | var ob; 47 | route = new Route('/foo/{*splat}', Controller, '', {}); 48 | ob = route.parse('http://sample.com/foo/bar/blog/p1'); 49 | expect(ob.splat[0]).to.equal('bar'); 50 | expect(ob.splat[1]).to.equal('blog'); 51 | expect(ob.splat[2]).to.equal('p1'); 52 | }); 53 | it('can parse {*splat} from a hash', function() { 54 | var ob; 55 | route = new Route('/foo/{*splat}', Controller, '', {}); 56 | ob = route.parse('http://sample.com/#/foo/bar/blog/p1'); 57 | expect(ob.splat[0]).to.equal('bar'); 58 | expect(ob.splat[1]).to.equal('blog'); 59 | expect(ob.splat[2]).to.equal('p1'); 60 | }); 61 | it('can parse querystring', function() { 62 | var ob; 63 | route = new Route('/blog', Controller, '', {}); 64 | ob = route.parse('http://sample.com/blog?cat=test&date=2012-06-14T22%3A30%3A30.181Z'); 65 | expect(ob.cat).to.equal('test'); 66 | expect(ob.date).to.equal('2012-06-14T22:30:30.181Z'); 67 | }); 68 | it('can parse querystring in a hash url', function() { 69 | var ob; 70 | route = new Route('/blog', Controller, '', {}); 71 | ob = route.parse('http://sample.com/#/blog?cat=test&date=2012-06-14T22%3A30%3A30.181Z'); 72 | expect(ob.cat).to.equal('test'); 73 | expect(ob.date).to.equal('2012-06-14T22:30:30.181Z'); 74 | }); 75 | it('can parse param and querystring', function() { 76 | var ob; 77 | route = new Route('/blog/tag/{tag}', Controller, '', {}); 78 | ob = route.parse('http://sample.com/blog/tag/javascript?cat=test&date=2012-06-14T22%3A30%3A30.181Z'); 79 | expect(ob.tag).to.equal('javascript'); 80 | expect(ob.cat).to.equal('test'); 81 | expect(ob.date).to.equal('2012-06-14T22:30:30.181Z'); 82 | }); 83 | it('can parse {*splat} and querystring', function() { 84 | var ob; 85 | route = new Route('/blog/{*splat}', Controller, '', {}); 86 | ob = route.parse('http://sample.com/blog/tag/javascript?cat=test&date=2012-06-14T22%3A30%3A30.181Z'); 87 | expect(ob.splat[0]).to.equal('tag'); 88 | expect(ob.splat[1]).to.equal('javascript'); 89 | expect(ob.cat).to.equal('test'); 90 | expect(ob.date).to.equal('2012-06-14T22:30:30.181Z'); 91 | }); 92 | it('can parse {param} then {*splat}', function() { 93 | var ob; 94 | route = new Route('/blog/{param}/{*splat}', Controller, '', {}); 95 | ob = route.parse('http://sample.com/blog/success/tag/javascript'); 96 | expect(ob.splat[0]).to.equal('tag'); 97 | expect(ob.splat[1]).to.equal('javascript'); 98 | expect(ob.param).to.equal('success'); 99 | }); 100 | it('can parse {param}, {*splat} and querystring', function() { 101 | var ob; 102 | route = new Route('/blog/{param}/{*splat}', Controller, '', {}); 103 | ob = route.parse('http://sample.com/blog/user/tag/javascript?cat=test&date=2012-06-14T22%3A30%3A30.181Z'); 104 | expect(ob.param).to.equal('user'); 105 | expect(ob.splat[0]).to.equal('tag'); 106 | expect(ob.splat[1]).to.equal('javascript'); 107 | expect(ob.cat).to.equal('test'); 108 | expect(ob.date).to.equal('2012-06-14T22:30:30.181Z'); 109 | }); 110 | }); 111 | 112 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es6": true 5 | }, 6 | "parser": "babel-eslint", 7 | "parserOptions": { 8 | "ecmaVersion": 6, 9 | "sourceType": "module" 10 | }, 11 | "globals": { 12 | }, 13 | "rules": { 14 | "comma-dangle": [2, "never"], 15 | "no-cond-assign": [2, "except-parens"], 16 | "no-console": 0, 17 | "no-constant-condition": 2, 18 | "no-control-regex": 2, 19 | "no-debugger": 2, 20 | "no-dupe-args": 2, 21 | "no-dupe-keys": 2, 22 | "no-duplicate-case": 2, 23 | "no-empty-character-class": 2, 24 | "no-empty": 2, 25 | "no-ex-assign": 2, 26 | "no-extra-boolean-cast": 2, 27 | "no-extra-parens": 0, 28 | "no-extra-semi": 2, 29 | "no-func-assign": 2, 30 | "no-inner-declarations": 2, 31 | "no-invalid-regexp": 2, 32 | "no-irregular-whitespace": 2, 33 | "no-negated-in-lhs": 2, 34 | "no-obj-calls": 2, 35 | "no-regex-spaces": 2, 36 | "no-sparse-arrays": 2, 37 | "no-unreachable": 2, 38 | "use-isnan": 2, 39 | "valid-jsdoc": 0, 40 | "valid-typeof": 2, 41 | "no-unexpected-multiline": 2, 42 | 43 | "accessor-pairs": 2, 44 | "block-scoped-var": 2, 45 | "complexity": 0, 46 | "consistent-return": 2, 47 | "curly": 2, 48 | "default-case": 2, 49 | "dot-notation": 2, 50 | "dot-location": 0, 51 | "eqeqeq": 2, 52 | "guard-for-in": 2, 53 | "no-alert": 2, 54 | "no-caller": 2, 55 | "no-div-regex": 2, 56 | "no-else-return": 0, 57 | "no-labels": [2, {"allowLoop": false, "allowSwitch": false}], 58 | "no-eq-null": 2, 59 | "no-eval": 2, 60 | "no-extend-native": 2, 61 | "no-extra-bind": 2, 62 | "no-fallthrough": 2, 63 | "no-floating-decimal": 2, 64 | "no-implicit-coercion": 2, 65 | "no-implied-eval": 2, 66 | "no-invalid-this": 2, 67 | "no-iterator": 2, 68 | "no-lone-blocks": 2, 69 | "no-loop-func": 2, 70 | "no-multi-spaces": 2, 71 | "no-multi-str": 2, 72 | "no-native-reassign": 2, 73 | "no-new-func": 2, 74 | "no-new-wrappers": 2, 75 | "no-new": 2, 76 | "no-octal-escape": 2, 77 | "no-octal": 2, 78 | "no-param-reassign": 0, 79 | "no-process-env": 2, 80 | "no-proto": 2, 81 | "no-redeclare": 2, 82 | "no-return-assign": 2, 83 | "no-script-url": 2, 84 | "no-self-compare": 2, 85 | "no-sequences": 2, 86 | "no-throw-literal": 2, 87 | "no-unused-expressions": 0, 88 | "no-useless-call": 2, 89 | "no-useless-concat": 2, 90 | "no-void": 2, 91 | "no-warning-comments": 0, 92 | "no-with": 2, 93 | "radix": 2, 94 | "vars-on-top": 0, 95 | "wrap-iife": [2, "inside"], 96 | "yoda": [2, "never", { "exceptRange": true }], 97 | 98 | "strict": 0, 99 | 100 | "init-declarations": 0, 101 | "no-catch-shadow": 0, 102 | "no-delete-var": 2, 103 | "no-label-var": 2, 104 | "no-shadow-restricted-names": 2, 105 | "no-shadow": 0, 106 | "no-undef-init": 2, 107 | "no-undef": 2, 108 | "no-undefined": 0, 109 | "no-unused-vars": [2, {"vars": "all", "args": "after-used"}], 110 | "no-use-before-define": [2, "nofunc"], 111 | 112 | "array-bracket-spacing": [2, "never"], 113 | "block-spacing": [2, "always"], 114 | "brace-style": [2, "stroustrup"], 115 | "camelcase": [2, {"properties": "always"}], 116 | "comma-spacing": [2, {"before": false, "after": true}], 117 | "comma-style": [2, "last"], 118 | "computed-property-spacing": [2, "never"], 119 | "consistent-this": [2, "self"], 120 | "eol-last": 2, 121 | "func-names": 0, 122 | "func-style": [2, "expression"], 123 | "id-length": 0, 124 | "id-match": 0, 125 | "indent": [2, 2, {"VariableDeclarator": 2, "SwitchCase": 1}], 126 | "key-spacing": [2, {"beforeColon": false, "afterColon": true}], 127 | "lines-around-comment": 0, 128 | "linebreak-style": [2, "unix"], 129 | "max-nested-callbacks": 0, 130 | "new-cap": 2, 131 | "new-parens": 2, 132 | "newline-after-var": 0, 133 | "no-array-constructor": 2, 134 | "no-continue": 0, 135 | "no-inline-comments": 0, 136 | "no-lonely-if": 0, 137 | "no-mixed-spaces-and-tabs": 2, 138 | "no-multiple-empty-lines": 0, 139 | "no-nested-ternary": 2, 140 | "no-new-object": 2, 141 | "no-spaced-func": 2, 142 | "no-ternary": 0, 143 | "no-trailing-spaces": 2, 144 | "no-underscore-dangle": 0, 145 | "no-unneeded-ternary": 2, 146 | "object-curly-spacing": [2, "always"], 147 | "one-var": 0, 148 | "operator-assignment": 0, 149 | "operator-linebreak": 0, 150 | "padded-blocks": 0, 151 | "quote-props": [2, "as-needed"], 152 | "quotes": [2, "single", "avoid-escape"], 153 | "semi-spacing": [2, {"before": false, "after": true}], 154 | "semi": [2, "always"], 155 | "sort-vars": 0, 156 | "keyword-spacing": [2, {"before": true, "after": true, "overrides": {}}], 157 | "space-before-function-paren": [2, "never"], 158 | "space-in-parens": 0, 159 | "space-infix-ops": 0, 160 | "space-unary-ops": [2, { "words": true, "nonwords": false }], 161 | "spaced-comment": 0, 162 | "wrap-regex": 0, 163 | 164 | "arrow-parens": 2, 165 | "arrow-spacing": 2, 166 | "constructor-super": 2, 167 | "no-class-assign": 2, 168 | "no-const-assign": 2, 169 | "no-dupe-class-members": 2, 170 | "no-this-before-super": 2, 171 | "no-var": 0, 172 | "object-shorthand": 0, 173 | "prefer-arrow-callback": 0, 174 | "prefer-const": 0, 175 | "prefer-spread": 0, 176 | "prefer-reflect": 0, 177 | "prefer-template": 0, 178 | "require-yield": 2 179 | } 180 | 181 | } 182 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | Changed in 3.0.0 2 | ====== 3 | * Major overhaul 4 | 5 | Changed in 2.3.1 and 2.3.2 6 | ====== 7 | * Patches for onTapLink 8 | 9 | Changed in 2.3.0 10 | ====== 11 | * Moved all client-side dependencies to use bower and restructured project to make sense of this change 12 | * Removed unused corodova plugin architecture from lavaca/env/Device since it is handled by corodva cli 13 | * Significant updates to the Form widget, and added tests for Form widget 14 | - Added 'autoTrim' option 15 | - Made URL regex more forgiving 16 | - More robust & less redundant get/set value logic 17 | - set() can accept a hash of names and values 18 | - Added 'treatAsGroup' option for rules 19 | - Added hook for input formatters 20 | * Added animationEndEvent function to the Animation class 21 | * Change autoRender behavior for childViews so that render() is called after the child view is created instead of in the View constructor fixing the intended behavior. 22 | * Updated History to fix navigating back out of a lavaca site. 23 | * Fixed cmd/ctr click on links 24 | * Refactored onTapLink 25 | - Now auto checks for external links and ignores them 26 | - Added support for cordova in app browser 27 | - Added `force-back` rel to force `History.isRoutingBack = true` 28 | * Add new signature to mapChildView which will allow for dynamically selecting/creating the childView's model 29 | * Fixed bindLinkHandler to appropriately bind click or tap events based on the inclusion of Hammer 30 | 31 | 32 | Changed in 2.2.0 33 | ====== 34 | * Combined PageView and View into a single class 35 | * Add ability to pass in custom arguments using the mapWidget() method 36 | * Add 'redrawsuccess' event 37 | * Fix bug with using the dot-syntax for mapping events to a view's model (ex. mapEvent({model: { 'change.attr' : myHandler }}) 38 | * Add ability to specify a default 'layer' property on a PageView-derived class' prototype 39 | * Fix bug with remove() not getting called on all models when clearModels() is called. 40 | * Fix issue with where initial URL may be URI encoded, such as when a site is saved to the home screen on iOS 41 | * Add transitionEndEvent method to Transition class 42 | * Fixed local ajax requests when offline 43 | 44 | Changed in 2.1.1 45 | ====== 46 | * Separated lavaca core files in NPM module 47 | 48 | Changed in 2.0.4 49 | ====== 50 | * Fix for Disposing of childviews and widgets in View.js 51 | * Fix for Bug in new syntax for mapEvent in Views 52 | 53 | Changed in 2.0.3 54 | ====== 55 | * Switched to grunt-contrib-yuidoc for code documentation generation 56 | * Cleaned up unused tasks in Gruntfile.js 57 | * Introduced new code scaffolding grunt task 58 | * Updated grunt server task to handle proxying both http and https based apis, added more configuration options 59 | * Added new syntax to mapEvent in Views, can now pass method name as a string and it will automatically bind to the view's context 60 | 61 | 62 | Changed in 2.0 63 | ====== 64 | * Upgraded Cordova to 2.6 65 | * Switched to Grunt for building the application 66 | * Switched to AMD architecture with RequireJS 67 | * Added insert method to collections 68 | * Added remove(index) signature to collections 69 | * Added of Lavaca CLI tool 70 | * Added responsefilter to models 71 | * Enhanced collection of page transitions 72 | * Added new way of specifying page transitions in Views 73 | * Ability to listen for model attribute events in mapEvent method in Views 74 | 75 | Changed in 1.0.5 76 | ====== 77 | * Upgraded Cordova to 2.2 78 | * Enhanced build script to generate scripts.xml and styles.xml files based on specially annotated sections of the index.html 79 | * Added computed attributes for models and collections ([more](https://github.com/mutualmobile/lavaca/wiki/3.1.-Models-and-Collections#wiki-computed-attributes)) 80 | * Added redraw() method to view that handels partial rendering based on a CSS selector or with custom redraw method 81 | * Added initial hash route parsing to facilitate page refreshing 82 | * Switched default templating engine to LinkedIn fork of Dust (NOTE: This change is not 100% backwards compatible. [Read more] (https://github.com/mutualmobile/Lavaca-modules/tree/master/x-dust#syntax-differences-from-default-lavaca-template-system)) 83 | * Overloaded collection's add() to accept an array of objects or models 84 | * Added sort method to collections following _.sortBy pattern 85 | * Added Dust helper to access variables from config files ([more](https://github.com/mutualmobile/lavaca/wiki/4.1.-Using-Templates-to-Generate-HTML#wiki-config-helper)) 86 | * Added entercomplete event that fires when a view is done animating 87 | 88 | Changed in 1.0.4 89 | ====== 90 | * Upgraded Cordova to 2.1 91 | * Fixed animation glitches in page transitions 92 | * Updated Android ChildBrowser plugin to remove legacy ctx in favor of cordova.getContext() 93 | * Removed preventDefault() from touchstart in tap events 94 | * Added support for all iOS app icons and startup images 95 | * Fixed an issue where $.fn.transition(props, duration, easing, callback) would not overload properly if transitions were not supported 96 | * Fixed issue where a tap event would fire if the fake scroll was started/ended on a element with a tap handler 97 | * Fixed issue in build.py where it was looking for mm:configs instead of Lavaca:configs 98 | * Fixed toObject call on Models that have Models/Collections as an attribute 99 | * Added better support for Android identity checks and added Mobile identity checks 100 | * Fixed Model.validate() and added support for quickly checking if model is valid 101 | 102 | Changed in 1.0.3 103 | ====== 104 | * Moved the "column" property from the model to the view in app.ui.BaseView 105 | * Upgraded x-dust to 0.5.3 106 | * Fixed an issue where views would fail to exit on Android 4.1 107 | * Lavaca.env.Device no longer throws errors when Zepto is swapped out for jQuery 108 | * Added support for target="_blank" on application's tap handler for `` tags 109 | * Fixed a timing issue with app.ui.BaseView's enter and exit animations 110 | * Fixed an issue where the signature $.fn.touch(onstart, onmove, onend) would fail to bind handlers 111 | * Fixed an issue where Lavaca.delay did not return a timeout ID 112 | * Fixed an issue where event handlers were unbound from cached views when Zepto is swapped out for jQuery 113 | * Documentation template no longer treats every method as static 114 | * Android now parses all route variables consistently 115 | 116 | Changed in 1.0.2 117 | ====== 118 | * Added enter/exit events for Lavaca.mvc.View 119 | * Lavaca.mvc.Collection#fetch now works as expected with complex data containing arrays 120 | * Lavaca.mvc.Collection now supports TModel being a Collection-type 121 | * You can now delegate events to the view's model using the "model" selector. Those events will be automatically unbound when the view is disposed 122 | -------------------------------------------------------------------------------- /src/mvc/ChildViewManager.js: -------------------------------------------------------------------------------- 1 | import $ from 'jquery'; 2 | import {default as Disposable} from '../util/Disposable'; 3 | import {default as History} from '../net/History'; 4 | import {default as fillIn} from 'mout/object/fillIn'; 5 | import {default as merge} from 'mout/object/merge'; 6 | import {default as removeAll} from 'mout/array/removeAll'; 7 | import {default as contains} from 'mout/array/contains'; 8 | 9 | var ChildViewManager = Disposable.extend(function ChildViewManager(el, routes, parent, childViewMixin, childViewFillin){ 10 | Disposable.call(this); 11 | this.history = []; 12 | this.animationBreadcrumb = []; 13 | this.step = 1; 14 | this.initialStep = 1; 15 | this.routes = []; 16 | this.currentView = false; 17 | this.isRoutingBack = false; 18 | this.layers = []; 19 | this.exitingViews = []; 20 | this.enteringViews = []; 21 | this.routes = {}; 22 | this.elName = el; 23 | this.parentView = parent; 24 | this.id = 'cvm-' + parent.id; 25 | this.childViewMixin = childViewMixin; 26 | this.childViewFillin = childViewFillin; 27 | this.hasInitialized = false; 28 | if (typeof routes === 'object') { 29 | for (var r in routes) { 30 | this.routes[r] = routes[r]; 31 | } 32 | } 33 | else{ 34 | console.warn('You must pass mapChildViewManager an element string and object of the routes.'); 35 | return; 36 | } 37 | $(window).on('cvmexec.'+this.id,_exec.bind(this)); 38 | }, { 39 | init(view, id) { 40 | this.el = view.find(this.elName); 41 | if (!this.el.hasClass('cvm')){ 42 | this.el.addClass('cvm'); 43 | this.exec(null, {isRedraw: this.hasInitialized}); 44 | this.hasInitialized = true; 45 | } 46 | }, 47 | back() { 48 | if(!this.history || this.history.length - 1 <= 0){ 49 | History.back(); 50 | } 51 | else{ 52 | this.history.pop(); 53 | var route = this.history.pop(); 54 | this.isRoutingBack = true; 55 | var _always = () => { 56 | this.isRoutingBack = false; 57 | }; 58 | this.exec(route).then(_always, _always); 59 | } 60 | }, 61 | stepBack() { 62 | if(this.history.length > 1){ 63 | var route = _getRoute.call(this, this.history[this.history.length - 1]); 64 | if(this.routes[route].step !== this.initialStep){ 65 | this.back(); 66 | } 67 | else{ 68 | History.back(); 69 | } 70 | } 71 | else{ 72 | this.back(); 73 | } 74 | }, 75 | exec(route, params) { 76 | if(!route){ 77 | route = 1; 78 | } 79 | route = _getRoute.call(this, route); 80 | if(!route){ 81 | return; 82 | } 83 | if(params && !params.isRedraw || ! params){ 84 | this.history.push(route); 85 | } 86 | var ChildView = this.routes[route].TView, 87 | model = this.routes[route].model; 88 | if(!model){ 89 | model = this.parentView ? this.parentView.model : null; 90 | } 91 | var layer = ChildView.prototype.layer || 0, 92 | childView = new ChildView(null, model, this.parentView); 93 | 94 | params = params || {}; 95 | if (typeof params === 'number') { 96 | layer = params; 97 | } else if (params.layer) { 98 | layer = params.layer; 99 | } 100 | childView.layer = layer; 101 | 102 | if (typeof this.childViewMixin === 'object') { 103 | merge(childView, this.childViewMixin); 104 | } 105 | if (typeof this.childViewFillin === 'object') { 106 | fillIn(childView, this.childViewFillin); 107 | } 108 | if (typeof params === 'object') { 109 | merge(childView, params); 110 | } 111 | 112 | childView.isChildViewManagerView = true; 113 | 114 | return childView.render().then(() => { 115 | this.currentView = childView; 116 | this.enteringViews = [childView]; 117 | return Promise.all([ 118 | (() => { 119 | if (this.layers[layer] !== childView) { 120 | return childView.enter(this.el, this.exitingViews, params ? params.isRedraw : false); 121 | } 122 | return Promise.resolve(); 123 | })(), 124 | (() => { 125 | return this.dismissLayersAbove(layer-1, childView); 126 | })() 127 | ]).catch(err=>console.trace(err.stack)); 128 | }) 129 | .then(() => { 130 | this.enteringPageViews = []; 131 | this.step = this.routes[route].step; 132 | this.layers[layer] = childView; 133 | if (this.parentView && 134 | this.parentView.onChildViewManagerExec && 135 | typeof this.parentView.onChildViewManagerExec === 'function') { 136 | this.parentView.onChildViewManagerExec(route, this.step); 137 | } 138 | }).catch(err=>console.trace(err.stack)); 139 | }, 140 | dismiss(layer) { 141 | if (typeof layer === 'number') { 142 | return this.dismissLayersAbove(layer - 1); 143 | } else { 144 | layer = $(layer); 145 | var index = layer.attr('data-layer-index'); 146 | if (index === null) { 147 | layer = layer.closest('[data-layer-index]'); 148 | index = layer.attr('data-layer-index'); 149 | } 150 | if (index !== null) { 151 | return this.dismiss(Number(index)); 152 | } 153 | } 154 | }, 155 | dismissLayersAbove(index, exceptForView) { 156 | var toDismiss = this.layers.slice(index+1) 157 | .filter((layer) => { 158 | return (layer && (!exceptForView || exceptForView !== layer)); 159 | }); 160 | this.layers = this.layers.map((layer) => { 161 | if (contains(toDismiss, layer)) { 162 | return null; 163 | } 164 | return layer; 165 | }); 166 | var promises = toDismiss.map((layer) => { 167 | return Promise.resolve() 168 | .then(() => { 169 | this.exitingViews.push(layer); 170 | return layer.exit(this.el, this.enteringViews); 171 | }) 172 | .then(() => { 173 | removeAll(this.exitingViews, layer); 174 | layer.dispose(); 175 | }); 176 | }); 177 | 178 | return Promise.all(promises).catch(err=>console.trace(err.stack)); 179 | }, 180 | dispose() { 181 | this.model = this.parentView = null; 182 | $(window).off('cvmexec.'+this.id); 183 | Disposable.prototype.dispose.call(this); 184 | }, 185 | flush() { 186 | this.history = []; 187 | } 188 | }); 189 | 190 | function _getRoute(url){ 191 | var route = false; 192 | if(this.routes){ 193 | for(var r in this.routes){ 194 | if(r === url || this.routes[r].step === url){ 195 | route = r; 196 | } 197 | } 198 | } 199 | return route; 200 | } 201 | 202 | function _exec(e,obj){ 203 | if(obj && obj.childViewSelector === this.elName){ 204 | if(obj.route === 'back' || obj.route === '#back'){ 205 | this.back(); 206 | return; 207 | } 208 | if(obj.route === 'stepback' || obj.route === '#stepback'){ 209 | this.stepback(); 210 | return; 211 | } 212 | this.exec(obj.route); 213 | } 214 | } 215 | 216 | export default ChildViewManager; 217 | -------------------------------------------------------------------------------- /src/mvc/Route.js: -------------------------------------------------------------------------------- 1 | import {default as Disposable} from '../util/Disposable'; 2 | import {default as merge} from 'mout/object/merge'; 3 | import {default as clone} from 'mout/lang/clone'; 4 | 5 | let _multivariablePattern = () => new RegExp('\\{\\*(.*?)\\}', 'g'); 6 | let _variablePattern = () => new RegExp('\\{([^\\/]*?)\\}', 'g'); 7 | let _variableCharacters = () => new RegExp('[\\{\\}\\*]', 'g'); 8 | let _datePattern = () => new RegExp('^\\d{4}-[0-1]\\d-[0-3]\\d$', 'g'); 9 | 10 | let _patternToRegExp = (pattern) => { 11 | if (pattern === '/') { 12 | return new RegExp('^\\/(\\?.*)?(#.*)?$', 'g'); 13 | } 14 | if (pattern.charAt(0) === '/') { 15 | pattern = pattern.slice(1); 16 | } 17 | pattern = pattern.split('/'); 18 | var exp = '^', 19 | i = -1, 20 | part; 21 | while (!!(part = pattern[++i])) { 22 | if (_multivariablePattern().test(part)) { 23 | exp += '(/([^/?#]+))*?'; 24 | } else if (_variablePattern().test(part)) { 25 | exp += '/([^/?#]+)'; 26 | } else { 27 | exp += '/' + part; 28 | } 29 | } 30 | exp += '(\\?.*)?(#\\.*)?$'; 31 | return new RegExp(exp, 'g'); 32 | }; 33 | 34 | let _scrubURLValue = (value) => { 35 | value = decodeURIComponent(value); 36 | if (!isNaN(value)) { 37 | value = Number(value); 38 | } else if (value.toLowerCase() === 'true') { 39 | value = true; 40 | } else if (value.toLowerCase() === 'false') { 41 | value = false; 42 | } else if (_datePattern().test(value)) { 43 | value = value.split('-'); 44 | value = new Date(Number(value[0]), Number(value[1]) - 1, Number(value[2])); 45 | } 46 | return value; 47 | }; 48 | 49 | /** 50 | * @class lavaca.mvc.Route 51 | * @extends lavaca.util.Disposable 52 | * A relationship between a URL pattern and a controller action 53 | * 54 | * @constructor 55 | * @param {String} pattern The route URL pattern 56 | * Route URL patterns should be in the form /path/{foo}/path/{*bar}. 57 | * The path variables, along with query string parameters, will be passed 58 | * to the controller action as a params object. In this case, when passed 59 | * the URL /path/something/path/1/2/3?abc=def, the params object would be 60 | * {foo: 'something', bar: [1, 2, 3], abc: 'def'}. 61 | * @param {Function} TController The type of controller that performs the action 62 | * (Should derive from [[Lavaca.mvc.Controller]]) 63 | * @param {String} action The name of the controller method to call 64 | * @param {Object} params Key-value pairs that will be merged into the params 65 | * object that is passed to the controller action 66 | */ 67 | var Route = Disposable.extend(function Route(pattern, TController, action, params){ 68 | Disposable.call(this); 69 | this.pattern = pattern; 70 | this.TController = TController; 71 | this.action = action; 72 | this.params = params || {}; 73 | }, { 74 | /** 75 | * Tests if this route applies to a URL 76 | * @method matches 77 | * 78 | * @param {String} url The URL to test 79 | * @return {Boolean} True when this route matches the URL 80 | */ 81 | matches(url) { 82 | return _patternToRegExp(this.pattern).test(url); 83 | }, 84 | /** 85 | * Converts a URL into a params object according to this route's pattern 86 | * @method parse 87 | * 88 | * @param {String} url The URL to convert 89 | * @return {Object} The params object 90 | */ 91 | parse(url) { 92 | var result = clone(this.params), 93 | pattern = this.pattern.slice(1), 94 | urlParts = url.split('#'), 95 | i, 96 | query, 97 | path, 98 | pathItem, 99 | patternItem, 100 | name; 101 | result.url = url; 102 | result.route = this; 103 | urlParts = urlParts[1] ? urlParts[1].split('?') : urlParts[0].split('?'); 104 | query = urlParts[1]; 105 | if (query) { 106 | i = -1; 107 | query = query.split('&'); 108 | while (!!(pathItem = query[++i])) { 109 | pathItem = pathItem.split('='); 110 | name = decodeURIComponent(pathItem[0]); 111 | if (result[name] !== undefined) { 112 | if (!(result[name] instanceof Array)) { 113 | result[name] = [result[name]]; 114 | } 115 | result[name].push(_scrubURLValue(pathItem[1])); 116 | } else { 117 | result[name] = _scrubURLValue(pathItem[1]); 118 | } 119 | } 120 | } 121 | i = 0; 122 | path = urlParts[0].replace(/(^(http(s?)\:\/\/[^\/]+)?\/?)|(\/$)/, ''); 123 | var breakApartPattern = new RegExp(pattern.replace(_multivariablePattern(), '(.+)').replace(_variablePattern(), '([^/]+)')), 124 | brokenPath = breakApartPattern.exec(path), 125 | brokenPattern = breakApartPattern.exec(pattern); 126 | while (!!(pathItem = brokenPath[++i])) { 127 | patternItem = brokenPattern[i]; 128 | if (_multivariablePattern().test(patternItem)) { 129 | pathItem = pathItem.split('/'); 130 | } 131 | result[patternItem.replace(_variableCharacters(), '')] = pathItem; 132 | } 133 | return result; 134 | }, 135 | /** 136 | * Executes this route's controller action see if work 137 | * @method exec 138 | * 139 | * @param {String} url The URL that supplies parameters to this route 140 | * @param {Lavaca.mvc.Router} router The router used by the application 141 | * @param {Lavaca.mvc.ViewManager} viewManager The view manager used by the application 142 | * @return {Promise} A promise 143 | */ 144 | /** 145 | * Executes this route's controller action see if work 146 | * @method exec 147 | * 148 | * @param {String} url The URL that supplies parameters to this route 149 | * @param {Lavaca.mvc.Router} router The router used by the application 150 | * @param {Lavaca.mvc.ViewManager} viewManager The view manager used by the application 151 | * @param {Object} state A history record object 152 | * @return {Promise} A promise 153 | */ 154 | /** 155 | * Executes this route's controller action see if work 156 | * @method exec 157 | * 158 | * @param {String} url The URL that supplies parameters to this route 159 | * @param {Lavaca.mvc.Router} router The router used by the application 160 | * @param {Lavaca.mvc.ViewManager} viewManager The view manager used by the application 161 | * @param {Object} state A history record object 162 | * @param {Object} params Additional parameters to pass to the controller action 163 | * @return {Promise} A promise 164 | */ 165 | exec(url, router, viewManager, state, params) { 166 | var controller = new this.TController(router, viewManager), 167 | urlParams = this.parse(url), 168 | model; 169 | controller.params = params; 170 | controller.state = state; 171 | params = params || {}; 172 | if (state) { 173 | model = state.state; 174 | params.bypassLoad = state.bypassLoad; 175 | } 176 | params = merge(urlParams, params); 177 | return Promise.resolve() 178 | .then(() => { 179 | return controller[this.action](params, model); 180 | }) 181 | .then(() => { 182 | if (state) { 183 | document.title = state.title; 184 | } 185 | }) 186 | .then(()=>this.dispose.call(this)) 187 | .catch((err) => { 188 | this.dispose(); 189 | console.trace(err.stack); 190 | throw err; 191 | }); 192 | } 193 | }); 194 | 195 | export default Route; -------------------------------------------------------------------------------- /src/fx/Transition.js: -------------------------------------------------------------------------------- 1 | import $ from 'jquery' 2 | 3 | var Transition = {}; 4 | 5 | const _props = { 6 | transition: ['transition', 'transitionend'], 7 | webkitTransition: ['-webkit-transition', 'webkitTransitionEnd'], 8 | MozTransition: ['-moz-transition', 'MozTransitionEnd'], 9 | OTransition: ['-o-transition', 'OTransitionEnd'], 10 | MSTransition: ['-ms-transition', 'MSTransitionEnd'] 11 | }; 12 | let _prop, 13 | _cssProp, 14 | _event; 15 | 16 | (() => { 17 | var style = document.createElement('div').style, 18 | s; 19 | for (s in _props) { 20 | if (s in style) { 21 | _prop = s; 22 | _cssProp = _props[s][0]; 23 | _event = _props[s][1]; 24 | break; 25 | } 26 | } 27 | })(); 28 | 29 | /** 30 | * Static utility type for working with CSS transitions 31 | * @class lavaca.fx.Transition 32 | */ 33 | 34 | /** 35 | * Whether or not transitions are supported by the browser 36 | * @method isSupported 37 | * @static 38 | * 39 | * @return {Boolean} True when CSS transitions are supported 40 | */ 41 | Transition.isSupported = () => { 42 | return !!_prop; 43 | }; 44 | 45 | /** 46 | * Generates a CSS transition property string from several values 47 | * @method toCSS 48 | * @static 49 | * 50 | * @param {Object} props A hash in which the keys are the names of the CSS properties 51 | * @param {Number} duration The amount of time in milliseconds that the transition lasts 52 | * @return {String} The generated CSS string 53 | */ 54 | /** 55 | * Generates a CSS transition property string from several values 56 | * @method toCSS 57 | * @static 58 | * 59 | * @param {Array} props An array of CSS property names 60 | * @param {Number} duration The amount of time in milliseconds that the transition lasts 61 | * @return {String} The generated CSS string 62 | */ 63 | /** 64 | * Generates a CSS transition property string from several values 65 | * @method toCSS 66 | * @static 67 | * 68 | * @param {Object} props A hash in which the keys are the names of the CSS properties 69 | * @param {Number} duration The amount of time in milliseconds that the transition lasts 70 | * @param {String} easing The interpolation for the transition 71 | * @return {String} The generated CSS string 72 | */ 73 | /** 74 | * Generates a CSS transition property string from several values 75 | * @method toCSS 76 | * @static 77 | * 78 | * @param {Array} props An array of CSS property names 79 | * @param {Number} duration The amount of time in milliseconds that the transition lasts 80 | * @param {String} easing The interpolation for the transition 81 | * @return {String} The generated CSS string 82 | */ 83 | Transition.toCSS = (props, duration, easing) => { 84 | easing = easing || 'linear'; 85 | var css = [], 86 | isArray = props instanceof Array, 87 | prop; 88 | for (prop in props) { 89 | if (isArray) { 90 | prop = props[prop]; 91 | } 92 | css.push(prop + ' ' + duration + 'ms ' + easing); 93 | } 94 | return css.join(','); 95 | }; 96 | 97 | /** 98 | * Gets the name of the transition CSS property 99 | * @method cssProperty 100 | * @static 101 | * 102 | * @return {String} The name of the CSS property 103 | */ 104 | Transition.cssProperty = () => _cssProp; 105 | 106 | /** 107 | * Gets the name of the transition end event 108 | * @method transitionEndEvent 109 | * @static 110 | * 111 | * @return {String} The name of the event 112 | */ 113 | Transition.transitionEndEvent = () => _event; 114 | 115 | /** 116 | * Causes an element to undergo a transition 117 | * @method $.fn.transition 118 | * 119 | * @param {Object} props The CSS property values at the end of the transition 120 | * @param {Number} duration The amount of time in milliseconds that the transition lasts 121 | * @return {jQuery} The jQuery object, for chaining 122 | */ 123 | /** 124 | * Causes an element to undergo a transition 125 | * @method $.fn.transition 126 | * 127 | * @param {Object} props The CSS property values at the end of the transition 128 | * @param {Number} duration The amount of time in milliseconds that the transition lasts 129 | * @param {String} easing The interpolation for the transition 130 | * @return {jQuery} The jQuery object, for chaining 131 | */ 132 | /** 133 | * Causes an element to undergo a transition 134 | * @method $.fn.transition 135 | * 136 | * @param {Object} props The CSS property values at the end of the transition 137 | * @param {Number} duration The amount of time in milliseconds that the transition lasts 138 | * @param {Function} callback A function to execute when the transition completes 139 | * @return {jQuery} The jQuery object, for chaining 140 | */ 141 | /** 142 | * Causes an element to undergo a transition 143 | * @method $.fn.transition 144 | * 145 | * @param {Object} props The CSS property values at the end of the transition 146 | * @param {Number} duration The amount of time in milliseconds that the transition lasts 147 | * @param {String} easing The interpolation for the transition 148 | * @param {Function} callback A function to execute when the transition completes 149 | * @return {jQuery} The jQuery object, for chaining 150 | */ 151 | $.fn.transition = function(props, duration, easing, callback) { 152 | if (easing instanceof Function) { 153 | callback = easing; 154 | easing = null; 155 | } 156 | if (Transition.isSupported()) { 157 | var css = Transition.toCSS(props, duration, easing); 158 | if (callback) { 159 | this.nextTransitionEnd(callback); 160 | } 161 | this.each(() => this.style[_prop] = css); 162 | this.css(props); 163 | } else { 164 | this.css(props); 165 | if (callback) { 166 | callback.call(this[0], {}); 167 | } 168 | } 169 | return this; 170 | }; 171 | 172 | /** 173 | * Binds a transition end handler to an element. 174 | * @method $.fn.transitionEnd 175 | * 176 | * @param {Function} callback Callback for when the transition ends 177 | * @return {jQuery} The jQuery object, for chaining 178 | */ 179 | /** 180 | * Binds a transition end handler to an element. 181 | * @method $.fn.transitionEnd 182 | * 183 | * @param {String} delegate Selector for the descendant elements to which the handlers will be bound 184 | * @param {Function} callback Callback for when the transition ends 185 | * @return {jQuery} The jQuery object, for chaining 186 | */ 187 | $.fn.transitionEnd = function(delegate, callback) { 188 | if (_event) { 189 | return this.on(_event, delegate, callback); 190 | } else { 191 | return this; 192 | } 193 | }; 194 | 195 | /** 196 | * Binds a transition end handler to an element's next transition end event. 197 | * @method $.fn.nextTransitionEnd 198 | * 199 | * @param {Function} callback Callback for when the transition ends 200 | * @return {jQuery} The jQuery object, for chaining 201 | */ 202 | /** 203 | * Binds a transition end handler to an element's next transition end event. 204 | * @method $.fn.nextTransitionEnd 205 | * 206 | * @param {String} delegate Selector for the descendant elements to which the handlers will be bound 207 | * @param {Function} callback Callback for when the transition ends 208 | * @return {jQuery} The jQuery object, for chaining 209 | */ 210 | $.fn.nextTransitionEnd = function(delegate, callback) { 211 | if (_event) { 212 | return this.one(_event, delegate, callback); 213 | } else { 214 | return this; 215 | } 216 | }; 217 | 218 | export default Transition; 219 | -------------------------------------------------------------------------------- /src/mvc/Application.js: -------------------------------------------------------------------------------- 1 | import $ from 'jquery'; 2 | import {default as History} from '../net/History'; 3 | import {default as Device} from '../env/Device'; 4 | import {default as EventDispatcher} from '../events/EventDispatcher'; 5 | import {default as Router} from './Router'; 6 | import {default as ViewManager} from './ViewManager'; 7 | 8 | var _stopEvent = (e) => { 9 | e.preventDefault(); 10 | e.stopPropagation(); 11 | } 12 | 13 | var _matchHashRoute = (hash) => { 14 | hash = hash.replace('#!', '#'); 15 | var matches = decodeURIComponent(hash).match(/^(?:#)(\/.*)#?@?/); 16 | if (matches instanceof Array && matches[1]) { 17 | return matches[1].replace(/#.*/, ''); 18 | } 19 | return null; 20 | }; 21 | 22 | var _isExternal = (url) => { 23 | var match = url.match(/^([^:\/?#]+:)?(?:\/\/([^\/?#]*))?([^?#]+)?(\?[^#]*)?(#.*)?/); 24 | if (typeof match[1] === 'string' 25 | && match[1].length > 0 26 | && match[1].toLowerCase() !== location.protocol) { 27 | return true; 28 | } 29 | if (typeof match[2] === 'string' 30 | && match[2].length > 0 31 | && match[2].replace(new RegExp(':('+{'http:':80,'https:':443}[location.protocol]+')?$'), '') !== location.host) { 32 | return true; 33 | } 34 | return false; 35 | }; 36 | 37 | /** 38 | * Base application type 39 | * @class lavaca.mvc.Application 40 | * @extends lavaca.events.EventDispatcher 41 | * 42 | */ 43 | /** 44 | * Creates an application 45 | * @constructor 46 | * @param {Function} [callback] A callback to execute when the application is initialized but not yet ready 47 | */ 48 | var Application = EventDispatcher.extend(function (callback){ 49 | EventDispatcher.apply(this, arguments); 50 | if (callback) { 51 | this._callback = callback.bind(this); 52 | } 53 | Device.init(()=>this.beforeInit().then(()=>this.init())); 54 | }, { 55 | /** 56 | * The default URL that the app will navigate to 57 | * @property initRoute 58 | * @default '/' 59 | * 60 | * @type String 61 | */ 62 | 63 | initRoute: '/', 64 | /** 65 | * The default state object to supply the initial route 66 | * @property initState 67 | * @default null 68 | * 69 | * @type {Object} 70 | */ 71 | initState: null, 72 | /** 73 | * The default params object to supply the initial route 74 | * @property initParams 75 | * @default null 76 | * 77 | * @type {Object} 78 | */ 79 | 80 | initParams: null, 81 | /** 82 | * The selector used to identify the DOM element that will contain views 83 | * @property viewRootSelector 84 | * @default #view-root 85 | * 86 | * @type {String} 87 | */ 88 | 89 | viewRootSelector: '#view-root', 90 | /** 91 | * Handler for when the user attempts to navigate to an invalid route 92 | * @method onInvalidRoute 93 | * 94 | * @param {Object} err The routing error 95 | */ 96 | onInvalidRoute(err) { 97 | // If the error is equal to "locked", it means that the router or view manager was 98 | // busy while while the user was attempting to navigate 99 | if (err !== 'locked') { 100 | alert('An error occurred while trying to display this URL.'); 101 | } 102 | }, 103 | /** 104 | * Handler for when the user taps on a element 105 | * @method onTapLink 106 | * 107 | * @param {Event} e The event object 108 | */ 109 | onTapLink(e) { 110 | var link = $(e.currentTarget), 111 | defaultPrevented = e.isDefaultPrevented(), 112 | url = link.attr('href') || link.attr('data-href'), 113 | rel = link.attr('rel'), 114 | target = link.attr('target'), 115 | isExternal = link.is('[data-external]') || _isExternal(url), 116 | metaKey = e.ctrlKey || e.metaKey; 117 | 118 | if (metaKey) { 119 | target = metaKey ? '_blank' : (target ? target : '_self'); 120 | } 121 | 122 | if (!defaultPrevented) { 123 | if (Device.isCordova() && target) { 124 | e.preventDefault(); 125 | window.open(url, target || '_blank'); 126 | } else if (isExternal || target) { 127 | window.open(url, target); 128 | return true; 129 | } else { 130 | e.preventDefault(); 131 | if (rel === 'back') { 132 | if (ViewManager.breadcrumb.length > 1) { 133 | History.back(); 134 | } else { 135 | this.router.exec('/', null, {'root': true}); 136 | } 137 | } else if (rel === 'force-back' && url) { 138 | History.isRoutingBack = true; 139 | var _always = function() { 140 | History.isRoutingBack = false; 141 | }; 142 | this.router.exec(url, null, null).then(_always, _always); 143 | } else if (rel === 'cancel') { 144 | this.viewManager.dismiss(e.currentTarget); 145 | } else if (rel === 'root' && url) { 146 | this.router.exec(url, null, {'root': true}).catch(this.onInvalidRoute); 147 | } else if (url) { 148 | url = url.replace(/^\/?#/, ''); 149 | this.router.exec(url).catch(this.onInvalidRoute); 150 | } 151 | } 152 | } 153 | }, 154 | /** 155 | * Initializes the application 156 | * @method init 157 | * 158 | * @param {Object} args Data of any type from a resolved promise returned by Application.beforeInit(). Defaults to null. 159 | * 160 | * @return {Promise} A promise that resolves when the application is ready for use 161 | */ 162 | init(args) { 163 | /** 164 | * View manager used to transition between UI states 165 | * @property viewManager 166 | * @default null 167 | * 168 | * @type {Lavaca.mvc.ViewManager} 169 | */ 170 | this.viewManager = ViewManager.setEl(this.viewRootSelector); 171 | /** 172 | * Router used to manage application traffic and URLs 173 | * @property router 174 | * @default null 175 | * 176 | * @type {Lavaca.mvc.Router} 177 | */ 178 | this.router = Router.setViewManager(this.viewManager); 179 | 180 | this.bindLinkHandler(); 181 | 182 | return Promise.resolve() 183 | .then(() => this._callback(args)) 184 | .then(() => { 185 | this.router.startHistory(); 186 | if (!this.router.hasNavigated) { 187 | if (this.initState) { 188 | History.replace(this.initState.state, this.initState.title, this.initState.url); 189 | } 190 | return this.router.exec(this.initialHashRoute || this.initRoute, this.initState, this.initParams); 191 | } 192 | }) 193 | .then(() => { 194 | this.trigger('ready'); 195 | }).catch(err=>{ 196 | console.error('Applicaiton had trouble initializing:'); 197 | console.trace(err.stack); 198 | this.router.unlock(); 199 | return this.router.exec(this.initRoute, this.initState, this.initParams); 200 | }); 201 | }, 202 | /** 203 | * Binds a global link handler 204 | * @method bindLinkHandler 205 | */ 206 | bindLinkHandler() { 207 | var $body = $(document.body), 208 | type = 'click'; 209 | if ($body.tap) { 210 | type = 'tap'; 211 | $body.on('click', 'a', _stopEvent); 212 | } 213 | $body 214 | .on(type, '.ui-blocker', _stopEvent) 215 | .on(type, 'a', this.onTapLink.bind(this)); 216 | }, 217 | /** 218 | * Gets initial route based on query string returned by server 302 redirect 219 | * @property initialStandardRoute 220 | * @default null 221 | * 222 | * @type {String} 223 | */ 224 | initialHashRoute: (function(hash){ 225 | return _matchHashRoute(hash); 226 | })(window.location.hash), 227 | /** 228 | * Handles asynchronous requests that need to happen before Application.init() is called in the constructor 229 | * @method {String} beforeInit 230 | * 231 | * @return {Promise} A promise 232 | */ 233 | beforeInit: () => Promise.resolve(null) 234 | }); 235 | 236 | export default Application; 237 | -------------------------------------------------------------------------------- /test/unit/events/EventDispatcher-spec.js: -------------------------------------------------------------------------------- 1 | 2 | // var $ = require('$'); 3 | var EventDispatcher = require('lavaca/events/EventDispatcher'); 4 | 5 | describe('An EventDispatcher', function() { 6 | var eventDispatcher; 7 | beforeEach(function() { 8 | eventDispatcher = new EventDispatcher(); 9 | }); 10 | it('can be initialized', function() { 11 | var type = typeof eventDispatcher; 12 | expect(type).to.equal(typeof new EventDispatcher()); 13 | }); 14 | describe('can bind', function() { 15 | it('an event handler', function(done) { 16 | eventDispatcher.on('test', done); 17 | eventDispatcher.trigger('test'); 18 | }); 19 | it('an event handler with a namespace', function(done) { 20 | eventDispatcher.on('test.ns', function() { 21 | done(); 22 | }); 23 | eventDispatcher.trigger('test'); 24 | }); 25 | it('an event handler with an event triggered', function(done) { 26 | eventDispatcher.on('test', function(e) { 27 | expect(e.type).to.equal('click'); 28 | done(); 29 | }); 30 | eventDispatcher.trigger('test', { type: 'click'}); 31 | }); 32 | }); 33 | describe('can unbind', function() { 34 | var handler, called; 35 | beforeEach(function() { 36 | called = []; 37 | handler = function() { 38 | called.push('test - specific handler'); 39 | }; 40 | eventDispatcher.on('test', handler); 41 | eventDispatcher.on('test', function() { 42 | called.push('test'); 43 | }); 44 | eventDispatcher.on('test.namespace', function() { 45 | called.push('test.namespace'); 46 | }); 47 | eventDispatcher.on('test.namespace2', function() { 48 | called.push('test.namespace2'); 49 | }); 50 | eventDispatcher.on('test.namespace.namespace2', function() { 51 | called.push('test.namespace.namespace2'); 52 | }); 53 | eventDispatcher.on('test2', function() { 54 | called.push('test2'); 55 | }); 56 | eventDispatcher.on('test2.namespace', function() { 57 | called.push('test2.namespace'); 58 | }); 59 | eventDispatcher.on('test2.namespace2', function() { 60 | called.push('test2.namespace2'); 61 | }); 62 | eventDispatcher.on('test2.namespace.namespace2', function() { 63 | called.push('test2.namespace.namespace2'); 64 | }); 65 | }); 66 | it('all event handlers', function(done) { 67 | eventDispatcher.off(); 68 | 69 | eventDispatcher.trigger('test'); 70 | eventDispatcher.trigger('test2'); 71 | 72 | setTimeout(function() { 73 | expect(called).not.to.contain('test - specific handler'); 74 | expect(called).not.to.contain('test'); 75 | expect(called).not.to.contain('test.namespace'); 76 | expect(called).not.to.contain('test.namespace2'); 77 | expect(called).not.to.contain('test.namespace.namespace2'); 78 | expect(called).not.to.contain('test2'); 79 | expect(called).not.to.contain('test2.namespace'); 80 | expect(called).not.to.contain('test2.namespace2'); 81 | expect(called).not.to.contain('test2.namespace.namespace2'); 82 | done(); 83 | }, 0); 84 | 85 | setTimeout(done, 0); 86 | }); 87 | it('all event handlers for an event', function(done) { 88 | eventDispatcher.off('test'); 89 | 90 | eventDispatcher.trigger('test'); 91 | eventDispatcher.trigger('test2'); 92 | 93 | setTimeout(function() { 94 | expect(called).not.to.contain('test - specific handler'); 95 | expect(called).not.to.contain('test'); 96 | expect(called).not.to.contain('test.namespace'); 97 | expect(called).not.to.contain('test.namespace2'); 98 | expect(called).not.to.contain('test.namespace.namespace2'); 99 | expect(called).to.contain('test2'); 100 | expect(called).to.contain('test2.namespace'); 101 | expect(called).to.contain('test2.namespace2'); 102 | expect(called).to.contain('test2.namespace.namespace2'); 103 | done(); 104 | }, 0); 105 | }); 106 | it('all event handlers for a namespace', function(done) { 107 | eventDispatcher.off('.namespace'); 108 | 109 | eventDispatcher.trigger('test'); 110 | eventDispatcher.trigger('test2'); 111 | 112 | setTimeout(function() { 113 | expect(called).to.contain('test - specific handler'); 114 | expect(called).to.contain('test'); 115 | expect(called).not.to.contain('test.namespace'); 116 | expect(called).to.contain('test.namespace2'); 117 | expect(called).not.to.contain('test.namespace.namespace2'); 118 | expect(called).to.contain('test2'); 119 | expect(called).not.to.contain('test2.namespace'); 120 | expect(called).to.contain('test2.namespace2'); 121 | expect(called).not.to.contain('test2.namespace.namespace2'); 122 | done(); 123 | }, 0); 124 | }); 125 | it('all event handlers for an event and a namespace', function(done) { 126 | eventDispatcher.off('test.namespace'); 127 | 128 | eventDispatcher.trigger('test'); 129 | eventDispatcher.trigger('test2'); 130 | 131 | setTimeout(function() { 132 | expect(called).to.contain('test - specific handler'); 133 | expect(called).to.contain('test'); 134 | expect(called).not.to.contain('test.namespace'); 135 | expect(called).to.contain('test.namespace2'); 136 | expect(called).not.to.contain('test.namespace.namespace2'); 137 | expect(called).to.contain('test2'); 138 | expect(called).to.contain('test2.namespace'); 139 | expect(called).to.contain('test2.namespace2'); 140 | expect(called).to.contain('test2.namespace.namespace2'); 141 | done(); 142 | }, 0); 143 | }); 144 | it('a specific event handler', function(done) { 145 | eventDispatcher.off('test', handler); 146 | 147 | eventDispatcher.trigger('test'); 148 | eventDispatcher.trigger('test2'); 149 | 150 | setTimeout(function() { 151 | expect(called).not.to.contain('test - specific handler'); 152 | expect(called).to.contain('test'); 153 | expect(called).to.contain('test.namespace'); 154 | expect(called).to.contain('test.namespace2'); 155 | expect(called).to.contain('test.namespace.namespace2'); 156 | expect(called).to.contain('test2'); 157 | expect(called).to.contain('test2.namespace'); 158 | expect(called).to.contain('test2.namespace2'); 159 | expect(called).to.contain('test2.namespace.namespace2'); 160 | done(); 161 | }, 0); 162 | }); 163 | it('a nonexistant event', function(done) { 164 | eventDispatcher.off('test100'); 165 | 166 | eventDispatcher.trigger('test'); 167 | eventDispatcher.trigger('test2'); 168 | 169 | setTimeout(function() { 170 | expect(called).to.contain('test - specific handler'); 171 | expect(called).to.contain('test'); 172 | expect(called).to.contain('test.namespace'); 173 | expect(called).to.contain('test.namespace2'); 174 | expect(called).to.contain('test.namespace.namespace2'); 175 | expect(called).to.contain('test2'); 176 | expect(called).to.contain('test2.namespace'); 177 | expect(called).to.contain('test2.namespace2'); 178 | expect(called).to.contain('test2.namespace.namespace2'); 179 | done(); 180 | }, 0); 181 | }); 182 | it('a nonexistant namespace', function(done) { 183 | eventDispatcher.off('.namespace100'); 184 | 185 | eventDispatcher.trigger('test'); 186 | eventDispatcher.trigger('test2'); 187 | 188 | setTimeout(function() { 189 | expect(called).to.contain('test - specific handler'); 190 | expect(called).to.contain('test'); 191 | expect(called).to.contain('test.namespace'); 192 | expect(called).to.contain('test.namespace2'); 193 | expect(called).to.contain('test.namespace.namespace2'); 194 | expect(called).to.contain('test2'); 195 | expect(called).to.contain('test2.namespace'); 196 | expect(called).to.contain('test2.namespace2'); 197 | expect(called).to.contain('test2.namespace.namespace2'); 198 | done(); 199 | }, 0); 200 | }); 201 | it('a nonexistant event and namespace', function(done) { 202 | eventDispatcher.off('test100.namespace100'); 203 | 204 | eventDispatcher.trigger('test'); 205 | eventDispatcher.trigger('test2'); 206 | 207 | setTimeout(function() { 208 | expect(called).to.contain('test - specific handler'); 209 | expect(called).to.contain('test'); 210 | expect(called).to.contain('test.namespace'); 211 | expect(called).to.contain('test.namespace2'); 212 | expect(called).to.contain('test.namespace.namespace2'); 213 | expect(called).to.contain('test2'); 214 | expect(called).to.contain('test2.namespace'); 215 | expect(called).to.contain('test2.namespace2'); 216 | expect(called).to.contain('test2.namespace.namespace2'); 217 | done(); 218 | }, 0); 219 | }); 220 | it('a nonexistant specific event handler', function(done) { 221 | eventDispatcher.off('test', undefined); 222 | 223 | eventDispatcher.trigger('test'); 224 | eventDispatcher.trigger('test2'); 225 | 226 | setTimeout(function() { 227 | expect(called).to.contain('test - specific handler'); 228 | expect(called).to.contain('test'); 229 | expect(called).to.contain('test.namespace'); 230 | expect(called).to.contain('test.namespace2'); 231 | expect(called).to.contain('test.namespace.namespace2'); 232 | expect(called).to.contain('test2'); 233 | expect(called).to.contain('test2.namespace'); 234 | expect(called).to.contain('test2.namespace2'); 235 | expect(called).to.contain('test2.namespace.namespace2'); 236 | done(); 237 | }, 0); 238 | }); 239 | }); 240 | }); 241 | -------------------------------------------------------------------------------- /src/mvc/Router.js: -------------------------------------------------------------------------------- 1 | import {default as Route} from './Route'; 2 | import {default as History} from '../net/History'; 3 | import {default as Disposable} from '../util/Disposable'; 4 | /** 5 | * @class lavaca.mvc.Router 6 | * @extends lavaca.util.Disposable 7 | * URL manager 8 | * 9 | * @constructor 10 | * @param {Lavaca.mvc.ViewManager} viewManager The view manager 11 | */ 12 | var Router = Disposable.extend(function(viewManager){ 13 | Disposable.call(this); 14 | /** 15 | * @field {Array} routes 16 | * @default [] 17 | * The [[Lavaca.mvc.Route]]s used by this router 18 | */ 19 | this.routes = []; 20 | /** 21 | * @field {Lavaca.mvc.ViewManager} viewManager 22 | * @default null 23 | * The view manager used by this router 24 | */ 25 | this.viewManager = viewManager; 26 | 27 | }, { 28 | /** 29 | * @field {Boolean} locked 30 | * @default false 31 | * When true, the router is prevented from executing a route 32 | */ 33 | locked: false, 34 | /** 35 | * @field {Boolean} hasNavigated 36 | * @default false 37 | * Whether or not this router has been used to navigate 38 | */ 39 | hasNavigated: false, 40 | /** 41 | * @field {Boolean} runAuthenticationCheck 42 | * @default false 43 | * When true, this runs the defined authentication function 44 | * set in this.setAuth() before executing a route. 45 | */ 46 | runAuthenticationCheck: false, 47 | 48 | startHistory() { 49 | this.onpopstate = (e) => { 50 | if (this.hasNavigated) { 51 | History.isRoutingBack = e.direction === 'back'; 52 | var _always = () => { 53 | History.isRoutingBack = false; 54 | }; 55 | this.exec(e.url, e).then(_always, _always); 56 | } 57 | }; 58 | History.on('popstate', this.onpopstate, this); 59 | }, 60 | /** 61 | * Sets the viewManager property on the instance which is the view manager used by this router 62 | * @method setEl 63 | * 64 | * @param {Lavaca.mvc.ViewManager} viewManager 65 | * @return {Lavaca.mvc.Router} This Router instance 66 | */ 67 | setViewManager(viewManager) { 68 | this.viewManager = viewManager; 69 | return this; 70 | }, 71 | /** 72 | * Adds multiple routes 73 | * @method add 74 | * 75 | * @param {Object} map A hash in the form {pattern: [TController, action, params]} 76 | * or {pattern: {controller: TController, action: action, params: params} 77 | * @return {Lavaca.mvc.Router} The router (for chaining) 78 | */ 79 | /** 80 | * Adds a route 81 | * @method add 82 | * 83 | * @param {String} pattern The route URL pattern 84 | * @param {Function} TController The type of controller to perform the action (should derive from [[Lavaca.mvc.Controller]]) 85 | * @param {String} action The name of the controller method to call 86 | * @return {Lavaca.mvc.Router} The router (for chaining) 87 | */ 88 | /** 89 | * Adds a route 90 | * @method add 91 | * 92 | * @param {String} pattern The route URL pattern 93 | * @param {Function} TController The type of controller to perform the action (should derive from [[Lavaca.mvc.Controller]]) 94 | * @param {String} action The name of the controller method to call 95 | * @param {Object} params Key-value pairs that will be passed to the action 96 | * @return {Lavaca.mvc.Router} The router (for chaining) 97 | */ 98 | add(pattern, TController, action, params) { 99 | if (typeof pattern === 'object') { 100 | for (var p in pattern) { 101 | var args = pattern[p]; 102 | if (args instanceof Array) { 103 | TController = args[0]; 104 | action = args[1]; 105 | params = args[2]; 106 | } else { 107 | TController = args.controller; 108 | action = args.action; 109 | params = args.params; 110 | } 111 | this.add(p, TController, action, params); 112 | } 113 | } else { 114 | this.routes.push(new Route(pattern, TController, action, params)); 115 | } 116 | return this; 117 | }, 118 | /** 119 | * Executes the action for a given URL 120 | * @method exec 121 | * 122 | * @param {String} url The URL 123 | * @return {Promise} A promise 124 | */ 125 | /** 126 | * Executes the action for a given URL 127 | * @method exec 128 | * 129 | * @param {String} url The URL 130 | * @param {Object} params Additional parameters to pass to the route 131 | * @return {Promise} A promise 132 | */ 133 | /** 134 | * Executes the action for a given URL 135 | * @method exec 136 | * 137 | * @param {String} url The URL 138 | * @param {Object} state A history record object 139 | * @param {Object} params Additional parameters to pass to the route 140 | * @return {Promise} A promise 141 | */ 142 | exec(url, state, params) { 143 | if (this.locked) { 144 | return Promise.reject('locked'); 145 | } else { 146 | this.locked = true; 147 | } 148 | 149 | if (!url) { 150 | url = '/'; 151 | } 152 | 153 | //remove trailing slash 154 | if (url.length > 1 && 155 | url.substr(0,1) === '/' && 156 | url.substr(-1) === '/') { 157 | url = url.substring(0,(url.length-1)); 158 | } 159 | 160 | if (url.indexOf('http') === 0) { 161 | url = url.replace(/^http(s?):\/\/.+?/, ''); 162 | } 163 | var i = -1, 164 | route; 165 | 166 | while (!!(route = this.routes[++i])) { 167 | if (route.matches(url)) { 168 | break; 169 | } 170 | } 171 | 172 | 173 | var checkAuth = params && params.auth && typeof params.auth.runAuthenticationCheck === 'boolean' ? params.auth.runAuthenticationCheck : this.runAuthenticationCheck, 174 | func = params && params.auth && typeof params.auth.func === 'function' ? params.auth.func : this.authenticate, 175 | failUrl = params && params.auth && typeof params.auth.failRoute === 'string' ? params.auth.failRoute : this.failRoute, 176 | ignoreAuth = route && route.params && route.params.ignoreAuth ? route.params.ignoreAuth : false; 177 | if(route && route.params && typeof route.params.func === 'function'){ 178 | func = route.params.func; 179 | } 180 | if(checkAuth && failUrl !== url && !ignoreAuth){ 181 | return func().then( 182 | () => _executeIfRouteExists.call(this, url, state, params).catch(url=>_rejection.call(this,url)), 183 | () => _executeIfRouteExists.call(this, failUrl, state, params)).catch(url=>_rejection.call(this,url)); 184 | } 185 | else{ 186 | return _executeIfRouteExists.call(this, url, state, params).catch(url=>_rejection.call(this,url)); 187 | } 188 | 189 | }, 190 | /** 191 | * Unlocks the router so that it can be used again 192 | * @method unlock 193 | * 194 | * @return {Lavaca.mvc.Router} This router (for chaining) 195 | */ 196 | unlock() { 197 | this.locked = false; 198 | return this; 199 | }, 200 | /** 201 | * Creates authentication check for routes 202 | * @method setAuth 203 | * 204 | * @param {Function} func A function to run for specific authentication. Must return a Promise. 205 | * @param {String} failRoute The route to execute if authentication fails. 206 | */ 207 | /** 208 | * Creates authentication check for routes 209 | * @method setAuth 210 | * 211 | * @param {Function} func A function to run for specific authentication. Must return a Promise. 212 | * @param {String} failRoute The route to execute if authentication fails. 213 | * @param {Boolean} checkAuthForEveryRoute Sets the default behavior of whether to run authentication check for each route. If no value is passed, it defaults to true. 214 | */ 215 | setAuth(func, failRoute, checkAuthForEveryRoute) { 216 | if(typeof func === 'function' && typeof failRoute === 'string'){ 217 | this.runAuthenticationCheck = typeof checkAuthForEveryRoute === 'boolean' ? checkAuthForEveryRoute : true; 218 | this.authenticate = func; 219 | this.failRoute = failRoute; 220 | } 221 | else{ 222 | console.warn('You must pass Router.setAuth() a function and a route to execute if authentication fails'); 223 | } 224 | }, 225 | /** 226 | * Readies the router for garbage collection 227 | * @method dispose 228 | */ 229 | dispose() { 230 | if (this.onpopstate) { 231 | History.off('popstate', this.onpopstate); 232 | this.onpopstate = null; 233 | } 234 | Disposable.prototype.dispose.apply(this, arguments); 235 | } 236 | }); 237 | 238 | /** 239 | * Checks if route exists and executes that route 240 | * @method exec 241 | * 242 | * @param {String} url The URL 243 | * @return {Promise} A promise 244 | */ 245 | /** 246 | * Checks if route exists and executes that route 247 | * @method exec 248 | * 249 | * @param {String} url The URL 250 | * @param {Object} state A history record object 251 | * @return {Promise} A promise 252 | */ 253 | /** 254 | * Checks if route exists and executes that route 255 | * @method exec 256 | * 257 | * @param {String} url The URL 258 | * @param {Object} state A history record object 259 | * @param {Object} params Additional parameters to pass to the route 260 | * @return {Promise} A promise 261 | */ 262 | let _executeIfRouteExists = function(url, state, params) { 263 | var i = -1, 264 | route; 265 | 266 | while (!!(route = this.routes[++i])) { 267 | if (route.matches(url)) { 268 | break; 269 | } 270 | } 271 | 272 | if (!route) { 273 | return Promise.reject(url); 274 | } 275 | return Promise.resolve() 276 | .then(() => route.exec(url, this, this.viewManager, state, params)) 277 | .then(() => this.hasNavigated = true) 278 | .then(() => this.unlock()) 279 | .catch((err) => { 280 | this.unlock(); 281 | throw err; 282 | }); 283 | } 284 | function _rejection(url){ 285 | !url && this.exec('/'); 286 | console.error('Unable to find a route' + (url ? ' for ' + url:'.')); 287 | }; 288 | 289 | let singletonRouter = new Router(); 290 | export default singletonRouter; -------------------------------------------------------------------------------- /src/fx/Animation.js: -------------------------------------------------------------------------------- 1 | import $ from 'jquery'; 2 | import {default as Transform} from './Transform'; 3 | 4 | var Animation = {}; 5 | 6 | const _props = { 7 | animation: ['animation', 'animationend', 'keyframes'], 8 | webkitAnimation: ['-webkit-animation', 'webkitAnimationEnd', '-webkit-keyframes'], 9 | MozAnimation: ['-moz-animation', 'animationend', '-moz-keyframes'] 10 | }; 11 | let _prop, 12 | _cssProp, 13 | _declaration, 14 | _event; 15 | 16 | (() => { 17 | let style = document.createElement('div').style, 18 | s, 19 | opts; 20 | for (s in _props) { 21 | if (s in style) { 22 | opts = _props[s]; 23 | _prop = s; 24 | _cssProp = opts[0]; 25 | _event = opts[1]; 26 | _declaration = opts[2]; 27 | break; 28 | } 29 | } 30 | })(); 31 | 32 | /** 33 | * Static utility type for working with CSS keyframe animations 34 | * @class lavaca.fx.Animation 35 | */ 36 | 37 | /** 38 | * Whether or not animations are supported by the browser 39 | * @method isSupported 40 | * @static 41 | * 42 | * @return {Boolean} True if CSS keyframe animations are supported 43 | */ 44 | Animation.isSupported = () => { 45 | return !!_prop; 46 | }; 47 | 48 | /** 49 | * Gets the name of the animation end event 50 | * @method animationEndEvent 51 | * @static 52 | * 53 | * @return {String} The name of the event 54 | */ 55 | Animation.animationEndEvent = () => { 56 | return _event; 57 | }; 58 | 59 | /** 60 | * Converts a list of keyframes to a CSS animation 61 | * @method keyframesToCSS 62 | * @static 63 | * 64 | * @param {String} name The name of the keyframe animation 65 | * @param {Object} keyframes A list of timestamped keyframes in the form {'0%': {color: 'red'}, '100%': 'color: blue'} 66 | * @return {String} The CSS keyframe animation declaration 67 | */ 68 | Animation.keyframesToCSS = (name, keyframes) => { 69 | var css = ['@', _declaration, ' ', name, '{'], 70 | time, 71 | keyframe, 72 | prop, 73 | value; 74 | for (time in keyframes) { 75 | css.push(time, '{'); 76 | keyframe = keyframes[time]; 77 | if (typeof keyframe === 'string') { 78 | css.push(keyframe); 79 | } else { 80 | for (prop in keyframe) { 81 | value = keyframe[prop]; 82 | if (prop === 'transform' && Transform) { 83 | prop = Transform.cssProperty(); 84 | value = Transform.toCSS(value); 85 | } 86 | css.push(prop, ':', value, ';'); 87 | } 88 | } 89 | css.push('}'); 90 | } 91 | css.push('}'); 92 | return css.join(''); 93 | }; 94 | 95 | /** 96 | * Generates a keyframe animation 97 | * @method generateKeyframes 98 | * @static 99 | * 100 | * @param {Object} keyframes A list of timestamped keyframes in the form {'0%': {color: 'red'}, '100%': 'color: blue'} 101 | * @return {String} The name fo the animation 102 | */ 103 | /** 104 | * Generates a keyframe animation 105 | * @method generateKeyframes 106 | * @static 107 | * @param {String} name The name of the animation 108 | * @param {Object} keyframes A list of timestamped keyframes in the form {'0%': {color: 'red'}, '100%': 'color: blue'} 109 | * @return {String} The name fo the animation 110 | */ 111 | Animation.generateKeyframes = (name, keyframes) => { 112 | if (typeof name === 'object') { 113 | keyframes = name; 114 | name = 'a' + new Date().getTime(); 115 | } 116 | var css = Animation.keyframesToCSS(name, keyframes); 117 | $('').appendTo('head'); 118 | return name; 119 | }; 120 | 121 | /** 122 | * Gets the name of the animation CSS property 123 | * @method cssProperty 124 | * @static 125 | * 126 | * @return {String} The name of the CSS property 127 | */ 128 | Animation.cssProperty = () => { 129 | return _cssProp; 130 | }; 131 | 132 | /** 133 | * Applies a keyframe animation to an element 134 | * @method $.fn.keyframe 135 | * 136 | * @param {String} name The name of the animation 137 | * @param {Object} options Options for the animation 138 | * @opt {Number} duration The number of milliseconds that the animation lasts 139 | * @opt {String} easing The name of a CSS easing function 140 | * @default 'linear' 141 | * @opt {Number} delay The number of milliseconds before the animation should start 142 | * @default 0 143 | * @opt {Object} iterations Either the number of iterations to play the animation or 'infinite' 144 | * @default 1 145 | * @opt {String} direction The name of a CSS animation direction 146 | * @default 'normal' 147 | * @opt {String} fillMode The CSS animation fill mode (none, forwards, backwards, both) 148 | * @default '' 149 | * @opt {Function} complete A function to execute when the animation has completed 150 | * @default null 151 | * @return {jQuery} The jQuery object, for chaining 152 | */ 153 | /** 154 | * Applies a keyframe animation to an element 155 | * @method $.fn.keyframe 156 | * 157 | * @param {Object} keyframes A list of timestamped keyframes in the form {'0%': {color: 'red'}, '100%': 'color: blue'} 158 | * @param {Object} options Options for the animation 159 | * @opt {String} name The name of the animation 160 | * @opt {Number} duration The number of milliseconds that the animation lasts 161 | * @opt {String} easing The name of a CSS easing function 162 | * @default 'linear' 163 | * @opt {Number} delay The number of milliseconds before the animation should start 164 | * @default 0 165 | * @opt {Object} iterations Either the number of iterations to play the animation or 'infinite' 166 | * @default 1 167 | * @opt {String} direction The name of a CSS animation direction 168 | * @default 'normal' 169 | * @opt {String} fillMode The CSS animation fill mode (none, forwards, backwards, both) 170 | * @default '' 171 | * @opt {Function} complete A function to execute when the animation has completed 172 | * @default null 173 | * @return {jQuery} The jQuery object, for chaining 174 | * 175 | */ 176 | /** 177 | * Applies a keyframe animation to an element 178 | * @method $.fn.keyframe 179 | * 180 | * @param {String} name The name of the animation 181 | * @param {Number} duration The number of milliseconds that the animation lasts 182 | * @param {String} easing The name of a CSS easing function 183 | * @param {Number} delay The number of milliseconds before the animation should start 184 | * @param {Object} iterations Either the number of iterations to play the animation or 'infinite' 185 | * @param {String} direction The name of a CSS animation direction 186 | * @param {Function} callback A function to execute when the animation has completed 187 | * @return {jQuery} The jQuery object, for chaining 188 | * 189 | */ 190 | /** 191 | * Applies a keyframe animation to an element 192 | * @method $.fn.keyframe 193 | * 194 | * @param {Object} keyframes A list of timestamped keyframes in the form {'0%': {color: 'red'}, '100%': 'color: blue'} 195 | * @param {Number} duration The number of milliseconds that the animation lasts 196 | * @param {String} easing The name of a CSS easing function 197 | * @param {Number} delay The number of milliseconds before the animation should start 198 | * @param {Object} iterations Either the number of iterations to play the animation or 'infinite' 199 | * @param {String} direction The name of a CSS animation direction 200 | * @param {Function} callback A function to execute when the animation has completed 201 | * @return {jQuery} The jQuery object, for chaining 202 | */ 203 | $.fn.keyframe = function(name, duration, easing, delay, iterations, direction, callback) { 204 | if (Animation.isSupported()) { 205 | var fillMode; 206 | if (typeof name === 'object') { 207 | if (typeof duration === 'object' && typeof duration.name === 'string') { 208 | name = Animation.generateKeyframes(duration.name, name); 209 | } else { 210 | name = Animation.generateKeyframes(name); 211 | } 212 | } 213 | if (typeof duration === 'object') { 214 | callback = duration.complete; 215 | direction = duration.direction; 216 | iterations = duration.iterations; 217 | delay = duration.delay; 218 | easing = duration.easing; 219 | fillMode = duration.fillMode; 220 | duration = duration.duration; 221 | } 222 | direction = direction || 'normal'; 223 | iterations = iterations || 1; 224 | delay = delay || 0; 225 | easing = easing || 'linear'; 226 | duration = duration || 1; 227 | fillMode = fillMode || ''; 228 | if (typeof duration === 'number') { 229 | duration += 'ms'; 230 | } 231 | if (typeof delay === 'number') { 232 | delay += 'ms'; 233 | } 234 | if (callback) { 235 | this.nextAnimationEnd(callback); 236 | } 237 | this.css(Animation.cssProperty(), [name, duration, easing, delay, iterations, direction, fillMode].join(' ')); 238 | } 239 | return this; 240 | }; 241 | 242 | /** 243 | * Binds an animation end handler to an element. 244 | * @method $.fn.animationEnd 245 | * 246 | * @param {Function} callback Callback for when the animation ends 247 | * @return {jQuery} This jQuery object, for chaining 248 | * 249 | /** 250 | * Binds an animation end handler to an element. 251 | * @method $.fn.animationEnd 252 | * 253 | * @param {String} delegate Selector for the descendant elements to which the handler will be bound 254 | * @param {Function} callback Callback for when the animation ends 255 | * @return {jQuery} This jQuery object, for chaining 256 | */ 257 | $.fn.animationEnd = function(delegate, callback) { 258 | if (_event) { 259 | return this.on(_event, delegate, callback); 260 | } else { 261 | return this; 262 | } 263 | }; 264 | 265 | /** 266 | * Binds an animation end handler to an element's next animation end event 267 | * @method $.fn.nextAnimationEnd 268 | * 269 | * @param {Function} callback Callback for when the animation ends 270 | * @return {jQuery} This jQuery object, for chaining 271 | */ 272 | /** 273 | * Binds an animation end handler to an element's next animation end event 274 | * @method $.fn.nextAnimationEnd 275 | * 276 | * @param {String} delegate Selector for the descendant elements to which the handler will be bound 277 | * @param {Function} callback Callback for when the animation ends 278 | * @return {jQuery} This jQuery object, for chaining 279 | */ 280 | $.fn.nextAnimationEnd = function(delegate, callback) { 281 | if (_event) { 282 | return this.one(_event, delegate, callback); 283 | } else { 284 | return this; 285 | } 286 | }; 287 | 288 | export default Animation; -------------------------------------------------------------------------------- /test/unit/mvc/Model-spec.js: -------------------------------------------------------------------------------- 1 | import Model from 'lavaca/mvc/Model'; 2 | 3 | describe('A Model', function() { 4 | var testModel; 5 | beforeEach(function() { 6 | testModel = new Model(); 7 | }); 8 | afterEach(function() { 9 | testModel.clear(); 10 | }); 11 | it('can be initialized', function() { 12 | testModel = new Model(); 13 | var type = typeof testModel; 14 | expect(type).toEqual(typeof new Model()); 15 | }); 16 | it('can be initialized with a hash of attributes', function() { 17 | testModel = new Model({ 18 | myAttribute : true, 19 | myNumber: 12, 20 | myString: "Hello, World!" 21 | }); 22 | expect(testModel.get('myAttribute')).toEqual(true); 23 | expect(testModel.get('myNumber')).toEqual(12); 24 | expect(testModel.get('myString')).toEqual('Hello, World!'); 25 | }); 26 | it('should be disposable', function() { 27 | expect(typeof testModel.dispose === 'function').toEqual(true); 28 | }); 29 | it('should remove all attributes on clear', function() { 30 | testModel.set('myAttribute', true); 31 | testModel.set('email', 'test@lavaca.com', Model.SENSITIVE); 32 | testModel.clear(); 33 | expect(testModel.get('myAttribute')).toBeNull(); 34 | }); 35 | it('should remove only flaged attributes on clear when a flag is specified', function() { 36 | testModel.set('myAttribute', true); 37 | testModel.set('email', 'test@lavaca.com', Model.SENSITIVE); 38 | testModel.clear(Model.SENSITIVE); 39 | expect(testModel.get('myAttribute')).toEqual(true); 40 | expect(testModel.get('email')).toBeNull(); 41 | }); 42 | 43 | describe('Saving and IDs', function() { 44 | it('should not have an ID if it has not been saved', function() { 45 | expect(testModel.get('id')).toBeNull(); 46 | expect(testModel.id()).toBeNull(); 47 | }); 48 | it('should be marked as new if it does not have an id', function() { 49 | expect(testModel.isNew()).toBe(true); 50 | }); 51 | it('should give back the right ID when idAttribute is assigned', function() { 52 | var myModelType = Model.extend({ 53 | idAttribute : 'myId' 54 | }), 55 | myModel = new myModelType({ 56 | myId : 'Hello, World!', 57 | id : 'This is not my ID' 58 | }); 59 | expect(myModel.id()).toEqual('Hello, World!'); 60 | }); 61 | }); 62 | 63 | describe('cloning and converting', function() { 64 | var myModel; 65 | beforeEach(function() { 66 | myModel = new Model({ 67 | foo: 'bar', 68 | bar: { 69 | foo : 'baz', 70 | bar : 'foo' 71 | } 72 | }); 73 | }); 74 | it('should create an exact copy on clone', function() { 75 | var myModelClone = myModel.clone(); 76 | 77 | expect(myModelClone.get('foo')).toEqual('bar'); 78 | expect(myModelClone.get('bar')).toEqual({ 79 | foo: 'baz', 80 | bar : 'foo' 81 | }); 82 | }); 83 | it('should create an equal object on toObject', function() { 84 | var myModelObject = myModel.toObject(); 85 | expect(myModelObject).toEqual({ 86 | foo: 'bar', 87 | bar : { 88 | foo : 'baz', 89 | bar : 'foo' 90 | } 91 | }); 92 | }); 93 | it('should create an equal JSON object on toJSON', function() { 94 | var myModelJSON = myModel.toJSON(); 95 | expect(myModelJSON).toEqual('{"foo":"bar","bar":{"foo":"baz","bar":"foo"}}'); 96 | }); 97 | }); 98 | 99 | describe('Events', function() { 100 | it('can fire an onChange event', function() { 101 | var noop = { 102 | changeModel: function() {} 103 | }; 104 | 105 | spyOn(noop, 'changeModel'); 106 | 107 | runs(function() { 108 | testModel.on('change', noop.changeModel); 109 | testModel.set('someAttribute', 'someValue'); 110 | }); 111 | 112 | waitsFor(function() { 113 | return testModel.get('someAttribute') === 'someValue'; 114 | }); 115 | 116 | runs(function() { 117 | expect(noop.changeModel).toHaveBeenCalled(); 118 | }); 119 | }); 120 | it('can fire a scoped onChange event', function() { 121 | var noop = { 122 | changeModel: function() {} 123 | }; 124 | 125 | spyOn(noop, 'changeModel'); 126 | 127 | runs(function() { 128 | testModel.on('change', 'someAttribute', noop.changeModel); 129 | testModel.set('someAttribute', 'someValue'); 130 | }); 131 | 132 | waitsFor(function() { 133 | return testModel.get('someAttribute') === 'someValue'; 134 | }); 135 | 136 | runs(function() { 137 | expect(noop.changeModel).toHaveBeenCalled(); 138 | }); 139 | }); 140 | it('should not fire a change event when suppressed is set on the apply() method', function() { 141 | var noop = { 142 | changeModel: function() {} 143 | }; 144 | 145 | spyOn(noop, 'changeModel'); 146 | 147 | runs(function() { 148 | testModel.on('change', noop.changeModel); 149 | testModel.apply({'someAttribute': 'someValue'}, true); 150 | }); 151 | 152 | waitsFor(function() { 153 | return testModel.get('someAttribute') === 'someValue'; 154 | }); 155 | 156 | runs(function() { 157 | expect(noop.changeModel).not.toHaveBeenCalled(); 158 | }); 159 | }); 160 | it('should not add unsaved attributes when suppressTracking is true', function() { 161 | testModel.suppressTracking = true; 162 | runs(function() { 163 | testModel.set('someAttribute', 'someValue'); 164 | }); 165 | 166 | waitsFor(function() { 167 | return testModel.get('someAttribute') === 'someValue'; 168 | }); 169 | 170 | runs(function() { 171 | expect(testModel.unsavedAttributes.length).toEqual(0); 172 | }); 173 | }); 174 | }); 175 | 176 | describe('Validation', function() { 177 | var noop = { 178 | func: function() {} 179 | }; 180 | beforeEach(function() { 181 | spyOn(noop, 'func'); 182 | testModel.addRule('phone', function(attribute, value) { 183 | return (/\d+/).test(value); 184 | }, 'Phone must contain only numbers'); 185 | }); 186 | it('should apply a rule given to addRule()', function() { 187 | expect(testModel.rules.get('phone').length).toBe(1); 188 | }); 189 | it('should refuse a set() call if it fails a rule', function() { 190 | testModel.on('invalid', 'phone', noop.func); 191 | testModel.set('phone', 'fdsaf'); 192 | expect(testModel.get('phone')).toBeNull(); 193 | expect(noop.func).toHaveBeenCalled(); 194 | }); 195 | it('should allow a set() call if it passes all rules', function() { 196 | testModel.on('invalid', 'phone', noop.func); 197 | testModel.set('phone', '5121234567'); 198 | expect(testModel.get('phone')).toEqual('5121234567'); 199 | expect(noop.func).not.toHaveBeenCalled(); 200 | }); 201 | it('should refuse to set only the attribute that failed when using calling apply()', function() { 202 | testModel.on('invalid', 'phone', noop.func); 203 | testModel.apply({ 204 | 'phone': 'fdsaf', 205 | 'someAttr': 'value' 206 | }); 207 | expect(testModel.get('phone')).toBeNull(); 208 | expect(noop.func).toHaveBeenCalled(); 209 | }); 210 | it('should not validate if suppress flag is sent to apply', function() { 211 | testModel.on('invalid', 'phone', noop.func); 212 | testModel.apply({ 213 | 'phone': 'fdsaf', 214 | 'someAttr': 'value' 215 | }, true); 216 | expect(testModel.get('phone')).toBe('fdsaf'); 217 | expect(noop.func).not.toHaveBeenCalled(); 218 | }); 219 | it('should not validate if suppress flag is sent to set', function() { 220 | testModel.on('invalid', 'phone', noop.func); 221 | testModel.set('phone', 'fdsaf', null, true); 222 | expect(testModel.get('phone')).toBe('fdsaf'); 223 | expect(noop.func).not.toHaveBeenCalled(); 224 | }); 225 | it('should not validate on apply() if suppressValidation is true', function() { 226 | testModel.suppressValidation = true; 227 | testModel.on('invalid', 'phone', noop.func); 228 | testModel.apply({ 229 | 'phone': 'fdsaf', 230 | 'someAttr': 'value' 231 | }); 232 | expect(testModel.get('phone')).toBe('fdsaf'); 233 | expect(noop.func).not.toHaveBeenCalled(); 234 | }); 235 | it('should not validate on set() if suppressValidation is true', function() { 236 | testModel.suppressValidation = true; 237 | testModel.on('invalid', 'phone', noop.func); 238 | testModel.set('phone', 'fdsaf'); 239 | expect(testModel.get('phone')).toBe('fdsaf'); 240 | expect(noop.func).not.toHaveBeenCalled(); 241 | }); 242 | }); 243 | 244 | describe('Attributes', function() { 245 | it('should return true from has() if the model has that attribute', function() { 246 | var myModel = new Model({ 247 | foo: 'bar' 248 | }); 249 | expect(myModel.has('foo')).toBe(true); 250 | }); 251 | 252 | it('should return false from has() if the model does not have that attribute', function() { 253 | var myModel = new Model(); 254 | expect(myModel.has('foo')).toBe(false); 255 | }); 256 | 257 | it('should merge default attributes with the new attributes upon instantialization', function() { 258 | var ModelType = Model.extend({ 259 | defaults: { 260 | boo: false, 261 | foo: 'bar', 262 | holy: 'moly', 263 | hello: 'hola' 264 | } 265 | }); 266 | var myEmptyModel = new ModelType(); 267 | var myModel = new ModelType({ 268 | boo: true, 269 | bar: 'foo', 270 | holy: 'cow' 271 | }); 272 | 273 | testModel.apply({ 274 | 0: 0.19, 275 | holy: 'shi*' 276 | }); 277 | 278 | expect(myEmptyModel instanceof ModelType).toBe(true); 279 | expect(myEmptyModel.get('boo')).toEqual(false); 280 | expect(myEmptyModel.get('foo')).toEqual('bar'); 281 | expect(myModel.get('boo')).toEqual(true); 282 | expect(myModel.get('foo')).toEqual('bar'); 283 | expect(myModel.get('hello')).toEqual('hola'); 284 | expect(testModel instanceof ModelType).toBe(false); 285 | expect(testModel.get('0')).toEqual(0.19); 286 | expect(testModel.get('hello')).toEqual(null); 287 | expect(testModel.get('holy')).toEqual('shi*'); 288 | }); 289 | 290 | }); 291 | 292 | }); -------------------------------------------------------------------------------- /src/net/History.js: -------------------------------------------------------------------------------- 1 | import {default as EventDispatcher} from '../events/EventDispatcher'; 2 | import {default as uuid} from '../util/uuid'; 3 | 4 | var _isAndroid = navigator.userAgent.indexOf('Android') > -1, 5 | _standardsMode = !_isAndroid && typeof history.pushState === 'function', 6 | _hasPushed = false, 7 | _shouldUseHashBang = false, 8 | _lastHash, 9 | _hist, 10 | _currentId, 11 | _pushCount = 0, 12 | _silentPop = false; 13 | 14 | function checkForParams() { 15 | var str = window.location.href; 16 | str = str.split('?')[1] || ''; 17 | str = str.split('#')[0] || ''; 18 | var paramArray = str.split('&') || []; 19 | for (var i=0; i { 35 | hist.position = position; 36 | var record = { 37 | id: id, 38 | state: state, 39 | title: title, 40 | url: url 41 | }; 42 | hist.sequence[position] = record; 43 | var hashReplacement = url + '#@' + id; 44 | _lastHash = hashReplacement; 45 | if (!checkForParams() || id !== 0) { 46 | location.hash = _shouldUseHashBang ? '!' + hashReplacement : hashReplacement; 47 | } 48 | return record; 49 | }; 50 | 51 | /** 52 | * HTML5 history abstraction layer 53 | * @class lavaca.net.History 54 | * @extends lavaca.events.EventDispatcher 55 | * 56 | * @event popstate 57 | * 58 | * @constructor 59 | */ 60 | var History = EventDispatcher.extend(function() { 61 | EventDispatcher.call(this); 62 | /** 63 | * A list containing history states generated by the app (not used for HTML5 history) 64 | * @property {Array} sequence 65 | */ 66 | this.sequence = []; 67 | /** 68 | * The current index in the history sequence (not used for HTML5 history) 69 | * @property {Number} position 70 | */ 71 | this.position = -1; 72 | var self = this; 73 | if (_standardsMode) { 74 | /** 75 | * Auto-generated callback executed when a history event occurs 76 | * @property {Function} onPopState 77 | */ 78 | var self = this; 79 | this.onPopState = (e) => { 80 | if (e.state) { 81 | _pushCount--; 82 | var previousId = _currentId; 83 | _currentId = e.state.id; 84 | 85 | self.trigger('popstate', { 86 | bypassLoad: _silentPop, 87 | state: e.state.state, 88 | title: e.state.title, 89 | url: e.state.url, 90 | id: e.state.id, 91 | direction: _currentId > previousId ? 'forward' : 'back' 92 | }); 93 | _silentPop = false; 94 | 95 | } 96 | }; 97 | window.addEventListener('popstate', this.onPopState, false); 98 | } else { 99 | this.onPopState = () => { 100 | var hash = location.hash, 101 | code, 102 | record, 103 | item, 104 | previousCode, 105 | i = -1; 106 | if (hash) { 107 | hash = _shouldUseHashBang ? hash.replace(/^#!/, '') : hash.replace(/^#/, ''); 108 | } 109 | if (hash !== _lastHash) { 110 | previousCode = _lastHash.split('#@')[1]; 111 | _lastHash = hash; 112 | if (hash) { 113 | _pushCount--; 114 | code = hash.split('#@')[1]; 115 | if (!code) { 116 | window.location.reload(); 117 | } 118 | while (!!(item = self.sequence[++i])) { 119 | if (item.id === parseInt(code, 10)) { 120 | record = item; 121 | self.position = i; 122 | break; 123 | } 124 | } 125 | if (record) { 126 | var hashReplacement = record.url + '#@' + record.id; 127 | hashReplacement = _shouldUseHashBang ? '!' + hashReplacement : hashReplacement; 128 | location.hash = hashReplacement; 129 | document.title = record.title; 130 | 131 | self.trigger('popstate', { 132 | bypassLoad: _silentPop, 133 | state: record.state, 134 | title: record.title, 135 | url: record.url, 136 | id: record.id, 137 | direction: record.id > parseInt(previousCode, 10) ? 'forward' : 'back' 138 | }); 139 | _silentPop = false; 140 | 141 | } 142 | } else { 143 | History.back(); 144 | } 145 | } 146 | }; 147 | if (window.attachEvent) { 148 | window.attachEvent('onhashchange', this.onPopState); 149 | } else { 150 | window.addEventListener('hashchange', this.onPopState, false); 151 | } 152 | } 153 | }, { 154 | /** 155 | * Retrieve the current history record 156 | * @method current 157 | * 158 | * @return {Object} The current history record 159 | */ 160 | current() { 161 | return this.sequence[this.position] || null; 162 | }, 163 | /** 164 | * Determines whether or not there are history states 165 | * @method hasHistory 166 | * 167 | * @returns {Boolean} True when there is a history state 168 | */ 169 | hasHistory() { 170 | return _pushCount > 0; 171 | }, 172 | /** 173 | * Adds a record to the history 174 | * @method push 175 | * 176 | * @param {Object} state A data object associated with the page state 177 | * @param {String} title The title of the page state 178 | * @param {String} url The URL of the page state 179 | */ 180 | push(state, title, url) { 181 | _pushCount++; 182 | if (_hasPushed) { 183 | document.title = title; 184 | _currentId = uuid('history'); 185 | if (_standardsMode) { 186 | history.pushState({state: state, title: title, url: url, id: _currentId}, title, url); 187 | } else { 188 | _insertState(this, ++this.position, _currentId, state, title, url); 189 | } 190 | } else { 191 | this.replace(state, title, url); 192 | } 193 | }, 194 | /** 195 | * Replaces the current record in the history 196 | * @method replace 197 | * 198 | * @param {Object} state A data object associated with the page state 199 | * @param {String} title The title of the page state 200 | * @param {String} url The URL of the page state 201 | */ 202 | replace(state, title, url) { 203 | _hasPushed = true; 204 | document.title = title; 205 | if (_standardsMode) { 206 | history.replaceState({state: state, title: title, url: url, id: _currentId}, title, url); 207 | } else { 208 | if (this.position < 0) { 209 | this.position = 0; 210 | } 211 | _insertState(this, this.position, typeof _currentId !== 'undefined' ? _currentId : uuid('history'), state, title, url); 212 | } 213 | }, 214 | /** 215 | * Unbind the history object and ready it for garbage collection 216 | * @method dispose 217 | */ 218 | dispose() { 219 | if (this.onPopState) { 220 | if (_standardsMode) { 221 | window.removeEventListener('popstate', this.onPopState, false); 222 | } else if (window.detachEvent) { 223 | window.detachEvent('onhashchange', this.onPopState); 224 | } else { 225 | window.removeEventListener('hashchange', this.onPopState, false); 226 | } 227 | } 228 | EventDispatcher.prototype.dispose.call(this); 229 | } 230 | }); 231 | /** 232 | * Initialize a singleton history abstraction layer 233 | * @method init 234 | * @static 235 | * 236 | * @return {Lavaca.mvc.History} The history instance 237 | */ 238 | /** 239 | * Initialize a singleton history abstraction layer 240 | * @method init 241 | * @static 242 | * 243 | * @param {Boolean} useHash When true, use the location hash to manage history state instead of HTML5 history 244 | * @return {Lavaca.mvc.History} The history instance 245 | */ 246 | History.init = (useHash) => { 247 | if (!_hist) { 248 | if (useHash) { 249 | History.overrideStandardsMode(); 250 | } 251 | _hist = new History(); 252 | } 253 | return _hist; 254 | }; 255 | 256 | /** 257 | * Adds a record to the history 258 | * @method push 259 | * @static 260 | * 261 | * @param {Object} state A data object associated with the page state 262 | * @param {String} title The title of the page state 263 | * @param {String} url The URL of the page state 264 | */ 265 | History.push = function(){History.init().push.apply(_hist, arguments);} 266 | 267 | /** 268 | * Replaces the current record in the history 269 | * @method replace 270 | * @static 271 | * 272 | * @param {Object} state A data object associated with the page state 273 | * @param {String} title The title of the page state 274 | * @param {String} url The URL of the page state 275 | */ 276 | History.replace = function(){History.init().replace.apply(_hist, arguments);} 277 | 278 | /** 279 | * Goes to the previous history state 280 | * @method back 281 | * @static 282 | */ 283 | History.back = () => history.back(); 284 | 285 | /** 286 | * Goes to the previous history state without notifying router 287 | * @method back 288 | * @static 289 | */ 290 | History.silentBack = () => { 291 | _silentPop = true; 292 | history.back(); 293 | }; 294 | 295 | /** 296 | * Goes to the next history state 297 | * @method forward 298 | * @static 299 | */ 300 | History.forward = () => history.forward(); 301 | 302 | /** 303 | * Unbind the history object and ready it for garbage collection 304 | * @method dispose 305 | * @static 306 | */ 307 | History.dispose = () => { 308 | if (_hist) { 309 | _hist.dispose(); 310 | _hist = null; 311 | } 312 | }; 313 | 314 | /** 315 | * Binds an event handler to the singleton history 316 | * @method on 317 | * @static 318 | * 319 | * @param {String} type The type of event 320 | * @param {Function} callback The function to execute when the event occurs 321 | * @return {Lavaca.mvc.History} The history object (for chaining) 322 | */ 323 | History.on = function(){History.init().on.apply(_hist, arguments);} 324 | 325 | /** 326 | * Unbinds an event handler from the singleton history 327 | * @method off 328 | * @static 329 | * 330 | * @param {String} type The type of event 331 | * @param {Function} callback The function to stop executing when the 332 | * event occurs 333 | * @return {Lavaca.mvc.History} The history object (for chaining) 334 | */ 335 | History.off = function(){History.init().off.apply(_hist, arguments);} 336 | 337 | /** 338 | * Sets Histroy to hash mode 339 | * @method overrideStandardsMode 340 | * @static 341 | */ 342 | History.overrideStandardsMode = () => _standardsMode = false; 343 | 344 | /** 345 | * Sets Histroy to use google crawlable #! 346 | * @method useHashBang 347 | * @static 348 | */ 349 | History.useHashBang = () => _shouldUseHashBang = true; 350 | 351 | /** 352 | * Stores the page transition animations so that if you route back, it will animate correctly 353 | * @property {Array} animationBreadcrumb 354 | */ 355 | History.animationBreadcrumb = []; 356 | 357 | /** 358 | * Flag to notify when history back is being called 359 | * @property {Boolean} isRoutingBack 360 | */ 361 | History.isRoutingBack = false; 362 | 363 | export default History; -------------------------------------------------------------------------------- /src/mvc/ViewManager.js: -------------------------------------------------------------------------------- 1 | import {default as History} from '../net/History'; 2 | import {default as View} from './View'; 3 | import {default as Disposable} from '../util/Disposable'; 4 | import {default as merge} from 'mout/object/merge'; 5 | import {default as fillIn} from 'mout/object/fillIn'; 6 | import {default as contains} from 'mout/array/contains'; 7 | import {default as removeAll} from 'mout/array/removeAll'; 8 | import $ from 'jquery'; 9 | 10 | /** 11 | * Manager responsible for drawing views 12 | * @class lavaca.mvc.ViewManager 13 | * @extends lavaca.util.Disposable 14 | * 15 | * @constructor 16 | * @param {jQuery} el The element that contains all layers 17 | */ 18 | var ViewManager = Disposable.extend(function ViewManager(el){ 19 | Disposable.call(this); 20 | /** 21 | * The element that contains all view layers 22 | * @property {jQuery} el 23 | * @default null 24 | */ 25 | this.el = $(el || document.body); 26 | /** 27 | * A cache containing all views 28 | * @property {Object} views 29 | * @default {} 30 | */ 31 | this.pageViews = {}; 32 | /** 33 | * A list containing all layers 34 | * @property {Array} layers 35 | * @default [] 36 | */ 37 | this.layers = []; 38 | /** 39 | * Toggles breadcrumb tracking 40 | * @property {Boolean} shouldTrackBreadcrumb 41 | * @default false 42 | */ 43 | this.shouldTrackBreadcrumb = false; 44 | /** 45 | * A list containing all views starting from the last root 46 | * @property {Array} breadcrumb 47 | * @default [] 48 | */ 49 | this.breadcrumb = []; 50 | /** 51 | * A list containing all views that are currently exiting 52 | * @property {Array} exitingPageViews 53 | * @default [] 54 | */ 55 | this.exitingPageViews = []; 56 | /** 57 | * A list containing all views that are currently entering 58 | * @property {Array} enteringPageViews 59 | * @default [] 60 | */ 61 | this.enteringPageViews = []; 62 | }, { 63 | /** 64 | * When true, the view manager is prevented from loading views. 65 | * @property {Boolean} locked 66 | * @default false 67 | */ 68 | locked: false, 69 | /** 70 | * Sets the el property on the instance 71 | * @method setEl 72 | * 73 | * @param {jQuery} el A jQuery object of the element that contains all layers 74 | * @return {Lavaca.mvc.ViewManager} This View Manager instance 75 | */ 76 | /** 77 | * Sets the el property on the instance 78 | * @method setEl 79 | * 80 | * @param {String} el A CSS selector matching the element that contains all layers 81 | * @return {Lavaca.mvc.ViewManager} This View Manager instance 82 | */ 83 | setEl(el) { 84 | this.el = typeof el === 'string' ? $(el) : el; 85 | return this; 86 | }, 87 | /** 88 | * Initializes Breadcrumb tracking to handle contextual animations and enable edge swipe history states 89 | * 90 | * @method initBreadcrumbTracking 91 | */ 92 | initBreadcrumbTracking() { 93 | this.shouldTrackBreadcrumb = true; 94 | History.on('popstate', (e) => { 95 | if (e.direction === 'back') { 96 | this.popBreadcrumb(); 97 | if (!e.bypassLoad) { 98 | this.popBreadcrumb(); 99 | } 100 | } 101 | }); 102 | }, 103 | /** 104 | * Handles the disposal of views that are popped out of the breadcrumb array 105 | * @method popBreadcrumb 106 | */ 107 | popBreadcrumb() { 108 | this.breadcrumb.pop(); 109 | }, 110 | /** 111 | * Tracks new breadcrumbs and resets root links 112 | * 113 | * @param {Object} obj An Object containing the parts to create a view (cacheKey TPageView model params layer) 114 | * @method trackBreadcrumb 115 | */ 116 | trackBreadcrumb(obj) { 117 | if (obj.params.root) { 118 | this.breadcrumb = []; 119 | } 120 | this.breadcrumb.push(obj); 121 | }, 122 | /** 123 | * A method to silently rewind history without fully routing back. 124 | * This is an important tool for implementing edge swipe history back 125 | * 126 | * @param {Lavaca.mvc.View} pageView A View instance 127 | * @method rewind 128 | */ 129 | rewind(pageView) { 130 | History.silentBack(); 131 | History.animationBreadcrumb.pop(); 132 | 133 | var replacingPageView = this.layers[0]; 134 | if (!replacingPageView.cacheKey) { 135 | replacingPageView.dispose(); 136 | } 137 | if (replacingPageView.el) { 138 | replacingPageView.el.detach(); 139 | } 140 | 141 | if (pageView) { 142 | this.layers[0] = pageView; 143 | } 144 | }, 145 | /** 146 | * Builds a pageView and merges in parameters 147 | * @method buildPageView 148 | * 149 | * @param {Object} obj An Object containing the parts to create a view (cacheKey TPageView model params layer) 150 | * @return {Lavaca.mvc.View} A View instance 151 | */ 152 | buildPageView(obj) { 153 | var pageView = this.pageViews[obj.cacheKey]; 154 | 155 | if (typeof obj.params === 'object') { 156 | obj.params.breadcrumbLength = this.breadcrumb.length; 157 | if ((this.breadcrumb.length > 1)) { 158 | obj.params.shouldShowBack = true; 159 | } 160 | } 161 | 162 | if (!pageView) { 163 | pageView = new obj.TPageView(null, obj.model, obj.layer); 164 | if (typeof this.pageViewMixin === 'object') { 165 | merge(pageView, this.pageViewMixin); 166 | } 167 | if (typeof this.pageViewFillin === 'object') { 168 | fillIn(pageView, this.pageViewFillin); 169 | } 170 | if (typeof obj.params === 'object') { 171 | merge(pageView, obj.params); 172 | } 173 | pageView.isViewManagerView = true; 174 | if (obj.cacheKey !== null) { 175 | this.pageViews[obj.cacheKey] = pageView; 176 | pageView.cacheKey = obj.cacheKey; 177 | } 178 | } else { 179 | if (typeof obj.params === 'object') { 180 | merge(pageView, obj.params); 181 | } 182 | } 183 | 184 | return pageView; 185 | }, 186 | /** 187 | * Loads a view 188 | * @method load 189 | * 190 | * @param {String} cacheKey The cache key associated with the view 191 | * @param {Function} TPageView The type of view to load (should derive from [[Lavaca.mvc.View]]) 192 | * @param {Object} model The views model 193 | * @param {Number} layer The index of the layer on which the view will sit 194 | * @return {Promise} A promise 195 | */ 196 | /** 197 | * Loads a view 198 | * @method load 199 | * 200 | * @param {String} cacheKey The cache key associated with the view 201 | * @param {Function} TPageView The type of view to load (should derive from [[Lavaca.mvc.View]]) 202 | * @param {Object} model The views model 203 | * @param {Object} params Parameters to be mapped to the view 204 | * @return {Promise} A promise 205 | */ 206 | load(cacheKey, TPageView, model, params) { 207 | if (this.locked) { 208 | return Promise.reject('locked'); 209 | } else { 210 | this.locked = true; 211 | } 212 | params = params || {}; 213 | 214 | if (params.bypassLoad) { 215 | this.locked = false; 216 | return Promise.resolve(); 217 | } 218 | 219 | var layer = TPageView.prototype.layer || 0; 220 | 221 | if (typeof params === 'number') { 222 | layer = params; 223 | params = {'layer': layer}; 224 | } else if (params.layer) { 225 | layer = params.layer; 226 | } 227 | 228 | var pageView, 229 | obj = { 230 | 'cacheKey':cacheKey, 231 | 'TPageView':TPageView, 232 | 'model':model, 233 | 'params':params, 234 | 'layer':layer 235 | }; 236 | 237 | if (this.shouldTrackBreadcrumb) { 238 | this.trackBreadcrumb(obj); 239 | } 240 | pageView = this.buildPageView(obj); 241 | 242 | return Promise.resolve() 243 | .then(() => { 244 | if (!pageView.hasRendered) { 245 | return pageView.render(); 246 | } 247 | }) 248 | .then(() => this.beforeEnterExit(layer-1, pageView)) 249 | .then(() => { 250 | this.enteringPageViews = [pageView]; 251 | return Promise.all([ 252 | (() => this.layers[layer] !== pageView ? pageView.enter(this.el, this.exitingPageViews):null)(), 253 | (() => this.dismissLayersAbove(layer-1, pageView))() 254 | ]); 255 | }) 256 | .then(() => { 257 | this.locked = false; 258 | this.enteringPageViews = []; 259 | this.layers[layer] = pageView; 260 | }).catch(err=>{ 261 | console.error('Error in ViewManager: '); 262 | console.trace(err.stack); 263 | }); 264 | }, 265 | /** 266 | * Execute beforeEnter or beforeExit for each layer. Both fucntions 267 | * beforeEnter and beforeExit must return promises. 268 | * @method beforeEnterExit 269 | * 270 | * @param {Number} index The index above which is to be cleared 271 | * @return {Promise} A promise 272 | */ 273 | /** 274 | * Execute beforeEnter or beforeExit for each layer. Both fucntions 275 | * beforeEnter and beforeExit must return promises. 276 | * @method beforeEnterExit 277 | * 278 | * @param {Number} index The index above which is to be cleared 279 | * @param {Lavaca.mvc.View} enteringView A view that will be entering 280 | * @return {Promise} A promise 281 | */ 282 | beforeEnterExit(index, enteringView) { 283 | var i, 284 | layer, 285 | list = []; 286 | if (enteringView && typeof enteringView.beforeEnter === 'function') { 287 | list.push(enteringView.beforeEnter()); 288 | } 289 | for (i = this.layers.length - 1; i > index; i--) { 290 | if ((layer = this.layers[i]) && (!enteringView || enteringView !== layer)) { 291 | ((layer) => { 292 | if (typeof layer.beforeExit === 'function') { 293 | list.push(layer.beforeExit()); 294 | } 295 | }).call(this, layer); 296 | } 297 | } 298 | return Promise.all(list); 299 | }, 300 | /** 301 | * Removes all views on a layer 302 | * @method dismiss 303 | * 304 | * @param {Number} index The index of the layer to remove 305 | * @return {Promise} A promise 306 | */ 307 | /** 308 | * Removes all views on a layer 309 | * @method dismiss 310 | * 311 | * @param {jQuery} el An element on the layer to remove (or the layer itself) 312 | * @return {Promise} A promise 313 | */ 314 | /** 315 | * Removes all views on a layer 316 | * @method dismiss 317 | * 318 | * @param {Lavaca.mvc.View} view The view on the layer to remove 319 | * @return {Promise} A promise 320 | */ 321 | dismiss(layer) { 322 | if (typeof layer === 'number') { 323 | return this.dismissLayersAbove(layer - 1); 324 | } else if (layer instanceof View) { 325 | return this.dismiss(layer.layer); 326 | } else { 327 | layer = $(layer); 328 | var index = layer.attr('data-layer-index'); 329 | if (index === null) { 330 | layer = layer.closest('[data-layer-index]'); 331 | index = layer.attr('data-layer-index'); 332 | } 333 | if (index !== null) { 334 | return this.dismiss(Number(index)); 335 | } 336 | } 337 | }, 338 | /** 339 | * Removes all layers above a given index 340 | * @method dismissLayersAbove 341 | * 342 | * @param {Number} index The index above which to clear 343 | * @return {Promise} A promise 344 | */ 345 | /** 346 | * Removes all layers above a given index 347 | * @method dismissLayersAbove 348 | * 349 | * @param {Number} index The index above which to clear 350 | * @param {Lavaca.mvc.View} exceptForView A view that should not be dismissed 351 | * @return {Promise} A promise 352 | */ 353 | dismissLayersAbove(index, exceptForView) { 354 | var toDismiss = this.layers.slice(index+1) 355 | .filter((layer) => { 356 | return (layer && (!exceptForView || exceptForView !== layer)); 357 | }); 358 | 359 | this.layers = this.layers.map((layer) => { 360 | if (contains(toDismiss, layer)) { 361 | return null; 362 | } 363 | return layer; 364 | }); 365 | 366 | var promises = toDismiss 367 | .map((layer) => { 368 | return Promise.resolve() 369 | .then(() => { 370 | this.exitingPageViews.push(layer); 371 | return layer.exit(this.el, this.enteringPageViews); 372 | }) 373 | .then(() => { 374 | removeAll(this.exitingPageViews, layer); 375 | if (!layer.cacheKey || (exceptForView && exceptForView.cacheKey === layer.cacheKey)) { 376 | layer.dispose(); 377 | } 378 | }); 379 | }); 380 | 381 | return Promise.all(promises); 382 | }, 383 | /** 384 | * Empties the view cache 385 | * @method flush 386 | */ 387 | flush(cacheKey) { 388 | // Don't dispose of any views that are currently displayed 389 | //flush individual cacheKey 390 | if (cacheKey){ 391 | delete this.pageViews[cacheKey]; 392 | } 393 | else { 394 | this.pageViews = {}; 395 | } 396 | }, 397 | /** 398 | * Readies the view manager for garbage collection 399 | * @method dispose 400 | */ 401 | dispose() { 402 | Disposable.prototype.dispose.call(this); 403 | } 404 | }); 405 | 406 | export default new ViewManager(null); 407 | -------------------------------------------------------------------------------- /src/fx/Transform.js: -------------------------------------------------------------------------------- 1 | import $ from 'jquery'; 2 | 3 | const _props = { 4 | transform: 'transform', 5 | webkitTransform: '-webkit-transform', 6 | MozTransform: '-moz-transform', 7 | OTransform: '-o-transform', 8 | MSTransform: '-ms-transform' 9 | }; 10 | let _prop, 11 | _cssProp, 12 | _3d = false, 13 | UNDEFINED; 14 | 15 | var Transform = {}; 16 | 17 | (() => { 18 | let style = document.createElement('div').style, 19 | s; 20 | for (s in _props) { 21 | if (s in style) { 22 | _prop = s; 23 | _cssProp = _props[s]; 24 | style[s] = 'translate3d(0,0,0)'; 25 | _3d = style[s].indexOf('translate3d') > -1 && navigator.userAgent.indexOf('Android') === -1; 26 | break; 27 | } 28 | } 29 | })(); 30 | 31 | let _isUndefined = (value) => value === UNDEFINED; 32 | let _toOriginUnit = (v) => typeof v === 'number' ? v * 100 + '%' : v; 33 | let _scrubRotateValue = (v) => typeof v === 'number' ? v + 'deg' : v; 34 | let _scrubTranslateValue = (v) => typeof v === 'number' ? v + 'px' : v; 35 | let _scrubScaleValue = (v) => typeof v === 'number' ? v + ',' + v : v; 36 | 37 | let _scrubTransformValue = (prop, value) => { 38 | var isRotate = prop.indexOf('rotate') === 0, 39 | isScale = prop === 'scale', 40 | isTranslate = prop.indexOf('translate') === 0, 41 | //isAxisSpecific = /(X|Y|Z)$/.test(prop), 42 | p, 43 | css = []; 44 | if (typeof value === 'object') { 45 | for (p in value) { 46 | css.push(prop 47 | + p.toUpperCase() 48 | + '(' 49 | + (isTranslate 50 | ? _scrubTranslateValue(value[p]) 51 | : isRotate 52 | ? _scrubRotateValue(value[p]) 53 | : isScale 54 | ? _scrubScaleValue(value[p]) 55 | : value[p]) 56 | + ')'); 57 | } 58 | } else { 59 | if (isScale) { 60 | value = _scrubScaleValue(value); 61 | } else if (isRotate) { 62 | value = _scrubRotateValue(value); 63 | } else if (isTranslate) { 64 | value = _scrubTranslateValue(value); 65 | } 66 | css.push(prop + '(' + value + ')'); 67 | } 68 | return css.join(' '); 69 | } 70 | 71 | /** 72 | * Static utility type for working with CSS transforms 73 | * @class lavaca.fx.Transform 74 | */ 75 | 76 | /** 77 | * Whether or not transforms are supported by the browser 78 | * @method isSupported 79 | * @static 80 | * 81 | * @return {Boolean} True when transforms are supported 82 | */ 83 | Transform.isSupported = () => !!_prop; 84 | 85 | /** 86 | * Whether or not 3D transforms are supported by the browser 87 | * @method is3dSupported 88 | * @static 89 | * 90 | * @return {Boolean} True when 3D transforms are supported 91 | */ 92 | Transform.is3dSupported = () => _3d; 93 | 94 | /** 95 | * Converts a transform hash into a CSS string 96 | * @method toCSS 97 | * @static 98 | * 99 | * @param {Object} opts A hash of CSS transform values, with properties in 100 | * the form {translateX: 1, translateY: 1} or {translate: {x: 1, y: 1}} 101 | * @opt {Object} translate An object or string containing the translation values 102 | * @opt {Object} translateX A string (in any unit) or number (in pixels) representing the X translation value 103 | * @opt {Object} translateY A string (in any unit) or number (in pixels) representing the Y translation value 104 | * @opt {Object} translateZ A string (in any unit) or number (in pixels) representing the Z translation value 105 | * @opt {String} translate3d A string containing the 3D translation values 106 | * @opt {Object} rotate An object, string, or number (in degrees) containing the rotation value(s) 107 | * @opt {Object} rotateX A string (in any unit) or number (in degrees) representing the X rotation value 108 | * @opt {Object} rotateY A string (in any unit) or number (in degrees) representing the Y rotation value 109 | * @opt {Object} rotateZ A string (in any unit) or number (in degrees) representing the Z rotation value 110 | * @opt {String} rotate3d A string containing the 3D rotation values 111 | * @opt {Object} scale An object, string or number (in percentage points) containing the scale value(s) 112 | * @opt {Object} scaleX A string (in any unit) or number (in percentage points) representing the X scale value 113 | * @opt {Object} scaleY A string (in any unit) or number (in percentage points) representing the Y scale value 114 | * @opt {Object} scaleZ A string (in any unit) or number (in percentage points) representing the Z scale value 115 | * @opt {String} scale3d Astring containing the 3D scale values 116 | * @opt {Object} skew An object or string containing the skew values 117 | * @opt {Object} skewX A string (in any unit) or number (in pixels) representing the X skew value 118 | * @opt {Object} skewY A string (in any unit) or number (in pixels) representing the Y skew value 119 | * @opt {String} matrix A string containing the matrix transform values 120 | * @opt {String} matrix3d A string containing the 3D matrix transform values 121 | * @opt {String} perspective A string containing the perspective transform values 122 | * @return {String} The generated CSS string 123 | */ 124 | Transform.toCSS = (opts) => { 125 | var css = [], 126 | prop; 127 | if (typeof opts === 'object') { 128 | for (prop in opts) { 129 | css.push(_scrubTransformValue(prop, opts[prop])); 130 | } 131 | } else { 132 | css.push(opts); 133 | } 134 | return css.join(' '); 135 | }; 136 | 137 | /** 138 | * Gets the name of the transform CSS property 139 | * @method cssProperty 140 | * @static 141 | * 142 | * @return {String} The name of the CSS property 143 | */ 144 | Transform.cssProperty = () => _cssProp; 145 | 146 | /** 147 | * Transforms an element 148 | * @method $.fn.transform 149 | * 150 | * @param {String} value The CSS transform string 151 | * @return {jQuery} The jQuery object, for chaining 152 | */ 153 | /** 154 | * Transforms an element 155 | * @method $.fn.transform 156 | * 157 | * @param {Object} opt A hash of CSS transform values, with properties in 158 | * the form {translateX: 1, translateY: 1} or {translate: {x: 1, y: 1}} 159 | * @opt {Object} translate An object or string containing the translation values 160 | * @opt {Object} translateX A string (in any unit) or number (in pixels) representing the X translation value 161 | * @opt {Object} translateY A string (in any unit) or number (in pixels) representing the Y translation value 162 | * @opt {Object} translateZ A string (in any unit) or number (in pixels) representing the Z translation value 163 | * @opt {String} translate3d A string containing the 3D translation values 164 | * @opt {Object} rotate An object, string, or number (in degrees) containing the rotation value(s) 165 | * @opt {Object} rotateX A string (in any unit) or number (in degrees) representing the X rotation value 166 | * @opt {Object} rotateY A string (in any unit) or number (in degrees) representing the Y rotation value 167 | * @opt {Object} rotateZ A string (in any unit) or number (in degrees) representing the Z rotation value 168 | * @opt {String} rotate3d A string containing the 3D rotation values 169 | * @opt {Object} scale An object, string or number (in percentage points) containing the scale value(s) 170 | * @opt {Object} scaleX A string (in any unit) or number (in percentage points) representing the X scale value 171 | * @opt {Object} scaleY A string (in any unit) or number (in percentage points) representing the Y scale value 172 | * @opt {Object} scaleZ A string (in any unit) or number (in percentage points) representing the Z scale value 173 | * @opt {String} scale3d Astring containing the 3D scale values 174 | * @opt {Object} skew An object or string containing the skew values 175 | * @opt {Object} skewX A string (in any unit) or number (in pixels) representing the X skew value 176 | * @opt {Object} skewY A string (in any unit) or number (in pixels) representing the Y skew value 177 | * @opt {String} matrix A string containing the matrix transform values 178 | * @opt {String} matrix3d A string containing the 3D matrix transform values 179 | * @opt {String} perspective A string containing the perspective transform values 180 | * @return {jQuery} The jQuery object, for chaining 181 | */ 182 | /** 183 | * Transforms an element 184 | * @method $.fn.transform 185 | * 186 | * @param {String} value The CSS transform string 187 | * @param {String} origin The CSS transform origin 188 | * @return {jQuery} The jQuery object, for chaining 189 | */ 190 | /** 191 | * Transforms an element 192 | * @method $.fn.transform 193 | * 194 | * @param {Object} opt A hash of CSS transform values, with properties in 195 | * the form {translateX: 1, translateY: 1} or {translate: {x: 1, y: 1}} 196 | * @opt {Object} translate An object or string containing the translation values 197 | * @opt {Object} translateX A string (in any unit) or number (in pixels) representing the X translation value 198 | * @opt {Object} translateY A string (in any unit) or number (in pixels) representing the Y translation value 199 | * @opt {Object} translateZ A string (in any unit) or number (in pixels) representing the Z translation value 200 | * @opt {String} translate3d A string containing the 3D translation values 201 | * @opt {Object} rotate An object, string, or number (in degrees) containing the rotation value(s) 202 | * @opt {Object} rotateX A string (in any unit) or number (in degrees) representing the X rotation value 203 | * @opt {Object} rotateY A string (in any unit) or number (in degrees) representing the Y rotation value 204 | * @opt {Object} rotateZ A string (in any unit) or number (in degrees) representing the Z rotation value 205 | * @opt {String} rotate3d A string containing the 3D rotation values 206 | * @opt {Object} scale An object, string or number (in percentage points) containing the scale value(s) 207 | * @opt {Object} scaleX A string (in any unit) or number (in percentage points) representing the X scale value 208 | * @opt {Object} scaleY A string (in any unit) or number (in percentage points) representing the Y scale value 209 | * @opt {Object} scaleZ A string (in any unit) or number (in percentage points) representing the Z scale value 210 | * @opt {String} scale3d Astring containing the 3D scale values 211 | * @opt {Object} skew An object or string containing the skew values 212 | * @opt {Object} skewX A string (in any unit) or number (in pixels) representing the X skew value 213 | * @opt {Object} skewY A string (in any unit) or number (in pixels) representing the Y skew value 214 | * @opt {String} matrix A string containing the matrix transform values 215 | * @opt {String} matrix3d A string containing the 3D matrix transform values 216 | * @opt {String} perspective A string containing the perspective transform values 217 | * @param {String} origin The CSS transform origin 218 | * @return {jQuery} The jQuery object, for chaining 219 | */ 220 | /** 221 | * Transforms an element 222 | * @method $.fn.transform 223 | * 224 | * @param {String} value The CSS transform string 225 | * @param {Object} origin The CSS transform origin, in the form {x: N, y: N}, 226 | * where N is a decimal percentage between -1 and 1 or N is a pixel value > 1 or < -1. 227 | * @return {jQuery} The jQuery object, for chaining 228 | */ 229 | /** 230 | * Transforms an element 231 | * @method $.fn.transform 232 | * 233 | * @param {Object} opt A hash of CSS transform values, with properties in 234 | * the form {translateX: 1, translateY: 1} or {translate: {x: 1, y: 1}} 235 | * @opt {Object} translate An object or string containing the translation values 236 | * @opt {Object} translateX A string (in any unit) or number (in pixels) representing the X translation value 237 | * @opt {Object} translateY A string (in any unit) or number (in pixels) representing the Y translation value 238 | * @opt {Object} translateZ A string (in any unit) or number (in pixels) representing the Z translation value 239 | * @opt {String} translate3d A string containing the 3D translation values 240 | * @opt {Object} rotate An object, string, or number (in degrees) containing the rotation value(s) 241 | * @opt {Object} rotateX A string (in any unit) or number (in degrees) representing the X rotation value 242 | * @opt {Object} rotateY A string (in any unit) or number (in degrees) representing the Y rotation value 243 | * @opt {Object} rotateZ A string (in any unit) or number (in degrees) representing the Z rotation value 244 | * @opt {String} rotate3d A string containing the 3D rotation values 245 | * @opt {Object} scale An object, string or number (in percentage points) containing the scale value(s) 246 | * @opt {Object} scaleX A string (in any unit) or number (in percentage points) representing the X scale value 247 | * @opt {Object} scaleY A string (in any unit) or number (in percentage points) representing the Y scale value 248 | * @opt {Object} scaleZ A string (in any unit) or number (in percentage points) representing the Z scale value 249 | * @opt {String} scale3d Astring containing the 3D scale values 250 | * @opt {Object} skew An object or string containing the skew values 251 | * @opt {Object} skewX A string (in any unit) or number (in pixels) representing the X skew value 252 | * @opt {Object} skewY A string (in any unit) or number (in pixels) representing the Y skew value 253 | * @opt {String} matrix A string containing the matrix transform values 254 | * @opt {String} matrix3d A string containing the 3D matrix transform values 255 | * @opt {String} perspective A string containing the perspective transform values 256 | * @param {Object} origin The CSS transform origin, in the form {x: N, y: N}, 257 | * where N is a decimal percentage between -1 and 1 or N is a pixel value > 1 or < -1. 258 | * @return {jQuery} The jQuery object, for chaining 259 | */ 260 | $.fn.transform = function(value, origin) { 261 | if (Transform.isSupported()) { 262 | value = Transform.toCSS(value); 263 | if (origin) { 264 | if (typeof origin === 'object') { 265 | origin = _toOriginUnit(origin.x) + (_isUndefined(origin.y) ? '' : ' ' + _toOriginUnit(origin.y)); 266 | } 267 | } 268 | this.each(() => { 269 | this.style[_prop] = value; 270 | if (origin) { 271 | this.style[_prop + 'Origin'] = origin; 272 | } 273 | }); 274 | } 275 | return this; 276 | }; 277 | 278 | export default Transform; -------------------------------------------------------------------------------- /test/unit/mvc/View-spec.js: -------------------------------------------------------------------------------- 1 | var View = require('lavaca/mvc/View'); 2 | var Model = require('lavaca/mvc/Model'); 3 | var Widget = require('lavaca/ui/Widget'); 4 | var values = require('mout/object/values'); 5 | 6 | describe('A View', function() { 7 | var testView, 8 | el, 9 | model; 10 | beforeEach(function() { 11 | el = $('
'); 12 | model = new Model({color: 'blue', primary: true}); 13 | testView = new View(el); 14 | }); 15 | afterEach(function() { 16 | testView.dispose(); 17 | }); 18 | it('can be initialized', function() { 19 | expect(testView instanceof View).to.equal(true); 20 | }); 21 | it('can be initialized with an el, a model and a parentView', function() { 22 | var parentView = new View(el, model); 23 | testView = new View(el, model, parentView); 24 | expect(testView.el).to.equal(el); 25 | expect(testView.model).to.equal(model); 26 | expect(testView.parentView).to.equal(parentView); 27 | }); 28 | it('can be initialized with different values for layer', function() { 29 | var view1 = new View(el, model); 30 | var view2 = new View(el, model, 2); 31 | expect(view1.layer).to.equal(0); 32 | expect(view2.layer).to.equal(2); 33 | }); 34 | describe('can map childViews', function() { 35 | var multiChildEl, 36 | multiChildView, 37 | handler; 38 | beforeEach(function() { 39 | multiChildEl = $(['
', 40 | '
', 41 | '
', 42 | '
', 43 | '
', 44 | '
'].join()); 45 | multiChildView = new View(multiChildEl, model); 46 | handler = { 47 | fn: function(index, el) { 48 | return new Model({index: index, id: $(el).attr('data-id')}); 49 | } 50 | }; 51 | sinon.spy(handler, 'fn'); 52 | }); 53 | it('with the same model as the parent view', function() { 54 | testView.mapChildView('.childView', View); 55 | testView.createChildViews(); 56 | var childView = values(testView.childViews)[0]; 57 | expect(childView instanceof View).to.equal(true); 58 | expect(childView.model).to.equal(testView.model); 59 | }); 60 | it('with a custom model', function() { 61 | var model = new Model({color: 'red'}); 62 | testView.mapChildView('.childView', View, model); 63 | testView.createChildViews(); 64 | var childView = values(testView.childViews)[0]; 65 | expect(childView instanceof View).to.equal(true); 66 | expect(childView.model).to.equal(model); 67 | }); 68 | it('with a function that returns a model', function() { 69 | multiChildView.mapChildView('.childView', View, handler.fn); 70 | multiChildView.createChildViews(); 71 | expect(handler.fn.callCount).to.equal(2); 72 | var childViews = values(multiChildView.childViews); 73 | expect(childViews[0].model.index).to.equal(0); 74 | expect(childViews[0].model.id).to.equal('abc'); 75 | expect(childViews[1].model.index).to.equal(1); 76 | expect(childViews[1].model.id).to.equal('def'); 77 | }); 78 | it('from a hash', function() { 79 | multiChildView.mapChildView({ 80 | '.childView': { 81 | TView: View 82 | }, 83 | '.altChild1': { 84 | TView: View, 85 | model: new Model({color: 'purple'}) 86 | }, 87 | '[data-id="abc"]': { 88 | TView: View, 89 | model: new Model({color: 'orange'}) 90 | } 91 | }); 92 | multiChildView.createChildViews(); 93 | var childViews = values(multiChildView.childViews); 94 | // [data-id="abc"] matches '.childView' too, which has already been 95 | // initialized. it won't be initialized a second time 96 | expect(childViews.length).to.equal(3); 97 | expect(childViews[0].model.color).to.equal('blue'); 98 | expect(childViews[1].model).to.equal(multiChildView.model); 99 | expect(childViews[2].model.color).to.equal('purple'); 100 | }); 101 | }); 102 | it('can be rendered', function(done) { 103 | var TestView = View.extend(function() { 104 | View.apply(this, arguments); 105 | }, 106 | { 107 | generateHtml: function() { 108 | return '

Hello World

Color is blue.

'; 109 | } 110 | }); 111 | testView = new TestView(el, model); 112 | testView.render().then(function() { 113 | expect(testView.hasRendered).to.equal(true); 114 | expect($(testView.el).length).to.equal(1); 115 | expect($(testView.el).html()).to.equal('

Hello World

Color is blue.

'); 116 | done(); 117 | }); 118 | }); 119 | it('can redraw whole view', function(done) { 120 | var TestView = View.extend(function() { 121 | View.apply(this, arguments); 122 | }, 123 | { 124 | generateHtml: function() { 125 | return '

Hello World

Color is ' + this.model.color + '.

'; 126 | } 127 | }); 128 | testView = new TestView(el, model); 129 | testView.render().then(function() { 130 | expect(testView.hasRendered).to.equal(true); 131 | expect($(testView.el).length).to.equal(1); 132 | expect($(testView.el).html()).to.equal('

Hello World

Color is blue.

'); 133 | model.color = 'red'; 134 | return testView.render(); 135 | }).then(function() { 136 | expect($(testView.el).html()).to.equal('

Hello World

Color is red.

'); 137 | done(); 138 | }); 139 | $('script[data-name="model-tmpl"]').remove(); 140 | }); 141 | it('can redraw whole view using a custom model', function(done) { 142 | var TestView = View.extend(function() { 143 | View.apply(this, arguments); 144 | }, 145 | { 146 | generateHtml: function(model) { 147 | return '

Color is ' + model.color + '.

'; 148 | } 149 | }); 150 | 151 | var otherModel = new Model({color: 'orange'}); 152 | testView = new TestView(el, model); 153 | testView.render().then(function() { 154 | expect(testView.hasRendered).to.equal(true); 155 | expect($(testView.el).length).to.equal(1); 156 | expect($(testView.el).html()).to.equal('

Color is blue.

'); 157 | return testView.render(otherModel); 158 | }).then(function() { 159 | expect($(testView.el).html()).to.equal('

Color is orange.

'); 160 | done(); 161 | }); 162 | $('script[data-name="model-tmpl"]').remove(); 163 | }); 164 | it('can redraw part of a based on a selector', function(done) { 165 | var TestView = View.extend(function() { 166 | View.apply(this, arguments); 167 | }, 168 | { 169 | generateHtml: function(model) { 170 | return '

Color is ' + model.color + '.

It is ' + (model.primary ? '' : 'not ') + 'primary

'; 171 | } 172 | }); 173 | 174 | testView = new TestView(el, model); 175 | testView.render().then(function() { 176 | expect(testView.hasRendered).to.equal(true); 177 | expect($(testView.el).length).to.equal(1); 178 | expect($(testView.el).html()).to.equal('

Color is blue.

It is primary

'); 179 | model.color = 'orange'; 180 | model.primary = false; 181 | return testView.render('p.redraw'); 182 | }).then(function() { 183 | expect($(testView.el).html()).to.equal('

Color is orange.

It is primary

'); 184 | done(); 185 | }); 186 | $('script[data-name="model-tmpl"]').remove(); 187 | }); 188 | it('can redraw part of a based on a selector with a custom model', function(done) { 189 | var TestView = View.extend(function() { 190 | View.apply(this, arguments); 191 | }, 192 | { 193 | generateHtml: function(model) { 194 | return '

Color is ' + model.color + '.

It is ' + (model.primary ? '' : 'not ') + 'primary

'; 195 | } 196 | }); 197 | 198 | var otherModel = new Model({color: 'orange', primary: false}); 199 | testView = new TestView(el, model); 200 | testView.render().then(function() { 201 | expect(testView.hasRendered).to.equal(true); 202 | expect($(testView.el).length).to.equal(1); 203 | expect($(testView.el).html()).to.equal('

Color is blue.

It is primary

'); 204 | return testView.render('p.redraw', otherModel); 205 | }).then(function() { 206 | expect($(testView.el).html()).to.equal('

Color is orange.

It is primary

'); 207 | done(); 208 | }); 209 | $('script[data-name="model-tmpl"]').remove(); 210 | }); 211 | it('can re-render without redrawing', function(done) { 212 | var TestView = View.extend(function() { 213 | View.apply(this, arguments); 214 | }, 215 | { 216 | generateHtml: function(model) { 217 | return '

Color is ' + model.color + '.

It is ' + (model.primary ? '' : 'not ') + 'primary

'; 218 | } 219 | }); 220 | 221 | testView = new TestView(el, model); 222 | testView.render().then(function() { 223 | expect(testView.hasRendered).to.equal(true); 224 | expect($(testView.el).length).to.equal(1); 225 | expect($(testView.el).html()).to.equal('

Color is blue.

It is primary

'); 226 | model.color = 'orange'; 227 | model.primary = false; 228 | return testView.render(false); 229 | }).then(function(html) { 230 | expect($(testView.el).html()).to.equal('

Color is blue.

It is primary

'); 231 | expect(html).to.equal('

Color is orange.

It is not primary

'); 232 | done(); 233 | }); 234 | $('script[data-name="model-tmpl"]').remove(); 235 | }); 236 | it('can re-render using a custom model without redrawing', function(done) { 237 | var TestView = View.extend(function() { 238 | View.apply(this, arguments); 239 | }, 240 | { 241 | generateHtml: function(model) { 242 | return '

Color is ' + model.color + '.

It is ' + (model.primary ? '' : 'not ') + 'primary

'; 243 | } 244 | }); 245 | 246 | var otherModel = new Model({color: 'orange', primary: false}); 247 | testView = new TestView(el, model); 248 | testView.render().then(function() { 249 | expect(testView.hasRendered).to.equal(true); 250 | expect($(testView.el).length).to.equal(1); 251 | expect($(testView.el).html()).to.equal('

Color is blue.

It is primary

'); 252 | return testView.render(false, otherModel); 253 | }).then(function(html) { 254 | expect($(testView.el).html()).to.equal('

Color is blue.

It is primary

'); 255 | expect(html).to.equal('

Color is orange.

It is not primary

'); 256 | done(); 257 | }); 258 | $('script[data-name="model-tmpl"]').remove(); 259 | }); 260 | it('can map an event', function(done) { 261 | var TestView = View.extend(function() { 262 | View.apply(this, arguments); 263 | 264 | this.mapEvent({ 265 | 'input': { 266 | 'click': function() { 267 | done(); 268 | } 269 | } 270 | }); 271 | }, 272 | { 273 | generateHtml: function() { 274 | return ''; 275 | } 276 | }); 277 | 278 | testView = new TestView(el, new Model()); 279 | testView.render().then(function() { 280 | testView.el.find('input').trigger('click'); 281 | }); 282 | }); 283 | it('always binds mapped event handlers to itself (`this` === View instance)', function(done) { 284 | var TestView = View.extend(function() { 285 | View.apply(this, arguments); 286 | 287 | var id = this.id; 288 | 289 | this.mapEvent({ 290 | 'input': { 291 | 'click': function() { 292 | expect(this.id).to.equal(id); 293 | done(); 294 | } 295 | } 296 | }); 297 | }, 298 | { 299 | generateHtml: function() { 300 | return ''; 301 | } 302 | }); 303 | 304 | testView = new TestView(el, new Model()); 305 | testView.render().then(function() { 306 | testView.el.find('input').trigger('click'); 307 | }); 308 | }); 309 | it('can map a widget', function(done) { 310 | var TestView = View.extend(function() { 311 | View.apply(this, arguments); 312 | }, 313 | { 314 | generateHtml: function() { 315 | return '
'; 316 | } 317 | }); 318 | 319 | var MyWidget = Widget.extend(function MyWidget() { 320 | Widget.apply(this, arguments); 321 | this.testProp = 'abc'; 322 | }); 323 | 324 | testView = new TestView(el, new Model()); 325 | testView.mapWidget('.widget', MyWidget); 326 | testView.render().then(function() { 327 | expect(testView.widgets['widget'].testProp).to.equal('abc'); 328 | done(); 329 | }); 330 | $('script[data-name="model-tmpl"]').remove(); 331 | }); 332 | it('can map a widget with custom arguments', function(done) { 333 | var TestView = View.extend(function() { 334 | View.apply(this, arguments); 335 | }, 336 | { 337 | generateHtml: function() { 338 | return '
'; 339 | } 340 | }); 341 | 342 | var MyWidget = Widget.extend(function MyWidget(el, testProp) { 343 | Widget.apply(this, arguments); 344 | this.testProp = testProp; 345 | }); 346 | 347 | var MyOtherWidget = Widget.extend(function MyOtherWidget(el, testStr, testInt) { 348 | Widget.apply(this, arguments); 349 | this.testStr = testStr; 350 | this.testInt = testInt; 351 | }); 352 | 353 | testView = new TestView(el, new Model()); 354 | testView.mapWidget({ 355 | '.widget': { 356 | TWidget: MyWidget, 357 | args: 'xyz' 358 | }, 359 | '.other-widget': { 360 | TWidget: MyOtherWidget, 361 | args: ['qwert', 12345] 362 | } 363 | }); 364 | testView.render().then(function() { 365 | expect(testView.widgets['widget'].testProp).to.equal('xyz'); 366 | expect(testView.widgets['other-widget'].testStr).to.equal('qwert'); 367 | expect(testView.widgets['other-widget'].testInt).to.equal(12345); 368 | done(); 369 | }).catch(function(e) { 370 | fail(e); 371 | done(); 372 | }); 373 | 374 | $('script[data-name="model-tmpl"]').remove(); 375 | }); 376 | }); 377 | -------------------------------------------------------------------------------- /src/mvc/Model.js: -------------------------------------------------------------------------------- 1 | import {default as removeAll} from 'mout/array/removeAll'; 2 | import {default as contains} from 'mout/array/contains'; 3 | import {default as merge} from 'mout/object/merge'; 4 | import {default as Cache} from '../util/Cache'; 5 | import {default as EventDispatcher} from '../events/EventDispatcher'; 6 | 7 | var UNDEFINED; 8 | 9 | let _triggerAttributeEvent = (model, event, attribute, previous, value, messages) => { 10 | model.trigger(event, { 11 | attribute: attribute, 12 | previous: previous === UNDEFINED ? null : previous, 13 | value: value === UNDEFINED ? model.get(attribute) : value, 14 | messages: messages || [] 15 | }); 16 | }; 17 | 18 | let _setFlagOn = (model, name, flag) => { 19 | var keys = model.flags[flag]; 20 | if (!keys) { 21 | keys = model.flags[flag] = []; 22 | } 23 | if (!contains(keys, name)) { 24 | keys.push(name); 25 | } 26 | }; 27 | 28 | let _suppressChecked = (model, suppress, callback) => { 29 | suppress = !!suppress; 30 | var props = ['suppressValidation', 'suppressEvents', 'suppressTracking'], 31 | old = {}, 32 | i = -1, 33 | prop, 34 | result; 35 | while (!!(prop = props[++i])) { 36 | old[prop] = model[prop]; 37 | model[prop] = suppress || model[prop]; 38 | } 39 | result = callback.call(model); 40 | i = -1; 41 | while (!!(prop = props[++i])) { 42 | model[prop] = old[prop]; 43 | } 44 | return result; 45 | }; 46 | 47 | let _isValid = (messages) => { 48 | var isValid = true; 49 | for(let attribute in messages){ 50 | if (messages[attribute].length > 0){ 51 | isValid = false; 52 | } 53 | } 54 | messages.isValid = isValid; 55 | return messages; 56 | }; 57 | 58 | 59 | // Virtual type 60 | /** 61 | * Event type used when an attribute is modified 62 | * @class lavaca.mvc.AttributeEvent 63 | * @extends Event 64 | */ 65 | /** 66 | * The name of the event-causing attribute 67 | * @property {String} attribute 68 | * @default null 69 | */ 70 | /** 71 | * The value of the attribute before the event 72 | * @property {Object} previous 73 | * @default null 74 | */ 75 | /** 76 | * The value of the attribute after the event 77 | * @property {Object} value 78 | * @default null 79 | */ 80 | /** 81 | * A list of validation messages the change caused 82 | * @property {Array} messages 83 | * @default [] 84 | */ 85 | 86 | /** 87 | * Basic model type 88 | * @class lavaca.mvc.Model 89 | * @extends lavaca.events.EventDispatcher 90 | * 91 | * Place the events where they are triggered in the code, see the yuidoc syntax reference and view.js for rendersuccess trigger 92 | * @event change 93 | * @event invalid 94 | * 95 | * @constructor 96 | * @param {Object} [map] A parameter hash to apply to the model 97 | */ 98 | var Model = EventDispatcher.extend(function Model(map) { 99 | var suppressEvents, suppressTracking; 100 | EventDispatcher.call(this); 101 | this.attributes = new Cache(); 102 | this.rules = new Cache(); 103 | this.unsavedAttributes = []; 104 | this.flags = {}; 105 | if (this.defaults) { 106 | map = merge({}, this.defaults, map); 107 | } 108 | if (map) { 109 | suppressEvents = this.suppressEvents; 110 | suppressTracking = this.suppressTracking; 111 | this.suppressEvents 112 | = this.suppressTracking 113 | = true; 114 | this.apply(map); 115 | this.suppressEvents = suppressEvents; 116 | this.suppressTracking = suppressTracking; 117 | } 118 | }, { 119 | /** 120 | * When true, attributes are not validated 121 | * @property suppressValidation 122 | * @default false 123 | * 124 | * @type Boolean 125 | */ 126 | 127 | suppressValidation: false, 128 | /** 129 | * When true, changes to attributes are not tracked 130 | * @property suppressTracking 131 | * @default false 132 | * 133 | * @type Boolean 134 | */ 135 | 136 | suppressTracking: false, 137 | /** 138 | * The name of the ID attribute 139 | * @property id 140 | * @default 'id' 141 | * 142 | * @type String 143 | */ 144 | 145 | idAttribute: 'id', 146 | /** 147 | * Gets the value of a attribute 148 | * @method get 149 | * 150 | * @param {String} attribute The name of the attribute 151 | * @return {Object} The value of the attribute, or null if there is no value 152 | */ 153 | get(attribute) { 154 | var attr = this.attributes.get(attribute), 155 | flags; 156 | if (typeof attr === 'function') { 157 | flags = this.flags[Model.DO_NOT_COMPUTE]; 158 | return !flags || flags.indexOf(attribute) === -1 ? attr.call(this) : attr; 159 | } 160 | return attr; 161 | }, 162 | /** 163 | * Determines whether or not an attribute can be assigned 164 | * @method canSet 165 | * 166 | * @param {String} attribute The name of the attribute 167 | * @return {Boolean} True if you can assign to the attribute 168 | */ 169 | canSet(){ 170 | return true; 171 | }, 172 | /** 173 | * Sets the value of the attribute, if it passes validation 174 | * @method set 175 | * 176 | * @param {String} attribute The name of the attribute 177 | * @param {Object} value The new value 178 | * @return {Boolean} True if attribute was set, false otherwise 179 | * 180 | */ 181 | /** 182 | * Sets the value of the attribute, if it passes validation 183 | * @method set 184 | * 185 | * @param {String} attribute The name of the attribute 186 | * @param {Object} value The new value 187 | * @param {String} flag A metadata flag describing the attribute 188 | * @param {Boolean} suppress When true, validation, events and tracking are suppressed 189 | * @return {Boolean} True if attribute was set, false otherwise 190 | */ 191 | //* @event invalid 192 | //* @event change 193 | 194 | 195 | set(attribute, value, flag, suppress) { 196 | return _suppressChecked(this, suppress, function() { 197 | if (!this.canSet(attribute)) { 198 | return false; 199 | } 200 | var previous = this.attributes.get(attribute), 201 | messages = this.suppressValidation ? [] : this.validate(attribute, value); 202 | if (messages.length) { 203 | _triggerAttributeEvent(this, 'invalid', attribute, previous, value, messages); 204 | return false; 205 | } else { 206 | if (previous !== value) { 207 | this.attributes.set(attribute, value); 208 | if (flag) { 209 | _setFlagOn(this, attribute, flag); 210 | } 211 | _triggerAttributeEvent(this, 'change', attribute, previous, value); 212 | if (!this.suppressTracking 213 | && !contains(this.unsavedAttributes, attribute)) { 214 | this.unsavedAttributes.push(attribute); 215 | } 216 | } 217 | return true; 218 | } 219 | }); 220 | }, 221 | /** 222 | * Determines whether or not this model has a named attribute 223 | * @method has 224 | * 225 | * @param {String} attribute The name of the attribute 226 | * @return {Boolean} True if the attribute exists and has a value 227 | */ 228 | has(attribute) { 229 | return this.get(attribute) !== null; 230 | }, 231 | /** 232 | * Gets the ID of the model 233 | * @method id 234 | * 235 | * @return {String} The ID of the model 236 | */ 237 | id() { 238 | return this.get(this.idAttribute); 239 | }, 240 | /** 241 | * Determines whether or not this model has been saved before 242 | * @method isNew 243 | * 244 | * @return {Boolean} True when the model has no ID associated with it 245 | */ 246 | isNew() { 247 | return null === this.id(); 248 | }, 249 | /** 250 | * Ensures that a map is suitable to be applied to this model 251 | * @method parse 252 | * 253 | * @param {Object} map The string or key-value hash to parse 254 | * @return {Object} The parsed version of the map 255 | */ 256 | parse(map) { 257 | if (typeof map === 'string') { 258 | map = JSON.parse(map); 259 | } 260 | return map; 261 | }, 262 | /** 263 | * Sets each attribute of this model according to the map 264 | * @method apply 265 | * 266 | * @param {Object} map The string or key-value map to parse and apply 267 | */ 268 | /** 269 | * Sets each attribute of this model according to the map 270 | * @method apply 271 | * 272 | * @param {Object} map The string or key-value map to parse and apply 273 | * @param {Boolean} suppress When true, validation, events and tracking are suppressed 274 | */ 275 | apply(map, suppress) { 276 | _suppressChecked(this, suppress, () => { 277 | map = this.parse(map); 278 | for (var n in map) { 279 | this.set(n, map[n]); 280 | } 281 | }); 282 | }, 283 | /** 284 | * Removes all data from the model or removes selected flag from model. 285 | * @method clear 286 | * 287 | * @sig 288 | * Removes all flagged data from the model 289 | * @param {String} flag The metadata flag describing the data to remove 290 | */ 291 | clear(flag) { 292 | if (flag) { 293 | var attrs = this.flags[flag], 294 | i = -1, 295 | attr, 296 | item; 297 | if (attrs) { 298 | while (!!(attr = attrs[++i])) { 299 | removeAll(this.unsavedAttributes, attr); 300 | item = this.get(attr); 301 | if (item && item.dispose) { 302 | item.dispose(); 303 | } 304 | this.set(attr, null); 305 | } 306 | } 307 | } else { 308 | this.attributes.dispose(); 309 | this.attributes = new Cache(); 310 | this.unsavedAttributes.length = 0; 311 | } 312 | }, 313 | /** 314 | * Makes a copy of this model 315 | * @method clone 316 | * 317 | * @return {Lavaca.mvc.Model} The copy 318 | */ 319 | clone() { 320 | return new this.constructor(this.attributes.toObject()); 321 | }, 322 | /** 323 | * Adds a validation rule to this model 324 | * @method addRule 325 | * 326 | * @param {String} attribute The name of the attribute to which the rule applies 327 | * @param {Function} callback The callback to use to validate the attribute, in the 328 | * form callback(attribute, value) 329 | * @param {String} message A text message used when a value fails the test 330 | */ 331 | addRule(attribute, callback, message) { 332 | this.rules.get(attribute, []).push({rule: callback, message: message}); 333 | }, 334 | /** 335 | * Validates all attributes on the model 336 | * @method validate 337 | * 338 | * @return {Object} A map of attribute names to validation error messages 339 | */ 340 | /** 341 | * Runs validation tests for a specific attribute 342 | * @method validate 343 | * 344 | * @param {String} The name of the attribute to test 345 | * @return {Array} A list of validation error messages 346 | */ 347 | /** 348 | * Runs validation against a potential value for a attribute 349 | * @method validate 350 | * @param {String} attribute The name of the attribute 351 | * @param {Object} value The potential value for the attribute 352 | * @return {Array} A list of validation error messages 353 | */ 354 | validate(attribute, value) { 355 | var messages, 356 | rules, 357 | i = -1, 358 | rule; 359 | if (attribute) { 360 | messages = []; 361 | value = value === UNDEFINED ? this.get(attribute, value) : value; 362 | rules = this.rules.get(attribute); 363 | if (rules) { 364 | while (!!(rule = rules[++i])) { 365 | if (!rule.rule(attribute, value)) { 366 | messages.push(rule.message); 367 | } 368 | } 369 | } 370 | return messages; 371 | } else { 372 | messages = {}; 373 | this.rules.each((attributeName) => { 374 | messages[attributeName] = this.validate(attributeName); 375 | }, this); 376 | return _isValid(messages); 377 | } 378 | }, 379 | /** 380 | * Converts this model to a key-value hash 381 | * @method toObject 382 | * 383 | * @return {Object} The key-value hash 384 | */ 385 | toObject() { 386 | var obj = this.attributes.toObject(), 387 | flags; 388 | for(var key in obj) { 389 | if(typeof obj[key] === "function") { 390 | flags = this.flags[Model.DO_NOT_COMPUTE]; 391 | if (!flags || flags.indexOf(key) === -1) { 392 | obj[key] = obj[key].call(this); 393 | } 394 | } 395 | } 396 | return obj; 397 | }, 398 | /** 399 | * Converts this model to JSON 400 | * @method toJSON 401 | * 402 | * @return {String} The JSON string representing the model 403 | */ 404 | toJSON() { 405 | return JSON.stringify(this.toObject()); 406 | }, 407 | /** 408 | * Bind an event handler to this object 409 | * @method on 410 | * 411 | * 412 | * @param {String} type The name of the event 413 | * @param {Function} callback The function to execute when the event occurs 414 | * @return {Lavaca.events.EventDispatcher} This event dispatcher (for chaining) 415 | */ 416 | /** 417 | * Bind an event handler to this object 418 | * @method on 419 | * 420 | * @param {String} type The name of the event 421 | * @param {String} attr An attribute to which to limit the scope of events 422 | * @param {Function} callback The function to execute when the event occurs 423 | * @return {Lavaca.events.EventDispatcher} This event dispatcher (for chaining) 424 | */ 425 | /** 426 | * Bind an event handler to this object 427 | * @method on 428 | * @param {String} type The name of the event 429 | * @param {Function} callback The function to execute when the event occurs 430 | * @param {Object} thisp The context of the handler 431 | * @return {Lavaca.events.EventDispatcher} This event dispatcher (for chaining) 432 | */ 433 | /** 434 | * Bind an event handler to this object 435 | * @method on 436 | * @param {String} type The name of the event 437 | * @param {String} attr An attribute to which to limit the scope of events 438 | * @param {Function} callback The function to execute when the event occurs 439 | * @param {Object} thisp The context of the handler 440 | * @return {Lavaca.events.EventDispatcher} This event dispatcher (for chaining) 441 | */ 442 | on(type, attr, callback, thisp) { 443 | if (typeof attr === 'function') { 444 | thisp = callback; 445 | callback = attr; 446 | attr = null; 447 | } 448 | let handler = (e) => { 449 | if (callback && (!attr || e.attribute === attr)) { 450 | callback.call(thisp || this, e); 451 | } 452 | } 453 | handler.fn = callback; 454 | handler.thisp = thisp; 455 | return EventDispatcher.prototype.on.call(this, type, handler, thisp); 456 | } 457 | }); 458 | /** 459 | * @field {String} SENSITIVE 460 | * @static 461 | * @default 'sensitive' 462 | * Flag indicating that data is sensitive 463 | */ 464 | Model.SENSITIVE = 'sensitive'; 465 | /** 466 | * @field {String} DO_NOT_COMPUTE 467 | * @static 468 | * @default 'do_not_compute' 469 | * Flag indicating that the selected attribute should not be executed 470 | * as a computed property and should instead just return the function. 471 | */ 472 | Model.DO_NOT_COMPUTE = 'do_not_compute'; 473 | 474 | export default Model; -------------------------------------------------------------------------------- /test/unit/mvc/Collection-spec.js: -------------------------------------------------------------------------------- 1 | import Collection from 'lavaca/mvc/Collection'; 2 | import Model from 'lavaca/mvc/Model'; 3 | import {isArray} from 'mout/lang'; 4 | 5 | describe('A Collection', function() { 6 | var testCollection, 7 | colors; 8 | beforeEach(function() { 9 | testCollection = new Collection(); 10 | colors = [ 11 | {id: 1, color: 'red', primary: true}, 12 | {id: 2, color: 'green', primary: true}, 13 | {id: 3, color: 'blue', primary: true}, 14 | {id: 4, color: 'yellow', primary: false} 15 | ]; 16 | }); 17 | afterEach(function() { 18 | // testCollection.clear(); 19 | }); 20 | it('can be initialized', function() { 21 | expect(testCollection instanceof Collection).toEqual(true); 22 | }); 23 | it('can be initialized with a list of models', function() { 24 | testCollection = new Collection(colors); 25 | expect(testCollection.count()).toEqual(4); 26 | }); 27 | it('can be initialized with a hash of attributes', function() { 28 | testCollection = new Collection(colors, { 29 | extra: 'stuff' 30 | }); 31 | expect(testCollection.get('extra')).toEqual('stuff'); 32 | }); 33 | it('can be cleared of all its models', function() { 34 | testCollection = new Collection(colors); 35 | testCollection.clear(); 36 | expect(testCollection.count()).toEqual(0); 37 | }); 38 | it('can add models with a custom model type', function() { 39 | var colorModel = Model.extend({ 40 | isBlack: function() { 41 | return this.get('color') === 'black'; 42 | } 43 | }), 44 | colorCollection = Collection.extend({ 45 | TModel: colorModel 46 | }); 47 | testCollection = new colorCollection(colors); 48 | expect(testCollection.models[0] instanceof colorModel).toEqual(true); 49 | }); 50 | it('can insert models at a specified index', function() { 51 | var items = [ 52 | {id: 3, color: 'blue', primary: true}, 53 | {id: 4, color: 'yellow', primary: false}, 54 | {id: 5, color: 'orange', primary: false}, 55 | {id: 6, color: 'purple', primary: false}, 56 | {id: 7, color: 'magenta', primary: false} 57 | ], 58 | noop = { 59 | addItem: function() {}, 60 | removedItem: function() {} 61 | }, 62 | expectedResult = []; 63 | spyOn(noop, 'addItem'); 64 | spyOn(noop, 'removedItem'); 65 | testCollection = new Collection(colors); 66 | testCollection.on('addItem', noop.addItem); 67 | testCollection.on('removeItem', noop.removedItem); 68 | testCollection.insert(1, items); 69 | expect(noop.addItem.callCount).toEqual(5); 70 | expect(noop.removedItem.callCount).toEqual(2); 71 | expectedResult = [colors[0]].concat(items).concat([colors[1]]); 72 | expect(testCollection.toObject().items).toEqual(expectedResult); 73 | }); 74 | it('can find the index of a model matching an attribute hash', function() { 75 | testCollection = new Collection(colors); 76 | expect(testCollection.indexOf({id: 2, color: 'green'})).toEqual(1); 77 | expect(testCollection.indexOf({id: 500, color: 'green'})).toEqual(-1); 78 | }); 79 | it('can find the index of a model matching a functional test', function() { 80 | var testFunc = function(index, model) { 81 | return model.get('id') === 2; 82 | }; 83 | testCollection = new Collection(colors); 84 | expect(testCollection.indexOf(testFunc)).toEqual(1); 85 | testCollection.remove(1); 86 | expect(testCollection.indexOf(testFunc)).toEqual(-1); 87 | }); 88 | it('can move an item to a new index', function() { 89 | testCollection = new Collection(colors); 90 | testCollection.on('moveItem', function(e) { 91 | expect(testCollection.itemAt(3).toObject()).toEqual(colors[0]); 92 | expect(e.index).toBe(3); 93 | }); 94 | testCollection.moveTo(0, 3); 95 | }); 96 | it('can be filtered by attributes', function() { 97 | var filteredArray; 98 | testCollection = new Collection(colors); 99 | filteredArray = testCollection.filter({'primary': true}); 100 | expect(filteredArray.length).toEqual(3); 101 | filteredArray = testCollection.filter({'primary': true}, 2); 102 | expect(filteredArray.length).toEqual(2); 103 | }); 104 | it('can be filtered by a function', function() { 105 | var filteredArray; 106 | testCollection = new Collection(colors); 107 | filteredArray = testCollection.filter(function(i, item) { 108 | return item.get('primary'); 109 | }); 110 | expect(filteredArray.length).toEqual(3); 111 | filteredArray = testCollection.filter(function(i, item) { 112 | return item.get('primary'); 113 | }, 2); 114 | expect(filteredArray.length).toEqual(2); 115 | }); 116 | it('can return the first item that matches a test', function() { 117 | var filteredArray; 118 | testCollection = new Collection(colors); 119 | filteredArray = testCollection.first({'primary': true}); 120 | expect(filteredArray instanceof Model).toEqual(true); 121 | filteredArray = testCollection.first(function(i, item) { 122 | return item.get('primary'); 123 | }); 124 | expect(filteredArray instanceof Model).toEqual(true); 125 | }); 126 | it('can iterate on its models with each()', function() { 127 | var myCount = 0; 128 | testCollection = new Collection(colors); 129 | testCollection.each(function(i, item) { 130 | if(item.get('primary')) { 131 | myCount++; 132 | } 133 | }); 134 | expect(myCount).toEqual(3); 135 | }); 136 | it('can stop iteration early', function() { 137 | var myCount = 0; 138 | testCollection = new Collection(colors); 139 | testCollection.each(function(i, item) { 140 | myCount++; 141 | if (item.get('color') === 'blue') { 142 | return false; 143 | } 144 | }); 145 | expect(myCount).toEqual(3); 146 | }); 147 | it('can convert its models into an object of attributes', function() { 148 | testCollection = new Collection(colors); 149 | expect(testCollection.toObject().items).toEqual(colors); 150 | }); 151 | it('triggers addItem event when a model is added', function() { 152 | var eventModel; 153 | var noop = { 154 | addItem: function(e) { 155 | eventModel = e.model; 156 | } 157 | }; 158 | spyOn(noop, 'addItem').andCallThrough(); 159 | testCollection = new Collection(colors); 160 | testCollection.on('addItem', noop.addItem); 161 | testCollection.add({color: 'purple', primary: false}); 162 | expect(noop.addItem).toHaveBeenCalled(); 163 | expect(eventModel.get('color')).toEqual(testCollection.itemAt(testCollection.count() - 1).get('color')); 164 | }); 165 | it('triggers changeItem event when a model is changed', function() { 166 | var noop = { 167 | changeItem: function(e) { 168 | expect(e.model).toEqual(testCollection.itemAt(4)); 169 | } 170 | }; 171 | spyOn(noop, 'changeItem').andCallThrough(); 172 | testCollection = new Collection(colors); 173 | testCollection.on('changeItem', noop.changeItem); 174 | testCollection.add({color: 'purple', primary: false}); 175 | testCollection 176 | .itemAt(4) 177 | .set('color', 'grey'); 178 | expect(noop.changeItem).toHaveBeenCalled(); 179 | }); 180 | it('triggers moveItem events when models are moved', function() { 181 | var noop = { 182 | moveItem: function(e) { 183 | moveRecords.push([e.model.get('testVal'), e.previousIndex, e.index]); 184 | } 185 | }, 186 | moveRecords = []; 187 | spyOn(noop, 'moveItem').andCallThrough(); 188 | testCollection.on('moveItem', noop.moveItem); 189 | testCollection.add([ 190 | { testVal: 'B' }, 191 | { testVal: 'C' }, 192 | { testVal: 'A' }, 193 | { testVal: 'D' } 194 | ]); 195 | testCollection.moveTo(2, 1); 196 | expect(testCollection.sort('testVal').toObject().items).toEqual([ 197 | { testVal: 'A' }, 198 | { testVal: 'B' }, 199 | { testVal: 'C' }, 200 | { testVal: 'D' } 201 | ]); 202 | expect(noop.moveItem.callCount).toEqual(3); 203 | expect(moveRecords).toEqual([ 204 | ['A', 2, 1], 205 | ['A', 1, 0], 206 | ['B', 0, 1] 207 | ]); 208 | }); 209 | it('triggers removeItem event models items are removed', function() { 210 | var noop = { 211 | removedItem: function(e) { 212 | expect(e.model).toEqual(model); 213 | } 214 | }, 215 | model; 216 | spyOn(noop, 'removedItem').andCallThrough(); 217 | testCollection = new Collection(colors); 218 | testCollection.on('removeItem', noop.removedItem); 219 | model = testCollection.itemAt(1); 220 | testCollection.remove(1); 221 | expect(noop.removedItem).toHaveBeenCalled(); 222 | }); 223 | it('can sort via a specified attribute name', function() { 224 | var noop = { 225 | moveItem: function() {} 226 | }; 227 | spyOn(noop, 'moveItem'); 228 | testCollection.on('moveItem', noop.moveItem); 229 | testCollection.add([ 230 | { testVal: 'B' }, 231 | { testVal: 'C' }, 232 | { testVal: 'A' } 233 | ]); 234 | expect(testCollection.sort('testVal').toObject().items).toEqual([ 235 | { testVal: 'A' }, 236 | { testVal: 'B' }, 237 | { testVal: 'C' } 238 | ]); 239 | expect(noop.moveItem.callCount).toEqual(3); 240 | }); 241 | it('can sort via a specified attribute name in descending order', function() { 242 | var noop = { 243 | moveItem: function() {} 244 | }; 245 | spyOn(noop, 'moveItem'); 246 | testCollection.on('moveItem', noop.moveItem); 247 | testCollection.add([ 248 | { testVal: 'B' }, 249 | { testVal: 'C' }, 250 | { testVal: 'A' } 251 | ]); 252 | expect(testCollection.sort('testVal', true).toObject().items).toEqual([ 253 | { testVal: 'C' }, 254 | { testVal: 'B' }, 255 | { testVal: 'A' } 256 | ]); 257 | expect(noop.moveItem.callCount).toEqual(2); 258 | }); 259 | it('can sort via a compare function', function() { 260 | var noop = { 261 | moveItem: function() {} 262 | }; 263 | 264 | function compareFunc(modelA, modelB) { 265 | var a = modelA.get('testVal'), 266 | b = modelB.get('testVal'); 267 | return a === b 268 | ? 0 269 | : a < b 270 | ? -1 271 | : 1; 272 | } 273 | spyOn(noop, 'moveItem').andCallThrough(); 274 | testCollection.on('moveItem', noop.moveItem); 275 | testCollection.add([ 276 | { testVal: 'B' }, 277 | { testVal: 'C' }, 278 | { testVal: 'A' } 279 | ]); 280 | expect(testCollection.sort(compareFunc).toObject().items).toEqual([ 281 | { testVal: 'A' }, 282 | { testVal: 'B' }, 283 | { testVal: 'C' } 284 | ]); 285 | expect(noop.moveItem.callCount).toEqual(3); 286 | }); 287 | it('can reverse order of models', function() { 288 | testCollection.add([ 289 | { testVal: 'A' }, 290 | { testVal: 'B' }, 291 | { testVal: 'C' } 292 | ]); 293 | expect(testCollection.reverse().toObject().items).toEqual([ 294 | { testVal: 'C' }, 295 | { testVal: 'B' }, 296 | { testVal: 'A' } 297 | ]); 298 | }); 299 | it('can clear models without clearing attributes', function() { 300 | testCollection.add(colors); 301 | testCollection.set('attrKey', 'attrValue'); 302 | expect(testCollection.count()).toEqual(4); 303 | testCollection.clearModels(); 304 | expect(testCollection.get('attrKey')).toEqual('attrValue'); 305 | expect(testCollection.count()).toEqual(0); 306 | expect(testCollection.changedOrder).toEqual(false); 307 | ['addedItems', 'changedItems', 'models', 'removedItems'].forEach(function(key) { 308 | expect(isArray(testCollection[key])).toBe(true); 309 | expect(testCollection[key].length).toEqual(0); 310 | }); 311 | }); 312 | it('can remove one or more items by passing in comma separated params', function () { 313 | testCollection.add(colors); 314 | testCollection.remove({color: 'red', primary: true}); 315 | expect(testCollection.count()).toEqual(3); 316 | testCollection.remove({color: 'blue'}, {color: 'green'}); 317 | expect(testCollection.count()).toEqual(1); 318 | expect(testCollection.first({color: 'blue'})).toEqual(null); 319 | expect(testCollection.first({color: 'green'})).toEqual(null); 320 | }); 321 | it('can remove one or more items by passing in an array', function () { 322 | testCollection.add(colors); 323 | testCollection.remove([{color: 'red', primary: true}, {color: 'blue'}]); 324 | expect(testCollection.count()).toEqual(2); 325 | expect(testCollection.first({color: 'red'})).toEqual(null); 326 | expect(testCollection.first({color: 'blue'})).toEqual(null); 327 | }); 328 | it('can remove an item by passing in an index', function () { 329 | testCollection.add(colors); 330 | testCollection.remove(2); 331 | expect(testCollection.count()).toEqual(3); 332 | expect(testCollection.first({color: 'red'})).toBeTruthy(); 333 | expect(testCollection.first({color: 'blue'})).toEqual(null); 334 | }); 335 | it('returns false when trying to remove an item at an invalid index', function () { 336 | testCollection.add(colors); 337 | expect(testCollection.remove(testCollection.count())).toEqual(false); 338 | expect(testCollection.remove(-1)).toEqual(false); 339 | }); 340 | it('should replace the old item(s) when trying to add items with duplicated IDs', function () { 341 | var filtered; 342 | testCollection.add(colors); 343 | testCollection.add({ color: 'grey'}); 344 | expect(testCollection.count()).toEqual(5); 345 | testCollection.add({ id: 1, color: '#f0f0f0'}); 346 | expect(testCollection.count()).toEqual(5); 347 | filtered = testCollection.filter({id: 1}); 348 | expect(filtered.length).toEqual(1); 349 | expect(filtered[0].get('color')).toEqual('#f0f0f0'); 350 | }); 351 | it('should not keep items with duplicated IDs when Collection.allowDuplicatedIds flag is false (default)', function () { 352 | var obj = {id: 4, color: '#efefef', primary: false}; 353 | colors.push(obj, obj); 354 | testCollection.add(colors); 355 | expect(testCollection.count()).toEqual(4); 356 | colors.splice(-3, 2); 357 | expect(testCollection.toObject().items).toEqual(colors); 358 | }); 359 | it('should keep items with duplicated IDs if Collection.allowDuplicatedIds flag is set to true', function () { 360 | var TCollection = Collection.extend({ 361 | allowDuplicatedIds: true 362 | }); 363 | var items = [{id: 4, color: '#efefef', primary: false}, {id: 3, color: 'transparent', primary: true}]; 364 | var testCollection; 365 | [].push.apply(colors, items); 366 | testCollection = new TCollection(colors); 367 | expect(testCollection.count()).toEqual(colors.length); 368 | expect(testCollection.toObject().items).toEqual(colors); 369 | }); 370 | }); 371 | 372 | --------------------------------------------------------------------------------