├── .gitignore ├── .jshintrc ├── .travis.yml ├── CONTRIBUTING.md ├── Gruntfile.js ├── LICENSE-BSDv3 ├── README.md ├── bower.json ├── demo ├── demo │ ├── __init__.py │ ├── settings.py │ ├── todos │ │ ├── __init__.py │ │ ├── admin.py │ │ ├── models.py │ │ ├── urls.py │ │ └── views.py │ └── urls.py ├── index.html ├── manage.py ├── static │ ├── css │ │ └── demo.css │ ├── fonts │ │ ├── icons.ttf │ │ └── icons.woff │ └── js │ │ ├── django-superformset.min.js │ │ └── jquery.js └── templates │ └── index.html ├── dist ├── django-superformset.js └── django-superformset.min.js ├── django-superformset.jquery.json ├── libs ├── jquery-loader.js ├── jquery │ └── jquery.js └── qunit │ ├── qunit-assert-html.js │ ├── qunit.css │ ├── qunit.js │ ├── sinon-qunit.js │ └── sinon.js ├── package.json ├── src ├── .jshintrc └── django-superformset.js └── test ├── .jshintrc ├── django-superformset.html └── django-superformset_test.js /.gitignore: -------------------------------------------------------------------------------- 1 | *.DS_Store 2 | *~ 3 | /node_modules/ 4 | jscov/ 5 | jscov_temp/ 6 | *.sqlite3 7 | -------------------------------------------------------------------------------- /.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "node": true, 3 | 4 | "bitwise": true, 5 | "curly": true, 6 | "eqeqeq": true, 7 | "forin": true, 8 | "immed": true, 9 | "indent": 2, 10 | "latedef": true, 11 | "newcap": true, 12 | "noarg": true, 13 | "noempty": true, 14 | "nonew": true, 15 | "plusplus": true, 16 | "quotmark": "single", 17 | "undef": true, 18 | "unused": true, 19 | "strict": true, 20 | "trailing": true, 21 | "maxparams": 4 22 | } 23 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | 3 | node_js: 4 | - "0.11" 5 | - "0.10" 6 | 7 | before_install: 8 | - npm install -g grunt-cli 9 | 10 | matrix: 11 | fast_finish: true 12 | allow_failures: 13 | - node_js: "0.11" 14 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | ## Important notes 4 | Please don't edit files in the `dist` subdirectory as they are generated via Grunt. You'll find source code in the `src` subdirectory! 5 | 6 | ### Code style 7 | Regarding code style like indentation and whitespace, **follow the conventions you see used in the source already.** 8 | 9 | ### PhantomJS 10 | While Grunt can run the included unit tests via [PhantomJS](http://phantomjs.org/), this shouldn't be considered a substitute for the real thing. Please be sure to test the `test/*.html` unit test file(s) in _actual_ browsers. 11 | 12 | ## Modifying the code 13 | First, ensure that you have the latest [Node.js](http://nodejs.org/) and [npm](http://npmjs.org/) installed. 14 | 15 | Test that Grunt's CLI is installed by running `grunt --version`. If the command isn't found, run `npm install -g grunt-cli`. For more information about installing Grunt, see the [getting started guide](http://gruntjs.com/getting-started). 16 | 17 | 1. Fork and clone the repo. 18 | 1. Run `npm install` to install all dependencies (including Grunt). 19 | 1. Run `grunt` to grunt this project. 20 | 21 | Assuming that you don't see any red, you're ready to go. Just be sure to run `grunt` after making any changes, to ensure that nothing is broken. 22 | 23 | ## Submitting pull requests 24 | 25 | 1. Create a new branch, please don't work in your `master` branch directly. 26 | 1. Add failing tests for the change you want to make. Run `grunt` to see the tests fail. 27 | 1. Fix stuff. 28 | 1. Run `grunt` to see if the tests pass. Repeat steps 2-4 until done. 29 | 1. Open `test/*.html` unit test file(s) in actual browser to ensure tests pass everywhere. 30 | 1. Update the documentation to reflect any changes. 31 | 1. Push to your fork and submit a pull request. 32 | -------------------------------------------------------------------------------- /Gruntfile.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = function(grunt) { 4 | 5 | // Project configuration. 6 | grunt.initConfig({ 7 | // Metadata. 8 | pkg: grunt.file.readJSON('django-superformset.jquery.json'), 9 | banner: '/*! <%= pkg.title || pkg.name %> - v<%= pkg.version %> - ' + 10 | '<%= grunt.template.today("yyyy-mm-dd") %>\n' + 11 | '<%= pkg.homepage ? "* " + pkg.homepage + "\\n" : "" %>' + 12 | '* Based on jQuery Formset 1.1r14, by Stanislaus Madueke\n' + 13 | '* Original Portions Copyright (c) 2009 Stanislaus Madueke\n' + 14 | '* Modifications Copyright (c) <%= grunt.template.today("yyyy") %> <%= pkg.author.name %>;' + 15 | ' Licensed <%= _.pluck(pkg.licenses, "type").join(", ") %> */\n', 16 | // Task configuration. 17 | clean: { 18 | files: ['dist'] 19 | }, 20 | concat: { 21 | options: { 22 | banner: '<%= banner %>', 23 | stripBanners: true 24 | }, 25 | dist: { 26 | src: ['src/<%= pkg.name %>.js'], 27 | dest: 'dist/<%= pkg.name %>.js' 28 | }, 29 | }, 30 | uglify: { 31 | options: { 32 | banner: '<%= banner %>' 33 | }, 34 | dist: { 35 | src: '<%= concat.dist.dest %>', 36 | dest: 'dist/<%= pkg.name %>.min.js' 37 | }, 38 | demo: { 39 | src: '<%= concat.dist.dest %>', 40 | dest: 'demo/static/js/<%= pkg.name %>.min.js' 41 | }, 42 | }, 43 | qunit: { 44 | options: { 45 | coverage: { 46 | src: ['src/**/*.js'], 47 | instrumentedFiles: 'jscov_temp/', 48 | htmlReport: 'jscov/' 49 | } 50 | }, 51 | files: ['test/**/*.html'] 52 | }, 53 | jshint: { 54 | gruntfile: { 55 | options: { 56 | jshintrc: '.jshintrc' 57 | }, 58 | src: 'Gruntfile.js' 59 | }, 60 | src: { 61 | options: { 62 | jshintrc: 'src/.jshintrc' 63 | }, 64 | src: ['src/**/*.js'] 65 | }, 66 | test: { 67 | options: { 68 | jshintrc: 'test/.jshintrc' 69 | }, 70 | src: ['test/**/*.js'] 71 | }, 72 | }, 73 | watch: { 74 | gruntfile: { 75 | files: '<%= jshint.gruntfile.src %>', 76 | tasks: ['jshint:gruntfile'] 77 | }, 78 | src: { 79 | files: '<%= jshint.src.src %>', 80 | tasks: ['jshint:src', 'qunit'] 81 | }, 82 | test: { 83 | files: '<%= jshint.test.src %>', 84 | tasks: ['jshint:test', 'qunit'] 85 | }, 86 | }, 87 | }); 88 | 89 | // These plugins provide necessary tasks. 90 | grunt.loadNpmTasks('grunt-contrib-clean'); 91 | grunt.loadNpmTasks('grunt-contrib-concat'); 92 | grunt.loadNpmTasks('grunt-contrib-uglify'); 93 | grunt.loadNpmTasks('grunt-qunit-istanbul'); 94 | grunt.loadNpmTasks('grunt-contrib-jshint'); 95 | grunt.loadNpmTasks('grunt-contrib-watch'); 96 | 97 | // Default task. 98 | grunt.registerTask('default', ['jshint', 'qunit', 'clean', 'concat', 'uglify']); 99 | 100 | }; 101 | -------------------------------------------------------------------------------- /LICENSE-BSDv3: -------------------------------------------------------------------------------- 1 | Original Portions Copyright (c) 2009, Stanislaus Madueke 2 | Modifications Copyright (c) 2013, Jonny Gerig Meyer 3 | All rights reserved. 4 | 5 | Redistribution and use in source and binary forms, with or without modification, 6 | are permitted provided that the following conditions are met: 7 | * Redistributions of source code must retain the above copyright notice, 8 | this list of conditions and the following disclaimer. 9 | * Redistributions in binary form must reproduce the above copyright notice, 10 | this list of conditions and the following disclaimer in the documentation 11 | and/or other materials provided with the distribution. 12 | * Neither the name of the organization nor the names of its contributors may 13 | be used to endorse or promote products derived from this software without 14 | specific prior written permission. 15 | 16 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 17 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 18 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 19 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR 20 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 21 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 22 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 23 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 24 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 25 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 26 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Django Superformset 2 | 3 | [![Build Status](https://travis-ci.org/jgerigmeyer/jquery-django-superformset.svg?branch=master)](https://travis-ci.org/jgerigmeyer/jquery-django-superformset) 4 | [![Built with Grunt](https://cdn.gruntjs.com/builtwith.png)](http://gruntjs.com/) 5 | 6 | jQuery Django Dynamic Formset Plugin 7 | 8 | ## Getting Started 9 | 10 | Download the [production version][min] or the [development version][max]. 11 | 12 | [min]: https://raw.github.com/jgerigmeyer/jquery-django-superformset/master/dist/django-superformset.min.js 13 | [max]: https://raw.github.com/jgerigmeyer/jquery-django-superformset/master/dist/django-superformset.js 14 | 15 | In your web page: 16 | 17 | ```html 18 |
19 |
20 | 21 | 22 |
23 | 24 | 25 | 26 |
27 |
28 |
29 |
30 | 31 | 32 | 33 |
34 |
35 |
36 | 37 | 38 | 39 | 40 | 45 | ``` 46 | 47 | ## Documentation 48 | 49 | There are numerous options documented in the [development version][max]. 50 | 51 | Available options, explictly set to their defaults: 52 | 53 | ```html 54 | $('.formlist').superformset({ 55 | prefix: 'form', // The form prefix for your django formset 56 | containerSel: 'form', // Container selector (must contain rows and formTemplate) 57 | rowSel: '.dynamic-form', // Selector used to match each form (row) in a formset 58 | formTemplate: '.empty-form .dynamic-form', 59 | // Selector for empty form (row) template to be cloned to 60 | // ...generate new form instances 61 | // ...This must be outside the element on which ``formset`` is 62 | // ...called, but within the containerSel 63 | deleteTrigger: 'remove', 64 | // The HTML "remove" link added to the end of each form-row 65 | // ...(if ``canDelete: true``) 66 | deleteTriggerSel: '.remove-row',// Selector for HTML "remove" links 67 | // ...Used to target existing delete-trigger, or to target 68 | // ...``deleteTrigger`` 69 | addTrigger: 'add', 70 | // The HTML "add" link added to the end of all forms if no 71 | // ...``addTriggerSel`` 72 | addTriggerSel: null, // Selector for trigger to add a new row, if already in markup 73 | // ...Used to target existing trigger; if provided, 74 | // ...``addTrigger`` will be ignored 75 | addedCallback: null, // Function called each time a new form row is added 76 | removedCallback: null, // Function called each time a form row is deleted 77 | deletedRowClass: 'deleted', // Added to deleted row if ``canDelete: false`` 78 | addAnimationSpeed: 'normal', // Speed (ms) to animate adding rows 79 | // ...If false, new rows will appear without animation 80 | removeAnimationSpeed: 'fast', // Speed (ms) to animate removing rows 81 | // ...If false, new rows will disappear without animation 82 | autoAdd: false, // If true, the "add" link will be removed, and a row will be 83 | // ...automatically added when text is entered in the final 84 | // ...textarea of the last row 85 | alwaysShowExtra: false, // If true, an extra (empty) row will always be displayed 86 | // ...(requires ``autoAdd: true``) 87 | deleteOnlyActive: false, // If true, extra empty rows cannot be removed until they 88 | // ...acquire focus (requires ``alwaysShowExtra: true``) 89 | canDelete: false, // If false, rows cannot be deleted (removed from the DOM). 90 | // ...``deleteTriggerSel`` will remove ``required`` attr from 91 | // ...fields within a "deleted" row 92 | // ...deleted rows should be hidden via CSS 93 | deleteOnlyNew: false, // If true, only newly-added rows can be deleted 94 | // ...(requires ``canDelete: true``) 95 | insertAbove: false, // If true, ``insertAboveTrigger`` will be added to the end of 96 | // ...each form-row 97 | insertAboveTrigger: 'insert', 98 | // The HTML "insert" link add to the end of each form-row 99 | // ...(requires ``insertAbove: true``) 100 | optionalIfEmpty: true, // If true, required fields in a row will be optional until 101 | // ...changed from their initial values 102 | optionalIfEmptySel: '[data-empty-permitted="true"]' 103 | // Selector for rows to apply optionalIfEmpty logic 104 | // ...(requires ``optionalIfEmpty: true``) 105 | }); 106 | ``` 107 | 108 | ## Release History 109 | 110 | * 1.0.4 - (06/13/2014) Bugfix: Allow adding row after deleting all rows with ``canDelete: true`` 111 | * 1.0.3 - (04/16/2014) Make totalForms and maxForms selectors less restrictive 112 | * 1.0.2 - (02/20/2014) Add deletedRowClass option 113 | * 1.0.1 - (02/19/2014) Add bower.json 114 | * 1.0.0 - (10/14/2013) Initial release 115 | -------------------------------------------------------------------------------- /bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "django-superformset", 3 | "version": "1.0.4", 4 | "homepage": "https://github.com/jgerigmeyer/jquery-django-superformset", 5 | "authors": [ 6 | "Jonny Gerig Meyer " 7 | ], 8 | "description": "jQuery Django Dynamic Formset Plugin", 9 | "main": "dist/django-superformset.js", 10 | "keywords": [ 11 | "formset", 12 | "dynamic", 13 | "django" 14 | ], 15 | "license": "BSDv3", 16 | "ignore": [ 17 | "**/.*", 18 | "node_modules", 19 | "bower_components", 20 | "test", 21 | "libs", 22 | "Gruntfile.js", 23 | "demo" 24 | ], 25 | "dependencies": { 26 | "jquery": "*" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /demo/demo/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jgerigmeyer/jquery-django-superformset/07c2a2c018c73c973afac5313c0ec50888eeaa32/demo/demo/__init__.py -------------------------------------------------------------------------------- /demo/demo/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for demo project. 3 | 4 | For more information on this file, see 5 | https://docs.djangoproject.com/en/1.6/topics/settings/ 6 | 7 | For the full list of settings and their values, see 8 | https://docs.djangoproject.com/en/1.6/ref/settings/ 9 | """ 10 | 11 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 12 | import os 13 | BASE_DIR = os.path.dirname(os.path.dirname(__file__)) 14 | 15 | 16 | # Quick-start development settings - unsuitable for production 17 | # See https://docs.djangoproject.com/en/1.6/howto/deployment/checklist/ 18 | 19 | # SECURITY WARNING: keep the secret key used in production secret! 20 | SECRET_KEY = 'h@&4y-z01hm3wksscqi#o_cmfjm^a&$m0m&jq(o=#8r3x#ldr7' 21 | 22 | # SECURITY WARNING: don't run with debug turned on in production! 23 | DEBUG = True 24 | 25 | TEMPLATE_DEBUG = True 26 | 27 | ALLOWED_HOSTS = [] 28 | 29 | 30 | # Application definition 31 | 32 | INSTALLED_APPS = ( 33 | 'django.contrib.admin', 34 | 'django.contrib.auth', 35 | 'django.contrib.contenttypes', 36 | 'django.contrib.sessions', 37 | 'django.contrib.messages', 38 | 'django.contrib.staticfiles', 39 | 'demo', 40 | 'demo.todos', 41 | ) 42 | 43 | MIDDLEWARE_CLASSES = ( 44 | 'django.contrib.sessions.middleware.SessionMiddleware', 45 | 'django.middleware.common.CommonMiddleware', 46 | 'django.middleware.csrf.CsrfViewMiddleware', 47 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 48 | 'django.contrib.messages.middleware.MessageMiddleware', 49 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 50 | ) 51 | 52 | ROOT_URLCONF = 'demo.urls' 53 | 54 | WSGI_APPLICATION = 'demo.wsgi.application' 55 | 56 | TEMPLATE_DIRS = os.path.join(BASE_DIR, 'templates') 57 | 58 | 59 | # Database 60 | # https://docs.djangoproject.com/en/1.6/ref/settings/#databases 61 | 62 | DATABASES = { 63 | 'default': { 64 | 'ENGINE': 'django.db.backends.sqlite3', 65 | 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), 66 | } 67 | } 68 | 69 | # Internationalization 70 | # https://docs.djangoproject.com/en/1.6/topics/i18n/ 71 | 72 | LANGUAGE_CODE = 'en-us' 73 | 74 | TIME_ZONE = 'US/Eastern' 75 | 76 | USE_I18N = True 77 | 78 | USE_L10N = False 79 | 80 | USE_TZ = True 81 | 82 | 83 | # Static files (CSS, JavaScript, Images) 84 | # https://docs.djangoproject.com/en/1.6/howto/static-files/ 85 | 86 | STATIC_URL = '/static/' 87 | STATICFILES_DIRS = [os.path.join(BASE_DIR, 'static')] 88 | -------------------------------------------------------------------------------- /demo/demo/todos/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jgerigmeyer/jquery-django-superformset/07c2a2c018c73c973afac5313c0ec50888eeaa32/demo/demo/todos/__init__.py -------------------------------------------------------------------------------- /demo/demo/todos/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from .models import Todo 3 | 4 | 5 | admin.site.register(Todo) 6 | -------------------------------------------------------------------------------- /demo/demo/todos/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | 4 | class Todo(models.Model): 5 | name = models.CharField(max_length=200) 6 | -------------------------------------------------------------------------------- /demo/demo/todos/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import patterns, url 2 | 3 | from . import views 4 | 5 | 6 | urlpatterns = patterns('', 7 | url(r'^$', views.index, name='index') 8 | ) 9 | -------------------------------------------------------------------------------- /demo/demo/todos/views.py: -------------------------------------------------------------------------------- 1 | from django.core.context_processors import csrf 2 | from django.forms.models import modelformset_factory 3 | from django.shortcuts import render_to_response 4 | from django.http import HttpResponseRedirect 5 | from django.template import RequestContext 6 | 7 | from .models import Todo 8 | 9 | 10 | def index(request): 11 | TodoFormSet = modelformset_factory(Todo, can_delete=True) 12 | if request.method == 'POST': 13 | formset = TodoFormSet(request.POST) 14 | if formset.is_valid(): 15 | formset.save() 16 | return HttpResponseRedirect("/") 17 | else: 18 | formset = TodoFormSet() 19 | 20 | return render_to_response( 21 | 'index.html', 22 | {'formset': formset}, 23 | context_instance=RequestContext(request), 24 | ) 25 | -------------------------------------------------------------------------------- /demo/demo/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import patterns, include, url 2 | 3 | from django.contrib import admin 4 | admin.autodiscover() 5 | 6 | 7 | urlpatterns = patterns('', 8 | url(r'^$', include('demo.todos.urls')), 9 | url(r'^admin/', include(admin.site.urls)), 10 | ) 11 | -------------------------------------------------------------------------------- /demo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Static Demo | jQuery Django Superformset 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 |
26 |
27 | 28 | 29 | 30 |
31 |
32 | 33 | 34 | 35 |
36 | 37 | 38 | 39 | 40 | 41 |
42 |
43 | 44 | 45 | 46 | 47 | 48 |
49 |
50 | 51 | 52 | 53 | 54 | 55 |
56 |
57 |
58 | 67 |
68 | 69 |
70 |
71 |

JavaScript:

72 |
 73 | jQuery(function($) {
 74 |   $('.init').one('click', function () {
 75 |     $('.formlist').superformset({
 76 |       containerSel: '.demo',
 77 |       deleteTriggerSel: 'input[name$="-DELETE"]'
 78 |     });
 79 |   });
 80 | });
 81 |           
82 |
83 |
84 |

Django Template:

85 |
 86 | <form method="post">
 87 |   <fieldset class="formlist">
 88 |     {{ formset.management_form }}
 89 |     {% for form in formset %}
 90 |     <div class="dynamic-form">
 91 |       {{ form }}
 92 |     </div>
 93 |     {% endfor %}
 94 |   </fieldset>
 95 | </form>
 96 | 
 97 | <div class="empty-form" style="display: none;">
 98 |   <div class="dynamic-form">
 99 |     {{ formset.empty_form }}
100 |   </div>
101 | </div>
102 |           
103 |
104 |
105 |
106 | 107 | 117 | 118 | 119 | -------------------------------------------------------------------------------- /demo/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | if __name__ == "__main__": 6 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "demo.settings") 7 | 8 | from django.core.management import execute_from_command_line 9 | 10 | execute_from_command_line(sys.argv) 11 | -------------------------------------------------------------------------------- /demo/static/css/demo.css: -------------------------------------------------------------------------------- 1 | /* Superformset demo styles */ 2 | 3 | @font-face { 4 | font-family: "superformset-demo"; 5 | src: url('../fonts/icons.woff') format("woff"), url('../fonts/icons.ttf') format("truetype"); 6 | } 7 | 8 | #css-input:checked ~ form .add-row { 9 | font-size: 0.75rem; 10 | line-height: 1.3125rem; 11 | margin-top: -0.32813rem; 12 | float: right; 13 | margin-right: 1.64063rem; 14 | } 15 | #css-input:checked ~ form .add-row:before { 16 | vertical-align: middle; 17 | content: "\e600"; 18 | font-family: "superformset-demo"; 19 | display: inline-block; 20 | } 21 | #css-input:checked ~ form input[type="text"] { 22 | width: 80%; 23 | } 24 | #css-input:checked ~ form .deleted { 25 | display: none; 26 | } 27 | 28 | /* Demo page styles */ 29 | 30 | * { 31 | box-sizing: border-box; 32 | } 33 | 34 | html { 35 | font-family: "Franklin Gothic Medium", "Franklin Gothic", "ITC Franklin Gothic", Arial, sans-serif; 36 | } 37 | 38 | .demo { 39 | min-width: 34em; 40 | max-width: 52em; 41 | margin: 1em auto; 42 | padding: 1em; 43 | } 44 | 45 | .demo-content { 46 | text-align: right; 47 | } 48 | 49 | form { 50 | clear: both; 51 | width: 100%; 52 | margin-bottom: 1em; 53 | text-align: left; 54 | } 55 | 56 | .init { 57 | float: left; 58 | margin-bottom: 1em; 59 | padding: 1em 2em; 60 | border: none; 61 | border-radius: .25em; 62 | background: pink; 63 | font: inherit; 64 | font-size: 1.25em; 65 | text-transform: uppercase; 66 | color: white; 67 | text-shadow: 0 1px 0 rgba(0, 0, 0, 0.5); 68 | transition: all 150ms; 69 | } 70 | 71 | .init:focus { 72 | outline: none; 73 | box-shadow: 0 0 1px 2px rgba(0, 0, 0, 0.1) inset; 74 | } 75 | 76 | .init:hover { 77 | cursor: pointer; 78 | background: #fd9297; 79 | } 80 | 81 | .init:active { 82 | background: #bc6d71; 83 | box-shadow: 0 0 1px 2px rgba(0, 0, 0, 0.25) inset; 84 | } 85 | 86 | label[for="css-input"] { 87 | display: inline-block; 88 | padding-top: 3em; 89 | } 90 | 91 | pre { 92 | overflow: auto; 93 | font-family: "Courier New", Courier, "Lucida Sans Typewriter", "Lucida Typewriter", monospace, serif; 94 | font-size: .85em; 95 | line-height: 1.5em; 96 | color: grey; 97 | } 98 | 99 | .code-samples .js { 100 | clear: both; 101 | margin-bottom: 1em; 102 | } 103 | 104 | .code-samples .html { 105 | clear: both; 106 | border-top: 1px dashed; 107 | border-color: grey; 108 | } 109 | -------------------------------------------------------------------------------- /demo/static/fonts/icons.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jgerigmeyer/jquery-django-superformset/07c2a2c018c73c973afac5313c0ec50888eeaa32/demo/static/fonts/icons.ttf -------------------------------------------------------------------------------- /demo/static/fonts/icons.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jgerigmeyer/jquery-django-superformset/07c2a2c018c73c973afac5313c0ec50888eeaa32/demo/static/fonts/icons.woff -------------------------------------------------------------------------------- /demo/static/js/django-superformset.min.js: -------------------------------------------------------------------------------- 1 | /*! Django Superformset - v1.0.4 - 2014-06-13 2 | * https://github.com/jgerigmeyer/jquery-django-superformset 3 | * Based on jQuery Formset 1.1r14, by Stanislaus Madueke 4 | * Original Portions Copyright (c) 2009 Stanislaus Madueke 5 | * Modifications Copyright (c) 2014 Jonny Gerig Meyer; Licensed BSDv3 */ 6 | !function(a){"use strict";var b={init:function(c){var d={},e=d.opts=a.extend({},a.fn.superformset.defaults,c),f=d.wrapper=a(this),g=d.rows=f.find(e.rowSel),h=d.container=g.closest(e.containerSel);d.totalForms=f.find('input[id$="'+e.prefix+'-TOTAL_FORMS"]'),d.maxForms=f.find('input[id$="'+e.prefix+'-MAX_NUM_FORMS"]');var i=d.tpl=h.find(e.formTemplate).clone(!0);return h.find(e.formTemplate).find("[required], .required").removeAttr("required").removeData("required-by").addClass("required"),i.removeAttr("id").find("input, select, textarea").filter("[required]").addClass("required").removeAttr("required"),b.addDeleteTrigger(i,e.canDelete,d),b.addInsertAboveTrigger(i,d),g.each(function(){var c=a(this);b.addDeleteTrigger(c,e.canDelete&&!e.deleteOnlyNew,d),b.addInsertAboveTrigger(c,d),b.watchForChangesToOptionalIfEmptyRow(c,d)}),e.autoAdd||b.activateAddTrigger(d),e.alwaysShowExtra&&e.autoAdd&&(b.autoAddRow(d),f.closest("form").submit(function(){a(this).find(e.rowSel).filter(".extra-row").find("input, select, textarea").each(function(){a(this).removeAttr("name")})})),f},activateAddTrigger:function(c){var d,e=c.opts;d=c.addButton=c.wrapper.find(e.addTriggerSel).length?c.wrapper.find(e.addTriggerSel):a(e.addTrigger).appendTo(c.wrapper),b.showAddButton(c)||d.hide(),d.click(function(d){var f=a(this),g=parseInt(c.totalForms.val(),10),h=c.tpl.clone(!0).addClass("new-row"),i=c.wrapper.find(e.rowSel).last();h.find("input, select, textarea").filter(".required").attr("required","required"),e.addAnimationSpeed?(i.length?h.hide().insertAfter(i):h.hide().insertBefore(f),h.animate({height:"toggle",opacity:"toggle"},e.addAnimationSpeed)):i.length?h.insertAfter(i).show():h.insertBefore(f).show(),h.find("input, select, textarea, label").each(function(){b.updateElementIndex(a(this),e.prefix,g)}),b.watchForChangesToOptionalIfEmptyRow(h,c),c.totalForms.val(g+1),b.showAddButton(c)||f.hide(),e.addedCallback&&e.addedCallback(h),d.preventDefault()})},watchForChangesToOptionalIfEmptyRow:function(a,c){var d=c.opts;if(d.optionalIfEmpty&&a.is(d.optionalIfEmptySel)){var e=a.find("input, select, textarea");e.filter("[required], .required").removeAttr("required").data("required-by",d.prefix).addClass("required"),a.data("original-vals",e.serialize()),e.not(d.deleteTriggerSel).change(function(){b.updateRequiredFields(a,c)})}},updateElementIndex:function(a,b,c){var d=new RegExp("("+b+"-(\\d+|__prefix__))"),e=b+"-"+c;a.attr("for")&&a.attr("for",a.attr("for").replace(d,e)),a.attr("id")&&a.attr("id",a.attr("id").replace(d,e)),a.attr("name")&&a.attr("name",a.attr("name").replace(d,e))},showAddButton:function(a){return""===a.maxForms.val()||a.maxForms.val()-a.totalForms.val()>0},addDeleteTrigger:function(c,d,e){var f=e.opts;d?a(f.deleteTrigger).appendTo(c).click(function(c){var d,g,h=a(this).closest(f.rowSel),i=function(c,d){c.eq(d).find("input, select, textarea, label").each(function(){b.updateElementIndex(a(this),f.prefix,d)})},j=function(){for(h.remove(),d=e.wrapper.find(f.rowSel),e.totalForms.val(d.not(".extra-row").length),g=0;gremove',deleteTriggerSel:".remove-row",addTrigger:'add',addTriggerSel:null,addedCallback:null,removedCallback:null,deletedRowClass:"deleted",addAnimationSpeed:"normal",removeAnimationSpeed:"fast",autoAdd:!1,alwaysShowExtra:!1,deleteOnlyActive:!1,canDelete:!1,deleteOnlyNew:!1,insertAbove:!1,insertAboveTrigger:'insert',optionalIfEmpty:!0,optionalIfEmptySel:'[data-empty-permitted="true"]'}}(jQuery); -------------------------------------------------------------------------------- /demo/templates/index.html: -------------------------------------------------------------------------------- 1 | {% load staticfiles %} 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | Demo | jQuery Django Superformset 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 |
28 |
29 | 30 | 31 | 32 |
33 | {% csrf_token %} 34 |
35 | {{ formset.management_form }} 36 | {% for form in formset %} 37 |
38 | {{ form }} 39 |
40 | {% endfor %} 41 |
42 | 43 |
44 | 45 | 50 |
51 | 52 |
53 |
54 |

JavaScript:

55 |
 56 | jQuery(function($) {
 57 |   $('.init').one('click', function () {
 58 |     $('.formlist').superformset({
 59 |       containerSel: '.demo',
 60 |       deleteTriggerSel: 'input[name$="-DELETE"]'
 61 |     });
 62 |   });
 63 | });
 64 |           
65 |
66 |
67 |

Django Template:

68 |
 69 | {% verbatim %}
 70 | <form method="post">
 71 |   {% csrf_token %}
 72 |   <fieldset class="formlist">
 73 |     {{ formset.management_form }}
 74 |     {% for form in formset %}
 75 |     <div class="dynamic-form">
 76 |       {{ form }}
 77 |     </div>
 78 |     {% endfor %}
 79 |   </fieldset>
 80 |   <button type="submit">Save</button>
 81 | </form>
 82 | 
 83 | <div class="empty-form" style="display: none;">
 84 |   <div class="dynamic-form">
 85 |     {{ formset.empty_form }}
 86 |   </div>
 87 | </div>
 88 | {% endverbatim %}
 89 |           
90 |
91 |
92 |
93 | 94 | 104 | 105 | 106 | -------------------------------------------------------------------------------- /dist/django-superformset.js: -------------------------------------------------------------------------------- 1 | /*! Django Superformset - v1.0.4 - 2014-06-13 2 | * https://github.com/jgerigmeyer/jquery-django-superformset 3 | * Based on jQuery Formset 1.1r14, by Stanislaus Madueke 4 | * Original Portions Copyright (c) 2009 Stanislaus Madueke 5 | * Modifications Copyright (c) 2014 Jonny Gerig Meyer; Licensed BSDv3 */ 6 | (function ($) { 7 | 8 | 'use strict'; 9 | 10 | var methods = { 11 | init: function (options) { 12 | var vars = {}; 13 | var opts = vars.opts = $.extend({}, $.fn.superformset.defaults, options); 14 | var wrapper = vars.wrapper = $(this); 15 | var rows = vars.rows = wrapper.find(opts.rowSel); 16 | var container = vars.container = rows.closest(opts.containerSel); 17 | vars.totalForms = wrapper 18 | .find('input[id$="' + opts.prefix + '-TOTAL_FORMS"]'); 19 | vars.maxForms = wrapper 20 | .find('input[id$="' + opts.prefix + '-MAX_NUM_FORMS"]'); 21 | 22 | // Clone the form template to generate new form instances 23 | var tpl = vars.tpl = container.find(opts.formTemplate).clone(true); 24 | container.find(opts.formTemplate).find('[required], .required') 25 | .removeAttr('required').removeData('required-by').addClass('required'); 26 | tpl.removeAttr('id').find('input, select, textarea').filter('[required]') 27 | .addClass('required').removeAttr('required'); 28 | 29 | // Add delete-trigger and insert-above-trigger (if applicable) to template 30 | methods.addDeleteTrigger(tpl, opts.canDelete, vars); 31 | methods.addInsertAboveTrigger(tpl, vars); 32 | 33 | // Iterate over existing rows... 34 | rows.each(function () { 35 | var thisRow = $(this); 36 | // Add delete-trigger and insert-above-trigger to existing rows 37 | methods.addDeleteTrigger( 38 | thisRow, 39 | (opts.canDelete && !opts.deleteOnlyNew), 40 | vars 41 | ); 42 | methods.addInsertAboveTrigger(thisRow, vars); 43 | // Attaches handlers watching for changes to inputs, 44 | // ...to add/remove ``required`` attr 45 | methods.watchForChangesToOptionalIfEmptyRow(thisRow, vars); 46 | }); 47 | 48 | // Unless using auto-added rows, add and/or activate trigger to add rows 49 | if (!opts.autoAdd) { 50 | methods.activateAddTrigger(vars); 51 | } 52 | 53 | // Add extra empty row, if applicable 54 | if (opts.alwaysShowExtra && opts.autoAdd) { 55 | methods.autoAddRow(vars); 56 | wrapper.closest('form').submit(function () { 57 | $(this).find(opts.rowSel).filter('.extra-row') 58 | .find('input, select, textarea').each(function () { 59 | $(this).removeAttr('name'); 60 | } 61 | ); 62 | }); 63 | } 64 | 65 | return wrapper; 66 | }, 67 | 68 | activateAddTrigger: function (vars) { 69 | var opts = vars.opts; 70 | var addButton; 71 | if (vars.wrapper.find(opts.addTriggerSel).length) { 72 | addButton = vars.addButton = vars.wrapper.find(opts.addTriggerSel); 73 | } else { 74 | addButton = vars.addButton = $(opts.addTrigger).appendTo(vars.wrapper); 75 | } 76 | // Hide the add-trigger if we've reach the maxForms limit 77 | if (!methods.showAddButton(vars)) { addButton.hide(); } 78 | addButton.click(function (e) { 79 | var trigger = $(this); 80 | var formCount = parseInt(vars.totalForms.val(), 10); 81 | var newRow = vars.tpl.clone(true).addClass('new-row'); 82 | var lastRow = vars.wrapper.find(opts.rowSel).last(); 83 | newRow.find('input, select, textarea').filter('.required') 84 | .attr('required', 'required'); 85 | if (opts.addAnimationSpeed) { 86 | if (lastRow.length) { 87 | newRow.hide().insertAfter(lastRow); 88 | } else { 89 | newRow.hide().insertBefore(trigger); 90 | } 91 | newRow.animate( 92 | {'height': 'toggle', 'opacity': 'toggle'}, 93 | opts.addAnimationSpeed 94 | ); 95 | } else { 96 | if (lastRow.length) { 97 | newRow.insertAfter(lastRow).show(); 98 | } else { 99 | newRow.insertBefore(trigger).show(); 100 | } 101 | } 102 | newRow.find('input, select, textarea, label').each(function () { 103 | methods.updateElementIndex($(this), opts.prefix, formCount); 104 | }); 105 | // Attaches handlers watching for changes to inputs, 106 | // ...to add/remove ``required`` attr 107 | methods.watchForChangesToOptionalIfEmptyRow(newRow, vars); 108 | vars.totalForms.val(formCount + 1); 109 | // Check if we've exceeded the maximum allowed number of forms: 110 | if (!methods.showAddButton(vars)) { trigger.hide(); } 111 | // If a post-add callback was supplied, call it with the added form: 112 | if (opts.addedCallback) { opts.addedCallback(newRow); } 113 | e.preventDefault(); 114 | }); 115 | }, 116 | 117 | // Attaches handlers watching for changes to inputs, 118 | // ...to add/remove ``required`` attr 119 | watchForChangesToOptionalIfEmptyRow: function (row, vars) { 120 | var opts = vars.opts; 121 | if (opts.optionalIfEmpty && row.is(opts.optionalIfEmptySel)) { 122 | var inputs = row.find('input, select, textarea'); 123 | inputs.filter('[required], .required').removeAttr('required') 124 | .data('required-by', opts.prefix).addClass('required'); 125 | row.data('original-vals', inputs.serialize()); 126 | inputs.not(opts.deleteTriggerSel).change(function () { 127 | methods.updateRequiredFields(row, vars); 128 | }); 129 | } 130 | }, 131 | 132 | // Replace ``-__prefix__`` with correct index in for, id, name attrs 133 | updateElementIndex: function (elem, prefix, ndx) { 134 | var idRegex = new RegExp('(' + prefix + '-(\\d+|__prefix__))'); 135 | var replacement = prefix + '-' + ndx; 136 | if (elem.attr('for')) { 137 | elem.attr('for', elem.attr('for').replace(idRegex, replacement)); 138 | } 139 | if (elem.attr('id')) { 140 | elem.attr('id', elem.attr('id').replace(idRegex, replacement)); 141 | } 142 | if (elem.attr('name')) { 143 | elem.attr('name', elem.attr('name').replace(idRegex, replacement)); 144 | } 145 | }, 146 | 147 | // Check whether we can add more rows 148 | showAddButton: function (vars) { 149 | return ( 150 | vars.maxForms.val() === '' || 151 | (vars.maxForms.val() - vars.totalForms.val() > 0) 152 | ); 153 | }, 154 | 155 | // Add delete trigger to end of a row, or activate existing delete-trigger 156 | addDeleteTrigger: function (row, canDelete, vars) { 157 | var opts = vars.opts; 158 | if (canDelete) { 159 | // Add a delete-trigger to remove the row from the DOM 160 | $(opts.deleteTrigger).appendTo(row).click(function (e) { 161 | var thisRow = $(this).closest(opts.rowSel); 162 | var rows, i; 163 | var updateSequence = function (rows, i) { 164 | rows.eq(i).find('input, select, textarea, label').each(function () { 165 | methods.updateElementIndex($(this), opts.prefix, i); 166 | }); 167 | }; 168 | var removeRow = function () { 169 | thisRow.remove(); 170 | // Update the TOTAL_FORMS count: 171 | rows = vars.wrapper.find(opts.rowSel); 172 | vars.totalForms.val(rows.not('.extra-row').length); 173 | // Update names and IDs for all child controls, 174 | // ...so they remain in sequence. 175 | for (i = 0; i < rows.length; i = i + 1) { 176 | updateSequence(rows, i); 177 | } 178 | // If a post-delete callback was provided, call it with deleted form 179 | if (opts.removedCallback) { opts.removedCallback(thisRow); } 180 | }; 181 | if (opts.removeAnimationSpeed) { 182 | $.when( 183 | thisRow.animate( 184 | {'height': 'toggle', 'opacity': 'toggle'}, 185 | opts.removeAnimationSpeed 186 | ) 187 | ).done(removeRow); 188 | } else { 189 | removeRow(); 190 | } 191 | e.preventDefault(); 192 | }); 193 | } else { 194 | // If we're dealing with an inline formset, 195 | // ...just remove :required attrs when marking a row deleted 196 | row.find(opts.deleteTriggerSel).change(function () { 197 | var trigger = $(this); 198 | var thisRow = trigger.closest(opts.rowSel); 199 | if (trigger.prop('checked')) { 200 | thisRow.addClass(opts.deletedRowClass); 201 | thisRow.find('[required]').removeAttr('required') 202 | .addClass('deleted-required'); 203 | // If a post-delete callback was provided, call it with deleted form 204 | if (opts.removedCallback) { opts.removedCallback(thisRow); } 205 | } else { 206 | thisRow.removeClass(opts.deletedRowClass); 207 | thisRow.find('.deleted-required').attr('required', 'required') 208 | .removeClass('deleted-required'); 209 | } 210 | }); 211 | } 212 | }, 213 | 214 | // Add insert-above trigger before a row, if ``insertAboveTrigger: true`` 215 | addInsertAboveTrigger: function (row, vars) { 216 | var opts = vars.opts; 217 | if (opts.insertAbove) { 218 | $(opts.insertAboveTrigger).prependTo(row).click(function (e) { 219 | var thisRow = $(this).closest(opts.rowSel); 220 | var formCount = parseInt(vars.totalForms.val(), 10); 221 | var newRow = vars.tpl.clone(true).addClass('new-row'); 222 | var rows, i; 223 | var updateSequence = function (rows, i) { 224 | rows.eq(i).find('input, select, textarea, label').each(function () { 225 | methods.updateElementIndex($(this), opts.prefix, i); 226 | }); 227 | }; 228 | newRow.find('input, select, textarea').filter('.required') 229 | .attr('required', 'required'); 230 | if (opts.addAnimationSpeed) { 231 | newRow.hide().insertBefore(thisRow).animate( 232 | {'height': 'toggle', 'opacity': 'toggle'}, opts.addAnimationSpeed 233 | ); 234 | } else { 235 | newRow.insertBefore(thisRow).show(); 236 | } 237 | // Update the TOTAL_FORMS count: 238 | rows = vars.wrapper.find(opts.rowSel); 239 | vars.totalForms.val(formCount + 1); 240 | // Update names and IDs for child controls so they remain in sequence. 241 | for (i = 0; i < rows.length; i = i + 1) { 242 | updateSequence(rows, i); 243 | } 244 | // Attaches handlers watching for changes to inputs, 245 | // ...to add/remove ``required`` attr 246 | methods.watchForChangesToOptionalIfEmptyRow(newRow, vars); 247 | // Check if we've exceeded the maximum allowed number of rows: 248 | if (!methods.showAddButton(vars)) { $(this).hide(); } 249 | // If a post-add callback was supplied, call it with the added form: 250 | if (opts.addedCallback) { opts.addedCallback(newRow); } 251 | $(this).blur(); 252 | e.preventDefault(); 253 | }); 254 | } 255 | }, 256 | 257 | // Add a row automatically 258 | autoAddRow: function (vars) { 259 | var opts = vars.opts; 260 | var formCount = parseInt(vars.totalForms.val(), 10); 261 | var newRow = vars.tpl.clone(true); 262 | var rows = vars.wrapper.find(opts.rowSel); 263 | if (opts.addAnimationSpeed) { 264 | newRow.hide().css('opacity', 0).insertAfter(rows.last()) 265 | .addClass('extra-row').animate( 266 | {'height': 'toggle', 'opacity': '0.5'}, 267 | opts.addAnimationSpeed 268 | ); 269 | } else { 270 | newRow.css('opacity', 0.5).insertAfter(rows.last()) 271 | .addClass('extra-row'); 272 | } 273 | // When the extra-row receives focus... 274 | newRow.find('input, select, textarea, label').one('focus', function () { 275 | var el = $(this); 276 | var thisRow = el.closest(opts.rowSel); 277 | // fade it in 278 | thisRow.removeClass('extra-row').css('opacity', 1); 279 | // add "required" to appropriate inputs if not an "optionalIfEmpty" row 280 | if ( 281 | el.hasClass('required') && 282 | !(opts.optionalIfEmpty && newRow.is(opts.optionalIfEmptySel)) 283 | ) { 284 | el.attr('required', 'required'); 285 | } 286 | // update the totalForms count 287 | vars.totalForms.val( 288 | vars.wrapper.find(opts.rowSel).not('.extra-row').length 289 | ); 290 | // fade in the delete-trigger 291 | if (opts.deleteOnlyActive) { 292 | thisRow.find(opts.deleteTriggerSel).fadeIn(); 293 | } 294 | // and auto-add another extra-row 295 | if ( 296 | methods.showAddButton(vars) && 297 | thisRow.is(vars.wrapper.find(opts.rowSel).last()) 298 | ) { 299 | methods.autoAddRow(vars); 300 | } 301 | }).each(function () { 302 | var el = $(this); 303 | methods.updateElementIndex(el, opts.prefix, formCount); 304 | el.filter('[required]').removeAttr('required').addClass('required'); 305 | }); 306 | // Attaches handlers watching for changes to inputs, 307 | // ...to add/remove ``required`` attr 308 | methods.watchForChangesToOptionalIfEmptyRow(newRow, vars); 309 | // Hide the delete-trigger initially, if ``deleteOnlyActive: true`` 310 | if (opts.deleteOnlyActive) { 311 | newRow.find(opts.deleteTriggerSel).hide(); 312 | } 313 | // If a post-add callback was supplied, call it with the added form 314 | if (opts.addedCallback) { opts.addedCallback(newRow); } 315 | }, 316 | 317 | // Check if inputs have changed from original state, 318 | // ...and update ``required`` attr accordingly 319 | updateRequiredFields: function (row, vars) { 320 | var opts = vars.opts; 321 | var inputs = row.find('input, select, textarea'); 322 | var relevantInputs = inputs.filter(function () { 323 | return $(this).data('required-by') === opts.prefix; 324 | }); 325 | var state = inputs.serialize(); 326 | var originalState = row.data('original-vals'); 327 | if (state === originalState) { 328 | relevantInputs.removeAttr('required'); 329 | } else { 330 | relevantInputs.filter('.required').not('.deleted-required') 331 | .attr('required', 'required'); 332 | } 333 | }, 334 | 335 | // Expose internal methods to allow stubbing in tests 336 | exposeMethods: function () { 337 | return methods; 338 | } 339 | }; 340 | 341 | $.fn.superformset = function (method) { 342 | if (methods[method]) { 343 | return methods[method].apply( 344 | this, 345 | Array.prototype.slice.call(arguments, 1) 346 | ); 347 | } else if (typeof method === 'object' || !method) { 348 | return methods.init.apply(this, arguments); 349 | } else { 350 | $.error('Method ' + method + ' does not exist on jQuery.superformset'); 351 | } 352 | }; 353 | 354 | /* Setup plugin defaults */ 355 | $.fn.superformset.defaults = { 356 | prefix: 'form', // The form prefix for your django formset 357 | containerSel: 'form', // Container selector 358 | // ...(must contain rows and formTemplate) 359 | rowSel: '.dynamic-form', // Selector to match each row in a formset 360 | formTemplate: '.empty-form .dynamic-form', 361 | // Selector for empty form template to be 362 | // ...cloned to generate new form instances 363 | // ...Must be outside element on which formset 364 | // ...is called, but within containerSel 365 | deleteTrigger: 'remove', 366 | // The HTML "remove" link added to the end of 367 | // ...each form-row (if ``canDelete: true``) 368 | deleteTriggerSel: '.remove-row', 369 | // Selector for HTML "remove" links 370 | // ...Used to target existing delete-trigger, 371 | // ...or to target ``deleteTrigger`` 372 | addTrigger: 'add', 373 | // The HTML "add" link added to the end of all 374 | // ...forms if no ``addTriggerSel`` 375 | addTriggerSel: null, // Selector for trigger to add a new row 376 | // ...Used to target existing trigger 377 | // ...if provided, ``addTrigger`` is ignored 378 | addedCallback: null, // Fn called each time a new form row is added 379 | removedCallback: null, // Fn called each time a form row is deleted 380 | deletedRowClass: 'deleted', // Add to deleted row if ``canDelete: false`` 381 | addAnimationSpeed: 'normal', // Speed (ms) to animate adding rows 382 | // ...If false, new rows appear w/o animation 383 | removeAnimationSpeed: 'fast', // Speed (ms) to animate removing rows 384 | // ...If false, new rows disappear w/o anim. 385 | autoAdd: false, // If true, the "add" link will be removed, 386 | // ...and a row will be automatically added 387 | // ...when text is entered in the final 388 | // ...textarea of the last row 389 | alwaysShowExtra: false, // If true, an extra (empty) row will always 390 | // ...be displayed (req. ``autoAdd: true``) 391 | deleteOnlyActive: false, // If true, extra empty rows cannot be removed 392 | // ...until they acquire focus 393 | // ...(requires ``alwaysShowExtra: true``) 394 | canDelete: false, // If false, rows cannot be removed from DOM. 395 | // ...``deleteTriggerSel`` will remove 396 | // ...``required`` attr from fields within a 397 | // ..."deleted" row. 398 | // ...deleted rows should be hidden via CSS 399 | deleteOnlyNew: false, // If true, only newly-added rows can be 400 | // ...deleted (requires ``canDelete: true``) 401 | insertAbove: false, // If true, ``insertAboveTrigger`` will be 402 | // ...added to the end of each form-row 403 | insertAboveTrigger: 404 | 'insert', 405 | // The HTML "insert" link add to the end of 406 | // ...each row (req. ``insertAbove: true``) 407 | optionalIfEmpty: true, // If true, required fields in a row will be 408 | // ...optional until changed from initial vals 409 | optionalIfEmptySel: '[data-empty-permitted="true"]' 410 | // Selector for rows to apply optionalIfEmpty 411 | // ...logic (req. ``optionalIfEmpty: true``) 412 | }; 413 | }(jQuery)); 414 | -------------------------------------------------------------------------------- /dist/django-superformset.min.js: -------------------------------------------------------------------------------- 1 | /*! Django Superformset - v1.0.4 - 2014-06-13 2 | * https://github.com/jgerigmeyer/jquery-django-superformset 3 | * Based on jQuery Formset 1.1r14, by Stanislaus Madueke 4 | * Original Portions Copyright (c) 2009 Stanislaus Madueke 5 | * Modifications Copyright (c) 2014 Jonny Gerig Meyer; Licensed BSDv3 */ 6 | !function(a){"use strict";var b={init:function(c){var d={},e=d.opts=a.extend({},a.fn.superformset.defaults,c),f=d.wrapper=a(this),g=d.rows=f.find(e.rowSel),h=d.container=g.closest(e.containerSel);d.totalForms=f.find('input[id$="'+e.prefix+'-TOTAL_FORMS"]'),d.maxForms=f.find('input[id$="'+e.prefix+'-MAX_NUM_FORMS"]');var i=d.tpl=h.find(e.formTemplate).clone(!0);return h.find(e.formTemplate).find("[required], .required").removeAttr("required").removeData("required-by").addClass("required"),i.removeAttr("id").find("input, select, textarea").filter("[required]").addClass("required").removeAttr("required"),b.addDeleteTrigger(i,e.canDelete,d),b.addInsertAboveTrigger(i,d),g.each(function(){var c=a(this);b.addDeleteTrigger(c,e.canDelete&&!e.deleteOnlyNew,d),b.addInsertAboveTrigger(c,d),b.watchForChangesToOptionalIfEmptyRow(c,d)}),e.autoAdd||b.activateAddTrigger(d),e.alwaysShowExtra&&e.autoAdd&&(b.autoAddRow(d),f.closest("form").submit(function(){a(this).find(e.rowSel).filter(".extra-row").find("input, select, textarea").each(function(){a(this).removeAttr("name")})})),f},activateAddTrigger:function(c){var d,e=c.opts;d=c.addButton=c.wrapper.find(e.addTriggerSel).length?c.wrapper.find(e.addTriggerSel):a(e.addTrigger).appendTo(c.wrapper),b.showAddButton(c)||d.hide(),d.click(function(d){var f=a(this),g=parseInt(c.totalForms.val(),10),h=c.tpl.clone(!0).addClass("new-row"),i=c.wrapper.find(e.rowSel).last();h.find("input, select, textarea").filter(".required").attr("required","required"),e.addAnimationSpeed?(i.length?h.hide().insertAfter(i):h.hide().insertBefore(f),h.animate({height:"toggle",opacity:"toggle"},e.addAnimationSpeed)):i.length?h.insertAfter(i).show():h.insertBefore(f).show(),h.find("input, select, textarea, label").each(function(){b.updateElementIndex(a(this),e.prefix,g)}),b.watchForChangesToOptionalIfEmptyRow(h,c),c.totalForms.val(g+1),b.showAddButton(c)||f.hide(),e.addedCallback&&e.addedCallback(h),d.preventDefault()})},watchForChangesToOptionalIfEmptyRow:function(a,c){var d=c.opts;if(d.optionalIfEmpty&&a.is(d.optionalIfEmptySel)){var e=a.find("input, select, textarea");e.filter("[required], .required").removeAttr("required").data("required-by",d.prefix).addClass("required"),a.data("original-vals",e.serialize()),e.not(d.deleteTriggerSel).change(function(){b.updateRequiredFields(a,c)})}},updateElementIndex:function(a,b,c){var d=new RegExp("("+b+"-(\\d+|__prefix__))"),e=b+"-"+c;a.attr("for")&&a.attr("for",a.attr("for").replace(d,e)),a.attr("id")&&a.attr("id",a.attr("id").replace(d,e)),a.attr("name")&&a.attr("name",a.attr("name").replace(d,e))},showAddButton:function(a){return""===a.maxForms.val()||a.maxForms.val()-a.totalForms.val()>0},addDeleteTrigger:function(c,d,e){var f=e.opts;d?a(f.deleteTrigger).appendTo(c).click(function(c){var d,g,h=a(this).closest(f.rowSel),i=function(c,d){c.eq(d).find("input, select, textarea, label").each(function(){b.updateElementIndex(a(this),f.prefix,d)})},j=function(){for(h.remove(),d=e.wrapper.find(f.rowSel),e.totalForms.val(d.not(".extra-row").length),g=0;gremove',deleteTriggerSel:".remove-row",addTrigger:'add',addTriggerSel:null,addedCallback:null,removedCallback:null,deletedRowClass:"deleted",addAnimationSpeed:"normal",removeAnimationSpeed:"fast",autoAdd:!1,alwaysShowExtra:!1,deleteOnlyActive:!1,canDelete:!1,deleteOnlyNew:!1,insertAbove:!1,insertAboveTrigger:'insert',optionalIfEmpty:!0,optionalIfEmptySel:'[data-empty-permitted="true"]'}}(jQuery); -------------------------------------------------------------------------------- /django-superformset.jquery.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "django-superformset", 3 | "title": "Django Superformset", 4 | "description": "jQuery Django Dynamic Formset Plugin", 5 | "version": "1.0.4", 6 | "homepage": "https://github.com/jgerigmeyer/jquery-django-superformset", 7 | "author": { 8 | "name": "Jonny Gerig Meyer", 9 | "email": "jonny@oddbird.net", 10 | "url": "http://oddbird.net/" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "git://github.com/jgerigmeyer/jquery-django-superformset.git" 15 | }, 16 | "bugs": "https://github.com/jgerigmeyer/jquery-django-superformset/issues", 17 | "licenses": [ 18 | { 19 | "type": "BSDv3", 20 | "url": "https://github.com/jgerigmeyer/jquery-django-superformset/blob/master/LICENSE-BSDv3" 21 | } 22 | ], 23 | "dependencies": { 24 | "jquery": "*" 25 | }, 26 | "keywords": ["formset", "dynamic", "django"] 27 | } 28 | -------------------------------------------------------------------------------- /libs/jquery-loader.js: -------------------------------------------------------------------------------- 1 | (function() { 2 | // Default to the local version. 3 | var path = '../libs/jquery/jquery.js'; 4 | // Get any jquery=___ param from the query string. 5 | var jqversion = location.search.match(/[?&]jquery=(.*?)(?=&|$)/); 6 | // If a version was specified, use that version from code.jquery.com. 7 | if (jqversion) { 8 | path = 'http://code.jquery.com/jquery-' + jqversion[1] + '.js'; 9 | } 10 | // This is the only time I'll ever use document.write, I promise! 11 | document.write(''); 12 | }()); 13 | -------------------------------------------------------------------------------- /libs/qunit/qunit-assert-html.js: -------------------------------------------------------------------------------- 1 | /*! qunit-assert-html - v0.2.0 - 2013-03-04 2 | * https://github.com/JamesMGreene/qunit-assert-html 3 | * Copyright (c) 2013 James M. Greene; Licensed MIT */ 4 | (function( QUnit, window, undefined ) { 5 | "use strict"; 6 | 7 | var trim = function( s ) { 8 | if ( !s ) { 9 | return ""; 10 | } 11 | return typeof s.trim === "function" ? s.trim() : s.replace( /^\s+|\s+$/g, "" ); 12 | }; 13 | 14 | var normalizeWhitespace = function( s ) { 15 | if ( !s ) { 16 | return ""; 17 | } 18 | return trim( s.replace( /\s+/g, " " ) ); 19 | }; 20 | 21 | var dedupeFlatDict = function( dictToDedupe, parentDict ) { 22 | var key, val; 23 | if ( parentDict ) { 24 | for ( key in dictToDedupe ) { 25 | val = dictToDedupe[key]; 26 | if ( val && ( val === parentDict[key] ) ) { 27 | delete dictToDedupe[key]; 28 | } 29 | } 30 | } 31 | return dictToDedupe; 32 | }; 33 | 34 | var objectKeys = Object.keys || (function() { 35 | var hasOwn = function( obj, propName ) { 36 | return Object.prototype.hasOwnProperty.call( obj, propName ); 37 | }; 38 | return function( obj ) { 39 | var keys = [], 40 | key; 41 | for ( key in obj ) { 42 | if ( hasOwn( obj, key ) ) { 43 | keys.push( key ); 44 | } 45 | } 46 | return keys; 47 | }; 48 | })(); 49 | 50 | /** 51 | * Calculate based on `currentStyle`/`getComputedStyle` styles instead 52 | */ 53 | var getElementStyles = (function() { 54 | 55 | // Memoized 56 | var camelCase = (function() { 57 | var camelCaseFn = (function() { 58 | // Matches dashed string for camelizing 59 | var rmsPrefix = /^-ms-/, 60 | msPrefixFix = "ms-", 61 | rdashAlpha = /-([\da-z])/gi, 62 | camelCaseReplacerFn = function( all, letter ) { 63 | return ( letter + "" ).toUpperCase(); 64 | }; 65 | 66 | return function( s ) { 67 | return s.replace(rmsPrefix, msPrefixFix).replace(rdashAlpha, camelCaseReplacerFn); 68 | }; 69 | })(); 70 | 71 | var camelCaseMemoizer = {}; 72 | 73 | return function( s ) { 74 | var temp = camelCaseMemoizer[s]; 75 | if ( temp ) { 76 | return temp; 77 | } 78 | 79 | temp = camelCaseFn( s ); 80 | camelCaseMemoizer[s] = temp; 81 | return temp; 82 | }; 83 | })(); 84 | 85 | var styleKeySortingFn = function( a, b ) { 86 | return camelCase( a ) < camelCase( b ); 87 | }; 88 | 89 | return function( elem ) { 90 | var styleCount, i, key, 91 | styles = {}, 92 | styleKeys = [], 93 | style = elem.ownerDocument.defaultView ? 94 | elem.ownerDocument.defaultView.getComputedStyle( elem, null ) : 95 | elem.currentStyle; 96 | 97 | // `getComputedStyle` 98 | if ( style && style.length && style[0] && style[style[0]] ) { 99 | styleCount = style.length; 100 | while ( styleCount-- ) { 101 | styleKeys.push( style[styleCount] ); 102 | } 103 | styleKeys.sort( styleKeySortingFn ); 104 | 105 | for ( i = 0, styleCount = styleKeys.length ; i < styleCount ; i++ ) { 106 | key = styleKeys[i]; 107 | if ( key !== "cssText" && typeof style[key] === "string" && style[key] ) { 108 | styles[camelCase( key )] = style[key]; 109 | } 110 | } 111 | } 112 | // `currentStyle` support: IE < 9.0, Opera < 10.6 113 | else { 114 | for ( key in style ) { 115 | styleKeys.push( key ); 116 | } 117 | styleKeys.sort(); 118 | 119 | for ( i = 0, styleCount = styleKeys.length ; i < styleCount ; i++ ) { 120 | key = styleKeys[i]; 121 | if ( key !== "cssText" && typeof style[key] === "string" && style[key] ) { 122 | styles[key] = style[key]; 123 | } 124 | } 125 | } 126 | 127 | return styles; 128 | 129 | }; 130 | })(); 131 | 132 | var serializeElementNode = function( elementNode, rootNodeStyles ) { 133 | var subNodes, i, len, styles, attrName, 134 | serializedNode = { 135 | NodeType: elementNode.nodeType, 136 | NodeName: elementNode.nodeName.toLowerCase(), 137 | Attributes: {}, 138 | ChildNodes: [] 139 | }; 140 | 141 | subNodes = elementNode.attributes; 142 | for ( i = 0, len = subNodes.length ; i < len ; i++ ) { 143 | attrName = subNodes[i].name.toLowerCase(); 144 | if ( attrName === "class" ) { 145 | serializedNode.Attributes[attrName] = normalizeWhitespace( subNodes[i].value ); 146 | } 147 | else if ( attrName !== "style" ) { 148 | serializedNode.Attributes[attrName] = subNodes[i].value; 149 | } 150 | // Ignore the "style" attribute completely 151 | } 152 | 153 | // Only add the style attribute if there is 1+ pertinent rules 154 | styles = dedupeFlatDict( getElementStyles( elementNode ), rootNodeStyles ); 155 | if ( styles && objectKeys( styles ).length ) { 156 | serializedNode.Attributes["style"] = styles; 157 | } 158 | 159 | subNodes = elementNode.childNodes; 160 | for ( i = 0, len = subNodes.length; i < len; i++ ) { 161 | serializedNode.ChildNodes.push( serializeNode( subNodes[i], rootNodeStyles ) ); 162 | } 163 | 164 | return serializedNode; 165 | }; 166 | 167 | var serializeNode = function( node, rootNodeStyles ) { 168 | var serializedNode; 169 | 170 | switch (node.nodeType) { 171 | case 1: // Node.ELEMENT_NODE 172 | serializedNode = serializeElementNode( node, rootNodeStyles ); 173 | break; 174 | case 3: // Node.TEXT_NODE 175 | serializedNode = { 176 | NodeType: node.nodeType, 177 | NodeName: node.nodeName.toLowerCase(), 178 | NodeValue: node.nodeValue 179 | }; 180 | break; 181 | case 4: // Node.CDATA_SECTION_NODE 182 | case 7: // Node.PROCESSING_INSTRUCTION_NODE 183 | case 8: // Node.COMMENT_NODE 184 | serializedNode = { 185 | NodeType: node.nodeType, 186 | NodeName: node.nodeName.toLowerCase(), 187 | NodeValue: trim( node.nodeValue ) 188 | }; 189 | break; 190 | case 5: // Node.ENTITY_REFERENCE_NODE 191 | case 6: // Node.ENTITY_NODE 192 | case 9: // Node.DOCUMENT_NODE 193 | case 10: // Node.DOCUMENT_TYPE_NODE 194 | case 11: // Node.DOCUMENT_FRAGMENT_NODE 195 | case 12: // Node.NOTATION_NODE 196 | serializedNode = { 197 | NodeType: node.nodeType, 198 | NodeName: node.nodeName 199 | }; 200 | break; 201 | case 2: // Node.ATTRIBUTE_NODE 202 | throw new Error( "`node.nodeType` was `Node.ATTRIBUTE_NODE` (2), which is not supported by this method" ); 203 | default: 204 | throw new Error( "`node.nodeType` was not recognized: " + node.nodeType ); 205 | } 206 | 207 | return serializedNode; 208 | }; 209 | 210 | var serializeHtml = function( html ) { 211 | var scratch = getCleanSlate(), 212 | rootNode = scratch.container(), 213 | rootNodeStyles = getElementStyles( rootNode ), 214 | serializedHtml = [], 215 | kids, i, len; 216 | rootNode.innerHTML = trim( html ); 217 | 218 | kids = rootNode.childNodes; 219 | for ( i = 0, len = kids.length; i < len; i++ ) { 220 | serializedHtml.push( serializeNode( kids[i], rootNodeStyles ) ); 221 | } 222 | 223 | scratch.reset(); 224 | 225 | return serializedHtml; 226 | }; 227 | 228 | var getCleanSlate = (function() { 229 | var containerElId = "qunit-html-addon-container", 230 | iframeReady = false, 231 | iframeLoaded = function() { 232 | iframeReady = true; 233 | }, 234 | iframeReadied = function() { 235 | if (iframe.readyState === "complete" || iframe.readyState === 4) { 236 | iframeReady = true; 237 | } 238 | }, 239 | iframeApi, 240 | iframe, 241 | iframeWin, 242 | iframeDoc; 243 | 244 | if ( !iframeApi ) { 245 | 246 | QUnit.begin(function() { 247 | // Initialize the background iframe! 248 | if ( !iframe || !iframeWin || !iframeDoc ) { 249 | iframe = window.document.createElement( "iframe" ); 250 | QUnit.addEvent( iframe, "load", iframeLoaded ); 251 | QUnit.addEvent( iframe, "readystatechange", iframeReadied ); 252 | iframe.style.position = "absolute"; 253 | iframe.style.top = iframe.style.left = "-1000px"; 254 | iframe.height = iframe.width = 0; 255 | 256 | // `getComputedStyle` behaves inconsistently cross-browser when not attached to a live DOM 257 | window.document.body.appendChild( iframe ); 258 | 259 | iframeWin = iframe.contentWindow || 260 | iframe.window || 261 | iframe.contentDocument && iframe.contentDocument.defaultView || 262 | iframe.document && ( iframe.document.defaultView || iframe.document.window ) || 263 | window.frames[( iframe.name || iframe.id )]; 264 | 265 | iframeDoc = iframeWin && iframeWin.document || 266 | iframe.contentDocument || 267 | iframe.document; 268 | 269 | var iframeContents = [ 270 | "", 271 | "", 272 | "", 273 | " QUnit HTML addon iframe", 274 | "", 275 | "", 276 | "
", 277 | " ", 280 | "", 281 | "" 282 | ].join( "\n" ); 283 | 284 | iframeDoc.open(); 285 | iframeDoc.write( iframeContents ); 286 | iframeDoc.close(); 287 | 288 | // Is ready? 289 | iframeReady = iframeReady || iframeWin.isReady; 290 | } 291 | }); 292 | 293 | QUnit.done(function() { 294 | if ( iframe && iframe.ownerDocument ) { 295 | iframe.parentNode.removeChild( iframe ); 296 | } 297 | iframe = iframeWin = iframeDoc = null; 298 | iframeReady = false; 299 | }); 300 | 301 | var waitForIframeReady = function( maxTimeout ) { 302 | if ( !iframeReady ) { 303 | if ( !maxTimeout ) { 304 | maxTimeout = 2000; // 2 seconds MAX 305 | } 306 | var startTime = new Date(); 307 | while ( !iframeReady && ( ( new Date() - startTime ) < maxTimeout ) ) { 308 | iframeReady = iframeReady || iframeWin.isReady; 309 | } 310 | } 311 | }; 312 | 313 | iframeApi = { 314 | container: function() { 315 | waitForIframeReady(); 316 | if ( iframeReady && iframeDoc ) { 317 | return iframeDoc.getElementById( containerElId ); 318 | } 319 | return undefined; 320 | }, 321 | reset: function() { 322 | var containerEl = iframeApi.container(); 323 | if ( containerEl ) { 324 | containerEl.innerHTML = ""; 325 | } 326 | } 327 | }; 328 | } 329 | 330 | // Actual function signature for `getCleanState` 331 | return function() { return iframeApi; }; 332 | })(); 333 | 334 | QUnit.extend( QUnit.assert, { 335 | 336 | /** 337 | * Compare two snippets of HTML for equality after normalization. 338 | * 339 | * @example htmlEqual("Hello, QUnit! ", "Hello, QUnit!", "HTML should be equal"); 340 | * @param {String} actual The actual HTML before normalization. 341 | * @param {String} expected The excepted HTML before normalization. 342 | * @param {String} [message] Optional message to display in the results. 343 | */ 344 | htmlEqual: function( actual, expected, message ) { 345 | if ( !message ) { 346 | message = "HTML should be equal"; 347 | } 348 | 349 | this.deepEqual( serializeHtml( actual ), serializeHtml( expected ), message ); 350 | }, 351 | 352 | /** 353 | * Compare two snippets of HTML for inequality after normalization. 354 | * 355 | * @example notHtmlEqual("Hello, QUnit!", "Hello, QUnit!", "HTML should not be equal"); 356 | * @param {String} actual The actual HTML before normalization. 357 | * @param {String} expected The excepted HTML before normalization. 358 | * @param {String} [message] Optional message to display in the results. 359 | */ 360 | notHtmlEqual: function( actual, expected, message ) { 361 | if ( !message ) { 362 | message = "HTML should not be equal"; 363 | } 364 | 365 | this.notDeepEqual( serializeHtml( actual ), serializeHtml( expected ), message ); 366 | }, 367 | 368 | /** 369 | * @private 370 | * Normalize and serialize an HTML snippet. Primarily only exposed for unit testing purposes. 371 | * 372 | * @example _serializeHtml('Test'); 373 | * @param {String} html The HTML snippet to normalize and serialize. 374 | * @returns {Object[]} The normalized and serialized form of the HTML snippet. 375 | */ 376 | _serializeHtml: serializeHtml 377 | 378 | }); 379 | })( QUnit, this ); 380 | -------------------------------------------------------------------------------- /libs/qunit/qunit.css: -------------------------------------------------------------------------------- 1 | /*! 2 | * QUnit 1.14.0 3 | * http://qunitjs.com/ 4 | * 5 | * Copyright 2013 jQuery Foundation and other contributors 6 | * Released under the MIT license 7 | * http://jquery.org/license 8 | * 9 | * Date: 2014-01-31T16:40Z 10 | */ 11 | 12 | /** Font Family and Sizes */ 13 | 14 | #qunit-tests, #qunit-header, #qunit-banner, #qunit-testrunner-toolbar, #qunit-userAgent, #qunit-testresult { 15 | font-family: "Helvetica Neue Light", "HelveticaNeue-Light", "Helvetica Neue", Calibri, Helvetica, Arial, sans-serif; 16 | } 17 | 18 | #qunit-testrunner-toolbar, #qunit-userAgent, #qunit-testresult, #qunit-tests li { font-size: small; } 19 | #qunit-tests { font-size: smaller; } 20 | 21 | 22 | /** Resets */ 23 | 24 | #qunit-tests, #qunit-header, #qunit-banner, #qunit-userAgent, #qunit-testresult, #qunit-modulefilter { 25 | margin: 0; 26 | padding: 0; 27 | } 28 | 29 | 30 | /** Header */ 31 | 32 | #qunit-header { 33 | padding: 0.5em 0 0.5em 1em; 34 | 35 | color: #8699A4; 36 | background-color: #0D3349; 37 | 38 | font-size: 1.5em; 39 | line-height: 1em; 40 | font-weight: 400; 41 | 42 | border-radius: 5px 5px 0 0; 43 | } 44 | 45 | #qunit-header a { 46 | text-decoration: none; 47 | color: #C2CCD1; 48 | } 49 | 50 | #qunit-header a:hover, 51 | #qunit-header a:focus { 52 | color: #FFF; 53 | } 54 | 55 | #qunit-testrunner-toolbar label { 56 | display: inline-block; 57 | padding: 0 0.5em 0 0.1em; 58 | } 59 | 60 | #qunit-banner { 61 | height: 5px; 62 | } 63 | 64 | #qunit-testrunner-toolbar { 65 | padding: 0.5em 0 0.5em 2em; 66 | color: #5E740B; 67 | background-color: #EEE; 68 | overflow: hidden; 69 | } 70 | 71 | #qunit-userAgent { 72 | padding: 0.5em 0 0.5em 2.5em; 73 | background-color: #2B81AF; 74 | color: #FFF; 75 | text-shadow: rgba(0, 0, 0, 0.5) 2px 2px 1px; 76 | } 77 | 78 | #qunit-modulefilter-container { 79 | float: right; 80 | } 81 | 82 | /** Tests: Pass/Fail */ 83 | 84 | #qunit-tests { 85 | list-style-position: inside; 86 | } 87 | 88 | #qunit-tests li { 89 | padding: 0.4em 0.5em 0.4em 2.5em; 90 | border-bottom: 1px solid #FFF; 91 | list-style-position: inside; 92 | } 93 | 94 | #qunit-tests.hidepass li.pass, #qunit-tests.hidepass li.running { 95 | display: none; 96 | } 97 | 98 | #qunit-tests li strong { 99 | cursor: pointer; 100 | } 101 | 102 | #qunit-tests li a { 103 | padding: 0.5em; 104 | color: #C2CCD1; 105 | text-decoration: none; 106 | } 107 | #qunit-tests li a:hover, 108 | #qunit-tests li a:focus { 109 | color: #000; 110 | } 111 | 112 | #qunit-tests li .runtime { 113 | float: right; 114 | font-size: smaller; 115 | } 116 | 117 | .qunit-assert-list { 118 | margin-top: 0.5em; 119 | padding: 0.5em; 120 | 121 | background-color: #FFF; 122 | 123 | border-radius: 5px; 124 | } 125 | 126 | .qunit-collapsed { 127 | display: none; 128 | } 129 | 130 | #qunit-tests table { 131 | border-collapse: collapse; 132 | margin-top: 0.2em; 133 | } 134 | 135 | #qunit-tests th { 136 | text-align: right; 137 | vertical-align: top; 138 | padding: 0 0.5em 0 0; 139 | } 140 | 141 | #qunit-tests td { 142 | vertical-align: top; 143 | } 144 | 145 | #qunit-tests pre { 146 | margin: 0; 147 | white-space: pre-wrap; 148 | word-wrap: break-word; 149 | } 150 | 151 | #qunit-tests del { 152 | background-color: #E0F2BE; 153 | color: #374E0C; 154 | text-decoration: none; 155 | } 156 | 157 | #qunit-tests ins { 158 | background-color: #FFCACA; 159 | color: #500; 160 | text-decoration: none; 161 | } 162 | 163 | /*** Test Counts */ 164 | 165 | #qunit-tests b.counts { color: #000; } 166 | #qunit-tests b.passed { color: #5E740B; } 167 | #qunit-tests b.failed { color: #710909; } 168 | 169 | #qunit-tests li li { 170 | padding: 5px; 171 | background-color: #FFF; 172 | border-bottom: none; 173 | list-style-position: inside; 174 | } 175 | 176 | /*** Passing Styles */ 177 | 178 | #qunit-tests li li.pass { 179 | color: #3C510C; 180 | background-color: #FFF; 181 | border-left: 10px solid #C6E746; 182 | } 183 | 184 | #qunit-tests .pass { color: #528CE0; background-color: #D2E0E6; } 185 | #qunit-tests .pass .test-name { color: #366097; } 186 | 187 | #qunit-tests .pass .test-actual, 188 | #qunit-tests .pass .test-expected { color: #999; } 189 | 190 | #qunit-banner.qunit-pass { background-color: #C6E746; } 191 | 192 | /*** Failing Styles */ 193 | 194 | #qunit-tests li li.fail { 195 | color: #710909; 196 | background-color: #FFF; 197 | border-left: 10px solid #EE5757; 198 | white-space: pre; 199 | } 200 | 201 | #qunit-tests > li:last-child { 202 | border-radius: 0 0 5px 5px; 203 | } 204 | 205 | #qunit-tests .fail { color: #000; background-color: #EE5757; } 206 | #qunit-tests .fail .test-name, 207 | #qunit-tests .fail .module-name { color: #000; } 208 | 209 | #qunit-tests .fail .test-actual { color: #EE5757; } 210 | #qunit-tests .fail .test-expected { color: #008000; } 211 | 212 | #qunit-banner.qunit-fail { background-color: #EE5757; } 213 | 214 | 215 | /** Result */ 216 | 217 | #qunit-testresult { 218 | padding: 0.5em 0.5em 0.5em 2.5em; 219 | 220 | color: #2B81AF; 221 | background-color: #D2E0E6; 222 | 223 | border-bottom: 1px solid #FFF; 224 | } 225 | #qunit-testresult .module-name { 226 | font-weight: 700; 227 | } 228 | 229 | /** Fixture */ 230 | 231 | #qunit-fixture { 232 | position: absolute; 233 | top: -10000px; 234 | left: -10000px; 235 | width: 1000px; 236 | height: 1000px; 237 | } 238 | -------------------------------------------------------------------------------- /libs/qunit/qunit.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * QUnit 1.14.0 3 | * http://qunitjs.com/ 4 | * 5 | * Copyright 2013 jQuery Foundation and other contributors 6 | * Released under the MIT license 7 | * http://jquery.org/license 8 | * 9 | * Date: 2014-01-31T16:40Z 10 | */ 11 | 12 | (function( window ) { 13 | 14 | var QUnit, 15 | assert, 16 | config, 17 | onErrorFnPrev, 18 | testId = 0, 19 | fileName = (sourceFromStacktrace( 0 ) || "" ).replace(/(:\d+)+\)?/, "").replace(/.+\//, ""), 20 | toString = Object.prototype.toString, 21 | hasOwn = Object.prototype.hasOwnProperty, 22 | // Keep a local reference to Date (GH-283) 23 | Date = window.Date, 24 | setTimeout = window.setTimeout, 25 | clearTimeout = window.clearTimeout, 26 | defined = { 27 | document: typeof window.document !== "undefined", 28 | setTimeout: typeof window.setTimeout !== "undefined", 29 | sessionStorage: (function() { 30 | var x = "qunit-test-string"; 31 | try { 32 | sessionStorage.setItem( x, x ); 33 | sessionStorage.removeItem( x ); 34 | return true; 35 | } catch( e ) { 36 | return false; 37 | } 38 | }()) 39 | }, 40 | /** 41 | * Provides a normalized error string, correcting an issue 42 | * with IE 7 (and prior) where Error.prototype.toString is 43 | * not properly implemented 44 | * 45 | * Based on http://es5.github.com/#x15.11.4.4 46 | * 47 | * @param {String|Error} error 48 | * @return {String} error message 49 | */ 50 | errorString = function( error ) { 51 | var name, message, 52 | errorString = error.toString(); 53 | if ( errorString.substring( 0, 7 ) === "[object" ) { 54 | name = error.name ? error.name.toString() : "Error"; 55 | message = error.message ? error.message.toString() : ""; 56 | if ( name && message ) { 57 | return name + ": " + message; 58 | } else if ( name ) { 59 | return name; 60 | } else if ( message ) { 61 | return message; 62 | } else { 63 | return "Error"; 64 | } 65 | } else { 66 | return errorString; 67 | } 68 | }, 69 | /** 70 | * Makes a clone of an object using only Array or Object as base, 71 | * and copies over the own enumerable properties. 72 | * 73 | * @param {Object} obj 74 | * @return {Object} New object with only the own properties (recursively). 75 | */ 76 | objectValues = function( obj ) { 77 | // Grunt 0.3.x uses an older version of jshint that still has jshint/jshint#392. 78 | /*jshint newcap: false */ 79 | var key, val, 80 | vals = QUnit.is( "array", obj ) ? [] : {}; 81 | for ( key in obj ) { 82 | if ( hasOwn.call( obj, key ) ) { 83 | val = obj[key]; 84 | vals[key] = val === Object(val) ? objectValues(val) : val; 85 | } 86 | } 87 | return vals; 88 | }; 89 | 90 | 91 | // Root QUnit object. 92 | // `QUnit` initialized at top of scope 93 | QUnit = { 94 | 95 | // call on start of module test to prepend name to all tests 96 | module: function( name, testEnvironment ) { 97 | config.currentModule = name; 98 | config.currentModuleTestEnvironment = testEnvironment; 99 | config.modules[name] = true; 100 | }, 101 | 102 | asyncTest: function( testName, expected, callback ) { 103 | if ( arguments.length === 2 ) { 104 | callback = expected; 105 | expected = null; 106 | } 107 | 108 | QUnit.test( testName, expected, callback, true ); 109 | }, 110 | 111 | test: function( testName, expected, callback, async ) { 112 | var test, 113 | nameHtml = "" + escapeText( testName ) + ""; 114 | 115 | if ( arguments.length === 2 ) { 116 | callback = expected; 117 | expected = null; 118 | } 119 | 120 | if ( config.currentModule ) { 121 | nameHtml = "" + escapeText( config.currentModule ) + ": " + nameHtml; 122 | } 123 | 124 | test = new Test({ 125 | nameHtml: nameHtml, 126 | testName: testName, 127 | expected: expected, 128 | async: async, 129 | callback: callback, 130 | module: config.currentModule, 131 | moduleTestEnvironment: config.currentModuleTestEnvironment, 132 | stack: sourceFromStacktrace( 2 ) 133 | }); 134 | 135 | if ( !validTest( test ) ) { 136 | return; 137 | } 138 | 139 | test.queue(); 140 | }, 141 | 142 | // Specify the number of expected assertions to guarantee that failed test (no assertions are run at all) don't slip through. 143 | expect: function( asserts ) { 144 | if (arguments.length === 1) { 145 | config.current.expected = asserts; 146 | } else { 147 | return config.current.expected; 148 | } 149 | }, 150 | 151 | start: function( count ) { 152 | // QUnit hasn't been initialized yet. 153 | // Note: RequireJS (et al) may delay onLoad 154 | if ( config.semaphore === undefined ) { 155 | QUnit.begin(function() { 156 | // This is triggered at the top of QUnit.load, push start() to the event loop, to allow QUnit.load to finish first 157 | setTimeout(function() { 158 | QUnit.start( count ); 159 | }); 160 | }); 161 | return; 162 | } 163 | 164 | config.semaphore -= count || 1; 165 | // don't start until equal number of stop-calls 166 | if ( config.semaphore > 0 ) { 167 | return; 168 | } 169 | // ignore if start is called more often then stop 170 | if ( config.semaphore < 0 ) { 171 | config.semaphore = 0; 172 | QUnit.pushFailure( "Called start() while already started (QUnit.config.semaphore was 0 already)", null, sourceFromStacktrace(2) ); 173 | return; 174 | } 175 | // A slight delay, to avoid any current callbacks 176 | if ( defined.setTimeout ) { 177 | setTimeout(function() { 178 | if ( config.semaphore > 0 ) { 179 | return; 180 | } 181 | if ( config.timeout ) { 182 | clearTimeout( config.timeout ); 183 | } 184 | 185 | config.blocking = false; 186 | process( true ); 187 | }, 13); 188 | } else { 189 | config.blocking = false; 190 | process( true ); 191 | } 192 | }, 193 | 194 | stop: function( count ) { 195 | config.semaphore += count || 1; 196 | config.blocking = true; 197 | 198 | if ( config.testTimeout && defined.setTimeout ) { 199 | clearTimeout( config.timeout ); 200 | config.timeout = setTimeout(function() { 201 | QUnit.ok( false, "Test timed out" ); 202 | config.semaphore = 1; 203 | QUnit.start(); 204 | }, config.testTimeout ); 205 | } 206 | } 207 | }; 208 | 209 | // We use the prototype to distinguish between properties that should 210 | // be exposed as globals (and in exports) and those that shouldn't 211 | (function() { 212 | function F() {} 213 | F.prototype = QUnit; 214 | QUnit = new F(); 215 | // Make F QUnit's constructor so that we can add to the prototype later 216 | QUnit.constructor = F; 217 | }()); 218 | 219 | /** 220 | * Config object: Maintain internal state 221 | * Later exposed as QUnit.config 222 | * `config` initialized at top of scope 223 | */ 224 | config = { 225 | // The queue of tests to run 226 | queue: [], 227 | 228 | // block until document ready 229 | blocking: true, 230 | 231 | // when enabled, show only failing tests 232 | // gets persisted through sessionStorage and can be changed in UI via checkbox 233 | hidepassed: false, 234 | 235 | // by default, run previously failed tests first 236 | // very useful in combination with "Hide passed tests" checked 237 | reorder: true, 238 | 239 | // by default, modify document.title when suite is done 240 | altertitle: true, 241 | 242 | // by default, scroll to top of the page when suite is done 243 | scrolltop: true, 244 | 245 | // when enabled, all tests must call expect() 246 | requireExpects: false, 247 | 248 | // add checkboxes that are persisted in the query-string 249 | // when enabled, the id is set to `true` as a `QUnit.config` property 250 | urlConfig: [ 251 | { 252 | id: "noglobals", 253 | label: "Check for Globals", 254 | tooltip: "Enabling this will test if any test introduces new properties on the `window` object. Stored as query-strings." 255 | }, 256 | { 257 | id: "notrycatch", 258 | label: "No try-catch", 259 | tooltip: "Enabling this will run tests outside of a try-catch block. Makes debugging exceptions in IE reasonable. Stored as query-strings." 260 | } 261 | ], 262 | 263 | // Set of all modules. 264 | modules: {}, 265 | 266 | // logging callback queues 267 | begin: [], 268 | done: [], 269 | log: [], 270 | testStart: [], 271 | testDone: [], 272 | moduleStart: [], 273 | moduleDone: [] 274 | }; 275 | 276 | // Initialize more QUnit.config and QUnit.urlParams 277 | (function() { 278 | var i, current, 279 | location = window.location || { search: "", protocol: "file:" }, 280 | params = location.search.slice( 1 ).split( "&" ), 281 | length = params.length, 282 | urlParams = {}; 283 | 284 | if ( params[ 0 ] ) { 285 | for ( i = 0; i < length; i++ ) { 286 | current = params[ i ].split( "=" ); 287 | current[ 0 ] = decodeURIComponent( current[ 0 ] ); 288 | 289 | // allow just a key to turn on a flag, e.g., test.html?noglobals 290 | current[ 1 ] = current[ 1 ] ? decodeURIComponent( current[ 1 ] ) : true; 291 | if ( urlParams[ current[ 0 ] ] ) { 292 | urlParams[ current[ 0 ] ] = [].concat( urlParams[ current[ 0 ] ], current[ 1 ] ); 293 | } else { 294 | urlParams[ current[ 0 ] ] = current[ 1 ]; 295 | } 296 | } 297 | } 298 | 299 | QUnit.urlParams = urlParams; 300 | 301 | // String search anywhere in moduleName+testName 302 | config.filter = urlParams.filter; 303 | 304 | // Exact match of the module name 305 | config.module = urlParams.module; 306 | 307 | config.testNumber = []; 308 | if ( urlParams.testNumber ) { 309 | 310 | // Ensure that urlParams.testNumber is an array 311 | urlParams.testNumber = [].concat( urlParams.testNumber ); 312 | for ( i = 0; i < urlParams.testNumber.length; i++ ) { 313 | current = urlParams.testNumber[ i ]; 314 | config.testNumber.push( parseInt( current, 10 ) ); 315 | } 316 | } 317 | 318 | // Figure out if we're running the tests from a server or not 319 | QUnit.isLocal = location.protocol === "file:"; 320 | }()); 321 | 322 | extend( QUnit, { 323 | 324 | config: config, 325 | 326 | // Initialize the configuration options 327 | init: function() { 328 | extend( config, { 329 | stats: { all: 0, bad: 0 }, 330 | moduleStats: { all: 0, bad: 0 }, 331 | started: +new Date(), 332 | updateRate: 1000, 333 | blocking: false, 334 | autostart: true, 335 | autorun: false, 336 | filter: "", 337 | queue: [], 338 | semaphore: 1 339 | }); 340 | 341 | var tests, banner, result, 342 | qunit = id( "qunit" ); 343 | 344 | if ( qunit ) { 345 | qunit.innerHTML = 346 | "

" + escapeText( document.title ) + "

" + 347 | "

" + 348 | "
" + 349 | "

" + 350 | "
    "; 351 | } 352 | 353 | tests = id( "qunit-tests" ); 354 | banner = id( "qunit-banner" ); 355 | result = id( "qunit-testresult" ); 356 | 357 | if ( tests ) { 358 | tests.innerHTML = ""; 359 | } 360 | 361 | if ( banner ) { 362 | banner.className = ""; 363 | } 364 | 365 | if ( result ) { 366 | result.parentNode.removeChild( result ); 367 | } 368 | 369 | if ( tests ) { 370 | result = document.createElement( "p" ); 371 | result.id = "qunit-testresult"; 372 | result.className = "result"; 373 | tests.parentNode.insertBefore( result, tests ); 374 | result.innerHTML = "Running...
     "; 375 | } 376 | }, 377 | 378 | // Resets the test setup. Useful for tests that modify the DOM. 379 | /* 380 | DEPRECATED: Use multiple tests instead of resetting inside a test. 381 | Use testStart or testDone for custom cleanup. 382 | This method will throw an error in 2.0, and will be removed in 2.1 383 | */ 384 | reset: function() { 385 | var fixture = id( "qunit-fixture" ); 386 | if ( fixture ) { 387 | fixture.innerHTML = config.fixture; 388 | } 389 | }, 390 | 391 | // Safe object type checking 392 | is: function( type, obj ) { 393 | return QUnit.objectType( obj ) === type; 394 | }, 395 | 396 | objectType: function( obj ) { 397 | if ( typeof obj === "undefined" ) { 398 | return "undefined"; 399 | } 400 | 401 | // Consider: typeof null === object 402 | if ( obj === null ) { 403 | return "null"; 404 | } 405 | 406 | var match = toString.call( obj ).match(/^\[object\s(.*)\]$/), 407 | type = match && match[1] || ""; 408 | 409 | switch ( type ) { 410 | case "Number": 411 | if ( isNaN(obj) ) { 412 | return "nan"; 413 | } 414 | return "number"; 415 | case "String": 416 | case "Boolean": 417 | case "Array": 418 | case "Date": 419 | case "RegExp": 420 | case "Function": 421 | return type.toLowerCase(); 422 | } 423 | if ( typeof obj === "object" ) { 424 | return "object"; 425 | } 426 | return undefined; 427 | }, 428 | 429 | push: function( result, actual, expected, message ) { 430 | if ( !config.current ) { 431 | throw new Error( "assertion outside test context, was " + sourceFromStacktrace() ); 432 | } 433 | 434 | var output, source, 435 | details = { 436 | module: config.current.module, 437 | name: config.current.testName, 438 | result: result, 439 | message: message, 440 | actual: actual, 441 | expected: expected 442 | }; 443 | 444 | message = escapeText( message ) || ( result ? "okay" : "failed" ); 445 | message = "" + message + ""; 446 | output = message; 447 | 448 | if ( !result ) { 449 | expected = escapeText( QUnit.jsDump.parse(expected) ); 450 | actual = escapeText( QUnit.jsDump.parse(actual) ); 451 | output += ""; 452 | 453 | if ( actual !== expected ) { 454 | output += ""; 455 | output += ""; 456 | } 457 | 458 | source = sourceFromStacktrace(); 459 | 460 | if ( source ) { 461 | details.source = source; 462 | output += ""; 463 | } 464 | 465 | output += "
    Expected:
    " + expected + "
    Result:
    " + actual + "
    Diff:
    " + QUnit.diff( expected, actual ) + "
    Source:
    " + escapeText( source ) + "
    "; 466 | } 467 | 468 | runLoggingCallbacks( "log", QUnit, details ); 469 | 470 | config.current.assertions.push({ 471 | result: !!result, 472 | message: output 473 | }); 474 | }, 475 | 476 | pushFailure: function( message, source, actual ) { 477 | if ( !config.current ) { 478 | throw new Error( "pushFailure() assertion outside test context, was " + sourceFromStacktrace(2) ); 479 | } 480 | 481 | var output, 482 | details = { 483 | module: config.current.module, 484 | name: config.current.testName, 485 | result: false, 486 | message: message 487 | }; 488 | 489 | message = escapeText( message ) || "error"; 490 | message = "" + message + ""; 491 | output = message; 492 | 493 | output += ""; 494 | 495 | if ( actual ) { 496 | output += ""; 497 | } 498 | 499 | if ( source ) { 500 | details.source = source; 501 | output += ""; 502 | } 503 | 504 | output += "
    Result:
    " + escapeText( actual ) + "
    Source:
    " + escapeText( source ) + "
    "; 505 | 506 | runLoggingCallbacks( "log", QUnit, details ); 507 | 508 | config.current.assertions.push({ 509 | result: false, 510 | message: output 511 | }); 512 | }, 513 | 514 | url: function( params ) { 515 | params = extend( extend( {}, QUnit.urlParams ), params ); 516 | var key, 517 | querystring = "?"; 518 | 519 | for ( key in params ) { 520 | if ( hasOwn.call( params, key ) ) { 521 | querystring += encodeURIComponent( key ) + "=" + 522 | encodeURIComponent( params[ key ] ) + "&"; 523 | } 524 | } 525 | return window.location.protocol + "//" + window.location.host + 526 | window.location.pathname + querystring.slice( 0, -1 ); 527 | }, 528 | 529 | extend: extend, 530 | id: id, 531 | addEvent: addEvent, 532 | addClass: addClass, 533 | hasClass: hasClass, 534 | removeClass: removeClass 535 | // load, equiv, jsDump, diff: Attached later 536 | }); 537 | 538 | /** 539 | * @deprecated: Created for backwards compatibility with test runner that set the hook function 540 | * into QUnit.{hook}, instead of invoking it and passing the hook function. 541 | * QUnit.constructor is set to the empty F() above so that we can add to it's prototype here. 542 | * Doing this allows us to tell if the following methods have been overwritten on the actual 543 | * QUnit object. 544 | */ 545 | extend( QUnit.constructor.prototype, { 546 | 547 | // Logging callbacks; all receive a single argument with the listed properties 548 | // run test/logs.html for any related changes 549 | begin: registerLoggingCallback( "begin" ), 550 | 551 | // done: { failed, passed, total, runtime } 552 | done: registerLoggingCallback( "done" ), 553 | 554 | // log: { result, actual, expected, message } 555 | log: registerLoggingCallback( "log" ), 556 | 557 | // testStart: { name } 558 | testStart: registerLoggingCallback( "testStart" ), 559 | 560 | // testDone: { name, failed, passed, total, runtime } 561 | testDone: registerLoggingCallback( "testDone" ), 562 | 563 | // moduleStart: { name } 564 | moduleStart: registerLoggingCallback( "moduleStart" ), 565 | 566 | // moduleDone: { name, failed, passed, total } 567 | moduleDone: registerLoggingCallback( "moduleDone" ) 568 | }); 569 | 570 | if ( !defined.document || document.readyState === "complete" ) { 571 | config.autorun = true; 572 | } 573 | 574 | QUnit.load = function() { 575 | runLoggingCallbacks( "begin", QUnit, {} ); 576 | 577 | // Initialize the config, saving the execution queue 578 | var banner, filter, i, j, label, len, main, ol, toolbar, val, selection, 579 | urlConfigContainer, moduleFilter, userAgent, 580 | numModules = 0, 581 | moduleNames = [], 582 | moduleFilterHtml = "", 583 | urlConfigHtml = "", 584 | oldconfig = extend( {}, config ); 585 | 586 | QUnit.init(); 587 | extend(config, oldconfig); 588 | 589 | config.blocking = false; 590 | 591 | len = config.urlConfig.length; 592 | 593 | for ( i = 0; i < len; i++ ) { 594 | val = config.urlConfig[i]; 595 | if ( typeof val === "string" ) { 596 | val = { 597 | id: val, 598 | label: val 599 | }; 600 | } 601 | config[ val.id ] = QUnit.urlParams[ val.id ]; 602 | if ( !val.value || typeof val.value === "string" ) { 603 | urlConfigHtml += ""; 611 | } else { 612 | urlConfigHtml += ""; 646 | } 647 | } 648 | for ( i in config.modules ) { 649 | if ( config.modules.hasOwnProperty( i ) ) { 650 | moduleNames.push(i); 651 | } 652 | } 653 | numModules = moduleNames.length; 654 | moduleNames.sort( function( a, b ) { 655 | return a.localeCompare( b ); 656 | }); 657 | moduleFilterHtml += ""; 668 | 669 | // `userAgent` initialized at top of scope 670 | userAgent = id( "qunit-userAgent" ); 671 | if ( userAgent ) { 672 | userAgent.innerHTML = navigator.userAgent; 673 | } 674 | 675 | // `banner` initialized at top of scope 676 | banner = id( "qunit-header" ); 677 | if ( banner ) { 678 | banner.innerHTML = "" + banner.innerHTML + " "; 679 | } 680 | 681 | // `toolbar` initialized at top of scope 682 | toolbar = id( "qunit-testrunner-toolbar" ); 683 | if ( toolbar ) { 684 | // `filter` initialized at top of scope 685 | filter = document.createElement( "input" ); 686 | filter.type = "checkbox"; 687 | filter.id = "qunit-filter-pass"; 688 | 689 | addEvent( filter, "click", function() { 690 | var tmp, 691 | ol = id( "qunit-tests" ); 692 | 693 | if ( filter.checked ) { 694 | ol.className = ol.className + " hidepass"; 695 | } else { 696 | tmp = " " + ol.className.replace( /[\n\t\r]/g, " " ) + " "; 697 | ol.className = tmp.replace( / hidepass /, " " ); 698 | } 699 | if ( defined.sessionStorage ) { 700 | if (filter.checked) { 701 | sessionStorage.setItem( "qunit-filter-passed-tests", "true" ); 702 | } else { 703 | sessionStorage.removeItem( "qunit-filter-passed-tests" ); 704 | } 705 | } 706 | }); 707 | 708 | if ( config.hidepassed || defined.sessionStorage && sessionStorage.getItem( "qunit-filter-passed-tests" ) ) { 709 | filter.checked = true; 710 | // `ol` initialized at top of scope 711 | ol = id( "qunit-tests" ); 712 | ol.className = ol.className + " hidepass"; 713 | } 714 | toolbar.appendChild( filter ); 715 | 716 | // `label` initialized at top of scope 717 | label = document.createElement( "label" ); 718 | label.setAttribute( "for", "qunit-filter-pass" ); 719 | label.setAttribute( "title", "Only show tests and assertions that fail. Stored in sessionStorage." ); 720 | label.innerHTML = "Hide passed tests"; 721 | toolbar.appendChild( label ); 722 | 723 | urlConfigContainer = document.createElement("span"); 724 | urlConfigContainer.innerHTML = urlConfigHtml; 725 | // For oldIE support: 726 | // * Add handlers to the individual elements instead of the container 727 | // * Use "click" instead of "change" for checkboxes 728 | // * Fallback from event.target to event.srcElement 729 | addEvents( urlConfigContainer.getElementsByTagName("input"), "click", function( event ) { 730 | var params = {}, 731 | target = event.target || event.srcElement; 732 | params[ target.name ] = target.checked ? 733 | target.defaultValue || true : 734 | undefined; 735 | window.location = QUnit.url( params ); 736 | }); 737 | addEvents( urlConfigContainer.getElementsByTagName("select"), "change", function( event ) { 738 | var params = {}, 739 | target = event.target || event.srcElement; 740 | params[ target.name ] = target.options[ target.selectedIndex ].value || undefined; 741 | window.location = QUnit.url( params ); 742 | }); 743 | toolbar.appendChild( urlConfigContainer ); 744 | 745 | if (numModules > 1) { 746 | moduleFilter = document.createElement( "span" ); 747 | moduleFilter.setAttribute( "id", "qunit-modulefilter-container" ); 748 | moduleFilter.innerHTML = moduleFilterHtml; 749 | addEvent( moduleFilter.lastChild, "change", function() { 750 | var selectBox = moduleFilter.getElementsByTagName("select")[0], 751 | selectedModule = decodeURIComponent(selectBox.options[selectBox.selectedIndex].value); 752 | 753 | window.location = QUnit.url({ 754 | module: ( selectedModule === "" ) ? undefined : selectedModule, 755 | // Remove any existing filters 756 | filter: undefined, 757 | testNumber: undefined 758 | }); 759 | }); 760 | toolbar.appendChild(moduleFilter); 761 | } 762 | } 763 | 764 | // `main` initialized at top of scope 765 | main = id( "qunit-fixture" ); 766 | if ( main ) { 767 | config.fixture = main.innerHTML; 768 | } 769 | 770 | if ( config.autostart ) { 771 | QUnit.start(); 772 | } 773 | }; 774 | 775 | if ( defined.document ) { 776 | addEvent( window, "load", QUnit.load ); 777 | } 778 | 779 | // `onErrorFnPrev` initialized at top of scope 780 | // Preserve other handlers 781 | onErrorFnPrev = window.onerror; 782 | 783 | // Cover uncaught exceptions 784 | // Returning true will suppress the default browser handler, 785 | // returning false will let it run. 786 | window.onerror = function ( error, filePath, linerNr ) { 787 | var ret = false; 788 | if ( onErrorFnPrev ) { 789 | ret = onErrorFnPrev( error, filePath, linerNr ); 790 | } 791 | 792 | // Treat return value as window.onerror itself does, 793 | // Only do our handling if not suppressed. 794 | if ( ret !== true ) { 795 | if ( QUnit.config.current ) { 796 | if ( QUnit.config.current.ignoreGlobalErrors ) { 797 | return true; 798 | } 799 | QUnit.pushFailure( error, filePath + ":" + linerNr ); 800 | } else { 801 | QUnit.test( "global failure", extend( function() { 802 | QUnit.pushFailure( error, filePath + ":" + linerNr ); 803 | }, { validTest: validTest } ) ); 804 | } 805 | return false; 806 | } 807 | 808 | return ret; 809 | }; 810 | 811 | function done() { 812 | config.autorun = true; 813 | 814 | // Log the last module results 815 | if ( config.previousModule ) { 816 | runLoggingCallbacks( "moduleDone", QUnit, { 817 | name: config.previousModule, 818 | failed: config.moduleStats.bad, 819 | passed: config.moduleStats.all - config.moduleStats.bad, 820 | total: config.moduleStats.all 821 | }); 822 | } 823 | delete config.previousModule; 824 | 825 | var i, key, 826 | banner = id( "qunit-banner" ), 827 | tests = id( "qunit-tests" ), 828 | runtime = +new Date() - config.started, 829 | passed = config.stats.all - config.stats.bad, 830 | html = [ 831 | "Tests completed in ", 832 | runtime, 833 | " milliseconds.
    ", 834 | "", 835 | passed, 836 | " assertions of ", 837 | config.stats.all, 838 | " passed, ", 839 | config.stats.bad, 840 | " failed." 841 | ].join( "" ); 842 | 843 | if ( banner ) { 844 | banner.className = ( config.stats.bad ? "qunit-fail" : "qunit-pass" ); 845 | } 846 | 847 | if ( tests ) { 848 | id( "qunit-testresult" ).innerHTML = html; 849 | } 850 | 851 | if ( config.altertitle && defined.document && document.title ) { 852 | // show ✖ for good, ✔ for bad suite result in title 853 | // use escape sequences in case file gets loaded with non-utf-8-charset 854 | document.title = [ 855 | ( config.stats.bad ? "\u2716" : "\u2714" ), 856 | document.title.replace( /^[\u2714\u2716] /i, "" ) 857 | ].join( " " ); 858 | } 859 | 860 | // clear own sessionStorage items if all tests passed 861 | if ( config.reorder && defined.sessionStorage && config.stats.bad === 0 ) { 862 | // `key` & `i` initialized at top of scope 863 | for ( i = 0; i < sessionStorage.length; i++ ) { 864 | key = sessionStorage.key( i++ ); 865 | if ( key.indexOf( "qunit-test-" ) === 0 ) { 866 | sessionStorage.removeItem( key ); 867 | } 868 | } 869 | } 870 | 871 | // scroll back to top to show results 872 | if ( config.scrolltop && window.scrollTo ) { 873 | window.scrollTo(0, 0); 874 | } 875 | 876 | runLoggingCallbacks( "done", QUnit, { 877 | failed: config.stats.bad, 878 | passed: passed, 879 | total: config.stats.all, 880 | runtime: runtime 881 | }); 882 | } 883 | 884 | /** @return Boolean: true if this test should be ran */ 885 | function validTest( test ) { 886 | var include, 887 | filter = config.filter && config.filter.toLowerCase(), 888 | module = config.module && config.module.toLowerCase(), 889 | fullName = ( test.module + ": " + test.testName ).toLowerCase(); 890 | 891 | // Internally-generated tests are always valid 892 | if ( test.callback && test.callback.validTest === validTest ) { 893 | delete test.callback.validTest; 894 | return true; 895 | } 896 | 897 | if ( config.testNumber.length > 0 ) { 898 | if ( inArray( test.testNumber, config.testNumber ) < 0 ) { 899 | return false; 900 | } 901 | } 902 | 903 | if ( module && ( !test.module || test.module.toLowerCase() !== module ) ) { 904 | return false; 905 | } 906 | 907 | if ( !filter ) { 908 | return true; 909 | } 910 | 911 | include = filter.charAt( 0 ) !== "!"; 912 | if ( !include ) { 913 | filter = filter.slice( 1 ); 914 | } 915 | 916 | // If the filter matches, we need to honour include 917 | if ( fullName.indexOf( filter ) !== -1 ) { 918 | return include; 919 | } 920 | 921 | // Otherwise, do the opposite 922 | return !include; 923 | } 924 | 925 | // so far supports only Firefox, Chrome and Opera (buggy), Safari (for real exceptions) 926 | // Later Safari and IE10 are supposed to support error.stack as well 927 | // See also https://developer.mozilla.org/en/JavaScript/Reference/Global_Objects/Error/Stack 928 | function extractStacktrace( e, offset ) { 929 | offset = offset === undefined ? 3 : offset; 930 | 931 | var stack, include, i; 932 | 933 | if ( e.stacktrace ) { 934 | // Opera 935 | return e.stacktrace.split( "\n" )[ offset + 3 ]; 936 | } else if ( e.stack ) { 937 | // Firefox, Chrome 938 | stack = e.stack.split( "\n" ); 939 | if (/^error$/i.test( stack[0] ) ) { 940 | stack.shift(); 941 | } 942 | if ( fileName ) { 943 | include = []; 944 | for ( i = offset; i < stack.length; i++ ) { 945 | if ( stack[ i ].indexOf( fileName ) !== -1 ) { 946 | break; 947 | } 948 | include.push( stack[ i ] ); 949 | } 950 | if ( include.length ) { 951 | return include.join( "\n" ); 952 | } 953 | } 954 | return stack[ offset ]; 955 | } else if ( e.sourceURL ) { 956 | // Safari, PhantomJS 957 | // hopefully one day Safari provides actual stacktraces 958 | // exclude useless self-reference for generated Error objects 959 | if ( /qunit.js$/.test( e.sourceURL ) ) { 960 | return; 961 | } 962 | // for actual exceptions, this is useful 963 | return e.sourceURL + ":" + e.line; 964 | } 965 | } 966 | function sourceFromStacktrace( offset ) { 967 | try { 968 | throw new Error(); 969 | } catch ( e ) { 970 | return extractStacktrace( e, offset ); 971 | } 972 | } 973 | 974 | /** 975 | * Escape text for attribute or text content. 976 | */ 977 | function escapeText( s ) { 978 | if ( !s ) { 979 | return ""; 980 | } 981 | s = s + ""; 982 | // Both single quotes and double quotes (for attributes) 983 | return s.replace( /['"<>&]/g, function( s ) { 984 | switch( s ) { 985 | case "'": 986 | return "'"; 987 | case "\"": 988 | return """; 989 | case "<": 990 | return "<"; 991 | case ">": 992 | return ">"; 993 | case "&": 994 | return "&"; 995 | } 996 | }); 997 | } 998 | 999 | function synchronize( callback, last ) { 1000 | config.queue.push( callback ); 1001 | 1002 | if ( config.autorun && !config.blocking ) { 1003 | process( last ); 1004 | } 1005 | } 1006 | 1007 | function process( last ) { 1008 | function next() { 1009 | process( last ); 1010 | } 1011 | var start = new Date().getTime(); 1012 | config.depth = config.depth ? config.depth + 1 : 1; 1013 | 1014 | while ( config.queue.length && !config.blocking ) { 1015 | if ( !defined.setTimeout || config.updateRate <= 0 || ( ( new Date().getTime() - start ) < config.updateRate ) ) { 1016 | config.queue.shift()(); 1017 | } else { 1018 | setTimeout( next, 13 ); 1019 | break; 1020 | } 1021 | } 1022 | config.depth--; 1023 | if ( last && !config.blocking && !config.queue.length && config.depth === 0 ) { 1024 | done(); 1025 | } 1026 | } 1027 | 1028 | function saveGlobal() { 1029 | config.pollution = []; 1030 | 1031 | if ( config.noglobals ) { 1032 | for ( var key in window ) { 1033 | if ( hasOwn.call( window, key ) ) { 1034 | // in Opera sometimes DOM element ids show up here, ignore them 1035 | if ( /^qunit-test-output/.test( key ) ) { 1036 | continue; 1037 | } 1038 | config.pollution.push( key ); 1039 | } 1040 | } 1041 | } 1042 | } 1043 | 1044 | function checkPollution() { 1045 | var newGlobals, 1046 | deletedGlobals, 1047 | old = config.pollution; 1048 | 1049 | saveGlobal(); 1050 | 1051 | newGlobals = diff( config.pollution, old ); 1052 | if ( newGlobals.length > 0 ) { 1053 | QUnit.pushFailure( "Introduced global variable(s): " + newGlobals.join(", ") ); 1054 | } 1055 | 1056 | deletedGlobals = diff( old, config.pollution ); 1057 | if ( deletedGlobals.length > 0 ) { 1058 | QUnit.pushFailure( "Deleted global variable(s): " + deletedGlobals.join(", ") ); 1059 | } 1060 | } 1061 | 1062 | // returns a new Array with the elements that are in a but not in b 1063 | function diff( a, b ) { 1064 | var i, j, 1065 | result = a.slice(); 1066 | 1067 | for ( i = 0; i < result.length; i++ ) { 1068 | for ( j = 0; j < b.length; j++ ) { 1069 | if ( result[i] === b[j] ) { 1070 | result.splice( i, 1 ); 1071 | i--; 1072 | break; 1073 | } 1074 | } 1075 | } 1076 | return result; 1077 | } 1078 | 1079 | function extend( a, b ) { 1080 | for ( var prop in b ) { 1081 | if ( hasOwn.call( b, prop ) ) { 1082 | // Avoid "Member not found" error in IE8 caused by messing with window.constructor 1083 | if ( !( prop === "constructor" && a === window ) ) { 1084 | if ( b[ prop ] === undefined ) { 1085 | delete a[ prop ]; 1086 | } else { 1087 | a[ prop ] = b[ prop ]; 1088 | } 1089 | } 1090 | } 1091 | } 1092 | 1093 | return a; 1094 | } 1095 | 1096 | /** 1097 | * @param {HTMLElement} elem 1098 | * @param {string} type 1099 | * @param {Function} fn 1100 | */ 1101 | function addEvent( elem, type, fn ) { 1102 | if ( elem.addEventListener ) { 1103 | 1104 | // Standards-based browsers 1105 | elem.addEventListener( type, fn, false ); 1106 | } else if ( elem.attachEvent ) { 1107 | 1108 | // support: IE <9 1109 | elem.attachEvent( "on" + type, fn ); 1110 | } else { 1111 | 1112 | // Caller must ensure support for event listeners is present 1113 | throw new Error( "addEvent() was called in a context without event listener support" ); 1114 | } 1115 | } 1116 | 1117 | /** 1118 | * @param {Array|NodeList} elems 1119 | * @param {string} type 1120 | * @param {Function} fn 1121 | */ 1122 | function addEvents( elems, type, fn ) { 1123 | var i = elems.length; 1124 | while ( i-- ) { 1125 | addEvent( elems[i], type, fn ); 1126 | } 1127 | } 1128 | 1129 | function hasClass( elem, name ) { 1130 | return (" " + elem.className + " ").indexOf(" " + name + " ") > -1; 1131 | } 1132 | 1133 | function addClass( elem, name ) { 1134 | if ( !hasClass( elem, name ) ) { 1135 | elem.className += (elem.className ? " " : "") + name; 1136 | } 1137 | } 1138 | 1139 | function removeClass( elem, name ) { 1140 | var set = " " + elem.className + " "; 1141 | // Class name may appear multiple times 1142 | while ( set.indexOf(" " + name + " ") > -1 ) { 1143 | set = set.replace(" " + name + " " , " "); 1144 | } 1145 | // If possible, trim it for prettiness, but not necessarily 1146 | elem.className = typeof set.trim === "function" ? set.trim() : set.replace(/^\s+|\s+$/g, ""); 1147 | } 1148 | 1149 | function id( name ) { 1150 | return defined.document && document.getElementById && document.getElementById( name ); 1151 | } 1152 | 1153 | function registerLoggingCallback( key ) { 1154 | return function( callback ) { 1155 | config[key].push( callback ); 1156 | }; 1157 | } 1158 | 1159 | // Supports deprecated method of completely overwriting logging callbacks 1160 | function runLoggingCallbacks( key, scope, args ) { 1161 | var i, callbacks; 1162 | if ( QUnit.hasOwnProperty( key ) ) { 1163 | QUnit[ key ].call(scope, args ); 1164 | } else { 1165 | callbacks = config[ key ]; 1166 | for ( i = 0; i < callbacks.length; i++ ) { 1167 | callbacks[ i ].call( scope, args ); 1168 | } 1169 | } 1170 | } 1171 | 1172 | // from jquery.js 1173 | function inArray( elem, array ) { 1174 | if ( array.indexOf ) { 1175 | return array.indexOf( elem ); 1176 | } 1177 | 1178 | for ( var i = 0, length = array.length; i < length; i++ ) { 1179 | if ( array[ i ] === elem ) { 1180 | return i; 1181 | } 1182 | } 1183 | 1184 | return -1; 1185 | } 1186 | 1187 | function Test( settings ) { 1188 | extend( this, settings ); 1189 | this.assertions = []; 1190 | this.testNumber = ++Test.count; 1191 | } 1192 | 1193 | Test.count = 0; 1194 | 1195 | Test.prototype = { 1196 | init: function() { 1197 | var a, b, li, 1198 | tests = id( "qunit-tests" ); 1199 | 1200 | if ( tests ) { 1201 | b = document.createElement( "strong" ); 1202 | b.innerHTML = this.nameHtml; 1203 | 1204 | // `a` initialized at top of scope 1205 | a = document.createElement( "a" ); 1206 | a.innerHTML = "Rerun"; 1207 | a.href = QUnit.url({ testNumber: this.testNumber }); 1208 | 1209 | li = document.createElement( "li" ); 1210 | li.appendChild( b ); 1211 | li.appendChild( a ); 1212 | li.className = "running"; 1213 | li.id = this.id = "qunit-test-output" + testId++; 1214 | 1215 | tests.appendChild( li ); 1216 | } 1217 | }, 1218 | setup: function() { 1219 | if ( 1220 | // Emit moduleStart when we're switching from one module to another 1221 | this.module !== config.previousModule || 1222 | // They could be equal (both undefined) but if the previousModule property doesn't 1223 | // yet exist it means this is the first test in a suite that isn't wrapped in a 1224 | // module, in which case we'll just emit a moduleStart event for 'undefined'. 1225 | // Without this, reporters can get testStart before moduleStart which is a problem. 1226 | !hasOwn.call( config, "previousModule" ) 1227 | ) { 1228 | if ( hasOwn.call( config, "previousModule" ) ) { 1229 | runLoggingCallbacks( "moduleDone", QUnit, { 1230 | name: config.previousModule, 1231 | failed: config.moduleStats.bad, 1232 | passed: config.moduleStats.all - config.moduleStats.bad, 1233 | total: config.moduleStats.all 1234 | }); 1235 | } 1236 | config.previousModule = this.module; 1237 | config.moduleStats = { all: 0, bad: 0 }; 1238 | runLoggingCallbacks( "moduleStart", QUnit, { 1239 | name: this.module 1240 | }); 1241 | } 1242 | 1243 | config.current = this; 1244 | 1245 | this.testEnvironment = extend({ 1246 | setup: function() {}, 1247 | teardown: function() {} 1248 | }, this.moduleTestEnvironment ); 1249 | 1250 | this.started = +new Date(); 1251 | runLoggingCallbacks( "testStart", QUnit, { 1252 | name: this.testName, 1253 | module: this.module 1254 | }); 1255 | 1256 | /*jshint camelcase:false */ 1257 | 1258 | 1259 | /** 1260 | * Expose the current test environment. 1261 | * 1262 | * @deprecated since 1.12.0: Use QUnit.config.current.testEnvironment instead. 1263 | */ 1264 | QUnit.current_testEnvironment = this.testEnvironment; 1265 | 1266 | /*jshint camelcase:true */ 1267 | 1268 | if ( !config.pollution ) { 1269 | saveGlobal(); 1270 | } 1271 | if ( config.notrycatch ) { 1272 | this.testEnvironment.setup.call( this.testEnvironment, QUnit.assert ); 1273 | return; 1274 | } 1275 | try { 1276 | this.testEnvironment.setup.call( this.testEnvironment, QUnit.assert ); 1277 | } catch( e ) { 1278 | QUnit.pushFailure( "Setup failed on " + this.testName + ": " + ( e.message || e ), extractStacktrace( e, 1 ) ); 1279 | } 1280 | }, 1281 | run: function() { 1282 | config.current = this; 1283 | 1284 | var running = id( "qunit-testresult" ); 1285 | 1286 | if ( running ) { 1287 | running.innerHTML = "Running:
    " + this.nameHtml; 1288 | } 1289 | 1290 | if ( this.async ) { 1291 | QUnit.stop(); 1292 | } 1293 | 1294 | this.callbackStarted = +new Date(); 1295 | 1296 | if ( config.notrycatch ) { 1297 | this.callback.call( this.testEnvironment, QUnit.assert ); 1298 | this.callbackRuntime = +new Date() - this.callbackStarted; 1299 | return; 1300 | } 1301 | 1302 | try { 1303 | this.callback.call( this.testEnvironment, QUnit.assert ); 1304 | this.callbackRuntime = +new Date() - this.callbackStarted; 1305 | } catch( e ) { 1306 | this.callbackRuntime = +new Date() - this.callbackStarted; 1307 | 1308 | QUnit.pushFailure( "Died on test #" + (this.assertions.length + 1) + " " + this.stack + ": " + ( e.message || e ), extractStacktrace( e, 0 ) ); 1309 | // else next test will carry the responsibility 1310 | saveGlobal(); 1311 | 1312 | // Restart the tests if they're blocking 1313 | if ( config.blocking ) { 1314 | QUnit.start(); 1315 | } 1316 | } 1317 | }, 1318 | teardown: function() { 1319 | config.current = this; 1320 | if ( config.notrycatch ) { 1321 | if ( typeof this.callbackRuntime === "undefined" ) { 1322 | this.callbackRuntime = +new Date() - this.callbackStarted; 1323 | } 1324 | this.testEnvironment.teardown.call( this.testEnvironment, QUnit.assert ); 1325 | return; 1326 | } else { 1327 | try { 1328 | this.testEnvironment.teardown.call( this.testEnvironment, QUnit.assert ); 1329 | } catch( e ) { 1330 | QUnit.pushFailure( "Teardown failed on " + this.testName + ": " + ( e.message || e ), extractStacktrace( e, 1 ) ); 1331 | } 1332 | } 1333 | checkPollution(); 1334 | }, 1335 | finish: function() { 1336 | config.current = this; 1337 | if ( config.requireExpects && this.expected === null ) { 1338 | QUnit.pushFailure( "Expected number of assertions to be defined, but expect() was not called.", this.stack ); 1339 | } else if ( this.expected !== null && this.expected !== this.assertions.length ) { 1340 | QUnit.pushFailure( "Expected " + this.expected + " assertions, but " + this.assertions.length + " were run", this.stack ); 1341 | } else if ( this.expected === null && !this.assertions.length ) { 1342 | QUnit.pushFailure( "Expected at least one assertion, but none were run - call expect(0) to accept zero assertions.", this.stack ); 1343 | } 1344 | 1345 | var i, assertion, a, b, time, li, ol, 1346 | test = this, 1347 | good = 0, 1348 | bad = 0, 1349 | tests = id( "qunit-tests" ); 1350 | 1351 | this.runtime = +new Date() - this.started; 1352 | config.stats.all += this.assertions.length; 1353 | config.moduleStats.all += this.assertions.length; 1354 | 1355 | if ( tests ) { 1356 | ol = document.createElement( "ol" ); 1357 | ol.className = "qunit-assert-list"; 1358 | 1359 | for ( i = 0; i < this.assertions.length; i++ ) { 1360 | assertion = this.assertions[i]; 1361 | 1362 | li = document.createElement( "li" ); 1363 | li.className = assertion.result ? "pass" : "fail"; 1364 | li.innerHTML = assertion.message || ( assertion.result ? "okay" : "failed" ); 1365 | ol.appendChild( li ); 1366 | 1367 | if ( assertion.result ) { 1368 | good++; 1369 | } else { 1370 | bad++; 1371 | config.stats.bad++; 1372 | config.moduleStats.bad++; 1373 | } 1374 | } 1375 | 1376 | // store result when possible 1377 | if ( QUnit.config.reorder && defined.sessionStorage ) { 1378 | if ( bad ) { 1379 | sessionStorage.setItem( "qunit-test-" + this.module + "-" + this.testName, bad ); 1380 | } else { 1381 | sessionStorage.removeItem( "qunit-test-" + this.module + "-" + this.testName ); 1382 | } 1383 | } 1384 | 1385 | if ( bad === 0 ) { 1386 | addClass( ol, "qunit-collapsed" ); 1387 | } 1388 | 1389 | // `b` initialized at top of scope 1390 | b = document.createElement( "strong" ); 1391 | b.innerHTML = this.nameHtml + " (" + bad + ", " + good + ", " + this.assertions.length + ")"; 1392 | 1393 | addEvent(b, "click", function() { 1394 | var next = b.parentNode.lastChild, 1395 | collapsed = hasClass( next, "qunit-collapsed" ); 1396 | ( collapsed ? removeClass : addClass )( next, "qunit-collapsed" ); 1397 | }); 1398 | 1399 | addEvent(b, "dblclick", function( e ) { 1400 | var target = e && e.target ? e.target : window.event.srcElement; 1401 | if ( target.nodeName.toLowerCase() === "span" || target.nodeName.toLowerCase() === "b" ) { 1402 | target = target.parentNode; 1403 | } 1404 | if ( window.location && target.nodeName.toLowerCase() === "strong" ) { 1405 | window.location = QUnit.url({ testNumber: test.testNumber }); 1406 | } 1407 | }); 1408 | 1409 | // `time` initialized at top of scope 1410 | time = document.createElement( "span" ); 1411 | time.className = "runtime"; 1412 | time.innerHTML = this.runtime + " ms"; 1413 | 1414 | // `li` initialized at top of scope 1415 | li = id( this.id ); 1416 | li.className = bad ? "fail" : "pass"; 1417 | li.removeChild( li.firstChild ); 1418 | a = li.firstChild; 1419 | li.appendChild( b ); 1420 | li.appendChild( a ); 1421 | li.appendChild( time ); 1422 | li.appendChild( ol ); 1423 | 1424 | } else { 1425 | for ( i = 0; i < this.assertions.length; i++ ) { 1426 | if ( !this.assertions[i].result ) { 1427 | bad++; 1428 | config.stats.bad++; 1429 | config.moduleStats.bad++; 1430 | } 1431 | } 1432 | } 1433 | 1434 | runLoggingCallbacks( "testDone", QUnit, { 1435 | name: this.testName, 1436 | module: this.module, 1437 | failed: bad, 1438 | passed: this.assertions.length - bad, 1439 | total: this.assertions.length, 1440 | runtime: this.runtime, 1441 | // DEPRECATED: this property will be removed in 2.0.0, use runtime instead 1442 | duration: this.runtime 1443 | }); 1444 | 1445 | QUnit.reset(); 1446 | 1447 | config.current = undefined; 1448 | }, 1449 | 1450 | queue: function() { 1451 | var bad, 1452 | test = this; 1453 | 1454 | synchronize(function() { 1455 | test.init(); 1456 | }); 1457 | function run() { 1458 | // each of these can by async 1459 | synchronize(function() { 1460 | test.setup(); 1461 | }); 1462 | synchronize(function() { 1463 | test.run(); 1464 | }); 1465 | synchronize(function() { 1466 | test.teardown(); 1467 | }); 1468 | synchronize(function() { 1469 | test.finish(); 1470 | }); 1471 | } 1472 | 1473 | // `bad` initialized at top of scope 1474 | // defer when previous test run passed, if storage is available 1475 | bad = QUnit.config.reorder && defined.sessionStorage && 1476 | +sessionStorage.getItem( "qunit-test-" + this.module + "-" + this.testName ); 1477 | 1478 | if ( bad ) { 1479 | run(); 1480 | } else { 1481 | synchronize( run, true ); 1482 | } 1483 | } 1484 | }; 1485 | 1486 | // `assert` initialized at top of scope 1487 | // Assert helpers 1488 | // All of these must either call QUnit.push() or manually do: 1489 | // - runLoggingCallbacks( "log", .. ); 1490 | // - config.current.assertions.push({ .. }); 1491 | assert = QUnit.assert = { 1492 | /** 1493 | * Asserts rough true-ish result. 1494 | * @name ok 1495 | * @function 1496 | * @example ok( "asdfasdf".length > 5, "There must be at least 5 chars" ); 1497 | */ 1498 | ok: function( result, msg ) { 1499 | if ( !config.current ) { 1500 | throw new Error( "ok() assertion outside test context, was " + sourceFromStacktrace(2) ); 1501 | } 1502 | result = !!result; 1503 | msg = msg || ( result ? "okay" : "failed" ); 1504 | 1505 | var source, 1506 | details = { 1507 | module: config.current.module, 1508 | name: config.current.testName, 1509 | result: result, 1510 | message: msg 1511 | }; 1512 | 1513 | msg = "" + escapeText( msg ) + ""; 1514 | 1515 | if ( !result ) { 1516 | source = sourceFromStacktrace( 2 ); 1517 | if ( source ) { 1518 | details.source = source; 1519 | msg += "
    Source:
    " +
    1520 | 					escapeText( source ) +
    1521 | 					"
    "; 1522 | } 1523 | } 1524 | runLoggingCallbacks( "log", QUnit, details ); 1525 | config.current.assertions.push({ 1526 | result: result, 1527 | message: msg 1528 | }); 1529 | }, 1530 | 1531 | /** 1532 | * Assert that the first two arguments are equal, with an optional message. 1533 | * Prints out both actual and expected values. 1534 | * @name equal 1535 | * @function 1536 | * @example equal( format( "Received {0} bytes.", 2), "Received 2 bytes.", "format() replaces {0} with next argument" ); 1537 | */ 1538 | equal: function( actual, expected, message ) { 1539 | /*jshint eqeqeq:false */ 1540 | QUnit.push( expected == actual, actual, expected, message ); 1541 | }, 1542 | 1543 | /** 1544 | * @name notEqual 1545 | * @function 1546 | */ 1547 | notEqual: function( actual, expected, message ) { 1548 | /*jshint eqeqeq:false */ 1549 | QUnit.push( expected != actual, actual, expected, message ); 1550 | }, 1551 | 1552 | /** 1553 | * @name propEqual 1554 | * @function 1555 | */ 1556 | propEqual: function( actual, expected, message ) { 1557 | actual = objectValues(actual); 1558 | expected = objectValues(expected); 1559 | QUnit.push( QUnit.equiv(actual, expected), actual, expected, message ); 1560 | }, 1561 | 1562 | /** 1563 | * @name notPropEqual 1564 | * @function 1565 | */ 1566 | notPropEqual: function( actual, expected, message ) { 1567 | actual = objectValues(actual); 1568 | expected = objectValues(expected); 1569 | QUnit.push( !QUnit.equiv(actual, expected), actual, expected, message ); 1570 | }, 1571 | 1572 | /** 1573 | * @name deepEqual 1574 | * @function 1575 | */ 1576 | deepEqual: function( actual, expected, message ) { 1577 | QUnit.push( QUnit.equiv(actual, expected), actual, expected, message ); 1578 | }, 1579 | 1580 | /** 1581 | * @name notDeepEqual 1582 | * @function 1583 | */ 1584 | notDeepEqual: function( actual, expected, message ) { 1585 | QUnit.push( !QUnit.equiv(actual, expected), actual, expected, message ); 1586 | }, 1587 | 1588 | /** 1589 | * @name strictEqual 1590 | * @function 1591 | */ 1592 | strictEqual: function( actual, expected, message ) { 1593 | QUnit.push( expected === actual, actual, expected, message ); 1594 | }, 1595 | 1596 | /** 1597 | * @name notStrictEqual 1598 | * @function 1599 | */ 1600 | notStrictEqual: function( actual, expected, message ) { 1601 | QUnit.push( expected !== actual, actual, expected, message ); 1602 | }, 1603 | 1604 | "throws": function( block, expected, message ) { 1605 | var actual, 1606 | expectedOutput = expected, 1607 | ok = false; 1608 | 1609 | // 'expected' is optional 1610 | if ( !message && typeof expected === "string" ) { 1611 | message = expected; 1612 | expected = null; 1613 | } 1614 | 1615 | config.current.ignoreGlobalErrors = true; 1616 | try { 1617 | block.call( config.current.testEnvironment ); 1618 | } catch (e) { 1619 | actual = e; 1620 | } 1621 | config.current.ignoreGlobalErrors = false; 1622 | 1623 | if ( actual ) { 1624 | 1625 | // we don't want to validate thrown error 1626 | if ( !expected ) { 1627 | ok = true; 1628 | expectedOutput = null; 1629 | 1630 | // expected is an Error object 1631 | } else if ( expected instanceof Error ) { 1632 | ok = actual instanceof Error && 1633 | actual.name === expected.name && 1634 | actual.message === expected.message; 1635 | 1636 | // expected is a regexp 1637 | } else if ( QUnit.objectType( expected ) === "regexp" ) { 1638 | ok = expected.test( errorString( actual ) ); 1639 | 1640 | // expected is a string 1641 | } else if ( QUnit.objectType( expected ) === "string" ) { 1642 | ok = expected === errorString( actual ); 1643 | 1644 | // expected is a constructor 1645 | } else if ( actual instanceof expected ) { 1646 | ok = true; 1647 | 1648 | // expected is a validation function which returns true is validation passed 1649 | } else if ( expected.call( {}, actual ) === true ) { 1650 | expectedOutput = null; 1651 | ok = true; 1652 | } 1653 | 1654 | QUnit.push( ok, actual, expectedOutput, message ); 1655 | } else { 1656 | QUnit.pushFailure( message, null, "No exception was thrown." ); 1657 | } 1658 | } 1659 | }; 1660 | 1661 | /** 1662 | * @deprecated since 1.8.0 1663 | * Kept assertion helpers in root for backwards compatibility. 1664 | */ 1665 | extend( QUnit.constructor.prototype, assert ); 1666 | 1667 | /** 1668 | * @deprecated since 1.9.0 1669 | * Kept to avoid TypeErrors for undefined methods. 1670 | */ 1671 | QUnit.constructor.prototype.raises = function() { 1672 | QUnit.push( false, false, false, "QUnit.raises has been deprecated since 2012 (fad3c1ea), use QUnit.throws instead" ); 1673 | }; 1674 | 1675 | /** 1676 | * @deprecated since 1.0.0, replaced with error pushes since 1.3.0 1677 | * Kept to avoid TypeErrors for undefined methods. 1678 | */ 1679 | QUnit.constructor.prototype.equals = function() { 1680 | QUnit.push( false, false, false, "QUnit.equals has been deprecated since 2009 (e88049a0), use QUnit.equal instead" ); 1681 | }; 1682 | QUnit.constructor.prototype.same = function() { 1683 | QUnit.push( false, false, false, "QUnit.same has been deprecated since 2009 (e88049a0), use QUnit.deepEqual instead" ); 1684 | }; 1685 | 1686 | // Test for equality any JavaScript type. 1687 | // Author: Philippe Rathé 1688 | QUnit.equiv = (function() { 1689 | 1690 | // Call the o related callback with the given arguments. 1691 | function bindCallbacks( o, callbacks, args ) { 1692 | var prop = QUnit.objectType( o ); 1693 | if ( prop ) { 1694 | if ( QUnit.objectType( callbacks[ prop ] ) === "function" ) { 1695 | return callbacks[ prop ].apply( callbacks, args ); 1696 | } else { 1697 | return callbacks[ prop ]; // or undefined 1698 | } 1699 | } 1700 | } 1701 | 1702 | // the real equiv function 1703 | var innerEquiv, 1704 | // stack to decide between skip/abort functions 1705 | callers = [], 1706 | // stack to avoiding loops from circular referencing 1707 | parents = [], 1708 | parentsB = [], 1709 | 1710 | getProto = Object.getPrototypeOf || function ( obj ) { 1711 | /*jshint camelcase:false */ 1712 | return obj.__proto__; 1713 | }, 1714 | callbacks = (function () { 1715 | 1716 | // for string, boolean, number and null 1717 | function useStrictEquality( b, a ) { 1718 | /*jshint eqeqeq:false */ 1719 | if ( b instanceof a.constructor || a instanceof b.constructor ) { 1720 | // to catch short annotation VS 'new' annotation of a 1721 | // declaration 1722 | // e.g. var i = 1; 1723 | // var j = new Number(1); 1724 | return a == b; 1725 | } else { 1726 | return a === b; 1727 | } 1728 | } 1729 | 1730 | return { 1731 | "string": useStrictEquality, 1732 | "boolean": useStrictEquality, 1733 | "number": useStrictEquality, 1734 | "null": useStrictEquality, 1735 | "undefined": useStrictEquality, 1736 | 1737 | "nan": function( b ) { 1738 | return isNaN( b ); 1739 | }, 1740 | 1741 | "date": function( b, a ) { 1742 | return QUnit.objectType( b ) === "date" && a.valueOf() === b.valueOf(); 1743 | }, 1744 | 1745 | "regexp": function( b, a ) { 1746 | return QUnit.objectType( b ) === "regexp" && 1747 | // the regex itself 1748 | a.source === b.source && 1749 | // and its modifiers 1750 | a.global === b.global && 1751 | // (gmi) ... 1752 | a.ignoreCase === b.ignoreCase && 1753 | a.multiline === b.multiline && 1754 | a.sticky === b.sticky; 1755 | }, 1756 | 1757 | // - skip when the property is a method of an instance (OOP) 1758 | // - abort otherwise, 1759 | // initial === would have catch identical references anyway 1760 | "function": function() { 1761 | var caller = callers[callers.length - 1]; 1762 | return caller !== Object && typeof caller !== "undefined"; 1763 | }, 1764 | 1765 | "array": function( b, a ) { 1766 | var i, j, len, loop, aCircular, bCircular; 1767 | 1768 | // b could be an object literal here 1769 | if ( QUnit.objectType( b ) !== "array" ) { 1770 | return false; 1771 | } 1772 | 1773 | len = a.length; 1774 | if ( len !== b.length ) { 1775 | // safe and faster 1776 | return false; 1777 | } 1778 | 1779 | // track reference to avoid circular references 1780 | parents.push( a ); 1781 | parentsB.push( b ); 1782 | for ( i = 0; i < len; i++ ) { 1783 | loop = false; 1784 | for ( j = 0; j < parents.length; j++ ) { 1785 | aCircular = parents[j] === a[i]; 1786 | bCircular = parentsB[j] === b[i]; 1787 | if ( aCircular || bCircular ) { 1788 | if ( a[i] === b[i] || aCircular && bCircular ) { 1789 | loop = true; 1790 | } else { 1791 | parents.pop(); 1792 | parentsB.pop(); 1793 | return false; 1794 | } 1795 | } 1796 | } 1797 | if ( !loop && !innerEquiv(a[i], b[i]) ) { 1798 | parents.pop(); 1799 | parentsB.pop(); 1800 | return false; 1801 | } 1802 | } 1803 | parents.pop(); 1804 | parentsB.pop(); 1805 | return true; 1806 | }, 1807 | 1808 | "object": function( b, a ) { 1809 | /*jshint forin:false */ 1810 | var i, j, loop, aCircular, bCircular, 1811 | // Default to true 1812 | eq = true, 1813 | aProperties = [], 1814 | bProperties = []; 1815 | 1816 | // comparing constructors is more strict than using 1817 | // instanceof 1818 | if ( a.constructor !== b.constructor ) { 1819 | // Allow objects with no prototype to be equivalent to 1820 | // objects with Object as their constructor. 1821 | if ( !(( getProto(a) === null && getProto(b) === Object.prototype ) || 1822 | ( getProto(b) === null && getProto(a) === Object.prototype ) ) ) { 1823 | return false; 1824 | } 1825 | } 1826 | 1827 | // stack constructor before traversing properties 1828 | callers.push( a.constructor ); 1829 | 1830 | // track reference to avoid circular references 1831 | parents.push( a ); 1832 | parentsB.push( b ); 1833 | 1834 | // be strict: don't ensure hasOwnProperty and go deep 1835 | for ( i in a ) { 1836 | loop = false; 1837 | for ( j = 0; j < parents.length; j++ ) { 1838 | aCircular = parents[j] === a[i]; 1839 | bCircular = parentsB[j] === b[i]; 1840 | if ( aCircular || bCircular ) { 1841 | if ( a[i] === b[i] || aCircular && bCircular ) { 1842 | loop = true; 1843 | } else { 1844 | eq = false; 1845 | break; 1846 | } 1847 | } 1848 | } 1849 | aProperties.push(i); 1850 | if ( !loop && !innerEquiv(a[i], b[i]) ) { 1851 | eq = false; 1852 | break; 1853 | } 1854 | } 1855 | 1856 | parents.pop(); 1857 | parentsB.pop(); 1858 | callers.pop(); // unstack, we are done 1859 | 1860 | for ( i in b ) { 1861 | bProperties.push( i ); // collect b's properties 1862 | } 1863 | 1864 | // Ensures identical properties name 1865 | return eq && innerEquiv( aProperties.sort(), bProperties.sort() ); 1866 | } 1867 | }; 1868 | }()); 1869 | 1870 | innerEquiv = function() { // can take multiple arguments 1871 | var args = [].slice.apply( arguments ); 1872 | if ( args.length < 2 ) { 1873 | return true; // end transition 1874 | } 1875 | 1876 | return (function( a, b ) { 1877 | if ( a === b ) { 1878 | return true; // catch the most you can 1879 | } else if ( a === null || b === null || typeof a === "undefined" || 1880 | typeof b === "undefined" || 1881 | QUnit.objectType(a) !== QUnit.objectType(b) ) { 1882 | return false; // don't lose time with error prone cases 1883 | } else { 1884 | return bindCallbacks(a, callbacks, [ b, a ]); 1885 | } 1886 | 1887 | // apply transition with (1..n) arguments 1888 | }( args[0], args[1] ) && innerEquiv.apply( this, args.splice(1, args.length - 1 )) ); 1889 | }; 1890 | 1891 | return innerEquiv; 1892 | }()); 1893 | 1894 | /** 1895 | * jsDump Copyright (c) 2008 Ariel Flesler - aflesler(at)gmail(dot)com | 1896 | * http://flesler.blogspot.com Licensed under BSD 1897 | * (http://www.opensource.org/licenses/bsd-license.php) Date: 5/15/2008 1898 | * 1899 | * @projectDescription Advanced and extensible data dumping for Javascript. 1900 | * @version 1.0.0 1901 | * @author Ariel Flesler 1902 | * @link {http://flesler.blogspot.com/2008/05/jsdump-pretty-dump-of-any-javascript.html} 1903 | */ 1904 | QUnit.jsDump = (function() { 1905 | function quote( str ) { 1906 | return "\"" + str.toString().replace( /"/g, "\\\"" ) + "\""; 1907 | } 1908 | function literal( o ) { 1909 | return o + ""; 1910 | } 1911 | function join( pre, arr, post ) { 1912 | var s = jsDump.separator(), 1913 | base = jsDump.indent(), 1914 | inner = jsDump.indent(1); 1915 | if ( arr.join ) { 1916 | arr = arr.join( "," + s + inner ); 1917 | } 1918 | if ( !arr ) { 1919 | return pre + post; 1920 | } 1921 | return [ pre, inner + arr, base + post ].join(s); 1922 | } 1923 | function array( arr, stack ) { 1924 | var i = arr.length, ret = new Array(i); 1925 | this.up(); 1926 | while ( i-- ) { 1927 | ret[i] = this.parse( arr[i] , undefined , stack); 1928 | } 1929 | this.down(); 1930 | return join( "[", ret, "]" ); 1931 | } 1932 | 1933 | var reName = /^function (\w+)/, 1934 | jsDump = { 1935 | // type is used mostly internally, you can fix a (custom)type in advance 1936 | parse: function( obj, type, stack ) { 1937 | stack = stack || [ ]; 1938 | var inStack, res, 1939 | parser = this.parsers[ type || this.typeOf(obj) ]; 1940 | 1941 | type = typeof parser; 1942 | inStack = inArray( obj, stack ); 1943 | 1944 | if ( inStack !== -1 ) { 1945 | return "recursion(" + (inStack - stack.length) + ")"; 1946 | } 1947 | if ( type === "function" ) { 1948 | stack.push( obj ); 1949 | res = parser.call( this, obj, stack ); 1950 | stack.pop(); 1951 | return res; 1952 | } 1953 | return ( type === "string" ) ? parser : this.parsers.error; 1954 | }, 1955 | typeOf: function( obj ) { 1956 | var type; 1957 | if ( obj === null ) { 1958 | type = "null"; 1959 | } else if ( typeof obj === "undefined" ) { 1960 | type = "undefined"; 1961 | } else if ( QUnit.is( "regexp", obj) ) { 1962 | type = "regexp"; 1963 | } else if ( QUnit.is( "date", obj) ) { 1964 | type = "date"; 1965 | } else if ( QUnit.is( "function", obj) ) { 1966 | type = "function"; 1967 | } else if ( typeof obj.setInterval !== undefined && typeof obj.document !== "undefined" && typeof obj.nodeType === "undefined" ) { 1968 | type = "window"; 1969 | } else if ( obj.nodeType === 9 ) { 1970 | type = "document"; 1971 | } else if ( obj.nodeType ) { 1972 | type = "node"; 1973 | } else if ( 1974 | // native arrays 1975 | toString.call( obj ) === "[object Array]" || 1976 | // NodeList objects 1977 | ( typeof obj.length === "number" && typeof obj.item !== "undefined" && ( obj.length ? obj.item(0) === obj[0] : ( obj.item( 0 ) === null && typeof obj[0] === "undefined" ) ) ) 1978 | ) { 1979 | type = "array"; 1980 | } else if ( obj.constructor === Error.prototype.constructor ) { 1981 | type = "error"; 1982 | } else { 1983 | type = typeof obj; 1984 | } 1985 | return type; 1986 | }, 1987 | separator: function() { 1988 | return this.multiline ? this.HTML ? "
    " : "\n" : this.HTML ? " " : " "; 1989 | }, 1990 | // extra can be a number, shortcut for increasing-calling-decreasing 1991 | indent: function( extra ) { 1992 | if ( !this.multiline ) { 1993 | return ""; 1994 | } 1995 | var chr = this.indentChar; 1996 | if ( this.HTML ) { 1997 | chr = chr.replace( /\t/g, " " ).replace( / /g, " " ); 1998 | } 1999 | return new Array( this.depth + ( extra || 0 ) ).join(chr); 2000 | }, 2001 | up: function( a ) { 2002 | this.depth += a || 1; 2003 | }, 2004 | down: function( a ) { 2005 | this.depth -= a || 1; 2006 | }, 2007 | setParser: function( name, parser ) { 2008 | this.parsers[name] = parser; 2009 | }, 2010 | // The next 3 are exposed so you can use them 2011 | quote: quote, 2012 | literal: literal, 2013 | join: join, 2014 | // 2015 | depth: 1, 2016 | // This is the list of parsers, to modify them, use jsDump.setParser 2017 | parsers: { 2018 | window: "[Window]", 2019 | document: "[Document]", 2020 | error: function(error) { 2021 | return "Error(\"" + error.message + "\")"; 2022 | }, 2023 | unknown: "[Unknown]", 2024 | "null": "null", 2025 | "undefined": "undefined", 2026 | "function": function( fn ) { 2027 | var ret = "function", 2028 | // functions never have name in IE 2029 | name = "name" in fn ? fn.name : (reName.exec(fn) || [])[1]; 2030 | 2031 | if ( name ) { 2032 | ret += " " + name; 2033 | } 2034 | ret += "( "; 2035 | 2036 | ret = [ ret, QUnit.jsDump.parse( fn, "functionArgs" ), "){" ].join( "" ); 2037 | return join( ret, QUnit.jsDump.parse(fn,"functionCode" ), "}" ); 2038 | }, 2039 | array: array, 2040 | nodelist: array, 2041 | "arguments": array, 2042 | object: function( map, stack ) { 2043 | /*jshint forin:false */ 2044 | var ret = [ ], keys, key, val, i; 2045 | QUnit.jsDump.up(); 2046 | keys = []; 2047 | for ( key in map ) { 2048 | keys.push( key ); 2049 | } 2050 | keys.sort(); 2051 | for ( i = 0; i < keys.length; i++ ) { 2052 | key = keys[ i ]; 2053 | val = map[ key ]; 2054 | ret.push( QUnit.jsDump.parse( key, "key" ) + ": " + QUnit.jsDump.parse( val, undefined, stack ) ); 2055 | } 2056 | QUnit.jsDump.down(); 2057 | return join( "{", ret, "}" ); 2058 | }, 2059 | node: function( node ) { 2060 | var len, i, val, 2061 | open = QUnit.jsDump.HTML ? "<" : "<", 2062 | close = QUnit.jsDump.HTML ? ">" : ">", 2063 | tag = node.nodeName.toLowerCase(), 2064 | ret = open + tag, 2065 | attrs = node.attributes; 2066 | 2067 | if ( attrs ) { 2068 | for ( i = 0, len = attrs.length; i < len; i++ ) { 2069 | val = attrs[i].nodeValue; 2070 | // IE6 includes all attributes in .attributes, even ones not explicitly set. 2071 | // Those have values like undefined, null, 0, false, "" or "inherit". 2072 | if ( val && val !== "inherit" ) { 2073 | ret += " " + attrs[i].nodeName + "=" + QUnit.jsDump.parse( val, "attribute" ); 2074 | } 2075 | } 2076 | } 2077 | ret += close; 2078 | 2079 | // Show content of TextNode or CDATASection 2080 | if ( node.nodeType === 3 || node.nodeType === 4 ) { 2081 | ret += node.nodeValue; 2082 | } 2083 | 2084 | return ret + open + "/" + tag + close; 2085 | }, 2086 | // function calls it internally, it's the arguments part of the function 2087 | functionArgs: function( fn ) { 2088 | var args, 2089 | l = fn.length; 2090 | 2091 | if ( !l ) { 2092 | return ""; 2093 | } 2094 | 2095 | args = new Array(l); 2096 | while ( l-- ) { 2097 | // 97 is 'a' 2098 | args[l] = String.fromCharCode(97+l); 2099 | } 2100 | return " " + args.join( ", " ) + " "; 2101 | }, 2102 | // object calls it internally, the key part of an item in a map 2103 | key: quote, 2104 | // function calls it internally, it's the content of the function 2105 | functionCode: "[code]", 2106 | // node calls it internally, it's an html attribute value 2107 | attribute: quote, 2108 | string: quote, 2109 | date: quote, 2110 | regexp: literal, 2111 | number: literal, 2112 | "boolean": literal 2113 | }, 2114 | // if true, entities are escaped ( <, >, \t, space and \n ) 2115 | HTML: false, 2116 | // indentation unit 2117 | indentChar: " ", 2118 | // if true, items in a collection, are separated by a \n, else just a space. 2119 | multiline: true 2120 | }; 2121 | 2122 | return jsDump; 2123 | }()); 2124 | 2125 | /* 2126 | * Javascript Diff Algorithm 2127 | * By John Resig (http://ejohn.org/) 2128 | * Modified by Chu Alan "sprite" 2129 | * 2130 | * Released under the MIT license. 2131 | * 2132 | * More Info: 2133 | * http://ejohn.org/projects/javascript-diff-algorithm/ 2134 | * 2135 | * Usage: QUnit.diff(expected, actual) 2136 | * 2137 | * QUnit.diff( "the quick brown fox jumped over", "the quick fox jumps over" ) == "the quick brown fox jumped jumps over" 2138 | */ 2139 | QUnit.diff = (function() { 2140 | /*jshint eqeqeq:false, eqnull:true */ 2141 | function diff( o, n ) { 2142 | var i, 2143 | ns = {}, 2144 | os = {}; 2145 | 2146 | for ( i = 0; i < n.length; i++ ) { 2147 | if ( !hasOwn.call( ns, n[i] ) ) { 2148 | ns[ n[i] ] = { 2149 | rows: [], 2150 | o: null 2151 | }; 2152 | } 2153 | ns[ n[i] ].rows.push( i ); 2154 | } 2155 | 2156 | for ( i = 0; i < o.length; i++ ) { 2157 | if ( !hasOwn.call( os, o[i] ) ) { 2158 | os[ o[i] ] = { 2159 | rows: [], 2160 | n: null 2161 | }; 2162 | } 2163 | os[ o[i] ].rows.push( i ); 2164 | } 2165 | 2166 | for ( i in ns ) { 2167 | if ( hasOwn.call( ns, i ) ) { 2168 | if ( ns[i].rows.length === 1 && hasOwn.call( os, i ) && os[i].rows.length === 1 ) { 2169 | n[ ns[i].rows[0] ] = { 2170 | text: n[ ns[i].rows[0] ], 2171 | row: os[i].rows[0] 2172 | }; 2173 | o[ os[i].rows[0] ] = { 2174 | text: o[ os[i].rows[0] ], 2175 | row: ns[i].rows[0] 2176 | }; 2177 | } 2178 | } 2179 | } 2180 | 2181 | for ( i = 0; i < n.length - 1; i++ ) { 2182 | if ( n[i].text != null && n[ i + 1 ].text == null && n[i].row + 1 < o.length && o[ n[i].row + 1 ].text == null && 2183 | n[ i + 1 ] == o[ n[i].row + 1 ] ) { 2184 | 2185 | n[ i + 1 ] = { 2186 | text: n[ i + 1 ], 2187 | row: n[i].row + 1 2188 | }; 2189 | o[ n[i].row + 1 ] = { 2190 | text: o[ n[i].row + 1 ], 2191 | row: i + 1 2192 | }; 2193 | } 2194 | } 2195 | 2196 | for ( i = n.length - 1; i > 0; i-- ) { 2197 | if ( n[i].text != null && n[ i - 1 ].text == null && n[i].row > 0 && o[ n[i].row - 1 ].text == null && 2198 | n[ i - 1 ] == o[ n[i].row - 1 ]) { 2199 | 2200 | n[ i - 1 ] = { 2201 | text: n[ i - 1 ], 2202 | row: n[i].row - 1 2203 | }; 2204 | o[ n[i].row - 1 ] = { 2205 | text: o[ n[i].row - 1 ], 2206 | row: i - 1 2207 | }; 2208 | } 2209 | } 2210 | 2211 | return { 2212 | o: o, 2213 | n: n 2214 | }; 2215 | } 2216 | 2217 | return function( o, n ) { 2218 | o = o.replace( /\s+$/, "" ); 2219 | n = n.replace( /\s+$/, "" ); 2220 | 2221 | var i, pre, 2222 | str = "", 2223 | out = diff( o === "" ? [] : o.split(/\s+/), n === "" ? [] : n.split(/\s+/) ), 2224 | oSpace = o.match(/\s+/g), 2225 | nSpace = n.match(/\s+/g); 2226 | 2227 | if ( oSpace == null ) { 2228 | oSpace = [ " " ]; 2229 | } 2230 | else { 2231 | oSpace.push( " " ); 2232 | } 2233 | 2234 | if ( nSpace == null ) { 2235 | nSpace = [ " " ]; 2236 | } 2237 | else { 2238 | nSpace.push( " " ); 2239 | } 2240 | 2241 | if ( out.n.length === 0 ) { 2242 | for ( i = 0; i < out.o.length; i++ ) { 2243 | str += "" + out.o[i] + oSpace[i] + ""; 2244 | } 2245 | } 2246 | else { 2247 | if ( out.n[0].text == null ) { 2248 | for ( n = 0; n < out.o.length && out.o[n].text == null; n++ ) { 2249 | str += "" + out.o[n] + oSpace[n] + ""; 2250 | } 2251 | } 2252 | 2253 | for ( i = 0; i < out.n.length; i++ ) { 2254 | if (out.n[i].text == null) { 2255 | str += "" + out.n[i] + nSpace[i] + ""; 2256 | } 2257 | else { 2258 | // `pre` initialized at top of scope 2259 | pre = ""; 2260 | 2261 | for ( n = out.n[i].row + 1; n < out.o.length && out.o[n].text == null; n++ ) { 2262 | pre += "" + out.o[n] + oSpace[n] + ""; 2263 | } 2264 | str += " " + out.n[i].text + nSpace[i] + pre; 2265 | } 2266 | } 2267 | } 2268 | 2269 | return str; 2270 | }; 2271 | }()); 2272 | 2273 | // For browser, export only select globals 2274 | if ( typeof window !== "undefined" ) { 2275 | extend( window, QUnit.constructor.prototype ); 2276 | window.QUnit = QUnit; 2277 | } 2278 | 2279 | // For CommonJS environments, export everything 2280 | if ( typeof module !== "undefined" && module.exports ) { 2281 | module.exports = QUnit; 2282 | } 2283 | 2284 | 2285 | // Get a reference to the global object, like window in browsers 2286 | }( (function() { 2287 | return this; 2288 | })() )); 2289 | -------------------------------------------------------------------------------- /libs/qunit/sinon-qunit.js: -------------------------------------------------------------------------------- 1 | /** 2 | * sinon-qunit 1.0.0, 2010/12/09 3 | * 4 | * @author Christian Johansen (christian@cjohansen.no) 5 | * 6 | * (The BSD License) 7 | * 8 | * Copyright (c) 2010-2011, Christian Johansen, christian@cjohansen.no 9 | * All rights reserved. 10 | * 11 | * Redistribution and use in source and binary forms, with or without modification, 12 | * are permitted provided that the following conditions are met: 13 | * 14 | * * Redistributions of source code must retain the above copyright notice, 15 | * this list of conditions and the following disclaimer. 16 | * * Redistributions in binary form must reproduce the above copyright notice, 17 | * this list of conditions and the following disclaimer in the documentation 18 | * and/or other materials provided with the distribution. 19 | * * Neither the name of Christian Johansen nor the names of his contributors 20 | * may be used to endorse or promote products derived from this software 21 | * without specific prior written permission. 22 | * 23 | * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 24 | * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 25 | * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 26 | * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 27 | * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 28 | * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 29 | * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 30 | * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 31 | * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF 32 | * THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 33 | */ 34 | /*global sinon, QUnit, test*/ 35 | sinon.assert.fail = function (msg) { 36 | QUnit.ok(false, msg); 37 | }; 38 | 39 | sinon.assert.pass = function (assertion) { 40 | QUnit.ok(true, assertion); 41 | }; 42 | 43 | sinon.config = { 44 | injectIntoThis: true, 45 | injectInto: null, 46 | properties: ["spy", "stub", "mock", "clock", "sandbox"], 47 | useFakeTimers: true, 48 | useFakeServer: false 49 | }; 50 | 51 | (function (global) { 52 | var qTest = QUnit.test; 53 | 54 | QUnit.test = global.test = function (testName, expected, callback, async) { 55 | if (arguments.length === 2) { 56 | callback = expected; 57 | expected = null; 58 | } 59 | 60 | return qTest(testName, expected, sinon.test(callback), async); 61 | }; 62 | }(this)); 63 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "jquery-plugin", 3 | "version": "0.0.0-ignored", 4 | "engines": { 5 | "node": ">= 0.8.0" 6 | }, 7 | "scripts": { 8 | "test": "grunt qunit" 9 | }, 10 | "devDependencies": { 11 | "grunt-contrib-jshint": "~0.10.0", 12 | "grunt-contrib-concat": "~0.4.0", 13 | "grunt-contrib-uglify": "~0.4.0", 14 | "grunt-contrib-watch": "~0.6.1", 15 | "grunt-contrib-clean": "~0.5.0", 16 | "grunt": "~0.4.4", 17 | "grunt-qunit-istanbul": "~0.4.0" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "browser": true, 3 | "jquery": true, 4 | 5 | "bitwise": true, 6 | "curly": true, 7 | "eqeqeq": true, 8 | "forin": true, 9 | "immed": true, 10 | "indent": 2, 11 | "latedef": true, 12 | "newcap": true, 13 | "noarg": true, 14 | "noempty": true, 15 | "nonew": true, 16 | "plusplus": true, 17 | "quotmark": "single", 18 | "undef": true, 19 | "unused": true, 20 | "strict": true, 21 | "trailing": true, 22 | "maxparams": 4, 23 | "maxlen": 80 24 | } 25 | -------------------------------------------------------------------------------- /src/django-superformset.js: -------------------------------------------------------------------------------- 1 | /* 2 | * django-superformset 3 | * https://github.com/jgerigmeyer/jquery-django-superformset 4 | * 5 | * Based on jQuery Formset 1.1r14 6 | * by Stanislaus Madueke 7 | * 8 | * Original Portions Copyright (c) 2009 Stanislaus Madueke 9 | * Modifications Copyright (c) 2014 Jonny Gerig Meyer 10 | * Licensed under the BSDv3 license. 11 | */ 12 | 13 | (function ($) { 14 | 15 | 'use strict'; 16 | 17 | var methods = { 18 | init: function (options) { 19 | var vars = {}; 20 | var opts = vars.opts = $.extend({}, $.fn.superformset.defaults, options); 21 | var wrapper = vars.wrapper = $(this); 22 | var rows = vars.rows = wrapper.find(opts.rowSel); 23 | var container = vars.container = rows.closest(opts.containerSel); 24 | vars.totalForms = wrapper 25 | .find('input[id$="' + opts.prefix + '-TOTAL_FORMS"]'); 26 | vars.maxForms = wrapper 27 | .find('input[id$="' + opts.prefix + '-MAX_NUM_FORMS"]'); 28 | 29 | // Clone the form template to generate new form instances 30 | var tpl = vars.tpl = container.find(opts.formTemplate).clone(true); 31 | container.find(opts.formTemplate).find('[required], .required') 32 | .removeAttr('required').removeData('required-by').addClass('required'); 33 | tpl.removeAttr('id').find('input, select, textarea').filter('[required]') 34 | .addClass('required').removeAttr('required'); 35 | 36 | // Add delete-trigger and insert-above-trigger (if applicable) to template 37 | methods.addDeleteTrigger(tpl, opts.canDelete, vars); 38 | methods.addInsertAboveTrigger(tpl, vars); 39 | 40 | // Iterate over existing rows... 41 | rows.each(function () { 42 | var thisRow = $(this); 43 | // Add delete-trigger and insert-above-trigger to existing rows 44 | methods.addDeleteTrigger( 45 | thisRow, 46 | (opts.canDelete && !opts.deleteOnlyNew), 47 | vars 48 | ); 49 | methods.addInsertAboveTrigger(thisRow, vars); 50 | // Attaches handlers watching for changes to inputs, 51 | // ...to add/remove ``required`` attr 52 | methods.watchForChangesToOptionalIfEmptyRow(thisRow, vars); 53 | }); 54 | 55 | // Unless using auto-added rows, add and/or activate trigger to add rows 56 | if (!opts.autoAdd) { 57 | methods.activateAddTrigger(vars); 58 | } 59 | 60 | // Add extra empty row, if applicable 61 | if (opts.alwaysShowExtra && opts.autoAdd) { 62 | methods.autoAddRow(vars); 63 | wrapper.closest('form').submit(function () { 64 | $(this).find(opts.rowSel).filter('.extra-row') 65 | .find('input, select, textarea').each(function () { 66 | $(this).removeAttr('name'); 67 | } 68 | ); 69 | }); 70 | } 71 | 72 | return wrapper; 73 | }, 74 | 75 | activateAddTrigger: function (vars) { 76 | var opts = vars.opts; 77 | var addButton; 78 | if (vars.wrapper.find(opts.addTriggerSel).length) { 79 | addButton = vars.addButton = vars.wrapper.find(opts.addTriggerSel); 80 | } else { 81 | addButton = vars.addButton = $(opts.addTrigger).appendTo(vars.wrapper); 82 | } 83 | // Hide the add-trigger if we've reach the maxForms limit 84 | if (!methods.showAddButton(vars)) { addButton.hide(); } 85 | addButton.click(function (e) { 86 | var trigger = $(this); 87 | var formCount = parseInt(vars.totalForms.val(), 10); 88 | var newRow = vars.tpl.clone(true).addClass('new-row'); 89 | var lastRow = vars.wrapper.find(opts.rowSel).last(); 90 | newRow.find('input, select, textarea').filter('.required') 91 | .attr('required', 'required'); 92 | if (opts.addAnimationSpeed) { 93 | if (lastRow.length) { 94 | newRow.hide().insertAfter(lastRow); 95 | } else { 96 | newRow.hide().insertBefore(trigger); 97 | } 98 | newRow.animate( 99 | {'height': 'toggle', 'opacity': 'toggle'}, 100 | opts.addAnimationSpeed 101 | ); 102 | } else { 103 | if (lastRow.length) { 104 | newRow.insertAfter(lastRow).show(); 105 | } else { 106 | newRow.insertBefore(trigger).show(); 107 | } 108 | } 109 | newRow.find('input, select, textarea, label').each(function () { 110 | methods.updateElementIndex($(this), opts.prefix, formCount); 111 | }); 112 | // Attaches handlers watching for changes to inputs, 113 | // ...to add/remove ``required`` attr 114 | methods.watchForChangesToOptionalIfEmptyRow(newRow, vars); 115 | vars.totalForms.val(formCount + 1); 116 | // Check if we've exceeded the maximum allowed number of forms: 117 | if (!methods.showAddButton(vars)) { trigger.hide(); } 118 | // If a post-add callback was supplied, call it with the added form: 119 | if (opts.addedCallback) { opts.addedCallback(newRow); } 120 | e.preventDefault(); 121 | }); 122 | }, 123 | 124 | // Attaches handlers watching for changes to inputs, 125 | // ...to add/remove ``required`` attr 126 | watchForChangesToOptionalIfEmptyRow: function (row, vars) { 127 | var opts = vars.opts; 128 | if (opts.optionalIfEmpty && row.is(opts.optionalIfEmptySel)) { 129 | var inputs = row.find('input, select, textarea'); 130 | inputs.filter('[required], .required').removeAttr('required') 131 | .data('required-by', opts.prefix).addClass('required'); 132 | row.data('original-vals', inputs.serialize()); 133 | inputs.not(opts.deleteTriggerSel).change(function () { 134 | methods.updateRequiredFields(row, vars); 135 | }); 136 | } 137 | }, 138 | 139 | // Replace ``-__prefix__`` with correct index in for, id, name attrs 140 | updateElementIndex: function (elem, prefix, ndx) { 141 | var idRegex = new RegExp('(' + prefix + '-(\\d+|__prefix__))'); 142 | var replacement = prefix + '-' + ndx; 143 | if (elem.attr('for')) { 144 | elem.attr('for', elem.attr('for').replace(idRegex, replacement)); 145 | } 146 | if (elem.attr('id')) { 147 | elem.attr('id', elem.attr('id').replace(idRegex, replacement)); 148 | } 149 | if (elem.attr('name')) { 150 | elem.attr('name', elem.attr('name').replace(idRegex, replacement)); 151 | } 152 | }, 153 | 154 | // Check whether we can add more rows 155 | showAddButton: function (vars) { 156 | return ( 157 | vars.maxForms.val() === '' || 158 | (vars.maxForms.val() - vars.totalForms.val() > 0) 159 | ); 160 | }, 161 | 162 | // Add delete trigger to end of a row, or activate existing delete-trigger 163 | addDeleteTrigger: function (row, canDelete, vars) { 164 | var opts = vars.opts; 165 | if (canDelete) { 166 | // Add a delete-trigger to remove the row from the DOM 167 | $(opts.deleteTrigger).appendTo(row).click(function (e) { 168 | var thisRow = $(this).closest(opts.rowSel); 169 | var rows, i; 170 | var updateSequence = function (rows, i) { 171 | rows.eq(i).find('input, select, textarea, label').each(function () { 172 | methods.updateElementIndex($(this), opts.prefix, i); 173 | }); 174 | }; 175 | var removeRow = function () { 176 | thisRow.remove(); 177 | // Update the TOTAL_FORMS count: 178 | rows = vars.wrapper.find(opts.rowSel); 179 | vars.totalForms.val(rows.not('.extra-row').length); 180 | // Update names and IDs for all child controls, 181 | // ...so they remain in sequence. 182 | for (i = 0; i < rows.length; i = i + 1) { 183 | updateSequence(rows, i); 184 | } 185 | // If a post-delete callback was provided, call it with deleted form 186 | if (opts.removedCallback) { opts.removedCallback(thisRow); } 187 | }; 188 | if (opts.removeAnimationSpeed) { 189 | $.when( 190 | thisRow.animate( 191 | {'height': 'toggle', 'opacity': 'toggle'}, 192 | opts.removeAnimationSpeed 193 | ) 194 | ).done(removeRow); 195 | } else { 196 | removeRow(); 197 | } 198 | e.preventDefault(); 199 | }); 200 | } else { 201 | // If we're dealing with an inline formset, 202 | // ...just remove :required attrs when marking a row deleted 203 | row.find(opts.deleteTriggerSel).change(function () { 204 | var trigger = $(this); 205 | var thisRow = trigger.closest(opts.rowSel); 206 | if (trigger.prop('checked')) { 207 | thisRow.addClass(opts.deletedRowClass); 208 | thisRow.find('[required]').removeAttr('required') 209 | .addClass('deleted-required'); 210 | // If a post-delete callback was provided, call it with deleted form 211 | if (opts.removedCallback) { opts.removedCallback(thisRow); } 212 | } else { 213 | thisRow.removeClass(opts.deletedRowClass); 214 | thisRow.find('.deleted-required').attr('required', 'required') 215 | .removeClass('deleted-required'); 216 | } 217 | }); 218 | } 219 | }, 220 | 221 | // Add insert-above trigger before a row, if ``insertAboveTrigger: true`` 222 | addInsertAboveTrigger: function (row, vars) { 223 | var opts = vars.opts; 224 | if (opts.insertAbove) { 225 | $(opts.insertAboveTrigger).prependTo(row).click(function (e) { 226 | var thisRow = $(this).closest(opts.rowSel); 227 | var formCount = parseInt(vars.totalForms.val(), 10); 228 | var newRow = vars.tpl.clone(true).addClass('new-row'); 229 | var rows, i; 230 | var updateSequence = function (rows, i) { 231 | rows.eq(i).find('input, select, textarea, label').each(function () { 232 | methods.updateElementIndex($(this), opts.prefix, i); 233 | }); 234 | }; 235 | newRow.find('input, select, textarea').filter('.required') 236 | .attr('required', 'required'); 237 | if (opts.addAnimationSpeed) { 238 | newRow.hide().insertBefore(thisRow).animate( 239 | {'height': 'toggle', 'opacity': 'toggle'}, opts.addAnimationSpeed 240 | ); 241 | } else { 242 | newRow.insertBefore(thisRow).show(); 243 | } 244 | // Update the TOTAL_FORMS count: 245 | rows = vars.wrapper.find(opts.rowSel); 246 | vars.totalForms.val(formCount + 1); 247 | // Update names and IDs for child controls so they remain in sequence. 248 | for (i = 0; i < rows.length; i = i + 1) { 249 | updateSequence(rows, i); 250 | } 251 | // Attaches handlers watching for changes to inputs, 252 | // ...to add/remove ``required`` attr 253 | methods.watchForChangesToOptionalIfEmptyRow(newRow, vars); 254 | // Check if we've exceeded the maximum allowed number of rows: 255 | if (!methods.showAddButton(vars)) { $(this).hide(); } 256 | // If a post-add callback was supplied, call it with the added form: 257 | if (opts.addedCallback) { opts.addedCallback(newRow); } 258 | $(this).blur(); 259 | e.preventDefault(); 260 | }); 261 | } 262 | }, 263 | 264 | // Add a row automatically 265 | autoAddRow: function (vars) { 266 | var opts = vars.opts; 267 | var formCount = parseInt(vars.totalForms.val(), 10); 268 | var newRow = vars.tpl.clone(true); 269 | var rows = vars.wrapper.find(opts.rowSel); 270 | if (opts.addAnimationSpeed) { 271 | newRow.hide().css('opacity', 0).insertAfter(rows.last()) 272 | .addClass('extra-row').animate( 273 | {'height': 'toggle', 'opacity': '0.5'}, 274 | opts.addAnimationSpeed 275 | ); 276 | } else { 277 | newRow.css('opacity', 0.5).insertAfter(rows.last()) 278 | .addClass('extra-row'); 279 | } 280 | // When the extra-row receives focus... 281 | newRow.find('input, select, textarea, label').one('focus', function () { 282 | var el = $(this); 283 | var thisRow = el.closest(opts.rowSel); 284 | // fade it in 285 | thisRow.removeClass('extra-row').css('opacity', 1); 286 | // add "required" to appropriate inputs if not an "optionalIfEmpty" row 287 | if ( 288 | el.hasClass('required') && 289 | !(opts.optionalIfEmpty && newRow.is(opts.optionalIfEmptySel)) 290 | ) { 291 | el.attr('required', 'required'); 292 | } 293 | // update the totalForms count 294 | vars.totalForms.val( 295 | vars.wrapper.find(opts.rowSel).not('.extra-row').length 296 | ); 297 | // fade in the delete-trigger 298 | if (opts.deleteOnlyActive) { 299 | thisRow.find(opts.deleteTriggerSel).fadeIn(); 300 | } 301 | // and auto-add another extra-row 302 | if ( 303 | methods.showAddButton(vars) && 304 | thisRow.is(vars.wrapper.find(opts.rowSel).last()) 305 | ) { 306 | methods.autoAddRow(vars); 307 | } 308 | }).each(function () { 309 | var el = $(this); 310 | methods.updateElementIndex(el, opts.prefix, formCount); 311 | el.filter('[required]').removeAttr('required').addClass('required'); 312 | }); 313 | // Attaches handlers watching for changes to inputs, 314 | // ...to add/remove ``required`` attr 315 | methods.watchForChangesToOptionalIfEmptyRow(newRow, vars); 316 | // Hide the delete-trigger initially, if ``deleteOnlyActive: true`` 317 | if (opts.deleteOnlyActive) { 318 | newRow.find(opts.deleteTriggerSel).hide(); 319 | } 320 | // If a post-add callback was supplied, call it with the added form 321 | if (opts.addedCallback) { opts.addedCallback(newRow); } 322 | }, 323 | 324 | // Check if inputs have changed from original state, 325 | // ...and update ``required`` attr accordingly 326 | updateRequiredFields: function (row, vars) { 327 | var opts = vars.opts; 328 | var inputs = row.find('input, select, textarea'); 329 | var relevantInputs = inputs.filter(function () { 330 | return $(this).data('required-by') === opts.prefix; 331 | }); 332 | var state = inputs.serialize(); 333 | var originalState = row.data('original-vals'); 334 | if (state === originalState) { 335 | relevantInputs.removeAttr('required'); 336 | } else { 337 | relevantInputs.filter('.required').not('.deleted-required') 338 | .attr('required', 'required'); 339 | } 340 | }, 341 | 342 | // Expose internal methods to allow stubbing in tests 343 | exposeMethods: function () { 344 | return methods; 345 | } 346 | }; 347 | 348 | $.fn.superformset = function (method) { 349 | if (methods[method]) { 350 | return methods[method].apply( 351 | this, 352 | Array.prototype.slice.call(arguments, 1) 353 | ); 354 | } else if (typeof method === 'object' || !method) { 355 | return methods.init.apply(this, arguments); 356 | } else { 357 | $.error('Method ' + method + ' does not exist on jQuery.superformset'); 358 | } 359 | }; 360 | 361 | /* Setup plugin defaults */ 362 | $.fn.superformset.defaults = { 363 | prefix: 'form', // The form prefix for your django formset 364 | containerSel: 'form', // Container selector 365 | // ...(must contain rows and formTemplate) 366 | rowSel: '.dynamic-form', // Selector to match each row in a formset 367 | formTemplate: '.empty-form .dynamic-form', 368 | // Selector for empty form template to be 369 | // ...cloned to generate new form instances 370 | // ...Must be outside element on which formset 371 | // ...is called, but within containerSel 372 | deleteTrigger: 'remove', 373 | // The HTML "remove" link added to the end of 374 | // ...each form-row (if ``canDelete: true``) 375 | deleteTriggerSel: '.remove-row', 376 | // Selector for HTML "remove" links 377 | // ...Used to target existing delete-trigger, 378 | // ...or to target ``deleteTrigger`` 379 | addTrigger: 'add', 380 | // The HTML "add" link added to the end of all 381 | // ...forms if no ``addTriggerSel`` 382 | addTriggerSel: null, // Selector for trigger to add a new row 383 | // ...Used to target existing trigger 384 | // ...if provided, ``addTrigger`` is ignored 385 | addedCallback: null, // Fn called each time a new form row is added 386 | removedCallback: null, // Fn called each time a form row is deleted 387 | deletedRowClass: 'deleted', // Add to deleted row if ``canDelete: false`` 388 | addAnimationSpeed: 'normal', // Speed (ms) to animate adding rows 389 | // ...If false, new rows appear w/o animation 390 | removeAnimationSpeed: 'fast', // Speed (ms) to animate removing rows 391 | // ...If false, new rows disappear w/o anim. 392 | autoAdd: false, // If true, the "add" link will be removed, 393 | // ...and a row will be automatically added 394 | // ...when text is entered in the final 395 | // ...textarea of the last row 396 | alwaysShowExtra: false, // If true, an extra (empty) row will always 397 | // ...be displayed (req. ``autoAdd: true``) 398 | deleteOnlyActive: false, // If true, extra empty rows cannot be removed 399 | // ...until they acquire focus 400 | // ...(requires ``alwaysShowExtra: true``) 401 | canDelete: false, // If false, rows cannot be removed from DOM. 402 | // ...``deleteTriggerSel`` will remove 403 | // ...``required`` attr from fields within a 404 | // ..."deleted" row. 405 | // ...deleted rows should be hidden via CSS 406 | deleteOnlyNew: false, // If true, only newly-added rows can be 407 | // ...deleted (requires ``canDelete: true``) 408 | insertAbove: false, // If true, ``insertAboveTrigger`` will be 409 | // ...added to the end of each form-row 410 | insertAboveTrigger: 411 | 'insert', 412 | // The HTML "insert" link add to the end of 413 | // ...each row (req. ``insertAbove: true``) 414 | optionalIfEmpty: true, // If true, required fields in a row will be 415 | // ...optional until changed from initial vals 416 | optionalIfEmptySel: '[data-empty-permitted="true"]' 417 | // Selector for rows to apply optionalIfEmpty 418 | // ...logic (req. ``optionalIfEmpty: true``) 419 | }; 420 | }(jQuery)); 421 | -------------------------------------------------------------------------------- /test/.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "browser": true, 3 | "jquery": true, 4 | 5 | "bitwise": true, 6 | "curly": true, 7 | "eqeqeq": true, 8 | "forin": true, 9 | "immed": true, 10 | "indent": 2, 11 | "latedef": true, 12 | "newcap": true, 13 | "noarg": true, 14 | "noempty": true, 15 | "nonew": true, 16 | "plusplus": true, 17 | "quotmark": "single", 18 | "undef": true, 19 | "unused": true, 20 | "strict": true, 21 | "trailing": true, 22 | 23 | "predef": [ 24 | "QUnit", 25 | "module", 26 | "test", 27 | "asyncTest", 28 | "expect", 29 | "start", 30 | "stop", 31 | "ok", 32 | "equal", 33 | "notEqual", 34 | "deepEqual", 35 | "notDeepEqual", 36 | "strictEqual", 37 | "notStrictEqual", 38 | "throws", 39 | "sinon", 40 | "htmlEqual", 41 | "notHtmlEqual" 42 | ] 43 | } 44 | -------------------------------------------------------------------------------- /test/django-superformset.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Django Superformset Test Suite 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 20 | 21 | 22 | 23 |
    24 |
    25 |
    26 |
    27 | 28 | 29 |
    30 | 31 |
    32 |
    33 |
    34 |
    35 |
    36 | 37 |
    38 |
    39 |
    40 |
    41 | 42 | 43 | -------------------------------------------------------------------------------- /test/django-superformset_test.js: -------------------------------------------------------------------------------- 1 | (function($) { 2 | 3 | 'use strict'; 4 | 5 | module('init', { 6 | setup: function () { 7 | this.container = $('#qunit-fixture .container'); 8 | this.formlist = this.container.find('.formlist'); 9 | this.template = this.container.find('.empty-form .dynamic-form'); 10 | this.templateInput = this.template.find('#template-input'); 11 | this.modifiedTemplate = this.template.clone().removeAttr('id'); 12 | this.modifiedTemplate.find('#template-input').addClass('required').removeAttr('required'); 13 | this.row = this.formlist.find('.dynamic-form'); 14 | this.methods = this.formlist.superformset('exposeMethods'); 15 | sinon.stub(this.methods, 'addDeleteTrigger'); 16 | sinon.stub(this.methods, 'addInsertAboveTrigger'); 17 | sinon.stub(this.methods, 'watchForChangesToOptionalIfEmptyRow'); 18 | sinon.stub(this.methods, 'activateAddTrigger'); 19 | sinon.stub(this.methods, 'autoAddRow'); 20 | }, 21 | teardown: function () { 22 | this.methods.addDeleteTrigger.restore(); 23 | this.methods.addInsertAboveTrigger.restore(); 24 | this.methods.watchForChangesToOptionalIfEmptyRow.restore(); 25 | this.methods.activateAddTrigger.restore(); 26 | this.methods.autoAddRow.restore(); 27 | } 28 | }); 29 | 30 | test('init is chainable', function () { 31 | ok(this.formlist.superformset().is(this.formlist), 'is chainable'); 32 | }); 33 | 34 | test('removes "required" attr and "required-by" data-attr from template', function () { 35 | this.templateInput.data('required-by', true); 36 | this.formlist.superformset(); 37 | 38 | ok(this.templateInput.hasClass('required'), 'template input has class "required"'); 39 | ok(!this.templateInput.is('[required]'), 'template input no longer has "required" attr'); 40 | strictEqual(this.templateInput.data('required'), undefined, 'template input no longer has "required-by" data-attr'); 41 | }); 42 | 43 | test('calls addDeleteTrigger with template and opts.canDelete', function (assert) { 44 | this.formlist.superformset({canDelete: 'test-delete'}); 45 | 46 | ok(this.methods.addDeleteTrigger.called, 'addDeleteTrigger was called'); 47 | assert.htmlEqual(this.methods.addDeleteTrigger.args[0][0].get(0).outerHTML, this.modifiedTemplate.get(0).outerHTML, 'addDeleteTrigger was passed modified template'); 48 | strictEqual(this.methods.addDeleteTrigger.args[0][1], 'test-delete', 'addDeleteTrigger was passed opts.canDelete'); 49 | }); 50 | 51 | test('calls addInsertAboveTrigger with template', function (assert) { 52 | this.formlist.superformset(); 53 | 54 | ok(this.methods.addInsertAboveTrigger.called, 'addInsertAboveTrigger was called'); 55 | assert.htmlEqual(this.methods.addInsertAboveTrigger.args[0][0].get(0).outerHTML, this.modifiedTemplate.get(0).outerHTML, 'addInsertAboveTrigger was passed modified template'); 56 | }); 57 | 58 | test('calls addDeleteTrigger with each row', function () { 59 | this.formlist.superformset({canDelete: true}); 60 | 61 | ok(this.methods.addDeleteTrigger.called, 'addDeleteTrigger was called'); 62 | ok(this.methods.addDeleteTrigger.args[1][0].is(this.row), 'addDeleteTrigger was passed row'); 63 | }); 64 | 65 | test('calls addDeleteTrigger with (opts.canDelete && !opts.deleteOnlyNew)', function () { 66 | this.formlist.superformset(); 67 | 68 | ok(!this.methods.addDeleteTrigger.args[1][1], 'addDeleteTrigger was passed "false" as second arg'); 69 | 70 | this.methods.addDeleteTrigger.reset(); 71 | this.formlist.superformset({canDelete: true}); 72 | 73 | ok(this.methods.addDeleteTrigger.args[1][1], 'addDeleteTrigger was passed "true" as second arg'); 74 | 75 | this.methods.addDeleteTrigger.reset(); 76 | this.formlist.superformset({deleteOnlyNew: true}); 77 | 78 | ok(!this.methods.addDeleteTrigger.args[1][1], 'addDeleteTrigger was passed "false" as second arg'); 79 | 80 | this.methods.addDeleteTrigger.reset(); 81 | this.formlist.superformset({canDelete: true, deleteOnlyNew: true}); 82 | 83 | ok(!this.methods.addDeleteTrigger.args[1][1], 'addDeleteTrigger was passed "false" as second arg'); 84 | }); 85 | 86 | test('calls addInsertAboveTrigger with each row', function () { 87 | this.formlist.superformset(); 88 | 89 | ok(this.methods.addInsertAboveTrigger.called, 'addInsertAboveTrigger was called'); 90 | ok(this.methods.addInsertAboveTrigger.args[1][0].is(this.row), 'addInsertAboveTrigger was passed row'); 91 | }); 92 | 93 | test('calls watchForChangesToOptionalIfEmptyRow with each row', function () { 94 | this.formlist.superformset(); 95 | 96 | ok(this.methods.watchForChangesToOptionalIfEmptyRow.calledOnce, 'watchForChangesToOptionalIfEmptyRow was called once'); 97 | ok(this.methods.watchForChangesToOptionalIfEmptyRow.args[0][0].is(this.row), 'watchForChangesToOptionalIfEmptyRow was passed row'); 98 | }); 99 | 100 | test('calls activateAddTrigger if opts.autoAdd is falsy', function () { 101 | this.formlist.superformset(); 102 | 103 | ok(this.methods.activateAddTrigger.calledOnce, 'activateAddTrigger was called once'); 104 | }); 105 | 106 | test('does not call activateAddTrigger if opts.autoAdd is truthy', function () { 107 | this.formlist.superformset({autoAdd: true}); 108 | 109 | ok(!this.methods.activateAddTrigger.called, 'activateAddTrigger was not called'); 110 | }); 111 | 112 | test('calls autoAddRow if opts.alwaysShowExtra && opts.autoAdd are truthy', function () { 113 | this.formlist.superformset(); 114 | 115 | ok(!this.methods.autoAddRow.called, 'autoAddRow was not called'); 116 | 117 | this.methods.autoAddRow.reset(); 118 | this.formlist.superformset({alwaysShowExtra: true}); 119 | 120 | ok(!this.methods.autoAddRow.called, 'autoAddRow was not called'); 121 | 122 | this.methods.autoAddRow.reset(); 123 | this.formlist.superformset({autoAdd: true}); 124 | 125 | ok(!this.methods.autoAddRow.called, 'autoAddRow was not called'); 126 | 127 | this.methods.autoAddRow.reset(); 128 | this.formlist.superformset({alwaysShowExtra: true, autoAdd: true}); 129 | 130 | ok(this.methods.autoAddRow.calledOnce, 'autoAddRow was called once'); 131 | }); 132 | 133 | test('removes "name" attr from .extra-row inputs on form submit', function () { 134 | var newRow = this.row.clone().addClass('extra-row').appendTo(this.formlist); 135 | var input = newRow.find('.test-input'); 136 | this.container.on('submit', function (e) { e.preventDefault(); }); 137 | this.formlist.superformset({alwaysShowExtra: true, autoAdd: true}); 138 | this.container.trigger('submit'); 139 | 140 | ok(!input.is('[name]'), '.extra-row input no longer has "name" attr'); 141 | }); 142 | 143 | 144 | module('activateAddTrigger', { 145 | setup: function () { 146 | this.container = $('#qunit-fixture .container'); 147 | this.formlist = this.container.find('.formlist'); 148 | this.template = this.container.find('.empty-form .dynamic-form').clone().removeAttr('id'); 149 | this.template.find('#template-input').addClass('required').removeAttr('required'); 150 | this.newRow = this.template.clone().addClass('new-row'); 151 | this.newInput = this.newRow.find('#template-input').attr('required', 'required'); 152 | this.row = this.formlist.find('.dynamic-form'); 153 | this.addButton = this.formlist.find('.add'); 154 | this.totalForms = this.formlist.find('#test-form-TOTAL_FORMS'); 155 | this.methods = this.formlist.superformset('exposeMethods'); 156 | this.vars = { 157 | opts: $.extend({}, $.fn.superformset.defaults), 158 | wrapper: this.formlist, 159 | totalForms: this.totalForms, 160 | tpl: this.template 161 | }; 162 | this.vars.opts.addTriggerSel = '.add'; 163 | this.vars.opts.addAnimationSpeed = false; 164 | sinon.stub(this.methods, 'showAddButton').returns(true); 165 | sinon.stub(this.methods, 'updateElementIndex'); 166 | sinon.stub(this.methods, 'watchForChangesToOptionalIfEmptyRow'); 167 | }, 168 | teardown: function () { 169 | this.methods.showAddButton.restore(); 170 | this.methods.updateElementIndex.restore(); 171 | this.methods.watchForChangesToOptionalIfEmptyRow.restore(); 172 | } 173 | }); 174 | 175 | test('if opts.addTriggerSel is missing, appends opts.addTrigger to wrapper', function () { 176 | delete this.vars.opts.addTriggerSel; 177 | this.formlist.superformset('activateAddTrigger', this.vars); 178 | 179 | ok(this.formlist.find('.add-row').length, '.add-row has been appended to wrapper'); 180 | }); 181 | 182 | test('hides the addButton if methods.showAddButton returns false', function () { 183 | this.methods.showAddButton.returns(false); 184 | this.formlist.superformset('activateAddTrigger', this.vars); 185 | 186 | ok(!this.addButton.is(':visible'), 'addButton has been hidden'); 187 | }); 188 | 189 | test('does not hide addButton if methods.showAddButton returns true', function () { 190 | this.formlist.superformset('activateAddTrigger', this.vars); 191 | 192 | ok(this.addButton.is(':visible'), 'addButton has not been hidden'); 193 | }); 194 | 195 | test('inserts new row after last row on addButton click', function () { 196 | var expected = this.newRow.html(); 197 | this.formlist.superformset('activateAddTrigger', this.vars); 198 | this.addButton.trigger('click'); 199 | 200 | strictEqual(this.row.next().html(), expected, 'new-row has been appended to formlist'); 201 | }); 202 | 203 | test('inserts new row before addButton if no rows exist on addButton click', function () { 204 | var expected = this.newRow.html(); 205 | this.formlist.superformset('activateAddTrigger', this.vars); 206 | this.row.remove(); 207 | this.addButton.trigger('click'); 208 | 209 | strictEqual(this.addButton.prev().html(), expected, 'new-row has been inserted above addButton'); 210 | }); 211 | 212 | test('animates display of new row if opts.addAnimationSpeed', function () { 213 | sinon.stub($.fn, 'animate'); 214 | this.vars.opts.addAnimationSpeed = 'test-speed'; 215 | this.formlist.superformset('activateAddTrigger', this.vars); 216 | this.addButton.trigger('click'); 217 | 218 | ok(this.row.next().animate.calledOnce, 'new-row .animate was called once'); 219 | ok(this.row.next().animate.calledWith({'height': 'toggle', 'opacity': 'toggle'}, 'test-speed'), 'new-row .animate was passed addAnimationSpeed'); 220 | 221 | $.fn.animate.restore(); 222 | }); 223 | 224 | test('animates display of new row if opts.addAnimationSpeed and no rows exist', function () { 225 | sinon.stub($.fn, 'animate'); 226 | this.vars.opts.addAnimationSpeed = 'test-speed'; 227 | this.formlist.superformset('activateAddTrigger', this.vars); 228 | this.row.remove(); 229 | this.addButton.trigger('click'); 230 | 231 | ok(this.addButton.prev().animate.calledOnce, 'new-row .animate was called once'); 232 | ok(this.addButton.prev().animate.calledWith({'height': 'toggle', 'opacity': 'toggle'}, 'test-speed'), 'new-row .animate was passed addAnimationSpeed'); 233 | 234 | $.fn.animate.restore(); 235 | }); 236 | 237 | test('calls updateElementIndex for each input in new row', function (assert) { 238 | this.formlist.superformset('activateAddTrigger', this.vars); 239 | this.addButton.trigger('click'); 240 | 241 | ok(this.methods.updateElementIndex.calledOnce, 'updateElementIndex was called once'); 242 | assert.htmlEqual(this.methods.updateElementIndex.args[0][0].get(0).outerHTML, this.newInput.get(0).outerHTML, 'updateElementIndex was passed new row input'); 243 | strictEqual(this.methods.updateElementIndex.args[0][1], 'form', 'updateElementIndex was passed prefix'); 244 | strictEqual(this.methods.updateElementIndex.args[0][2], 1, 'updateElementIndex was passed zero-indexed number of rows'); 245 | }); 246 | 247 | test('calls watchForChangesToOptionalIfEmptyRow with new row', function (assert) { 248 | this.formlist.superformset('activateAddTrigger', this.vars); 249 | this.addButton.trigger('click'); 250 | 251 | ok(this.methods.watchForChangesToOptionalIfEmptyRow.calledOnce, 'watchForChangesToOptionalIfEmptyRow was called once'); 252 | assert.htmlEqual(this.methods.watchForChangesToOptionalIfEmptyRow.args[0][0].get(0).outerHTML, this.newRow.get(0).outerHTML, 'watchForChangesToOptionalIfEmptyRow was passed new row'); 253 | }); 254 | 255 | test('increments totalForms by 1', function () { 256 | this.formlist.superformset('activateAddTrigger', this.vars); 257 | this.addButton.trigger('click'); 258 | 259 | strictEqual(this.totalForms.val(), '2', 'totalForms is now 2'); 260 | }); 261 | 262 | test('hides the addButton if methods.showAddButton returns false', function () { 263 | this.formlist.superformset('activateAddTrigger', this.vars); 264 | 265 | ok(this.addButton.is(':visible'), 'addButton is visible'); 266 | 267 | this.methods.showAddButton.returns(false); 268 | this.addButton.trigger('click'); 269 | 270 | ok(!this.addButton.is(':visible'), 'addButton has been hidden'); 271 | }); 272 | 273 | test('calls opts.addedCallback with new row', function (assert) { 274 | var callback = this.vars.opts.addedCallback = sinon.spy(); 275 | this.formlist.superformset('activateAddTrigger', this.vars); 276 | this.addButton.trigger('click'); 277 | 278 | ok(callback.calledOnce, 'addedCallback was called once'); 279 | assert.htmlEqual(callback.args[0][0].get(0).outerHTML, this.newRow.get(0).outerHTML, 'addedCallback was passed new row'); 280 | }); 281 | 282 | 283 | module('watchForChangesToOptionalIfEmptyRow', { 284 | setup: function () { 285 | this.container = $('#qunit-fixture .container'); 286 | this.formlist = this.container.find('.formlist'); 287 | this.row = this.formlist.find('.dynamic-form').attr('data-empty-permitted', true); 288 | this.input = this.row.find('.test-input'); 289 | this.totalForms = this.formlist.find('#test-form-TOTAL_FORMS'); 290 | this.methods = this.formlist.superformset('exposeMethods'); 291 | this.vars = { 292 | opts: $.extend({}, $.fn.superformset.defaults) 293 | }; 294 | sinon.stub(this.methods, 'updateRequiredFields'); 295 | }, 296 | teardown: function () { 297 | this.methods.updateRequiredFields.restore(); 298 | } 299 | }); 300 | 301 | test('removes "required" attr, adds .required class and "required-by" data-attr to row input', function () { 302 | this.formlist.superformset('watchForChangesToOptionalIfEmptyRow', this.row, this.vars); 303 | 304 | ok(!this.input.is('[required]'), 'input no longer has "required" attr'); 305 | ok(this.input.hasClass('required'), 'input has .required'); 306 | strictEqual(this.input.data('required-by'), 'form', 'input data-required-by is prefix'); 307 | }); 308 | 309 | test('saves serialized inputs in row data-original-vals', function () { 310 | this.formlist.superformset('watchForChangesToOptionalIfEmptyRow', this.row, this.vars); 311 | 312 | strictEqual(this.row.data('original-vals'), this.input.serialize(), 'row data-original-vals has serialized inputs'); 313 | }); 314 | 315 | test('does nothing if opts.optionalIfEmpty is falsy or row is not opts.optionalIfEmpty', function () { 316 | this.vars.opts.optionalIfEmpty = false; 317 | this.formlist.superformset('watchForChangesToOptionalIfEmptyRow', this.row, this.vars); 318 | 319 | ok(this.input.is('[required]'), 'input is still :required'); 320 | 321 | this.vars.opts.optionalIfEmpty = true; 322 | this.vars.opts.optionalIfEmptySel = '.optional'; 323 | this.formlist.superformset('watchForChangesToOptionalIfEmptyRow', this.row, this.vars); 324 | 325 | ok(this.input.is('[required]'), 'input is still :required'); 326 | }); 327 | 328 | test('calls updateRequiredFields with row when input changes', function () { 329 | this.vars.opts.deleteTriggerSel = '[id$="-TOTAL_FORMS"]'; 330 | this.formlist.superformset('watchForChangesToOptionalIfEmptyRow', this.row, this.vars); 331 | this.totalForms.trigger('change'); 332 | 333 | ok(!this.methods.updateRequiredFields.called, 'updateRequiredFields was not called'); 334 | 335 | this.input.trigger('change'); 336 | 337 | ok(this.methods.updateRequiredFields.calledOnce, 'updateRequiredFields was called once'); 338 | ok(this.methods.updateRequiredFields.calledWith(this.row), 'updateRequiredFields was passed row'); 339 | }); 340 | 341 | 342 | module('updateElementIndex', { 343 | setup: function () { 344 | this.container = $('#qunit-fixture .container'); 345 | this.input = $(''); 346 | this.label = $(''); 347 | this.methods = this.container.superformset('exposeMethods'); 348 | } 349 | }); 350 | 351 | test('replaces "__prefix__" with index in "for" attr', function () { 352 | this.container.superformset('updateElementIndex', this.label, 'test', '2'); 353 | 354 | strictEqual(this.label.attr('for'), 'test-2-input', '__prefix__ replaced in "for" attr'); 355 | }); 356 | 357 | test('replaces "__prefix__" with index in "id" attr', function () { 358 | this.container.superformset('updateElementIndex', this.input, 'test', '2'); 359 | 360 | strictEqual(this.input.attr('id'), 'test-2-input', '__prefix__ replaced in "id" attr'); 361 | }); 362 | 363 | test('replaces "__prefix__" with index in "name" attr', function () { 364 | this.container.superformset('updateElementIndex', this.input, 'test', '2'); 365 | 366 | strictEqual(this.input.attr('name'), 'test-2-name', '__prefix__ replaced in "name" attr'); 367 | }); 368 | 369 | 370 | module('showAddButton', { 371 | setup: function () { 372 | this.container = $('#qunit-fixture .container'); 373 | this.vars = { 374 | totalForms: this.container.find('#test-form-TOTAL_FORMS'), 375 | maxForms: this.container.find('#test-form-MAX_NUM_FORMS') 376 | }; 377 | this.methods = this.container.superformset('exposeMethods'); 378 | } 379 | }); 380 | 381 | test('returns true if maxForms has no val', function () { 382 | this.vars.maxForms.val(''); 383 | var result = this.container.superformset('showAddButton', this.vars); 384 | 385 | ok(result, 'true'); 386 | }); 387 | 388 | test('returns false if maxForms minus totalForms is zero', function () { 389 | this.vars.maxForms.val('1'); 390 | var result = this.container.superformset('showAddButton', this.vars); 391 | 392 | ok(!result, 'false'); 393 | }); 394 | 395 | test('returns true if maxForms minus totalForms is greater than zero', function () { 396 | var result = this.container.superformset('showAddButton', this.vars); 397 | 398 | ok(result, 'true'); 399 | }); 400 | 401 | 402 | module('addDeleteTrigger', { 403 | setup: function () { 404 | this.container = $('#qunit-fixture .container'); 405 | this.formlist = this.container.find('.formlist'); 406 | this.row = this.formlist.find('.dynamic-form'); 407 | this.row2 = this.row.clone().addClass('extra-row').insertAfter(this.row); 408 | this.input = this.row.find('.test-input'); 409 | this.totalForms = this.formlist.find('#test-form-TOTAL_FORMS'); 410 | this.methods = this.formlist.superformset('exposeMethods'); 411 | this.vars = { 412 | opts: $.extend({}, $.fn.superformset.defaults), 413 | wrapper: this.formlist, 414 | totalForms: this.totalForms 415 | }; 416 | this.vars.opts.removeAnimationSpeed = false; 417 | sinon.stub(this.methods, 'updateElementIndex'); 418 | sinon.spy($.fn, 'animate'); 419 | $.fx.off = true; 420 | }, 421 | teardown: function () { 422 | this.methods.updateElementIndex.restore(); 423 | $.fn.animate.restore(); 424 | $.fx.off = false; 425 | } 426 | }); 427 | 428 | test('if canDelete, appends deleteTrigger to row', function () { 429 | this.formlist.superformset('addDeleteTrigger', this.row, true, this.vars); 430 | 431 | ok(this.row.find('.remove-row').length, '.remove-row has been added to row'); 432 | }); 433 | 434 | test('if canDelete, removes row on deleteTrigger click', function () { 435 | this.formlist.superformset('addDeleteTrigger', this.row, true, this.vars); 436 | this.row.find('.remove-row').trigger('click'); 437 | 438 | ok(!this.formlist.find(this.row).length, 'row has been removed'); 439 | }); 440 | 441 | test('if canDelete, updates totalForms on deleteTrigger click', function () { 442 | this.formlist.superformset('addDeleteTrigger', this.row, true, this.vars); 443 | this.row.find('.remove-row').trigger('click'); 444 | 445 | strictEqual(this.vars.totalForms.val(), '0', 'totalForms val is now zero'); 446 | }); 447 | 448 | test('if canDelete, calls updateElementIndex on each remaining input after deleteTrigger click', function () { 449 | this.formlist.superformset('addDeleteTrigger', this.row, true, this.vars); 450 | this.row.find('.remove-row').trigger('click'); 451 | 452 | ok(this.methods.updateElementIndex.calledOnce, 'updateElementIndex was called once'); 453 | ok(this.methods.updateElementIndex.args[0][0].is(this.row2.find('.test-input')), 'updateElementIndex was passed input'); 454 | strictEqual(this.methods.updateElementIndex.args[0][1], 'form', 'updateElementIndex was passed opts.prefix'); 455 | strictEqual(this.methods.updateElementIndex.args[0][2], 0, 'updateElementIndex was passed input index'); 456 | }); 457 | 458 | test('if canDelete and removeAnimationSpeed, removes row after animation on deleteTrigger click', function () { 459 | this.vars.opts.removeAnimationSpeed = 100; 460 | this.formlist.superformset('addDeleteTrigger', this.row, true, this.vars); 461 | this.row.find('.remove-row').trigger('click'); 462 | 463 | ok(this.row.animate.calledOnce, '$.fn.animate was called on row'); 464 | ok(this.row.animate.calledWith({'height': 'toggle', 'opacity': 'toggle'}, 100), '$.fn.animate was passed opts'); 465 | ok(!this.formlist.find(this.row).length, 'row has been removed'); 466 | }); 467 | 468 | test('if canDelete, calls opts.removedCallback with row on deleteTrigger click', function () { 469 | var callback = this.vars.opts.removedCallback = sinon.spy(); 470 | this.formlist.superformset('addDeleteTrigger', this.row, true, this.vars); 471 | this.row.find('.remove-row').trigger('click'); 472 | 473 | ok(callback.calledOnce, 'removedCallback was called once'); 474 | ok(callback.args[0][0].is(this.row), 'removedCallback was passed row'); 475 | }); 476 | 477 | test('when deleteTriggerSel is checked, calls opts.removedCallback with row', function () { 478 | var callback = this.vars.opts.removedCallback = sinon.spy(); 479 | var deleteTrigger = $('').appendTo(this.row); 480 | this.formlist.superformset('addDeleteTrigger', this.row, false, this.vars); 481 | deleteTrigger.trigger('change'); 482 | 483 | ok(callback.calledOnce, 'removedCallback was called once'); 484 | ok(callback.args[0][0].is(this.row), 'removedCallback was passed row'); 485 | }); 486 | 487 | test('when deleteTriggerSel is checked, adds .deleted to row', function () { 488 | var deleteTrigger = $('').appendTo(this.row); 489 | this.formlist.superformset('addDeleteTrigger', this.row, false, this.vars); 490 | deleteTrigger.trigger('change'); 491 | 492 | ok(this.row.hasClass('deleted'), 'row has .deleted'); 493 | }); 494 | 495 | test('when deleteTriggerSel is checked, removes :required attr and adds .deleted-required', function () { 496 | var deleteTrigger = $('').appendTo(this.row); 497 | this.formlist.superformset('addDeleteTrigger', this.row, false, this.vars); 498 | deleteTrigger.trigger('change'); 499 | 500 | ok(!this.input.is('[required]'), 'input is no longer :required'); 501 | ok(this.input.hasClass('deleted-required'), 'input has .deleted-required'); 502 | }); 503 | 504 | test('when deleteTriggerSel is unchecked, adds :required attr and removes .deleted-required', function () { 505 | var deleteTrigger = $('').appendTo(this.row); 506 | this.formlist.superformset('addDeleteTrigger', this.row, false, this.vars); 507 | deleteTrigger.trigger('change'); 508 | 509 | strictEqual(this.input.attr('required'), 'required', 'input is now :required'); 510 | ok(!this.input.hasClass('deleted-required'), 'input no longer has .deleted-required'); 511 | }); 512 | 513 | test('when deleteTriggerSel is unchecked, removes .deleted from row', function () { 514 | var deleteTrigger = $('').appendTo(this.row); 515 | this.formlist.superformset('addDeleteTrigger', this.row, false, this.vars); 516 | deleteTrigger.trigger('change'); 517 | 518 | ok(!this.row.hasClass('deleted'), 'row does not have .deleted'); 519 | }); 520 | 521 | 522 | module('addInsertAboveTrigger', { 523 | setup: function () { 524 | this.container = $('#qunit-fixture .container'); 525 | this.formlist = this.container.find('.formlist'); 526 | this.row = this.formlist.find('.dynamic-form'); 527 | this.input = this.row.find('.test-input'); 528 | this.template = this.container.find('.empty-form .dynamic-form').clone().removeAttr('id'); 529 | this.template.find('#template-input').addClass('required').removeAttr('required'); 530 | this.newRow = this.template.clone().addClass('new-row'); 531 | this.newRow.find('#template-input').attr('required', 'required'); 532 | this.totalForms = this.formlist.find('#test-form-TOTAL_FORMS'); 533 | this.methods = this.formlist.superformset('exposeMethods'); 534 | this.vars = { 535 | opts: $.extend({}, $.fn.superformset.defaults), 536 | wrapper: this.formlist, 537 | totalForms: this.totalForms, 538 | tpl: this.template 539 | }; 540 | this.vars.opts.addAnimationSpeed = false; 541 | this.vars.opts.insertAbove = true; 542 | sinon.stub(this.methods, 'updateElementIndex'); 543 | sinon.stub(this.methods, 'showAddButton').returns(true); 544 | sinon.stub(this.methods, 'watchForChangesToOptionalIfEmptyRow'); 545 | sinon.spy($.fn, 'animate'); 546 | $.fx.off = true; 547 | }, 548 | teardown: function () { 549 | this.methods.updateElementIndex.restore(); 550 | this.methods.showAddButton.restore(); 551 | this.methods.watchForChangesToOptionalIfEmptyRow.restore(); 552 | $.fn.animate.restore(); 553 | $.fx.off = false; 554 | } 555 | }); 556 | 557 | test('prepends insertAboveTrigger to row', function () { 558 | this.formlist.superformset('addInsertAboveTrigger', this.row, this.vars); 559 | 560 | ok(this.row.find('.insert-row').length, '.insert-row has been added to row'); 561 | }); 562 | 563 | test('does nothing if opts.insertAbove is falsy', function () { 564 | this.vars.opts.insertAbove = false; 565 | this.formlist.superformset('addInsertAboveTrigger', this.row, this.vars); 566 | 567 | ok(!this.row.find('.insert-row').length, '.insert-row has not been added to row'); 568 | }); 569 | 570 | test('inserts new row above triggered row', function (assert) { 571 | this.formlist.superformset('addInsertAboveTrigger', this.row, this.vars); 572 | this.row.find('.insert-row').trigger('click'); 573 | var result = this.row.prev(); 574 | 575 | assert.htmlEqual(result.get(0).outerHTML, this.newRow.get(0).outerHTML, 'new row was added above triggered row'); 576 | ok(result.is(':visible'), 'new row is visible'); 577 | }); 578 | 579 | test('if addAnimationSpeed, inserts row after animation', function (assert) { 580 | this.vars.opts.addAnimationSpeed = 100; 581 | this.formlist.superformset('addInsertAboveTrigger', this.row, this.vars); 582 | this.row.find('.insert-row').trigger('click'); 583 | var result = this.row.prev(); 584 | 585 | ok(result.animate.calledOnce, '$.fn.animate was called on row'); 586 | ok(result.animate.calledWith({'height': 'toggle', 'opacity': 'toggle'}, 100), '$.fn.animate was passed opts'); 587 | ok(result.is(':visible'), 'new row is visible'); 588 | assert.htmlEqual(result.removeAttr('style').get(0).outerHTML, this.newRow.get(0).outerHTML, 'new row was added above triggered row'); 589 | }); 590 | 591 | test('updates totalForms', function () { 592 | this.formlist.superformset('addInsertAboveTrigger', this.row, this.vars); 593 | this.row.find('.insert-row').trigger('click'); 594 | 595 | strictEqual(this.vars.totalForms.val(), '2', 'totalForms val is now two'); 596 | }); 597 | 598 | test('calls updateElementIndex on each input', function () { 599 | this.formlist.superformset('addInsertAboveTrigger', this.row, this.vars); 600 | this.row.find('.insert-row').trigger('click'); 601 | var result = this.row.prev(); 602 | 603 | ok(this.methods.updateElementIndex.calledTwice, 'updateElementIndex was called twice'); 604 | ok(this.methods.updateElementIndex.args[0][0].is(result.find('.test-input')), 'updateElementIndex was passed new input'); 605 | strictEqual(this.methods.updateElementIndex.args[0][1], 'form', 'updateElementIndex was passed opts.prefix'); 606 | strictEqual(this.methods.updateElementIndex.args[0][2], 0, 'updateElementIndex was passed new input index'); 607 | ok(this.methods.updateElementIndex.args[1][0].is(this.input), 'updateElementIndex was passed existing input'); 608 | strictEqual(this.methods.updateElementIndex.args[1][1], 'form', 'updateElementIndex was passed opts.prefix'); 609 | strictEqual(this.methods.updateElementIndex.args[1][2], 1, 'updateElementIndex was passed existing input index'); 610 | }); 611 | 612 | test('calls watchForChangesToOptionalIfEmptyRow with new row', function (assert) { 613 | this.formlist.superformset('addInsertAboveTrigger', this.row, this.vars); 614 | this.row.find('.insert-row').trigger('click'); 615 | 616 | ok(this.methods.watchForChangesToOptionalIfEmptyRow.calledOnce, 'watchForChangesToOptionalIfEmptyRow was called once'); 617 | assert.htmlEqual(this.methods.watchForChangesToOptionalIfEmptyRow.args[0][0].get(0).outerHTML, this.newRow.get(0).outerHTML, 'watchForChangesToOptionalIfEmptyRow was passed new row'); 618 | }); 619 | 620 | test('hides the addButton if methods.showAddButton returns false', function () { 621 | this.methods.showAddButton.returns(false); 622 | this.formlist.superformset('addInsertAboveTrigger', this.row, this.vars); 623 | var trigger = this.row.find('.insert-row').trigger('click'); 624 | 625 | ok(!trigger.is(':visible'), 'insertAbove trigger has been hidden'); 626 | }); 627 | 628 | test('does not hide addButton if methods.showAddButton returns true', function () { 629 | this.formlist.superformset('addInsertAboveTrigger', this.row, this.vars); 630 | var trigger = this.row.find('.insert-row').trigger('click'); 631 | 632 | ok(trigger.is(':visible'), 'insertAbove trigger has not been hidden'); 633 | }); 634 | 635 | test('calls opts.addedCallback with new row', function (assert) { 636 | var callback = this.vars.opts.addedCallback = sinon.spy(); 637 | this.formlist.superformset('addInsertAboveTrigger', this.row, this.vars); 638 | this.row.find('.insert-row').trigger('click'); 639 | 640 | ok(callback.calledOnce, 'addedCallback was called once'); 641 | assert.htmlEqual(callback.args[0][0].get(0).outerHTML, this.newRow.get(0).outerHTML, 'addedCallback was passed new row'); 642 | }); 643 | 644 | test('blurs trigger', function () { 645 | sinon.spy($.fn, 'blur'); 646 | this.formlist.superformset('addInsertAboveTrigger', this.row, this.vars); 647 | var trigger = this.row.find('.insert-row').trigger('click'); 648 | 649 | ok(trigger.blur.calledOnce, 'trigger.blur was called once'); 650 | 651 | $.fn.blur.restore(); 652 | }); 653 | 654 | 655 | module('autoAddRow', { 656 | setup: function () { 657 | this.container = $('#qunit-fixture .container'); 658 | this.formlist = this.container.find('.formlist'); 659 | this.row = this.formlist.find('.dynamic-form'); 660 | this.input = this.row.find('.test-input'); 661 | this.template = this.container.find('.empty-form .dynamic-form').clone().removeAttr('id'); 662 | this.newRow = this.template.clone().addClass('extra-row').css('opacity', 0.5); 663 | this.newRow.find('.test-input').addClass('required').removeAttr('required'); 664 | this.totalForms = this.formlist.find('#test-form-TOTAL_FORMS'); 665 | this.methods = this.formlist.superformset('exposeMethods'); 666 | this.vars = { 667 | opts: $.extend({}, $.fn.superformset.defaults), 668 | wrapper: this.formlist, 669 | totalForms: this.totalForms, 670 | tpl: this.template 671 | }; 672 | this.vars.opts.addAnimationSpeed = false; 673 | this.vars.opts.optionalIfEmpty = false; 674 | sinon.stub(this.methods, 'updateElementIndex'); 675 | sinon.stub(this.methods, 'showAddButton').returns(true); 676 | sinon.stub(this.methods, 'watchForChangesToOptionalIfEmptyRow'); 677 | sinon.spy($.fn, 'animate'); 678 | $.fx.off = true; 679 | }, 680 | teardown: function () { 681 | this.methods.updateElementIndex.restore(); 682 | this.methods.showAddButton.restore(); 683 | this.methods.watchForChangesToOptionalIfEmptyRow.restore(); 684 | $.fn.animate.restore(); 685 | $.fx.off = false; 686 | } 687 | }); 688 | 689 | test('adds new row with opacity: 0.5 after last row', function (assert) { 690 | this.formlist.superformset('autoAddRow', this.vars); 691 | var result = this.row.next(); 692 | 693 | assert.htmlEqual(result.get(0).outerHTML, this.newRow.get(0).outerHTML, 'new row was added after last row'); 694 | ok(result.is(':visible'), 'new row is visible'); 695 | }); 696 | 697 | test('calls updateElementIndex with each new row', function () { 698 | this.formlist.superformset('autoAddRow', this.vars); 699 | var input = this.row.next().find('.test-input'); 700 | 701 | ok(this.methods.updateElementIndex.calledOnce, 'updateElementIndex was called once'); 702 | ok(this.methods.updateElementIndex.args[0][0].is(input), 'updateElementIndex was passed new row input'); 703 | strictEqual(this.methods.updateElementIndex.args[0][1], 'form', 'updateElementIndex was passed prefix'); 704 | strictEqual(this.methods.updateElementIndex.args[0][2], 1, 'updateElementIndex was passed new row zero-based index'); 705 | }); 706 | 707 | test('removes :required attr and adds .required to inputs in new row', function () { 708 | this.formlist.superformset('autoAddRow', this.vars); 709 | var input = this.row.next().find('.test-input'); 710 | 711 | ok(!input.is('[required]'), 'new input is not :required'); 712 | ok(input.hasClass('required'), 'new input has class .required'); 713 | }); 714 | 715 | test('if addAnimationSpeed, adds row after animation', function (assert) { 716 | this.vars.opts.addAnimationSpeed = 100; 717 | this.formlist.superformset('autoAddRow', this.vars); 718 | var result = this.row.next(); 719 | 720 | ok(result.animate.calledOnce, '$.fn.animate was called on row'); 721 | ok(result.animate.calledWith({'height': 'toggle', 'opacity': '0.5'}, 100), '$.fn.animate was passed opts'); 722 | assert.htmlEqual(result.get(0).outerHTML, this.newRow.get(0).outerHTML, 'new row was added after last row'); 723 | ok(result.is(':visible'), 'new row is visible'); 724 | }); 725 | 726 | test('on focus, remove .extra-row and opacity: 0.5 from new row', function () { 727 | this.formlist.superformset('autoAddRow', this.vars); 728 | var row = this.row.next(); 729 | row.find('.test-input').trigger('focus'); 730 | 731 | ok(!row.hasClass('extra-row'), 'new row no longer has class .extra-row'); 732 | strictEqual(row.css('opacity'), '1', 'new row now has opacity: 1'); 733 | }); 734 | 735 | test('on focus, add :required to .required inputs', function () { 736 | this.formlist.superformset('autoAddRow', this.vars); 737 | var input = this.row.next().find('.test-input').trigger('focus'); 738 | 739 | ok(input.is('[required]'), 'input is now :required'); 740 | }); 741 | 742 | test('on focus, add :required to .required inputs', function () { 743 | this.vars.opts.optionalIfEmpty = true; 744 | this.formlist.superformset('autoAddRow', this.vars); 745 | var input = this.row.next().find('.test-input').trigger('focus'); 746 | 747 | ok(input.is('[required]'), 'input is now :required'); 748 | }); 749 | 750 | test('on focus, add :required to .required inputs', function () { 751 | this.formlist.superformset('autoAddRow', this.vars); 752 | var row = this.row.next().attr('data-empty-permitted', true); 753 | var input = row.find('.test-input').trigger('focus'); 754 | 755 | ok(input.is('[required]'), 'input is now :required'); 756 | }); 757 | 758 | test('on focus, do not add :required to .required inputs if optionalIfEmpty row', function () { 759 | this.vars.opts.optionalIfEmpty = true; 760 | this.formlist.superformset('autoAddRow', this.vars); 761 | var row = this.row.next().attr('data-empty-permitted', true); 762 | var input = row.find('.test-input').trigger('focus'); 763 | 764 | ok(!input.is('[required]'), 'input is still not :required'); 765 | }); 766 | 767 | test('updates totalForms', function () { 768 | this.formlist.superformset('autoAddRow', this.vars); 769 | this.formlist.prepend('
    '); 770 | this.row.next().find('.test-input').trigger('focus'); 771 | 772 | strictEqual(this.vars.totalForms.val(), '2', 'totalForms val is now two'); 773 | }); 774 | 775 | test('shows deleteTrigger if deleteOnlyActive', function () { 776 | this.vars.opts.deleteOnlyActive = true; 777 | this.formlist.superformset('autoAddRow', this.vars); 778 | var row = this.row.next(); 779 | $('
    ').hide().appendTo(row); 780 | var remove = row.find('.remove-row'); 781 | row.find('.test-input').trigger('focus'); 782 | 783 | ok(remove.is(':visible'), 'deleteTrigger is visible'); 784 | }); 785 | 786 | test('does not show deleteTrigger if deleteOnlyActive is falsy', function () { 787 | this.formlist.superformset('autoAddRow', this.vars); 788 | var row = this.row.next(); 789 | $('
    ').hide().appendTo(row); 790 | var remove = row.find('.remove-row'); 791 | row.find('.test-input').trigger('focus'); 792 | 793 | ok(!remove.is(':visible'), 'deleteTrigger is visible'); 794 | }); 795 | 796 | test('calls autoAddRow if showAddButton returns true and row is last row', function () { 797 | this.formlist.superformset('autoAddRow', this.vars); 798 | sinon.stub(this.methods, 'autoAddRow'); 799 | this.row.next().find('.test-input').trigger('focus'); 800 | 801 | ok(this.methods.autoAddRow.calledOnce, 'autoAddRow was called once'); 802 | 803 | this.methods.autoAddRow.restore(); 804 | }); 805 | 806 | test('does not call autoAddRow if showAddButton returns false', function () { 807 | this.methods.showAddButton.returns(false); 808 | this.formlist.superformset('autoAddRow', this.vars); 809 | sinon.stub(this.methods, 'autoAddRow'); 810 | this.row.next().find('.test-input').trigger('focus'); 811 | 812 | ok(!this.methods.autoAddRow.called, 'autoAddRow was not called'); 813 | 814 | this.methods.autoAddRow.restore(); 815 | }); 816 | 817 | test('does not call autoAddRow if row is not last row', function () { 818 | this.formlist.superformset('autoAddRow', this.vars); 819 | sinon.stub(this.methods, 'autoAddRow'); 820 | this.formlist.append('
    '); 821 | this.row.next().find('.test-input').trigger('focus'); 822 | 823 | ok(!this.methods.autoAddRow.called, 'autoAddRow was not called'); 824 | 825 | this.methods.autoAddRow.restore(); 826 | }); 827 | 828 | test('calls watchForChangesToOptionalIfEmptyRow with new row', function (assert) { 829 | this.formlist.superformset('autoAddRow', this.vars); 830 | var row = this.row.next(); 831 | 832 | ok(this.methods.watchForChangesToOptionalIfEmptyRow.calledOnce, 'watchForChangesToOptionalIfEmptyRow was called once'); 833 | assert.htmlEqual(this.methods.watchForChangesToOptionalIfEmptyRow.args[0][0].get(0).outerHTML, row.get(0).outerHTML, 'watchForChangesToOptionalIfEmptyRow was passed new row'); 834 | }); 835 | 836 | test('hides deleteTrigger if deleteOnlyActive', function () { 837 | this.vars.opts.deleteOnlyActive = true; 838 | $('
    ').appendTo(this.template); 839 | this.formlist.superformset('autoAddRow', this.vars); 840 | var remove = this.row.next().find('.remove-row'); 841 | 842 | ok(!remove.is(':visible'), 'deleteTrigger is not visible'); 843 | }); 844 | 845 | test('does not hide deleteTrigger if deleteOnlyActive is falsy', function () { 846 | $('
    ').appendTo(this.template); 847 | this.formlist.superformset('autoAddRow', this.vars); 848 | var remove = this.row.next().find('.remove-row'); 849 | 850 | ok(remove.is(':visible'), 'deleteTrigger is still visible'); 851 | }); 852 | 853 | test('calls opts.addedCallback with new row', function (assert) { 854 | var callback = this.vars.opts.addedCallback = sinon.spy(); 855 | this.formlist.superformset('autoAddRow', this.vars); 856 | var row = this.row.next(); 857 | 858 | ok(callback.calledOnce, 'addedCallback was called once'); 859 | assert.htmlEqual(callback.args[0][0].get(0).outerHTML, row.get(0).outerHTML, 'addedCallback was passed new row'); 860 | }); 861 | 862 | 863 | module('updateRequiredFields', { 864 | setup: function () { 865 | this.container = $('#qunit-fixture .container'); 866 | this.formlist = this.container.find('.formlist'); 867 | this.row = this.formlist.find('.dynamic-form'); 868 | this.input = this.row.find('.test-input'); 869 | this.input.data('required-by', 'form'); 870 | this.row.data('original-vals', this.input.serialize()); 871 | this.methods = this.formlist.superformset('exposeMethods'); 872 | this.vars = { 873 | opts: $.extend({}, $.fn.superformset.defaults) 874 | }; 875 | } 876 | }); 877 | 878 | test('removes :required from inputs if serialized data matches row data-original-vals', function () { 879 | this.formlist.superformset('updateRequiredFields', this.row, this.vars); 880 | 881 | ok(!this.input.is('[required]'), 'input is no longer :required'); 882 | }); 883 | 884 | test('does not remove :required from inputs where data-required-by !== opts prefix', function () { 885 | this.input.removeData('required-by'); 886 | this.formlist.superformset('updateRequiredFields', this.row, this.vars); 887 | 888 | ok(this.input.is('[required]'), 'input is still :required'); 889 | }); 890 | 891 | test('adds :required to inputs if serialized data does not match row data-original-vals', function () { 892 | this.row.removeData('original-vals'); 893 | this.input.removeAttr('required').addClass('required'); 894 | this.formlist.superformset('updateRequiredFields', this.row, this.vars); 895 | 896 | strictEqual(this.input.attr('required'), 'required', 'input is :required'); 897 | }); 898 | 899 | test('does not add :required to inputs with .deleted-required', function () { 900 | this.row.removeData('original-vals'); 901 | this.input.removeAttr('required').addClass('required deleted-required'); 902 | this.formlist.superformset('updateRequiredFields', this.row, this.vars); 903 | 904 | ok(!this.input.is('[required]'), 'input is not :required'); 905 | }); 906 | 907 | 908 | module('superformset methods', { 909 | setup: function () { 910 | this.container = $('#qunit-fixture'); 911 | this.methods = this.container.superformset('exposeMethods'); 912 | this.initStub = sinon.stub(this.methods, 'init'); 913 | }, 914 | teardown: function () { 915 | this.initStub.restore(); 916 | } 917 | }); 918 | 919 | test('if no args, calls init method', function () { 920 | this.container.superformset(); 921 | 922 | ok(this.initStub.calledOnce, 'init was called once'); 923 | }); 924 | 925 | test('if first arg is an object, calls init method with args', function () { 926 | this.container.superformset({test: 'data'}, 'more'); 927 | 928 | ok(this.initStub.calledOnce, 'init was called once'); 929 | ok(this.initStub.calledWith({test: 'data'}, 'more'), 'init was passed args'); 930 | }); 931 | 932 | test('if first arg is a method, calls method with remaining args', function () { 933 | this.container.superformset('init', {test: 'data'}, 'more'); 934 | 935 | ok(this.initStub.calledOnce, 'init was called once'); 936 | ok(this.initStub.calledWith({test: 'data'}, 'more'), 'init was passed remaining args'); 937 | }); 938 | 939 | test('if first arg not a method or object, returns an error', function () { 940 | sinon.stub($, 'error'); 941 | this.container.superformset('test'); 942 | 943 | ok(!this.initStub.called, 'init was not called'); 944 | ok($.error.calledOnce, '$.error was called once'); 945 | ok($.error.calledWith('Method test does not exist on jQuery.superformset'), '$.error was passed error msg'); 946 | 947 | $.error.restore(); 948 | }); 949 | 950 | }(jQuery)); 951 | --------------------------------------------------------------------------------