├── demo ├── amd │ ├── amd.css │ ├── rjs │ │ ├── config │ │ │ ├── unified │ │ │ │ └── build-config.js │ │ │ └── jsbin-parts │ │ │ │ ├── vendor-config.js │ │ │ │ └── app-config.js │ │ ├── output │ │ │ └── parts │ │ │ │ └── app.js │ │ └── build-commands.md │ ├── jsbin │ │ ├── map-config.js │ │ └── index.html │ ├── require-config.js │ ├── main.js │ └── index.html ├── .bowerrc ├── demo.css ├── demo.js ├── about.txt ├── memtest │ ├── memtest.css │ ├── index.html │ └── memtest.js ├── common.css ├── bower.json ├── close-build-info.js ├── index.html └── bower-check.js ├── lib-other ├── about.txt └── lodash-underscore-clonedeep │ └── Readme.md ├── .gitignore ├── .jshintrc ├── bower.json ├── LICENSE ├── web-mocha ├── test-framework.css ├── help.html └── _index.html ├── package.json ├── dist ├── backbone.marionette.export.min.js ├── backbone.marionette.export.min.js.map └── backbone.marionette.export.js ├── karma.legacy.conf.js ├── karma.conf.js ├── spec ├── compositeview.test.js ├── itemview.test.js ├── collection.test.js └── model.test.js ├── src └── backbone.marionette.export.js ├── Gruntfile.js └── README.md /demo/amd/amd.css: -------------------------------------------------------------------------------- 1 | @charset "utf-8"; 2 | 3 | -------------------------------------------------------------------------------- /demo/.bowerrc: -------------------------------------------------------------------------------- 1 | { 2 | "directory": "bower_demo_components" 3 | } 4 | -------------------------------------------------------------------------------- /demo/demo.css: -------------------------------------------------------------------------------- 1 | @charset "utf-8"; 2 | 3 | body { 4 | margin: 0.25rem; 5 | } 6 | 7 | -------------------------------------------------------------------------------- /demo/demo.js: -------------------------------------------------------------------------------- 1 | ( function( Backbone, _ ) { 2 | "use strict"; 3 | 4 | }( Backbone, _ )); -------------------------------------------------------------------------------- /lib-other/about.txt: -------------------------------------------------------------------------------- 1 | This directory is for those dependencies of the script which can't be managed by 2 | Bower. Ideally, the directory should be empty. -------------------------------------------------------------------------------- /demo/amd/rjs/config/unified/build-config.js: -------------------------------------------------------------------------------- 1 | ({ 2 | mainConfigFile: "../../../require-config.js", 3 | optimize: "none", 4 | name: "local.main", 5 | out: "../../output/unified/build.js" 6 | }) -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | *.swp 3 | *.swo 4 | *.orig 5 | tmp/ 6 | ext/ 7 | node_modules/ 8 | bower_components/ 9 | _SpecRunner.html 10 | .idea/ 11 | web-mocha/index.html 12 | reports/ 13 | demo/bower_demo_components/ 14 | -------------------------------------------------------------------------------- /demo/amd/rjs/config/jsbin-parts/vendor-config.js: -------------------------------------------------------------------------------- 1 | ({ 2 | mainConfigFile: "../../../require-config.js", 3 | optimize: "none", 4 | name: "local.main", 5 | excludeShallow: [ 6 | "local.main" 7 | ], 8 | out: "../../output/parts/vendor.js" 9 | }) -------------------------------------------------------------------------------- /demo/about.txt: -------------------------------------------------------------------------------- 1 | If you'd like to build a demo or an interactive playground for this project, 2 | edit the files in this directory. 3 | 4 | With `grunt demo`, the index.html file is displayed in the browser, and the 5 | demo, src and spec directories are monitored for changes. If anything is altered 6 | there, the grunt task will live-reload the demo page. 7 | -------------------------------------------------------------------------------- /demo/amd/rjs/config/jsbin-parts/app-config.js: -------------------------------------------------------------------------------- 1 | ({ 2 | mainConfigFile: "../../../require-config.js", 3 | optimize: "none", 4 | name: "local.main", 5 | exclude: [ 6 | "jquery", 7 | "underscore", 8 | "backbone", 9 | "backbone.radio", 10 | "marionette", 11 | "backbone.marionette.export" 12 | ], 13 | out: "../../output/parts/app.js" 14 | }) -------------------------------------------------------------------------------- /lib-other/lodash-underscore-clonedeep/Readme.md: -------------------------------------------------------------------------------- 1 | Lo-dash underscore build, with added deepClone support. 2 | 3 | Generated with `lodash underscore plus=clone,cloneDeep`. 4 | 5 | The regular Underscore build of Lo-dash does not include `cloneDeep`. For Backbone projects, it can be added in a custom build, though. When building with the command above, Underscore compatibility is still preserved. 6 | 7 | See https://github.com/bestiejs/lodash/issues/206 for a discussion of the topic. 8 | -------------------------------------------------------------------------------- /demo/amd/jsbin/map-config.js: -------------------------------------------------------------------------------- 1 | requirejs.config( { 2 | 3 | // !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! 4 | // 5 | // Keep this in sync with the map config in amd/require-config.js 6 | // 7 | // !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! 8 | 9 | map: { 10 | "*": { 11 | // Using legacy versions here: jQuery 1, Marionette 2. Makes the demo work in legacy browsers. 12 | "jquery": "jquery-legacy-v1", 13 | "marionette": "marionette-legacy" 14 | } 15 | } 16 | 17 | } ); 18 | -------------------------------------------------------------------------------- /.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "bitwise": true, 3 | "curly": false, 4 | "eqeqeq": true, 5 | "es3": true, 6 | "freeze": true, 7 | "immed": true, 8 | "latedef": true, 9 | "newcap": true, 10 | "noarg": true, 11 | "noempty": true, 12 | "nonbsp": true, 13 | "undef": true, 14 | 15 | "eqnull": true, 16 | "expr": true, 17 | "sub": true, 18 | 19 | "browser": true, 20 | "jquery": true, 21 | 22 | "strict": true, 23 | 24 | "globals": { 25 | "Backbone": true, 26 | "_": true, 27 | "$": true, 28 | 29 | "JSON": false, 30 | "require": false, 31 | "define": false, 32 | "module": false, 33 | "exports": true 34 | } 35 | } -------------------------------------------------------------------------------- /demo/memtest/memtest.css: -------------------------------------------------------------------------------- 1 | @charset "utf-8"; 2 | 3 | body { 4 | margin: 0.25rem; 5 | } 6 | 7 | label.disabled { 8 | color: #acacac; 9 | } 10 | 11 | .row input[type="checkbox"]{ 12 | margin-left: 0.5rem; 13 | } 14 | 15 | /* min-width 641px and max-width 1024px, medium screens in Foundation */ 16 | @media only screen and (min-width: 40.063em) and (max-width: 64em) { 17 | #runMemtest { 18 | margin-top: 1.75rem; 19 | } 20 | #testTypes label { 21 | margin-right: 0.5rem; 22 | } 23 | } 24 | 25 | /* min-width 1025px, large screens in Foundation */ 26 | @media only screen and (min-width: 64.063em) { 27 | #runMemtest { 28 | margin-top: 3.75rem; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /demo/common.css: -------------------------------------------------------------------------------- 1 | @charset "utf-8"; 2 | 3 | body { 4 | margin: 0.25rem; 5 | } 6 | 7 | #header { 8 | margin-bottom: 2rem; 9 | } 10 | 11 | #header h1, #header h2 { 12 | margin-bottom: 1rem; 13 | } 14 | 15 | h1 { 16 | font-size: 2.5rem; 17 | } 18 | 19 | h2 { 20 | font-size: 1.75rem; 21 | } 22 | 23 | #build-info { 24 | position: relative; 25 | border: 1px solid #d8d8d8; 26 | border-radius: 3px; 27 | padding: 1.25rem; 28 | margin-bottom: 1.25rem; 29 | background-color: #ecfaff; 30 | } 31 | 32 | #build-info a { 33 | display: block; 34 | position: absolute; 35 | top: 0.15rem; 36 | right: 0.6rem; 37 | color: lightgrey; 38 | } 39 | 40 | #build-info a:hover, #build-info a:active { 41 | color: #007b9f; 42 | } 43 | 44 | -------------------------------------------------------------------------------- /demo/bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "demo", 3 | "main": "index.html", 4 | "version": "0.0.0", 5 | "homepage": "https://github.com/hashchange/backbone.marionette.export", 6 | "authors": [ 7 | "hashchange " 8 | ], 9 | "description": "Backbone.Marionette.Export demo and playground", 10 | "keywords": [ 11 | "demo" 12 | ], 13 | "license": "MIT", 14 | "private": true, 15 | "ignore": [ 16 | "**/.*", 17 | "node_modules", 18 | "bower_components", 19 | "bower_demo_components", 20 | "test", 21 | "tests" 22 | ], 23 | "dependencies": { 24 | "foundation": "~5.5.3", 25 | "jquery-legacy-v1": "jquery#^1.9.0", 26 | "jquery-legacy-v2": "jquery#^2.0.0", 27 | "modernizr": "^2 || ~3.3.1", 28 | "requirejs": "~2.3.1" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "backbone.marionette.export", 3 | "version": "3.0.0", 4 | "homepage": "https://github.com/hashchange/backbone.marionette.export", 5 | "authors": [ 6 | "Michael Heim " 7 | ], 8 | "description": "Exposing Backbone model and collection methods to templates.", 9 | "main": "dist/backbone.marionette.export.js", 10 | "keywords": [ 11 | "backbone", 12 | "marionette", 13 | "templates", 14 | "models", 15 | "collections" 16 | ], 17 | "license": "MIT", 18 | "ignore": [ 19 | "**/.*", 20 | "node_modules", 21 | "bower_components", 22 | "test", 23 | "tests", 24 | "public", 25 | "reports", 26 | "demo", 27 | "lib-other", 28 | "web-mocha", 29 | "spec", 30 | "src", 31 | "Gruntfile.js", 32 | "karma.conf.js", 33 | "package.json" 34 | ], 35 | "devDependencies": { 36 | "jquery": "~3.2.1", 37 | "marionette-legacy": "marionette#^2.0.0", 38 | "marionette": "^3.0.0" 39 | }, 40 | "dependencies": { 41 | "backbone": "^1.0.0 <1.4.0", 42 | "underscore": "^1.5.0 <1.9.0" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014-2017 Michael Heim 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /demo/close-build-info.js: -------------------------------------------------------------------------------- 1 | // Closes the #build-info box when clicking on the close button. Hides the box immediately on JS Bin and Codepen. 2 | // 3 | // Works in any browser. 4 | ( function ( window, document ) { 5 | "use strict"; 6 | 7 | var isLocalDemo =!window.location.hostname.match( /jsbin\.|codepen\./i ), 8 | 9 | box = document.getElementById( "build-info" ), 10 | closeButton = document.getElementById( "close-build-info" ), 11 | 12 | close = function ( event ) { 13 | if ( event ) event.preventDefault(); 14 | box.style.display = "none"; 15 | }; 16 | 17 | addEventHandler( closeButton, "click", close ); 18 | if ( !isLocalDemo ) close(); 19 | 20 | function addEventHandler( element, event, handler ) { 21 | 22 | if ( element ) { 23 | 24 | if ( element.addEventListener ) { 25 | element.addEventListener( event, handler, false ); 26 | } else if ( element.attachEvent ) { 27 | element.attachEvent( "on" + event, handler ) 28 | } else { 29 | element["on" + event] = handler; 30 | } 31 | 32 | } else if ( window.console && window.console.log ) { 33 | window.console.log( "close-build-info.js: Build info box (or its close button) not found" ); 34 | } 35 | 36 | } 37 | 38 | } ( window, document ) ); -------------------------------------------------------------------------------- /web-mocha/test-framework.css: -------------------------------------------------------------------------------- 1 | @charset "utf-8"; 2 | 3 | .aux { 4 | margin-left: 65px; 5 | position: absolute; 6 | top: 15px; 7 | } 8 | 9 | #test-framework-help-link { 10 | width: 12em; 11 | margin-left: 450px; 12 | } 13 | 14 | #alternate-setup { 15 | top: 15px; 16 | } 17 | .aux, 18 | .test-framework-help nav { 19 | margin-top: 1em; 20 | margin-bottom: 1em; 21 | font: 12px/1.5 "Helvetica Neue", Helvetica, Arial, sans-serif; 22 | color: #888; 23 | } 24 | .aux a, 25 | .test-framework-help a { 26 | text-decoration: none; 27 | color: inherit; 28 | } 29 | 30 | .aux a:hover, 31 | .aux a:active, 32 | .test-framework-help nav a:hover, 33 | .test-framework-help nav a:active 34 | { 35 | text-decoration: underline; 36 | } 37 | 38 | .test-framework-help section a { 39 | color: #555; 40 | border-bottom: 1px dotted black; 41 | } 42 | 43 | .test-framework-help section a:hover, 44 | .test-framework-help section a:active { 45 | border-bottom-style: solid; 46 | border-bottom-color: #888; 47 | } 48 | 49 | .test-framework-help { 50 | margin: 0; 51 | padding: 15px 65px; 52 | font: 16px/1.5 "Helvetica Neue", Helvetica, Arial, sans-serif 53 | } 54 | 55 | .test-framework-help h1 { 56 | font-size: 20px; 57 | font-weight: 200; 58 | } 59 | 60 | .test-framework-help ul { 61 | padding-left: 0; 62 | } 63 | 64 | .test-framework-help li { 65 | margin-bottom: 0.5em; 66 | } 67 | -------------------------------------------------------------------------------- /demo/amd/require-config.js: -------------------------------------------------------------------------------- 1 | requirejs.config( { 2 | 3 | // Base URL: project root 4 | baseUrl: "../../", 5 | 6 | paths: { 7 | // Using a different jQuery here than elsewhere: 1.x, instead of 2.x (in bower_demo_components) or 3.x 8 | // (in bower_components). Makes the demo work in oldIE, too. 9 | 10 | "jquery-legacy-v1": "demo/bower_demo_components/jquery-legacy-v1/dist/jquery", 11 | "jquery-legacy-v2": "demo/bower_demo_components/jquery-legacy-v2/dist/jquery", 12 | "jquery-modern": "bower_components/jquery/dist/jquery", 13 | 14 | "underscore": "bower_components/underscore/underscore", 15 | "backbone": "bower_components/backbone/backbone", 16 | "backbone.radio": "bower_components/backbone.radio/build/backbone.radio", 17 | "marionette-modern": "bower_components/marionette/lib/backbone.marionette", 18 | "marionette-legacy": "bower_components/marionette-legacy/lib/backbone.marionette", 19 | 20 | "backbone.marionette.export": "dist/backbone.marionette.export", 21 | 22 | "local.main": "demo/amd/main" 23 | }, 24 | 25 | map: { 26 | "*": { 27 | // Using legacy versions here: jQuery 1, Marionette 2. Makes the demo work in legacy browsers. 28 | "jquery": "jquery-legacy-v1", 29 | "marionette": "marionette-legacy" 30 | } 31 | }, 32 | 33 | shim: { 34 | "jquery-legacy-v1": { 35 | exports: "jQuery" 36 | }, 37 | "jquery-legacy-v2": { 38 | exports: "jQuery" 39 | }, 40 | "jquery-modern": { 41 | exports: "jQuery" 42 | }, 43 | 44 | "backbone.marionette.export": ["marionette"] 45 | } 46 | } ); 47 | 48 | -------------------------------------------------------------------------------- /web-mocha/help.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Test Framework Help 7 | 8 | 9 | 10 | 11 | 12 |
13 |

Test Framework: Help and Reference

14 |

The test framework is based on Mocha and Chai. It includes these components:

15 | 38 |
39 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /demo/amd/main.js: -------------------------------------------------------------------------------- 1 | // main.js 2 | 3 | require( [ 4 | 5 | 'underscore', 6 | 'backbone', 7 | 'marionette', 8 | 'backbone.marionette.export' 9 | 10 | ], function ( _, Backbone ) { 11 | 12 | var MarionetteBaseView = Backbone.Marionette.ItemView || Backbone.Marionette.View, // supporting both Marionette 2 and 3 13 | 14 | Model = Backbone.Model.extend( { 15 | exportable: "someMethod", 16 | 17 | onExport: function ( data ) { 18 | data || (data = {}); // jshint ignore:line 19 | data.someMethodWithArgs = this.someMethodWithArgs( this.cid ); 20 | return data; 21 | }, 22 | someMethod: function () { 23 | return "This is the return value of a method call on model " + this.id + "."; 24 | }, 25 | someMethodWithArgs: function ( arg ) { 26 | return "This is the return value of a method call with argument " + arg + " on model " + this.id + "."; 27 | } 28 | } ), 29 | 30 | Collection = Backbone.Collection.extend( { 31 | exportable: [ "first", "last" ] 32 | } ), 33 | 34 | m1 = new Model( { id: 1 } ), 35 | m2 = new Model( { id: 2 } ), 36 | m3 = new Model( { id: 3 } ), 37 | 38 | collection = new Collection( [ m1, m2, m3 ] ), 39 | 40 | DataView = Backbone.Marionette.CompositeView.extend( { 41 | childView: MarionetteBaseView.extend( { 42 | tagName: "li", 43 | template: "#model-template" 44 | } ), 45 | childViewContainer: "ul", 46 | template: "#collection-template" 47 | } ), 48 | 49 | dataRegion = new Backbone.Marionette.Region( { 50 | el: "#main" 51 | } ); 52 | 53 | dataRegion.show( new DataView( { collection: collection } ) ); 54 | } ); 55 | -------------------------------------------------------------------------------- /demo/amd/rjs/output/parts/app.js: -------------------------------------------------------------------------------- 1 | // main.js 2 | 3 | require( [ 4 | 5 | 'underscore', 6 | 'backbone', 7 | 'marionette', 8 | 'backbone.marionette.export' 9 | 10 | ], function ( _, Backbone ) { 11 | 12 | var MarionetteBaseView = Backbone.Marionette.ItemView || Backbone.Marionette.View, // supporting both Marionette 2 and 3 13 | 14 | Model = Backbone.Model.extend( { 15 | exportable: "someMethod", 16 | 17 | onExport: function ( data ) { 18 | data || (data = {}); // jshint ignore:line 19 | data.someMethodWithArgs = this.someMethodWithArgs( this.cid ); 20 | return data; 21 | }, 22 | someMethod: function () { 23 | return "This is the return value of a method call on model " + this.id + "."; 24 | }, 25 | someMethodWithArgs: function ( arg ) { 26 | return "This is the return value of a method call with argument " + arg + " on model " + this.id + "."; 27 | } 28 | } ), 29 | 30 | Collection = Backbone.Collection.extend( { 31 | exportable: [ "first", "last" ] 32 | } ), 33 | 34 | m1 = new Model( { id: 1 } ), 35 | m2 = new Model( { id: 2 } ), 36 | m3 = new Model( { id: 3 } ), 37 | 38 | collection = new Collection( [ m1, m2, m3 ] ), 39 | 40 | DataView = Backbone.Marionette.CompositeView.extend( { 41 | childView: MarionetteBaseView.extend( { 42 | tagName: "li", 43 | template: "#model-template" 44 | } ), 45 | childViewContainer: "ul", 46 | template: "#collection-template" 47 | } ), 48 | 49 | dataRegion = new Backbone.Marionette.Region( { 50 | el: "#main" 51 | } ); 52 | 53 | dataRegion.show( new DataView( { collection: collection } ) ); 54 | } ); 55 | 56 | define("local.main", function(){}); 57 | 58 | -------------------------------------------------------------------------------- /demo/amd/rjs/build-commands.md: -------------------------------------------------------------------------------- 1 | # Generating r.js builds 2 | 3 | ## Using a Grunt task 4 | 5 | Instead of individual r.js calls, the following command will create all builds: 6 | 7 | ``` 8 | grunt requirejs 9 | ``` 10 | 11 | The grunt task simply reads the build profiles described below, and feeds them to r.js. 12 | 13 | 14 | ## Split builds with two build files, for JS Bin demos 15 | 16 | The demo HTML files for JS Bin reference two concatenated build files: 17 | 18 | - `vendor.js` for the third-party dependencies. It includes __COMPONENT_NAME_UC__. 19 | - `app.js` for the demo code, consisting of local modules. 20 | 21 | The code is not rolled up into a single file because that file would be massive, making it unnecessarily difficult to examine the demo code. The purpose of the demo is to see how __COMPONENT_NAME_UC__ is used, so it makes sense to keep the client code separate. 22 | 23 | ### Adjustments 24 | 25 | Care must be taken to avoid duplication. A module pulled into `vendor.js` must not be part of `app.js`, and vice versa. Update the module exclusions in **all** build config files when new modules are added to a demo. 26 | 27 | ### r.js calls 28 | 29 | Open a command prompt in the **project root** directory. 30 | 31 | ``` 32 | # For vendor.js: 33 | 34 | node node_modules/requirejs/bin/r.js -o demo/amd/rjs/config/jsbin-parts/vendor-config.js 35 | 36 | # For app.js: 37 | 38 | node node_modules/requirejs/bin/r.js -o demo/amd/rjs/config/jsbin-parts/app-config.js 39 | ``` 40 | 41 | ### Output files 42 | 43 | The output is written to the directory `demo/amd/rjs/output/parts`. 44 | 45 | 46 | ## Single-file builds, for local demos 47 | 48 | Builds for local demos are created to test that the setup continues to work after optimization with r.js. All modules of a demo end up in a single file. For easier examination, the file is not minified. 49 | 50 | For more info, see the comments in `index.html`. 51 | 52 | ### r.js calls 53 | 54 | For building the output file, open a command prompt in the **project root** directory, and run this command: 55 | 56 | ``` 57 | node node_modules/requirejs/bin/r.js -o demo/amd/rjs/config/unified/build-config.js 58 | ``` 59 | 60 | ### Output files 61 | 62 | The output is written to the directory `demo/amd/rjs/output/unified`. -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "backbone.marionette.export", 3 | "version": "3.0.0", 4 | "homepage": "https://github.com/hashchange/backbone.marionette.export", 5 | "bugs": "https://github.com/hashchange/backbone.marionette.export/issues", 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/hashchange/backbone.marionette.export.git" 9 | }, 10 | "author": "Michael Heim (http://www.zeilenwechsel.de/)", 11 | "description": "Exposing Backbone model and collection methods to templates.", 12 | "main": "dist/backbone.marionette.export.js", 13 | "keywords": [ 14 | "backbone", 15 | "marionette", 16 | "templates", 17 | "models", 18 | "collections" 19 | ], 20 | "license": "MIT", 21 | "scripts": { 22 | "cleanup": "del-cli bower_components demo/bower_demo_components node_modules -f", 23 | "setup": "npm install && bower install && cd demo && bower install && cd ..", 24 | "reinstall": "npm run cleanup -s && npm run setup || npm run setup" 25 | }, 26 | "dependencies": { 27 | "backbone": "^1.0.0 <1.4.0", 28 | "underscore": "^1.5.0 <1.9.0" 29 | }, 30 | "devDependencies": { 31 | "bower": "^1.8.0", 32 | "connect-livereload": "~0.6.0", 33 | "del-cli": "~0.2.1", 34 | "grunt": "^1.0.1", 35 | "grunt-cli": "^1.2.0", 36 | "grunt-contrib-concat": "~1.0.1", 37 | "grunt-contrib-connect": "~1.0.2", 38 | "grunt-contrib-jshint": "~1.1.0", 39 | "grunt-contrib-requirejs": "^1.0.0", 40 | "grunt-contrib-uglify": "~2.2.0", 41 | "grunt-contrib-watch": "~1.0.0", 42 | "grunt-focus": "~1.0.0", 43 | "grunt-karma": "~2.0.0", 44 | "grunt-mocha": "~1.0.4", 45 | "grunt-preprocess": "~5.1.0", 46 | "grunt-sails-linker": "~1.0.4", 47 | "grunt-text-replace": "~0.4.0", 48 | "karma": "~1.5.0", 49 | "karma-chai-plugins": "~0.8.0", 50 | "karma-chrome-launcher": "~2.0.0", 51 | "karma-firefox-launcher": "~1.0.1", 52 | "karma-html2js-preprocessor": "~1.1.0", 53 | "karma-ie-launcher": "~1.0.0", 54 | "karma-mocha": "~1.3.0", 55 | "karma-mocha-reporter": "~2.2.3", 56 | "karma-opera-launcher": "~1.0.0", 57 | "karma-phantomjs-launcher": "~1.0.4", 58 | "karma-requirejs": "~1.1.0", 59 | "karma-safari-launcher": "1.0.0", 60 | "karma-script-launcher": "~1.0.0", 61 | "karma-slimerjs-launcher": "~1.1.0", 62 | "mocha": "~3.2.0", 63 | "phantomjs-prebuilt": "^2.1.14", 64 | "require-from-string": "^1.2.1", 65 | "requirejs": "^2.3.3" 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /demo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Backbone.Marionette.Export: Demo and Playground 9 | 10 | 11 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 33 | 34 |
35 | 41 |
42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | -------------------------------------------------------------------------------- /demo/amd/jsbin/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Backbone.Marionette.Export 2.1.6, demo (AMD) 9 | 10 | 11 | 12 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 34 | 35 | 40 | 41 | 42 | 43 | 44 | 48 | 49 |
50 | 51 |
52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | -------------------------------------------------------------------------------- /dist/backbone.marionette.export.min.js: -------------------------------------------------------------------------------- 1 | // Backbone.Marionette.Export, v3.0.0 2 | // Copyright (c) 2013-2016 Michael Heim, Zeilenwechsel.de 3 | // Distributed under MIT license 4 | // http://github.com/hashchange/backbone.marionette.export 5 | 6 | 7 | !function(a,b){"use strict";var c="object"==typeof exports&&exports&&!exports.nodeType&&"object"==typeof module&&module&&!module.nodeType;"function"==typeof define&&"object"==typeof define.amd&&define.amd?define(["exports","underscore","backbone"],b):c?b(exports,require("underscore"),require("backbone")):b({},_,Backbone)}(this,function(a,_,Backbone){"use strict";function b(a){var b,c,d=[];if(Object.getPrototypeOf&&Object.getOwnPropertyNames)for(b=a;null!==b;b=Object.getPrototypeOf(b))d=d.concat(Object.getOwnPropertyNames(b));else for(c in a)d.push(c);return _.unique(d)}var c=b([]),d=Backbone.Marionette&&(Backbone.Marionette.ItemView||Backbone.Marionette.View);Backbone.Model.prototype.onBeforeExport=Backbone.Collection.prototype.onBeforeExport=function(){},Backbone.Model.prototype.onAfterExport=Backbone.Collection.prototype.onAfterExport=function(){},Backbone.Model.prototype.onExport=Backbone.Collection.prototype.onExport=function(a){return a},Backbone.Model.prototype.export=Backbone.Collection.prototype.export=function(){function a(a){return a&&a.export&&(a instanceof Backbone.Model||a instanceof Backbone.Collection)&&f 2 | 3 | 4 | 5 | 6 | Mocha Spec Runner 7 | 8 | 9 | 10 | 11 | 12 |
13 |
14 | 15 | 16 | 17 | 18 | 19 | 20 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 51 | 52 | 53 | 54 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 81 | 82 | 83 | 84 | 85 | -------------------------------------------------------------------------------- /dist/backbone.marionette.export.min.js.map: -------------------------------------------------------------------------------- 1 | {"version":3,"sources":["backbone.marionette.export.js"],"names":["root","factory","supportsExports","exports","nodeType","module","define","amd","require","_","Backbone","this","listAllProperties","obj","objectToInspect","property","result","Object","getPrototypeOf","getOwnPropertyNames","concat","push","unique","nativeArrayProperties","MarionetteBaseView","Marionette","ItemView","View","Model","prototype","onBeforeExport","Collection","onAfterExport","onExport","data","allowExport","hops","global","maxHops","exportable","conflicts","arguments","length","map","model","cloneDeep","attributes","attribute","undefined","toJSON","each","attrValue","attrName","isArray","method","name","strictMode","strict","isUndefined","Error","isString","indexOf","substr","isFunction","apply","value","intersection","keys","join","version","serializeData","CompositeView","collection","items","info"],"mappings":";;;;;;CAKG,SAAWA,EAAMC,GAChB,YAQA,IAAIC,GAAqC,gBAAZC,UAAwBA,UAAYA,QAAQC,UAA8B,gBAAXC,SAAuBA,SAAWA,OAAOD,QAO9G,mBAAXE,SAA+C,gBAAfA,QAAOC,KAAoBD,OAAOC,IAG1ED,QAAU,UAAW,aAAc,YAAcL,GAEzCC,EAGRD,EAASE,QAASK,QAAS,cAAgBA,QAAS,aAKpDP,KAAaQ,EAAGC,WAIrBC,KAAM,SAAWR,EAASM,EAAGC,UAC5B,YAgBA,SAASE,GAAoBC,GAEzB,GAAIC,GACAC,EACAC,IAEJ,IAAKC,OAAOC,gBAAkBD,OAAOE,oBAGjC,IAAML,EAAkBD,EAAyB,OAApBC,EAA0BA,EAAkBG,OAAOC,eAAgBJ,GAC5FE,EAASA,EAAOI,OAAQH,OAAOE,oBAAqBL,QAMxD,KAAMC,IAAYF,GAAMG,EAAOK,KAAMN,EAGzC,OAAON,GAAEa,OAAQN,GAIrB,GAAIO,GAAyBX,MAGzBY,EAAqBd,SAASe,aAAgBf,SAASe,WAAWC,UAAYhB,SAASe,WAAWE,KAMtGjB,UAASkB,MAAMC,UAAUC,eAAiBpB,SAASqB,WAAWF,UAAUC,eAAiB,aAOzFpB,SAASkB,MAAMC,UAAUG,cAAgBtB,SAASqB,WAAWF,UAAUG,cAAgB,aAgBvFtB,SAASkB,MAAMC,UAAUI,SAAWvB,SAASqB,WAAWF,UAAUI,SAAW,SAAWC,GACpF,MAAOA,IAGXxB,SAASkB,MAAMC,UAAkB,OAAInB,SAASqB,WAAWF,UAAkB,OAAI,WAG3E,QAASM,GAActB,GACnB,MACIA,IAAOA,EAAY,SACjBA,YAAeH,UAASkB,OAASf,YAAeH,UAASqB,aAC3DK,EAAOvB,EAAY,OAAEwB,OAAOC,QANpC,GAAIJ,GAAMK,EAAYC,EAAWJ,CA6IjC,IAnIAA,EAAOK,UAAUC,OAASD,UAAU,GAAK,EAGpC9B,KAAKmB,gBAAiBnB,KAAKmB,iBAG3BnB,eAAgBD,UAASqB,WAO1BG,EAAOvB,KAAKgC,IAAK,SAAWC,GAAU,MAAOT,GAAaS,GAAUA,EAAc,OAAGR,EAAO,GAAMQ,IAM7FnC,EAAEoC,UAKHX,EAAOzB,EAAEoC,UAAWlC,KAAKmC,WAAY,SAAWC,GAC5C,MAAOZ,GAAaY,GAAcA,EAAkB,OAAGX,EAAO,GAAMY,UAOxEd,EAAOvB,KAAKsC,SAIZxC,EAAEyC,KAAMhB,EAAM,SAAWiB,EAAWC,EAAUlB,GACrCC,EAAagB,KAAcjB,EAAKkB,GAAYD,EAAkB,OAAGf,EAAO,OASpFzB,KAAK4B,aAENA,EAAa5B,KAAK4B,WACX9B,EAAE4C,QAASd,KAAeA,EAAaA,GAAeA,OAE7D9B,EAAEyC,KAAMX,EAAY,SAAUe,GAE1B,GAAIC,GAIAC,EAAa7C,KAAa,OAAE0B,OAAOoB,MAEvC,IAAKhD,EAAEiD,YAAaJ,GAAW,KAAM,IAAIK,OAAO,kDAEhD,KAAKlD,EAAEmD,SAAUN,GAQb,KAAM,IAAIK,OAAO,mDAJjB,IADAJ,EAAqC,IAA9BD,EAAOO,QAAS,SAAkBP,EAAOQ,OAAQ,GAAMR,IACrDC,IAAQ5C,QAAU6C,EAAa,KAAM,IAAIG,OAAO,kBAAoBJ,EAAO,+BAOxF,IANID,EAAS3C,KAAK4C,GAMb9C,EAAEsD,WAAYT,GAGfpB,EAAKqB,GAAQD,EAAOU,MAAOrD,UAExB,CAEH,GAAKA,eAAgBD,UAASkB,OAAS4B,EAQnC,KAAM,IAAIG,OAAO,sDAAwDJ,EAAO,kCAMhFrB,GAAKqB,GAAQ5C,KAAK4C,GAQrB9C,EAAEoC,UAIHX,EAAKqB,GAAQ9C,EAAEoC,UAAWX,EAAKqB,GAAO,SAAWU,GAC7C,MAAO9B,GAAa8B,GAAUA,EAAc,OAAG7B,EAAO,GAAMY,SAI3Db,EAAaD,EAAKqB,MAAUrB,EAAKqB,GAAQrB,EAAKqB,GAAc,OAAGnB,EAAO,IAI1E3B,EAAEiD,YAAaxB,EAAKqB,WAAiBrB,GAAKqB,IAEhD5C,OAIFA,KAAKsB,WAAWC,EAAOvB,KAAKsB,SAAUC,IAGtCvB,KAAKqB,eAAgBrB,KAAKqB,gBAQ1BrB,eAAgBD,UAASqB,aAE1BS,EAAY/B,EAAEyD,aAAc3C,EAAwBd,EAAE0D,KAAMjC,IACvDM,EAAUE,QACX,KAAM,IAAIiB,OAAO,4GAA8GnB,EAAU4B,KAAM,MAKvJ,OAAOlC,IAGXxB,SAASkB,MAAMC,UAAkB,OAAEQ,OAAS3B,SAASqB,WAAWF,UAAkB,OAAEQ,QAChFC,QAAS,EACTmB,QAAQ,EACRY,QAAS,SAGR3D,SAASe,aAEVD,EAAmBK,UAAUyC,cAAgB5D,SAASe,WAAW8C,cAAc1C,UAAUyC,cAAgB,WAOrG,GAAIpC,KASJ,OAPKvB,MAAKiC,MACNV,EAAOvB,KAAKiC,MAAc,QAAKjC,KAAKiC,MAAc,UAAOjC,KAAKiC,MAAMK,SAE9DtC,KAAK6D,aACXtC,GAASuC,MAAO9D,KAAK6D,WAAmB,QAAK7D,KAAK6D,WAAmB,UAAO7D,KAAK6D,WAAWvB,WAGzFf,IAWf/B,EAAQuE,KAAO","file":"backbone.marionette.export.min.js"} -------------------------------------------------------------------------------- /karma.conf.js: -------------------------------------------------------------------------------- 1 | // Karma configuration 2 | // Generated on Mon Dec 30 2013 16:14:03 GMT+0100 (CET) 3 | 4 | // ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 5 | // + For automated testing with Grunt, some settings in this config file + 6 | // + are overridden in Gruntfile.js. Check both locations. + 7 | // ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 8 | 9 | module.exports = function(config) { 10 | config.set({ 11 | 12 | // base path, that will be used to resolve files and exclude 13 | basePath: '', 14 | 15 | 16 | // frameworks to use 17 | // 18 | // Available for chai (installed with karma-chai-plugins): 19 | // sinon-chai, chai-as-promised, chai-jquery. Enable as needed. 20 | // 21 | // NB sinon-chai includes Sinon; chai-jquery does _not_ include jQuery 22 | frameworks: ['mocha', 'chai', 'sinon-chai'], 23 | 24 | 25 | // list of files / patterns to load in the browser 26 | files: [ 27 | // Test dependencies 28 | 'bower_components/jquery/dist/jquery.js', 29 | 30 | // Component dependencies 31 | 32 | // Using the latest Marionette by default. Switch to Marionette 2.x as needed, or run Karma with the config for 33 | // legacy Marionette. 34 | // 35 | // NB Tests run through the interactive web interface use Marionette 2.x. Use `grunt interactive` or `grunt webtest` 36 | // for them. 37 | 38 | 'bower_components/underscore/underscore.js', 39 | 'bower_components/backbone/backbone.js', 40 | 41 | // 'bower_components/marionette-legacy/lib/backbone.marionette.js', 42 | 'bower_components/backbone.radio/build/backbone.radio.js', 43 | 'bower_components/marionette/lib/backbone.marionette.js', 44 | 45 | // Comment the lib-other files out to prevent Lodash from loading, and run the tests with Underscore. 46 | // 47 | // Either way, some tests - targeted at the other library - are ignored. More tests are ignored with 48 | // Underscore, so it is reasonable to run the Lodash tests by default. 49 | 'lib-other/**/*.js', 50 | 51 | // Component under test 52 | 'src/backbone.marionette.export.js', 53 | 54 | // Tests 55 | 'spec/**/*.+(spec|test|tests).js' 56 | ], 57 | 58 | 59 | // list of files to exclude 60 | exclude: [ 61 | 62 | ], 63 | 64 | 65 | // test results reporter to use 66 | // possible values: 'dots', 'progress', 'junit', 'growl', 'coverage', 'mocha' 67 | reporters: ['progress'], 68 | 69 | 70 | // web server port 71 | port: 9876, 72 | 73 | 74 | // enable / disable colors in the output (reporters and logs) 75 | colors: true, 76 | 77 | 78 | // level of logging 79 | // possible values: config.LOG_DISABLE || config.LOG_ERROR || config.LOG_WARN || config.LOG_INFO || config.LOG_DEBUG 80 | logLevel: config.LOG_INFO, 81 | 82 | 83 | // enable / disable watching file and executing tests whenever any file changes 84 | autoWatch: false, 85 | 86 | 87 | // Start these browsers, currently available: 88 | // - Chrome 89 | // - ChromeCanary 90 | // - Firefox 91 | // - Opera 92 | // - Safari 93 | // - PhantomJS 94 | // - SlimerJS 95 | // - IE (Windows only) 96 | // 97 | // ATTN Interactive debugging in PhpStorm/WebStorm doesn't work with PhantomJS. Use Firefox or Chrome instead. 98 | browsers: ['PhantomJS'], 99 | 100 | 101 | // If browser does not capture in given timeout [ms], kill it 102 | captureTimeout: 60000, 103 | 104 | 105 | // Continuous Integration mode 106 | // if true, it capture browsers, run tests and exit 107 | singleRun: false 108 | }); 109 | }; 110 | -------------------------------------------------------------------------------- /demo/amd/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Backbone.Marionette.Export: Demo (AMD) 9 | 10 | 11 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 33 | 34 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 55 | 56 |
57 | 58 |
59 | 60 | 61 | 62 | 63 | 64 | 65 | 72 | 73 | 80 | 81 | 82 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | -------------------------------------------------------------------------------- /demo/bower-check.js: -------------------------------------------------------------------------------- 1 | // Checks that 'bower install' has been run in the demo dir if demo/bower.json isn't empty. 2 | 3 | // ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 4 | // + + 5 | // + This is a blocking script, triggering _synchronous_ http subrequests. Use locally only. + 6 | // + + 7 | // ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 8 | 9 | ( function ( window ) { 10 | "use strict"; 11 | 12 | var BOWER_DEMO_COMPONENTS_DIR = "/demo/bower_demo_components", 13 | 14 | msg = "
" + 15 | "

Bower components for the demo seem to be missing. Install them first:

" + 16 | "
    " + 17 | "
  • Open a command prompt in the demo directory of the project.
  • " + 18 | "
  • Run bower install
  • " + 19 | "
" + 20 | "

If this is a false positive and the packages are in fact in place, " + 21 | "disable the check by removing bower-check.js at the top of the <body>.

" + 22 | "
"; 23 | 24 | getJSON( "/demo/bower.json", false, function ( data ) { 25 | 26 | var i, j, depNames, 27 | exists = false, 28 | files = [ 29 | 'bower.json', 'package.json', 30 | 'readme.md', 'Readme.md', 'README.md', 'README', 31 | 'license.txt', 'LICENSE.txt', 'LICENSE', 32 | 'Gruntfile.js', 33 | 'composer.json', 'component.json' 34 | ]; 35 | 36 | if ( data && data.dependencies ) { 37 | 38 | depNames = Object.keys( data.dependencies ); 39 | 40 | // Bower packages don't necessarily have a bower.json. file. The only file guaranteed to be there after 41 | // install is `.bower.json` (note the leading dot), but it is hidden and won't be served over http. 42 | // 43 | // So instead, we are looking for a bunch of files which are very likely to be there. If none of them is, 44 | // for none of the projects, the dependencies are most likely not installed. 45 | for ( i = 0; i < depNames.length; i++ ) { 46 | for ( j = 0; j < files.length; j++ ) { 47 | 48 | get( 49 | BOWER_DEMO_COMPONENTS_DIR + "/" + depNames[i] + "/" + files[j], false, 50 | function ( data ) { 51 | exists = !!data; 52 | } 53 | ); 54 | 55 | if ( exists ) return; 56 | 57 | } 58 | } 59 | 60 | window.document.write( msg ); 61 | } 62 | 63 | } ); 64 | 65 | 66 | // Helper functions in the absence of a library like jQuery (not loaded at this point) 67 | function get ( url, async, cb, cbError ) { 68 | var data, 69 | request = new XMLHttpRequest; 70 | 71 | request.open( 'GET', url, async ); 72 | 73 | request.onload = function () { 74 | if ( this.status >= 200 && this.status < 400 ) { 75 | // Success! 76 | data = this.response; 77 | } else { 78 | // We reached our target server, but it returned an error. Most likely, the bower.json file is missing. 79 | // We just return undefined here. 80 | data = undefined; 81 | } 82 | cb( data ); 83 | }; 84 | 85 | request.onerror = function ( err ) { 86 | // There was a connection error of some sort. 87 | if ( cbError ) cbError( err ); 88 | }; 89 | 90 | request.send(); 91 | } 92 | 93 | function getJSON ( url, async, cb, cbError ) { 94 | get( url, async, function ( data ) { 95 | 96 | if ( data ) data = JSON.parse( data ); 97 | cb( data ); 98 | 99 | }, cbError ); 100 | } 101 | 102 | }( window )); -------------------------------------------------------------------------------- /demo/memtest/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Memory Leak Test - Demos and Playground 9 | 10 | 11 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 |
30 |

Memory leak test

31 |

Enable memory profiling in the browser, then run one of these tests:

32 |
33 | 34 |
35 | 36 |
37 | Component 38 |
39 |
40 |
41 | 42 |
43 | Repetition 44 |
45 |
46 | 47 | 48 |
49 |
50 | 51 | 52 |
53 |
54 | 55 | 56 |
57 |
58 |
59 |
* reference released immediately, only one instance kept around
60 |
61 |
62 | 63 |
64 | 65 |
66 | 67 |
68 | Behaviour / Options ... 69 |

Nothing available here.

70 |
71 | 72 |
73 | 74 |
75 | 76 |
77 | 78 |
79 |
80 |

Script output

81 |

82 |         
83 |
84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | -------------------------------------------------------------------------------- /spec/compositeview.test.js: -------------------------------------------------------------------------------- 1 | /*global describe, it */ 2 | (function () { 3 | "use strict"; 4 | 5 | describe( 'Marionette.CompositeView automatically provides the output of export() to its template.', function () { 6 | 7 | var CompositeView, Model, model, Collection, collection; 8 | 9 | beforeEach( function () { 10 | 11 | CompositeView = Marionette.CompositeView.extend( { 12 | template: function ( injectedData ) { 13 | return _.template( 'some template HTML', injectedData ); 14 | } 15 | } ); 16 | 17 | Model = Backbone.Model.extend( { 18 | exportable: "method", 19 | method: function () { return "a method return value, model cid = " + this.cid; } 20 | } ); 21 | model = new Model(); 22 | 23 | Collection = Backbone.Collection.extend( { 24 | exportable: "collectionMethod", 25 | collectionMethod: function () { return "a return value from a collection method"; } 26 | } ); 27 | collection = new Collection( [ new Model(), new Model(), new Model() ] ); 28 | 29 | sinon.spy( model, "export" ); 30 | sinon.spy( collection, "export" ); 31 | 32 | } ); 33 | 34 | afterEach( function () { 35 | 36 | if ( model["export"].restore ) model["export"].restore(); 37 | if ( collection["export"].restore ) collection["export"].restore(); 38 | 39 | } ); 40 | 41 | describe( 'When CompositeView handles a Backbone model, the render() method of the view', function () { 42 | 43 | it( 'invokes the export() method of the model', function () { 44 | 45 | var compositeView = new CompositeView( { model: model } ); 46 | 47 | compositeView.render(); 48 | expect( model["export"] ).to.have.been.calledOnce; 49 | 50 | } ); 51 | 52 | it( 'makes the exported data available to the template', function () { 53 | 54 | var compositeView = new CompositeView( { model: model } ); 55 | sinon.spy( compositeView, "template" ); 56 | 57 | var exportedModel = model["export"](); 58 | compositeView.render(); 59 | expect( compositeView.template ).to.have.been.calledWithExactly( exportedModel ); 60 | 61 | } ); 62 | 63 | } ); 64 | 65 | describe( 'When CompositeView handles a Backbone collection, the render() method of the view', function () { 66 | 67 | it( 'invokes the export() method of the collection', function () { 68 | 69 | var compositeView = new CompositeView( { collection: collection } ); 70 | 71 | compositeView.render(); 72 | expect( collection["export"] ).to.have.been.calledOnce; 73 | 74 | } ); 75 | 76 | it( 'makes the exported methods and properties of the collection available to the template as properties of the "items" array', function () { 77 | 78 | var compositeView = new CompositeView( { collection: collection } ); 79 | sinon.spy( compositeView, "template" ); 80 | 81 | compositeView.render(); 82 | expect( compositeView.template ).to.have.been.calledWithExactly( sinon.match( function ( templateData ) { 83 | return templateData.items && _.isEqual( _.pairs( templateData.items ), _.pairs( collection["export"]() ) ); 84 | } ) ); 85 | // 86 | // NB: _.isEqual does compare arrays, but does NOT pick up any custom properties attached to the array 87 | // objects. That appears to be on purpose, see http://goo.gl/R3WlXu . The docs talk about "an optimized 88 | // deep comparison". To work around it, we convert the arrays into key-value hashes with _.pairs first, 89 | // and then compare those hashes. 90 | 91 | } ); 92 | 93 | it( 'makes the collection array available to the template in an "items" property', function () { 94 | 95 | var compositeView = new CompositeView( { collection: collection } ); 96 | sinon.spy( compositeView, "template" ); 97 | 98 | compositeView.render(); 99 | expect( compositeView.template ).to.have.been.calledWithExactly( sinon.match.has( "items", collection["export"]() ) ); 100 | // 101 | // NB: sinon.match.has does compare the array, but does NOT pick up any custom properties attached to 102 | // the array object. Here, we only verify that the content of the arrays, ie the items in it, are 103 | // identical. We can't verify full equality for the array objects this way - until the patch for it has 104 | // landed in a Sinon release. See https://github.com/cjohansen/Sinon.JS/issues/315 105 | 106 | } ); 107 | 108 | } ); 109 | 110 | } ); 111 | 112 | })(); 113 | -------------------------------------------------------------------------------- /demo/memtest/memtest.js: -------------------------------------------------------------------------------- 1 | ( function ( Backbone, _ ) { 2 | "use strict"; 3 | 4 | if ( !( this && this.console || window.console ) ) window.console = { log: function ( msg ) {} }; 5 | 6 | var $log = Backbone.$( "#log" ), 7 | $memtest = Backbone.$( "#memtest" ), 8 | $submit = $memtest.find( "#runMemtest" ), 9 | 10 | $modelSetSize = $memtest.find( "#modelSetSize" ), 11 | $modelLoop = $memtest.find( "#modelLoop" ), 12 | $collectionLoop = $memtest.find( "#collectionLoop" ), 13 | 14 | $testTypes = $memtest.find( "#testTypes" ), 15 | getTestType = function () { 16 | return String( $testTypes.find( "input[name='testType']:checked" ).val() ); 17 | }, 18 | 19 | msg = function ( text, collection, models ) { 20 | collection || (collection = []); 21 | models || (models = []); 22 | 23 | var msg, 24 | collectionStatus = " Length of collection: " + collection.length, 25 | modelStatus = " Number of models: " + models.length, 26 | lf = "\n", 27 | br = "
"; 28 | 29 | msg = text.replace( lf, br ); 30 | if ( collection.length ) msg += br + collectionStatus; 31 | if ( models.length ) msg += br + modelStatus; 32 | 33 | console.log( msg.replace( br, lf ) ); 34 | $log.append( msg + br ); 35 | }, 36 | 37 | waitCb = function ( collection, models ) { 38 | // NB Verify that collection, model and length props exist. Needed for for IE8. 39 | if ( collection && collection.length && models && models.length ) console.log( "WAIT output: collection.length=" + collection.length + ", models.length=" + models.length ); 40 | msg( "WAIT has ended.\n" ); 41 | }, 42 | 43 | wait = function () { 44 | window.setTimeout( waitCb, 500, arguments[0], arguments[1] ); 45 | }, 46 | 47 | memtest = {}; 48 | 49 | // NB, wait function: 50 | // 51 | // Don't use _.debounce, as in `wait = _.debounce( waitCb, 500 )`. It leaked memory like crazy in these tests, using 52 | // Underscore 1.5.2. I thought I had a leak, turned out I had been chasing ghosts for a couple of hours. Apparently 53 | // it has been fixed and merged months ago (jashkenas/underscore#1329, #1330), but still hasn't made it into a 54 | // release. 55 | 56 | $submit.click( function ( event ) { 57 | var modelSetSize = Number( $modelSetSize.val() ), 58 | modelLoop = Number( $modelLoop.val() ), 59 | collectionLoop = Number( $collectionLoop.val() ); 60 | 61 | event.preventDefault(); 62 | memtest.run( modelSetSize, modelLoop, collectionLoop, getTestType() ); 63 | } ); 64 | 65 | memtest.run = function ( modelSetSize, modelLoop, collectionLoop, testType ) { 66 | var i, j, 67 | Collection = Backbone.Collection.extend( { 68 | exportable: "bar", 69 | bar: function () { 70 | return "bar"; 71 | } 72 | } ), 73 | collection, 74 | Model = Backbone.Model.extend( { 75 | exportable: "foo", 76 | foo: function () { 77 | return "foo"; 78 | } 79 | } ), 80 | models; 81 | 82 | switch ( testType ) { 83 | case "backbone": 84 | msg( "Testing Backbone with exportable methods (no onExport handler)" ); 85 | break; 86 | 87 | case "backbone.onExport": 88 | msg( "Testing Backbone with exportable methods and onExport handler" ); 89 | 90 | Model = Model.extend( { 91 | onExport: function ( data ) { 92 | data.baz = this.baz( "baz" ); 93 | 94 | return data; 95 | }, 96 | baz: function ( arg ) { 97 | return "baz called with arg: " + arg; 98 | } 99 | } ); 100 | 101 | Collection = Collection.extend( { 102 | onExport: function ( data ) { 103 | data.qux = this.qux( "baz" ); 104 | return data; 105 | }, 106 | qux: function ( arg ) { 107 | return "qux called with arg: " + arg; 108 | } 109 | } ); 110 | 111 | break; 112 | 113 | default: 114 | msg( "Invalid test type selected!!!" ); 115 | } 116 | 117 | for ( i = 0; i < modelLoop; i++ ) { 118 | models = []; 119 | for ( j = 0; j < modelSetSize; j++ ) { 120 | models.push( new Model( { id: j, number: j + 1, caption: "I am model #" + ( j + 1 ) } ) ); 121 | } 122 | } 123 | 124 | msg( i + " model sets are created.", undefined, models ); 125 | 126 | collection = { close: function () {} }; 127 | for ( i = 0; i < collectionLoop; i++ ) { 128 | collection = new Collection( models ); 129 | } 130 | 131 | msg( 132 | i + " collections are created.", 133 | collection, models 134 | ); 135 | 136 | msg( "Done.\n----\n" ); 137 | 138 | // Prevent collection and models from being released too early by keeping the references around a bit. Should 139 | // ensure that the profiler captures memory spikes. 140 | wait( collection, models ); 141 | 142 | } 143 | 144 | }( Backbone, _ )); -------------------------------------------------------------------------------- /spec/itemview.test.js: -------------------------------------------------------------------------------- 1 | /*global describe, it */ 2 | (function () { 3 | "use strict"; 4 | 5 | describe( 'Marionette.View (or .ItemView) automatically provides the output of export() to its template.', function () { 6 | 7 | var ItemView; 8 | 9 | beforeEach( function () { 10 | 11 | var MarionetteBaseView = Marionette.ItemView || Marionette.View; // supporting both Marionette 2 and 3 12 | 13 | ItemView = MarionetteBaseView.extend( { 14 | template: function ( injectedData ) { 15 | return _.template( 'some template HTML', injectedData ); 16 | } 17 | } ); 18 | 19 | } ); 20 | 21 | describe( 'When ItemView handles a Backbone model, the render() method of the view', function () { 22 | 23 | it( 'invokes the export() method of the model', function () { 24 | 25 | var Model = Backbone.Model.extend( {} ); 26 | var model = new Model(); 27 | var itemView = new ItemView( { model: model } ); 28 | sinon.spy( model, "export" ); 29 | 30 | itemView.render(); 31 | expect( model["export"] ).to.have.been.calledOnce; 32 | 33 | } ); 34 | 35 | it( 'makes the exported data available to the template', function () { 36 | 37 | var Model = Backbone.Model.extend( { 38 | exportable: "method", 39 | method: function () { return "a method return value"; } 40 | } ); 41 | var model = new Model(); 42 | var itemView = new ItemView( { model: model } ); 43 | sinon.spy( itemView, "template" ); 44 | 45 | var exportedModel = model["export"](); 46 | itemView.render(); 47 | expect( itemView.template ).to.have.been.calledWithExactly( exportedModel ); 48 | 49 | } ); 50 | 51 | } ); 52 | 53 | describe( 'When ItemView handles a Backbone collection, the render() method of the view', function () { 54 | 55 | it( 'invokes the export() method of the collection', function () { 56 | 57 | var Collection = Backbone.Collection.extend( {} ); 58 | var collection = new Collection(); 59 | var itemView = new ItemView( { collection: collection } ); 60 | sinon.spy( collection, "export" ); 61 | 62 | itemView.render(); 63 | expect( collection["export"] ).to.have.been.calledOnce; 64 | 65 | } ); 66 | 67 | it( 'makes the exported methods and properties of the collection available to the template as properties of the "items" array', function () { 68 | 69 | var Model = Backbone.Model.extend( { 70 | exportable: "method", 71 | method: function () { return "a method return value, model cid = " + this.cid; } 72 | } ); 73 | 74 | var Collection = Backbone.Collection.extend( { 75 | exportable: "collectionMethod", 76 | collectionMethod: function () { return "a return value from a collection method"; } 77 | } ); 78 | var collection = new Collection( [ new Model(), new Model(), new Model() ] ); 79 | 80 | var itemView = new ItemView( { collection: collection } ); 81 | sinon.spy( itemView, "template" ); 82 | 83 | itemView.render(); 84 | expect( itemView.template ).to.have.been.calledWithExactly( sinon.match( function ( templateData ) { 85 | return templateData.items && _.isEqual( _.pairs( templateData.items ), _.pairs( collection["export"]() ) ); 86 | } ) ); 87 | // 88 | // NB: _.isEqual does compare arrays, but does NOT pick up any custom properties attached to the array 89 | // objects. That appears to be on purpose, see http://goo.gl/R3WlXu . The docs talk about "an optimized 90 | // deep comparison". To work around it, we convert the arrays into key-value hashes with _.pairs first, 91 | // and then compare those hashes. 92 | 93 | } ); 94 | 95 | it( 'makes the collection array available to the template in an "items" property', function () { 96 | 97 | var Model = Backbone.Model.extend( { 98 | exportable: "method", 99 | method: function () { return "a method return value, model cid = " + this.cid; } 100 | } ); 101 | 102 | var Collection = Backbone.Collection.extend( { 103 | exportable: "collectionMethod", 104 | collectionMethod: function () { return "a return value from a collection method"; } 105 | } ); 106 | var collection = new Collection( [ new Model(), new Model(), new Model() ] ); 107 | 108 | var itemView = new ItemView( { collection: collection } ); 109 | sinon.spy( itemView, "template" ); 110 | 111 | itemView.render(); 112 | expect( itemView.template ).to.have.been.calledWithExactly( sinon.match.has( "items", collection["export"]() ) ); 113 | // 114 | // NB: sinon.match.has does compare the array, but does NOT pick up any custom properties attached to 115 | // the array object. Here, we only verify that the content of the arrays, ie the items in it, are 116 | // identical. We can't verify full equality for the array objects this way - until the patch for it has 117 | // landed in a Sinon release. See https://github.com/cjohansen/Sinon.JS/issues/315 118 | 119 | } ); 120 | 121 | } ); 122 | 123 | } ); 124 | 125 | })(); 126 | -------------------------------------------------------------------------------- /src/backbone.marionette.export.js: -------------------------------------------------------------------------------- 1 | ;( function ( root, factory ) { 2 | "use strict"; 3 | 4 | // UMD for a Backbone plugin. Supports AMD, Node.js, CommonJS and globals. 5 | // 6 | // - Code lives in the Backbone namespace. 7 | // - The module does not export a meaningful value. 8 | // - The module does not create a global. 9 | 10 | var supportsExports = typeof exports === "object" && exports && !exports.nodeType && typeof module === "object" && module && !module.nodeType; 11 | 12 | // AMD: 13 | // - Some AMD build optimizers like r.js check for condition patterns like the AMD check below, so keep it as is. 14 | // - Check for `exports` after `define` in case a build optimizer adds an `exports` object. 15 | // - The AMD spec requires the dependencies to be an array **literal** of module IDs. Don't use a variable there, 16 | // or optimizers may fail. 17 | if ( typeof define === "function" && typeof define.amd === "object" && define.amd ) { 18 | 19 | // AMD module 20 | define( [ "exports", "underscore", "backbone" ], factory ); 21 | 22 | } else if ( supportsExports ) { 23 | 24 | // Node module, CommonJS module 25 | factory( exports, require( "underscore" ), require( "backbone" ) ); 26 | 27 | } else { 28 | 29 | // Global (browser or Rhino) 30 | factory( {}, _, Backbone ); 31 | 32 | } 33 | 34 | }( this, function ( exports, _, Backbone ) { 35 | "use strict"; 36 | 37 | /** 38 | * Captures all properties of an object, including the non-enumerable ones, all the way up the prototype chain. 39 | * Returns them as an array of property names. 40 | * 41 | * In legacy browsers which don't support Object.getOwnPropertyNames, only enumerable properties are returned. 42 | * There is no alternative way to list non-enumerable properties in ES3, which these browsers are based on (see 43 | * http://stackoverflow.com/a/8241423/508355). Listing the enumerable properties is usually good enough, though. 44 | * Affects IE8. 45 | * 46 | * Code lifted from the MDC docs, http://goo.gl/hw2h4G 47 | * 48 | * @param obj 49 | * @returns string[] 50 | */ 51 | function listAllProperties ( obj ) { 52 | 53 | var objectToInspect, 54 | property, 55 | result = []; 56 | 57 | if ( Object.getPrototypeOf && Object.getOwnPropertyNames ) { 58 | 59 | // Modern browser. Return enumerable and non-enumerable properties, all up the prototype chain. 60 | for ( objectToInspect = obj; objectToInspect !== null; objectToInspect = Object.getPrototypeOf( objectToInspect ) ) { 61 | result = result.concat( Object.getOwnPropertyNames( objectToInspect ) ); 62 | } 63 | 64 | } else { 65 | 66 | // Legacy browser. Return enumerable properties only, all up the prototype chain. 67 | for ( property in obj ) result.push( property ); 68 | } 69 | 70 | return _.unique( result ); 71 | } 72 | 73 | // Capture all native array properties. 74 | var nativeArrayProperties = listAllProperties( [] ), 75 | 76 | // Get the Marionette base view (Marionette.ItemView for legacy Marionette, Marionette.View for modern) 77 | MarionetteBaseView = Backbone.Marionette && ( Backbone.Marionette.ItemView || Backbone.Marionette.View ); 78 | 79 | /** 80 | * Is called before export(). Use it to manipulate or add state before export. No-op by default, implement as 81 | * needed. 82 | */ 83 | Backbone.Model.prototype.onBeforeExport = Backbone.Collection.prototype.onBeforeExport = function () { 84 | // noop by default. 85 | }; 86 | 87 | /** 88 | * Is called after export(). No-op by default, implement as needed. 89 | */ 90 | Backbone.Model.prototype.onAfterExport = Backbone.Collection.prototype.onAfterExport = function () { 91 | // noop by default. 92 | }; 93 | 94 | /** 95 | * Is called on export and handed the data hash intended for export. It can manipulate or add to the data and must 96 | * return it afterwards. 97 | * 98 | * The method is a no-op by default, returns the data unmodified. Implement as needed. 99 | * 100 | * There is no need to call the methods which have been specified in options.export. They have already been baked 101 | * into properties and are part of the data hash. Rather, onExport is intended for calling methods with a arguments 102 | * (those can't be passed to options.export) and for more complex manipulation tasks. 103 | * 104 | * @param data 105 | */ 106 | Backbone.Model.prototype.onExport = Backbone.Collection.prototype.onExport = function ( data ) { 107 | return data; 108 | }; 109 | 110 | Backbone.Model.prototype["export"] = Backbone.Collection.prototype["export"] = function () { 111 | var data, exportable, conflicts, hops; 112 | 113 | function allowExport ( obj ) { 114 | return ( 115 | obj && obj["export"] && 116 | ( obj instanceof Backbone.Model || obj instanceof Backbone.Collection ) && 117 | hops < obj["export"].global.maxHops 118 | ); 119 | } 120 | 121 | hops = arguments.length ? arguments[0] : 0; 122 | 123 | // Before all else, run the onBeforeExport handler. 124 | if ( this.onBeforeExport ) this.onBeforeExport(); 125 | 126 | // Get the Model or Collection data just like Marionette does it. 127 | if ( this instanceof Backbone.Collection ) { 128 | 129 | // Collection: Map the array of models to an array of exported model hashes. This is the only thing 130 | // Marionette does, out of the box, except that it calls model.toJSON for the transformation. 131 | // 132 | // We use the enhancements of model.export instead. But still, we get no more than an array of model hashes 133 | // at this point. 134 | data = this.map( function ( model ) { return allowExport( model ) ? model["export"]( hops + 1 ) : model; } ); 135 | 136 | } else { 137 | 138 | // Model 139 | 140 | if ( _.cloneDeep ) { 141 | 142 | // With Lo-dash / deep-cloning ability: Deep clone the model attributes, calling export() on nested 143 | // Backbone models and collections in the process (up to the maximum recursion depth, then switching to 144 | // cloning without calls to export() ). 145 | data = _.cloneDeep( this.attributes, function ( attribute ) { 146 | return allowExport( attribute ) ? attribute["export"]( hops + 1 ) : undefined; } 147 | ); 148 | 149 | } else { 150 | 151 | // Model: Get the model properties for export to the template. This is the only thing Marionette does, out 152 | // of the box. 153 | data = this.toJSON(); // this is the same as _.clone(this.attributes); 154 | 155 | // Call export() recursively on attributes holding a Backbone model or collection, up to the maximum 156 | // recursion depth. 157 | _.each( data, function ( attrValue, attrName, data ) { 158 | if ( allowExport( attrValue ) ) data[attrName] = attrValue["export"]( hops + 1 ); 159 | } ); 160 | 161 | } 162 | 163 | } 164 | 165 | // Call the methods which are defined in the `exportable` property. Attach the result of each call to the 166 | // exported data, setting the property name to that of the method. 167 | if ( this.exportable ) { 168 | 169 | exportable = this.exportable; 170 | if ( ! _.isArray( exportable ) ) exportable = exportable ? [ exportable ] : []; 171 | 172 | _.each( exportable, function( method ) { 173 | 174 | var name, 175 | 176 | // The configuration can be read off either the Model or Collection prototype; 177 | // both reference the same object. 178 | strictMode = this["export"].global.strict; 179 | 180 | if ( _.isUndefined( method ) ) throw new Error( "Can't export method. Undefined method reference" ); 181 | 182 | if ( _.isString( method ) ) { 183 | 184 | // Normalize the method name and get the method reference from the name. 185 | name = method.indexOf( "this." ) === 0 ? method.substr( 5 ) : method; 186 | if ( ! ( name in this ) && strictMode ) throw new Error( "Can't export \"" + name + "\". The method doesn't exist" ); 187 | method = this[name]; 188 | 189 | } else { 190 | throw new Error( "'exportable' property: Invalid method identifier" ); 191 | } 192 | 193 | if ( _.isFunction( method )) { 194 | 195 | // Call the method and turn it into a property of the exported object. 196 | data[name] = method.apply( this ); 197 | 198 | } else { 199 | 200 | if ( this instanceof Backbone.Model && strictMode ) { 201 | 202 | // Model: Only act on a real method. Here, `method` is a reference to an ordinary property, ie 203 | // one which is not a function. Throw an error because a reference of that kind is likely to be 204 | // a mistake, or else bad design. 205 | // 206 | // Model data must be created with Model.set and must not be handled here. It is captured by 207 | // toJSON() and thus available to the templates anyway. 208 | throw new Error( "'exportable' property: Invalid method identifier \"" + name + "\", does not point to a function" ); 209 | 210 | } else { 211 | 212 | // Collection: Export an ordinary, non-function property. There isn't a native way to make a 213 | // collection property available to templates, so exporting it is legit. 214 | data[name] = this[name]; 215 | 216 | } 217 | 218 | } 219 | 220 | // Call export() recursively if the property holds a Backbone model or collection, up to the maximum 221 | // recursion depth. 222 | if ( _.cloneDeep ) { 223 | 224 | // With Lo-dash / deep-cloning ability: clone other objects, too, and also call export on Backbone 225 | // models or collections deeply nested within those objects. 226 | data[name] = _.cloneDeep( data[name], function ( value ) { 227 | return allowExport( value ) ? value["export"]( hops + 1 ) : undefined; 228 | } ); 229 | 230 | } else { 231 | if ( allowExport( data[name] ) ) data[name] = data[name]["export"]( hops + 1 ); 232 | } 233 | 234 | // Discard undefined values. According to the spec, valid JSON does not represent undefined values. 235 | if ( _.isUndefined( data[name] ) ) delete data[name]; 236 | 237 | }, this ); 238 | } 239 | 240 | // Run the onExport handler to modify/finalize the data if needed. 241 | if ( this.onExport ) data = this.onExport( data ); 242 | 243 | // Trigger the onAfterExport handler just before returning. 244 | if ( this.onAfterExport ) this.onAfterExport(); 245 | 246 | // Collection: 247 | // The exported collection is simply an array (of model hashes). But the native array object is augmented with 248 | // properties created by the export. 249 | // 250 | // These properties must not be allowed to overwrite native array methods or properties. Check the exported 251 | // property names and throw an error if they clash with the native ones. 252 | if ( this instanceof Backbone.Collection ) { 253 | 254 | conflicts = _.intersection( nativeArrayProperties, _.keys( data ) ) ; 255 | if ( conflicts.length ) { 256 | throw new Error( "Can't export a property with a name which is reserved for a native array property. Offending properties: " + conflicts.join( ", " ) ); 257 | } 258 | 259 | } 260 | 261 | return data; 262 | }; 263 | 264 | Backbone.Model.prototype["export"].global = Backbone.Collection.prototype["export"].global = { 265 | maxHops: 4, 266 | strict: false, 267 | version: "__COMPONENT_VERSION_PLACEHOLDER__" 268 | }; 269 | 270 | if ( Backbone.Marionette ) { 271 | 272 | MarionetteBaseView.prototype.serializeData = Backbone.Marionette.CompositeView.prototype.serializeData = function () { 273 | // Largely duplicating the original serializeData() method in Marionette.ItemView, but using Model.export 274 | // instead of Model.toJSON as a data source if Model.export is available. Ditto for Collection.export. 275 | // 276 | // For the original code, taken from Marionette 1.0.4, see 277 | // https://github.com/marionettejs/backbone.marionette/blob/v1.0.4/src/marionette.itemview.js#L21 278 | 279 | var data = {}; 280 | 281 | if ( this.model ) { 282 | data = this.model["export"] && this.model["export"]() || this.model.toJSON(); 283 | } 284 | else if ( this.collection ) { 285 | data = { items: this.collection["export"] && this.collection["export"]() || this.collection.toJSON() }; 286 | } 287 | 288 | return data; 289 | }; 290 | 291 | } 292 | 293 | 294 | // Module return value 295 | // ------------------- 296 | // 297 | // A return value may be necessary for AMD to detect that the module is loaded. It ony exists for that reason and is 298 | // purely symbolic. Don't use it in client code. The functionality of this module lives in the Backbone namespace. 299 | exports.info = "Backbone.Marionette.Export has loaded. Don't use the exported value of the module. Its functionality is available inside the Backbone namespace."; 300 | 301 | } ) ); 302 | 303 | -------------------------------------------------------------------------------- /dist/backbone.marionette.export.js: -------------------------------------------------------------------------------- 1 | // Backbone.Marionette.Export, v3.0.0 2 | // Copyright (c) 2013-2016 Michael Heim, Zeilenwechsel.de 3 | // Distributed under MIT license 4 | // http://github.com/hashchange/backbone.marionette.export 5 | 6 | ;( function ( root, factory ) { 7 | "use strict"; 8 | 9 | // UMD for a Backbone plugin. Supports AMD, Node.js, CommonJS and globals. 10 | // 11 | // - Code lives in the Backbone namespace. 12 | // - The module does not export a meaningful value. 13 | // - The module does not create a global. 14 | 15 | var supportsExports = typeof exports === "object" && exports && !exports.nodeType && typeof module === "object" && module && !module.nodeType; 16 | 17 | // AMD: 18 | // - Some AMD build optimizers like r.js check for condition patterns like the AMD check below, so keep it as is. 19 | // - Check for `exports` after `define` in case a build optimizer adds an `exports` object. 20 | // - The AMD spec requires the dependencies to be an array **literal** of module IDs. Don't use a variable there, 21 | // or optimizers may fail. 22 | if ( typeof define === "function" && typeof define.amd === "object" && define.amd ) { 23 | 24 | // AMD module 25 | define( [ "exports", "underscore", "backbone" ], factory ); 26 | 27 | } else if ( supportsExports ) { 28 | 29 | // Node module, CommonJS module 30 | factory( exports, require( "underscore" ), require( "backbone" ) ); 31 | 32 | } else { 33 | 34 | // Global (browser or Rhino) 35 | factory( {}, _, Backbone ); 36 | 37 | } 38 | 39 | }( this, function ( exports, _, Backbone ) { 40 | "use strict"; 41 | 42 | /** 43 | * Captures all properties of an object, including the non-enumerable ones, all the way up the prototype chain. 44 | * Returns them as an array of property names. 45 | * 46 | * In legacy browsers which don't support Object.getOwnPropertyNames, only enumerable properties are returned. 47 | * There is no alternative way to list non-enumerable properties in ES3, which these browsers are based on (see 48 | * http://stackoverflow.com/a/8241423/508355). Listing the enumerable properties is usually good enough, though. 49 | * Affects IE8. 50 | * 51 | * Code lifted from the MDC docs, http://goo.gl/hw2h4G 52 | * 53 | * @param obj 54 | * @returns string[] 55 | */ 56 | function listAllProperties ( obj ) { 57 | 58 | var objectToInspect, 59 | property, 60 | result = []; 61 | 62 | if ( Object.getPrototypeOf && Object.getOwnPropertyNames ) { 63 | 64 | // Modern browser. Return enumerable and non-enumerable properties, all up the prototype chain. 65 | for ( objectToInspect = obj; objectToInspect !== null; objectToInspect = Object.getPrototypeOf( objectToInspect ) ) { 66 | result = result.concat( Object.getOwnPropertyNames( objectToInspect ) ); 67 | } 68 | 69 | } else { 70 | 71 | // Legacy browser. Return enumerable properties only, all up the prototype chain. 72 | for ( property in obj ) result.push( property ); 73 | } 74 | 75 | return _.unique( result ); 76 | } 77 | 78 | // Capture all native array properties. 79 | var nativeArrayProperties = listAllProperties( [] ), 80 | 81 | // Get the Marionette base view (Marionette.ItemView for legacy Marionette, Marionette.View for modern) 82 | MarionetteBaseView = Backbone.Marionette && ( Backbone.Marionette.ItemView || Backbone.Marionette.View ); 83 | 84 | /** 85 | * Is called before export(). Use it to manipulate or add state before export. No-op by default, implement as 86 | * needed. 87 | */ 88 | Backbone.Model.prototype.onBeforeExport = Backbone.Collection.prototype.onBeforeExport = function () { 89 | // noop by default. 90 | }; 91 | 92 | /** 93 | * Is called after export(). No-op by default, implement as needed. 94 | */ 95 | Backbone.Model.prototype.onAfterExport = Backbone.Collection.prototype.onAfterExport = function () { 96 | // noop by default. 97 | }; 98 | 99 | /** 100 | * Is called on export and handed the data hash intended for export. It can manipulate or add to the data and must 101 | * return it afterwards. 102 | * 103 | * The method is a no-op by default, returns the data unmodified. Implement as needed. 104 | * 105 | * There is no need to call the methods which have been specified in options.export. They have already been baked 106 | * into properties and are part of the data hash. Rather, onExport is intended for calling methods with a arguments 107 | * (those can't be passed to options.export) and for more complex manipulation tasks. 108 | * 109 | * @param data 110 | */ 111 | Backbone.Model.prototype.onExport = Backbone.Collection.prototype.onExport = function ( data ) { 112 | return data; 113 | }; 114 | 115 | Backbone.Model.prototype["export"] = Backbone.Collection.prototype["export"] = function () { 116 | var data, exportable, conflicts, hops; 117 | 118 | function allowExport ( obj ) { 119 | return ( 120 | obj && obj["export"] && 121 | ( obj instanceof Backbone.Model || obj instanceof Backbone.Collection ) && 122 | hops < obj["export"].global.maxHops 123 | ); 124 | } 125 | 126 | hops = arguments.length ? arguments[0] : 0; 127 | 128 | // Before all else, run the onBeforeExport handler. 129 | if ( this.onBeforeExport ) this.onBeforeExport(); 130 | 131 | // Get the Model or Collection data just like Marionette does it. 132 | if ( this instanceof Backbone.Collection ) { 133 | 134 | // Collection: Map the array of models to an array of exported model hashes. This is the only thing 135 | // Marionette does, out of the box, except that it calls model.toJSON for the transformation. 136 | // 137 | // We use the enhancements of model.export instead. But still, we get no more than an array of model hashes 138 | // at this point. 139 | data = this.map( function ( model ) { return allowExport( model ) ? model["export"]( hops + 1 ) : model; } ); 140 | 141 | } else { 142 | 143 | // Model 144 | 145 | if ( _.cloneDeep ) { 146 | 147 | // With Lo-dash / deep-cloning ability: Deep clone the model attributes, calling export() on nested 148 | // Backbone models and collections in the process (up to the maximum recursion depth, then switching to 149 | // cloning without calls to export() ). 150 | data = _.cloneDeep( this.attributes, function ( attribute ) { 151 | return allowExport( attribute ) ? attribute["export"]( hops + 1 ) : undefined; } 152 | ); 153 | 154 | } else { 155 | 156 | // Model: Get the model properties for export to the template. This is the only thing Marionette does, out 157 | // of the box. 158 | data = this.toJSON(); // this is the same as _.clone(this.attributes); 159 | 160 | // Call export() recursively on attributes holding a Backbone model or collection, up to the maximum 161 | // recursion depth. 162 | _.each( data, function ( attrValue, attrName, data ) { 163 | if ( allowExport( attrValue ) ) data[attrName] = attrValue["export"]( hops + 1 ); 164 | } ); 165 | 166 | } 167 | 168 | } 169 | 170 | // Call the methods which are defined in the `exportable` property. Attach the result of each call to the 171 | // exported data, setting the property name to that of the method. 172 | if ( this.exportable ) { 173 | 174 | exportable = this.exportable; 175 | if ( ! _.isArray( exportable ) ) exportable = exportable ? [ exportable ] : []; 176 | 177 | _.each( exportable, function( method ) { 178 | 179 | var name, 180 | 181 | // The configuration can be read off either the Model or Collection prototype; 182 | // both reference the same object. 183 | strictMode = this["export"].global.strict; 184 | 185 | if ( _.isUndefined( method ) ) throw new Error( "Can't export method. Undefined method reference" ); 186 | 187 | if ( _.isString( method ) ) { 188 | 189 | // Normalize the method name and get the method reference from the name. 190 | name = method.indexOf( "this." ) === 0 ? method.substr( 5 ) : method; 191 | if ( ! ( name in this ) && strictMode ) throw new Error( "Can't export \"" + name + "\". The method doesn't exist" ); 192 | method = this[name]; 193 | 194 | } else { 195 | throw new Error( "'exportable' property: Invalid method identifier" ); 196 | } 197 | 198 | if ( _.isFunction( method )) { 199 | 200 | // Call the method and turn it into a property of the exported object. 201 | data[name] = method.apply( this ); 202 | 203 | } else { 204 | 205 | if ( this instanceof Backbone.Model && strictMode ) { 206 | 207 | // Model: Only act on a real method. Here, `method` is a reference to an ordinary property, ie 208 | // one which is not a function. Throw an error because a reference of that kind is likely to be 209 | // a mistake, or else bad design. 210 | // 211 | // Model data must be created with Model.set and must not be handled here. It is captured by 212 | // toJSON() and thus available to the templates anyway. 213 | throw new Error( "'exportable' property: Invalid method identifier \"" + name + "\", does not point to a function" ); 214 | 215 | } else { 216 | 217 | // Collection: Export an ordinary, non-function property. There isn't a native way to make a 218 | // collection property available to templates, so exporting it is legit. 219 | data[name] = this[name]; 220 | 221 | } 222 | 223 | } 224 | 225 | // Call export() recursively if the property holds a Backbone model or collection, up to the maximum 226 | // recursion depth. 227 | if ( _.cloneDeep ) { 228 | 229 | // With Lo-dash / deep-cloning ability: clone other objects, too, and also call export on Backbone 230 | // models or collections deeply nested within those objects. 231 | data[name] = _.cloneDeep( data[name], function ( value ) { 232 | return allowExport( value ) ? value["export"]( hops + 1 ) : undefined; 233 | } ); 234 | 235 | } else { 236 | if ( allowExport( data[name] ) ) data[name] = data[name]["export"]( hops + 1 ); 237 | } 238 | 239 | // Discard undefined values. According to the spec, valid JSON does not represent undefined values. 240 | if ( _.isUndefined( data[name] ) ) delete data[name]; 241 | 242 | }, this ); 243 | } 244 | 245 | // Run the onExport handler to modify/finalize the data if needed. 246 | if ( this.onExport ) data = this.onExport( data ); 247 | 248 | // Trigger the onAfterExport handler just before returning. 249 | if ( this.onAfterExport ) this.onAfterExport(); 250 | 251 | // Collection: 252 | // The exported collection is simply an array (of model hashes). But the native array object is augmented with 253 | // properties created by the export. 254 | // 255 | // These properties must not be allowed to overwrite native array methods or properties. Check the exported 256 | // property names and throw an error if they clash with the native ones. 257 | if ( this instanceof Backbone.Collection ) { 258 | 259 | conflicts = _.intersection( nativeArrayProperties, _.keys( data ) ) ; 260 | if ( conflicts.length ) { 261 | throw new Error( "Can't export a property with a name which is reserved for a native array property. Offending properties: " + conflicts.join( ", " ) ); 262 | } 263 | 264 | } 265 | 266 | return data; 267 | }; 268 | 269 | Backbone.Model.prototype["export"].global = Backbone.Collection.prototype["export"].global = { 270 | maxHops: 4, 271 | strict: false, 272 | version: "3.0.0" 273 | }; 274 | 275 | if ( Backbone.Marionette ) { 276 | 277 | MarionetteBaseView.prototype.serializeData = Backbone.Marionette.CompositeView.prototype.serializeData = function () { 278 | // Largely duplicating the original serializeData() method in Marionette.ItemView, but using Model.export 279 | // instead of Model.toJSON as a data source if Model.export is available. Ditto for Collection.export. 280 | // 281 | // For the original code, taken from Marionette 1.0.4, see 282 | // https://github.com/marionettejs/backbone.marionette/blob/v1.0.4/src/marionette.itemview.js#L21 283 | 284 | var data = {}; 285 | 286 | if ( this.model ) { 287 | data = this.model["export"] && this.model["export"]() || this.model.toJSON(); 288 | } 289 | else if ( this.collection ) { 290 | data = { items: this.collection["export"] && this.collection["export"]() || this.collection.toJSON() }; 291 | } 292 | 293 | return data; 294 | }; 295 | 296 | } 297 | 298 | 299 | // Module return value 300 | // ------------------- 301 | // 302 | // A return value may be necessary for AMD to detect that the module is loaded. It ony exists for that reason and is 303 | // purely symbolic. Don't use it in client code. The functionality of this module lives in the Backbone namespace. 304 | exports.info = "Backbone.Marionette.Export has loaded. Don't use the exported value of the module. Its functionality is available inside the Backbone namespace."; 305 | 306 | } ) ); 307 | 308 | -------------------------------------------------------------------------------- /Gruntfile.js: -------------------------------------------------------------------------------- 1 | /*global module:false*/ 2 | module.exports = function (grunt) { 3 | 4 | var LIVERELOAD_PORT = 35731, 5 | HTTP_PORT = 9400, 6 | KARMA_PORT = 9877, 7 | WATCHED_FILES_SRC = [ 8 | 'src/**/*' 9 | ], 10 | WATCHED_FILES_SPEC = [ 11 | 'spec/**/*' 12 | ], 13 | WATCHED_FILES_DIST = [ 14 | 'dist/**/*' 15 | ], 16 | WATCHED_FILES_DEMO = [ 17 | 'demo/**/*' 18 | ], 19 | 20 | SINON_SOURCE_DIR = 'node_modules/karma-chai-plugins/node_modules/sinon/lib/sinon/', 21 | 22 | path = require( "path" ), 23 | requireFromString = require( "require-from-string" ), 24 | 25 | /** 26 | * Receives an object and the name of a path property on that object. Translates the path property to a new path, 27 | * based on a directory prefix. Does not return anything, modifies the object itself. 28 | * 29 | * The directory prefix can be relative (e.g. "../../"). It may or may not end in a slash. 30 | * 31 | * @param {string} dirPrefix 32 | * @param {Object} object 33 | * @param {string} propertyName 34 | * @param {boolean} [verbose=false] 35 | */ 36 | translatePathProperty = function ( dirPrefix, object, propertyName, verbose ) { 37 | var originalPath = object[propertyName]; 38 | 39 | if ( originalPath ) { 40 | object[propertyName] = path.normalize( dirPrefix + path.sep + originalPath ); 41 | if ( verbose ) grunt.log.writeln( 'Translating path property "' + propertyName + '": ' + originalPath + " => " + object[propertyName] ); 42 | } 43 | }, 44 | 45 | /** 46 | * Reads an r.js build profile and returns it as an options set for a grunt-contrib-requirejs task. 47 | * 48 | * For a discussion, see https://github.com/gruntjs/grunt-contrib-requirejs/issues/13 49 | * 50 | * Paths in the build profile are relative to the profile location. In the returned options object, they are 51 | * transformed to be relative to the Gruntfile. (The list is nowhere near complete. More properties need to be 52 | * transformed as build profiles become more complex.) 53 | * 54 | * @param {string} profilePath relative to the Gruntfile 55 | * @param {boolean} [verbose=false] 56 | * @returns {Object} 57 | */ 58 | getRequirejsBuildProfile = function ( profilePath, verbose ) { 59 | var profileContent = grunt.file.read( profilePath ), 60 | profile = requireFromString( "module.exports = " + profileContent ), 61 | 62 | dirPrefix = path.dirname( profilePath ); 63 | 64 | if ( verbose ) grunt.log.writeln( "Loading r.js build profile " + profilePath ); 65 | 66 | // Add more paths here as needed. 67 | translatePathProperty( dirPrefix, profile, "mainConfigFile", verbose ); 68 | translatePathProperty( dirPrefix, profile, "out", verbose ); 69 | 70 | if ( verbose ) grunt.log.writeln(); 71 | 72 | return profile; 73 | }; 74 | 75 | // Project configuration. 76 | grunt.config.init({ 77 | pkg: grunt.file.readJSON('package.json'), 78 | meta: { 79 | version: '<%= pkg.version %>', 80 | banner: '// Backbone.Marionette.Export, v<%= meta.version %>\n' + 81 | '// Copyright (c) 2013-<%= grunt.template.today("yyyy") %> Michael Heim, Zeilenwechsel.de\n' + 82 | '// Distributed under MIT license\n' + 83 | '// http://github.com/hashchange/backbone.marionette.export\n' + 84 | '\n' 85 | }, 86 | 87 | preprocess: { 88 | build: { 89 | files: { 90 | 'dist/backbone.marionette.export.js' : 'src/backbone.marionette.export.js' 91 | } 92 | }, 93 | interactive: { 94 | files: { 95 | 'web-mocha/index.html': 'web-mocha/_index.html' 96 | } 97 | } 98 | }, 99 | 100 | concat: { 101 | options: { 102 | banner: "<%= meta.banner %>", 103 | process: function( src, filepath ) { 104 | var bowerVersion = grunt.file.readJSON( "bower.json" ).version, 105 | npmVersion = grunt.file.readJSON( "package.json" ).version; 106 | 107 | if ( npmVersion === undefined || npmVersion === "" ) grunt.fail.fatal( "Version number not specified in package.json. Specify it in bower.json and package.json" ); 108 | if ( npmVersion !== bowerVersion ) grunt.fail.fatal( "Version numbers in package.json and bower.json are not identical. Make them match." + " " + npmVersion ); 109 | if ( ! /^\d+\.\d+.\d+$/.test( npmVersion ) ) grunt.fail.fatal( 'Version numbers in package.json and bower.json are not semantic. Provide a version number in the format n.n.n, e.g "1.2.3"' ); 110 | return src.replace( "__COMPONENT_VERSION_PLACEHOLDER__", npmVersion ); 111 | } 112 | }, 113 | build: { 114 | src: 'dist/backbone.marionette.export.js', 115 | dest: 'dist/backbone.marionette.export.js' 116 | } 117 | }, 118 | 119 | uglify: { 120 | options: { 121 | banner: "<%= meta.banner %>", 122 | mangle: { 123 | except: ['jQuery', 'Zepto', 'Backbone', '_'] 124 | }, 125 | sourceMap: true 126 | }, 127 | core: { 128 | src: 'dist/backbone.marionette.export.js', 129 | dest: 'dist/backbone.marionette.export.min.js' 130 | } 131 | }, 132 | 133 | karma: { 134 | options: { 135 | configFile: 'karma.conf.js', 136 | browsers: ['PhantomJS'], 137 | port: KARMA_PORT 138 | }, 139 | test: { 140 | reporters: ['progress'], 141 | singleRun: true 142 | }, 143 | build: { 144 | reporters: ['progress'], 145 | singleRun: true 146 | } 147 | }, 148 | 149 | jshint: { 150 | components: { 151 | // Workaround for merging .jshintrc with Gruntfile options, see http://goo.gl/Of8QoR 152 | options: grunt.util._.merge({ 153 | globals: { 154 | // Add vars which are shared between various sub-components 155 | // (before concatenation makes them local) 156 | } 157 | }, grunt.file.readJSON('.jshintrc')), 158 | files: { 159 | src: ['src/**/*.js'] 160 | } 161 | }, 162 | concatenated: { 163 | options: grunt.util._.merge({ 164 | // Suppressing 'W034: Unnecessary directive "use strict"'. 165 | // Redundant nested "use strict" is ok in concatenated file, 166 | // no adverse effects. 167 | '-W034': true 168 | }, grunt.file.readJSON('.jshintrc')), 169 | files: { 170 | src: 'dist/**/backbone.marionette.export.js' 171 | } 172 | } 173 | }, 174 | 175 | 'sails-linker': { 176 | options: { 177 | startTag: '', 178 | endTag: '', 179 | fileTmpl: '', 180 | // relative doesn't seem to have any effect, ever 181 | relative: true, 182 | // appRoot is a misnomer for "strip out this prefix from the file path before inserting", 183 | // should be stripPrefix 184 | appRoot: '' 185 | }, 186 | interactive_spec: { 187 | options: { 188 | startTag: '', 189 | endTag: '' 190 | }, 191 | files: { 192 | // the target file is changed in place; for generating copies, run preprocess first 193 | 'web-mocha/index.html': ['spec/**/*.+(spec|test|tests).js'] 194 | } 195 | }, 196 | interactive_sinon: { 197 | options: { 198 | startTag: '', 199 | endTag: '' 200 | }, 201 | files: { 202 | // the target file is changed in place; for generating copies, run preprocess first 203 | // 204 | // The util/core.js file must be loaded first, and typeof.js must be loaded before match.js. 205 | // 206 | // mock.js must be loaded last (specifically, after spy.js). For the pattern achieving it, see 207 | // http://gruntjs.com/configuring-tasks#globbing-patterns 208 | 'web-mocha/index.html': [ 209 | SINON_SOURCE_DIR + 'util/core.js', 210 | SINON_SOURCE_DIR + 'typeof.js', 211 | SINON_SOURCE_DIR + '**/*.js', 212 | '!' + SINON_SOURCE_DIR + 'mock.js', 213 | SINON_SOURCE_DIR + 'mock.js' 214 | ] 215 | } 216 | } 217 | }, 218 | 219 | requirejs : { 220 | unifiedBuild : { 221 | options : getRequirejsBuildProfile( 'demo/amd/rjs/config/unified/build-config.js', false ) 222 | }, 223 | splitBuildVendor : { 224 | options : getRequirejsBuildProfile( 'demo/amd/rjs/config/jsbin-parts/vendor-config.js', false ) 225 | }, 226 | splitBuildApp : { 227 | options : getRequirejsBuildProfile( 'demo/amd/rjs/config/jsbin-parts/app-config.js', false ) 228 | } 229 | }, 230 | 231 | // Use focus to run Grunt watch with a hand-picked set of simultaneous watch targets. 232 | focus: { 233 | demo: { 234 | include: ['livereloadDemo'] 235 | }, 236 | demoCi: { 237 | include: ['build', 'livereloadDemo'] 238 | }, 239 | demoCiDirty: { 240 | include: ['buildDirty', 'livereloadDemo'] 241 | } 242 | }, 243 | 244 | // Use watch to monitor files for changes, and to kick off a task then. 245 | watch: { 246 | options: { 247 | nospawn: false 248 | }, 249 | // Live-reloads the web page when the source files or the spec files change. Meant for test pages. 250 | livereloadTest: { 251 | options: { 252 | livereload: LIVERELOAD_PORT 253 | }, 254 | files: WATCHED_FILES_SRC.concat( WATCHED_FILES_SPEC ) 255 | }, 256 | // Live-reloads the web page when the dist files or the demo files change. Meant for demo pages. 257 | livereloadDemo: { 258 | options: { 259 | livereload: LIVERELOAD_PORT 260 | }, 261 | files: WATCHED_FILES_DEMO.concat( WATCHED_FILES_DIST ) 262 | }, 263 | // Runs the "build" task (ie, runs linter and tests, then compiles the dist files) when the source files or the 264 | // spec files change. Meant for continuous integration tasks ("ci", "demo-ci"). 265 | build: { 266 | tasks: ['build'], 267 | files: WATCHED_FILES_SRC.concat( WATCHED_FILES_SPEC ) 268 | }, 269 | // Runs the "build-dirty" task (ie, compiles the dist files without running linter and tests) when the source 270 | // files change. Meant for "dirty" continuous integration tasks ("ci-dirty", "demo-ci-dirty"). 271 | buildDirty: { 272 | tasks: ['build-dirty'], 273 | files: WATCHED_FILES_SRC 274 | } 275 | }, 276 | 277 | // Spins up a web server. 278 | connect: { 279 | options: { 280 | port: HTTP_PORT, 281 | // For restricting access to localhost only, change the hostname from '*' to 'localhost' 282 | hostname: '*', 283 | open: true, 284 | base: '.' 285 | }, 286 | livereload: { 287 | livereload: LIVERELOAD_PORT 288 | }, 289 | test: { 290 | options: { 291 | open: 'http://localhost:<%= connect.options.port %>/web-mocha/', 292 | livereload: LIVERELOAD_PORT 293 | } 294 | }, 295 | testNoReload: { 296 | options: { 297 | open: 'http://localhost:<%= connect.options.port %>/web-mocha/', 298 | keepalive: true 299 | } 300 | }, 301 | demo: { 302 | options: { 303 | open: 'http://localhost:<%= connect.options.port %>/demo/', 304 | livereload: LIVERELOAD_PORT 305 | } 306 | } 307 | }, 308 | 309 | replace: { 310 | version: { 311 | src: ['bower.json', 'package.json'], 312 | overwrite: true, 313 | replacements: [{ 314 | from: /"version"\s*:\s*"((\d+\.\d+\.)(\d+))"\s*,/, 315 | to: function (matchedWord, index, fullText, regexMatches) { 316 | var version = grunt.option('inc') ? regexMatches[1] + (parseInt(regexMatches[2], 10) + 1) : grunt.option('to'); 317 | 318 | if (version === undefined) grunt.fail.fatal('Version number not specified. Use the --to option, e.g. --to=1.2.3, or the --inc option to increment the revision'); 319 | if (typeof version !== "string") grunt.fail.fatal('Version number is not a string. Provide a semantic version number, e.g. --to=1.2.3'); 320 | if (!/^\d+\.\d+.\d+$/.test(version)) grunt.fail.fatal('Version number is not semantic. Provide a version number in the format n.n.n, e.g. --to=1.2.3'); 321 | 322 | grunt.log.writeln('Modifying file: Changing the version number from ' + regexMatches[0] + ' to ' + version); 323 | return '"version": "' + version + '",'; 324 | } 325 | }] 326 | } 327 | }, 328 | getver: { 329 | files: ['bower.json', 'package.json'] 330 | } 331 | }); 332 | 333 | grunt.loadNpmTasks('grunt-preprocess'); 334 | grunt.loadNpmTasks('grunt-contrib-concat'); 335 | grunt.loadNpmTasks('grunt-contrib-jshint'); 336 | grunt.loadNpmTasks('grunt-contrib-uglify'); 337 | grunt.loadNpmTasks('grunt-karma'); 338 | grunt.loadNpmTasks('grunt-contrib-watch'); 339 | grunt.loadNpmTasks('grunt-contrib-connect'); 340 | grunt.loadNpmTasks('grunt-sails-linker'); 341 | grunt.loadNpmTasks('grunt-text-replace'); 342 | grunt.loadNpmTasks('grunt-contrib-requirejs'); 343 | grunt.loadNpmTasks('grunt-focus'); 344 | 345 | grunt.registerTask('lint', ['jshint:components']); 346 | grunt.registerTask('hint', ['jshint:components']); // alias 347 | grunt.registerTask('test', ['jshint:components', 'karma:test']); 348 | grunt.registerTask('webtest', ['preprocess:interactive', 'sails-linker:interactive_sinon', 'sails-linker:interactive_spec', 'connect:testNoReload']); 349 | grunt.registerTask('interactive', ['preprocess:interactive', 'sails-linker:interactive_sinon', 'sails-linker:interactive_spec', 'connect:test', 'watch:livereloadTest']); 350 | grunt.registerTask('demo', ['connect:demo', 'focus:demo']); 351 | grunt.registerTask('build', ['jshint:components', 'karma:build', 'preprocess:build', 'concat', 'uglify', 'jshint:concatenated', 'requirejs']); 352 | grunt.registerTask('ci', ['build', 'watch:build']); 353 | grunt.registerTask('setver', ['replace:version']); 354 | grunt.registerTask('getver', function () { 355 | grunt.config.get('getver.files').forEach(function (file) { 356 | var config = grunt.file.readJSON(file); 357 | grunt.log.writeln('Version number in ' + file + ': ' + config.version); 358 | }); 359 | }); 360 | 361 | // Special tasks, not mentioned in Readme documentation: 362 | // 363 | // - requirejs: 364 | // creates build files for the AMD demo with r.js 365 | // - build-dirty: 366 | // builds the project without running checks (no linter, no tests) 367 | // - ci-dirty: 368 | // builds the project without running checks (no linter, no tests) on every source change 369 | // - demo-ci: 370 | // Runs the demo (= "demo" task), and also rebuilds the project on every source change (= "ci" task) 371 | // - demo-ci-dirty: 372 | // Runs the demo (= "demo" task), and also rebuilds the project "dirty", without tests or linter, on every source 373 | // change (= "ci-dirty" task) 374 | grunt.registerTask('build-dirty', ['preprocess:build', 'concat', 'uglify', 'requirejs']); 375 | grunt.registerTask('ci-dirty', ['build-dirty', 'watch:buildDirty']); 376 | grunt.registerTask('demo-ci', ['build', 'connect:demo', 'focus:demoCi']); 377 | grunt.registerTask('demo-ci-dirty', ['build-dirty', 'connect:demo', 'focus:demoCiDirty']); 378 | 379 | // Make 'build' the default task. 380 | grunt.registerTask('default', ['build']); 381 | 382 | 383 | }; 384 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Backbone.Marionette.Export 2 | 3 | Backbone.Marionette.Export is a plugin for [Backbone][], and specifically targeted at [Marionette][]. It makes the methods of models and collections available to templates. 4 | 5 | The name of the plugin has turned out to be a bit of a misnomer, though. Backbone.Marionette.Export does not depend on Marionette and [works great without it][use-with-plain-backbone]. In fact, Backbone.Marionette.Export is [just as useful][use-case] in projects based on plain Backbone. 6 | 7 | ## Setup 8 | 9 | Include backbone.marionette.export.js after Backbone, and after Marionette (if you use Marionette). 10 | 11 | The stable version of Backbone.Marionette.Export is available in the `dist` directory ([dev][dist-dev], [prod][dist-prod]). If you use Bower, fetch the files with `bower install backbone.marionette.export`. With npm, it is `npm install backbone.marionette.export`. 12 | 13 | ###### Module systems (AMD, CJS) 14 | 15 | Because Marionette is optional, it is not defined as a dependency in Backbone.Marionette.Export. Your own code must make sure that Marionette is loaded first. With [RequireJS][], for instance, use [a shim][RequireJS-shim] to express the dependency: 16 | 17 | ```js 18 | requirejs.config({ 19 | shim: { 20 | "backbone.marionette.export": ["marionette"] 21 | } 22 | }); 23 | ``` 24 | 25 | Backbone.Marionette.Export does not export a meaningful value. It solely lives in the Backbone namespace. 26 | 27 | ###### Underscore vs. Lodash 28 | 29 | If you need to handle deeply nested structures recursively, swap out Underscore for a compatible Lo-dash build with _.cloneDeep support. [See below](#enhanced-recursion-support-with-lo-dash-and-_clonedeep). 30 | 31 | ## Use case 32 | 33 | Out of the box, templates handled by Marionette views have access to all attributes of a model, and to the array of models represented by a collection. But templates can't use the output of methods. 34 | 35 | To work around that, you could override the `toJSON` method in a model or collection, but that creates [its own set of problems][1]. Specifically, anything you change in `toJSON` will also get written back to the server on save. 36 | 37 | Backbone.Marionette.Export does not cause any such side effects. After dropping it in, this is what you can do: 38 | 39 | - Select which methods of a model, or of a collection, provide their output to templates. 40 | - Modify the data before it is handed to a template, by implementing an `onExport` handler. 41 | - Manipulate the model or collection state itself before it is passed to a template, using an `onBeforeExport` 42 | handler. 43 | - Clean up by implementing an `onAfterExport` handler. 44 | 45 | ## Usage and examples 46 | 47 | ### The basics 48 | 49 | Here is how it works, in its simplest form. The examples below use the `Marionette.ItemView` type of Marionette 2. If you use Marionette 3, just substitute the successor, `Marionette.View`, for it. 50 | 51 | ```html 52 | 55 | ``` 56 | 57 | `...` 58 | 59 | ```js 60 | var Model = Backbone.Model.extend ({ 61 | exportable: "foo", // <-- this is the one line you have to add 62 | foo: function () { 63 | return "some calculated result of calling foo"; 64 | } 65 | }); 66 | 67 | var view = new Marionette.ItemView ({ 68 | model: new Model(), 69 | template: "#item-view-template" 70 | }); 71 | 72 | view.render(); 73 | ``` 74 | 75 | In the model definition, you declare which methods are available to a template. Just provide the method name to `exportable`, or an array of them. For method names, both `"foo"` and `"this.foo"` are acceptable. 76 | 77 | That works fine for simple method signatures. But what about **methods which take arguments**? 78 | 79 | ```html 80 | 83 | ``` 84 | 85 | `...` 86 | 87 | ```js 88 | var Model = Backbone.Model.extend ({ 89 | 90 | foo: function ( arg ) { 91 | return "some calculated result of calling foo with " + arg; 92 | }, 93 | 94 | onExport: function ( modelHash ) { 95 | modelHash.foo = this.foo( someArg ); 96 | return modelHash; 97 | } 98 | 99 | }); 100 | 101 | var view = new Marionette.ItemView ({ 102 | model: new Model(), 103 | template: "#item-view-template" 104 | }); 105 | 106 | view.render(); 107 | ``` 108 | 109 | In this scenario, there is no need to declare the method as `exportable`. In fact, you can't: the method takes arguments, so it can't be called automatically. Instead, modify the exported model data in an `onExport` handler. You can do pretty much anything there. Just remember to return the data at the end. 110 | 111 | For a **collection**, the process is the same as for a model. 112 | 113 | ```html 114 | 117 | ``` 118 | 119 | `...` 120 | 121 | ```js 122 | var Collection = Backbone.Collection.extend ({ 123 | exportable: "foo", 124 | foo: function () { return "some calculated result of calling foo"; } 125 | }); 126 | 127 | var view = new Marionette.ItemView ({ 128 | collection: new Collection(), 129 | template: "#item-view-template" 130 | }); 131 | 132 | view.render(); 133 | ``` 134 | 135 | The collection data is provided to the template as it always is: in an `items` array. That is the [standard behaviour][2] of a Marionette `ItemView` (or `View` in Marionette 3) and unrelated to Backbone.Marionette.Export. 136 | 137 | Besides being an array, `items` is an object like any other. Arbitrary properties can be added to it. And that is exactly what happened to the exported method, `foo`. 138 | 139 | ###### Demo 140 | 141 | There is an interactive demo you can play around with. The demo is kept simple, and is a good way to explore the features of Backbone.Cycle. Check it out at [JSBin][demo-jsbin] or [Codepen][demo-codepen]. 142 | 143 | ### Recursion 144 | 145 | #### Baseline support 146 | 147 | Now, suppose a collection is passed to a template. What if it is made up of models which, in turn, have methods marked for export to the template? 148 | 149 | ```html 150 | 154 | ``` 155 | 156 | `...` 157 | 158 | ```js 159 | var Model = Backbone.Model.extend ({ 160 | exportable: "foo", 161 | foo: function () { return "my cid is " + this.cid; } 162 | }); 163 | 164 | var Collection = Backbone.Collection.extend ({ 165 | exportable: [ "first", "last" ] // <-- you can use it for built-in methods, too 166 | }); 167 | 168 | var view = new Marionette.ItemView ({ 169 | collection: new Collection( [ new Model(), new Model() ] ), 170 | template: "#item-view-template" 171 | }); 172 | 173 | view.render(); 174 | ``` 175 | 176 | The message here is that the plugin handles recursion for you, no matter of what kind. You can have a collection method return another, nested collection, which in turn holds models with methods marked for export. It all gets exported, just as you would expect, without any additional measures on your part. 177 | 178 | #### Enhanced recursion support with Lo-dash and _.cloneDeep 179 | 180 | In the default Backbone setup, recursion works fine as long as Backbone objects are nested directly within one another. If the chain is broken by other, non-Backbone objects in between, recursion stops. Consider these examples: 181 | 182 | - A hypothetical `model.getInnerModel.getInnerCollection.getYetAnotherModel` will behave as expected. 183 | - By contrast, `model.getObjectLiteral.someProperty.innerBackboneModelHere` won't trigger a call to `export` on the inner model. 184 | 185 | This limitation is imposed by the underlying utility library, [Underscore][]. Underscore [doesn't do deep cloning][3]. Handling deeply nested structures with Underscore can be full of surprises, mostly [unpleasant ones][4]. 186 | 187 | The good news is that deep cloning, and support for deep recursion, is easy to add. Just swap out Underscore for a fully compatible build of [Lo-dash][]. 188 | 189 | On the Lo-dash site, there are a number of builds to choose from, including one designed to replace Underscore with 100% compatibility, but it lacks support for deep cloning. You can [add it yourself][5], though, with a few commands in a terminal. 190 | 191 | - Make sure you have [Node.js][] and npm working. 192 | - Install Lo-dash globally with `npm install -g lodash`, and the CLI with `npm install -g lodash-cli`. 193 | - Change into the directory where you want to put your Underscore replacement. 194 | - Create the library with `lodash underscore plus=clone,cloneDeep`. 195 | - Replace the Underscore script tag on your pages with one loading the new library. 196 | 197 | With the Lo-dash build in place, deeply nested structures are no longer a problem. In the second example above, `model.getObjectLiteral.someProperty.innerBackboneModelHere` indeed triggers a call to `export` on the inner model, as one would expect. 198 | 199 | #### Maximum recursion depth 200 | 201 | Recursion can generate huge data structures in some cases, so it makes sense to impose a limit. By default, there won't be more than four recursive calls to `export` for a given top-level object. That should be more than enough for almost any template requirement. 202 | 203 | You can change the limit globally in your project by setting `Backbone.Model.prototype.export.global.maxHops` or `Backbone.Collection.prototype.export.global.maxHops` to the desired recursion depth. (Changing one of them is enough. Model and Collection prototypes share the same config object.) 204 | 205 | Circular dependencies between your models and collections are contained by the recursion limit, too. 206 | 207 | ### Complex data wrangling 208 | 209 | In case you have to change the model state, or collection state, before the model is handed over to a template, you can do whatever you need to do with `onBeforeExport`. Implement it, and it will be called before the data export kicks in. 210 | 211 | `onBeforeExport` is fully separated from the actual export mechanism. You can even manipulate the `exportable` property itself and set it dynamically each time template data is requested. 212 | 213 | For any clean-up operations, implement `onAfterExport`. When it is called, the data for templates is already finalized. 214 | 215 | ### Strict mode 216 | 217 | You can make Backbone.Marionette.Export considerably more pedantic, having it throw more exceptions, if you put it in strict mode. 218 | 219 | #### What strict mode does 220 | 221 | Strict mode is off by default. When enabled, Backbone.Marionette.Export will throw an exception 222 | 223 | - when a method or a collection property is declared as exportable, but does not exist 224 | - when a model property is declared as exportable 225 | 226 | #### Enabling strict mode 227 | 228 | You enable strict mode globally in your project by setting `Backbone.Model.prototype.export.global.strict` or `Backbone.Collection.prototype.export.global.strict` to true. (Changing one of them is enough. Model and Collection prototypes share the same config object.) 229 | 230 | #### When to use strict mode 231 | 232 | By default, if you try to export a non-existent method or property, it will just get ignored. Turning on strict mode may be helpful for catching typos, and perhaps even logical errors. 233 | 234 | Also, by default, you can declare model properties as exportable. But model properties, if they are relevant to a template, should really be [model _attributes_][backbone-model-attributes]. Those get passed to templates out of the box. 235 | 236 | So in the vast majority of cases, you won't have the need to say `exportable: "someProperty"`. By turning on strict mode, you'll get an error thrown at you for trying. That may help to catch accidental assignments. 237 | 238 | Collections, by contrast, don't have attributes, and they don't provide a native way to have a property show up in a template. So here, `exportable: "someProperty"` makes sense, and indeed it will work just fine – even in strict mode. 239 | 240 | ### For which Marionette view types does it work? 241 | 242 | All of them. 243 | 244 | ### But I don't use Marionette! 245 | 246 | The export functionality in models and collections is wired up with Marionette views and works out of the box. But you can use it in your own view types, too. 247 | 248 | When you grab model or collection data during render(), call `model.export()` or `collection.export()` instead of their `toJSON` counterparts. That's all there is to it. 249 | 250 | _(If you have to support old browsers which are based on ES3, don't call `export` as shown above, in dot notation. Because `export` is a [reserved keyword][6], using it as a property name [may throw an error][7] in ES3-compliant browsers, and in fact it does [in IE8 and Android 2.x][8]. Use the bracket notation instead: `model["export"]()` or `collection["export"]()`.)_ 251 | 252 | 253 | ## Dependencies 254 | 255 | [Backbone][] is the only dependency. It makes most sense to use the enhancements with Marionette views, though. 256 | If present, Marionette is modified to respond to the export() functionality, and Marionette views pass the exported 257 | properties to the templates automatically. 258 | 259 | ## Build process and tests 260 | 261 | If you'd like to fix, customize or otherwise improve the project: here are your tools. 262 | 263 | ### Setup 264 | 265 | [npm][] sets up the environment for you. 266 | 267 | - The only thing you've got to have on your machine (besides Git) is [Node.js]. Download the installer [here][Node.js]. 268 | - Clone the project and open a command prompt in the project directory. 269 | - Run the setup with `npm run setup`. 270 | - Make sure the Grunt CLI is installed as a global Node module. If not, or if you are not sure, run `npm install -g grunt-cli` from the command prompt. 271 | 272 | Your test and build environment is ready now. If you want to test against specific versions of Backbone or Marionette, edit `bower.json` first. 273 | 274 | ### Running tests, creating a new build 275 | 276 | The test tool chain: [Grunt][] (task runner), [Karma][] (test runner), [Mocha][] (test framework), [Chai][] (assertion library), [Sinon][] (mocking framework). The good news: you don't need to worry about any of this. 277 | 278 | A handful of commands manage everything for you: 279 | 280 | - Run the tests in a terminal with `grunt test`. 281 | - Run the tests in a browser interactively, live-reloading the page when the source or the tests change: `grunt interactive`. 282 | - If the live reload bothers you, you can also run the tests in a browser without it: `grunt webtest`. 283 | - Run the linter only with `grunt lint` or `grunt hint`. (The linter is part of `grunt test` as well.) 284 | - Build the dist files (also running tests and linter) with `grunt build`, or just `grunt`. 285 | - Build continuously on every save with `grunt ci`. 286 | - Change the version number throughout the project with `grunt setver --to=1.2.3`. Or just increment the revision with `grunt setver --inc`. (Remember to rebuild the project with `grunt` afterwards.) 287 | - `grunt getver` will quickly tell you which version you are at. 288 | 289 | Finally, if need be, you can set up a quick demo page to play with the code. First, edit the files in the `demo` directory. Then display `demo/index.html`, live-reloading your changes to the code or the page, with `grunt demo`. Libraries needed for the demo/playground should go into the Bower dev dependencies – in the project-wide `bower.json` – or else be managed by the dedicated `bower.json` in the demo directory. 290 | 291 | _The `grunt interactive` and `grunt demo` commands spin up a web server, opening up the **whole project** to access via http._ So please be aware of the security implications. You can restrict that access to localhost in `Gruntfile.js` if you just use browsers on your machine. 292 | 293 | ### Changing the tool chain configuration 294 | 295 | In case anything about the test and build process needs to be changed, have a look at the following config files: 296 | 297 | - `karma.conf.js` (changes to dependencies, additional test frameworks) 298 | - `Gruntfile.js` (changes to the whole process) 299 | - `web-mocha/_index.html` (changes to dependencies, additional test frameworks) 300 | 301 | New test files in the `spec` directory are picked up automatically, no need to edit the configuration for that. 302 | 303 | ## Release Notes 304 | 305 | ### v3.0.0 306 | 307 | - Removed the separate AMD/Node builds in `dist/amd`. Module systems and browser globals are now supported by the same file, `dist/backbone.marionette.export.js` (or `.min.js`) 308 | 309 | ### v2.1.6 310 | 311 | - Added support for Marionette 3 312 | - Version is exposed in `Backbone.Model.prototype.export.global.version` and `Backbone.Collection.prototype.export.global.version` 313 | - AMD demo allows testing r.js output 314 | 315 | ### v2.1.5 316 | 317 | - Updated Backbone dependency 318 | - Updated build environment 319 | 320 | ### v2.1.3 321 | 322 | - Updated Backbone dependency 323 | 324 | ### v2.1.2 325 | 326 | - Updated component dependencies 327 | - Updated build environment 328 | 329 | ### v2.1.1 330 | 331 | - Made the component backward compatible with ES3 (fix for IE8) 332 | - Fixed strict mode in AMD build 333 | 334 | ### v2.1.0 335 | 336 | - Removed Marionette as a hard dependency in AMD/CJS build. _Important: It is now up to the client code to make sure that Marionette is loaded first._ 337 | - Made available as an npm install 338 | 339 | ### v2.0.0 340 | 341 | Bumping the major version is necessary because of changes to the public API, even though they are minor. 342 | 343 | - No longer throwing exceptions by default when declaring non-existent methods or properties as exportable 344 | - No longer throwing an exception by default when declaring model properties as exportable 345 | - Added strict mode 346 | - Moved `maxHops` into global config object 347 | 348 | ### v1.0.1 - 1.0.7 349 | 350 | - Minor bug fixes 351 | - Added demo code, documentation changes 352 | - New build environment 353 | 354 | ### v1.0.0 355 | 356 | - Initial public release 357 | 358 | ## License 359 | 360 | MIT. 361 | 362 | Copyright (c) 2014-2025 Michael Heim. 363 | 364 | [dist-dev]: https://raw.github.com/hashchange/backbone.marionette.export/master/dist/backbone.marionette.export.js "backbone.marionette.export.js" 365 | [dist-prod]: https://raw.github.com/hashchange/backbone.marionette.export/master/dist/backbone.marionette.export.min.js "backbone.marionette.export.min.js" 366 | 367 | [demo-jsbin]: http://jsbin.com/hoyome/7/edit?js,output "Backbone.Marionette.Export demo (AMD) – JSBin" 368 | [demo-codepen]: http://codepen.io/hashchange/pen/jPjvoG "Backbone.Marionette.Export demo (AMD) – Codepen" 369 | 370 | [Backbone]: http://backbonejs.org/ "Backbone.js" 371 | [Marionette]: https://github.com/marionettejs/backbone.marionette#readme "Marionette: a composite application library for Backbone.js" 372 | [Underscore]: http://underscorejs.org/ "Underscore" 373 | [Lo-dash]: http://lodash.com/ "Lo-Dash" 374 | [RequireJS]: http://requirejs.org/ "RequireJS" 375 | [RequireJS-shim]: http://requirejs.org/docs/api.html#config-shim "RequireJS Configuration Options: Shim" 376 | [Node.js]: http://nodejs.org/ "Node.js" 377 | [Bower]: http://bower.io/ "Bower: a package manager for the web" 378 | [npm]: https://npmjs.org/ "npm: Node Packaged Modules" 379 | [Grunt]: http://gruntjs.com/ "Grunt: The JavaScript Task Runner" 380 | [Karma]: http://karma-runner.github.io/ "Karma – Spectacular Test Runner for Javascript" 381 | [Mocha]: http://mochajs.org/ "Mocha – the fun, simple, flexible JavaScript test framework" 382 | [Chai]: http://chaijs.com/ "Chai: a BDD / TDD assertion library" 383 | [Sinon]: http://sinonjs.org/ "Sinon.JS – Versatile standalone test spies, stubs and mocks for JavaScript" 384 | [JSHint]: http://www.jshint.com/ "JSHint, a JavaScript Code Quality Tool" 385 | 386 | [1]: http://stackoverflow.com/a/10653468/508355 "Stack Overflow: How to access a calculated field of a backbone model from handlebars template?" 387 | [2]: https://github.com/marionettejs/backbone.marionette/blob/master/docs/marionette.view.md#advanced-view-topics "Marionette.View: Advanced View topics" 388 | [3]: https://github.com/jashkenas/underscore/pull/595 "Underscore Pull Request #595: Deep copying with _.clone(obj, deep)" 389 | [4]: http://coding.smashingmagazine.com/2013/08/09/backbone-js-tips-patterns/ "Backbone.js Tips And Patterns: Perform Deep Copies Of Objects" 390 | [5]: https://github.com/bestiejs/lodash/issues/206 "lodash Issue #206: Underscore compatibility" 391 | [6]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Lexical_grammar#Keywords "MDN JavaScript Reference: Lexical grammar – Keywords" 392 | [7]: http://mathiasbynens.be/notes/javascript-properties "Unquoted property names / object keys in JavaScript" 393 | [8]: http://stackoverflow.com/questions/23105089/angular-q-catch-method-fails-in-ie8/23105836#23105836 "Stack Overflow answer: Reserved keywords in IE8 and Android 2.x" 394 | 395 | [use-case]: #use-case "Backbone.Marionette.Export: Use case" 396 | [use-with-plain-backbone]: #but-i-dont-use-marionette "Using Backbone.Marionette.Export with plain Backbone" 397 | [backbone-model-attributes]: http://backbonejs.org/#Model-attributes "Backbone.Model attributes" 398 | 399 | [license]: #license "License" 400 | [hashchange-projects-overview]: http://hashchange.github.io/ "Hacking the front end: Backbone, Marionette, jQuery and the DOM. An overview of open-source projects by @hashchange." 401 | -------------------------------------------------------------------------------- /spec/collection.test.js: -------------------------------------------------------------------------------- 1 | /*global describe, it */ 2 | (function () { 3 | "use strict"; 4 | 5 | var it_accepts = it, 6 | it_throws_an_error = it, 7 | it_acts_recursively = it; 8 | 9 | // Detect the ability to create deep clones with the utility library (Lo-dash build with _.cloneDeep support vs 10 | // Underscore), and make test execution dependent on it for affected tests. 11 | var withCloneDeep_it_acts_recursively = ( _.cloneDeep ? it : it.skip ); // Lo-dash build with cloneDeep support 12 | 13 | describe( 'A Backbone collection is enhanced with the export functionality.', function () { 14 | 15 | var models, modelsWithExportedMethods, CollectionWithMethods; 16 | 17 | beforeEach( function () { 18 | 19 | models = [ 20 | new Backbone.Model ( { modelProp: 1 } ), 21 | new Backbone.Model ( { modelProp: 2 } ), 22 | new Backbone.Model ( { modelProp: 3 } ) 23 | ]; 24 | 25 | var ModelWithExportedMethods = Backbone.Model.extend( { 26 | exportable: "method", 27 | method: function () { return "returning a value"; } 28 | } ); 29 | 30 | modelsWithExportedMethods = [ 31 | new ModelWithExportedMethods( { modelProp: 1 } ), 32 | new ModelWithExportedMethods( { modelProp: 2 } ), 33 | new ModelWithExportedMethods( { modelProp: 3 } ) 34 | ]; 35 | 36 | CollectionWithMethods = Backbone.Collection.extend( { 37 | method: function () { return "collection method, returning a value"; } 38 | } ); 39 | 40 | }); 41 | 42 | describe( 'By default, export()', function () { 43 | 44 | it( 'returns an array of exported model hashes', function () { 45 | 46 | var collection = new Backbone.Collection( modelsWithExportedMethods ); 47 | 48 | var exportedModels = _.map( modelsWithExportedMethods, function ( model ) { return model["export"](); } ); 49 | expect( collection["export"]() ).to.deep.equal( exportedModels ); 50 | 51 | } ); 52 | 53 | it( 'calls the export() method of each model in the array', function () { 54 | 55 | // Add a spy to the export() method of each model 56 | _.each( models, function ( model ) { sinon.spy( model, "export" ); } ); 57 | 58 | var collection = new Backbone.Collection( models ); 59 | collection["export"](); 60 | 61 | _.each( models, function ( model ) { 62 | expect( model["export"] ).to.have.been.calledOnce; 63 | } ); 64 | 65 | // Remove the spy 66 | _.each( models, function ( model ) { model["export"].restore(); } ); 67 | 68 | } ); 69 | 70 | it( 'returns an empty array if the collection does not hold any models', function () { 71 | 72 | var collection = new Backbone.Collection(); 73 | expect( collection["export"]() ).to.deep.equal( [] ); 74 | 75 | } ); 76 | 77 | it( 'does not call any method on the collection itself', function () { 78 | 79 | var collectionWithMethods = new CollectionWithMethods( models ); 80 | sinon.spy( collectionWithMethods, "method" ); 81 | 82 | collectionWithMethods["export"](); 83 | expect( collectionWithMethods.method ).not.to.have.been.called; 84 | 85 | } ); 86 | 87 | it( 'returns identical results with and without custom methods having been added to the collection', function () { 88 | 89 | var collectionWithMethods = new CollectionWithMethods( modelsWithExportedMethods ); 90 | var plainCollection = new Backbone.Collection( modelsWithExportedMethods ); 91 | 92 | expect( collectionWithMethods["export"]() ).to.deep.equal( plainCollection["export"]() ); 93 | 94 | } ); 95 | 96 | } ); 97 | 98 | describe( 'The "exportable" property stores the methods which are called on export.', function () { 99 | 100 | describe( 'It accepts', function () { 101 | 102 | it_accepts( 'a string with the name of the method. export() evaluates the method and returns it as a property', function () { 103 | 104 | var Collection = CollectionWithMethods.extend( { exportable: "method" } ); 105 | var collection = new Collection( models ); 106 | 107 | expect( collection["export"]() ).to.have.a.property( 'method' ).with.a.string( "collection method, returning a value" ); 108 | 109 | } ); 110 | 111 | it_accepts( 'a string in the format "this.method". export() evaluates the method and returns it as a property', function () { 112 | 113 | var Collection = CollectionWithMethods.extend( { exportable: "this.method" } ); 114 | var collection = new Collection( models ); 115 | 116 | expect( collection["export"]() ).to.have.a.property( 'method' ).with.a.string( "collection method, returning a value" ); 117 | 118 | } ); 119 | 120 | it_accepts( 'an array of method names. export() evaluates all of them and returns them as properties', function () { 121 | 122 | var Collection = CollectionWithMethods.extend( { 123 | exportable: [ "method", "this.anotherMethod" ], 124 | anotherMethod: function () { return "another collection method, returning a value"; } 125 | } ); 126 | var collection = new Collection( models ); 127 | 128 | expect( collection["export"]() ).to.have.a.property( 'method' ).with.a.string( "collection method, returning a value" ); 129 | expect( collection["export"]() ).to.have.a.property( 'anotherMethod' ).with.a.string( "another collection method, returning a value" ); 130 | 131 | } ); 132 | 133 | } ); 134 | 135 | describe( 'By default, in non-strict mode, it ignores', function () { 136 | 137 | it( 'when a method or property is declared as exportable but does not exist', function () { 138 | 139 | var Collection = CollectionWithMethods.extend( { exportable: "missing" } ); 140 | var collection = new Collection( models ); 141 | 142 | var exportedModelHashes = _.map( models, function( model ) { return model["export"](); } ); 143 | expect( collection["export"]() ).to.eql( exportedModelHashes ); 144 | 145 | } ); 146 | 147 | } ); 148 | 149 | describe( 'In strict mode, it causes an error on export()', function () { 150 | 151 | beforeEach( function () { 152 | Backbone.Collection.prototype["export"].global.strict = true; 153 | } ); 154 | 155 | afterEach( function () { 156 | Backbone.Collection.prototype["export"].global.strict = false; 157 | } ); 158 | 159 | it_throws_an_error( 'when a method or property does not exist', function () { 160 | 161 | var Collection = CollectionWithMethods.extend( { exportable: "missing" } ); 162 | var collection = new Collection( models ); 163 | 164 | var exportFunction = _.bind( collection["export"], collection ); 165 | 166 | expect( exportFunction ).to.throw( Error, "Can't export \"missing\". The method doesn't exist" ); 167 | 168 | } ); 169 | 170 | } ); 171 | 172 | describe( 'It always causes an error on export()', function () { 173 | 174 | it_throws_an_error( 'when being assigned a method reference', function () { 175 | 176 | var Collection = CollectionWithMethods.extend( { 177 | initialize: function () { this.exportable = [ this.method ]; } 178 | } ); 179 | var collection = new Collection( models ); 180 | 181 | var exportFunction = _.bind( collection["export"], collection ); 182 | expect( exportFunction ).to.throw( Error, "'exportable' property: Invalid method identifier" ); 183 | 184 | } ); 185 | 186 | it_throws_an_error( 'if an exported collection method would overwrite a native array property', function () { 187 | 188 | var Collection = Backbone.Collection.extend( { 189 | exportable: "join", 190 | join: function () { return "foo"; } 191 | } ); 192 | var collection = new Collection( models ); 193 | 194 | var exportFunction = _.bind( collection["export"], collection ); 195 | 196 | expect( exportFunction ).to.throw( Error, "Can't export a property with a name which is reserved for a native array property. Offending properties: join" ); 197 | 198 | } ); 199 | 200 | } ); 201 | 202 | describe( 'It', function () { 203 | 204 | it( 'does not change how the models in the collection are returned: as an array of export()ed model hashes', function () { 205 | 206 | var Collection = CollectionWithMethods.extend( { exportable: "method" } ); 207 | var collection = new Collection( modelsWithExportedMethods ); 208 | 209 | var expectedModelHashes = _.map( modelsWithExportedMethods, function( model ) { return model["export"](); } ); 210 | var exportedModelHashes = _.map( collection["export"](), function( modelHash ) {return modelHash; } ); 211 | expect( exportedModelHashes ).to.deep.equal( expectedModelHashes ); 212 | 213 | } ); 214 | 215 | it( 'ignores methods which are declared as exportable, but return a value of undefined', function () { 216 | // This conforms to the JSON spec. Valid JSON does not represent undefined values. 217 | var Collection = Backbone.Collection.extend( { 218 | exportable: "method", 219 | method: function () { 220 | return undefined; 221 | } 222 | } ); 223 | var collection = new Collection(); 224 | 225 | expect( collection["export"]() ).to.deep.equal( [] ); 226 | } ); 227 | 228 | } ); 229 | 230 | describe( 'It also handles ordinary properties of the collection, not just methods. It', function () { 231 | 232 | it( 'exports properties with a string value', function () { 233 | var Collection = Backbone.Collection.extend( { 234 | exportable: "property", 235 | property: "string property value" 236 | } ); 237 | var collection = new Collection(); 238 | 239 | expect( collection["export"]() ).to.have.a.property( 'property' ).with.a.string( "string property value" ); 240 | } ); 241 | 242 | it( 'exports properties with a boolean value of true', function () { 243 | var Collection = Backbone.Collection.extend( { 244 | exportable: "property", 245 | property: true 246 | } ); 247 | var collection = new Collection(); 248 | 249 | expect( collection["export"]() ).to.have.a.property( 'property' ); 250 | expect( collection["export"]().property ).to.be.a( 'boolean' ); 251 | expect( collection["export"]().property ).to.be.true; 252 | } ); 253 | 254 | it( 'exports properties with a boolean value of false', function () { 255 | var Collection = Backbone.Collection.extend( { 256 | exportable: "property", 257 | property: false 258 | } ); 259 | var collection = new Collection(); 260 | 261 | expect( collection["export"]() ).to.have.a.property( 'property' ); 262 | expect( collection["export"]().property ).to.be.a( 'boolean' ); 263 | expect( collection["export"]().property ).to.be.false; 264 | } ); 265 | 266 | it( 'exports properties with a null value', function () { 267 | var Collection = Backbone.Collection.extend( { 268 | exportable: "property", 269 | property: null 270 | } ); 271 | var collection = new Collection(); 272 | 273 | var expected = []; 274 | expected.property = null; 275 | 276 | expect( collection["export"]() ).to.have.a.property( 'property' ); 277 | expect( collection["export"]() ).to.deep.equal( expected ); 278 | } ); 279 | 280 | it( 'ignores properties which exist, but have a value of undefined', function () { 281 | // This conforms to the JSON spec. Valid JSON does not represent undefined values. 282 | var Collection = Backbone.Collection.extend( { 283 | exportable: "property", 284 | property: undefined 285 | } ); 286 | var collection = new Collection(); 287 | 288 | expect( collection["export"]() ).to.deep.equal( [] ); 289 | } ); 290 | 291 | it( 'attaches exported properties to the array of models (as direct properties of the array object)', function () { 292 | 293 | var Collection = Backbone.Collection.extend( { 294 | exportable: "property", 295 | property: "ordinary property value" 296 | } ); 297 | var collection = new Collection( modelsWithExportedMethods ); 298 | 299 | var expectedModelHashes = _.map( modelsWithExportedMethods, function( model ) { return model["export"](); } ); 300 | var expectedExport = expectedModelHashes; 301 | expectedExport.property = "ordinary property value"; 302 | 303 | expect( collection["export"]() ).to.deep.equal( expectedExport ); 304 | } ); 305 | 306 | } ); 307 | 308 | describe( 'It calls export() recursively', function () { 309 | 310 | var OuterCollection, ModelWithMethod, InnerModel, innerModel, innerModel_expectedExport, 311 | InnerCollection, innerCollection, innerCollection_expectedExport, 312 | deeplyNestedModel, deeplyNestedModel_ExpectedExport, 313 | deeplyNestedCollection, deeplyNestedCollection_expectedExport, 314 | getOuterCollection; 315 | 316 | beforeEach( function () { 317 | 318 | OuterCollection = Backbone.Collection.extend( { 319 | returnsInner: function () { return this.propWithInnerObject; }, 320 | propWithInnerObject: undefined, 321 | setInnerObject: function ( innerObject ) { this.propWithInnerObject = innerObject; } 322 | } ); 323 | 324 | getOuterCollection = function ( opts ) { 325 | var Outer = OuterCollection.extend( opts ); 326 | return new Outer(); 327 | }; 328 | 329 | ModelWithMethod = Backbone.Model.extend( { 330 | method: function () { return "returning a value"; } 331 | } ); 332 | 333 | InnerModel = ModelWithMethod.extend( { exportable: "method" } ); 334 | innerModel = new InnerModel(); 335 | innerModel_expectedExport = innerModel["export"](); 336 | 337 | InnerCollection = Backbone.Collection.extend( { 338 | exportable: "method", 339 | method: function() { return "returned by method of inner collection"; } 340 | } ); 341 | innerCollection = new InnerCollection(); 342 | innerCollection_expectedExport = innerCollection["export"](); 343 | 344 | deeplyNestedModel = { levelOneProp: [ 1, { nestedHere: innerModel }, 3 ] }; 345 | deeplyNestedModel_ExpectedExport = { levelOneProp: [ 1, { nestedHere: innerModel["export"]() }, 3 ] }; 346 | 347 | // NB deeplyNestedCollection: is a _model_, with a deeply nested collection inside 348 | deeplyNestedCollection = { levelOneProp: [ 1, { nestedHere: innerCollection }, 3 ] }; 349 | deeplyNestedCollection_expectedExport = { levelOneProp: [ 1, { nestedHere: innerCollection["export"]() }, 3 ] }; 350 | 351 | sinon.spy( innerModel, "export" ); 352 | sinon.spy( innerCollection, "export" ); 353 | 354 | }); 355 | 356 | afterEach( function () { 357 | if ( innerModel["export"].restore ) innerModel["export"].restore(); 358 | if ( innerCollection["export"].restore ) innerCollection["export"].restore(); 359 | }); 360 | 361 | 362 | it_acts_recursively( 'on models which are returned by an exported collection method', function () { 363 | 364 | var collection = getOuterCollection( { exportable: "returnsInner" } ); 365 | collection.setInnerObject( innerModel ); 366 | 367 | var exported = collection["export"](); 368 | 369 | var expectedArr = []; 370 | expectedArr.returnsInner = innerModel_expectedExport; 371 | 372 | expect( innerModel["export"] ).to.have.been.calledOnce; 373 | expect( exported ).to.deep.equal( expectedArr ); 374 | } ); 375 | 376 | it_acts_recursively( 'on inner, nested collections which are returned by an exported collection method', function () { 377 | 378 | var collection = getOuterCollection( { exportable: "returnsInner" } ); 379 | collection.setInnerObject( innerCollection ); 380 | 381 | var exported = collection["export"](); 382 | 383 | var expectedArr = []; 384 | expectedArr.returnsInner = innerCollection_expectedExport; 385 | 386 | expect( innerCollection["export"] ).to.have.been.calledOnce; 387 | expect( exported ).to.deep.equal( expectedArr ); 388 | 389 | } ); 390 | 391 | it_acts_recursively( 'on models which are assigned to an exported collection property', function () { 392 | 393 | var collection = getOuterCollection( { exportable: "propWithInnerObject" } ); 394 | collection.setInnerObject( innerModel ); 395 | 396 | var exported = collection["export"](); 397 | 398 | var expectedArr = []; 399 | expectedArr.propWithInnerObject = innerModel_expectedExport; 400 | 401 | expect( innerModel["export"] ).to.have.been.calledOnce; 402 | expect( exported ).to.deep.equal( expectedArr ); 403 | 404 | } ); 405 | 406 | it_acts_recursively( 'on inner, nested collections which are assigned to an exported collection property', function () { 407 | 408 | var collection = getOuterCollection( { exportable: "propWithInnerObject" } ); 409 | collection.setInnerObject( innerCollection ); 410 | 411 | var exported = collection["export"](); 412 | 413 | var expectedArr = []; 414 | expectedArr.propWithInnerObject = innerCollection_expectedExport; 415 | 416 | expect( innerCollection["export"] ).to.have.been.calledOnce; 417 | expect( exported ).to.deep.equal( expectedArr ); 418 | 419 | } ); 420 | 421 | withCloneDeep_it_acts_recursively( '[+ _.cloneDeep] on inner models, deeply nested within other structures and returned by an exported method', function () { 422 | 423 | var collection = getOuterCollection( { exportable: "returnsInner" } ); 424 | collection.setInnerObject( deeplyNestedModel ); 425 | 426 | var exported = collection["export"](); 427 | 428 | var expectedArr = []; 429 | expectedArr.returnsInner = deeplyNestedModel_ExpectedExport; 430 | 431 | expect( innerModel["export"] ).to.have.been.calledOnce; 432 | expect( exported ).to.deep.equal( expectedArr ); 433 | 434 | } ); 435 | 436 | withCloneDeep_it_acts_recursively( '[+ _.cloneDeep] on inner collections, deeply nested within other structures and returned by an exported method', function () { 437 | 438 | var collection = getOuterCollection( { exportable: "returnsInner" } ); 439 | collection.setInnerObject( deeplyNestedCollection ); 440 | 441 | var exported = collection["export"](); 442 | 443 | var expectedArr = []; 444 | expectedArr.returnsInner = deeplyNestedCollection_expectedExport; 445 | 446 | expect( innerCollection["export"] ).to.have.been.calledOnce; 447 | expect( exported ).to.deep.equal( expectedArr ); 448 | 449 | } ); 450 | 451 | withCloneDeep_it_acts_recursively( '[+ _.cloneDeep] on inner models, deeply nested within other structures and assigned to an exported property', function () { 452 | 453 | var collection = getOuterCollection( { exportable: "propWithInnerObject" } ); 454 | collection.setInnerObject( deeplyNestedModel ); 455 | 456 | var exported = collection["export"](); 457 | 458 | var expectedArr = []; 459 | expectedArr.propWithInnerObject = deeplyNestedModel_ExpectedExport; 460 | 461 | expect( innerModel["export"] ).to.have.been.calledOnce; 462 | expect( exported ).to.deep.equal( expectedArr ); 463 | 464 | } ); 465 | 466 | withCloneDeep_it_acts_recursively( '[+ _.cloneDeep] on inner collections, deeply nested within other structures and assigned to an exported property', function () { 467 | 468 | var collection = getOuterCollection( { exportable: "propWithInnerObject" } ); 469 | collection.setInnerObject( deeplyNestedCollection ); 470 | 471 | var exported = collection["export"](); 472 | 473 | var expectedArr = []; 474 | expectedArr.propWithInnerObject = deeplyNestedCollection_expectedExport; 475 | 476 | expect( innerCollection["export"] ).to.have.been.calledOnce; 477 | expect( exported ).to.deep.equal( expectedArr ); 478 | 479 | } ); 480 | 481 | it_acts_recursively( 'up to the maximum recursion depth, controlling circular dependencies', function () { 482 | 483 | var Collection = Backbone.Collection.extend({ 484 | exportable: "next", 485 | _next: undefined, 486 | setNext: function ( next ) { this._next = next }, 487 | next: function () { return this._next } 488 | }); 489 | 490 | var collection1 = new Collection(); 491 | var collection2 = new Collection(); 492 | 493 | sinon.spy( collection1, "export" ); 494 | sinon.spy( collection2, "export" ); 495 | 496 | collection1.setNext( collection2 ); 497 | collection2.setNext( collection1 ); 498 | 499 | collection1["export"](); 500 | 501 | // Recursion depth of export calls (hops). Equals call count - 1 (the initial export call is not a 502 | // hop). 503 | var hops = collection1["export"].callCount + collection2["export"].callCount - 1; 504 | 505 | expect( hops ).to.equal( Collection.prototype["export"].global.maxHops ); 506 | 507 | } ); 508 | 509 | } ); 510 | 511 | } ); 512 | 513 | describe( 'The onExport() handler', function () { 514 | 515 | it( 'is run by export(). It receives an array of model hashes - the data designated for export - as an argument', function () { 516 | 517 | var collection = new Backbone.Collection( modelsWithExportedMethods ); 518 | sinon.spy( collection, "onExport" ); 519 | 520 | var exportedModels = _.map( modelsWithExportedMethods, function( model ) { return model["export"](); } ); 521 | 522 | collection["export"](); 523 | expect( collection.onExport ).to.have.been.calledWithExactly( exportedModels ); 524 | 525 | } ); 526 | 527 | it( 'receives the array last. Ie, the methods of the collection which are marked as "exportable" have already been transformed into properties of the array', function () { 528 | 529 | var Collection = CollectionWithMethods.extend( { exportable: "method" } ); 530 | var collection = new Collection( [] ); 531 | sinon.spy( collection, "onExport" ); 532 | 533 | var expected = []; 534 | expected.method = "collection method, returning a value"; 535 | 536 | collection["export"](); 537 | expect( collection.onExport ).to.have.been.calledWithExactly( expected ); 538 | 539 | } ); 540 | 541 | it( 'is able to alter the data before it is returned by export()', function () { 542 | 543 | var Collection = Backbone.Collection.extend( { 544 | property: "in original state", 545 | onExport: function ( data ) { 546 | data.property = "in modified state"; 547 | return data; 548 | } 549 | } ); 550 | var collection = new Collection(); 551 | 552 | expect( collection["export"]() ).to.have.a.property( 'property' ).with.a.string( "in modified state" ); 553 | 554 | } ); 555 | 556 | it( 'does not allow to overwrite a native array property and throws an error', function () { 557 | 558 | var Collection = Backbone.Collection.extend( { 559 | onExport: function ( data ) { 560 | data.join = "foo"; 561 | data.concat = "bar"; 562 | return data; 563 | } 564 | } ); 565 | var collection = new Collection( models ); 566 | 567 | var exportFunction = _.bind( collection["export"], collection ); 568 | 569 | expect( exportFunction ).to.throw( Error, /Can't export a property with a name which is reserved for a native array property\. Offending properties: (join, concat|concat, join)/ ); 570 | 571 | } ); 572 | 573 | } ); 574 | 575 | describe( 'The onBeforeExport() handler', function () { 576 | 577 | it( 'is run when export() is called', function () { 578 | 579 | var collection = new Backbone.Collection(); 580 | sinon.spy( collection, "onBeforeExport" ); 581 | 582 | collection["export"](); 583 | expect( collection.onBeforeExport ).to.have.been.calledOnce; 584 | 585 | } ); 586 | 587 | it( 'can modify the collection state before it is turned into a hash', function () { 588 | 589 | var traceMe = new Backbone.Model( { traceMe: true } ); 590 | 591 | var Collection = Backbone.Collection.extend( { 592 | onBeforeExport: function () { 593 | this.add( traceMe ); 594 | } 595 | } ); 596 | var collection = new Collection(); 597 | 598 | expect( collection["export"]() ).to.deep.equal( [ traceMe["export"]() ] ); 599 | 600 | } ); 601 | 602 | it( 'can manipulate other, "exportable" collection methods before they are transformed and added to the hash', function () { 603 | 604 | var Collection = Backbone.Collection.extend( { 605 | exportable: "method", 606 | onBeforeExport: function () { 607 | this.method = function () { return "manipulated method return value"; } 608 | }, 609 | method: function () { return "original method return value"; } 610 | } ); 611 | var collection = new Collection(); 612 | 613 | expect( collection["export"]() ).to.have.a.property( 'method' ).with.a.string( "manipulated method return value" ); 614 | 615 | } ); 616 | 617 | it( 'alters the collection state permanently, beyond the export() call', function () { 618 | 619 | var traceMe = new Backbone.Model( { traceMe: true } ); 620 | 621 | var Collection = Backbone.Collection.extend( { 622 | onBeforeExport: function () { 623 | this.add( traceMe ); 624 | } 625 | } ); 626 | var collection = new Collection(); 627 | 628 | collection["export"](); 629 | expect( collection.at( 0 ) ).to.deep.equal( traceMe ); 630 | 631 | } ); 632 | 633 | it( 'runs before onExport()', function () { 634 | 635 | var collection = new Backbone.Collection(); 636 | sinon.spy( collection, "onBeforeExport" ); 637 | sinon.spy( collection, "onExport" ); 638 | 639 | collection["export"](); 640 | expect( collection.onBeforeExport ).to.have.been.calledBefore( collection.onExport ); 641 | 642 | } ); 643 | 644 | } ); 645 | 646 | describe( 'The onAfterExport() handler', function () { 647 | 648 | it( 'is run when export() is called', function () { 649 | 650 | var collection = new Backbone.Collection(); 651 | sinon.spy( collection, "onAfterExport" ); 652 | 653 | collection["export"](); 654 | expect( collection.onAfterExport ).to.have.been.calledOnce; 655 | 656 | } ); 657 | 658 | it( 'can act on the model state after it has been turned into a hash, leaving the exported hash unchanged', function () { 659 | 660 | var traceMe = new Backbone.Model( { traceMe: true } ); 661 | 662 | var Collection = Backbone.Collection.extend( { 663 | onAfterExport: function () { 664 | this.add( traceMe ); 665 | } 666 | } ); 667 | var collection = new Collection(); 668 | 669 | expect( collection["export"]() ).to.deep.equal( [] ); 670 | expect( collection.at( 0 ) ).to.deep.equal( traceMe ); 671 | 672 | } ); 673 | 674 | it( 'can manipulate other, "exportable" collection methods only after they have been transformed and added to the hash', function () { 675 | 676 | var Collection = Backbone.Collection.extend( { 677 | exportable: "method", 678 | onAfterExport: function () { 679 | this.method = function () { return "manipulated method return value"; } 680 | }, 681 | method: function () { return "original method return value"; } 682 | } ); 683 | var collection = new Collection(); 684 | 685 | expect( collection["export"]() ).to.have.a.property( 'method' ).with.a.string( "original method return value" ); 686 | expect( collection.method() ).to.be.a.string( "manipulated method return value" ); 687 | 688 | } ); 689 | 690 | it( 'runs after onExport()', function () { 691 | 692 | var collection = new Backbone.Collection(); 693 | sinon.spy( collection, "onAfterExport" ); 694 | sinon.spy( collection, "onExport" ); 695 | 696 | collection["export"](); 697 | expect( collection.onAfterExport ).to.have.been.calledAfter( collection.onExport ); 698 | 699 | } ); 700 | 701 | } ); 702 | 703 | } ); 704 | 705 | })(); 706 | -------------------------------------------------------------------------------- /spec/model.test.js: -------------------------------------------------------------------------------- 1 | /*global describe, it */ 2 | (function () { 3 | "use strict"; 4 | 5 | var it_accepts = it, 6 | it_throws_an_error = it, 7 | they = it, 8 | expectation = it; 9 | 10 | // Detect the ability to create deep clones with the utility library (Lo-dash build with _.cloneDeep support vs 11 | // Underscore), and make test execution dependent on it for affected tests. 12 | var withCloneDeep_it = ( _.cloneDeep ? it : it.skip ), // Lo-dash build with cloneDeep support 13 | withCloneDeep_they = withCloneDeep_it, 14 | withCloneDeep_describe = ( _.cloneDeep ? describe : describe.skip ), 15 | withoutCloneDeep_they = ( _.cloneDeep ? it.skip : it ); // Underscore (no deep cloning support) 16 | 17 | // ATTN Using cloneDeep: 18 | // 19 | // A comparison of an object with its clone can fail when using current versions of Chai (used to work previously). 20 | // The reason seems to be that methods and properties from the objects prototype chain are copied directly onto the 21 | // clone when it is created. 22 | // 23 | // Problematic tests can be fixed easily: Don't compare the object itself to the cloned reference, but create a 24 | // second clone, and compare that. 25 | // 26 | // So instead of 27 | // 28 | // expect( fooObject ).to.deep.equal( clonedFoo ); 29 | // 30 | // use 31 | // 32 | // expect( cloneDeep( fooObject ) ).to.deep.equal( clonedFoo ); 33 | 34 | function cloneDeep ( obj ) { 35 | return jQuery.extend( true, {}, obj ); 36 | } 37 | 38 | describe( 'A Backbone model is enhanced with the export functionality.', function () { 39 | 40 | var ModelWithMethod; 41 | 42 | beforeEach( function () { 43 | 44 | ModelWithMethod = Backbone.Model.extend( { 45 | method: function () { return "returning a value"; } 46 | } ); 47 | 48 | }); 49 | 50 | describe( 'By default, export()', function () { 51 | 52 | it( 'returns a hash of model properties, just like toJSON()', function () { 53 | 54 | var model = new Backbone.Model(); 55 | 56 | model.set( { property: "a value", anotherProperty: "another value" } ); 57 | var propHash = model.toJSON(); 58 | expect( model["export"]() ).to.deep.equal( propHash ); 59 | 60 | } ); 61 | 62 | it( 'does not call any method', function () { 63 | 64 | var model = new ModelWithMethod(); 65 | sinon.spy( model, "method" ); 66 | 67 | model["export"](); 68 | expect( model.method ).not.to.have.been.called; 69 | 70 | } ); 71 | 72 | it( 'does not alter the properties hash, even if custom methods have been added to the model', function () { 73 | 74 | var model = new ModelWithMethod(); 75 | 76 | model.set( { property: "a value", anotherProperty: "another value" } ); 77 | var propHash = model.toJSON(); 78 | expect( model["export"]() ).to.deep.equal( propHash ); 79 | 80 | } ); 81 | 82 | } ); 83 | 84 | describe( 'The "exportable" property stores the methods which are called on export.', function () { 85 | 86 | describe( 'It accepts', function () { 87 | 88 | it_accepts( 'a string with the name of the method. export() evaluates the method and returns it as a property', function () { 89 | 90 | var Model = ModelWithMethod.extend( { exportable: "method" } ); 91 | var model = new Model(); 92 | 93 | expect( model["export"]() ).to.have.a.property( 'method' ).with.a.string( "returning a value" ); 94 | 95 | } ); 96 | 97 | it_accepts( 'a string in the format "this.method". export() evaluates the method and returns it as a property', function () { 98 | 99 | var Model = ModelWithMethod.extend( { exportable: "this.method" } ); 100 | var model = new Model(); 101 | 102 | expect( model["export"]() ).to.have.a.property( 'method' ).with.a.string( "returning a value" ); 103 | 104 | } ); 105 | 106 | it_accepts( 'an array of method names. export() evaluates all of them and returns them as properties', function () { 107 | 108 | var Model = Backbone.Model.extend( { 109 | exportable: [ "method", "this.anotherMethod" ], 110 | method: function () { return "returning a value"; }, 111 | anotherMethod: function () { return "returning another value"; } 112 | } ); 113 | var model = new Model(); 114 | 115 | expect( model["export"]() ).to.have.a.property( 'method' ).with.a.string( "returning a value" ); 116 | expect( model["export"]() ).to.have.a.property( 'anotherMethod' ).with.a.string( "returning another value" ); 117 | 118 | } ); 119 | 120 | } ); 121 | 122 | describe( 'By default, in non-strict mode, it ignores', function () { 123 | 124 | it( 'when a method is declared as exportable but does not exist', function () { 125 | 126 | var Model = ModelWithMethod.extend( { exportable: "missing" } ); 127 | var model = new Model(); 128 | 129 | expect( model["export"]() ).to.eql( {} ); 130 | 131 | } ); 132 | 133 | it( 'exports the value when instead of a method, a model property is declared as exportable', function () { 134 | 135 | var Model = Backbone.Model.extend( { 136 | exportable: "property", 137 | property: "ordinary property, not a method" 138 | } ); 139 | var model = new Model(); 140 | 141 | expect( model["export"]() ).to.eql( { property: "ordinary property, not a method" } ); 142 | 143 | } ); 144 | 145 | } ); 146 | 147 | describe( 'In strict mode, it causes an error on export()', function () { 148 | 149 | beforeEach( function () { 150 | Backbone.Model.prototype["export"].global.strict = true; 151 | } ); 152 | 153 | afterEach( function () { 154 | Backbone.Model.prototype["export"].global.strict = false; 155 | } ); 156 | 157 | it_throws_an_error( 'when one of the methods does not exist', function () { 158 | 159 | var Model = ModelWithMethod.extend( { exportable: "missing" } ); 160 | var model = new Model(); 161 | 162 | var exportFunction = _.bind( model["export"], model ); 163 | expect( exportFunction ).to.throw( Error, "Can't export \"missing\". The method doesn't exist" ); 164 | 165 | } ); 166 | 167 | it_throws_an_error( 'when one of the methods is in fact not a function', function () { 168 | 169 | var Model = Backbone.Model.extend( { 170 | exportable: "property", 171 | property: "ordinary property, not a method" 172 | } ); 173 | var model = new Model(); 174 | 175 | var exportFunction = _.bind( model["export"], model ); 176 | expect( exportFunction ).to.throw( Error, "'exportable' property: Invalid method identifier \"property\", does not point to a function" ); 177 | 178 | } ); 179 | 180 | } ); 181 | 182 | describe( 'It always causes an error on export()', function () { 183 | 184 | it_throws_an_error( 'when being assigned a method reference', function () { 185 | 186 | // Assigning method references had been implemented and did work, but introduced unnecessary complexity 187 | // and was difficult to use correctly. 188 | var Model = ModelWithMethod.extend( { 189 | initialize: function () { this.exportable = [ this.method ]; } 190 | } ); 191 | var model = new Model(); 192 | 193 | var exportFunction = _.bind( model["export"], model ); 194 | expect( exportFunction ).to.throw( Error, "'exportable' property: Invalid method identifier" ); 195 | 196 | } ); 197 | 198 | } ); 199 | 200 | describe( 'It always ignores', function () { 201 | 202 | it( 'methods which are declared as exportable, but return a value of undefined', function () { 203 | // This conforms to the JSON spec. Valid JSON does not represent undefined values. 204 | var Model = Backbone.Model.extend( { 205 | exportable: "method", 206 | method: function () { 207 | return undefined; 208 | } 209 | } ); 210 | var model = new Model(); 211 | 212 | expect( model["export"]() ).to.deep.equal( {} ); 213 | } ); 214 | 215 | } ); 216 | 217 | describe( 'The configuration object which enables strict mode or changes maxHops', function () { 218 | 219 | describe( 'is indeed an object with maxHops and strict properties', function () { 220 | expect( Backbone.Model.prototype["export"].global ).to.be.a( 'object' ); 221 | expect( Backbone.Model.prototype["export"].global ).to.have.a.property( 'maxHops' ); 222 | expect( Backbone.Model.prototype["export"].global ).to.have.a.property( 'strict' ); 223 | } ); 224 | 225 | it( 'is the same on the the Model and Collection prototype', function () { 226 | expect( Backbone.Model.prototype["export"].global ).to.equal( Backbone.Collection.prototype["export"].global ); 227 | } ); 228 | } ); 229 | 230 | } ); 231 | 232 | describe( 'The export() method works recursively.', function () { 233 | 234 | describe( 'Nesting scenarios:', function () { 235 | 236 | var OuterModel, outerModel, 237 | InnerModel, innerModel, 238 | InnerCollection, innerCollection, 239 | innerModelClone, innerCollectionClone, 240 | deeplyNestedModel, deeplyNestedModel_ExpectedExport, 241 | deeplyNestedCollection, deeplyNestedCollection_expectedExport, 242 | deeplyNestedModelClone, deeplyNestedCollectionClone; 243 | 244 | beforeEach( function() { 245 | 246 | OuterModel = Backbone.Model.extend( { 247 | exportable: "returnsInner", 248 | returnsInner: function () { return this._innerObject; }, 249 | _innerObject: undefined, 250 | setInnerObject: function ( innerObject ) { this._innerObject = innerObject; } 251 | } ); 252 | 253 | outerModel = new OuterModel(); 254 | 255 | InnerModel = ModelWithMethod.extend( { exportable: "method" } ); 256 | innerModel = new InnerModel( { foo: "bar" } ); 257 | 258 | InnerCollection = Backbone.Collection.extend( { 259 | exportable: "method", 260 | method: function() { return "returned by method of inner collection"; } 261 | } ); 262 | innerCollection = new InnerCollection(); 263 | 264 | deeplyNestedModel = { levelOneProp: [ 1, { nestedHere: innerModel }, 3 ] }; 265 | deeplyNestedModel_ExpectedExport = { levelOneProp: [ 1, { nestedHere: innerModel["export"]() }, 3 ] }; 266 | 267 | deeplyNestedCollection = { levelOneProp: [ 1, { nestedHere: innerCollection }, 3 ] }; 268 | deeplyNestedCollection_expectedExport = { levelOneProp: [ 1, { nestedHere: innerCollection["export"]() }, 3 ] }; 269 | 270 | sinon.spy( innerModel, "export" ); 271 | sinon.spy( innerCollection, "export" ); 272 | 273 | innerModelClone = cloneDeep( innerModel ); 274 | innerCollectionClone = cloneDeep( innerCollection ); 275 | 276 | deeplyNestedModelClone = cloneDeep( deeplyNestedModel ); 277 | deeplyNestedCollectionClone = cloneDeep( deeplyNestedCollection ); 278 | 279 | }); 280 | 281 | afterEach( function () { 282 | if ( innerModel["export"].restore ) innerModel["export"].restore(); 283 | if ( innerCollection["export"].restore ) innerCollection["export"].restore(); 284 | if ( innerModelClone["export"].restore ) innerModelClone["export"].restore(); 285 | if ( innerCollectionClone["export"].restore ) innerCollectionClone["export"].restore(); 286 | }); 287 | 288 | describe( 'An exported method returns an inner model', function () { 289 | 290 | expectation( 'the inner model has export() called on it', function () { 291 | 292 | outerModel.setInnerObject( innerModel ); 293 | var exported = outerModel["export"](); 294 | 295 | expect( innerModel["export"] ).to.have.been.calledOnce; 296 | expect( exported ).to.deep.equal( { returnsInner: innerModel["export"]() } ); 297 | 298 | } ); 299 | 300 | expectation( 'the inner model is unaffected by changes to its exported hash', function () { 301 | 302 | outerModel.setInnerObject( innerModel ); 303 | var exported = outerModel["export"](); 304 | 305 | // Inner model has been properly cloned 306 | exported.returnsInner.appendedAfterwards = "should not appear in inner model"; 307 | expect( innerModel ).not.to.have.a.property( "appendedAfterwards" ); 308 | 309 | } ); 310 | 311 | expectation( 'the method producing the model stays untouched, immune to manipulation of the exported data', function () { 312 | 313 | outerModel.setInnerObject( innerModel ); 314 | var exported = outerModel["export"](); 315 | 316 | // Outer model still holds a reference to the original inner model 317 | exported.returnsInner = "overwrite the exported inner model"; 318 | 319 | // (For the use of cloneDeep in the test, see note above the cloneDeep function) 320 | expect( cloneDeep( outerModel.returnsInner() ) ).to.deep.equal( innerModelClone ); 321 | 322 | } ); 323 | 324 | } ); 325 | 326 | describe( 'An exported method returns an inner model, which in turn has an exported method returning yet another model', function () { 327 | 328 | var middleModel, outerModelClone; 329 | 330 | beforeEach( function () { 331 | 332 | middleModel = new OuterModel(); 333 | middleModel.setInnerObject( innerModel ); 334 | outerModel.setInnerObject( middleModel ); 335 | 336 | outerModelClone = cloneDeep( outerModel ); 337 | 338 | } ); 339 | 340 | expectation( 'the innermost model has export() called on it', function () { 341 | 342 | var exported = outerModel["export"](); 343 | 344 | expect( innerModel["export"] ).to.have.been.calledOnce; 345 | expect( exported ).to.deep.equal( { 346 | returnsInner: { 347 | returnsInner: innerModel["export"]() 348 | } 349 | } ); 350 | 351 | } ); 352 | 353 | expectation( 'the outer model is not altered by the export itself, including the models nested inside (deep equality)', function () { 354 | 355 | outerModel["export"](); 356 | 357 | // Inner model has been properly cloned 358 | // 359 | // (For the use of cloneDeep in the test, see note above the cloneDeep function) 360 | expect( cloneDeep( outerModel ) ).to.deep.equal( outerModelClone ); 361 | 362 | } ); 363 | 364 | expectation( 'the innermost model is unaffected by changes to its exported hash', function () { 365 | 366 | var exported = outerModel["export"](); 367 | 368 | // Inner model has been properly cloned 369 | exported.returnsInner.returnsInner.appendedAfterwards = "should not appear in inner model"; 370 | expect( innerModel ).not.to.have.a.property( "appendedAfterwards" ); 371 | 372 | } ); 373 | 374 | expectation( 'the method producing the innermost model stays untouched, immune to manipulation of the exported data', function () { 375 | 376 | var exported = outerModel["export"](); 377 | 378 | // Middle model still holds a reference to the original inner model 379 | exported.returnsInner = "overwrite the exported inner model"; 380 | 381 | // (For the use of cloneDeep in the test, see note above the cloneDeep function) 382 | expect( cloneDeep( middleModel.returnsInner() ) ).to.deep.equal( innerModelClone ); 383 | 384 | } ); 385 | 386 | expectation( 'the middle model is unaffected by changes to its exported hash', function () { 387 | 388 | var exported = outerModel["export"](); 389 | 390 | // Middle model has been properly cloned 391 | exported.returnsInner.appendedAfterwards = "should not appear in middle model"; 392 | expect( middleModel ).not.to.have.a.property( "appendedAfterwards" ); 393 | 394 | } ); 395 | 396 | expectation( 'the method producing the middle model stays untouched, immune to manipulation of the exported data', function () { 397 | 398 | var middleModelClone = cloneDeep( middleModel ); 399 | var exported = outerModel["export"](); 400 | 401 | // Outer model still holds a reference to the original middle model 402 | exported.returnsInner = "overwrite the exported middle model"; 403 | 404 | // (For the use of cloneDeep in the test, see note above the cloneDeep function) 405 | expect( cloneDeep( outerModel.returnsInner() ) ).to.deep.equal( middleModelClone ); 406 | 407 | } ); 408 | 409 | } ); 410 | 411 | describe( 'An exported method returns an inner collection', function () { 412 | 413 | expectation( 'the inner collection has export() called on it', function () { 414 | 415 | outerModel.setInnerObject( innerCollection ); 416 | var exported = outerModel["export"](); 417 | 418 | expect( innerCollection["export"] ).to.have.been.calledOnce; 419 | expect( exported ).to.deep.equal( { returnsInner: innerCollection["export"]() } ); 420 | 421 | } ); 422 | 423 | expectation( 'the inner collection is unaffected by changes to the corresponding exported array', function () { 424 | 425 | outerModel.setInnerObject( innerCollection ); 426 | var exported = outerModel["export"](); 427 | 428 | // Inner model has been properly cloned 429 | exported.returnsInner.appendedAfterwards = "should not appear in inner collection"; 430 | expect( innerCollection ).not.to.have.a.property( "appendedAfterwards" ); 431 | 432 | } ); 433 | 434 | expectation( 'the method producing the collection stays untouched, immune to manipulation of the exported data', function () { 435 | 436 | outerModel.setInnerObject( innerCollection ); 437 | var exported = outerModel["export"](); 438 | 439 | // Outer model still holds a reference to the original inner model 440 | exported.returnsInner = "overwrite the exported inner collection"; 441 | expect( outerModel.returnsInner() ).to.deep.equal( innerCollectionClone ); 442 | 443 | } ); 444 | 445 | } ); 446 | 447 | describe( 'An attribute holds an inner model', function () { 448 | 449 | expectation( 'the inner model has export() called on it', function () { 450 | 451 | var model = new Backbone.Model( { containerAttribute: innerModel } ); 452 | var exported = model["export"](); 453 | 454 | expect( innerModel["export"] ).to.have.been.calledOnce; 455 | expect( exported ).to.deep.equal( { containerAttribute: innerModel["export"]() } ); 456 | 457 | } ); 458 | 459 | expectation( 'the inner model is unaffected by changes to its exported hash', function () { 460 | 461 | var model = new Backbone.Model( { containerAttribute: innerModel } ); 462 | var exported = model["export"](); 463 | 464 | exported.containerAttribute.appendedAfterwards = "should not appear in inner model"; 465 | expect( innerModel ).not.to.have.a.property( "appendedAfterwards" ); 466 | 467 | } ); 468 | 469 | expectation( 'the attribute of the outer model continues to hold a reference to the inner model, immune to manipulation of the exported data', function () { 470 | 471 | var model = new Backbone.Model( { containerAttribute: innerModel } ); 472 | var exported = model["export"](); 473 | 474 | exported.containerAttribute = "overwrite the exported inner collection"; 475 | 476 | // (For the use of cloneDeep in the test, see note above the cloneDeep function) 477 | expect( cloneDeep( model.get( "containerAttribute" ) ) ).to.deep.equal( innerModelClone ); 478 | 479 | } ); 480 | 481 | } ); 482 | 483 | describe( 'An attribute holds an inner collection', function () { 484 | 485 | expectation( 'the inner collection has export() called on it', function () { 486 | 487 | var model = new Backbone.Model( { containerAttribute: innerCollection } ); 488 | var exported = model["export"](); 489 | 490 | expect( innerCollection["export"] ).to.have.been.calledOnce; 491 | expect( exported ).to.deep.equal( { containerAttribute: innerCollection["export"]() } ); 492 | 493 | } ); 494 | 495 | expectation( 'the inner collection is unaffected by changes to the corresponding exported array', function () { 496 | 497 | var model = new Backbone.Model( { containerAttribute: innerCollection } ); 498 | var exported = model["export"](); 499 | 500 | exported.containerAttribute.appendedAfterwards = "should not appear in inner collection"; 501 | expect( innerCollection ).not.to.have.a.property( "appendedAfterwards" ); 502 | 503 | } ); 504 | 505 | expectation( 'the attribute of the outer model continues to hold a reference to the inner collection, immune to manipulation of the exported data', function () { 506 | 507 | var model = new Backbone.Model( { containerAttribute: innerCollection } ); 508 | var exported = model["export"](); 509 | 510 | exported.containerAttribute = "overwrite the exported inner collection"; 511 | expect( model.get( "containerAttribute" ) ).to.deep.equal( innerCollectionClone ); 512 | 513 | } ); 514 | 515 | } ); 516 | 517 | withCloneDeep_describe( '[+ _.cloneDeep] An exported method returns an inner model, deeply nested within other structures', function () { 518 | 519 | expectation( 'the inner model has export() called on it', function () { 520 | 521 | outerModel.setInnerObject( deeplyNestedModel ); 522 | var exported = outerModel["export"](); 523 | 524 | expect( innerModel["export"] ).to.have.been.calledOnce; 525 | expect( exported ).to.deep.equal( { returnsInner: deeplyNestedModel_ExpectedExport } ); 526 | 527 | } ); 528 | 529 | expectation( 'the inner model is unaffected by changes to its exported hash', function () { 530 | 531 | outerModel.setInnerObject( deeplyNestedModel ); 532 | var exported = outerModel["export"](); 533 | 534 | exported.returnsInner.levelOneProp[1].nestedHere.appendedAfterwards = "should not appear in inner model"; 535 | expect( innerModel ).not.to.have.a.property( "appendedAfterwards" ); 536 | 537 | } ); 538 | 539 | expectation( 'the object wrapping the inner model stays untouched, immune to manipulation of the exported data', function () { 540 | 541 | outerModel.setInnerObject( deeplyNestedModel ); 542 | var exported = outerModel["export"](); 543 | 544 | exported.returnsInner.levelOneProp[1].nestedHere = "overwrite the exported inner model"; 545 | expect( outerModel.returnsInner() ).to.deep.equal( deeplyNestedModelClone ); 546 | 547 | } ); 548 | 549 | } ); 550 | 551 | withCloneDeep_describe( '[+ _.cloneDeep] An exported method returns an inner collection, deeply nested within other structures', function () { 552 | 553 | expectation( 'the inner collection has export() called on it', function () { 554 | 555 | outerModel.setInnerObject( deeplyNestedCollection ); 556 | var exported = outerModel["export"](); 557 | 558 | expect( innerCollection["export"] ).to.have.been.calledOnce; 559 | expect( exported ).to.deep.equal( { returnsInner: deeplyNestedCollection_expectedExport } ); 560 | 561 | } ); 562 | 563 | expectation( 'the inner collection is unaffected by changes to the corresponding exported array', function () { 564 | 565 | outerModel.setInnerObject( deeplyNestedCollection ); 566 | var exported = outerModel["export"](); 567 | 568 | exported.returnsInner.levelOneProp[1].nestedHere.appendedAfterwards = "should not appear in inner model"; 569 | expect( innerCollection ).not.to.have.a.property( "appendedAfterwards" ); 570 | 571 | } ); 572 | 573 | expectation( 'the object wrapping the inner collection stays untouched, immune to manipulation of the exported data', function () { 574 | 575 | outerModel.setInnerObject( deeplyNestedCollection ); 576 | var exported = outerModel["export"](); 577 | 578 | exported.returnsInner.levelOneProp[1].nestedHere = "overwrite the exported inner collection"; 579 | expect( outerModel.returnsInner() ).to.deep.equal( deeplyNestedCollectionClone ); 580 | 581 | } ); 582 | 583 | } ); 584 | 585 | withCloneDeep_describe( '[+ _.cloneDeep] An attribute holds an inner model, deeply nested within other structures', function () { 586 | 587 | expectation( 'the inner model has export() called on it', function () { 588 | 589 | var model = new Backbone.Model( { containerAttribute: deeplyNestedModel } ); 590 | var exported = model["export"](); 591 | 592 | expect( innerModel["export"] ).to.have.been.calledOnce; 593 | expect( exported ).to.deep.equal( { containerAttribute: deeplyNestedModel_ExpectedExport } ); 594 | 595 | } ); 596 | 597 | expectation( 'the inner model is unaffected by changes to its exported hash', function () { 598 | 599 | var model = new Backbone.Model( { containerAttribute: deeplyNestedModel } ); 600 | var exported = model["export"](); 601 | 602 | exported.containerAttribute.levelOneProp[1].nestedHere.appendedAfterwards = "should not appear in inner model"; 603 | expect( innerModel ).not.to.have.a.property( "appendedAfterwards" ); 604 | 605 | } ); 606 | 607 | expectation( 'the object wrapping the inner model stays untouched, immune to manipulation of the exported data', function () { 608 | 609 | var model = new Backbone.Model( { containerAttribute: deeplyNestedModel } ); 610 | var exported = model["export"](); 611 | 612 | exported.containerAttribute.levelOneProp[1].nestedHere = "overwrite the exported inner collection"; 613 | expect( model.get( "containerAttribute" ) ).to.deep.equal( deeplyNestedModelClone ); 614 | 615 | } ); 616 | 617 | } ); 618 | 619 | withCloneDeep_describe( '[+ _.cloneDeep] An attribute holds an inner collection, deeply nested within other structures', function () { 620 | 621 | expectation( 'the inner collection has export() called on it', function () { 622 | 623 | var model = new Backbone.Model( { containerAttribute: deeplyNestedCollection } ); 624 | var exported = model["export"](); 625 | 626 | expect( innerCollection["export"] ).to.have.been.calledOnce; 627 | expect( exported ).to.deep.equal( { containerAttribute: deeplyNestedCollection_expectedExport } ); 628 | 629 | } ); 630 | 631 | expectation( 'the inner collection is unaffected by changes to the corresponding exported array', function () { 632 | 633 | var model = new Backbone.Model( { containerAttribute: deeplyNestedCollection } ); 634 | var exported = model["export"](); 635 | 636 | exported.containerAttribute.levelOneProp[1].nestedHere.appendedAfterwards = "should not appear in inner model"; 637 | expect( innerCollection ).not.to.have.a.property( "appendedAfterwards" ); 638 | 639 | } ); 640 | 641 | expectation( 'the object wrapping the inner collection stays untouched, immune to manipulation of the exported data', function () { 642 | 643 | var model = new Backbone.Model( { containerAttribute: deeplyNestedCollection } ); 644 | var exported = model["export"](); 645 | 646 | exported.containerAttribute.levelOneProp[1].nestedHere = "overwrite the exported inner collection"; 647 | expect( model.get( "containerAttribute" ) ).to.deep.equal( deeplyNestedCollectionClone ); 648 | 649 | } ); 650 | 651 | } ); 652 | 653 | } ); 654 | 655 | describe( 'Circular references during recursion', function () { 656 | 657 | var model1, model2, model3, Model; 658 | 659 | beforeEach( function () { 660 | 661 | Model = Backbone.Model.extend({ 662 | exportable: "next", 663 | _next: undefined, 664 | setNext: function ( next ) { this._next = next }, 665 | next: function () { return this._next } 666 | }); 667 | 668 | model1 = new Model(); 669 | model2 = new Model(); 670 | model3 = new Model(); 671 | 672 | } ); 673 | they( 'are caught when two objects return each other in an export, and don\'t cause an infinite loop (single hop)', function () { 674 | // 1 .next -> 2 .next -> 1 675 | model1.setNext( model2 ); 676 | model2.setNext( model1 ); 677 | 678 | model1["export"](); 679 | } ); 680 | 681 | they( 'are caught when the chain extends across several models, and don\'t cause an infinite loop (multiple hops)', function () { 682 | // 1 .next -> 2 .next -> 3 .next -> 1 683 | model1.setNext( model2 ); 684 | model2.setNext( model3 ); 685 | model3.setNext( model1 ); 686 | 687 | model1["export"](); 688 | } ); 689 | 690 | they( 'are caught with intermediate objects in between which are not Backbone models or collections (multiple hops, not invoking export() in part of the chain)', function () { 691 | // model 1 .next -> array -> element: generic object -> property: model 2 .next -> model 1 692 | model1.setNext( [ { property: model2 } ] ); 693 | model2.setNext( model1 ); 694 | 695 | model1["export"](); 696 | } ); 697 | 698 | they( 'return an exported representation of each model in the cycle until the recursion limit has been reached', function () { 699 | // 1 .next -> 2 .next -> 1 700 | model1.setNext( model2 ); 701 | model2.setNext( model1 ); 702 | 703 | var maxHops = Model.prototype["export"].global.maxHops; 704 | // Last exported model: Underscore returns a reference to the model, Lo-dash with _.cloneDeep 705 | // returns _.cloneDeep( last model ) 706 | var exportedLast = _.cloneDeep ? _.cloneDeep : function ( model ) { return model }; 707 | 708 | var expectedHash = maxHops % 2 ? exportedLast( model1 ) : exportedLast( model2 ); 709 | for ( var i = 0; i <= maxHops; i++ ) expectedHash = { next: expectedHash }; 710 | 711 | var exported = model1["export"](); 712 | expect( exported ).to.deep.equal( expectedHash ); 713 | } ); 714 | 715 | withoutCloneDeep_they( '[- _.cloneDeep] return a reference to the last model when the recursion limit has been reached', function () { 716 | // This test is ignored when Lodash is used during the test run; executed with Underscore 717 | 718 | // 1 .next -> 2 .next -> 1 719 | model1.setNext( model2 ); 720 | model2.setNext( model1 ); 721 | 722 | var seed = model1; 723 | 724 | var expectedLast = seed.next(), hops = 0; 725 | while ( hops++ < Model.prototype["export"].global.maxHops ) expectedLast = expectedLast.next(); 726 | 727 | var exported = seed["export"](); 728 | var inner = exported.next; 729 | while ( !_.isFunction( inner.next ) ) inner = inner.next; 730 | 731 | expect( inner ).to.deep.equal( expectedLast ); 732 | } ); 733 | 734 | withCloneDeep_they( '[+ _.cloneDeep] return a _.cloneDeep representation of the last model (deep clone of properties) when the recursion limit has been reached', function () { 735 | // This test is ignored when Underscore is used during the test run; executed with Lodash 736 | 737 | // 1 .next -> 2 .next -> 1 738 | model1.setNext( model2 ); 739 | model2.setNext( model1 ); 740 | 741 | var seed = model1; 742 | 743 | var expectedLast = seed.next(), hops = 0; 744 | while ( hops++ < Model.prototype["export"].global.maxHops ) expectedLast = expectedLast.next(); 745 | expectedLast = _.cloneDeep( expectedLast ); 746 | 747 | var exported = seed["export"](); 748 | var inner = exported.next; 749 | while ( inner.next ) inner = inner.next; 750 | 751 | expect( inner ).to.deep.equal( expectedLast ); 752 | } ); 753 | 754 | } ); 755 | 756 | } ); 757 | 758 | describe( 'The onExport() handler', function () { 759 | 760 | it( 'is run by export(). It receives a hash of the model properties - the toJSON() data - as an argument', function () { 761 | 762 | var model = new Backbone.Model(); 763 | sinon.spy( model, "onExport" ); 764 | 765 | model.set( { property: "a value", anotherProperty: "another value" } ); 766 | var propHash = model.toJSON(); 767 | 768 | model["export"](); 769 | expect( model.onExport ).to.have.been.calledWithExactly( propHash ); 770 | 771 | } ); 772 | 773 | it( 'receives the properties hash last, ie after the methods marked as "exportable" have been transformed into properties of the hash', function () { 774 | 775 | var Model = ModelWithMethod.extend( { exportable: "method" } ); 776 | var model = new Model(); 777 | sinon.spy( model, "onExport" ); 778 | 779 | model["export"](); 780 | expect( model.onExport ).to.have.been.calledWithExactly( { method: "returning a value" } ); 781 | 782 | } ); 783 | 784 | it( 'is able to alter the data before it is returned by export()', function () { 785 | 786 | var Model = Backbone.Model.extend( { 787 | onExport: function ( data ) { 788 | data.property = "in modified state"; 789 | return data; 790 | } 791 | } ); 792 | var model = new Model(); 793 | model.set( { property: "in original state" } ); 794 | 795 | expect( model["export"]() ).to.have.a.property( 'property' ).with.a.string( "in modified state" ); 796 | 797 | } ); 798 | 799 | it( 'acts (at least) on a shallow clone of the data, permitting to change to top-level properties of the data without affecting the model', function () { 800 | 801 | var Model = Backbone.Model.extend( { 802 | defaults: { innerObject: { whoami: "inner object, model data" } }, 803 | onExport: function ( data ) { 804 | data.innerObject = "a string replaces the inner object in exported data"; 805 | return data; 806 | } 807 | } ); 808 | var model = new Model(); 809 | 810 | model["export"](); 811 | expect( model.get( "innerObject" ) ).to.deep.equal( { whoami: "inner object, model data" } ); 812 | 813 | } ); 814 | 815 | withCloneDeep_it( '[+ _.cloneDeep] acts on a deep clone of the data, permitting to change to nested properties of the data without affecting the model', function () { 816 | 817 | var Model = Backbone.Model.extend( { 818 | defaults: { innerObject: { whoami: "inner object, model data" } }, 819 | onExport: function ( data ) { 820 | data.innerObject.whoami = "inner object, exported data"; 821 | return data; 822 | } 823 | } ); 824 | var model = new Model(); 825 | 826 | model["export"](); 827 | expect( model.get( "innerObject" ).whoami ).to.equal( "inner object, model data" ); 828 | 829 | } ); 830 | 831 | } ); 832 | 833 | describe( 'The onBeforeExport() handler', function () { 834 | 835 | it( 'is run when export() is called', function () { 836 | 837 | var model = new Backbone.Model(); 838 | sinon.spy( model, "onBeforeExport" ); 839 | 840 | model["export"](); 841 | expect( model.onBeforeExport ).to.have.been.calledOnce; 842 | 843 | } ); 844 | 845 | it( 'can modify the model state before it is turned into a hash', function () { 846 | 847 | var Model = Backbone.Model.extend( { 848 | onBeforeExport: function () { 849 | this.set( { property: "in modified state" } ); 850 | } 851 | } ); 852 | var model = new Model(); 853 | 854 | model.set( { property: "in original state" } ); 855 | 856 | expect( model["export"]() ).to.have.a.property( 'property' ).with.a.string( "in modified state" ); 857 | 858 | } ); 859 | 860 | it( 'can manipulate other, "exportable" model methods before they are transformed and added to the hash', function () { 861 | 862 | var Model = Backbone.Model.extend( { 863 | exportable: "method", 864 | onBeforeExport: function () { 865 | this.method = function () { return "manipulated method return value"; } 866 | }, 867 | method: function () { return "original method return value"; } 868 | } ); 869 | var model = new Model(); 870 | 871 | expect( model["export"]() ).to.have.a.property( 'method' ).with.a.string( "manipulated method return value" ); 872 | 873 | } ); 874 | 875 | it( 'can manipulate the "exportable" property itself before model state is turned into a hash', function () { 876 | 877 | var Model = Backbone.Model.extend( { 878 | exportable: "droppedProperty", 879 | onBeforeExport: function () { 880 | this.exportable = "includedProperty"; 881 | }, 882 | droppedProperty: "value of dropped property", 883 | includedProperty: "value of dynamically included property" 884 | } ); 885 | var model = new Model(); 886 | 887 | expect( model["export"]() ).not.to.have.a.property( 'droppedProperty' ); 888 | expect( model["export"]() ).to.have.a.property( 'includedProperty' ).with.a.string( "value of dynamically included property" ); 889 | 890 | } ); 891 | 892 | it( 'runs before onExport()', function () { 893 | 894 | var model = new Backbone.Model(); 895 | sinon.spy( model, "onBeforeExport" ); 896 | sinon.spy( model, "onExport" ); 897 | 898 | model["export"](); 899 | expect( model.onBeforeExport ).to.have.been.calledBefore( model.onExport ); 900 | 901 | } ); 902 | 903 | } ); 904 | 905 | describe( 'The onAfterExport() handler', function () { 906 | 907 | it( 'is run when export() is called', function () { 908 | 909 | var model = new Backbone.Model(); 910 | sinon.spy( model, "onAfterExport" ); 911 | 912 | model["export"](); 913 | expect( model.onAfterExport ).to.have.been.calledOnce; 914 | 915 | } ); 916 | 917 | it( 'can act on the model state after it has been turned into a hash, leaving the exported hash unchanged', function () { 918 | 919 | var Model = Backbone.Model.extend( { 920 | onAfterExport: function () { 921 | this.set( { property: "in modified state" } ); 922 | } 923 | } ); 924 | var model = new Model(); 925 | 926 | model.set( { property: "in original state" } ); 927 | 928 | expect( model["export"]() ).to.have.a.property( 'property' ).with.a.string( "in original state" ); 929 | expect( model.get( 'property' ) ).to.be.a.string( "in modified state" ); 930 | 931 | } ); 932 | 933 | it( 'can manipulate other, "exportable" model methods only after they have been transformed and added to the hash', function () { 934 | 935 | var Model = Backbone.Model.extend( { 936 | exportable: "method", 937 | onAfterExport: function () { 938 | this.method = function () { return "manipulated method return value"; } 939 | }, 940 | method: function () { return "original method return value"; } 941 | } ); 942 | var model = new Model(); 943 | 944 | expect( model["export"]() ).to.have.a.property( 'method' ).with.a.string( "original method return value" ); 945 | expect( model.method() ).to.be.a.string( "manipulated method return value" ); 946 | 947 | } ); 948 | 949 | it( 'runs after onExport()', function () { 950 | 951 | var model = new Backbone.Model(); 952 | sinon.spy( model, "onAfterExport" ); 953 | sinon.spy( model, "onExport" ); 954 | 955 | model["export"](); 956 | expect( model.onAfterExport ).to.have.been.calledAfter( model.onExport ); 957 | 958 | } ); 959 | 960 | } ); 961 | 962 | } ); 963 | 964 | })(); 965 | --------------------------------------------------------------------------------