├── .bowerrc ├── .gitignore ├── .jshintrc ├── .travis.yml ├── CHANGELOG.md ├── CONTRIBUTING.md ├── Gruntfile.js ├── LICENSE ├── README.md ├── benchmark ├── bench.html ├── bench.js └── suite.js ├── bower.json ├── build.json ├── component.json ├── composer.json ├── lumbar.json ├── package.json ├── src ├── collection.js ├── data-object.js ├── deferrable.js ├── event.js ├── form.js ├── fragments │ └── scope.handlebars ├── helper-view.js ├── helpers │ ├── button-link.js │ ├── collection.js │ ├── element.js │ ├── empty.js │ ├── loading.js │ ├── super.js │ ├── template.js │ ├── url.js │ └── view.js ├── layout.js ├── loading.js ├── mixin.js ├── mobile.js ├── mobile │ └── tap-highlight.js ├── model.js ├── server-marshal.js ├── server-side.js ├── thorax.js └── util.js ├── tasks ├── build.js ├── fruit-loops.js ├── util │ └── git.js └── version.js └── test ├── README.md ├── fruit-loops.html ├── jquery-backbone-1-0.html ├── jquery.html ├── lib ├── backbone-1-0.js ├── expect.js ├── handlebars-reset.js ├── json2.js └── sinon-ie.js ├── src ├── collection.js ├── deferrable.js ├── event.js ├── form.js ├── helper-view.js ├── helpers │ ├── button-link.js │ ├── collection.js │ ├── element.js │ ├── empty.js │ ├── super.js │ ├── template.js │ ├── url.js │ └── view.js ├── layout.js ├── loading.js ├── model.js ├── server-marshal.js ├── server-side.js ├── thorax.js └── util.js ├── zepto-backbone-1-0.html └── zepto.html /.bowerrc: -------------------------------------------------------------------------------- 1 | { 2 | "directory": "components" 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_STORE 2 | .project 3 | node_modules 4 | components 5 | build 6 | dist 7 | *.sublime-project 8 | *.sublime-workspace 9 | sauce_connect.log -------------------------------------------------------------------------------- /.jshintrc: -------------------------------------------------------------------------------- 1 | /*dotfiles browser, thorax, mocha, chai*/ 2 | { 3 | "node": false, 4 | "esnext": false, 5 | "browser": true, 6 | "bitwise": true, 7 | "curly": true, 8 | "eqeqeq": false, 9 | "eqnull": true, 10 | "forin": true, 11 | "immed": false, 12 | "latedef": false, 13 | "newcap": true, 14 | "noarg": true, 15 | "noempty": true, 16 | "nonew": false, 17 | "plusplus": false, 18 | "regexp": false, 19 | "undef": true, 20 | "unused": false, 21 | "strict": false, 22 | "trailing": true, 23 | "maxparams": 5, 24 | "asi": false, 25 | "boss": false, 26 | "expr": true, 27 | "laxbreak": true, 28 | "loopfunc": true, 29 | "shadow": true, 30 | "nonstandard": true, 31 | "onevar": false, 32 | "-W082": true, 33 | "predef": [ 34 | "$", 35 | "_", 36 | "Backbone", 37 | "Handlebars", 38 | "Thorax", 39 | "describe", 40 | "it", 41 | "before", 42 | "after", 43 | "beforeEach", 44 | "afterEach", 45 | "chai", 46 | "expect", 47 | "sinon" 48 | ] 49 | } 50 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: node_js 3 | node_js: 4 | - 0.1 5 | before_script: 6 | - npm install -g grunt-cli 7 | - "./node_modules/bower/bin/bower install" 8 | - export DISPLAY=:99.0 9 | - sh -e /etc/init.d/xvfb start 10 | - sleep 5 11 | notifications: 12 | webhooks: 13 | urls: 14 | secure: RaVEv4a1Dk75pN5R+0/gfdZleNvw++ojCkwxS1Nrm6hCWLAdfRpDL5QPq/a6iKYu6+HFEt5b37YUIx14Q12L7BhVBtLQhbxEwRV0gD00CcfuxqHENv52vNIVXH0MkENFwW0hE9HaMVXkFho0IvHmSBYNXSQywyP6LhL3fP+Ia+Y= 15 | on_success: change 16 | on_failure: always 17 | env: 18 | global: 19 | - secure: BE8kRmR7tK6Akxru7zEdXs6W6vQnJYWluBtKi3DUJx29CQ6lzD8OvnBLOl2MaVeeXK63xDxhKQC8OQZ8Ux5Y78WYnWpKtA+r/GZuhkMQCIx7z+xpnBlWZCGjxK0USrlnuZL1sBA1AI7lB3burXuxivAfj++j1WdcX2jBl91204U= 20 | - secure: TCgiYb5FeyuXq2bmCguavSMZuAUhz2oZZPIIY+ksIPxEZY6eoIRvMDOZeJnUknNnDzX/XhXXFGSgSZ6fKflNH4xEeQeonxRWczh0sF5p8i8tv+ZUs4PECBuy4phRBISfJ4mtF5ai42t1x4EBgv506Zo9Ea6I/akAj9H7+d7b1bw= 21 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # How to Contribute 2 | 3 | ## Reporting Issues 4 | 5 | Should you run into other issues with the project, please don't hesitate to let us know by filing an [issue][issue]! In general we are going to ask for an example of the problem failing, which can be as simple as a jsfiddle/jsbin/etc. Jsfiddle provides a Thorax framework target to ease creating test cases. 6 | 7 | Pull requests containing only failing thats demonstrating the issue are welcomed and this also helps ensure that your issue won't regress in the future once it's fixed. 8 | 9 | ## Pull Requests 10 | 11 | We also accept [pull requests][pull-request]! 12 | 13 | Generally we like to see pull requests that 14 | - Maintain the existing code style 15 | - Are focused on a single change (i.e. avoid large refactoring or style adjustments in untouched code if not the primary goal of the pull request) 16 | - Have [good commit messages](http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html) 17 | - Have tests 18 | 19 | ## Building 20 | 21 | To build you'll need a few things installed: 22 | 23 | * Node.js 24 | * [Grunt](http://gruntjs.com/getting-started) 25 | 26 | Project dependencies may be installed via `npm install`. 27 | 28 | The `grunt dev` implements watching for tests and allows for in browser testing at `http://localhost:9999/jquery/test.html` and `http://localhost:9999/zepto/test.html`. 29 | 30 | If you notice any problems, please report them to the GitHub issue tracker. 31 | 32 | ## Releasing 33 | 34 | Thorax utilizes the [release yeoman generator][generator-release] to perform most release tasks. 35 | 36 | ```sh 37 | npm install -g yo generator-release 38 | ``` 39 | 40 | A full release may be completed with the following: 41 | 42 | ```sh 43 | grunt clean thorax:build 44 | yo release 45 | npm publish 46 | yo release:publish cdnjs thorax build/release 47 | yo release:publish components thorax build/release 48 | ``` 49 | 50 | [generator-release]: https://github.com/walmartlabs/generator-release 51 | [pull-request]: https://github.com/walmartlabs/thorax/pull/new/master 52 | [issue]: https://github.com/walmartlabs/thorax/issues/new 53 | -------------------------------------------------------------------------------- /Gruntfile.js: -------------------------------------------------------------------------------- 1 | module.exports = function(grunt) { 2 | var grep = grunt.option('grep'), 3 | mochaArgs = grep ? '?grep=' + grep : ''; 4 | 5 | grunt.initConfig({ 6 | jshint: { 7 | options: { 8 | jshintrc: '.jshintrc' 9 | }, 10 | files: [ 11 | 'src/**/*.js' 12 | ] 13 | }, 14 | 15 | clean: ['build'], 16 | 17 | 'phoenix-build': { 18 | dev: { 19 | options: { 20 | lumbarFile: 'build.json', 21 | build: true, 22 | output: 'build/dev', 23 | sourceMap: true 24 | } 25 | } 26 | }, 27 | 28 | connect: { 29 | server: { 30 | options: { 31 | base: 'build/dev', 32 | hostname: '*', 33 | port: 9999 34 | } 35 | } 36 | }, 37 | 38 | 'mocha_phantomjs': { 39 | options: { 40 | reporter: 'dot' 41 | }, 42 | quick: { 43 | options: { 44 | urls: [ 45 | 'http://localhost:9999/jquery/test.html' + mochaArgs, 46 | 'http://localhost:9999/zepto/test.html' + mochaArgs 47 | ] 48 | } 49 | }, 50 | legacy: { 51 | options: { 52 | urls: [ 53 | 'http://localhost:9999/jquery-backbone-1-0/test.html' + mochaArgs, 54 | 'http://localhost:9999/zepto-backbone-1-0/test.html' + mochaArgs 55 | ] 56 | } 57 | } 58 | }, 59 | 60 | 'saucelabs-mocha': { 61 | options: { 62 | testname: 'thorax', 63 | build: process.env.TRAVIS_JOB_ID, 64 | tunnelArgs: [ 65 | '--verbose' 66 | ] 67 | }, 68 | jquery: { 69 | options: { 70 | tags: ['jquery'], 71 | urls: [ 72 | 'http://localhost:9999/jquery/test.html', 73 | 'http://localhost:9999/jquery-backbone-1-0/test.html' 74 | ], 75 | browsers: [ 76 | {browserName: 'chrome'}, 77 | {browserName: 'firefox'}, 78 | {browserName: 'safari', version: 7, platform: 'OS X 10.9'}, 79 | {browserName: 'internet explorer', version: 11, platform: 'Windows 8.1'}, 80 | {browserName: 'internet explorer', version: 8, platform: 'XP'} 81 | ] 82 | } 83 | }, 84 | zepto: { 85 | options: { 86 | tags: ['zepto'], 87 | urls: [ 88 | 'http://localhost:9999/zepto/test.html' 89 | ], 90 | browsers: [ 91 | {browserName: 'chrome'}, 92 | {browserName: 'firefox'}, 93 | {browserName: 'internet explorer', version: 11, platform: 'Windows 8.1'} 94 | ] 95 | } 96 | } 97 | }, 98 | 99 | watch: { 100 | scripts: { 101 | options: { 102 | atBegin: true 103 | }, 104 | 105 | files: ['src/**/*.js', 'test/**/*.js'], 106 | tasks: ['jshint', 'phoenix-build:dev', 'mocha_phantomjs:quick', 'fruit-loops:test'] 107 | } 108 | } 109 | }); 110 | 111 | grunt.loadNpmTasks('grunt-contrib-clean'); 112 | grunt.loadNpmTasks('grunt-contrib-connect'); 113 | grunt.loadNpmTasks('grunt-contrib-jshint'); 114 | grunt.loadNpmTasks('grunt-contrib-watch'); 115 | grunt.loadNpmTasks('grunt-mocha-phantomjs'); 116 | grunt.loadNpmTasks('grunt-saucelabs'); 117 | grunt.loadNpmTasks('phoenix-build'); 118 | 119 | grunt.loadTasks('tasks'); 120 | 121 | 122 | grunt.registerTask('sauce', process.env.SAUCE_USERNAME ? ['saucelabs-mocha:zepto', 'saucelabs-mocha:jquery'] : []); 123 | 124 | grunt.registerTask('test', ['clean', 'connect', 'jshint', 'phoenix-build', 'fruit-loops:test', 'mocha_phantomjs', 'sauce']); 125 | grunt.registerTask('dev', ['clean', 'connect', 'watch']); 126 | }; 127 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright (c) 2011-2013 @WalmartLabs 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to 6 | deal in the Software without restriction, including without limitation the 7 | rights to use, copy, modify, merge, publish, distribute, sublicense, and/or 8 | sell copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in 12 | all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 19 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 20 | DEALINGS IN THE SOFTWARE. 21 | */ 22 | -------------------------------------------------------------------------------- /benchmark/bench.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Thorax Test Suite 6 | 7 | 8 | 9 | 10 |
11 |
12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /benchmark/bench.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | var Suites = require('./suite'); 4 | var suites = new Suites(); 5 | 6 | suites.add('View creation', { 7 | setup: function() { 8 | var ChildView = Thorax.View.extend({ 9 | events: { 10 | 'baz': function() {} 11 | } 12 | }); 13 | ChildView.on({ 14 | model: { 15 | 'bar': function() {} 16 | }, 17 | 'foo': function() { 18 | }, 19 | 'bar': function() { 20 | }, 21 | 'nested click': function() { 22 | } 23 | }); 24 | var SubChildView = ChildView.extend({ 25 | events: { 26 | 'baz': function() {} 27 | } 28 | }); 29 | SubChildView.on({ 30 | 'foo': function() { 31 | }, 32 | 'bar': function() { 33 | } 34 | }); 35 | }, 36 | test: function() { 37 | new SubChildView(); 38 | } 39 | }); 40 | suites.add('setModel', { 41 | setup: function() { 42 | var View = Thorax.View.extend({ 43 | events: { 44 | model: { 45 | foo: function() {} 46 | } 47 | } 48 | }); 49 | 50 | var ChildModel = Thorax.Model.extend({}); 51 | var SubChildModel = ChildModel.extend({}); 52 | 53 | var view = new View({ 54 | }); 55 | }, 56 | test: function() { 57 | var model = new SubChildModel() 58 | view.setModel(model); 59 | view.setModel(false); 60 | } 61 | }); 62 | 63 | suites.add('Collection Render', { 64 | setup: function() { 65 | var collection = new Thorax.Collection([{id: 1}, {id: 2}, {id: 3}, {id: 4}, {id: 5}, {id: 6}]); 66 | var View = Thorax.View.extend({ 67 | template: Handlebars.compile('{{collection}}'), 68 | itemTemplate: Handlebars.compile('
{{id}}{{bar}}
'), 106 | model: new Thorax.Model({foo: 'bar', baz: 'bat'}), 107 | 108 | bar: 'baz' 109 | }); 110 | view.render(); 111 | }, 112 | test: function() { 113 | view.render(); 114 | } 115 | }); 116 | suites.add('Children rendering', { 117 | setup: function() { 118 | var parentTemplate = Handlebars.compile('foo{{view child1}}
{{view child2}}
'); 119 | }, 120 | test: function() { 121 | var parent = new Thorax.View({ 122 | template: parentTemplate, 123 | 124 | child1: new Thorax.View({template: function() { return 'foo'; }}), 125 | child2: new Thorax.View({template: function() { return 'foo'; }}) 126 | }); 127 | 128 | parent.render(); 129 | parent.release(); 130 | } 131 | }); 132 | suites.add('Children destroy', { 133 | test: function() { 134 | var parent = new Thorax.View({ 135 | child1: new Thorax.View(), 136 | child2: new Thorax.View() 137 | }); 138 | 139 | parent._addChild(parent.child1); 140 | parent._addChild(parent.child2); 141 | parent.release(); 142 | } 143 | }); 144 | 145 | suites.add('url helper', { 146 | test: function() { 147 | Handlebars.helpers.url('foo', 'bar', 'baz', 'bat', {}); 148 | Handlebars.helpers.url('/foo/bar/baz/bat', {}); 149 | } 150 | }); 151 | 152 | suites.add('template helper', { 153 | setup: function() { 154 | var template = Handlebars.compile('{{template "foo"}}'); 155 | var block = Handlebars.compile('{{#template "foo" foo=true}}bar{{/template}}'); 156 | Handlebars.templates.foo = Handlebars.compile('foo'); 157 | 158 | var view = new Thorax.View(); 159 | view.foo = 'bar'; 160 | }, 161 | test: function() { 162 | view.template = template; 163 | view.render(); 164 | 165 | view.template = block; 166 | view.render(); 167 | } 168 | }); 169 | 170 | suites.add('uniqueId', { 171 | test: function() { 172 | _.uniqueId(); 173 | } 174 | }); 175 | -------------------------------------------------------------------------------- /benchmark/suite.js: -------------------------------------------------------------------------------- 1 | var fs = require('fs'); 2 | var path = require('path'); 3 | 4 | var Benchmark = require('benchmark'), 5 | FruitLoops = require('fruit-loops'); 6 | 7 | var filterRe = /./; 8 | 9 | var Suites = module.exports = function() {}; 10 | 11 | Suites.prototype.filter = function(str) { 12 | filterRe = new RegExp(str, 'i'); 13 | }; 14 | 15 | Suites.prototype.add = function(name, options) { 16 | var markup, suite, testFn; 17 | if (!filterRe.test(name)) { 18 | return; 19 | } 20 | suite = new Benchmark.Suite(name); 21 | testFn = options.test; 22 | 23 | suite.on('start', function(event) { 24 | console.log('Test: ' + name); 25 | }); 26 | suite.on('cycle', function(event) { 27 | if (event.target.error) { 28 | return; 29 | } 30 | console.log('\t' + String(event.target)); 31 | }); 32 | suite.on('error', function(event) { 33 | console.log('*** Error in ' + event.target.name + ': ***'); 34 | console.log('\t' + event.target.error.stack); 35 | console.log('*** Test invalidated. ***'); 36 | }); 37 | 38 | this._bench(suite, options); 39 | }; 40 | 41 | Suites.prototype._bench = function(suite, options) { 42 | var page = FruitLoops.page({ 43 | index: __dirname + '/../build/dev/fruit-loops/bench.html', 44 | evil: true, 45 | 46 | resolver: function(href, window) { 47 | if (!/-server/.test(href)) { 48 | href = href.replace(/\.js$/, '-server.js'); 49 | } 50 | return path.resolve(__dirname + '/../build/dev/fruit-loops/', href); 51 | }, 52 | beforeExec: function(page, next) { 53 | // Prevent tests from causing an emit to occur 54 | page.window.FruitLoops.emit = function() {}; 55 | next(); 56 | }, 57 | loaded: function(page) { 58 | var test = ''; 59 | if (options.setup) { 60 | test = options.setup.toString().replace(/^function\s*\(\)\s*\{([\s\S]+)\}$/, '$1'); 61 | } 62 | test += 'window.testFn = ' + options.test + ';'; 63 | 64 | page.runScript(test, function(err) { 65 | if (err) { 66 | throw err; 67 | } 68 | 69 | setImmediate(function() { 70 | global.testFn = page.window.testFn; 71 | suite.run(); 72 | }); 73 | }); 74 | } 75 | }); 76 | 77 | function exec() { 78 | testFn(); 79 | } 80 | 81 | var testFn; 82 | suite.add('thorax', function() { 83 | testFn(); 84 | }); 85 | }; 86 | -------------------------------------------------------------------------------- /bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "thorax", 3 | "version": "3.0.0-beta.5", 4 | "devDependencies": { 5 | "jquery": "1.9.x", 6 | "underscore": "1.4.4", 7 | "lodash": "2.4.1", 8 | "zepto": "1.1.3", 9 | "handlebars": "2.x", 10 | "backbone": "1.1.0" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /build.json: -------------------------------------------------------------------------------- 1 | { 2 | "modules": { 3 | 4 | "jquery": { 5 | "mixins": [ 6 | {"name": "thorax-dep-jquery", "ignoreWarnings": true} 7 | ] 8 | }, 9 | "zepto": { 10 | "mixins": [ 11 | {"name": "thorax-dep-zepto", "ignoreWarnings": true} 12 | ], 13 | "scripts": [ 14 | {"src": "test/lib/handlebars-reset.js", "global": true}, 15 | {"src": "components/handlebars/handlebars.js", "ignoreWarnings": true, "global": true} 16 | ] 17 | }, 18 | "fruit-loops": { 19 | "scripts": [ 20 | {"src": "dist/lodash.underscore.js", "bower": "lodash", "global": true, "ignoreWarnings": true}, 21 | {"src": "backbone.js", "bower": "backbone", "global": true, "ignoreWarnings": true}, 22 | {"src": "handlebars.js", "bower": "handlebars", "global": true, "ignoreWarnings": true} 23 | ] 24 | }, 25 | "jquery-backbone-1-0": { 26 | "mixins": [ 27 | {"name": "thorax-dep-jquery", "ignoreWarnings": true, "overrides": {"backbone.js": "test/lib/backbone-1-0.js"}} 28 | ] 29 | }, 30 | "zepto-backbone-1-0": { 31 | "mixins": [ 32 | {"name": "thorax-dep-zepto", "ignoreWarnings": true, "overrides": {"backbone.js": "test/lib/backbone-1-0.js"}} 33 | ], 34 | "scripts": [ 35 | {"src": "test/lib/handlebars-reset.js", "global": true}, 36 | {"src": "components/handlebars/handlebars.js", "ignoreWarnings": true, "global": true} 37 | ] 38 | }, 39 | 40 | "thorax": { 41 | "scripts": [ 42 | {"src": "LICENSE", "global": true} 43 | ], 44 | "mixins": [ 45 | "thorax", 46 | {"name": "thorax-form", "server": false}, 47 | "thorax-helper-tags", 48 | "thorax-loading" 49 | ] 50 | }, 51 | "thorax-mobile": { 52 | "scripts": [ 53 | {"src": "LICENSE", "global": true} 54 | ], 55 | "mixins": [ 56 | "thorax", 57 | {"name": "thorax-form", "server": false}, 58 | "thorax-helper-tags", 59 | "thorax-loading", 60 | "thorax-mobile" 61 | ] 62 | }, 63 | 64 | "test": { 65 | "mixins": [ 66 | { 67 | "name": "test", 68 | "overrides": { 69 | "test/lib/chai.js": "test/lib/expect.js", 70 | "test/lib/sinon-chai.js": false 71 | } 72 | }, 73 | "loaded-test-runner" 74 | ], 75 | "scripts": [ 76 | // IE I hate you 77 | {"src": "test/lib/json2.js", "global": true}, 78 | "test/src/" 79 | ], 80 | "static": [ 81 | {"src": "test/jquery.html", "dest": "jquery/test.html"}, 82 | {"src": "test/zepto.html", "dest": "zepto/test.html"}, 83 | {"src": "test/jquery-backbone-1-0.html", "dest": "jquery-backbone-1-0/test.html"}, 84 | {"src": "test/zepto-backbone-1-0.html", "dest": "zepto-backbone-1-0/test.html"} 85 | ] 86 | }, 87 | "test-ie": { 88 | "scripts": [ 89 | // Must be defined in distinct file from the test declaration that loads sinon itself 90 | // otherwise hoisting will break the hack. Seemingly only applies to IE8 and below 91 | {"src": "test/lib/sinon-ie.js", "global": true} 92 | ] 93 | }, 94 | 95 | 96 | 97 | "test-fruit-loops": { 98 | "mixins": [ 99 | // We will be injecting our own test runners that are able to safely exec in this environment 100 | "loaded-test-runner" 101 | ], 102 | "scripts": [ 103 | "test/src/" 104 | ], 105 | "static": [ 106 | {"src": "test/fruit-loops.html", "dest": "fruit-loops/test.html"}, 107 | {"src": "benchmark/bench.html", "dest": "fruit-loops/bench.html"} 108 | ] 109 | }, 110 | }, 111 | "mixins": [ 112 | "." 113 | ], 114 | "scope": { 115 | "template": "src/fragments/scope.handlebars" 116 | }, 117 | "server": true 118 | } 119 | -------------------------------------------------------------------------------- /component.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "thorax", 3 | "version": "3.0.0-beta.5", 4 | "repo": "components/thorax", 5 | "main": "thorax.js", 6 | "scripts": [ 7 | "thorax.js", 8 | "thorax-mobile.js" 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "components/thorax", 3 | "description": "An opinionated, battle-tested Backbone + Handlebars framework to build large scale web applications.", 4 | "homepage": "http://thoraxjs.org/", 5 | "type": "component", 6 | "license": "MIT", 7 | "authors": [ 8 | { 9 | "name": "Walmart Labs", 10 | "homepage": "http://walmartlabs.com" 11 | } 12 | ], 13 | "support": { 14 | "issues": "https://github.com/walmartlabs/thorax/issues", 15 | "source": "https://github.com/walmartlabs/thorax" 16 | }, 17 | "require": { 18 | "components/handlebars.js" : "*", 19 | "components/underscore": "*", 20 | "components/backbone": "*" 21 | }, 22 | "suggest": { 23 | "components/jquery": "Requires either jQuery or Zepto", 24 | "components/zepto": "Requires either jQuery or Zepto" 25 | }, 26 | "require": { 27 | "components/jquery" : "*", 28 | "components/zepto": "*" 29 | }, 30 | "extra": { 31 | "component": { 32 | "scripts": [ 33 | "thorax.js", 34 | "thorax-mobile.js" 35 | ], 36 | "shim": { 37 | "exports": "Reveal", 38 | "deps": [ 39 | "handlebars", 40 | "underscore", 41 | "backbone" 42 | ] 43 | } 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /lumbar.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "thorax", 3 | 4 | "mixins": { 5 | "thorax-dep-jquery": { 6 | "scripts": [ 7 | {"src": "jquery.js", "bower": "jquery", "global": true, "ignoreWarnings": true, "server": false}, 8 | {"src": "underscore.js", "bower": "underscore", "global": true, "ignoreWarnings": true, "server": false}, 9 | {"src": "dist/lodash.underscore.js", "bower": "lodash", "global": true, "ignoreWarnings": true, "server": true}, 10 | {"src": "backbone.js", "bower": "backbone", "global": true, "ignoreWarnings": true}, 11 | {"src": "handlebars.js", "bower": "handlebars", "global": true, "ignoreWarnings": true}, 12 | ] 13 | }, 14 | "thorax-dep-zepto": { 15 | "scripts": [ 16 | {"src": "zepto.js", "bower": "zepto", "global": true, "ignoreWarnings": true, "server": false}, 17 | {"src": "underscore.js", "bower": "underscore", "global": true, "ignoreWarnings": true, "server": false}, 18 | {"src": "dist/lodash.underscore.js", "bower": "lodash", "global": true, "ignoreWarnings": true, "server": true}, 19 | {"src": "backbone.js", "bower": "backbone", "global": true, "ignoreWarnings": true}, 20 | {"src": "handlebars.runtime.js", "bower": "handlebars", "global": true, "ignoreWarnings": true}, 21 | ] 22 | }, 23 | 24 | "thorax": { 25 | "scripts": [ 26 | {"src": "src/server-side.js"}, 27 | {"src": "src/thorax.js"}, 28 | {"src": "src/util.js"}, 29 | {"src": "src/deferrable.js"}, 30 | {"src": "src/server-marshal.js"}, 31 | {"src": "src/mixin.js"}, 32 | {"src": "src/event.js"}, 33 | {"src": "src/helper-view.js"}, 34 | {"src": "src/data-object.js"}, 35 | {"src": "src/model.js"}, 36 | {"src": "src/collection.js"}, 37 | {"src": "src/layout.js"}, 38 | {"src": "src/helpers/collection.js"}, 39 | {"src": "src/helpers/empty.js"}, 40 | {"src": "src/helpers/template.js"}, 41 | {"src": "src/helpers/url.js"}, 42 | {"src": "src/helpers/view.js"}, 43 | ] 44 | }, 45 | 46 | "thorax-form": { 47 | "scripts": [ 48 | {"src": "src/form.js"}, 49 | ] 50 | }, 51 | 52 | "thorax-helper-tags": { 53 | "scripts": [ 54 | {"src": "src/helpers/button-link.js"}, 55 | {"src": "src/helpers/element.js"}, 56 | {"src": "src/helpers/super.js"}, 57 | ] 58 | }, 59 | 60 | "thorax-loading": { 61 | "scripts": [ 62 | {"src": "src/loading.js"}, 63 | {"src": "src/helpers/loading.js"} 64 | ] 65 | }, 66 | 67 | "thorax-mobile": { 68 | "scripts": [ 69 | {"src": "src/mobile.js", "server": false}, 70 | {"src": "src/mobile/tap-highlight.js", "server": false} 71 | ] 72 | } 73 | }, 74 | 75 | "templates": { 76 | "template": "Handlebars.templates['{{{without-extension name}}}'] = {{handlebarsCall}}({{{data}}});", 77 | 78 | "knownHelpers": [ 79 | "layout-element", 80 | "view", 81 | "template", 82 | "collection", 83 | "empty", 84 | "loading", 85 | "url" 86 | ] 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "thorax", 3 | "version": "3.0.0-beta.5", 4 | "description": "Handlebars + Backbone", 5 | "keywords": [ 6 | "backbone", 7 | "handlebars" 8 | ], 9 | "homepage": "http://thoraxjs.org", 10 | "authors": [ 11 | "Ryan Eastridge (http://eastridge.me)", 12 | "Kevin Decker (http://incaseofstairs.com)" 13 | ], 14 | "repository": { 15 | "type": "git", 16 | "url": "git://github.com/walmartlabs/thorax.git" 17 | }, 18 | "engines": { 19 | "node": ">=0.8" 20 | }, 21 | "devDependencies": { 22 | "async": "0.2.9", 23 | "benchmark": "^1.0.0", 24 | "bower": "0.9.2", 25 | "expect.js": "~0.3.1", 26 | "fruit-loops": "^0.11.0", 27 | "grunt": "0.4.x", 28 | "grunt-contrib-clean": "~0.5.0", 29 | "grunt-contrib-connect": "~0.7.1", 30 | "grunt-contrib-jshint": "~0.8.0", 31 | "grunt-contrib-watch": "~0.5.3", 32 | "grunt-mocha-phantomjs": "~0.4.3", 33 | "grunt-saucelabs": "8.x", 34 | "mocha": "~1.17.1", 35 | "phoenix-build": "3.x", 36 | "semver": "~2.1.0", 37 | "sinon": "~1.9.0", 38 | "uglify-js": "1.3.4" 39 | }, 40 | "scripts": { 41 | "test": "grunt test" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/data-object.js: -------------------------------------------------------------------------------- 1 | /*global $serverSide, getValue, inheritVars, listenTo, walkInheritTree */ 2 | 3 | function dataObject(type, spec) { 4 | spec = inheritVars[type] = _.defaults({ 5 | name: '_' + type + 'Events', 6 | event: true 7 | }, spec); 8 | 9 | spec.preConfig = function(view, options) { 10 | // If we were passed this data object in the options, then we want to 11 | // save it for later so we don't bind to it imediately (and consequently 12 | // be unable to bind to future set* calls) 13 | if (options && options[type]) { 14 | options['_' + type] = options[type]; 15 | options[type] = null; 16 | } 17 | }; 18 | 19 | // Add a callback in the view constructor 20 | spec.ctor = function(view) { 21 | var object = view['_' + type]; 22 | if (object) { 23 | // Need to null this.model/collection so setModel/Collection will 24 | // not treat it as the old model/collection and immediately return 25 | delete view['_' + type]; 26 | 27 | view[spec.set](object); 28 | } 29 | }; 30 | 31 | function setObject(dataObject, options) { 32 | var old = this[type], 33 | $el = getValue(this, spec.$el); 34 | 35 | if (dataObject === old) { 36 | return this; 37 | } 38 | if (old) { 39 | this.unbindDataObject(old); 40 | } 41 | 42 | if (dataObject) { 43 | this[type] = dataObject; 44 | 45 | if (spec.loading) { 46 | spec.loading(this); 47 | } 48 | 49 | this.bindDataObject(type, dataObject, _.extend({}, this.options, options)); 50 | if ($el) { 51 | var attr = {}; 52 | if ($serverSide && spec.idAttrName) { 53 | attr[spec.idAttrName] = dataObject.id; 54 | } 55 | attr[spec.cidAttrName] = dataObject.cid; 56 | $el.attr(attr); 57 | } 58 | dataObject.trigger('set', dataObject, old); 59 | } else { 60 | this[type] = false; 61 | if (spec.change) { 62 | spec.change(this, false); 63 | } 64 | $el && $el.removeAttr(spec.cidAttrName); 65 | } 66 | this.trigger('change:data-object', type, dataObject, old); 67 | return this; 68 | } 69 | 70 | Thorax.View.prototype[spec.set] = setObject; 71 | } 72 | 73 | _.extend(Thorax.View.prototype, { 74 | getObjectOptions: function(dataObject) { 75 | return dataObject && this._objectOptionsByCid[dataObject.cid]; 76 | }, 77 | 78 | bindDataObject: function(type, dataObject, options) { 79 | if (this._boundDataObjectsByCid[dataObject.cid]) { 80 | return; 81 | } 82 | this._boundDataObjectsByCid[dataObject.cid] = dataObject; 83 | 84 | var options = this._modifyDataObjectOptions(dataObject, _.extend({}, inheritVars[type].defaultOptions, options)); 85 | this._objectOptionsByCid[dataObject.cid] = options; 86 | 87 | bindEvents(this, type, dataObject, this.constructor); 88 | bindEvents(this, type, dataObject, this); 89 | 90 | var spec = inheritVars[type]; 91 | spec.bindCallback && spec.bindCallback(this, dataObject, options); 92 | 93 | if (dataObject.shouldFetch && dataObject.shouldFetch(options)) { 94 | loadObject(dataObject, options); 95 | } else if (inheritVars[type].change) { 96 | // want to trigger built in rendering without triggering event on model 97 | inheritVars[type].change(this, dataObject, options); 98 | } 99 | }, 100 | 101 | unbindDataObject: function (dataObject) { 102 | this.stopListening(dataObject); 103 | delete this._boundDataObjectsByCid[dataObject.cid]; 104 | delete this._objectOptionsByCid[dataObject.cid]; 105 | }, 106 | 107 | _modifyDataObjectOptions: function(dataObject, options) { 108 | return options; 109 | } 110 | }); 111 | 112 | function bindEvents(context, type, target, source) { 113 | walkInheritTree(source, '_' + type + 'Events', true, function(event) { 114 | listenTo(context, target, event[0], event[1], event[2] || context); 115 | }); 116 | } 117 | 118 | function loadObject(dataObject, options) { 119 | if (dataObject.load) { 120 | dataObject.load(function() { 121 | options && options.success && options.success(dataObject); 122 | }, options); 123 | } else { 124 | dataObject.fetch(options); 125 | } 126 | } 127 | 128 | function getEventCallback(callback, context) { 129 | if (_.isFunction(callback)) { 130 | return callback; 131 | } else { 132 | return context[callback]; 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /src/deferrable.js: -------------------------------------------------------------------------------- 1 | /*global setImmediate */ 2 | 3 | // Provides a sync/async task runner that allows for operations to run in the 4 | // best mode for their current environment. This is primarily intended for use 5 | // in server side (async) vs. client side (sync) operations but code utilizing 6 | // this should not make assumptions about one state or another. 7 | // 8 | // When `complete` is is a callback passed, all of the tasks will be executed 9 | // asynchronously. If this parameter is omitted, then all tasks will be executed 10 | // synchronously. 11 | // 12 | // All callbacks to `exec`/`chain` are guaranteed to execute in the order that they 13 | // were received. All operations will be run when the `run` call is made, meaning 14 | // the normal code interleaved with deferrable tasks will run before the deferrable 15 | // task. Generally it's not recommended to mix and match the two code styles 16 | // outside of initialization logic. 17 | function Deferrable(complete) { 18 | var queue = []; 19 | 20 | function next() { 21 | if (complete) { 22 | setImmediate(function() { 23 | // Run the task 24 | var callback = queue.shift(); 25 | if (callback) { 26 | callback(); 27 | } else { 28 | // If this is the last task then complete the overall operation 29 | complete(); 30 | } 31 | }); 32 | } else { 33 | /*jshint boss:true */ 34 | var callback; 35 | while (callback = queue.shift()) { 36 | callback(); 37 | } 38 | } 39 | } 40 | 41 | return { 42 | // Registers a task that will always be complete after it returns. 43 | // Execution of subsequent tasks is automatic. 44 | exec: function(callback) { 45 | queue.push(function() { 46 | callback(); 47 | 48 | if (complete) { 49 | next(); 50 | } 51 | }); 52 | }, 53 | 54 | // Registers a task that may optionally defer to another deferrable stack. 55 | // When in async mode the task will recieve a callback to execute further 56 | // tasks after this one is completed. 57 | // 58 | // Note that this is not intended for allowing a true async behavior and 59 | // should only be used to execute additional deferrable chains. 60 | chain: function(callback) { 61 | queue.push(function() { 62 | if (complete) { 63 | callback(next); 64 | } else { 65 | callback(); 66 | } 67 | }); 68 | }, 69 | 70 | // Signal that all potential tasks have been registered and execution should 71 | // commence. 72 | run: function() { 73 | // Check if there were no asyncable calls made and complete immediately 74 | if (complete && !queue.length) { 75 | setImmediate(complete); 76 | } else { 77 | // Otherwise fire off the async processes 78 | next(); 79 | } 80 | } 81 | }; 82 | } 83 | Thorax.Util.Deferrable = Deferrable; 84 | 85 | // Executes an event loop chain with an attached deferrable as the final argument. 86 | // This method expects a final argument to be the callback for the deferrable or 87 | // explicitly undefined. If in a situation where it's known ahead of time that 88 | // there will be no callback value then `trigger` should be used directly. 89 | Thorax.View.prototype.triggerDeferrable = function() { 90 | var args = [], 91 | len = arguments.length - 1, 92 | callback = arguments[len]; 93 | for (var i = 0; i < len; i++) { 94 | args.push(arguments[i]); 95 | } 96 | 97 | var controller = new Deferrable(callback); 98 | args.push(controller); 99 | 100 | this.trigger.apply(this, args); 101 | controller.run(); 102 | }; 103 | -------------------------------------------------------------------------------- /src/event.js: -------------------------------------------------------------------------------- 1 | /*global $serverSide, createInheritVars, inheritVars, listenTo, objectEvents, walkInheritTree */ 2 | // Save a copy of the _on method to call as a $super method 3 | var _on = Thorax.View.prototype.on; 4 | 5 | var eventSplitter = /^(nested\s+)?(\S+)(?:\s+(.+))?/; 6 | 7 | var domEvents = {}, 8 | eventParamsCache = {}; 9 | 10 | (function(events) { 11 | _.each(events, function(event) { domEvents[event] = true; }); 12 | })([ 13 | 'touchstart', 'touchmove', 'touchend', 'touchcancel', 14 | 'mouseenter', 'mouseleave', 'mousemove', 'mousedown', 'mouseup', 'mouseover', 'mouseout', 15 | 'keydown', 'keyup', 'keypress', 16 | 'contextmenu', 17 | 'click', 'dblclick', 18 | 'focusin', 'focusout', 'focus', 'blur', 19 | 'submit', 'input', 'change', 20 | 'dragstart', 'drag', 'dragenter', 'dragleave', 'dragover', 'drop', 'dragend', 21 | 22 | 'singleTap', 'doubleTap', 'longTap', 23 | 'swipe', 24 | 'swipeUp', 'swipeDown', 25 | 'swipeLeft', 'swipeRight' 26 | ]); 27 | 28 | inheritVars.event = { 29 | name: '_events', 30 | 31 | configure: function(self) { 32 | walkInheritTree(self.constructor, '_events', true, function(event) { 33 | self.on.call(self, event[0], event[1]); 34 | }); 35 | walkInheritTree(self, 'events', false, function(handler, eventName) { 36 | self.on(eventName, handler, self); 37 | }); 38 | } 39 | }; 40 | 41 | _.extend(Thorax.View, { 42 | on: function(eventName, callback) { 43 | createInheritVars(this); 44 | 45 | if (objectEvents(this, eventName, callback)) { 46 | return this; 47 | } 48 | 49 | //accept on({"rendered": handler}) 50 | if (_.isObject(eventName)) { 51 | _.each(eventName, function(value, key) { 52 | this.on(key, value); 53 | }, this); 54 | } else { 55 | eventName = eventNameParams(eventName); 56 | 57 | //accept on({"rendered": [handler, handler]}) 58 | if (_.isArray(callback)) { 59 | _.each(callback, function(cb) { 60 | this._events.push([eventName, cb]); 61 | }, this); 62 | //accept on("rendered", handler) 63 | } else { 64 | this._events.push([eventName, callback]); 65 | } 66 | } 67 | return this; 68 | } 69 | }); 70 | 71 | _.extend(Thorax.View.prototype, { 72 | on: function(eventName, callback, context) { 73 | var self = this; 74 | 75 | if (objectEvents(self, eventName, callback, context)) { 76 | return self; 77 | } 78 | 79 | if (_.isObject(eventName) && !eventName.type && arguments.length < 3) { 80 | //accept on({"rendered": callback}) 81 | _.each(eventName, function(value, key) { 82 | self.on(key, value, callback || self); // callback is context in this form of the call 83 | }); 84 | } else { 85 | //accept on("rendered", callback, context) 86 | //accept on("click a", callback, context) 87 | function handleEvent(callback) { 88 | var params = eventParamsForInstance(eventName, self, callback, context || self); 89 | 90 | if (params.event.type === 'DOM') { 91 | // Avoid overhead of handling DOM events on the server 92 | if ($serverSide) { 93 | return; 94 | } 95 | 96 | //will call _addEvent during delegateEvents() 97 | if (!self._eventsToDelegate) { 98 | self._eventsToDelegate = []; 99 | } 100 | self._eventsToDelegate.push(params); 101 | } 102 | 103 | if (params.event.type !== 'DOM' || self._eventsDelegated) { 104 | self._addEvent(params); 105 | } 106 | } 107 | if (_.isArray(callback)) { 108 | _.each(callback, handleEvent); 109 | } else { 110 | handleEvent(callback); 111 | } 112 | } 113 | return self; 114 | }, 115 | 116 | delegateEvents: function(events) { 117 | this.undelegateEvents(); 118 | if (events) { 119 | if (_.isFunction(events)) { 120 | events = events.call(this); 121 | } 122 | this._eventsToDelegate = []; 123 | this.on(events); 124 | } 125 | _.each(this._eventsToDelegate, this._addEvent, this); 126 | this._eventsDelegated = true; 127 | }, 128 | //params may contain: 129 | //- name 130 | //- originalName 131 | //- selector 132 | //- type "view" || "DOM" 133 | //- handler 134 | _addEvent: function(params) { 135 | // If this is recursvie due to listenTo delegate below then pass through to super class 136 | if (params.handler._thoraxBind) { 137 | return _on.call(this, params.event.name, params.handler, params.context || this); 138 | } 139 | 140 | // Shortcircuit DOM events on the server 141 | if ($serverSide && params.event.type !== 'view') { 142 | return; 143 | } 144 | 145 | var boundHandler = bindEventHandler(this, params.event.type + '-event:', params); 146 | 147 | if (params.event.type === 'view') { 148 | // If we have our context set to an outside view then listen rather than directly bind so 149 | // we can cleanup properly. 150 | if (params.context && params.context !== this && params.context instanceof Thorax.View) { 151 | listenTo(params.context, this, params.event.name, boundHandler, params.context); 152 | } else { 153 | _on.call(this, params.event.name, boundHandler, params.context || this); 154 | } 155 | } else { 156 | // DOM Events 157 | if (!params.event.nested) { 158 | boundHandler = containHandlerToCurentView(boundHandler, this); 159 | } 160 | 161 | var name = params.event.name + '.delegateEvents' + this.cid; 162 | if (params.event.selector) { 163 | this.$el.on(name, params.event.selector, boundHandler); 164 | } else { 165 | this.$el.on(name, boundHandler); 166 | } 167 | } 168 | } 169 | }); 170 | 171 | Thorax.View.prototype.bind = Thorax.View.prototype.on; 172 | 173 | // When view is ready trigger ready event on all 174 | // children that are present, then register an 175 | // event that will trigger ready on new children 176 | // when they are added 177 | Thorax.View.on('ready', function(options) { 178 | if (!this._isReady) { 179 | this._isReady = true; 180 | function triggerReadyOnChild(child) { 181 | child._isReady || child.trigger('ready', options); 182 | } 183 | _.each(this.children, triggerReadyOnChild); 184 | this.on('child', triggerReadyOnChild); 185 | } 186 | }); 187 | 188 | function containHandlerToCurentView(handler, current) { 189 | // Passing the current view rather than just a cid to allow for updates to the view's cid 190 | // caused by the restore process. 191 | return function(event) { 192 | var view = $(event.target).view({el: true, helper: false}); 193 | if (view[0] === current.el) { 194 | event.originalContext = this; 195 | return handler(event); 196 | } 197 | }; 198 | } 199 | 200 | function bindEventHandler(view, eventName, params) { 201 | eventName += params.event.originalName; 202 | 203 | var callback = params.handler, 204 | method = typeof callback == 'string' ? view[callback] : callback; 205 | if (!method) { 206 | throw new Error('Event "' + callback + '" does not exist ' + (view.name || view.cid) + ':' + eventName); 207 | } 208 | 209 | var context = params.context || view, 210 | ret = Thorax.bindSection( 211 | 'thorax-event', 212 | {view: context.name || context.cid, eventName: eventName}, 213 | function() { return method.apply(context, arguments); }); 214 | 215 | // Backbone will delegate to _callback in off calls so we should still be able to support 216 | // calling off on specific handlers. 217 | ret._callback = method; 218 | ret._thoraxBind = true; 219 | return ret; 220 | } 221 | 222 | function eventNameParams(name) { 223 | if (name.type) { 224 | return name; 225 | } 226 | 227 | var params = eventParamsCache[name]; 228 | if (params) { 229 | return params; 230 | } 231 | 232 | params = eventNameParams[name] = { 233 | type: 'view', 234 | name: name, 235 | originalName: name, 236 | 237 | nested: false, 238 | selector: undefined 239 | }; 240 | 241 | var match = name.match(eventSplitter); 242 | if (match && domEvents[match[2]]) { 243 | params.type = 'DOM'; 244 | params.name = match[2]; 245 | params.nested = !!match[1]; 246 | params.selector = match[3]; 247 | } 248 | return params; 249 | } 250 | function eventParamsForInstance(eventName, view, handler, context) { 251 | return { 252 | event: eventNameParams(eventName), 253 | context: context, 254 | handler: typeof handler == 'string' ? view[handler] : handler 255 | }; 256 | } 257 | -------------------------------------------------------------------------------- /src/form.js: -------------------------------------------------------------------------------- 1 | /*global $serverSide, inheritVars */ 2 | 3 | inheritVars.model.defaultOptions.populate = true; 4 | 5 | var oldModelChange = inheritVars.model.change; 6 | inheritVars.model.change = function(view, model, options) { 7 | view._isChanging = true; 8 | oldModelChange.apply(view, arguments); 9 | view._isChanging = false; 10 | 11 | if (options && options.serializing) { 12 | return; 13 | } 14 | 15 | var populate = populateOptions(view); 16 | if (view._renderCount && populate) { 17 | view.populate(!populate.context && view.model.attributes, populate); 18 | } 19 | }; 20 | 21 | _.extend(Thorax.View.prototype, { 22 | //serializes a form present in the view, returning the serialized data 23 | //as an object 24 | //pass {set:false} to not update this.model if present 25 | //can pass options, callback or event in any order 26 | serialize: function() { 27 | var callback, options, event; 28 | //ignore undefined arguments in case event was null 29 | for (var i = 0; i < arguments.length; ++i) { 30 | if (_.isFunction(arguments[i])) { 31 | callback = arguments[i]; 32 | } else if (_.isObject(arguments[i])) { 33 | if ('stopPropagation' in arguments[i] && 'preventDefault' in arguments[i]) { 34 | event = arguments[i]; 35 | } else { 36 | options = arguments[i]; 37 | } 38 | } 39 | } 40 | 41 | if (event && !this._preventDuplicateSubmission(event)) { 42 | return; 43 | } 44 | 45 | options = _.extend({ 46 | set: true, 47 | validate: true, 48 | children: true 49 | }, options || {}); 50 | 51 | var attributes = options.attributes || {}; 52 | 53 | //callback has context of element 54 | var view = this; 55 | var errors = []; 56 | eachNamedInput(this, options, function($element, i, name, type) { 57 | var value = view._getInputValue($element, type); 58 | if (!_.isUndefined(value)) { 59 | objectAndKeyFromAttributesAndName(attributes, name, {mode: 'serialize'}, function(object, key) { 60 | if (!object[key]) { 61 | object[key] = value; 62 | } else if (_.isArray(object[key])) { 63 | object[key].push(value); 64 | } else { 65 | object[key] = [object[key], value]; 66 | } 67 | }); 68 | } 69 | }); 70 | 71 | if (!options._silent) { 72 | this.trigger('serialize', attributes, options); 73 | } 74 | 75 | if (options.validate) { 76 | var validateInputErrors = this.validateInput(attributes); 77 | if (validateInputErrors && validateInputErrors.length) { 78 | errors = errors.concat(validateInputErrors); 79 | } 80 | this.trigger('validate', attributes, errors, options); 81 | if (errors.length) { 82 | this.trigger('invalid', errors); 83 | return; 84 | } 85 | } 86 | 87 | if (options.set && this.model) { 88 | if (!this.model.set(attributes, {silent: options.silent, serializing: true})) { 89 | return false; 90 | } 91 | } 92 | 93 | var self = this; 94 | callback && callback.call(this, attributes, function() { 95 | resetSubmitState(self); 96 | }); 97 | return attributes; 98 | }, 99 | 100 | _preventDuplicateSubmission: function(event, callback) { 101 | event.preventDefault(); 102 | 103 | var form = $(event.target); 104 | if ((event.target.tagName || '').toLowerCase() !== 'form') { 105 | // Handle non-submit events by gating on the form 106 | form = $(event.target).closest('form'); 107 | } 108 | 109 | if (!form.attr('data-submit-wait')) { 110 | form.attr('data-submit-wait', 'true'); 111 | if (callback) { 112 | callback.call(this, event); 113 | } 114 | return true; 115 | } else { 116 | return false; 117 | } 118 | }, 119 | 120 | //populate a form from the passed attributes or this.model if present 121 | populate: function(attributes, options) { 122 | options = _.extend({ 123 | children: true 124 | }, options || {}); 125 | 126 | var value, 127 | attributes = attributes || this._getContext(); 128 | 129 | //callback has context of element 130 | eachNamedInput(this, options, function($element, i, name, type) { 131 | objectAndKeyFromAttributesAndName(attributes, name, {mode: 'populate'}, function(object, key) { 132 | value = object && object[key]; 133 | 134 | if (!_.isUndefined(value)) { 135 | //will only execute if we have a name that matches the structure in attributes 136 | var isBinary = type === 'checkbox' || type === 'radio'; 137 | if (isBinary) { 138 | value = _.isBoolean(value) ? value : value === $element.val(); 139 | $element[value ? 'attr' : 'removeAttr']('checked', 'checked'); 140 | } else { 141 | $element.val(value); 142 | } 143 | } 144 | }); 145 | }); 146 | 147 | ++this._populateCount; 148 | if (!options._silent) { 149 | this.trigger('populate', attributes); 150 | } 151 | }, 152 | 153 | //perform form validation, implemented by child class 154 | validateInput: function(/* attributes, options, errors */) {}, 155 | 156 | _getInputValue: function($input, type) { 157 | if (type === 'checkbox' || type === 'radio') { 158 | // `prop` doesn't exist in fruit-loops, but it updates after user input. 159 | // whereas attr does not. 160 | var checked = $input[$input.prop ? 'prop' : 'attr']('checked'); 161 | if (checked || checked === '') { 162 | // Under older versions of IE we see 'on' when no value is set so we want to cast this 163 | // to true. 164 | var value = $input.attr('value'); 165 | return (value === 'on') || value || true; 166 | } 167 | } else { 168 | return $input.val() || ''; 169 | } 170 | }, 171 | 172 | _populateCount: 0 173 | }); 174 | 175 | // Keeping state in the views 176 | Thorax.View.on({ 177 | 'before:rendered': function() { 178 | // Do not store previous options if we have not rendered or if we have changed the associated 179 | // model since the last render 180 | if (!this._renderCount || (this.model && this.model.cid) !== this._formModelCid) { 181 | return; 182 | } 183 | 184 | var modelOptions = this.getObjectOptions(this.model); 185 | // When we have previously populated and rendered the view, reuse the user data 186 | this.previousFormData = filterObject( 187 | this.serialize(_.extend({ set: false, validate: false, _silent: true }, modelOptions)), 188 | function(value) { return value !== '' && value != null; } 189 | ); 190 | }, 191 | rendered: function() { 192 | var populate = populateOptions(this); 193 | 194 | if (populate && !this._isChanging && !this._populateCount) { 195 | this.populate(!populate.context && this.model.attributes, populate); 196 | } 197 | if (this.previousFormData) { 198 | this.populate(this.previousFormData, _.extend({_silent: true}, populate)); 199 | } 200 | 201 | this._formModelCid = this.model && this.model.cid; 202 | this.previousFormData = null; 203 | } 204 | }); 205 | 206 | function filterObject(object, callback) { 207 | _.each(object, function (value, key) { 208 | if (_.isObject(value)) { 209 | return filterObject(value, callback); 210 | } 211 | if (callback(value, key, object) === false) { 212 | delete object[key]; 213 | } 214 | }); 215 | return object; 216 | } 217 | 218 | if (!$serverSide) { 219 | Thorax.View.on({ 220 | invalid: onErrorOrInvalidData, 221 | error: onErrorOrInvalidData, 222 | deactivated: function() { 223 | if (this.$el) { 224 | resetSubmitState(this); 225 | } 226 | } 227 | }); 228 | } 229 | 230 | function onErrorOrInvalidData () { 231 | resetSubmitState(this); 232 | 233 | // If we errored with a model we want to reset the content but leave the UI 234 | // intact. If the user updates the data and serializes any overwritten data 235 | // will be restored. 236 | if (this.model && this.model.previousAttributes) { 237 | this.model.set(this.model.previousAttributes(), { 238 | silent: true 239 | }); 240 | } 241 | } 242 | 243 | function eachNamedInput(view, options, iterator) { 244 | var i = 0; 245 | 246 | $('select,input,textarea', options.root || view.el).each(function() { 247 | var $el = $(this); 248 | 249 | if (!options.children) { 250 | if (view.el !== $el.view({el: true, helper: false})[0]) { 251 | return; 252 | } 253 | } 254 | 255 | var type = $el.attr('type'), 256 | name = $el.attr('name'); 257 | if (type !== 'button' && type !== 'cancel' && type !== 'submit' && name) { 258 | iterator($el, i, name, type); 259 | ++i; 260 | } 261 | }); 262 | } 263 | 264 | //calls a callback with the correct object fragment and key from a compound name 265 | function objectAndKeyFromAttributesAndName(attributes, name, options, callback) { 266 | var key, 267 | object = attributes, 268 | keys = name.split('['), 269 | mode = options.mode; 270 | 271 | for (var i = 0; i < keys.length - 1; ++i) { 272 | key = keys[i].replace(']', ''); 273 | if (!object[key]) { 274 | if (mode === 'serialize') { 275 | object[key] = {}; 276 | } else { 277 | return callback(undefined, key); 278 | } 279 | } 280 | object = object[key]; 281 | } 282 | key = keys[keys.length - 1].replace(']', ''); 283 | callback(object, key); 284 | } 285 | 286 | function resetSubmitState(view) { 287 | view.$('form').removeAttr('data-submit-wait'); 288 | view.$el.removeAttr('data-submit-wait'); 289 | } 290 | 291 | function populateOptions(view) { 292 | var modelOptions = view.getObjectOptions(view.model) || {}; 293 | return modelOptions.populate === true ? {} : modelOptions.populate; 294 | } 295 | -------------------------------------------------------------------------------- /src/fragments/scope.handlebars: -------------------------------------------------------------------------------- 1 | (function() { 2 | 3 | {{{yield}}} 4 | 5 | })(); 6 | -------------------------------------------------------------------------------- /src/helper-view.js: -------------------------------------------------------------------------------- 1 | /*global 2 | ServerMarshal, 3 | $serverSide, createError, filterAncestors, 4 | normalizeHTMLAttributeOptions, viewHelperAttributeName 5 | */ 6 | var viewPlaceholderAttributeName = 'data-view-tmp', 7 | viewTemplateOverrides = {}; 8 | 9 | // Will be shared by HelperView and CollectionHelperView 10 | var helperViewPrototype = { 11 | _ensureElement: function() { 12 | Thorax.View.prototype._ensureElement.call(this); 13 | this.$el.attr(viewHelperAttributeName, this._helperName); 14 | }, 15 | _getContext: function() { 16 | return this.parent._getContext(); 17 | } 18 | }; 19 | 20 | Thorax.HelperView = Thorax.View.extend(helperViewPrototype); 21 | 22 | // Ensure nested inline helpers will always have this.parent 23 | // set to the view containing the template 24 | function getParent(parent) { 25 | // The `view` helper is a special case as it embeds 26 | // a view instead of creating a new one 27 | while (parent._helperName && parent._helperName !== 'view') { 28 | parent = parent.parent; 29 | } 30 | return parent; 31 | } 32 | 33 | function expandHash(context, hash) { 34 | if (hash['expand-tokens']) { 35 | delete hash['expand-tokens']; 36 | _.each(hash, function(value, key) { 37 | hash[key] = Thorax.Util.expandToken(value, context); 38 | }); 39 | return true; 40 | } 41 | } 42 | 43 | Handlebars.registerViewHelper = function(name, ViewClass, callback) { 44 | if (arguments.length === 2) { 45 | if (ViewClass.factory) { 46 | callback = ViewClass.callback; 47 | } else { 48 | callback = ViewClass; 49 | ViewClass = Thorax.HelperView; 50 | } 51 | } 52 | 53 | var viewOptionWhiteList = ViewClass.attributeWhiteList; 54 | 55 | Handlebars.registerHelper(name, function() { 56 | var args = [], 57 | options = arguments[arguments.length-1], 58 | declaringView = options.data.view; 59 | for (var i = 0, len = arguments.length-1; i < len; i++) { 60 | args.push(arguments[i]); 61 | } 62 | 63 | // Evaluate any nested parameters that we may have to content with 64 | var expandTokens = expandHash(this, options.hash); 65 | 66 | var viewOptions = createViewOptions(name, args, options, declaringView); 67 | setHelperTemplate(viewOptions, options, ViewClass); 68 | 69 | normalizeHTMLAttributeOptions(options.hash); 70 | var htmlAttributes = _.clone(options.hash); 71 | 72 | // Remap any view options per the whitelist and remove the source form the HTML 73 | _.each(viewOptionWhiteList, function(dest, source) { 74 | delete htmlAttributes[source]; 75 | if (!_.isUndefined(options.hash[source])) { 76 | viewOptions[dest] = options.hash[source]; 77 | } 78 | }); 79 | if(htmlAttributes.tagName) { 80 | viewOptions.tagName = htmlAttributes.tagName; 81 | } 82 | 83 | viewOptions.attributes = function() { 84 | var attrs = (ViewClass.prototype && ViewClass.prototype.attributes) || {}; 85 | if (_.isFunction(attrs)) { 86 | attrs = attrs.call(this); 87 | } 88 | _.extend(attrs, _.omit(htmlAttributes, ['tagName'])); 89 | // backbone wants "class" 90 | if (attrs.className) { 91 | attrs['class'] = attrs.className; 92 | delete attrs.className; 93 | } 94 | return attrs; 95 | }; 96 | 97 | 98 | // Check to see if we have an existing instance that we can reuse 99 | var instance = _.find(declaringView._previousHelpers, function(child) { 100 | return child._cull && compareHelperOptions(viewOptions, child); 101 | }); 102 | 103 | // Create the instance if we don't already have one 104 | if (!instance) { 105 | instance = getHelperInstance(args, viewOptions, ViewClass); 106 | if (!instance) { 107 | return ''; 108 | } 109 | 110 | instance.$el.attr('data-view-helper-restore', name); 111 | 112 | if ($serverSide && instance.$el.attr('data-view-restore') !== 'false') { 113 | saveServerState(instance, args, options); 114 | } 115 | 116 | helperInit(args, instance, callback, viewOptions); 117 | } else { 118 | if (!instance.el) { 119 | throw new Error('insert-destroyed'); 120 | } 121 | 122 | declaringView.children[instance.cid] = instance; 123 | } 124 | 125 | // Remove any possible entry in previous helpers in case this is a cached value returned from 126 | // slightly different data that does not qualify for the previous helpers direct reuse. 127 | // (i.e. when using an array that is modified between renders) 128 | instance._cull = false; 129 | 130 | // Register the append helper if not already done 131 | if (!declaringView._pendingAppend) { 132 | declaringView._pendingAppend = true; 133 | declaringView.once('append', helperAppend); 134 | } 135 | 136 | htmlAttributes[viewPlaceholderAttributeName] = instance.cid; 137 | if (ViewClass.modifyHTMLAttributes) { 138 | ViewClass.modifyHTMLAttributes(htmlAttributes, instance); 139 | } 140 | return new Handlebars.SafeString(Thorax.Util.tag(htmlAttributes, '', expandTokens ? this : null)); 141 | }); 142 | 143 | var helper = Handlebars.helpers[name]; 144 | 145 | function saveServerState(instance, args, options) { 146 | try { 147 | ServerMarshal.store(instance.$el, 'args', args, options.ids, options); 148 | ServerMarshal.store(instance.$el, 'attrs', options.hash, options.hashIds, options); 149 | if (options.fn && options.fn !== Handlebars.VM.noop) { 150 | if (options.fn.depth) { 151 | // Depthed block helpers are not supoprted. 152 | throw new Error(); 153 | } 154 | ServerMarshal.store(instance.$el, 'fn', options.fn.program); 155 | } 156 | if (options.inverse && options.inverse !== Handlebars.VM.noop) { 157 | if (options.inverse.depth) { 158 | // Depthed block helpers are not supoprted. 159 | throw new Error(); 160 | } 161 | ServerMarshal.store(instance.$el, 'inverse', options.inverse.program); 162 | } 163 | } catch (err) { 164 | instance.$el.attr('data-view-restore', 'false'); 165 | 166 | instance.trigger('restore:fail', { 167 | type: 'serialize', 168 | view: instance, 169 | err: err 170 | }); 171 | } 172 | } 173 | helper.restore = function(declaringView, el, forceRerender) { 174 | var context = declaringView.context(), 175 | args = ServerMarshal.load(el, 'args', declaringView, context) || [], 176 | attrs = ServerMarshal.load(el, 'attrs', declaringView, context) || {}; 177 | 178 | var options = { 179 | hash: attrs, 180 | fn: ServerMarshal.load(el, 'fn'), 181 | inverse: ServerMarshal.load(el, 'inverse') 182 | }; 183 | 184 | declaringView.template._setup({helpers: this.helpers}); 185 | 186 | if (options.fn) { 187 | options.fn = declaringView.template._child(options.fn); 188 | } 189 | if (options.inverse) { 190 | options.inverse = declaringView.template._child(options.inverse); 191 | } 192 | 193 | var viewOptions = createViewOptions(name, args, options, declaringView); 194 | setHelperTemplate(viewOptions, options, ViewClass); 195 | 196 | if (viewOptionWhiteList) { 197 | _.each(viewOptionWhiteList, function(dest, source) { 198 | if (!_.isUndefined(attrs[source])) { 199 | viewOptions[dest] = attrs[source]; 200 | } 201 | }); 202 | } 203 | 204 | var instance = getHelperInstance(args, viewOptions, ViewClass); 205 | if (!instance) { 206 | // We can't do anything more, leave the element in 207 | return; 208 | } 209 | 210 | instance._assignCid(el.getAttribute('data-view-cid')); 211 | helperInit(args, instance, callback, viewOptions); 212 | 213 | instance.restore(el, forceRerender); 214 | 215 | return instance; 216 | }; 217 | 218 | return helper; 219 | }; 220 | 221 | Thorax.View.on('restore', function(forceRerender) { 222 | var parent = this, 223 | context; 224 | 225 | parent.$('[data-view-helper-restore][data-view-restore=true]').each(filterAncestors(parent, function() { 226 | var helper = Handlebars.helpers[this.getAttribute('data-view-helper-restore')], 227 | child = helper.restore(parent, this, forceRerender); 228 | if (child) { 229 | parent._addChild(child); 230 | } 231 | })); 232 | }); 233 | 234 | function createViewOptions(name, args, options, declaringView) { 235 | return { 236 | inverse: options.inverse, 237 | options: options.hash, 238 | declaringView: declaringView, 239 | parent: getParent(declaringView), 240 | _helperName: name, 241 | _helperOptions: { 242 | options: cloneHelperOptions(options), 243 | args: _.clone(args) 244 | } 245 | }; 246 | } 247 | 248 | function setHelperTemplate(viewOptions, options, ViewClass) { 249 | if (options.fn) { 250 | // Only assign if present, allow helper view class to 251 | // declare template 252 | viewOptions.template = options.fn; 253 | } else if (ViewClass && ViewClass.prototype && !ViewClass.prototype.template) { 254 | // ViewClass may also be an instance or object with factory method 255 | // so need to do this check 256 | viewOptions.template = Handlebars.VM.noop; 257 | } 258 | } 259 | 260 | function getHelperInstance(args, viewOptions, ViewClass) { 261 | var instance; 262 | 263 | if (ViewClass.factory) { 264 | instance = ViewClass.factory(args, viewOptions); 265 | if (!instance) { 266 | return; 267 | } 268 | 269 | instance._helperName = viewOptions._helperName; 270 | instance._helperOptions = viewOptions._helperOptions; 271 | } else { 272 | instance = new ViewClass(viewOptions); 273 | } 274 | 275 | if (!instance.el) { 276 | // ViewClass.factory may return existing objects which may have been destroyed 277 | throw createError('insert-destroyed-factory'); 278 | } 279 | return instance; 280 | } 281 | function helperInit(args, instance, callback, viewOptions) { 282 | var declaringView = viewOptions.declaringView, 283 | name = viewOptions._helperName; 284 | 285 | args.push(instance); 286 | declaringView._addChild(instance); 287 | declaringView.trigger.apply(declaringView, ['helper', name].concat(args)); 288 | 289 | callback && callback.apply(this, args); 290 | } 291 | 292 | function helperAppend(scope, callback, deferrable) { 293 | this._pendingAppend = undefined; 294 | 295 | var self = this; 296 | (scope || this.$el).find('[' + viewPlaceholderAttributeName + ']').forEach(function(el) { 297 | var $el = $(el), 298 | placeholderId = $el.attr(viewPlaceholderAttributeName), 299 | view = self.children[placeholderId]; 300 | 301 | if (view) { 302 | deferrable.chain(function(next) { 303 | //see if the view helper declared an override for the view 304 | //if not, ensure the view has been rendered at least once 305 | if (viewTemplateOverrides[placeholderId]) { 306 | view.render(viewTemplateOverrides[placeholderId], next); 307 | delete viewTemplateOverrides[placeholderId]; 308 | } else { 309 | view.ensureRendered(next); 310 | } 311 | $el.replaceWith(view.el); 312 | }); 313 | } 314 | if (view && callback) { 315 | deferrable.exec(function() { 316 | callback(view.$el); 317 | }); 318 | } 319 | }); 320 | } 321 | 322 | /** 323 | * Clones the helper options, dropping items that are known to change 324 | * between rendering cycles as appropriate. 325 | */ 326 | function cloneHelperOptions(options) { 327 | var ret = _.pick(options, 'fn', 'inverse', 'hash', 'data'); 328 | ret.data = _.omit(options.data, 'cid', 'view', 'yield', 'root', '_parent'); 329 | 330 | // This is necessary to prevent failures when mixing restored and rendered data 331 | // as it forces the keys object to be complete. 332 | ret.fn = ret.fn || undefined; 333 | ret.inverse = ret.inverse || undefined; 334 | 335 | return ret; 336 | } 337 | 338 | /** 339 | * Checks for basic equality between two sets of parameters for a helper view. 340 | * 341 | * Checked fields include: 342 | * - _helperName 343 | * - All args 344 | * - Hash 345 | * - Data 346 | * - Function and Invert (id based if possible) 347 | * 348 | * This method allows us to determine if the inputs to a given view are the same. If they 349 | * are then we make the assumption that the rendering will be the same (or the child view will 350 | * otherwise rerendering it by monitoring it's parameters as necessary) and reuse the view on 351 | * rerender of the parent view. 352 | */ 353 | function compareHelperOptions(a, b) { 354 | function compareValues(a, b) { 355 | return _.every(a, function(value, key) { 356 | return b[key] === value; 357 | }); 358 | } 359 | 360 | if (a._helperName !== b._helperName) { 361 | return false; 362 | } 363 | 364 | a = a._helperOptions; 365 | b = b._helperOptions; 366 | 367 | // Implements a first level depth comparison 368 | return a.args.length === b.args.length 369 | && compareValues(a.args, b.args) 370 | && _.isEqual(_.keys(a.options).sort(), _.keys(b.options).sort()) 371 | && _.every(a.options, function(value, key) { 372 | if (key === 'data' || key === 'hash') { 373 | return compareValues(a.options[key], b.options[key]); 374 | } else if (key === 'fn' || key === 'inverse') { 375 | if (b.options[key] === value) { 376 | return true; 377 | } 378 | 379 | var other = b.options[key] || {}; 380 | return value && _.has(value, 'program') && !value.depth && other.program === value.program; 381 | } 382 | return b.options[key] === value; 383 | }); 384 | } 385 | -------------------------------------------------------------------------------- /src/helpers/button-link.js: -------------------------------------------------------------------------------- 1 | /* global createErrorMessage, normalizeHTMLAttributeOptions */ 2 | 3 | var callMethodAttributeName = 'data-call-method', 4 | triggerEventAttributeName = 'data-trigger-event'; 5 | 6 | Handlebars.registerHelper('button', function(method, options) { 7 | if (arguments.length === 1) { 8 | options = method; 9 | method = options.hash.method; 10 | } 11 | var hash = options.hash, 12 | expandTokens = hash['expand-tokens']; 13 | delete hash['expand-tokens']; 14 | if (!method && !options.hash.trigger) { 15 | throw new Error(createErrorMessage('button-trigger')); 16 | } 17 | normalizeHTMLAttributeOptions(hash); 18 | hash.tagName = hash.tagName || 'button'; 19 | hash.trigger && (hash[triggerEventAttributeName] = hash.trigger); 20 | delete hash.trigger; 21 | method && (hash[callMethodAttributeName] = method); 22 | return new Handlebars.SafeString(Thorax.Util.tag(hash, options.fn ? options.fn(this) : '', expandTokens ? this : null)); 23 | }); 24 | 25 | Handlebars.registerHelper('link', function() { 26 | var args = _.toArray(arguments), 27 | options = args.pop(), 28 | hash = options.hash, 29 | // url is an array that will be passed to the url helper 30 | url = args.length === 0 ? [hash.href] : args, 31 | expandTokens = hash['expand-tokens']; 32 | delete hash['expand-tokens']; 33 | if (!url[0] && url[0] !== '') { 34 | throw new Error(createErrorMessage('link-href')); 35 | } 36 | normalizeHTMLAttributeOptions(hash); 37 | url.push(options); 38 | hash.href = Handlebars.helpers.url.apply(this, url); 39 | hash.tagName = hash.tagName || 'a'; 40 | hash.trigger && (hash[triggerEventAttributeName] = options.hash.trigger); 41 | delete hash.trigger; 42 | hash[callMethodAttributeName] = '_anchorClick'; 43 | return new Handlebars.SafeString(Thorax.Util.tag(hash, options.fn ? options.fn(this) : '', expandTokens ? this : null)); 44 | }); 45 | 46 | var clickSelector = '[' + callMethodAttributeName + '], [' + triggerEventAttributeName + ']'; 47 | 48 | function handleClick(event) { 49 | var $this = $(this), 50 | view = $this.view({helper: false}), 51 | methodName = $this.attr(callMethodAttributeName), 52 | eventName = $this.attr(triggerEventAttributeName), 53 | methodResponse = false; 54 | methodName && (methodResponse = view[methodName](event)); 55 | eventName && view.trigger(eventName, event); 56 | this.tagName === 'A' && methodResponse === false && event.preventDefault(); 57 | } 58 | 59 | var lastClickHandlerEventName; 60 | 61 | function registerClickHandler() { 62 | unregisterClickHandler(); 63 | lastClickHandlerEventName = Thorax._fastClickEventName || 'click'; 64 | $(document).on(lastClickHandlerEventName, clickSelector, handleClick); 65 | } 66 | 67 | function unregisterClickHandler() { 68 | lastClickHandlerEventName && $(document).off(lastClickHandlerEventName, clickSelector, handleClick); 69 | } 70 | 71 | $(document).ready(function() { 72 | if (!Thorax._fastClickEventName) { 73 | registerClickHandler(); 74 | } 75 | }); 76 | -------------------------------------------------------------------------------- /src/helpers/collection.js: -------------------------------------------------------------------------------- 1 | /* global 2 | $serverSide, 3 | collectionElementAttributeName, createErrorMessage, getParent, 4 | helperViewPrototype, normalizeHTMLAttributeOptions, 5 | viewRestoreAttribute 6 | */ 7 | 8 | Thorax.CollectionHelperView = Thorax.CollectionView.extend({ 9 | // Forward render events to the parent 10 | events: { 11 | 'rendered:collection': forwardRenderEvent('rendered:collection'), 12 | 'rendered:item': function(view, collection, model, itemEl, index) { 13 | this.parent.trigger('rendered:item', view, collection, model, itemEl, index); 14 | }, 15 | 'rendered:empty': forwardRenderEvent('rendered:empty'), 16 | 'restore:collection': forwardRenderEvent('restore:collection'), 17 | 'restore:item': forwardRenderEvent('restore:item'), 18 | 'restore:empty': forwardRenderEvent('restore:empty') 19 | }, 20 | 21 | // Thorax.CollectionView allows a collectionSelector 22 | // to be specified, disallow in a collection helper 23 | // as it will cause problems when neseted 24 | getCollectionElement: function() { 25 | return this.$el; 26 | }, 27 | 28 | constructor: function(options) { 29 | var restorable = true; 30 | 31 | // need to fetch templates if template name was passed 32 | if (options.options['item-template']) { 33 | options.itemTemplate = Thorax.Util.getTemplate(options.options['item-template']); 34 | } 35 | if (options.options['empty-template']) { 36 | options.emptyTemplate = Thorax.Util.getTemplate(options.options['empty-template']); 37 | } 38 | 39 | // Handlebars.VM.noop is passed in the handlebars options object as 40 | // a default for fn and inverse, if a block was present. Need to 41 | // check to ensure we don't pick the empty / null block up. 42 | if (!options.itemTemplate && options.template && options.template !== Handlebars.VM.noop) { 43 | options.itemTemplate = options.template; 44 | options.template = Handlebars.VM.noop; 45 | 46 | // We can not restore if the item has a depthed reference, ../foo, so we need to 47 | // force a rerender on the client-side 48 | if (options.itemTemplate.depth) { 49 | restorable = false; 50 | } 51 | } 52 | if (!options.emptyTemplate && options.inverse && options.inverse !== Handlebars.VM.noop) { 53 | options.emptyTemplate = options.inverse; 54 | options.inverse = Handlebars.VM.noop; 55 | 56 | if (options.emptyTemplate.depth) { 57 | restorable = false; 58 | } 59 | } 60 | 61 | var shouldBindItemContext = _.isFunction(options.itemContext), 62 | shouldBindItemFilter = _.isFunction(options.itemFilter); 63 | 64 | var response = Thorax.HelperView.call(this, options); 65 | 66 | if (shouldBindItemContext) { 67 | this.itemContext = _.bind(this.itemContext, this.parent); 68 | } else if (_.isString(this.itemContext)) { 69 | this.itemContext = _.bind(this.parent[this.itemContext], this.parent); 70 | } 71 | 72 | if (shouldBindItemFilter) { 73 | this.itemFilter = _.bind(this.itemFilter, this.parent); 74 | } else if (_.isString(this.itemFilter)) { 75 | this.itemFilter = _.bind(this.parent[this.itemFilter], this.parent); 76 | } 77 | 78 | if (this.parent.name) { 79 | if (!this.emptyView && !this.parent.renderEmpty) { 80 | this.emptyView = Thorax.Util.getViewClass(this.parent.name + '-empty', true); 81 | } 82 | if (!this.emptyTemplate && !this.parent.renderEmpty) { 83 | this.emptyTemplate = Thorax.Util.getTemplate(this.parent.name + '-empty', true); 84 | } 85 | if (!this.itemView && !this.parent.renderItem) { 86 | this.itemView = Thorax.Util.getViewClass(this.parent.name + '-item', true); 87 | } 88 | if (!this.itemTemplate && !this.parent.renderItem) { 89 | // item template must be present if an itemView is not 90 | this.itemTemplate = Thorax.Util.getTemplate(this.parent.name + '-item', !!this.itemView); 91 | } 92 | } 93 | 94 | if ($serverSide && !restorable) { 95 | this.$el.attr(viewRestoreAttribute, 'false'); 96 | 97 | this.trigger('restore:fail', { 98 | type: 'serialize', 99 | view: this, 100 | err: 'collection-depthed-query' 101 | }); 102 | } 103 | 104 | return response; 105 | }, 106 | setAsPrimaryCollectionHelper: function() { 107 | var self = this, 108 | parent = self.parent; 109 | _.each(forwardableProperties, function(propertyName) { 110 | forwardMissingProperty(self, propertyName); 111 | }); 112 | 113 | _.each(['itemFilter', 'itemContext', 'renderItem', 'renderEmpty'], function(propertyName) { 114 | if (parent[propertyName]) { 115 | self[propertyName] = function(thing1, thing2) { 116 | return parent[propertyName](thing1, thing2); 117 | }; 118 | } 119 | }); 120 | } 121 | }); 122 | 123 | _.extend(Thorax.CollectionHelperView.prototype, helperViewPrototype); 124 | 125 | 126 | Thorax.CollectionHelperView.attributeWhiteList = { 127 | 'item-context': 'itemContext', 128 | 'item-filter': 'itemFilter', 129 | 'item-template': 'itemTemplate', 130 | 'empty-template': 'emptyTemplate', 131 | 'item-view': 'itemView', 132 | 'empty-view': 'emptyView', 133 | 'empty-class': 'emptyClass' 134 | }; 135 | 136 | function forwardRenderEvent(eventName) { 137 | return function(thing1, thing2) { 138 | this.parent.trigger(eventName, thing1, thing2); 139 | }; 140 | } 141 | 142 | var forwardableProperties = [ 143 | 'itemTemplate', 144 | 'itemView', 145 | 'emptyTemplate', 146 | 'emptyView' 147 | ]; 148 | 149 | function forwardMissingProperty(view, propertyName) { 150 | var parent = getParent(view); 151 | if (!view[propertyName]) { 152 | var prop = parent[propertyName]; 153 | if (prop){ 154 | view[propertyName] = prop; 155 | } 156 | } 157 | } 158 | 159 | Handlebars.registerViewHelper('collection', Thorax.CollectionHelperView, function(collection, view) { 160 | if (arguments.length === 1) { 161 | view = collection; 162 | collection = view.parent.collection; 163 | 164 | if (collection) { 165 | view.setAsPrimaryCollectionHelper(); 166 | } 167 | view.$el.attr(collectionElementAttributeName, 'true'); 168 | // propagate future changes to the parent's collection object 169 | // to the helper view 170 | view.listenTo(view.parent, 'change:data-object', function(type, dataObject) { 171 | if (type === 'collection') { 172 | view.setAsPrimaryCollectionHelper(); 173 | view.setCollection(dataObject); 174 | } 175 | }); 176 | } 177 | if (collection) { 178 | view.setCollection(collection); 179 | } 180 | }); 181 | 182 | Handlebars.registerHelper('collection-element', function(options) { 183 | if (!options.data.view.renderCollection) { 184 | throw new Error(createErrorMessage('collection-element-helper')); 185 | } 186 | var hash = options.hash; 187 | normalizeHTMLAttributeOptions(hash); 188 | hash.tagName = hash.tagName || 'div'; 189 | hash[collectionElementAttributeName] = true; 190 | return new Handlebars.SafeString(Thorax.Util.tag.call(this, hash, '', this)); 191 | }); 192 | -------------------------------------------------------------------------------- /src/helpers/element.js: -------------------------------------------------------------------------------- 1 | /*global normalizeHTMLAttributeOptions */ 2 | var elementPlaceholderAttributeName = 'data-element-tmp'; 3 | 4 | Handlebars.registerHelper('element', function(element, options) { 5 | normalizeHTMLAttributeOptions(options.hash); 6 | var cid = _.uniqueId('element'), 7 | declaringView = options.data.view; 8 | options.hash[elementPlaceholderAttributeName] = cid; 9 | declaringView._elementsByCid || (declaringView._elementsByCid = {}); 10 | declaringView._elementsByCid[cid] = element; 11 | 12 | // Register the append helper if not already done 13 | if (!declaringView._pendingElement) { 14 | declaringView._pendingElement = true; 15 | declaringView.once('append', elementAppend); 16 | } 17 | 18 | return new Handlebars.SafeString(Thorax.Util.tag(options.hash)); 19 | }); 20 | 21 | function elementAppend(scope, callback) { 22 | this._pendingElement = undefined; 23 | 24 | var self = this; 25 | (scope || this.$el).find('[' + elementPlaceholderAttributeName + ']').forEach(function(el) { 26 | var $el = $(el), 27 | cid = $el.attr(elementPlaceholderAttributeName), 28 | element = self._elementsByCid[cid]; 29 | // A callback function may be specified as the value 30 | if (_.isFunction(element)) { 31 | element = element.call(self); 32 | } 33 | $el.replaceWith(element); 34 | callback && callback($(element)); 35 | }); 36 | } 37 | -------------------------------------------------------------------------------- /src/helpers/empty.js: -------------------------------------------------------------------------------- 1 | Handlebars.registerHelper('empty', function(dataObject, options) { 2 | if (arguments.length === 1) { 3 | options = dataObject; 4 | } 5 | var view = options.data.view; 6 | if (arguments.length === 1) { 7 | dataObject = view.model; 8 | } 9 | // listeners for the empty helper rather than listeners 10 | // that are themselves empty 11 | if (!view._emptyListeners) { 12 | view._emptyListeners = {}; 13 | } 14 | // duck type check for collection 15 | if (dataObject && !view._emptyListeners[dataObject.cid] && dataObject.models && ('length' in dataObject)) { 16 | view._emptyListeners[dataObject.cid] = true; 17 | view.listenTo(dataObject, 'remove', function() { 18 | if (dataObject.length === 0) { 19 | view.render(); 20 | } 21 | }); 22 | view.listenTo(dataObject, 'add', function() { 23 | if (dataObject.length === 1) { 24 | view.render(); 25 | } 26 | }); 27 | view.listenTo(dataObject, 'reset', function() { 28 | view.render(); 29 | }); 30 | } 31 | return !dataObject || dataObject.isEmpty() ? options.fn(this) : options.inverse(this); 32 | }); 33 | -------------------------------------------------------------------------------- /src/helpers/loading.js: -------------------------------------------------------------------------------- 1 | Handlebars.registerHelper('loading', function(options) { 2 | var view = options.data.view; 3 | view.off('change:load-state', onLoadStateChange, view); 4 | view.on('change:load-state', onLoadStateChange, view); 5 | return view._isLoading ? options.fn(this) : options.inverse(this); 6 | }); 7 | 8 | function onLoadStateChange() { 9 | this.render(); 10 | } 11 | -------------------------------------------------------------------------------- /src/helpers/super.js: -------------------------------------------------------------------------------- 1 | /* global createErrorMessage */ 2 | 3 | Handlebars.registerHelper('super', function(options) { 4 | var declaringView = options.data.view, 5 | parent = declaringView.constructor && declaringView.constructor.__super__; 6 | if (parent) { 7 | var template = parent.template; 8 | if (!template) { 9 | if (!parent.name) { 10 | throw new Error(createErrorMessage('super-parent')); 11 | } 12 | template = parent.name; 13 | } 14 | if (_.isString(template)) { 15 | template = Thorax.Util.getTemplate(template, false); 16 | } 17 | return new Handlebars.SafeString(template(this, options)); 18 | } else { 19 | return ''; 20 | } 21 | }); 22 | -------------------------------------------------------------------------------- /src/helpers/template.js: -------------------------------------------------------------------------------- 1 | Handlebars.registerHelper('template', function(name, options) { 2 | /*jshint -W089 */ 3 | var hasHash = false; 4 | for (var _name in options.hash) { 5 | // Not doing hasOwnProperty check here as this is going to be a handlebars 6 | // generated object literal in most cases and under the rare situation that 7 | // the Object prototype has manipulated, the extend path will continue to do 8 | // the correct thing. 9 | hasHash = true; 10 | break; 11 | } 12 | 13 | var context = this; 14 | if (options.fn || hasHash) { 15 | context = Object.create ? Object.create(this) : _.clone(this); 16 | _.extend(context, {fn: options.fn}, options.hash); 17 | } 18 | 19 | var output = options.data.view.renderTemplate(name, context); 20 | return new Handlebars.SafeString(output); 21 | }); 22 | 23 | Handlebars.registerHelper('yield', function(options) { 24 | return options.data.yield(); 25 | }); 26 | -------------------------------------------------------------------------------- /src/helpers/url.js: -------------------------------------------------------------------------------- 1 | Handlebars.registerHelper('url', function(_url, options) { 2 | var url = _url || ''; 3 | 4 | var fragment = ''; 5 | if (arguments.length > 2) { 6 | for (var i = 0, len = arguments.length - 1; i < len; i++) { 7 | fragment += (i ? '/' : '') + encodeURIComponent(arguments[i]); 8 | } 9 | } else { 10 | var hash = (options && options.hash) || options; 11 | if (hash && hash['expand-tokens']) { 12 | fragment = Thorax.Util.expandToken(url, this, true); 13 | } else { 14 | fragment = url; 15 | } 16 | } 17 | if (Backbone.history._hasPushState) { 18 | var root = Backbone.history.options.root; 19 | if (root === '/' && fragment.substr(0, 1) === '/') { 20 | return fragment; 21 | } else { 22 | return root + fragment; 23 | } 24 | } else { 25 | return '#' + fragment; 26 | } 27 | }); 28 | -------------------------------------------------------------------------------- /src/helpers/view.js: -------------------------------------------------------------------------------- 1 | /*global viewTemplateOverrides, createErrorMessage */ 2 | Handlebars.registerViewHelper('view', { 3 | factory: function(args, options) { 4 | var View = args.length >= 1 ? args[0] : Thorax.View; 5 | return Thorax.Util.getViewInstance(View, options.options); 6 | }, 7 | // ensure generated placeholder tag in template 8 | // will match tag of view instance 9 | modifyHTMLAttributes: function(htmlAttributes, instance) { 10 | // Handle fruitloops tag name lookup via the .name case. 11 | htmlAttributes.tagName = (instance.el.tagName || instance.el.name || '').toLowerCase(); 12 | }, 13 | callback: function(view) { 14 | var instance = arguments[arguments.length-1], 15 | options = instance._helperOptions.options, 16 | placeholderId = instance.cid; 17 | // view will be the argument passed to the helper, if it was 18 | // a string, a new instance was created on the fly, ok to pass 19 | // hash arguments, otherwise need to throw as templates should 20 | // not introduce side effects to existing view instances 21 | if (!_.isString(view) && options.hash && _.keys(options.hash).length > 0) { 22 | throw new Error(createErrorMessage('view-helper-hash-args')); 23 | } 24 | if (options.fn) { 25 | viewTemplateOverrides[placeholderId] = options.fn; 26 | } 27 | } 28 | }); 29 | -------------------------------------------------------------------------------- /src/layout.js: -------------------------------------------------------------------------------- 1 | /*global 2 | $serverSide, FruitLoops, 3 | createErrorMessage, getLayoutViewsTargetElement, 4 | normalizeHTMLAttributeOptions, setImmediate, viewNameAttributeName 5 | */ 6 | var layoutCidAttributeName = 'data-layout-cid'; 7 | 8 | Thorax.LayoutView = Thorax.View.extend({ 9 | _defaultTemplate: Handlebars.VM.noop, 10 | render: function(output) { 11 | var response = Thorax.View.prototype.render.call(this, output); 12 | if (this.template === Handlebars.VM.noop) { 13 | // if there is no template setView will append to this.$el 14 | ensureLayoutCid(this); 15 | } else { 16 | // if a template was specified is must declare a layout-element 17 | ensureLayoutViewsTargetElement(this); 18 | } 19 | 20 | // Restore our child view if we had one previously 21 | if (this._view) { 22 | this._view.appendTo(this._layoutViewEl); 23 | } 24 | 25 | return response; 26 | }, 27 | restore: function(element, forceRerender) { 28 | // Layout views don't have a traditional forced rerender cycle so we want to manage this 29 | // ourselves. 30 | this._forceRerender = forceRerender; 31 | Thorax.View.prototype.restore.call(this, element); 32 | }, 33 | setView: function(view, options) { 34 | options = _.extend({ 35 | scroll: true 36 | }, options); 37 | 38 | if (_.isString(view)) { 39 | view = new (Thorax.Util.registryGet(Thorax, 'Views', view, false))(); 40 | } 41 | 42 | if (!$serverSide && !this.hasBeenSet) { 43 | var existing = this.$('[' + viewNameAttributeName + '="' + view.name + '"]')[0]; 44 | if (existing) { 45 | view.restore(existing, this._forceRerender); 46 | } else { 47 | $(this._layoutViewEl).empty(); 48 | } 49 | } 50 | this.ensureRendered(); 51 | 52 | var oldView = this._view, 53 | self = this, 54 | serverRender = view && $serverSide && (options.serverRender || view.serverRender), 55 | attemptAsync = options.async !== false ? options.async || serverRender : false; 56 | if (view === oldView) { 57 | return false; 58 | } 59 | 60 | if (attemptAsync && view && !view._renderCount) { 61 | setImmediate(function() { 62 | view.ensureRendered(function() { 63 | self.setView(view, options); 64 | }); 65 | }); 66 | return; 67 | } 68 | 69 | this.trigger('change:view:start', view, oldView, options); 70 | 71 | function remove() { 72 | if (oldView) { 73 | oldView.$el && oldView.$el.detach(); 74 | triggerLifecycleEvent(oldView, 'deactivated', options); 75 | self._removeChild(oldView); 76 | } 77 | } 78 | 79 | function append() { 80 | if (!view) { 81 | self._view = undefined; 82 | } else if ($serverSide && !serverRender) { 83 | // Emit only data for non-server rendered views 84 | // But we do want to put ourselves into the queue for cleanup on future exec 85 | self._view = view; 86 | self._addChild(view); 87 | 88 | FruitLoops.emit(); 89 | } else { 90 | view.ensureRendered(); 91 | options.activating = view; 92 | 93 | triggerLifecycleEvent(self, 'activated', options); 94 | view.trigger('activated', options); 95 | self._view = view; 96 | var targetElement = self._layoutViewEl; 97 | self._view.appendTo(targetElement); 98 | self._addChild(view); 99 | } 100 | } 101 | 102 | function complete() { 103 | self.hasBeenSet = true; 104 | self.trigger('change:view:end', view, oldView, options); 105 | } 106 | 107 | if (!options.transition) { 108 | remove(); 109 | append(); 110 | complete(); 111 | } else { 112 | options.transition(view, oldView, append, remove, complete); 113 | } 114 | 115 | return view; 116 | }, 117 | 118 | getView: function() { 119 | return this._view; 120 | } 121 | }); 122 | 123 | Thorax.LayoutView.on('after-restore', function() { 124 | ensureLayoutViewsTargetElement(this); 125 | }); 126 | 127 | Handlebars.registerHelper('layout-element', function(options) { 128 | var view = options.data.view; 129 | // duck type check for LayoutView 130 | if (!view.getView) { 131 | throw new Error(createErrorMessage('layout-element-helper')); 132 | } 133 | options.hash[layoutCidAttributeName] = view.cid; 134 | normalizeHTMLAttributeOptions(options.hash); 135 | return new Handlebars.SafeString(Thorax.Util.tag.call(this, options.hash, '', this)); 136 | }); 137 | 138 | function triggerLifecycleEvent(view, eventName, options) { 139 | options = options || {}; 140 | options.target = view; 141 | view.trigger(eventName, options); 142 | _.each(view.children, function(child) { 143 | child.trigger(eventName, options); 144 | }); 145 | } 146 | 147 | function ensureLayoutCid(view) { 148 | //set the layoutCidAttributeName on this.$el if there was no template 149 | view.$el.attr(layoutCidAttributeName, view.cid); 150 | view._layoutViewEl = view.el; 151 | } 152 | 153 | function ensureLayoutViewsTargetElement(view) { 154 | var el = view.$('[' + layoutCidAttributeName + '="' + view.cid + '"]')[0]; 155 | if (!el && view.$el.attr(layoutCidAttributeName)) { 156 | el = view.el; 157 | } 158 | if (!el) { 159 | throw new Error('No layout element found in ' + (view.name || view.cid)); 160 | } 161 | view._layoutViewEl = el; 162 | } 163 | -------------------------------------------------------------------------------- /src/mixin.js: -------------------------------------------------------------------------------- 1 | Thorax.Mixins = {}; 2 | 3 | _.extend(Thorax.View, { 4 | mixin: function(name) { 5 | Thorax.Mixins[name](this); 6 | }, 7 | registerMixin: function(name, callback, methods) { 8 | Thorax.Mixins[name] = function(obj) { 9 | var isInstance = !!obj.cid; 10 | if (methods) { 11 | _.extend(isInstance ? obj : obj.prototype, methods); 12 | } 13 | if (isInstance) { 14 | callback.call(obj); 15 | } else { 16 | obj.on('configure', callback); 17 | } 18 | }; 19 | } 20 | }); 21 | 22 | Thorax.View.prototype.mixin = function(name) { 23 | Thorax.Mixins[name](this); 24 | }; 25 | -------------------------------------------------------------------------------- /src/mobile.js: -------------------------------------------------------------------------------- 1 | /*global pushDomEvents */ 2 | var isiOS = navigator.userAgent.match(/(iPhone|iPod|iPad)/i), 3 | isAndroid = navigator.userAgent.toLowerCase().indexOf("android") > -1 ? 1 : 0, 4 | minimumScrollYOffset = isAndroid ? 1 : 0; 5 | 6 | Thorax.Util.scrollTo = function(x, y) { 7 | y = y || minimumScrollYOffset; 8 | function _scrollTo() { 9 | window.scrollTo(x, y); 10 | } 11 | if (isiOS) { 12 | // a defer is required for ios 13 | _.defer(_scrollTo); 14 | } else { 15 | _scrollTo(); 16 | } 17 | return [x, y]; 18 | }; 19 | 20 | Thorax.LayoutView.on('change:view:end', function(newView, oldView, options) { 21 | options && options.scroll && Thorax.Util.scrollTo(0, 0); 22 | }); 23 | 24 | Thorax.Util.scrollToTop = function() { 25 | // android will use height of 1 because of minimumScrollYOffset in scrollTo() 26 | return this.scrollTo(0, 0); 27 | }; 28 | 29 | //built in dom events 30 | Thorax.View.on({ 31 | 'submit form': function(/* event */) { 32 | // Hide any virtual keyboards that may be lingering around 33 | var focused = $(':focus')[0]; 34 | focused && focused.blur(); 35 | } 36 | }); 37 | -------------------------------------------------------------------------------- /src/mobile/tap-highlight.js: -------------------------------------------------------------------------------- 1 | /*global isAndroid */ 2 | 3 | // This doesn't work on HTC devices with Android 4.0. 4 | // Not much can be done about it as it seems to be a browser bug 5 | // (it doesn't update visual styling while you hold your finger on the screen) 6 | $.fn.tapHoldAndEnd = function(selector, callbackStart, callbackEnd) { 7 | return this.each(function() { 8 | var tapHoldStart, 9 | timer, 10 | target; 11 | 12 | function clearTapTimer() { 13 | clearTimeout(timer); 14 | 15 | if (tapHoldStart && target) { 16 | callbackEnd(target); 17 | } 18 | 19 | target = undefined; 20 | tapHoldStart = false; 21 | } 22 | 23 | $(this).on('touchstart', selector, function(event) { 24 | if ($(event.currentTarget).attr('data-no-tap-highlight')) { 25 | return; 26 | } 27 | 28 | clearTapTimer(); 29 | 30 | target = event.currentTarget; 31 | timer = setTimeout(function() { 32 | tapHoldStart = true; 33 | callbackStart(target); 34 | }, 50); 35 | }) 36 | .on('touchmove touchend', clearTapTimer); 37 | 38 | $(document).on('touchcancel', clearTapTimer); 39 | }); 40 | }; 41 | 42 | //only enable on android 43 | var useNativeHighlight = !isAndroid; 44 | Thorax.configureTapHighlight = function(useNative, highlightClass) { 45 | useNativeHighlight = useNative; 46 | highlightClass = highlightClass || 'tap-highlight'; 47 | 48 | if (!useNative) { 49 | function _tapHighlightStart(target) { 50 | var tagName = target && target.tagName.toLowerCase(); 51 | 52 | // User input controls may be visually part of a larger group. For these cases 53 | // we want to give priority to any parent that may provide a focus operation. 54 | if (tagName === 'input' || tagName === 'select' || tagName === 'textarea') { 55 | target = $(target).closest('[data-tappable=true]')[0] || target; 56 | } 57 | 58 | if (target) { 59 | $(target).addClass(highlightClass); 60 | return false; 61 | } 62 | } 63 | function _tapHighlightEnd() { 64 | $('.' + highlightClass).removeClass(highlightClass); 65 | } 66 | $(document.body).tapHoldAndEnd( 67 | '[data-tappable=true], a, input, button, select, textarea', 68 | _tapHighlightStart, 69 | _tapHighlightEnd); 70 | } 71 | }; 72 | 73 | var NATIVE_TAPPABLE = { 74 | 'A': true, 75 | 'INPUT': true, 76 | 'BUTTON': true, 77 | 'SELECT': true, 78 | 'TEXTAREA': true 79 | }; 80 | 81 | // Out here so we do not retain a scope 82 | function NOP(){} 83 | 84 | function fixupTapHighlight() { 85 | _.each(this._domEvents || [], function(bind) { 86 | var components = bind.split(' '), 87 | selector = components.slice(1).join(' ') || undefined; // Needed to make zepto happy 88 | 89 | if (components[0] === 'click') { 90 | // !selector case is for root click handlers on the view, i.e. 'click' 91 | $(selector || this.el, selector && this.el).forEach(function(el) { 92 | var $el = $(el).attr('data-tappable', true); 93 | 94 | if (useNativeHighlight && !NATIVE_TAPPABLE[el.tagName]) { 95 | // Add an explicit NOP bind to allow tap-highlight support 96 | $el.on('click', NOP); 97 | } 98 | }); 99 | } 100 | }, this); 101 | } 102 | 103 | Thorax.View.on({ 104 | 'rendered': fixupTapHighlight, 105 | 'rendered:collection': fixupTapHighlight, 106 | 'rendered:item': fixupTapHighlight, 107 | 'rendered:empty': fixupTapHighlight 108 | }); 109 | 110 | var _addEvent = Thorax.View.prototype._addEvent; 111 | Thorax.View.prototype._addEvent = function(params) { 112 | this._domEvents = this._domEvents || []; 113 | if (params.type === "DOM") { 114 | this._domEvents.push(params.originalName); 115 | } 116 | return _addEvent.call(this, params); 117 | }; 118 | -------------------------------------------------------------------------------- /src/model.js: -------------------------------------------------------------------------------- 1 | /*global createRegistryWrapper, dataObject, getValue, inheritVars */ 2 | var modelCidAttributeName = 'data-model-cid', 3 | modelIdAttributeName = 'data-model-id'; 4 | 5 | Thorax.Model = Backbone.Model.extend({ 6 | isEmpty: function() { 7 | return !this.isPopulated(); 8 | }, 9 | isPopulated: function() { 10 | /*jshint -W089 */ 11 | 12 | // We are populated if we have attributes set 13 | var attributes = _.clone(this.attributes), 14 | defaults = getValue(this, 'defaults') || {}; 15 | for (var default_key in defaults) { 16 | if (attributes[default_key] != defaults[default_key]) { 17 | return true; 18 | } 19 | delete attributes[default_key]; 20 | } 21 | var keys = _.keys(attributes); 22 | return keys.length > 1 || (keys.length === 1 && keys[0] !== this.idAttribute); 23 | }, 24 | shouldFetch: function(options) { 25 | // url() will throw if model has no `urlRoot` and no `collection` 26 | // or has `collection` and `collection` has no `url` 27 | var url; 28 | try { 29 | url = getValue(this, 'url'); 30 | } catch(e) { 31 | url = false; 32 | } 33 | return options.fetch && !!url && !this.isPopulated(); 34 | } 35 | }); 36 | 37 | Thorax.Models = {}; 38 | createRegistryWrapper(Thorax.Model, Thorax.Models); 39 | 40 | dataObject('model', { 41 | set: 'setModel', 42 | defaultOptions: { 43 | render: undefined, // Default to deferred rendering 44 | fetch: true, 45 | success: false, 46 | invalid: true 47 | }, 48 | change: onModelChange, 49 | $el: '$el', 50 | idAttrName: modelIdAttributeName, 51 | cidAttrName: modelCidAttributeName 52 | }); 53 | 54 | function onModelChange(view, model, options) { 55 | if (options && options.serializing) { 56 | return; 57 | } 58 | 59 | var modelOptions = view.getObjectOptions(model) || {}; 60 | // !modelOptions will be true when setModel(false) is called 61 | view.conditionalRender(modelOptions.render); 62 | } 63 | 64 | Thorax.View.on({ 65 | model: { 66 | invalid: function(model, errors) { 67 | if (this.getObjectOptions(model).invalid) { 68 | this.trigger('invalid', errors, model); 69 | } 70 | }, 71 | error: function(model, resp /*, options */) { 72 | this.trigger('error', resp, model); 73 | }, 74 | change: function(model, options) { 75 | // Indirect refernece to allow for overrides 76 | inheritVars.model.change(this, model, options); 77 | } 78 | } 79 | }); 80 | 81 | $.fn.model = function(view) { 82 | var $this = $(this), 83 | modelElement = $this.closest('[' + modelCidAttributeName + ']'), 84 | modelCid = modelElement && modelElement.attr(modelCidAttributeName); 85 | if (modelCid) { 86 | var view = view || $this.view(); 87 | if (view && view.model && view.model.cid === modelCid) { 88 | return view.model || false; 89 | } 90 | var collection = $this.collection(view); 91 | if (collection) { 92 | return collection.get(modelCid); 93 | } 94 | } 95 | return false; 96 | }; 97 | -------------------------------------------------------------------------------- /src/server-marshal.js: -------------------------------------------------------------------------------- 1 | /*global $serverSide, createError, FruitLoops */ 2 | var _thoraxServerData = window._thoraxServerData || []; 3 | 4 | /* 5 | * Allows for complex data to be communicated between the server and client 6 | * contexts for an arbitrary element. 7 | * 8 | * This is primarily intended for resolving template associated data on the client 9 | * but any data can be expressed via simple paths from a known root object, such 10 | * as a view instance or it's rendering context, may be marshaled. 11 | */ 12 | var ServerMarshal = Thorax.ServerMarshal = { 13 | store: function($el, name, data, dataIds, options) { 14 | if (!$serverSide) { 15 | return; 16 | } 17 | 18 | dataIds = dataIds || {}; 19 | 20 | options = (options && options.data) || options || {}; 21 | 22 | // Find or create the lookup table element 23 | var elementCacheId = $el._serverData || parseInt($el.attr('data-server-data'), 10); 24 | if (isNaN(elementCacheId)) { 25 | elementCacheId = _thoraxServerData.length; 26 | _thoraxServerData[elementCacheId] = {}; 27 | 28 | $el._serverData = elementCacheId; 29 | $el.attr('data-server-data', elementCacheId); 30 | } 31 | 32 | var cache = _thoraxServerData[elementCacheId]; 33 | cache[name] = undefined; 34 | 35 | // Store whatever data that we have 36 | if (_.isArray(data) && !_.isString(dataIds) && !data.toJSON) { 37 | if (data.length) { 38 | cache[name] = _.map(data, function(value, key) { 39 | return lookupValue(value, dataIds[key], options); 40 | }); 41 | } 42 | } else if (_.isObject(data) && !_.isString(dataIds) && !data.toJSON) { 43 | var stored = {}, 44 | valueSet; 45 | _.each(data, function(value, key) { 46 | stored[key] = lookupValue(value, dataIds[key], options); 47 | valueSet = true; 48 | }); 49 | if (valueSet) { 50 | cache[name] = stored; 51 | } 52 | } else { 53 | // We were passed a singular value (attributeId is a simple id value) 54 | cache[name] = lookupValue(data, dataIds, options); 55 | } 56 | }, 57 | load: function(el, name, parentView, context) { 58 | var elementCacheId = parseInt(el.getAttribute('data-server-data'), 0), 59 | cache = _thoraxServerData[elementCacheId]; 60 | if (!cache) { 61 | return; 62 | } 63 | 64 | function resolve(value) { 65 | return (value && value.$lut != null) ? lookupField(parentView, context, value.$lut) : value; 66 | } 67 | 68 | cache = cache[name]; 69 | if (_.isArray(cache)) { 70 | return _.map(cache, resolve); 71 | } else if (!_.isFunction(cache) && _.isObject(cache) && cache.$lut == null) { 72 | var ret = {}; 73 | _.each(cache, function(value, key) { 74 | ret[key] = resolve(value); 75 | }); 76 | return ret; 77 | } else { 78 | return resolve(cache); 79 | } 80 | }, 81 | 82 | serialize: function() { 83 | if ($serverSide) { 84 | return JSON.stringify(_thoraxServerData); 85 | } 86 | }, 87 | 88 | destroy: function($el) { 89 | /*jshint -W035 */ 90 | var elementCacheId = parseInt($el.attr('data-server-data'), 10); 91 | if (!isNaN(elementCacheId)) { 92 | _thoraxServerData[elementCacheId] = undefined; 93 | 94 | // Reclaim whatever slots that we can. This ensures a smaller output structure while avoiding 95 | // conflicts that may occur when operating in a shared environment. 96 | var len = _thoraxServerData.length; 97 | while (len-- && !_thoraxServerData[len]) { /* NOP */ } 98 | if (len < _thoraxServerData.length - 1) { 99 | _thoraxServerData.length = len + 1; 100 | } 101 | } 102 | }, 103 | 104 | _reset: function() { 105 | // Intended for tests only 106 | _thoraxServerData = []; 107 | } 108 | }; 109 | 110 | // Register a callback to output our content from the server implementation. 111 | if ($serverSide) { 112 | FruitLoops.onEmit(function() { 113 | $('body').append(''); 114 | }); 115 | } 116 | 117 | /* 118 | * Walks a given parent or context scope, attempting to resolve a dot 119 | * separated path. 120 | * 121 | * The parent context is given priority. 122 | */ 123 | function lookupField(parent, context, fieldName) { 124 | function lookup(context) { 125 | for (var i = 0; context && i < components.length; i++) { 126 | if (components[i] !== '' && components[i] !== '.' && components[i] !== 'this') { 127 | context = context[components[i]]; 128 | } 129 | } 130 | return context; 131 | } 132 | 133 | var components = fieldName.split('.'); 134 | return lookup(context) || lookup(parent); 135 | } 136 | 137 | /* 138 | * Determines the value to be saved in the lookup table to be restored on the client. 139 | */ 140 | function lookupValue(value, lutKey, data) { 141 | if (_.isString(value) || _.isNumber(value) || _.isNull(value) || _.isBoolean(value)) { 142 | return value; 143 | } else if (lutKey != null && lutKey !== true && !/^\.\.\//.test(lutKey)) { 144 | // This is an object what has a path associated with it so we should hopefully 145 | // be able to resolve it on the client. 146 | var contextPath = Handlebars.Utils.appendContextPath(data.contextPath, lutKey); 147 | if (lookupField(data.view, data.root, contextPath) === value) { 148 | return { 149 | $lut: contextPath 150 | }; 151 | } 152 | } 153 | 154 | // This is some sort of unsuppored object type or a depthed reference (../foo) 155 | // which is not supported. 156 | throw createError('server-marshall-object'); 157 | } 158 | -------------------------------------------------------------------------------- /src/server-side.js: -------------------------------------------------------------------------------- 1 | /*global $serverSide, FruitLoops */ 2 | 3 | // Override uniqueId to ensure uniqueness across both the server and client 4 | // rendering cycles 5 | var _idCounter = window._idCounter || 0, 6 | _reqId = ''; 7 | window._resetIdCounter = function(reqId) { 8 | _idCounter = 0; 9 | _reqId = reqId || ''; 10 | }; 11 | 12 | _.uniqueId = function(prefix) { 13 | var id = _reqId + (++_idCounter); 14 | return prefix ? prefix + id : id; 15 | }; 16 | 17 | if (window.$serverSide) { 18 | FruitLoops.onEmit(function() { 19 | $('body').append(''); 20 | }); 21 | } 22 | -------------------------------------------------------------------------------- /src/util.js: -------------------------------------------------------------------------------- 1 | /*global createRegistryWrapper:true, getEventCallback */ 2 | 3 | function createErrorMessage(code) { 4 | return 'Error "' + code + '". For more information visit http://thoraxjs.org/error-codes.html' + '#' + code; 5 | } 6 | function createError(code, info) { 7 | var error = new Error(createErrorMessage(code)); 8 | error.name = code; 9 | error.info = info; 10 | return error; 11 | } 12 | 13 | function createRegistryWrapper(klass, hash) { 14 | var $super = klass.extend; 15 | klass.extend = function() { 16 | var child = $super.apply(this, arguments); 17 | if (child.prototype.name) { 18 | hash[child.prototype.name] = child; 19 | } 20 | return child; 21 | }; 22 | } 23 | 24 | function registryGet(object, type, name, ignoreErrors) { 25 | var target = object[type], 26 | value; 27 | if (_.indexOf(name, '.') >= 0) { 28 | var bits = name.split(/\./); 29 | name = bits.pop(); 30 | _.each(bits, function(key) { 31 | target = target[key]; 32 | }); 33 | } 34 | target && (value = target[name]); 35 | if (!value && !ignoreErrors) { 36 | throw new Error(type + ': ' + name + ' does not exist.'); 37 | } else { 38 | return value; 39 | } 40 | } 41 | 42 | 43 | function assignView(view, attributeName, options) { 44 | var ViewClass; 45 | // if attribute is the name of view to fetch 46 | if (_.isString(view[attributeName])) { 47 | ViewClass = Thorax.Util.getViewClass(view[attributeName], true); 48 | // else try and fetch the view based on the name 49 | } else if (view.name && !_.isFunction(view[attributeName])) { 50 | ViewClass = Thorax.Util.getViewClass(view.name + (options.extension || ''), true); 51 | } 52 | // if we found something, assign it 53 | if (ViewClass && !_.isFunction(view[attributeName])) { 54 | view[attributeName] = ViewClass; 55 | } 56 | // if nothing was found and it's required, throw 57 | if (options.required && !_.isFunction(view[attributeName])) { 58 | throw new Error('View ' + (view.name || view.cid) + ' requires: ' + attributeName); 59 | } 60 | } 61 | 62 | function assignTemplate(view, attributeName, options) { 63 | var template; 64 | // if attribute is the name of template to fetch 65 | if (_.isString(view[attributeName])) { 66 | template = Thorax.Util.getTemplate(view[attributeName], true); 67 | // else try and fetch the template based on the name 68 | } else if (view.name && !_.isFunction(view[attributeName])) { 69 | template = Thorax.Util.getTemplate(view.name + (options.extension || ''), true); 70 | } 71 | // CollectionView and LayoutView have a defaultTemplate that may be used if none 72 | // was found, regular views must have a template if render() is called 73 | if (!template && attributeName === 'template' && view._defaultTemplate) { 74 | template = view._defaultTemplate; 75 | } 76 | // if we found something, assign it 77 | if (template && !_.isFunction(view[attributeName])) { 78 | view[attributeName] = template; 79 | } 80 | // if nothing was found and it's required, throw 81 | if (options.required && !_.isFunction(view[attributeName])) { 82 | var err = new Error('view-requires: ' + attributeName); 83 | err.info = { 84 | name: view.name || view.cid, 85 | parent: view.parent && (view.parent.name || view.parent.cid), 86 | helperName: view._helperName 87 | }; 88 | throw err; 89 | } 90 | } 91 | 92 | // getValue is used instead of _.result because we 93 | // need an extra scope parameter, and will minify 94 | // better than _.result 95 | function getValue(object, prop, scope) { 96 | prop = object && object[prop]; 97 | return prop && prop.call ? prop.call(scope || object) : prop; 98 | } 99 | 100 | var inheritVars = {}; 101 | function createInheritVars(self) { 102 | // Ensure that we have our static event objects 103 | _.each(inheritVars, function(obj) { 104 | if (!self[obj.name]) { 105 | self[obj.name] = []; 106 | } 107 | }); 108 | } 109 | function resetInheritVars(self) { 110 | // Ensure that we have our static event objects 111 | _.each(inheritVars, function(obj) { 112 | self[obj.name] = []; 113 | }); 114 | } 115 | function walkInheritTree(source, fieldName, isStatic, callback) { 116 | /*jshint boss:true */ 117 | var tree = []; 118 | if (_.has(source, fieldName)) { 119 | tree.push(source); 120 | } 121 | var iterate = source; 122 | if (isStatic) { 123 | while (iterate = iterate.__parent__) { 124 | if (_.has(iterate, fieldName)) { 125 | tree.push(iterate); 126 | } 127 | } 128 | } else { 129 | iterate = iterate.constructor; 130 | 131 | // Iterate over all prototypes exclusive of the backbone view prototype 132 | while (iterate && iterate.__super__) { 133 | if (iterate.prototype && _.has(iterate.prototype, fieldName)) { 134 | tree.push(iterate.prototype); 135 | } 136 | iterate = iterate.__super__ && iterate.__super__.constructor; 137 | } 138 | } 139 | 140 | var i = tree.length; 141 | while (i--) { 142 | _.each(getValue(tree[i], fieldName, source), callback); 143 | } 144 | } 145 | 146 | function objectEvents(target, eventName, callback, context) { 147 | if (_.isObject(callback)) { 148 | var spec = inheritVars[eventName]; 149 | if (spec && spec.event) { 150 | if (target && target.listenTo && target[eventName] && target[eventName].cid) { 151 | addEvents(target, callback, context, eventName); 152 | } else { 153 | addEvents(target['_' + eventName + 'Events'], callback, context); 154 | } 155 | return true; 156 | } 157 | } 158 | } 159 | // internal listenTo function will error on destroyed 160 | // race condition 161 | function listenTo(object, target, eventName, callback, context) { 162 | // getEventCallback will resolve if it is a string or a method 163 | // and return a method 164 | var callbackMethod = getEventCallback(callback, object), 165 | destroyedCount = 0; 166 | 167 | function eventHandler() { 168 | if (object.el) { 169 | callbackMethod.apply(context, arguments); 170 | } else { 171 | // If our event handler is removed by destroy while another event is processing then we 172 | // we might see one latent event percolate through due to caching in the event loop. If we 173 | // see multiple events this is a concern and a sign that something was not cleaned properly. 174 | if (destroyedCount) { 175 | throw new Error('destroyed-event:' + object.name + ':' + eventName); 176 | } 177 | destroyedCount++; 178 | } 179 | } 180 | eventHandler._callback = callbackMethod._callback || callbackMethod; 181 | eventHandler._thoraxBind = true; 182 | object.listenTo(target, eventName, eventHandler); 183 | } 184 | 185 | function addEvents(target, source, context, listenToObject) { 186 | function addEvent(callback, eventName) { 187 | if (listenToObject) { 188 | listenTo(target, target[listenToObject], eventName, callback, context || target); 189 | } else { 190 | target.push([eventName, callback, context]); 191 | } 192 | } 193 | 194 | _.each(source, function(callback, eventName) { 195 | if (_.isArray(callback)) { 196 | _.each(callback, function(cb) { 197 | addEvent(cb, eventName); 198 | }); 199 | } else { 200 | addEvent(callback, eventName); 201 | } 202 | }); 203 | } 204 | 205 | // In helpers "tagName" or "tag" may be specified, as well 206 | // as "class" or "className". Normalize to "tagName" and 207 | // "className" to match the property names used by Backbone 208 | // jQuery, etc. Special case for "className" in 209 | // Thorax.Util.tag: will be rewritten as "class" in 210 | // generated HTML. 211 | function normalizeHTMLAttributeOptions(options) { 212 | if (options.tag) { 213 | options.tagName = options.tag; 214 | delete options.tag; 215 | } 216 | if (options['class']) { 217 | options.className = options['class']; 218 | delete options['class']; 219 | } 220 | } 221 | 222 | var voidTags; 223 | function isVoidTag(tag) { 224 | if (!voidTags) { 225 | // http://www.w3.org/html/wg/drafts/html/master/syntax.html#void-elements 226 | var tags = 'area,base,br,col,embed,hr,img,input,keygen,link,menuitem,meta,param,source,track,wbr'; 227 | 228 | voidTags = {}; 229 | _.each(tags.split(','), function(tag) { 230 | voidTags[tag] = true; 231 | }); 232 | } 233 | 234 | return voidTags[tag]; 235 | } 236 | 237 | function filterAncestors(parent, callback) { 238 | return function() { 239 | if ($(this).parent().view({el: true, helper: true})[0] === parent.el) { 240 | return callback.call(this); 241 | } 242 | }; 243 | } 244 | 245 | Thorax.Util = { 246 | getViewInstance: function(name, attributes) { 247 | var ViewClass = Thorax.Util.getViewClass(name, true); 248 | return ViewClass ? new ViewClass(attributes || {}) : name; 249 | }, 250 | 251 | getViewClass: function(name, ignoreErrors) { 252 | if (_.isString(name)) { 253 | return registryGet(Thorax, 'Views', name, ignoreErrors); 254 | } else if (_.isFunction(name)) { 255 | return name; 256 | } else { 257 | return false; 258 | } 259 | }, 260 | 261 | getTemplate: function(file, ignoreErrors) { 262 | if (_.isFunction(file)) { 263 | return file; 264 | } 265 | 266 | //append the template path prefix if it is missing 267 | var pathPrefix = Thorax.templatePathPrefix, 268 | template; 269 | if (pathPrefix && file.substr(0, pathPrefix.length) !== pathPrefix) { 270 | file = pathPrefix + file; 271 | } 272 | 273 | // Without extension 274 | file = file.replace(/\.handlebars$/, ''); 275 | template = Handlebars.templates[file]; 276 | if (!template) { 277 | // With extension 278 | file = file + '.handlebars'; 279 | template = Handlebars.templates[file]; 280 | } 281 | 282 | if (!template && !ignoreErrors) { 283 | throw new Error('templates: ' + file + ' does not exist.'); 284 | } 285 | return template; 286 | }, 287 | 288 | //'selector' is not present in $('

') 289 | //TODO: investigage a better detection method 290 | is$: function(obj) { 291 | return _.isObject(obj) && ('length' in obj); 292 | }, 293 | expandToken: function(input, scope, encode) { 294 | /*jshint boss:true */ 295 | 296 | if (input && input.indexOf && input.indexOf('{{') >= 0) { 297 | var re = /(?:\{?[^{]+)|(?:\{\{([^}]+)\}\})/g, 298 | match, 299 | ret = []; 300 | function deref(token, scope) { 301 | if (token.match(/^("|')/) && token.match(/("|')$/)) { 302 | return token.replace(/(^("|')|('|")$)/g, ''); 303 | } 304 | var segments = token.split('.'), 305 | len = segments.length; 306 | for (var i = 0; scope && i < len; i++) { 307 | if (segments[i] !== 'this') { 308 | scope = scope[segments[i]]; 309 | } 310 | } 311 | if (encode && _.isString(scope)) { 312 | return encodeURIComponent(scope); 313 | } else { 314 | return scope; 315 | } 316 | } 317 | while (match = re.exec(input)) { 318 | if (match[1]) { 319 | var params = match[1].split(/\s+/); 320 | if (params.length > 1) { 321 | var helper = params.shift(); 322 | params = _.map(params, function(param) { return deref(param, scope); }); 323 | if (Handlebars.helpers[helper]) { 324 | ret.push(Handlebars.helpers[helper].apply(scope, params)); 325 | } else { 326 | // If the helper is not defined do nothing 327 | ret.push(match[0]); 328 | } 329 | } else { 330 | ret.push(deref(params[0], scope)); 331 | } 332 | } else { 333 | ret.push(match[0]); 334 | } 335 | } 336 | input = ret.join(''); 337 | } 338 | return input; 339 | }, 340 | tag: function(attributes, content, scope) { 341 | var tag = attributes.tagName || 'div', 342 | noClose = isVoidTag(tag); 343 | 344 | if (noClose && content) { 345 | throw new Error(createErrorMessage('void-tag-content')); 346 | } 347 | 348 | var openingTag = '<' + tag + ' ' + _.map(attributes, function(value, key) { 349 | if (value == null || key === 'expand-tokens' || key === 'tagName') { 350 | return ''; 351 | } 352 | var formattedValue = value; 353 | if (scope) { 354 | formattedValue = Thorax.Util.expandToken(value, scope); 355 | } 356 | return (key === 'className' ? 'class' : key) + '="' + Handlebars.Utils.escapeExpression(formattedValue) + '"'; 357 | }).join(' ') + '>'; 358 | 359 | if (noClose) { 360 | return openingTag; 361 | } else { 362 | return openingTag + (content == null ? '' : content) + ''; 363 | } 364 | } 365 | }; 366 | -------------------------------------------------------------------------------- /tasks/build.js: -------------------------------------------------------------------------------- 1 | var fs = require('fs'), 2 | path = require('path'), 3 | exec = require('child_process').exec, 4 | uglify = require('uglify-js'), 5 | targetDir = '../build/release', 6 | relativeTargetDir = 'build/release'; //for command line 7 | 8 | try { 9 | fs.mkdirSync(path.join(__dirname, targetDir)); 10 | } catch(e) { 11 | /* NOP */ 12 | } 13 | 14 | var config = { 15 | "thorax-combined.js": [ 16 | 'components/jquery/jquery.js', 17 | 'components/handlebars/handlebars.js', 18 | 'components/underscore/underscore.js', 19 | 'components/backbone/backbone.js', 20 | 'build/dev/thorax.js' 21 | ], 22 | "thorax-combined-mobile.js": [ 23 | 'components/zepto/zepto.js', 24 | 'components/handlebars/handlebars.runtime.js', 25 | 'components/underscore/underscore.js', 26 | 'components/backbone/backbone.js', 27 | 'build/dev/thorax-mobile.js' 28 | ] 29 | }; 30 | 31 | function minify(code) { 32 | var jsp = uglify.parser, 33 | pro = uglify.uglify, 34 | ast = jsp.parse(code); 35 | ast = pro.ast_mangle(ast); 36 | ast = pro.ast_squeeze(ast); 37 | return pro.gen_code(ast); 38 | } 39 | 40 | function removeSourceMapUrl(source) { 41 | return source.replace(/\/\/@ sourceMappingURL\=thorax(\-mobile)?\.js\.map/, ''); 42 | } 43 | 44 | module.exports = function(grunt) { 45 | grunt.registerTask('thorax:build', function() { 46 | var done = this.async(); 47 | exec('jake lumbar', function(error, stdout, stderr) { 48 | error && process.stdout.write(error.toString()); 49 | stdout && process.stdout.write(stdout); 50 | stderr && process.stdout.write(stderr); 51 | for (var target in config) { 52 | var fileList = config[target], 53 | output = ''; 54 | fileList.forEach(function(file) { 55 | output += fs.readFileSync(path.join(__dirname, '..', file)).toString() + "\n"; 56 | }); 57 | if (!fs.existsSync(path.join(__dirname, targetDir))) { 58 | fs.mkdirSync(path.join(__dirname, targetDir)); 59 | } 60 | var targetFile = path.join(__dirname, targetDir, target); 61 | fs.writeFileSync(targetFile.replace(/\.js$/, '.min.js'), minify(output)); 62 | fs.writeFileSync(targetFile, output); 63 | console.log("Wrote: " + targetFile); 64 | console.log("Wrote: " + targetFile.replace(/\.js$/, '.min.js')); 65 | } 66 | var thoraxSrc = path.join(__dirname, '../build/dev/thorax.js'), 67 | thoraxMobileSrc = path.join(__dirname, '../build/dev/thorax-mobile.js'), 68 | thoraxDest = path.join(__dirname, targetDir, 'thorax.js'), 69 | thoraxMobileDest = path.join(__dirname, targetDir, 'thorax-mobile.js'), 70 | packageSrc = path.join(__dirname, '../package.json'), 71 | packageDest = path.join(relativeTargetDir, 'package.json'), 72 | composerSrc = path.join(__dirname, '../composer.json'), 73 | composerDest = path.join(relativeTargetDir, 'composer.json'), 74 | bowerSrc = path.join(__dirname, '../bower.json'), 75 | bowerDest = path.join(relativeTargetDir, 'bower.json'); 76 | 77 | var copyCommand = 78 | 'cp ' + packageSrc + ' ' + packageDest + ';' + 79 | 'cp ' + composerSrc + ' ' + composerDest + ';' + 80 | 'cp ' + bowerSrc + ' ' + bowerDest + ';'; 81 | 82 | exec(copyCommand, function(error, stdout, stderr) { 83 | error && process.stdout.write(error.toString()); 84 | stdout && process.stdout.write(stdout); 85 | stderr && process.stdout.write(stderr); 86 | 87 | fs.writeFileSync(thoraxDest + '.map', fs.readFileSync(thoraxSrc + '.map').toString()); 88 | fs.writeFileSync(thoraxMobileDest + '.map', fs.readFileSync(thoraxMobileSrc + '.map').toString()); 89 | 90 | fs.writeFileSync(thoraxDest, removeSourceMapUrl(fs.readFileSync(thoraxSrc).toString())); 91 | fs.writeFileSync(thoraxMobileDest, removeSourceMapUrl(fs.readFileSync(thoraxMobileSrc).toString())); 92 | 93 | fs.writeFileSync(thoraxDest.replace(/\.js$/, '.min.js'), minify(fs.readFileSync(thoraxDest).toString())); 94 | fs.writeFileSync(thoraxMobileDest.replace(/\.js$/, '.min.js'), minify(fs.readFileSync(thoraxMobileDest).toString())); 95 | 96 | console.log("Wrote: " + thoraxDest + '.map'); 97 | console.log("Wrote: " + thoraxMobileDest + '.map'); 98 | console.log("Wrote: " + thoraxDest); 99 | console.log("Wrote: " + thoraxMobileDest); 100 | console.log("Wrote: " + thoraxDest.replace(/\.js$/, '.min.js')); 101 | console.log("Wrote: " + thoraxMobileDest.replace(/\.js$/, '.min.js')); 102 | done(true); 103 | }); 104 | }); 105 | }); 106 | }; 107 | -------------------------------------------------------------------------------- /tasks/fruit-loops.js: -------------------------------------------------------------------------------- 1 | /*jshint node:true */ 2 | var FruitLoops = require('fruit-loops'), 3 | Mocha = require('mocha'), 4 | Sinon = require('sinon'), 5 | expect = require('expect.js'), 6 | path = require('path'); 7 | 8 | module.exports = function(grunt) { 9 | grunt.registerTask('fruit-loops:test', function() { 10 | var done = this.async(); 11 | 12 | var _error = console.error; 13 | console.error = console.log; 14 | 15 | var page = FruitLoops.page({ 16 | index: __dirname + '/../build/dev/fruit-loops/test.html', 17 | evil: true, 18 | 19 | resolver: function(href, window) { 20 | if (!/-server/.test(href)) { 21 | href = href.replace(/\.js$/, '-server.js'); 22 | } 23 | return path.resolve(__dirname + '/../build/dev/fruit-loops/', href); 24 | }, 25 | beforeExec: function(page, next) { 26 | page.window.Mocha = Mocha; 27 | page.window.mocha = createMocha(page.window); 28 | page.window.sinon = Sinon; 29 | page.window.expect = expect; 30 | 31 | // NOP emit so tests of that functionality don't cause early termination 32 | page.window.FruitLoops.emit = function() {}; 33 | 34 | var grep = grunt.option('grep'); 35 | if (grep) { 36 | page.window.mocha.grep(grep); 37 | } 38 | 39 | next(); 40 | }, 41 | callback: function(err, data) { 42 | console.error = _error; 43 | 44 | if (err) { 45 | done(err); 46 | } else if (!page.window.mochaResults) { 47 | done(new Error('Fruit Loops tests terminated early')); 48 | } else if (page.window.mochaResults.reports.length) { 49 | done(new Error(page.window.mochaResults.reports.length + ' failed tests')); 50 | } else { 51 | done(); 52 | } 53 | } 54 | }); 55 | }); 56 | }; 57 | 58 | function createMocha(global) { 59 | var mocha = new Mocha({reporter: 'dot'}), 60 | emit = global.FruitLoops.emit; 61 | 62 | /** 63 | * Override ui to ensure that the ui functions are initialized. 64 | * Normally this would happen in Mocha.prototype.loadFiles. 65 | */ 66 | 67 | mocha.ui = function(ui){ 68 | Mocha.prototype.ui.call(this, ui); 69 | this.suite.emit('pre-require', global, null, this); 70 | return this; 71 | }; 72 | 73 | /** 74 | * Setup mocha with the given setting options. 75 | */ 76 | 77 | mocha.setup = function(opts){ 78 | if ('string' == typeof opts) { 79 | opts = { ui: opts }; 80 | } 81 | for (var opt in opts) { 82 | this[opt](opts[opt]); 83 | } 84 | return this; 85 | }; 86 | 87 | /** 88 | * Run mocha, returning the Runner. 89 | */ 90 | 91 | mocha.run = function(fn){ 92 | return Mocha.prototype.run.call(mocha, function() { 93 | // Have to manually emit as mocha will use the process async methods rather than the 94 | // window's so events emit will cause early termination. 95 | emit.call(FruitLoops); 96 | 97 | fn && fn(); 98 | }); 99 | }; 100 | return mocha; 101 | } 102 | -------------------------------------------------------------------------------- /tasks/util/git.js: -------------------------------------------------------------------------------- 1 | var childProcess = require('child_process'); 2 | 3 | module.exports = { 4 | debug: function(callback) { 5 | childProcess.exec('git remote -v', {}, function(err, remotes) { 6 | if (err) { 7 | throw new Error('git.remote: ' + err.message); 8 | } 9 | 10 | childProcess.exec('git branch -a', {}, function(err, branches) { 11 | if (err) { 12 | throw new Error('git.branch: ' + err.message); 13 | } 14 | 15 | callback(remotes, branches); 16 | }); 17 | }); 18 | }, 19 | clean: function(callback) { 20 | childProcess.exec('git diff-index --name-only HEAD --', {}, function(err, stdout) { 21 | callback(undefined, !err && !stdout); 22 | }); 23 | }, 24 | 25 | commitInfo: function(callback) { 26 | module.exports.head(function(err, headSha) { 27 | module.exports.master(function(err, masterSha) { 28 | module.exports.tagName(function(err, tagName) { 29 | callback(undefined, { 30 | head: headSha, 31 | master: masterSha, 32 | tagName: tagName, 33 | isMaster: headSha === masterSha 34 | }); 35 | }); 36 | }); 37 | }); 38 | }, 39 | 40 | head: function(callback) { 41 | childProcess.exec('git rev-parse --short HEAD', {}, function(err, stdout) { 42 | if (err) { 43 | throw new Error('git.head: ' + err.message); 44 | } 45 | 46 | callback(undefined, stdout.trim()); 47 | }); 48 | }, 49 | master: function(callback) { 50 | childProcess.exec('git rev-parse --short origin/master', {}, function(err, stdout) { 51 | // This will error if master was not checked out but in this case we know we are not master 52 | // so we can ignore. 53 | if (err && !/Needed a single revision/.test(err.message)) { 54 | throw new Error('git.master: ' + err.message); 55 | } 56 | 57 | callback(undefined, stdout.trim()); 58 | }); 59 | }, 60 | 61 | add: function(path, callback, cwd) { 62 | console.log({cwd: cwd}); 63 | childProcess.exec('git add -f ' + path, {cwd: cwd}, function(err, stdout) { 64 | if (err) { 65 | throw new Error('git.add: ' + err.message); 66 | } 67 | 68 | callback(); 69 | }); 70 | }, 71 | commit: function(name, callback) { 72 | childProcess.exec('git commit --message=' + name, {}, function(err, stdout) { 73 | if (err) { 74 | throw new Error('git.commit: ' + err.message); 75 | } 76 | 77 | callback(); 78 | }); 79 | }, 80 | tag: function(name, callback) { 81 | childProcess.exec('git tag -a --message=' + name + ' ' + name, {}, function(err, stdout, stderr) { 82 | if (err) { 83 | throw new Error('git.tag: ' + err.message); 84 | throw err; 85 | } 86 | 87 | callback(); 88 | }); 89 | }, 90 | tagName: function(callback) { 91 | childProcess.exec('git tag -l --points-at HEAD', {}, function(err, stdout) { 92 | if (err) { 93 | throw new Error('git.tagName: ' + err.message); 94 | } 95 | 96 | var tags = stdout.trim().split(/\n/), 97 | versionTags = tags.filter(function(tag) { return /^v/.test(tag); }); 98 | callback(undefined, versionTags[0] || tags[0]); 99 | }); 100 | } 101 | }; -------------------------------------------------------------------------------- /tasks/version.js: -------------------------------------------------------------------------------- 1 | var async = require('async'), 2 | git = require('./util/git'), 3 | semver = require('semver'), 4 | path = require('path'); 5 | 6 | module.exports = function(grunt) { 7 | grunt.registerTask('version', 'Updates component.json', function() { 8 | var done = this.async(), 9 | version = grunt.option('ver'); 10 | 11 | if (!semver.valid(version)) { 12 | throw new Error('Must provide a version number (Ex: --ver=1.0.0):\n\t' + version + '\n\n'); 13 | } 14 | 15 | grunt.log.writeln('Updating to version ' + version); 16 | 17 | async.series([ 18 | function(next) { 19 | replace(path.join(__dirname, '../component.json'), /"version":.*/, '"version": "' + version + '",'); 20 | git.add('component.json', next, path.join(__dirname, '..')); 21 | }, 22 | ], done); 23 | 24 | function replace(path, regex, replace) { 25 | var content = grunt.file.read(path); 26 | content = content.replace(regex, replace); 27 | grunt.file.write(path, content); 28 | } 29 | 30 | }); 31 | }; -------------------------------------------------------------------------------- /test/README.md: -------------------------------------------------------------------------------- 1 | ## Invoking the test suite from the command line 2 | 3 | From the project root: 4 | 5 | npm test 6 | 7 | ## Invoking the test suite from a browser 8 | 9 | From the project root: 10 | 11 | jake watch 12 | 13 | Then open: 14 | 15 | http://localhost:8080/jquery/test.html 16 | http://localhost:8080/zepto/test.html 17 | 18 | -------------------------------------------------------------------------------- /test/fruit-loops.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Thorax Test Suite 6 | 7 | 8 | 9 | 10 |
11 |
12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /test/jquery-backbone-1-0.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Thorax Test Suite 6 | 7 | 8 | 9 | 10 |
11 |
12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /test/jquery.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Thorax Test Suite 6 | 7 | 8 | 9 | 10 |
11 |
12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /test/lib/handlebars-reset.js: -------------------------------------------------------------------------------- 1 | this.Handlebars = undefined; 2 | -------------------------------------------------------------------------------- /test/lib/sinon-ie.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Sinon.JS 1.5.2, 2012/11/27 3 | * 4 | * @author Christian Johansen (christian@cjohansen.no) 5 | * @author Contributors: https://github.com/cjohansen/Sinon.JS/blob/master/AUTHORS 6 | * 7 | * (The BSD License) 8 | * 9 | * Copyright (c) 2010-2012, Christian Johansen, christian@cjohansen.no 10 | * All rights reserved. 11 | * 12 | * Redistribution and use in source and binary forms, with or without modification, 13 | * are permitted provided that the following conditions are met: 14 | * 15 | * * Redistributions of source code must retain the above copyright notice, 16 | * this list of conditions and the following disclaimer. 17 | * * Redistributions in binary form must reproduce the above copyright notice, 18 | * this list of conditions and the following disclaimer in the documentation 19 | * and/or other materials provided with the distribution. 20 | * * Neither the name of Christian Johansen nor the names of his contributors 21 | * may be used to endorse or promote products derived from this software 22 | * without specific prior written permission. 23 | * 24 | * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 25 | * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 26 | * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 27 | * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 28 | * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 29 | * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 30 | * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 31 | * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 32 | * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF 33 | * THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 34 | */ 35 | 36 | /*global sinon, setTimeout, setInterval, clearTimeout, clearInterval, Date*/ 37 | /** 38 | * Helps IE run the fake timers. By defining global functions, IE allows 39 | * them to be overwritten at a later point. If these are not defined like 40 | * this, overwriting them will result in anything from an exception to browser 41 | * crash. 42 | * 43 | * If you don't require fake timers to work in IE, don't include this file. 44 | * 45 | * @author Christian Johansen (christian@cjohansen.no) 46 | * @license BSD 47 | * 48 | * Copyright (c) 2010-2011 Christian Johansen 49 | */ 50 | function setTimeout() {} 51 | function clearTimeout() {} 52 | function setInterval() {} 53 | function clearInterval() {} 54 | function Date() {} 55 | 56 | // Reassign the original functions. Now their writable attribute 57 | // should be true. Hackish, I know, but it works. 58 | setTimeout = sinon.timers.setTimeout; 59 | clearTimeout = sinon.timers.clearTimeout; 60 | setInterval = sinon.timers.setInterval; 61 | clearInterval = sinon.timers.clearInterval; 62 | Date = sinon.timers.Date; 63 | 64 | /*global sinon*/ 65 | /** 66 | * Helps IE run the fake XMLHttpRequest. By defining global functions, IE allows 67 | * them to be overwritten at a later point. If these are not defined like 68 | * this, overwriting them will result in anything from an exception to browser 69 | * crash. 70 | * 71 | * If you don't require fake XHR to work in IE, don't include this file. 72 | * 73 | * @author Christian Johansen (christian@cjohansen.no) 74 | * @license BSD 75 | * 76 | * Copyright (c) 2010-2011 Christian Johansen 77 | */ 78 | function XMLHttpRequest() {} 79 | 80 | // Reassign the original function. Now its writable attribute 81 | // should be true. Hackish, I know, but it works. 82 | XMLHttpRequest = sinon.xhr.XMLHttpRequest || undefined; 83 | -------------------------------------------------------------------------------- /test/src/deferrable.js: -------------------------------------------------------------------------------- 1 | 2 | describe('Deferrable', function() { 3 | describe('#Deferrable', function() { 4 | describe('sync', function() { 5 | it('should work on no exec', function() { 6 | // Does not throw 7 | Thorax.Util.Deferrable().run(); 8 | }); 9 | it('should handle', function() { 10 | var deferrable = Thorax.Util.Deferrable(), 11 | foo; 12 | deferrable.exec(function() { 13 | expect(foo).to.be(undefined); 14 | foo = 'foo'; 15 | }); 16 | deferrable.exec(function() { 17 | expect(foo).to.equal('foo'); 18 | foo = 'bar'; 19 | }); 20 | expect(foo).to.be(undefined); 21 | deferrable.run(); 22 | expect(foo).to.equal('bar'); 23 | }); 24 | it('should handle late addition', function() { 25 | var deferrable = Thorax.Util.Deferrable(), 26 | foo; 27 | deferrable.exec(function() { 28 | expect(foo).to.be(undefined); 29 | foo = 'foo'; 30 | 31 | deferrable.exec(function() { 32 | expect(foo).to.equal('bar'); 33 | foo = 'bat'; 34 | }); 35 | }); 36 | deferrable.exec(function() { 37 | expect(foo).to.equal('foo'); 38 | foo = 'bar'; 39 | }); 40 | expect(foo).to.be(undefined); 41 | deferrable.run(); 42 | expect(foo).to.equal('bat'); 43 | }); 44 | it('should handle chaining', function() { 45 | var deferrable = Thorax.Util.Deferrable(), 46 | foo; 47 | deferrable.exec(function() { 48 | expect(foo).to.be(undefined); 49 | foo = 'foo'; 50 | }); 51 | deferrable.chain(function() { 52 | expect(foo).to.equal('foo'); 53 | foo = 'bar'; 54 | }); 55 | expect(foo).to.be(undefined); 56 | deferrable.run(); 57 | expect(foo).to.equal('bar'); 58 | }); 59 | }); 60 | 61 | describe('async', function() { 62 | it('should work on no exec', function(done) { 63 | this.clock.restore(); 64 | Thorax.Util.Deferrable(done).run(); 65 | }); 66 | it('should handle', function(done) { 67 | this.clock.restore(); 68 | var deferrable = Thorax.Util.Deferrable(function() { 69 | expect(foo).to.equal('bar'); 70 | done(); 71 | }); 72 | var foo; 73 | deferrable.exec(function() { 74 | expect(foo).to.be(undefined); 75 | foo = 'foo'; 76 | }); 77 | deferrable.exec(function() { 78 | expect(foo).to.equal('foo'); 79 | foo = 'bar'; 80 | }); 81 | deferrable.run(); 82 | expect(foo).to.be(undefined); 83 | }); 84 | it('should handle late addition', function(done) { 85 | this.clock.restore(); 86 | var deferrable = Thorax.Util.Deferrable(function() { 87 | expect(foo).to.equal('bat'); 88 | done(); 89 | }); 90 | var foo; 91 | deferrable.exec(function() { 92 | expect(foo).to.be(undefined); 93 | foo = 'foo'; 94 | 95 | deferrable.exec(function() { 96 | expect(foo).to.equal('bar'); 97 | foo = 'bat'; 98 | }); 99 | }); 100 | deferrable.exec(function() { 101 | expect(foo).to.equal('foo'); 102 | foo = 'bar'; 103 | }); 104 | expect(foo).to.be(undefined); 105 | deferrable.run(); 106 | }); 107 | it('should handle chaining', function(done) { 108 | this.clock.restore(); 109 | var deferrable = Thorax.Util.Deferrable(function() { 110 | expect(foo).to.equal('bar'); 111 | done(); 112 | }); 113 | var foo; 114 | deferrable.exec(function() { 115 | expect(foo).to.be(undefined); 116 | foo = 'foo'; 117 | }); 118 | deferrable.chain(function(next) { 119 | expect(foo).to.equal('foo'); 120 | foo = 'bar'; 121 | next(); 122 | }); 123 | expect(foo).to.be(undefined); 124 | deferrable.run(); 125 | }); 126 | }); 127 | }); 128 | 129 | describe('#triggerDeferrable', function() { 130 | var view; 131 | beforeEach(function() { 132 | view = new Thorax.View(); 133 | }); 134 | it('should exec sync', function() { 135 | var exec; 136 | view.on('foo', function(a, deferrable) { 137 | expect(a).to.equal('bar'); 138 | deferrable.exec(function() { 139 | exec = true; 140 | }); 141 | }); 142 | view.triggerDeferrable('foo', 'bar', undefined); 143 | expect(exec).to.be(true); 144 | }); 145 | it('should exec async', function(done) { 146 | this.clock.restore(); 147 | 148 | var exec; 149 | view.on('foo', function(a, deferrable) { 150 | expect(a).to.equal('bar'); 151 | deferrable.exec(function() { 152 | exec = true; 153 | }); 154 | }); 155 | view.triggerDeferrable('foo', 'bar', function() { 156 | expect(exec).to.be(true); 157 | done(); 158 | }); 159 | expect(exec).to.be(undefined); 160 | }); 161 | }); 162 | }); 163 | -------------------------------------------------------------------------------- /test/src/form.js: -------------------------------------------------------------------------------- 1 | describe('form', function() { 2 | if ($serverSide) { 3 | return; 4 | } 5 | 6 | var FormView = Thorax.View.extend({ 7 | name: 'form', 8 | template: function() { 9 | return '
' 10 | + '' 11 | + '' 12 | + '' 13 | + '' 14 | + '' 15 | + '' 16 | + '' 17 | + '
'; 18 | } 19 | }); 20 | 21 | it('serialize() / populate()', function() { 22 | var model = new Thorax.Model({ 23 | one: 'a', 24 | two: 'b', 25 | three: { 26 | four: 'c' 27 | } 28 | }); 29 | 30 | var view = new FormView(); 31 | view.render(); 32 | var attributes = view.serialize(); 33 | expect(attributes.one).to.equal('', 'serialize empty attributes'); 34 | expect(attributes.five).to.eql(['B', 'C'], 'serialize empty attributes'); 35 | expect(attributes.six).to.equal('LOL', 'serialize empty attributes'); 36 | view.setModel(model); 37 | attributes = view.serialize(); 38 | 39 | expect(attributes.one).to.equal('a', 'serialize attributes from model'); 40 | expect(attributes.two).to.equal('b', 'serialize attributes from model'); 41 | expect(attributes.three.four).to.equal('c', 'serialize attributes from model'); 42 | 43 | view.populate({ 44 | one: 'aa', 45 | two: 'b', 46 | three: { 47 | four: 'cc' 48 | } 49 | }); 50 | 51 | attributes = view.serialize(); 52 | expect(attributes.one).to.equal('aa', 'serialize attributes from populate()'); 53 | expect(attributes.two).to.equal('b', 'serialize attributes from populate()'); 54 | expect(attributes.three.four).to.equal('cc', 'serialize attributes from populate()'); 55 | 56 | view.validateInput = function() { 57 | return ['error']; 58 | }; 59 | var invalidCallbackCallCount = 0; 60 | view.on('invalid', function() { 61 | ++invalidCallbackCallCount; 62 | }); 63 | expect(view.serialize()).to.be(undefined); 64 | expect(invalidCallbackCallCount).to.equal(1, 'invalid event triggered when validateInput returned errors'); 65 | }); 66 | 67 | it('nested serialize / populate', function() { 68 | //the test has a child view and a mock helper view fragment 69 | //the child view should act as a child view, the view fragment 70 | //should act as a part of the parent view 71 | var mockViewHelperFragment = '
'; 72 | var view = new Thorax.View({ 73 | child: new Thorax.View({ 74 | template: Handlebars.compile('') 75 | }), 76 | template: Handlebars.compile('{{view child}}' + mockViewHelperFragment) 77 | }); 78 | view.render(); 79 | var model = new Thorax.Model({ 80 | parentKey: 'parentValue', 81 | childKey: 'childValue' 82 | }); 83 | view.setModel(model); 84 | expect(view.$('input[name="parentKey"]').val()).to.equal('parentValue'); 85 | expect(view.$('input[name="childKey"]').val()).to.equal('childValue'); 86 | 87 | view.populate({ 88 | parentKey: '', 89 | childKey: '' 90 | }); 91 | expect(view.$('input[name="parentKey"]').val()).to.equal(''); 92 | expect(view.$('input[name="childKey"]').val()).to.equal(''); 93 | 94 | view.setModel(false); 95 | view.setModel(model, { 96 | populate: { 97 | children: false 98 | } 99 | }); 100 | expect(view.$('input[name="parentKey"]').eq(0).val()).to.equal('parentValue'); 101 | expect(view.$('input[name="childKey"]').eq(1).val()).to.equal('childValue'); 102 | expect(view.$('input[name="childKey"]').eq(0).val()).to.equal(''); 103 | 104 | view.populate({ 105 | parentKey: '', 106 | childKey: '' 107 | }); 108 | view.populate(model.attributes, { 109 | children: false 110 | }); 111 | expect(view.$('input[name="parentKey"]').eq(0).val()).to.equal('parentValue'); 112 | expect(view.$('input[name="childKey"]').eq(1).val()).to.equal('childValue'); 113 | expect(view.$('input[name="childKey"]').eq(0).val()).to.equal(''); 114 | 115 | view.$('input[name="childKey"]').eq(0).val('childValue'); 116 | 117 | //multuple childKey inputs should be serialized so there should be an array 118 | expect(view.serialize({ 119 | children: true 120 | }).childKey[0]).to.equal('childValue'); 121 | 122 | view.$('input[name="childKey"]').eq(0).val(''); 123 | 124 | //no children so only one childKey 125 | expect(view.serialize({ 126 | children: false 127 | }).childKey).to.equal('childValue'); 128 | }); 129 | 130 | it('should populate on initial render', function() { 131 | var attributes = { 132 | one: 'a', 133 | two: 'b', 134 | three: { 135 | four: 'c' 136 | } 137 | }; 138 | var model = new Thorax.Model(attributes); 139 | 140 | var view = new FormView(); 141 | view.setModel(model); 142 | expect(view._renderCount).to.equal(0); 143 | expect(view._populateCount).to.equal(0); 144 | expect(view.serialize()).to.eql({}); 145 | 146 | view.render(); 147 | expect(_.pick(view.serialize(), _.keys(attributes))).to.eql(attributes); 148 | }); 149 | 150 | it('keep state on rerender', function() { 151 | var FormView = Thorax.View.extend({ 152 | name: 'form', 153 | template: function() { 154 | return '
'; 155 | } 156 | }); 157 | 158 | var model = new Thorax.Model({ 159 | test: 'fail', 160 | nested: { 161 | test: 'fail' 162 | } 163 | }); 164 | 165 | var view = new FormView(); 166 | 167 | var populateSpy = this.spy(), 168 | serializeSpy = this.spy(); 169 | 170 | // Set spies to make sure the event aren't firing 171 | view.on('populate', populateSpy); 172 | view.on('serialize', serializeSpy); 173 | 174 | view.render(); 175 | view.setModel(model); // Triggers first data population 176 | 177 | // Expect the populate event to have fired once 178 | expect(populateSpy.callCount).to.equal(1); 179 | expect(serializeSpy.callCount).to.equal(0); 180 | 181 | model.set('merge', 'test-merge'); // Set model data in between to test the merge 182 | expect(populateSpy.callCount).to.equal(2); 183 | 184 | view.$('input[name="test"]').val('test'); 185 | view.$('input[name="nested[test]"]').val('test-nested'); 186 | view.render(); // Should trigger another data population with user data 187 | 188 | // Expect the user input to persist 189 | expect(view.$('input[name="merge"]').eq(0).val()).to.equal('test-merge'); 190 | expect(view.$('input[name="test"]').eq(0).val()).to.equal('test'); 191 | expect(view.$('input[name="nested[test]"]').eq(0).val()).to.equal('test-nested'); 192 | 193 | // Expect the events to not have fired 194 | expect(populateSpy.callCount).to.equal(2); 195 | expect(serializeSpy.callCount).to.equal(0); 196 | }); 197 | 198 | it('discard previous state on model change', function() { 199 | var FormView = Thorax.View.extend({ 200 | name: 'form', 201 | template: function() { 202 | return '
'; 203 | } 204 | }); 205 | 206 | var model = new Thorax.Model({ 207 | merge: 'test-merge', 208 | test: 'fail', 209 | nested: { 210 | test: 'fail' 211 | } 212 | }); 213 | 214 | var view = new FormView(); 215 | view.render(); 216 | view.setModel(model); // Triggers first data population 217 | 218 | model.set('merge', 'test-merge'); // Set model data in between to test the merge 219 | view.$('input[name="test"]').val('test'); 220 | view.$('input[name="nested[test]"]').val('test-nested'); 221 | view.render(); 222 | 223 | model = new Thorax.Model({ 224 | test: 'win', 225 | nested: { 226 | test: 'win' 227 | } 228 | }); 229 | view.setModel(model); // Should trigger another data population with user data 230 | 231 | // Expect the user input to persist 232 | expect(view.$('input[name="merge"]').eq(0).val()).to.not.be.ok(); 233 | expect(view.$('input[name="test"]').eq(0).val()).to.equal('win'); 234 | expect(view.$('input[name="nested[test]"]').eq(0).val()).to.equal('win'); 235 | }); 236 | 237 | it('should not populate missing fields', function() { 238 | var FormView = Thorax.View.extend({ 239 | name: 'form', 240 | template: function() { 241 | return '
'; 242 | } 243 | }); 244 | 245 | var model = new Thorax.Model({}); 246 | 247 | var view = new FormView(); 248 | view.setModel(model); 249 | view.render(); 250 | 251 | // Expect the user input to persist 252 | expect(view.$('input[name="nested[test]"]').eq(0).val()).to.not.be.ok(); 253 | }); 254 | 255 | it('works when calling render before binding the model', function() { 256 | var FormView = Thorax.View.extend({ 257 | name: 'form', 258 | template: function() { 259 | return '
'; 260 | }, 261 | 262 | initialize: function() { 263 | this.render(); 264 | } 265 | }); 266 | var view = new FormView({model: new Thorax.Model({test: 'test'})}); 267 | expect(view.$('input[name="test"]').eq(0).val()).to.equal('test'); 268 | }); 269 | 270 | it('should populate on model change', function() { 271 | var view = new FormView(), 272 | model = new Thorax.Model(); 273 | 274 | view.setModel(model); 275 | view.render(); 276 | expect(view.$('input[name="one"]').eq(0).val()).to.not.be.ok(); 277 | 278 | model.set('one', 'foo'); 279 | expect(view.$('input[name="one"]').eq(0).val()).to.equal('foo'); 280 | }); 281 | 282 | it('should serialize checkboxes without values', function() { 283 | var view = new FormView({ 284 | template: function() { 285 | return ''; 286 | } 287 | }); 288 | 289 | var model = new Thorax.Model({}); 290 | view.setModel(model); 291 | view.render(); 292 | 293 | expect(view.serialize()).to.eql({}); 294 | 295 | model.set('foo', true); 296 | expect(view.serialize()).to.eql({foo: true}); 297 | 298 | view.render(); 299 | expect(view.serialize()).to.eql({foo: true}); 300 | }); 301 | 302 | describe( "populate checked", function() { 303 | var view; 304 | 305 | function renderedFormView(type, inputValue, attrValue) { 306 | var newView = new FormView({ 307 | template: function() { 308 | return ''; 309 | } 310 | }); 311 | 312 | var attributes = { bat: attrValue }; 313 | var model = new Thorax.Model(attributes); 314 | newView.setModel(model); 315 | newView.render(); 316 | return newView; 317 | } 318 | 319 | function viewCheckedAttr() { 320 | return view.$('input[name="bat"]').eq(0).attr('checked'); 321 | } 322 | 323 | function expectChecked() { 324 | expect(viewCheckedAttr()).to.equal('checked'); 325 | } 326 | 327 | function expectNotChecked() { 328 | // don't be the string 'false', instead be boolean false, since the attr is non-existent 329 | expect(viewCheckedAttr()).to.not.equal('false').and.to.be['false']; 330 | } 331 | 332 | describe( "checkbox", function() { 333 | it( "should populate input attribute 'checked' with value 'checked' if set", function() { 334 | view = renderedFormView('checkbox', 'man', 'man'); 335 | expectChecked(); 336 | }); 337 | 338 | it( "should not populate input attribute 'checked' if not set", function() { 339 | view = renderedFormView('checkbox', 'man', 'woman'); 340 | expectNotChecked(); 341 | }); 342 | }); 343 | 344 | describe( "radio", function() { 345 | // this is currently broken on fruit-loops. see here: https://github.com/kpdecker/cheerio/blob/master/lib/api/attributes.js#L143 346 | xit( "should populate input attribute 'checked' with value 'checked' if set", function() { 347 | view = renderedFormView('radio', 'man', 'man'); 348 | expectChecked(); 349 | }); 350 | 351 | it( "should not populate input attribute 'checked' if not set", function() { 352 | view = renderedFormView('radio', 'man', 'woman'); 353 | expectNotChecked(); 354 | }); 355 | }); 356 | }); 357 | }); 358 | -------------------------------------------------------------------------------- /test/src/helper-view.js: -------------------------------------------------------------------------------- 1 | describe('helper-view', function() { 2 | var spy, 3 | view; 4 | beforeEach(function() { 5 | spy = this.spy(function(viewHelper) { 6 | //expect(view.cid).to.equal(viewHelper.parent.cid); 7 | return viewHelper; 8 | }); 9 | Handlebars.registerViewHelper('test', spy); 10 | }); 11 | afterEach(function() { 12 | delete Handlebars.helpers.test; 13 | }); 14 | 15 | it('should handle deferred rendering', function(done) { 16 | this.clock.restore(); 17 | 18 | view = new Thorax.View({ 19 | name: 'outer', 20 | template: Handlebars.compile('{{#test}}{{#test}}{{#test}}{{key}}{{/test}}{{/test}}{{/test}}'), 21 | key: 'value' 22 | }); 23 | view.on('append', function(scope, callback, deferred) { 24 | deferred.exec(function() { 25 | expect(_.values(view.children)[0]._renderCount).to.equal(0); 26 | }); 27 | }); 28 | view.render(undefined, function() { 29 | expect(view.$('[data-view-helper]').eq(2).html()).to.equal('value'); 30 | done(); 31 | }); 32 | }); 33 | 34 | it('should nest helper view instances', function() { 35 | view = new Thorax.View({ 36 | name: 'outer', 37 | template: Handlebars.compile('{{#test}}{{#test}}{{#test}}{{key}}{{/test}}{{/test}}{{/test}}'), 38 | key: 'value' 39 | }); 40 | view.render(); 41 | expect(view.$('[data-view-helper]').eq(2).html()).to.equal('value'); 42 | }); 43 | it('should allow an empty template', function() { 44 | view = new Thorax.View({ 45 | template: Handlebars.compile('{{test}}') 46 | }); 47 | view.render(); 48 | expect(spy.callCount).to.equal(1); 49 | }); 50 | it('should render multiple identical calls', function() { 51 | view = new Thorax.View({ 52 | template: Handlebars.compile('{{test a=1}}{{test a=1}}{{#test a=1}}{{/test}}') 53 | }); 54 | view.render(); 55 | expect(spy.callCount).to.equal(3); 56 | expect(_.keys(view.children).length).to.equal(3); 57 | }); 58 | 59 | describe('container render', function() { 60 | it('should preserve itself in the DOM', function() { 61 | view = new Thorax.View({ 62 | template: Handlebars.compile('{{#test}}{{/test}}') 63 | }); 64 | view.render(); 65 | expect(spy.callCount).to.equal(1); 66 | expect(_.keys(view.children).length).to.equal(1); 67 | var firstKey = _.keys(view.children)[0]; 68 | view.render(); 69 | expect(spy.callCount).to.equal(1); 70 | expect(_.keys(view.children).length).to.equal(1); 71 | var newFirstKey = _.keys(view.children)[0]; 72 | expect(firstKey).to.equal(newFirstKey); 73 | }); 74 | it('should rerender if an input parameter changes', function() { 75 | view = new Thorax.View({ 76 | template: Handlebars.compile('{{#test key}}{{/test}}'), 77 | key: 1 78 | }); 79 | view.render(); 80 | expect(spy.callCount).to.equal(1); 81 | expect(_.keys(view.children).length).to.equal(1); 82 | var firstKey = _.keys(view.children)[0]; 83 | 84 | view.key = 2; 85 | view.render(); 86 | expect(spy.callCount).to.equal(2); 87 | expect(_.keys(view.children).length).to.equal(1); 88 | var newFirstKey = _.keys(view.children)[0]; 89 | expect(firstKey).to.not.equal(newFirstKey); 90 | }); 91 | it('should rerender a helper has depth', function() { 92 | view = new Thorax.View({ 93 | template: Handlebars.compile('{{#test}}{{../foo}}{{/test}}') 94 | }); 95 | view.render(); 96 | expect(spy.callCount).to.equal(1); 97 | expect(_.keys(view.children).length).to.equal(1); 98 | var firstKey = _.keys(view.children)[0]; 99 | 100 | view.render(); 101 | expect(spy.callCount).to.equal(2); 102 | expect(_.keys(view.children).length).to.equal(1); 103 | var newFirstKey = _.keys(view.children)[0]; 104 | expect(firstKey).to.not.equal(newFirstKey); 105 | }); 106 | it('should cooperate with each loops', function() { 107 | view = new Thorax.View({ 108 | template: Handlebars.compile('{{#each keys}}{{#test}}@index{{/test}}{{/each}}'), 109 | keys: _.range(5) 110 | }); 111 | view.render(); 112 | expect(spy.callCount).to.equal(5); 113 | expect(_.keys(view.children).length).to.equal(5); 114 | 115 | view.render(); 116 | expect(spy.callCount).to.equal(5); 117 | expect(_.keys(view.children).length).to.equal(5); 118 | }); 119 | 120 | it('should release old children on re-render', function() { 121 | view = new Thorax.View({ 122 | template: Handlebars.compile('{{#test key}}{{/test}}'), 123 | key: 1 124 | }); 125 | view.render(); 126 | expect(spy.callCount).to.equal(1); 127 | expect(_.keys(view.children).length).to.equal(1); 128 | var child = _.first(_.values(view.children)); 129 | this.spy(child, '_destroy'); 130 | 131 | view.key = 2; 132 | view.render(); 133 | expect(spy.callCount).to.equal(2); 134 | expect(child._destroy.callCount).to.equal(1); 135 | }); 136 | 137 | it('id, class and tag passed to helper view', function() { 138 | view = new Thorax.View({ 139 | template: Handlebars.compile('{{#test tagName="a" className="b" id="c"}}{{/test}}') 140 | }); 141 | view.render(); 142 | expect(view.$('a').length).to.equal(1); 143 | expect(view.$('.b').length).to.equal(1); 144 | expect(view.$('#c').length).to.equal(1); 145 | }); 146 | 147 | it('className and tagName re-written in helper view', function() { 148 | view = new Thorax.View({ 149 | template: Handlebars.compile('{{#test tagName="a" className="b"}}{{/test}}') 150 | }); 151 | view.render(); 152 | expect(view.$('a').length).to.equal(1); 153 | expect(view.$('.b').length).to.equal(1); 154 | }); 155 | 156 | it("should preserve a class template if no block is passed", function() { 157 | var ViewClass = Thorax.View.extend({ 158 | template: Handlebars.compile('hello') 159 | }); 160 | Handlebars.registerViewHelper('test-view-helper', ViewClass, function() {}); 161 | var view = new Thorax.View({ 162 | template: Handlebars.compile("{{test-view-helper}}") 163 | }); 164 | view.render(); 165 | expect(view.$('div').html()).to.equal('hello'); 166 | }); 167 | 168 | it("should preserve view's attributes if view class specified them", function() { 169 | var ViewClassWithObjectAttributes = Thorax.View.extend({ 170 | attributes: { 171 | random: 'value' 172 | } 173 | }); 174 | Handlebars.registerViewHelper('test-view-helper-attributes-object', ViewClassWithObjectAttributes, function() {}); 175 | view = new Thorax.View({ 176 | template: Handlebars.compile('{{test-view-helper-attributes-object}}') 177 | }); 178 | view.render(); 179 | expect(view.$('[random="value"]').length).to.equal(1); 180 | 181 | var ViewClassWithObjectAttributes = Thorax.View.extend({ 182 | attributes: function() { 183 | return { 184 | random: 'value' 185 | }; 186 | } 187 | }); 188 | Handlebars.registerViewHelper('test-view-helper-attributes-callback', ViewClassWithObjectAttributes, function() {}); 189 | 190 | view = new Thorax.View({ 191 | template: Handlebars.compile('{{test-view-helper-attributes-callback}}') 192 | }); 193 | view.render(); 194 | expect(view.$('[random="value"]').length).to.equal(1); 195 | 196 | }); 197 | }); 198 | }); 199 | -------------------------------------------------------------------------------- /test/src/helpers/button-link.js: -------------------------------------------------------------------------------- 1 | describe('link helper with pushState', function() { 2 | before(function() { 3 | this.previousPushState = Backbone.history._hasPushState; 4 | Backbone.history._hasPushState = true; 5 | }); 6 | 7 | after(function() { 8 | Backbone.history._hasPushState = this.previousPushState; 9 | }); 10 | 11 | it("should not have double slashes if the argument starts with a slash", function() { 12 | var link = $(Handlebars.helpers.link({hash: {href: '/a'}}).toString()); 13 | expect(link.attr('href')).to.equal('/a'); 14 | }); 15 | }); 16 | 17 | describe('button-link helpers', function() { 18 | var root, 19 | pushState; 20 | beforeEach(function() { 21 | root = Backbone.history.root; 22 | pushState =Backbone.history._hasPushState; 23 | }); 24 | afterEach(function() { 25 | Backbone.history.root = root; 26 | Backbone.history._hasPushState = pushState; 27 | }); 28 | 29 | it("option hash required arguments for button and link", function() { 30 | var link = $(Handlebars.helpers.link({hash: {href: 'a'}}).toString()), 31 | button = $(Handlebars.helpers.button({hash: {method: 'b'}}).toString()); 32 | expect(link.attr('href')).to.equal('#a'); 33 | expect(button.attr('data-call-method')).to.equal('b'); 34 | }); 35 | 36 | it("multiple arguments to link", function() { 37 | var view = new Thorax.View({ 38 | template: Handlebars.compile('{{#link a b c class="test"}}link{{/link}}'), 39 | a: 'a', 40 | b: 'b', 41 | c: 'c' 42 | }); 43 | view.render(); 44 | expect(view.$('a').attr('href')).to.equal('#a/b/c'); 45 | }); 46 | 47 | it("expand-tokens in link", function() { 48 | var view = new Thorax.View({ 49 | template: Handlebars.compile('{{#link "a/{{key}}"}}link{{/link}}'), 50 | key: 'b' 51 | }); 52 | view.render(); 53 | expect(view.$('a').attr('href')).to.equal('#a/{{key}}'); 54 | 55 | view = new Thorax.View({ 56 | template: Handlebars.compile('{{#link "a/{{key}}" expand-tokens=true}}link{{/link}}'), 57 | key: 'b' 58 | }); 59 | view.render(); 60 | expect(view.$('a').attr('href')).to.equal('#a/b'); 61 | expect(view.$('a[expand-tokens]').length).to.equal(0); 62 | }); 63 | 64 | it("allow empty string as link", function() { 65 | var view = new Thorax.View({ 66 | template: Handlebars.compile('{{#link ""}}text{{/link}}') 67 | }); 68 | view.render(); 69 | expect(view.$('a').html()).to.equal('text'); 70 | }); 71 | 72 | it("button and link helpers", function() { 73 | var view = new Thorax.View({ 74 | events: { 75 | testEvent: function() {} 76 | }, 77 | someMethod: function() {}, 78 | template: Handlebars.compile('{{#button "someMethod"}}Button{{/button}}{{#button trigger="testEvent"}}Button 2{{/button}}{{#link "href"}}content{{/link}}') 79 | }); 80 | view.render(); 81 | expect($(view.$('button')[0]).html()).to.equal('Button'); 82 | expect($(view.$('button')[0]).attr('data-call-method')).to.equal('someMethod'); 83 | expect($(view.$('button')[1]).attr('data-trigger-event')).to.equal('testEvent'); 84 | expect(view.$('a').html()).to.equal('content'); 85 | expect(view.$('a').attr('href')).to.equal('#href'); 86 | }); 87 | 88 | it('strip root', function () { 89 | if ($serverSide) { 90 | return; 91 | } 92 | 93 | this.stub(Backbone.history, 'navigate'); 94 | 95 | var root = Backbone.history.root, 96 | pushState =Backbone.history._hasPushState; 97 | Backbone.history._hasPushState = true; 98 | Backbone.history.root = '/foo'; 99 | 100 | var spy = this.spy(), 101 | view = new Thorax.View({ 102 | template: Handlebars.compile('{{#link "foo/test"}}text{{/link}}') 103 | }); 104 | // Append the view to the body for testing 105 | view.appendTo(document.body); 106 | view.retain(); 107 | view.$('a span').trigger('click'); 108 | view.$el.remove(); 109 | 110 | expect(Backbone.history.navigate.callCount).to.equal(1); 111 | expect(Backbone.history.navigate.args[0][0]).to.equal('/test'); 112 | }); 113 | 114 | it('nested prevent default', function (done) { 115 | if ($serverSide) { 116 | return done(); 117 | } 118 | 119 | var spy = this.spy(), 120 | view = new Thorax.View({ 121 | template: Handlebars.compile('{{#link "test"}}text{{/link}}') 122 | }); 123 | // Make sure that hash change is only triggered once 124 | $(document).on('click.test.prevent-default', function (e) { 125 | expect(e.isDefaultPrevented()).to.equal(true); 126 | done(); 127 | }); 128 | // Append the view to the body for testing 129 | view.appendTo(document.body); 130 | view.retain(); 131 | view.$('a span').trigger('click'); 132 | view.$el.remove(); 133 | $(document).off('click.test.prevent-default'); 134 | }); 135 | 136 | it("does not invoke Backbone.Navigate if when shift or meta keys are pressed on {{#links}}", function() { 137 | var spy = sinon.spy(Backbone.history, 'navigate'); 138 | 139 | var view = new Thorax.View({ 140 | template: Handlebars.compile("{{#link '#test'}}Link{{/link}}") 141 | }); 142 | var el = view.$('a').get(0); 143 | 144 | view._anchorClick({ metaKey: true, currentTarget: el }); 145 | view._anchorClick({ shiftKey: true, currentTarget: el }); 146 | 147 | expect(spy.called).to.equal(false); 148 | 149 | spy.restore(); 150 | }); 151 | }); 152 | -------------------------------------------------------------------------------- /test/src/helpers/element.js: -------------------------------------------------------------------------------- 1 | describe('element helper', function() { 2 | it("element helper", function() { 3 | var a = $('
  • one
  • '); 4 | var view = new Thorax.View({ 5 | template: Handlebars.compile(''), 6 | a: a, 7 | b: function() { 8 | if (document.createElement) { 9 | var li = document.createElement('li'); 10 | li.innerHTML = 'two'; 11 | return li; 12 | } else { 13 | return $('
  • two
  • '); 14 | } 15 | }, 16 | c: function() { 17 | return $('
  • three
  • four
  • '); 18 | }, 19 | d: $('
  • five
  • ') 20 | }); 21 | view.render(); 22 | expect(view.$('li').eq(0).html()).to.equal('one'); 23 | expect(view.$('li').eq(1).html()).to.equal('two'); 24 | expect(view.$('li').eq(2).html()).to.equal('three'); 25 | expect(view.$('li').eq(3).html()).to.equal('four'); 26 | expect(view.$('li').eq(4).html()).to.equal('five'); 27 | view.html(''); 28 | expect(view.$('li').length).to.equal(0); 29 | view.render(); 30 | expect(view.$('li').eq(0).html()).to.equal('one'); 31 | expect(view.$('li').eq(1).html()).to.equal('two'); 32 | expect(view.$('li').eq(2).html()).to.equal('three'); 33 | expect(view.$('li').eq(3).html()).to.equal('four'); 34 | expect(view.$('li').eq(4).html()).to.equal('five'); 35 | }); 36 | }); 37 | -------------------------------------------------------------------------------- /test/src/helpers/empty.js: -------------------------------------------------------------------------------- 1 | describe('empty helper', function() { 2 | var letterCollection; 3 | 4 | beforeEach(function() { 5 | letterCollection = new (Thorax.Collection.extend({ 6 | model: Thorax.Model.extend({}) 7 | }))(_.map(['a', 'b', 'c', 'd'], function(letter) { 8 | return {letter: letter}; 9 | })); 10 | }); 11 | 12 | it('should render empty without any inputs', function() { 13 | var emptyView = new Thorax.View({ 14 | template: Handlebars.compile('{{#empty}}empty{{else}}not empty{{/empty}}') 15 | }); 16 | emptyView.render(); 17 | expect(emptyView.html()).to.equal('empty'); 18 | }); 19 | it('should render empty with an empty model', function() { 20 | var emptyModelView = new Thorax.View({ 21 | template: Handlebars.compile('{{#empty}}empty{{else}}not empty{{/empty}}'), 22 | model: new Thorax.Model() 23 | }); 24 | emptyModelView.render(); 25 | expect(emptyModelView.html()).to.equal('empty'); 26 | emptyModelView.model.set({foo: 'value'}); 27 | expect(emptyModelView.html()).to.equal('not empty'); 28 | }); 29 | it('should render when model is added', function() { 30 | var emptyModelView = new Thorax.View({ 31 | template: Handlebars.compile('{{#empty}}empty{{else}}not empty{{/empty}}') 32 | }); 33 | emptyModelView.render(); 34 | expect(emptyModelView.html()).to.equal('empty'); 35 | emptyModelView.setModel(new Thorax.Model({foo: 'value'})); 36 | expect(emptyModelView.html()).to.equal('not empty'); 37 | }); 38 | it('should render empty with collection parameter', function() { 39 | var emptyCollectionView = new Thorax.View({ 40 | template: Handlebars.compile('{{#empty myCollection}}empty{{else}}not empty{{/empty}}'), 41 | myCollection: new Thorax.Collection() 42 | }); 43 | emptyCollectionView.render(); 44 | expect(emptyCollectionView.html()).to.equal('empty'); 45 | var model = new Thorax.Model(); 46 | emptyCollectionView.myCollection.add(model); 47 | expect(emptyCollectionView.html()).to.equal('not empty'); 48 | emptyCollectionView.myCollection.remove(model); 49 | expect(emptyCollectionView.html()).to.equal('empty'); 50 | }); 51 | 52 | it('empty and collection helpers in the same template', function() { 53 | var a = new Thorax.View({ 54 | template: Handlebars.compile('{{#empty letters}}
    empty
    {{/empty}}{{#collection letters}}{{letter}}{{/collection}}'), 55 | letters: new Thorax.Collection() 56 | }); 57 | a.render(); 58 | expect(a.$('.empty').html()).to.equal('empty'); 59 | a.letters.reset(_.clone(letterCollection.models)); 60 | expect(a.$('.empty').length).to.equal(0); 61 | expect(a.$('[data-collection-cid] div').eq(0).html()).to.equal('a'); 62 | var b = new Thorax.View({ 63 | template: Handlebars.compile('{{#empty letters}}
    empty a
    {{/empty}}{{#collection letters}}{{letter}}{{else}}empty b{{/collection}}'), 64 | letters: new Thorax.Collection() 65 | }); 66 | b.render(); 67 | expect(b.$('.empty').html()).to.equal('empty a'); 68 | expect(b.$('[data-collection-cid] div').eq(0).html()).to.equal('empty b'); 69 | b.letters.reset(letterCollection.models); 70 | expect(b.$('.empty').length).to.equal(0); 71 | expect(b.$('[data-collection-cid] div').eq(0).html()).to.equal('a'); 72 | }); 73 | 74 | it("multiple empty helpers binding the same object will not cause multiple renders", function() { 75 | var spy = this.spy(); 76 | var view = new Thorax.View({ 77 | events: { 78 | rendered: spy 79 | }, 80 | template: Handlebars.compile("{{#empty collection}}{{/empty}}{{#empty collection}}{{/empty}}"), 81 | collection: new Thorax.Collection() 82 | }); 83 | view.ensureRendered(); 84 | expect(spy.callCount).to.equal(1); 85 | view.collection.add({key: 'value'}); 86 | expect(spy.callCount).to.equal(2); 87 | }); 88 | }); 89 | -------------------------------------------------------------------------------- /test/src/helpers/super.js: -------------------------------------------------------------------------------- 1 | describe('super helper', function() { 2 | 3 | it("super helper", function() { 4 | var parent, child; 5 | Handlebars.templates['super-named-test'] = Handlebars.compile('
    '); 6 | parent = Thorax.View.extend({ 7 | name: 'super-named-test' 8 | }); 9 | child = new (parent.extend({ 10 | template: Handlebars.compile('
    {{super}}') 11 | }))(); 12 | child.render(); 13 | expect(child.$('.parent').length).to.equal(1); 14 | expect(child.$('.child').length).to.equal(1); 15 | 16 | // same as above but with template attr using name 17 | parent = Thorax.View.extend({ 18 | template: 'super-named-test' 19 | }); 20 | child = new (parent.extend({ 21 | template: Handlebars.compile('
    {{super}}') 22 | }))(); 23 | child.render(); 24 | expect(child.$('.parent').length).to.equal(1); 25 | expect(child.$('.child').length).to.equal(1); 26 | 27 | parent = Thorax.View.extend({ 28 | name: 'super-test', 29 | template: Handlebars.compile('
    ') 30 | }); 31 | child = new (parent.extend({ 32 | template: Handlebars.compile('
    {{super}}') 33 | }))(); 34 | child.render(); 35 | expect(child.$('.parent').length).to.equal(1); 36 | expect(child.$('.child').length).to.equal(1); 37 | 38 | parent = Thorax.View.extend({ 39 | template: Handlebars.compile('{{#collection letters tag="ul"}}
  • {{letter}}
  • {{/collection}}') 40 | }); 41 | var instance = new (parent.extend({ 42 | template: Handlebars.compile('{{super}}') 43 | }))({letters: new Thorax.Collection([{letter: 'a'}])}); 44 | instance.render(); 45 | expect(instance.$('li').length).to.equal(1); 46 | expect(instance.$('li').eq(0).html()).to.equal('a'); 47 | }); 48 | }); 49 | -------------------------------------------------------------------------------- /test/src/helpers/template.js: -------------------------------------------------------------------------------- 1 | describe('template helper', function() { 2 | it("template function can be specified", function() { 3 | var childReturningString = new Thorax.View({ 4 | template: function(data, options) { 5 | expect(options.data.cid).to.match(/^t/); 6 | return 'template'; 7 | } 8 | }); 9 | childReturningString.render(); 10 | expect(childReturningString.html()).to.equal('template'); 11 | var childReturningElement = new Thorax.View({ 12 | template: function() { 13 | return $('

    template

    ')[0]; 14 | } 15 | }); 16 | childReturningElement.render(); 17 | expect(childReturningElement.$('p').html()).to.equal('template'); 18 | var childReturning$ = new Thorax.View({ 19 | template: function() { 20 | return $('

    template

    '); 21 | } 22 | }); 23 | childReturning$.render(); 24 | expect(childReturning$.$('p').html()).to.equal('template'); 25 | }); 26 | 27 | it("template yield", function() { 28 | Handlebars.templates['yield-child'] = Handlebars.compile('{{@yield}}'); 29 | Handlebars.templates['yield-parent'] = Handlebars.compile('

    {{#template "yield-child"}}content{{/template}}

    '); 30 | var view = new Thorax.View({ 31 | name: 'yield-parent' 32 | }); 33 | view.render(); 34 | expect(view.$('p > span').html()).to.equal('content'); 35 | }); 36 | }); 37 | -------------------------------------------------------------------------------- /test/src/helpers/url.js: -------------------------------------------------------------------------------- 1 | describe('url helper', function() { 2 | it("url helper", function() { 3 | var href = Handlebars.helpers.url.call({}, '/a/{{b}}', {'expand-tokens': true}); 4 | expect(href).to.equal('#/a/'); 5 | href = Handlebars.helpers.url.call({b: 'b'}, '/a/{{b}}', {'expand-tokens': true}); 6 | expect(href).to.equal('#/a/b'); 7 | href = Handlebars.helpers.url.call({b: 'c'}, '/a/{{b}}', {'expand-tokens': true}); 8 | expect(href).to.equal('#/a/c'); 9 | 10 | href = Handlebars.helpers.url('a', 'c', {}); 11 | expect(href).to.equal('#a/c'); 12 | }); 13 | 14 | describe('urls are properly encoded', function () { 15 | it('when joining multiple arguments automatically', function () { 16 | // uses encodeURIComponent in /src/helpers/url.js 17 | var slug = "hello world, sup!", 18 | actual = Handlebars.helpers.url('articles', slug, {}), 19 | expected = '#articles/hello%20world%2C%20sup!'; 20 | 21 | expect(actual).to.equal(expected); 22 | }); 23 | it('when using expand-tokens=true (bug)', function () { 24 | // uses Thorax.Util.expandToken from /src/util.js, line 260 25 | var context = {slug: "hello world, sup!"}, 26 | actual = Handlebars.helpers.url.call(context, '/articles/{{slug}}', {'expand-tokens': true}), 27 | expected = '#/articles/hello%20world%2C%20sup!'; 28 | 29 | expect(actual).to.equal(expected); 30 | }); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /test/src/helpers/view.js: -------------------------------------------------------------------------------- 1 | describe('view helper', function() { 2 | it("throws an error when template compiled without data", function() { 3 | expect(function() { 4 | Handlebars.helpers.view({}, {}); 5 | }).to.throwError(); 6 | }); 7 | 8 | it("throws an error when any hash arguments are passed on an instance", function() { 9 | var view = new Thorax.View({ 10 | instance: new Thorax.View({ 11 | template: Handlebars.compile('') 12 | }), 13 | template: Handlebars.compile('{{view instance tag="span" key="value"}}') 14 | }); 15 | expect(function() { 16 | view.render(); 17 | }).to.throwError(); 18 | }); 19 | 20 | it("should allow hash arguments when a view class name is passed", function() { 21 | Thorax.View.extend({ 22 | tagName: 'p', 23 | name: 'HashArgsClassTest', 24 | template: Handlebars.compile('{{key}}') 25 | }); 26 | var view = new Thorax.View({ 27 | template: Handlebars.compile('
    {{view "HashArgsClassTest" key="value"}}
    ') 28 | }); 29 | view.render(); 30 | expect(view.$('span').html()).to.equal('value'); 31 | }); 32 | 33 | it('should use the registry to lookup view clases', function() { 34 | //test nested 35 | Thorax.Views.Outer = { 36 | Inner: Thorax.View.extend({ 37 | tagName: 'span', 38 | template: function() { return 'inner'; } 39 | }), 40 | More: { 41 | Nested: Thorax.View.extend({ 42 | tagName: 'span', 43 | template: function() { return 'nested'; } 44 | }) 45 | } 46 | }; 47 | 48 | var view = new Thorax.View({ 49 | template: Handlebars.compile('

    {{view "Outer.Inner"}}

    {{view "Outer.More.Nested"}}
    ') 50 | }); 51 | view.render(); 52 | expect(view.$('p > span').html()).to.equal('inner', 'test nested registryGet'); 53 | expect(view.$('div > span').html()).to.equal('nested', 'test nested registryGet'); 54 | 55 | view = new Thorax.View({ 56 | name: 'extension-test' 57 | }); 58 | view.render(); 59 | expect(view.html()).to.equal('123'); 60 | }); 61 | 62 | it("fail silently when no view initialized", function() { 63 | var parent = new Thorax.View({ 64 | template: Handlebars.compile("{{view child}}") 65 | }); 66 | parent.render(); 67 | expect(parent.$el.html()).to.equal(''); 68 | }); 69 | 70 | it("child views", function() { 71 | var childRenderedCount = 0, 72 | parentRenderedCount = 0; 73 | Thorax.View.extend({ 74 | name: 'child', 75 | initialize: function() { 76 | this.on('rendered', function() { 77 | ++childRenderedCount; 78 | }); 79 | } 80 | }); 81 | var Parent = Thorax.View.extend({ 82 | name: 'parent', 83 | initialize: function() { 84 | this.on('rendered', function() { 85 | ++parentRenderedCount; 86 | }); 87 | this.childModel = new Thorax.Model({ 88 | value: 'a' 89 | }); 90 | this.child = new Thorax.Views.child({ 91 | model: this.childModel 92 | }); 93 | } 94 | }); 95 | var parent = new Parent(); 96 | parent.render(); 97 | expect(parent.$('[data-view-name="child"] > div').html()).to.equal('a', 'view embedded'); 98 | expect(parentRenderedCount).to.equal(1); 99 | expect(childRenderedCount).to.equal(1); 100 | 101 | parent.render(); 102 | expect(parent.$('[data-view-name="child"] > div').html()).to.equal('a', 'view embedded'); 103 | expect(parentRenderedCount).to.equal(2, 're-render of parent does not render child'); 104 | expect(childRenderedCount).to.equal(1, 're-render of parent does not render child'); 105 | 106 | parent.childModel.set({value: 'b'}); 107 | expect(parent.$('[data-view-name="child"] > div').html()).to.equal('b', 'view embedded'); 108 | expect(parentRenderedCount).to.equal(2, 're-render of child does not parent child'); 109 | expect(childRenderedCount).to.equal(2, 're-render of child does not render parent'); 110 | 111 | //ensure recursion does not happen when child view has the same model 112 | //as parent 113 | parent.setModel(parent.childModel); 114 | parent.model.set({value: 'c'}); 115 | expect(parentRenderedCount).to.equal(4); 116 | expect(childRenderedCount).to.equal(3); 117 | }); 118 | 119 | it("child views within #each", function() { 120 | var parent = new Thorax.View({ 121 | template: Handlebars.compile('{{#each views}}{{view this}}{{/each}}'), 122 | views: [ 123 | new Thorax.View({ 124 | template: Handlebars.compile("a") 125 | }), 126 | new Thorax.View({ 127 | template: Handlebars.compile("b") 128 | }) 129 | ] 130 | }); 131 | parent.render(); 132 | expect(parent.$el.text().replace(/\r\n/g, '')).to.equal('ab'); 133 | }); 134 | 135 | it('child views within #each with mutation', function() { 136 | var parent = new Thorax.View({ 137 | template: Handlebars.compile('{{#each views}}{{view this}}{{/each}}'), 138 | views: [ 139 | new Thorax.View({ 140 | template: Handlebars.compile('a') 141 | }), 142 | new Thorax.View({ 143 | template: Handlebars.compile('b') 144 | }), 145 | new Thorax.View({ 146 | template: Handlebars.compile('c') 147 | }) 148 | ] 149 | }); 150 | parent.render(); 151 | expect(parent.$el.text().replace(/\r\n/g, '')).to.equal('abc'); 152 | 153 | parent.views.splice(1, 1); 154 | parent.render(); 155 | expect(parent.$el.text().replace(/\r\n/g, '')).to.equal('ac'); 156 | }); 157 | 158 | it("template passed to constructor and view block", function() { 159 | var view = new Thorax.View({ 160 | template: Handlebars.compile('

    {{key}}

    '), 161 | key: 'value' 162 | }); 163 | view.render(); 164 | expect(view.$('p').html()).to.equal('value'); 165 | 166 | var view = new (Thorax.View.extend({ 167 | template: Handlebars.compile('

    {{key}}

    '), 168 | key: 'value' 169 | }))(); 170 | view.render(); 171 | expect(view.$('p').html()).to.equal('value'); 172 | 173 | var Child = Thorax.View.extend({ 174 | template: Handlebars.compile('
    {{key}}
    '), 175 | key: 'value' 176 | }); 177 | 178 | var a = new Child(); 179 | var b = new Child(); 180 | 181 | var parent = new Thorax.View({ 182 | template: Handlebars.compile('
    {{#view b}}
    {{key}}
    {{/view}}{{view a}}
    '), 183 | a: a, 184 | b: b 185 | }); 186 | parent.render(); 187 | expect(parent.$('.child-a').length).to.equal(1); 188 | expect(parent.$('.child-a').html()).to.equal('value'); 189 | expect(parent.$('.child-b').length).to.equal(1); 190 | expect(parent.$('.child-b').html()).to.equal('value'); 191 | 192 | //ensure that override does not persist to view itself 193 | b.render(); 194 | expect(b.$('.child-a').html()).to.equal('value'); 195 | 196 | //test nesting 197 | var outer = new Thorax.View({ 198 | template: Handlebars.compile('
    {{#view inner}}
    {{#view child}}
    value
    {{/view}}
    {{/view}}
    '), 199 | inner: new Thorax.View({ 200 | child: new Thorax.View() 201 | }) 202 | }); 203 | outer.render(); 204 | expect(outer.$('.c').html()).to.equal('value'); 205 | }); 206 | 207 | it("child view re-render will keep dom events intact", function() { 208 | if ($serverSide) { 209 | return; 210 | } 211 | 212 | var callCount = 0; 213 | var parent = new Thorax.View({ 214 | name: 'parent-event-dom-test', 215 | child: new Thorax.View({ 216 | name: 'child-event-dom-test', 217 | events: { 218 | 'click .test': function() { 219 | ++callCount; 220 | } 221 | }, 222 | template: function() { return '
    '; } 223 | }), 224 | template: Handlebars.compile("{{view child}}") 225 | }); 226 | parent.render(); 227 | $('body').append(parent.el); 228 | parent.child.$('.test').trigger('click'); 229 | expect(callCount).to.equal(1); 230 | parent.render(); 231 | parent.child.$('.test').trigger('click'); 232 | expect(callCount).to.equal(2); 233 | parent.$el.remove(); 234 | }); 235 | 236 | it("$.fn.view", function() { 237 | var child = new Thorax.View({ 238 | template: Handlebars.compile('
    ') 239 | }); 240 | var parent = new Thorax.View({ 241 | template: Handlebars.compile('
    {{view child}}
    '), 242 | child: child 243 | }); 244 | parent.render(); 245 | parent.retain(); 246 | 247 | expect(child.$('div.child').view()).to.equal(child); 248 | expect(parent.$('div.parent').view()).to.equal(parent); 249 | expect(parent.$('div.child').view()).to.equal(child); 250 | }); 251 | 252 | it("multiple views initialized by name will not be re-rendered", function() { 253 | var spy = this.spy(function() { 254 | return Thorax.View.prototype.initialize.apply(this, arguments); 255 | }); 256 | Thorax.View.extend({ 257 | name: 'named-view', 258 | template: Handlebars.compile('inner'), 259 | initialize: spy 260 | }); 261 | var view = new Thorax.View({ 262 | template: Handlebars.compile('{{view "named-view"}}{{view "named-view"}}') 263 | }); 264 | view.render(); 265 | var firstCids = _.keys(view.children); 266 | expect(spy.callCount).to.equal(2); 267 | expect(view.$('div').eq(0).html()).to.equal('inner'); 268 | expect(view.$('div').eq(1).html()).to.equal('inner'); 269 | 270 | view.render(); 271 | expect(spy.callCount).to.equal(2); 272 | expect(view.$('div').eq(0).html()).to.equal('inner'); 273 | expect(view.$('div').eq(1).html()).to.equal('inner'); 274 | 275 | var secondCids = _.keys(view.children); 276 | expect(firstCids.length).to.equal(secondCids.length); 277 | expect(firstCids[0]).to.equal(secondCids[0]); 278 | expect(firstCids[1]).to.equal(secondCids[1]); 279 | }); 280 | 281 | it("views embedded with view helper do not incorrectly set parent", function() { 282 | var view = new Thorax.View({ 283 | child: new Thorax.View({ 284 | template: Handlebars.compile('{{#collection}}{{/collection}}') 285 | }), 286 | template: Handlebars.compile('{{view child}}'), 287 | collection: new Thorax.Collection() 288 | }); 289 | view.render(); 290 | var collectionView = _.find(view.child.children, function(child) { 291 | return child._helperName === 'collection'; 292 | }); 293 | expect(collectionView.parent).to.equal(view.child); 294 | 295 | // ensure overrides do not modify either 296 | view = new Thorax.View({ 297 | child: new Thorax.View({ 298 | template: function() {} 299 | }), 300 | template: Handlebars.compile('{{#view child}}{{#collection}}{{/collection}}{{/view}}'), 301 | collection: new Thorax.Collection() 302 | }); 303 | view.render(); 304 | emptyView = _.find(view.child.children, function(child) { 305 | return child._helperName === 'collection'; 306 | }); 307 | expect(emptyView.parent).to.equal(view.child); 308 | }); 309 | 310 | it('ensure manually initialized child view is not destroyed if it goes out of scope in template', function() { 311 | var child = new Thorax.View({ 312 | template: Handlebars.compile('content') 313 | }); 314 | child.retain(); 315 | 316 | var parent = new Thorax.View({ 317 | child: child, 318 | showChild: true, 319 | template: Handlebars.compile('{{#showChild}}{{view child}}{{/showChild}}') 320 | }); 321 | parent.render(); 322 | expect(parent.$('span').length).to.equal(1); 323 | 324 | parent.showChild = false; 325 | parent.render(); 326 | expect(parent.$('span').length).to.equal(0); 327 | 328 | parent.showChild = true; 329 | parent.render(); 330 | expect(parent.$('span').length).to.equal(1); 331 | }); 332 | 333 | it('ensure automatically initialized child view is destroyed if it goes out of scope in template', function() { 334 | var spy = this.spy(); 335 | var ScopedChildTestView = Thorax.View.extend({ 336 | name: 'scoped-child-test', 337 | events: { 338 | rendered: spy 339 | }, 340 | template: Handlebars.compile('content') 341 | }); 342 | var parent = new Thorax.View({ 343 | showChild: true, 344 | template: Handlebars.compile('{{#showChild}}{{view "scoped-child-test"}}{{/showChild}}') 345 | }); 346 | parent.render(); 347 | expect(parent.$('span').length).to.equal(1); 348 | expect(spy.callCount).to.equal(1); 349 | 350 | parent.showChild = false; 351 | parent.render(); 352 | expect(parent.$('span').length).to.equal(0); 353 | 354 | parent.showChild = true; 355 | parent.render(); 356 | expect(parent.$('span').length).to.equal(1); 357 | // should be a new instance 358 | expect(spy.callCount).to.equal(2); 359 | }); 360 | }); 361 | -------------------------------------------------------------------------------- /test/src/layout.js: -------------------------------------------------------------------------------- 1 | /*global $serverSide:true */ 2 | 3 | describe('layout', function() { 4 | var _serverSide = window.$serverSide, 5 | _fruitLoops = window.FruitLoops; 6 | beforeEach(function() { 7 | window.FruitLoops = { 8 | emit: this.spy() 9 | }; 10 | }); 11 | afterEach(function() { 12 | $serverSide = _serverSide; 13 | window.FruitLoops = _fruitLoops; 14 | }); 15 | 16 | function bindCounter(view) { 17 | var counter = {}; 18 | view.bind('all', function(eventName) { 19 | // For activated, ensure that we actually have DOM content 20 | if (eventName === 'activated') { 21 | expect(this.html().length).to.be.greaterThan(0); 22 | } 23 | 24 | counter[eventName] = (counter[eventName] || 0) + 1; 25 | }); 26 | return counter; 27 | } 28 | 29 | describe('view lifecycle', function() { 30 | var a, 31 | aEventCounter, 32 | b, 33 | bEventCounter, 34 | layout; 35 | 36 | beforeEach(function() { 37 | layout = new Thorax.LayoutView(); 38 | 39 | a = new Thorax.View({ 40 | render: function(output, callback) { 41 | Thorax.View.prototype.render.call(this, 'a', callback); 42 | } 43 | }); 44 | aEventCounter = bindCounter(a); 45 | 46 | b = new Thorax.View({ 47 | render: function(output, callback) { 48 | Thorax.View.prototype.render.call(this, 'b', callback); 49 | } 50 | }); 51 | bEventCounter = bindCounter(b); 52 | }); 53 | 54 | it('should process', function() { 55 | $serverSide = false; 56 | expect(layout.getView()).to.not.be.ok(); 57 | 58 | layout.setView(a, {async: false}); 59 | expect(layout.getView()).to.equal(a, 'layout sets view'); 60 | expect(layout.$('[data-view-cid]').length).to.be.above(0, 'layout updates HTML'); 61 | 62 | b.render(); 63 | layout.setView(b, {async: false}); 64 | expect(layout.getView()).to.equal(b, 'layout sets view'); 65 | 66 | //lifecycle checks 67 | expect(aEventCounter).to.eql({ 68 | 'before:rendered': 1, 69 | rendered: 1, 70 | 'before:append': 1, 71 | append: 1, 72 | activated: 1, 73 | ready: 1, 74 | deactivated: 1, 75 | destroyed: 1 76 | }); 77 | 78 | expect(bEventCounter).to.eql({ 79 | 'before:rendered': 1, 80 | rendered: 1, 81 | 'before:append': 1, 82 | append: 1, 83 | activated: 1, 84 | ready: 1 85 | }); 86 | 87 | layout.setView(false); 88 | expect(layout.getView()).to.not.be.ok(); 89 | expect(bEventCounter).to.eql({ 90 | 'before:rendered': 1, 91 | rendered: 1, 92 | 'before:append': 1, 93 | append: 1, 94 | activated: 1, 95 | ready: 1, 96 | deactivated: 1, 97 | destroyed: 1 98 | }); 99 | }); 100 | 101 | it('should process server-side', function(done) { 102 | $serverSide = true; 103 | expect(layout.getView()).to.not.be.ok(); 104 | 105 | layout.once('change:view:end', function() { 106 | expect(layout.getView()).to.equal(a, 'layout sets view'); 107 | expect(layout.$('[data-view-cid]')).to.not.be.empty(); 108 | expect(aEventCounter).to.eql({ 109 | 'before:rendered': 1, 110 | rendered: 1, 111 | 'before:append': 1, 112 | append: 1, 113 | activated: 1, 114 | ready: 1 115 | }); 116 | 117 | layout.setView(b, {async: false}); 118 | expect(layout.getView()).to.equal(b, 'layout sets view'); 119 | expect(layout.$('[data-view-cid]')).to.be.empty(); 120 | expect(window.FruitLoops.emit.calledOnce).to.be.ok(); 121 | 122 | //lifecycle checks 123 | expect(aEventCounter).to.eql({ 124 | 'before:rendered': 1, 125 | rendered: 1, 126 | 'before:append': 1, 127 | append: 1, 128 | activated: 1, 129 | ready: 1, 130 | deactivated: 1, 131 | destroyed: 1 132 | }); 133 | 134 | expect(bEventCounter).to.eql({}); 135 | 136 | layout.setView(false); 137 | expect(layout.getView()).to.not.be.ok(); 138 | expect(bEventCounter).to.eql({ 139 | deactivated: 1, 140 | destroyed: 1 141 | }); 142 | 143 | done(); 144 | }); 145 | layout.setView(a, {serverRender: true}); 146 | expect(layout.getView()).to.be(undefined); 147 | 148 | this.clock.tick(1000); 149 | }); 150 | }); 151 | 152 | it("LayoutView destroy will destroy child view", function() { 153 | var callCounts = { 154 | parent: 0, 155 | layout: 0, 156 | child: 0 157 | }; 158 | var parent = new Thorax.View({ 159 | events: { 160 | destroyed: function() { 161 | ++callCounts.parent; 162 | } 163 | }, 164 | template: Handlebars.compile("{{view this.layout}}"), 165 | layout: new Thorax.LayoutView({ 166 | events: { 167 | destroyed: function() { 168 | ++callCounts.layout; 169 | } 170 | } 171 | }) 172 | }); 173 | parent.render(); 174 | parent.layout.setView(new Thorax.View({ 175 | template: function() {}, 176 | events: { 177 | destroyed: function() { 178 | ++callCounts.child; 179 | } 180 | } 181 | }), {async: false}); 182 | parent.release(); 183 | expect(callCounts.parent).to.equal(1); 184 | expect(callCounts.layout).to.equal(1); 185 | expect(callCounts.child).to.equal(1); 186 | }); 187 | 188 | it("Layout will not destroy view if retained", function() { 189 | var aSpy = this.spy(), 190 | bSpy = this.spy(); 191 | var a = new Thorax.View({ 192 | name: 'a', 193 | events: { 194 | destroyed: aSpy 195 | }, 196 | template: Handlebars.compile("") 197 | }); 198 | var b = new Thorax.View({ 199 | name: 'b', 200 | events: { 201 | destroyed: bSpy 202 | }, 203 | template: Handlebars.compile("") 204 | }); 205 | var layout = new Thorax.LayoutView(); 206 | layout.setView(a, {async: false}); 207 | b.retain(); 208 | layout.setView(b, {async: false}); 209 | layout.setView(false); 210 | expect(aSpy.callCount).to.equal(1); 211 | expect(bSpy.callCount).to.equal(0); 212 | b.release(); 213 | expect(bSpy.callCount).to.equal(1); 214 | }); 215 | 216 | it("Layout can set view el", function() { 217 | $('body').append('
    '); 218 | var view = new Thorax.LayoutView({ 219 | el: $('#test-target')[0] 220 | }); 221 | view.render(); 222 | expect(view.$el.parent()[0]).to.equal($('#test-target-container')[0]); 223 | $('#test-target-container').remove(); 224 | }); 225 | 226 | it('layouts with templates and {{layout-element}}', function() { 227 | var layoutWithTemplate = new Thorax.LayoutView({ 228 | template: Handlebars.compile('
    {{layout-element}}
    ') 229 | }); 230 | layoutWithTemplate.setView(new Thorax.View({ 231 | serverRender: true, 232 | template: Handlebars.compile('
    ') 233 | }), {async: false}); 234 | expect($(layoutWithTemplate.el).attr('data-layout-cid')).to.not.be.ok(); 235 | expect(layoutWithTemplate.$('[data-layout-cid]').length).to.equal(1); 236 | expect(layoutWithTemplate.$('.outer').length).to.equal(1); 237 | expect(layoutWithTemplate.$('.inner').length).to.equal(1); 238 | }); 239 | 240 | it('should fail if missing layout-element', function() { 241 | var layoutWithTemplateWithoutLayoutTag = new Thorax.LayoutView({ 242 | template: Handlebars.compile('
    ') 243 | }); 244 | expect(function() { 245 | layoutWithTemplateWithoutLayoutTag.setView(new Thorax.View({ 246 | template: Handlebars.compile('
    ') 247 | })); 248 | }).to.throwError(); 249 | }); 250 | 251 | it("layout-element used outside of a LayoutView with throw", function() { 252 | var view = new Thorax.View({ 253 | template: Handlebars.compile('{{layout-element}}') 254 | }); 255 | expect(_.bind(view.render, view)).to.throwError(); 256 | }); 257 | 258 | it("transition option can be passed to setView", function(done) { 259 | this.clock.restore(); 260 | 261 | var layout = new Thorax.LayoutView(); 262 | var a = new Thorax.View({ 263 | serverRender: true, 264 | template: function() { 265 | return 'a'; 266 | } 267 | }); 268 | var b = new Thorax.View({ 269 | serverRender: true, 270 | template: function() { 271 | return 'b'; 272 | } 273 | }); 274 | layout.setView(a, { 275 | serverRender: true, 276 | transition: function(newView, oldView, append, remove) { 277 | append(); 278 | remove(); 279 | 280 | expect(layout.$('span').html()).to.equal('a'); 281 | layout.setView(b, { 282 | serverRender: true, 283 | transition: function(newView, oldView, append, remove) { 284 | append(); 285 | remove(); 286 | 287 | expect(layout.$('span').html()).to.equal('b'); 288 | done(); 289 | } 290 | }); 291 | } 292 | }); 293 | }); 294 | 295 | it('setView should not throw even if old view is destroyed', function() { 296 | var layout = new Thorax.LayoutView(); 297 | var a = new Thorax.View({ 298 | serverRender: true, 299 | template: function() { 300 | return 'a'; 301 | } 302 | }); 303 | var b = new Thorax.View({ 304 | serverRender: true, 305 | template: function() { 306 | return 'b'; 307 | } 308 | }); 309 | layout.setView(a, {async: false}); 310 | expect(layout.$('span').html()).to.equal('a'); 311 | a.release(); 312 | layout.setView(b, {async: false}); 313 | expect(layout.$('span').html()).to.equal('b'); 314 | }); 315 | 316 | it('keeps the set view in the DOM after render', function() { 317 | var layoutView = new Thorax.LayoutView({ 318 | template: Handlebars.compile('

    My Layout View {{layout-element}}

    ') 319 | }); 320 | 321 | var childView = new Thorax.View({ 322 | template: Handlebars.compile('

    My Child View

    ') 323 | }); 324 | 325 | layoutView.setView(childView, {serverRender: true, async: false}); 326 | 327 | expect(layoutView.$(".layout-view").length).to.not.equal(0); 328 | expect(layoutView.$(".child-view").length).to.not.equal(0); 329 | 330 | layoutView.render(); 331 | 332 | expect(layoutView.$(".layout-view").length).to.not.equal(0); 333 | expect(layoutView.$(".child-view").length).to.not.equal(0); 334 | }); 335 | }); 336 | -------------------------------------------------------------------------------- /test/src/model.js: -------------------------------------------------------------------------------- 1 | /*global $serverSide */ 2 | 3 | describe('model', function() { 4 | it("shouldFetch", function() { 5 | var options = {fetch: true}; 6 | var a = new (Thorax.Model.extend())(); 7 | expect(a.shouldFetch(options)).to.not.be.ok(); 8 | 9 | var b = new (Thorax.Model.extend({urlRoot: '/'}))(); 10 | expect(b.shouldFetch(options)).to.be(true); 11 | 12 | var c = new (Thorax.Model.extend({urlRoot: '/'}))(); 13 | c.set({key: 'value'}); 14 | expect(c.shouldFetch(options)).to.not.be.ok(); 15 | 16 | var d = new (Thorax.Collection.extend())(); 17 | expect(d.shouldFetch(options)).to.not.be.ok(); 18 | 19 | var e = new (Thorax.Collection.extend({url: '/'}))(); 20 | expect(e.shouldFetch(options)).to.be(true); 21 | 22 | var f = new (Thorax.Collection.extend({url: '/'}))(); 23 | expect(e.shouldFetch({fetch: false})).to.be(false); 24 | }); 25 | 26 | it("allow model url to be a string", function() { 27 | var model = new (Thorax.Model.extend({ 28 | url: '/test' 29 | }))(); 30 | expect(model.shouldFetch({fetch: true})).to.be(true); 31 | }); 32 | 33 | describe('model view binding', function() { 34 | var model, 35 | template; 36 | beforeEach(function() { 37 | model = new Thorax.Model({letter: 'a'}); 38 | template = Handlebars.compile('
  • {{letter}}
  • '); 39 | }); 40 | 41 | it('should render properly', function() { 42 | var a = new Thorax.View({ 43 | template: template, 44 | model: model 45 | }); 46 | a.render(); 47 | expect(a.$el.children().eq(0).html()).to.equal('a', 'set via constructor'); 48 | }); 49 | it('should update on setModel', function() { 50 | var b = new Thorax.View({ 51 | template: template 52 | }); 53 | b.render(); 54 | b.setModel(model); 55 | expect(b.$el.children().eq(0).html()).to.equal('a', 'set via setModel'); 56 | }); 57 | it('should update on setModel', function() { 58 | var b = new Thorax.View({ 59 | template: template 60 | }); 61 | b.setModel(model, {render: true}); 62 | expect(b.$el.children().eq(0).html()).to.equal('a', 'set via setModel'); 63 | }); 64 | 65 | it('should update on change', function() { 66 | var a = new Thorax.View({ 67 | template: template, 68 | model: model 69 | }); 70 | a.render(); 71 | model.set({letter: 'B'}); 72 | expect(a.$el.children().eq(0).html()).to.equal('B', 'update attribute triggers render'); 73 | }); 74 | 75 | it('should defer existing render', function() { 76 | var c = new Thorax.View({ 77 | template: template 78 | }); 79 | c.render(); 80 | c.setModel(model, { 81 | render: false 82 | }); 83 | expect(c.$el.children().eq(0).html()).to.equal(''); 84 | c.render(); 85 | expect(c.$el.children().eq(0).html()).to.equal('a', 'manual render'); 86 | }); 87 | it('should defer new render', function() { 88 | var a = new Thorax.View({ 89 | template: template, 90 | model: new Thorax.Model({letter: 'foo-gazi'}) 91 | }); 92 | expect(a.$el.children()).to.be.empty(); 93 | a.setModel(model); 94 | expect(a.$el.children()).to.be.empty(); 95 | a.render(); 96 | expect(a.$el.children().eq(0).html()).to.equal('a', 'set via constructor'); 97 | }); 98 | }); 99 | 100 | it("isPopulated", function() { 101 | expect((new Thorax.Model()).isPopulated()).to.be(false); 102 | expect((new Thorax.Model({key: 'value'})).isPopulated()).to.be(true); 103 | }); 104 | 105 | it("$.fn.model", function() { 106 | var model = new Thorax.Model({ 107 | key: 'value' 108 | }); 109 | var view = new Thorax.View({ 110 | model: model, 111 | template: Handlebars.compile('{{key}}') 112 | }); 113 | view.render(); 114 | view.retain(); 115 | expect(view.html()).to.equal('value'); 116 | expect(view.$el.model()).to.equal(model); 117 | }); 118 | 119 | it("model events", function() { 120 | var callCounter = { 121 | all: 0, 122 | test1: 0, 123 | test2: 0 124 | }; 125 | var view = new Thorax.View({ 126 | template: function() {}, 127 | events: { 128 | model: { 129 | all: function() { 130 | ++callCounter.all; 131 | }, 132 | test1: 'test1', 133 | test2: function() { 134 | ++callCounter.test2; 135 | } 136 | } 137 | }, 138 | test1: function() { 139 | ++callCounter.test1; 140 | } 141 | }); 142 | var model = new Thorax.Model(); 143 | view.setModel(model, {fetch: false}); 144 | var oldAllCount = Number(callCounter.all); 145 | model.trigger('test1'); 146 | model.trigger('test2'); 147 | expect(callCounter.all - oldAllCount).to.equal(2); 148 | expect(callCounter.test1).to.equal(1); 149 | expect(callCounter.test2).to.equal(1); 150 | }); 151 | 152 | // Not really a great idea, but support allow it to work 153 | // with some hacks to render if someone really wants it 154 | it("set collection as model", function() { 155 | if ($serverSide) { 156 | return; 157 | } 158 | 159 | // example that will need to fetch / load 160 | var server = sinon.fakeServer.create(); 161 | var spy = this.spy(function() { 162 | this.render(); 163 | }); 164 | var collection = new (Thorax.Collection.extend({ 165 | url: '/test' 166 | }))(); 167 | collection.key = 'value'; 168 | var view = new Thorax.View({ 169 | events: { 170 | model: { 171 | reset: spy 172 | } 173 | }, 174 | template: Handlebars.compile('{{key}}'), 175 | context: function() { 176 | return this.model; 177 | } 178 | }); 179 | view.render(); 180 | view.setModel(collection); 181 | expect(view.html()).to.equal(''); 182 | expect(spy.callCount).to.equal(0); 183 | server.requests[0].respond( 184 | 200, 185 | { "Content-Type": "application/json" }, 186 | JSON.stringify([{id: 1, text: "test"}]) 187 | ); 188 | expect(view.html()).to.equal('value'); 189 | expect(spy.callCount).to.equal(1); 190 | server.restore(); 191 | 192 | // local model will not load() 193 | spy.callCount = 0; 194 | collection = new (Thorax.Collection.extend({ 195 | url: '/test' 196 | }))([{id: 1, text: 'test'}]); 197 | collection.key = 'value'; 198 | view = new Thorax.View({ 199 | template: Handlebars.compile('{{key}}'), 200 | context: function() { 201 | return this.model; 202 | } 203 | }); 204 | view.render(); 205 | view.setModel(collection); 206 | expect(view.html()).to.equal('value'); 207 | }); 208 | 209 | it('should bind on setModel after constructor set', function() { 210 | var changed = this.spy(); 211 | 212 | var MyView = Thorax.View.extend({ 213 | template: Handlebars.compile('{{title}}'), 214 | events: { 215 | model: { 216 | change: changed 217 | } 218 | } 219 | }); 220 | 221 | var m1 = new Thorax.Model({title: 'M1'}), 222 | m2 = new Thorax.Model({title: 'M2'}); 223 | 224 | var view = new MyView({model: m1}); 225 | expect(changed.callCount).to.equal(0); 226 | 227 | m1.set({title: 'M1 TWEAKED'}); 228 | expect(changed.callCount).to.equal(1); 229 | 230 | view.setModel(m2); 231 | m2.set({title: 'M2 TWEAKED'}); 232 | expect(changed.callCount).to.equal(2); 233 | }); 234 | }); 235 | -------------------------------------------------------------------------------- /test/src/server-marshal.js: -------------------------------------------------------------------------------- 1 | /*global $serverSide */ 2 | describe('server-marshal', function() { 3 | var $el; 4 | 5 | beforeEach(function() { 6 | Thorax.ServerMarshal._reset(); 7 | window.$serverSide = true; 8 | 9 | $el = $('
    '); 10 | }); 11 | 12 | describe('store/load', function() { 13 | it('should create a new id if none', function() { 14 | Thorax.ServerMarshal.store($el, 'name'); 15 | expect($el.attr('data-server-data')).to.equal('0'); 16 | 17 | $el = $('
    '); 18 | Thorax.ServerMarshal.store($el, 'name'); 19 | expect($el.attr('data-server-data')).to.equal('1'); 20 | }); 21 | it('should reuse existing ids', function() { 22 | Thorax.ServerMarshal.store($el, 'name'); 23 | expect($el.attr('data-server-data')).to.equal('0'); 24 | 25 | Thorax.ServerMarshal.store($el, 'other'); 26 | expect($el.attr('data-server-data')).to.equal('0'); 27 | }); 28 | 29 | describe('primitive values', function() { 30 | if ($serverSide) { 31 | return; 32 | } 33 | 34 | it('should store constants', function() { 35 | _.each(['foo', 1234, true, false, 0, '', null], function(value) { 36 | Thorax.ServerMarshal.store($el, 'value', value); 37 | expect(Thorax.ServerMarshal.load($el[0], 'value')).to.eql(value); 38 | }); 39 | }); 40 | it('should store lookups', function() { 41 | var context = { 42 | aField: {aField: true} 43 | }; 44 | 45 | Thorax.ServerMarshal.store($el, 'value', context.aField, 'aField', {data: {root: context}}); 46 | expect(Thorax.ServerMarshal.load($el[0], 'value', context)).to.eql(context.aField); 47 | expect(Thorax.ServerMarshal.load($el[0], 'value', undefined, context)).to.eql(context.aField); 48 | }); 49 | }); 50 | describe('arrays', function() { 51 | if ($serverSide) { 52 | return; 53 | } 54 | 55 | it('should store constant children', function() { 56 | Thorax.ServerMarshal.store($el, 'array', ['foo', 1234, true, false, 0, '', null]); 57 | expect(Thorax.ServerMarshal.load($el[0], 'array')).to.eql(['foo', 1234, true, false, 0, '', null]); 58 | }); 59 | it('should store with lookup references', function() { 60 | var context = { 61 | aField: {aField: true} 62 | }; 63 | 64 | Thorax.ServerMarshal.store($el, 'array', ['foo', context.aField], [null, 'aField'], {data: {view: context}}); 65 | expect(Thorax.ServerMarshal.load($el[0], 'array', context)).to.eql(['foo', context.aField]); 66 | expect(Thorax.ServerMarshal.load($el[0], 'array', undefined, context)).to.eql(['foo', context.aField]); 67 | expect(Thorax.ServerMarshal.load($el[0], 'array', {}, context)).to.eql(['foo', context.aField]); 68 | }); 69 | it('should throw on complex values without lookups', function() { 70 | var context = { 71 | aField: {aField: true} 72 | }; 73 | 74 | expect(function() { 75 | Thorax.ServerMarshal.store($el, 'array', ['foo', context.aField], [null, null]); 76 | }).to.throwError(/server-marshall-object/); 77 | expect(function() { 78 | Thorax.ServerMarshal.store($el, 'array', ['foo', context.aField]); 79 | }).to.throwError(/server-marshall-object/); 80 | }); 81 | }); 82 | describe('objects', function() { 83 | if ($serverSide) { 84 | return; 85 | } 86 | 87 | it('should store constant children', function() { 88 | Thorax.ServerMarshal.store($el, 'obj', { 89 | thing1: 'foo', 90 | thing2: 1234, 91 | thing3: true, 92 | thing4: false, 93 | thing5: 0, 94 | thing6: '', 95 | thing7: null 96 | }); 97 | expect(Thorax.ServerMarshal.load($el[0], 'obj')).to.eql({ 98 | thing1: 'foo', 99 | thing2: 1234, 100 | thing3: true, 101 | thing4: false, 102 | thing5: 0, 103 | thing6: '', 104 | thing7: null 105 | }); 106 | }); 107 | it('should store with lookup references', function() { 108 | var context = { 109 | aField: {aField: true} 110 | }; 111 | 112 | Thorax.ServerMarshal.store($el, 'obj', {foo: context.aField}, {foo: 'aField'}, {data: {view: context}}); 113 | expect(Thorax.ServerMarshal.load($el[0], 'obj', context)).to.eql({foo: context.aField}); 114 | expect(Thorax.ServerMarshal.load($el[0], 'obj', undefined, context)).to.eql({foo: context.aField}); 115 | expect(Thorax.ServerMarshal.load($el[0], 'obj', {}, context)).to.eql({foo: context.aField}); 116 | }); 117 | it('should throw on complex values without lookups', function() { 118 | var context = { 119 | aField: {aField: true} 120 | }; 121 | 122 | expect(function() { 123 | Thorax.ServerMarshal.store($el, 'obj', {'foo': context.aField}, {}); 124 | }).to.throwError(/server-marshall-object/); 125 | }); 126 | it('should throw on complex values with depthed path', function() { 127 | var context = { 128 | aField: {aField: true} 129 | }; 130 | 131 | expect(function() { 132 | Thorax.ServerMarshal.store($el, 'obj', {'foo': context.aField}, {'foo': '../foo'}); 133 | }).to.throwError(/server-marshall-object/); 134 | }); 135 | it('should throw on complex values from subexpressions', function() { 136 | var context = { 137 | aField: {aField: true} 138 | }; 139 | 140 | expect(function() { 141 | Thorax.ServerMarshal.store($el, 'obj', {'foo': context.aField}, {'foo': true}); 142 | }).to.throwError(/server-marshall-object/); 143 | }); 144 | 145 | it('should track with contextPath on view', function() { 146 | var context = { 147 | aField: {aField: true} 148 | }; 149 | var options = { 150 | data: { 151 | view: { 152 | foo: { 153 | bar: context 154 | } 155 | }, 156 | contextPath: 'foo.bar' 157 | } 158 | }; 159 | 160 | Thorax.ServerMarshal.store($el, 'obj', {foo: context.aField}, {foo: 'aField'}, options); 161 | expect(Thorax.ServerMarshal.load($el[0], 'obj', {foo: {bar: context}})).to.eql({foo: context.aField}); 162 | }); 163 | 164 | it('should track with contextPath on context', function() { 165 | var context = { 166 | aField: {aField: true} 167 | }; 168 | var options = { 169 | data: { 170 | root: { 171 | foo: { 172 | bar: context 173 | } 174 | }, 175 | contextPath: 'foo.bar' 176 | } 177 | }; 178 | 179 | Thorax.ServerMarshal.store($el, 'obj', {foo: context.aField}, {foo: 'aField'}, options); 180 | expect(Thorax.ServerMarshal.load($el[0], 'obj', {foo: {bar: context}})).to.eql({foo: context.aField}); 181 | }); 182 | 183 | it('should throw with different value on contextPath', function() { 184 | var context = { 185 | aField: {aField: true} 186 | }; 187 | var options = { 188 | data: { 189 | root: { 190 | foo: { 191 | bar: 'lets go fishing' 192 | } 193 | }, 194 | contextPath: 'foo.bar' 195 | } 196 | }; 197 | 198 | expect(function() { 199 | Thorax.ServerMarshal.store($el, 'obj', {foo: context.aField}, {foo: 'aField'}, options); 200 | }).to.throwError(/server-marshall-object/); 201 | }); 202 | }); 203 | }); 204 | 205 | describe('serialize', function() { 206 | it('should not fail with circular references', function() { 207 | var context = { 208 | aField: {aField: true} 209 | }; 210 | context.aField.aField = context.aField; 211 | 212 | Thorax.ServerMarshal.store($el, 'obj', {foo: context.aField}, {foo: 'aField'}, {data: {view: context}}); 213 | 214 | expect(Thorax.ServerMarshal.serialize()).to.match(/"\$lut":\s*"aField"/); 215 | }); 216 | }); 217 | 218 | describe('destroy', function() { 219 | it('should cleanup lut', function() { 220 | var $el1 = $('
    '), 221 | $el2 = $('
    '), 222 | $el3 = $('
    '); 223 | 224 | Thorax.ServerMarshal.store($el1, 'foo', 'bar1'); 225 | Thorax.ServerMarshal.store($el2, 'foo', 'bar2'); 226 | Thorax.ServerMarshal.store($el3, 'foo', 'bar3'); 227 | 228 | expect(JSON.parse(Thorax.ServerMarshal.serialize())).to.eql([ 229 | {"foo": 'bar1'}, 230 | {"foo": 'bar2'}, 231 | {"foo": 'bar3'} 232 | ]); 233 | 234 | Thorax.ServerMarshal.destroy($el2); 235 | expect(JSON.parse(Thorax.ServerMarshal.serialize())).to.eql([ 236 | {foo: 'bar1'}, 237 | null, 238 | {foo: 'bar3'} 239 | ]); 240 | 241 | Thorax.ServerMarshal.destroy($el3); 242 | expect(JSON.parse(Thorax.ServerMarshal.serialize())).to.eql([ 243 | {foo: 'bar1'} 244 | ]); 245 | }); 246 | }); 247 | }); 248 | -------------------------------------------------------------------------------- /test/src/util.js: -------------------------------------------------------------------------------- 1 | describe('util', function() { 2 | describe('#tag', function() { 3 | it('should handle close tags', function() { 4 | expect(Thorax.Util.tag({tagName: 'div'})).to.equal('
    '); 5 | expect(Thorax.Util.tag({tagName: 'div'}, 'foo')).to.equal('
    foo
    '); 6 | }); 7 | it('should handle void tags', function() { 8 | expect(Thorax.Util.tag({tagName: 'img'})).to.equal(''); 9 | }); 10 | it('should throw on void tag with content', function() { 11 | expect(function() { 12 | Thorax.Util.tag({tagName: 'hr'}, 'something'); 13 | }).to.throwError(/void-tag-content/); 14 | }); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /test/zepto-backbone-1-0.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Thorax Test Suite 6 | 7 | 8 | 9 | 10 |
    11 |
    12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /test/zepto.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Thorax Test Suite 6 | 7 | 8 | 9 | 10 |
    11 |
    12 | 13 | 14 | 15 | 16 | 17 | --------------------------------------------------------------------------------