├── .jshintignore ├── test ├── index.js ├── callbacks.js └── basic.js ├── .gitignore ├── .jshintrc ├── .zuul.yml ├── .travis.yml ├── LICENSE.md ├── package.json ├── ampersand-form-view.js └── README.md /.jshintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /test/index.js: -------------------------------------------------------------------------------- 1 | require('./basic'); 2 | require('./callbacks'); 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | *.log 4 | debug.js 5 | .zuulrc 6 | -------------------------------------------------------------------------------- /.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "browser": true, 3 | "latedef": true, 4 | "quotmark": true, 5 | "undef": true, 6 | "unused": true, 7 | "trailing": true, 8 | "predef": [ 9 | "require", 10 | "setTimeout", 11 | "document", 12 | "module", 13 | "console" 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /.zuul.yml: -------------------------------------------------------------------------------- 1 | ui: tape 2 | browsers: 3 | - name: chrome 4 | version: latest 5 | - name: firefox 6 | version: latest 7 | - name: safari 8 | version: latest 9 | - name: ie 10 | version: 9..latest 11 | - name: iphone 12 | version: latest 13 | - name: android 14 | version: latest 15 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: node_js 3 | node_js: '4' 4 | cache: 5 | directories: node_modules 6 | before_script: npm prune 7 | branches: 8 | except: /^v\d+\.\d+\.\d+$/ 9 | notifications: 10 | webhooks: 11 | urls: https://webhooks.gitter.im/e/df4440290bd89d941fb4 12 | on_success: change 13 | on_failure: always 14 | on_start: false 15 | email: false 16 | script: npm run test-ci 17 | addons: 18 | sauce_connect: true 19 | env: 20 | global: 21 | - secure: VrCv62N+2yKZopgcI7cyCsjBM1pwa/z0Q6uSyOm0CEF58/33olicOYy1U/5An0XW7ExBPMR5sNlTYzew7q5FQMVPe571g3hZcLAc05GMRGTPXnigsUI9zzdL0Tk6uF61/yuxuOeOxV3kx6MMLbPrvF1onZXtnCPp3S4rYJUHQO0= 22 | - secure: Wc66lVAXEsR7CshnlQsIdNlZ5Kb0NmrIjH3D5ZKqTAIdxI+5HB5CJzKZYjWyUv+XGKwAndp/mDOB0JfJuTO4QV1mMHEcPAdheNcl2BIZmHWl9xzwJe66O/iamKcrllEHXm/ODFLamjtm+B7QmPthANqBTv+nJreD6zOvyS7Zca8= 23 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright © 2014 &yet, LLC and AmpersandJS contributors 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a 4 | copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included 12 | in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS 15 | OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 17 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 18 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 19 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 20 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ampersand-form-view", 3 | "description": "Completely customizable form lib for bulletproof clientside forms.", 4 | "version": "7.1.0", 5 | "author": "Henrik Joreteg ", 6 | "files": [ 7 | "ampersand-form-view.js" 8 | ], 9 | "browserify": { 10 | "transform": [ 11 | "ampersand-version" 12 | ] 13 | }, 14 | "bugs": { 15 | "url": "https://github.com/ampersandjs/ampersand-form-view/issues" 16 | }, 17 | "dependencies": { 18 | "ampersand-version": "^1.0.0", 19 | "ampersand-view": "^10.0.1", 20 | "lodash": "^4.11.1" 21 | }, 22 | "devDependencies": { 23 | "ampersand-checkbox-view": "*", 24 | "ampersand-input-view": "^7.0.0", 25 | "ampersand-model": "^8.0.0", 26 | "browserify": "^16.2.2", 27 | "jshint": "^2.6.0", 28 | "phantomjs-prebuilt": "^2.1.3", 29 | "precommit-hook": "^3.0.0", 30 | "tape": "^4.0.0", 31 | "zuul": "^3.9.0" 32 | }, 33 | "homepage": "https://github.com/ampersandjs/ampersand-form-view", 34 | "keywords": [ 35 | "forms", 36 | "ampersand", 37 | "browser" 38 | ], 39 | "license": "MIT", 40 | "main": "ampersand-form-view.js", 41 | "repository": { 42 | "type": "git", 43 | "url": "git://github.com/ampersandjs/ampersand-form-view" 44 | }, 45 | "scripts": { 46 | "lint": "jshint .", 47 | "start": "zuul --local -- test/index.js", 48 | "test": "zuul --phantom -- test/index.js", 49 | "test-ci": "zuul -- test/index.js", 50 | "debug": "browserify test/index.js -o debug.js", 51 | "preversion": "git checkout master && git pull && npm ls", 52 | "publish-patch": "npm run preversion && npm version patch && git push origin master --tags && npm publish", 53 | "publish-minor": "npm run preversion && npm version minor && git push origin master --tags && npm publish", 54 | "publish-major": "npm run preversion && npm version major && git push origin master --tags && npm publish", 55 | "validate": "npm ls" 56 | }, 57 | "pre-commit": [ 58 | "lint", 59 | "validate", 60 | "test" 61 | ] 62 | } 63 | -------------------------------------------------------------------------------- /test/callbacks.js: -------------------------------------------------------------------------------- 1 | var test = require('tape'); 2 | var FormView = require('../ampersand-form-view'); 3 | 4 | function FakeField(opts) { 5 | opts = opts || {}; 6 | 7 | this.valid = opts.valid === false ? false : true; 8 | this.name = opts.name || 'fake-field'; 9 | this.value = opts.value || 'fake-value'; 10 | this.parent = opts.parent || null; 11 | this.beforeSubmit = opts.beforeSubmit || function() {}; 12 | } 13 | FakeField.prototype = { 14 | setValue: function(value) { 15 | this.value = value; 16 | this.updateParent(); 17 | }, 18 | 19 | setValid: function(valid) { 20 | this.valid = valid; 21 | this.updateParent(); 22 | }, 23 | 24 | updateParent: function() { 25 | if (this.parent) { 26 | this.parent.update(this); 27 | } 28 | }, 29 | 30 | render: function() { 31 | if (!this.el) { 32 | this.el = document.createElement('div'); 33 | } 34 | return this; 35 | }, 36 | 37 | remove: function() { 38 | } 39 | }; 40 | 41 | test('submitCallback', function(t) { 42 | var form = new FormView({ 43 | submitCallback: function(data) { 44 | t.notEqual(data, undefined, 'should call submitCallback with data'); 45 | t.end(); 46 | } 47 | }); 48 | form.render(); 49 | form.handleSubmit(document.createEvent('Event')); 50 | }); 51 | 52 | test('preventDefault === false', function(t) { 53 | t.plan(1); 54 | 55 | var form = new FormView({ 56 | preventDefault: false, 57 | submitCallback: function() { 58 | t.fail('submit callback called when it shouldn\'t be'); 59 | t.end(); 60 | } 61 | }); 62 | form.render(); 63 | var result = form.handleSubmit(document.createEvent('Event')); 64 | 65 | t.strictEqual(result, undefined, 'form submission not intercepted'); 66 | }); 67 | 68 | test('prevent submission on invalid', function(t) { 69 | t.plan(3); 70 | 71 | var field = new FakeField({ 72 | name: 'field' 73 | }); 74 | 75 | var form = new FormView({ 76 | fields: [field], 77 | submitCallback: function() { 78 | t.fail('submit callback called when it shouldn\'t be'); 79 | t.end(); 80 | } 81 | }); 82 | form.render(); 83 | field.setValid(false); 84 | 85 | t.notOk(field.valid, 'field is not valid'); 86 | t.notOk(form.valid, 'form is not valid'); 87 | 88 | var result = form.handleSubmit(document.createEvent('Event')); 89 | 90 | t.strictEqual(result, false, 'form submission halted'); 91 | }); 92 | 93 | test('on submit', function(t) { 94 | var form = new FormView(); 95 | 96 | form.on('submit', function(data) { 97 | t.notEqual(data, undefined, 'should trigger `submit` event with data'); 98 | t.end(); 99 | }); 100 | 101 | form.render(); 102 | form.handleSubmit(document.createEvent('Event')); 103 | }); 104 | 105 | test('beforeSubmit', function(t) { 106 | var field = new FakeField({ 107 | name: 'field', 108 | beforeSubmit: function() { 109 | t.equal(this, field, 'should call beforeSubmit on the field'); 110 | this.value = 42; 111 | } 112 | }); 113 | var form = new FormView({ 114 | fields: [ field ], 115 | submitCallback: function(data) { 116 | t.equal(data.field, 42, 'should call submitCallback after beforeSubmit on the fields'); 117 | t.end(); 118 | } 119 | }); 120 | form.render(); 121 | form.handleSubmit(document.createEvent('Event')); 122 | }); 123 | 124 | test('validCallback', function(t) { 125 | t.plan(2); 126 | var field = new FakeField({valid: false}); 127 | var form = new FormView({ 128 | fields: [ field ], 129 | validCallback: (function(valid) { 130 | t.equal(valid, field.valid, 'should call validCallback twice'); 131 | }) 132 | }); 133 | form.render(); 134 | field.setValid(true); 135 | }); 136 | 137 | test('on change:valid', function(t) { 138 | t.plan(2); 139 | var field = new FakeField({valid: false}); 140 | var form = new FormView({ 141 | fields: [ field ] 142 | }); 143 | form.on('change:valid', function(view, validBool) { 144 | t.equal(validBool, field.valid, 'should trigger `valid` event twice'); 145 | }); 146 | form.render(); 147 | field.setValid(true); 148 | }); 149 | 150 | test('verbose data', function (t) { 151 | var fields = [ 152 | new FakeField({name: 'name.first', value: 'Michael'}), 153 | new FakeField({name: 'name.last', value: 'Mustermann'}), 154 | new FakeField({name: 'phone[0].type', value: 'home'}), 155 | new FakeField({name: 'phone[0].number', value: '1234567'}), 156 | new FakeField({name: 'phone[1].type', value: 'mobile'}), 157 | new FakeField({name: 'phone[1].number', value: '7654321'}) 158 | ]; 159 | var form = new FormView({ fields: fields }); 160 | t.same(form.data, { 161 | name: {first: 'Michael', last: 'Mustermann'}, 162 | phone: [ 163 | {type: 'home', number: '1234567'}, 164 | {type: 'mobile', number: '7654321'} 165 | ] 166 | }, 'verbose data should be correctly parsed'); 167 | t.end(); 168 | }); 169 | 170 | test('bracketed field names', function(t) { 171 | var fields = [ 172 | new FakeField({name: 'foobars[]', value: [1,2,3,4]}), 173 | new FakeField({name: 'name.first', value: 'Michael'}), 174 | new FakeField({name: 'name.last', value: 'Mustermann'}), 175 | new FakeField({name: 'phone[0].type', value: 'home'}), 176 | new FakeField({name: 'phone[0].number', value: '1234567'}), 177 | new FakeField({name: 'phone[1].type', value: 'mobile'}), 178 | new FakeField({name: 'phone[1].number', value: '7654321'}) 179 | ]; 180 | var form = new FormView({ fields: fields }); 181 | t.same(form.data, { 182 | 'foobars[]': [1,2,3,4], 183 | name: {first: 'Michael', last: 'Mustermann'}, 184 | phone: [ 185 | {type: 'home', number: '1234567'}, 186 | {type: 'mobile', number: '7654321'} 187 | ] 188 | }, 'verbose data should be correctly parsed while maintaining support for legacy field names ending in empty square brackets'); 189 | t.end(); 190 | }); 191 | 192 | test('clean', function(t) { 193 | var field = new FakeField({ 194 | name: 'some_field', 195 | value: '27' 196 | }); 197 | var FormViewExtendedWithClean = FormView.extend({ 198 | clean: function(data) { 199 | t.equal(data.some_field, field.value, 'data should have the raw value from the field'); 200 | data.some_field = Number(data.some_field); 201 | return data; 202 | } 203 | }); 204 | var form = new FormViewExtendedWithClean({ 205 | fields: [ field ] 206 | }); 207 | var data = form.data; 208 | t.equal(data.some_field, Number(field.value), 'data should return cleaned data'); 209 | t.end(); 210 | }); 211 | 212 | test('deprecated: getData', function(t) { 213 | var _warn = console.warn; 214 | 215 | console.warn = function(message) { 216 | t.ok(message); 217 | console.warn = _warn; 218 | t.end(); 219 | }; 220 | 221 | var form = new FormView(); 222 | 223 | form.render(); 224 | 225 | form.getData(); 226 | }); 227 | -------------------------------------------------------------------------------- /ampersand-form-view.js: -------------------------------------------------------------------------------- 1 | /*$AMPERSAND_VERSION*/ 2 | var View = require('ampersand-view'); 3 | var set = require('lodash/set'); 4 | var isFunction = require('lodash/isFunction'); 5 | var result = require('lodash/result'); 6 | 7 | module.exports = View.extend({ 8 | 9 | session: { 10 | valid: ['boolean', false, false] 11 | }, 12 | 13 | derived: { 14 | data: { 15 | fn: function () { 16 | var res = {}; 17 | for (var key in this._fieldViews) { 18 | if (this._fieldViews.hasOwnProperty(key)) { 19 | // If field name ends with '[]', don't interpret 20 | // as verbose form field... 21 | if (key.match(/\[\]$/)) { 22 | res[key] = this._fieldViews[key].value; 23 | } else { 24 | set(res, key, this._fieldViews[key].value); 25 | } 26 | } 27 | } 28 | return this.clean(res); 29 | }, 30 | cache: false 31 | } 32 | }, 33 | 34 | initialize: function(opts) { 35 | opts = opts || {}; 36 | this.el = opts.el; 37 | this.fieldContainerEl = opts.fieldContainerEl; 38 | this.validCallback = opts.validCallback || this.validCallback; 39 | this.submitCallback = opts.submitCallback || this.submitCallback; 40 | this.clean = opts.clean || this.clean || function (res) { return res; }; 41 | 42 | if (opts.model) this.model = opts.model; 43 | 44 | this.preventDefault = opts.preventDefault === false ? false : true; 45 | this.autoAppend = opts.autoAppend === false ? false : true; 46 | 47 | // storage for our fields 48 | this._fieldViews = {}; 49 | this._fieldViewsArray = []; 50 | 51 | // add all our fields 52 | (result(opts, 'fields') || result(this, 'fields') || []).forEach(this.addField, this); 53 | 54 | if (opts.autoRender) { 55 | this.autoRender = opts.autoRender; 56 | // &-view requires this.template && this.autoRender to be truthy in 57 | // order to autoRender. template doesn't apply to &-form-view, but 58 | // we manually flip the bit to honor autoRender 59 | this.template = opts.template || this.template || true; 60 | } 61 | 62 | if (opts.values) this._startingValues = opts.values; 63 | 64 | if (this.validCallback) { 65 | this.on('change:valid', function(view, validBool) { 66 | this.validCallback(validBool); 67 | }); 68 | } 69 | 70 | if (this.submitCallback) this.on('submit', this.submitCallback); 71 | }, 72 | 73 | addField: function (fieldView) { 74 | this._fieldViews[fieldView.name] = fieldView; 75 | this._fieldViewsArray.push(fieldView); 76 | return this; 77 | }, 78 | 79 | removeField: function (name, strict) { 80 | var field = this.getField(name, strict); 81 | if (field) { 82 | field.remove(); 83 | delete this._fieldViews[name]; 84 | this._fieldViewsArray.splice(this._fieldViewsArray.indexOf(field), 1); 85 | } 86 | }, 87 | 88 | getField: function (name, strict) { 89 | var field = this._fieldViews[name]; 90 | if (!field && strict) { 91 | throw new ReferenceError('field name "' + name + '" not found'); 92 | } 93 | return field; 94 | }, 95 | 96 | setValues: function (data) { 97 | for (var name in data) { 98 | if (data.hasOwnProperty(name)) { 99 | this.setValue(name, data[name]); 100 | } 101 | } 102 | }, 103 | 104 | checkValid: function () { 105 | this.valid = this._fieldViewsArray.every(function (field) { 106 | return field.valid; 107 | }); 108 | return this.valid; 109 | }, 110 | 111 | beforeSubmit: function () { 112 | this._fieldViewsArray.forEach(function (field) { 113 | if (field.beforeSubmit) field.beforeSubmit(); 114 | }); 115 | }, 116 | 117 | update: function (field) { 118 | this.trigger('change:' + field.name, field); 119 | // if this one's good check 'em all 120 | if (field.valid) { 121 | this.checkValid(); 122 | } else { 123 | this.valid = false; 124 | } 125 | }, 126 | 127 | remove: function () { 128 | this.el.removeEventListener('submit', this.handleSubmit, false); 129 | this._fieldViewsArray.forEach(function (field) { 130 | field.remove(); 131 | }); 132 | return View.prototype.remove.call(this); 133 | }, 134 | 135 | handleSubmit: function (e) { 136 | this.beforeSubmit(); 137 | this.checkValid(); 138 | if (!this.valid) { 139 | e.preventDefault(); 140 | return false; 141 | } 142 | 143 | if (this.preventDefault) { 144 | e.preventDefault(); 145 | this.trigger('submit', this.data); 146 | return false; 147 | } 148 | }, 149 | 150 | reset: function () { 151 | this._fieldViewsArray.forEach(function (field) { 152 | if (isFunction(field.reset)) { 153 | field.reset(); 154 | } 155 | }); 156 | }, 157 | 158 | clear: function () { 159 | this._fieldViewsArray.forEach(function (field) { 160 | if (isFunction(field.clear)) { 161 | field.clear(); 162 | } 163 | }); 164 | }, 165 | 166 | render: function () { 167 | if (this.rendered) return; 168 | if (!this.el) { 169 | this.el = document.createElement('form'); 170 | } 171 | if (this.autoAppend) { 172 | this.fieldContainerEl = this.query(this.fieldContainerEl || '[data-hook~=field-container]') || this.el; 173 | } 174 | this._fieldViewsArray.forEach(function renderEachField(fV) { 175 | this.renderField(fV, true); 176 | }, this); 177 | if (this._startingValues) { 178 | // setValues is ideally executed at initialize, with no persistent 179 | // memory consumption inside ampersand-form-view, however, some 180 | // fieldViews don't permit `setValue(...)` unless the field view 181 | // itself is rendered. thus, cache init values into _startingValues 182 | // and update all values after each field is rendered 183 | this.setValues(this._startingValues); 184 | delete this._startingValues; 185 | } 186 | this.handleSubmit = this.handleSubmit.bind(this); 187 | this.el.addEventListener('submit', this.handleSubmit, false); 188 | // force `change:valid` to be triggered when `valid === false` post-render, 189 | // despite `valid` not having changed from its default pre-render value of `false` 190 | this.set('valid', null, {silent: true}); 191 | this.checkValid(); 192 | }, 193 | 194 | renderField: function (fieldView, renderInProgress) { 195 | if (!this.rendered && !renderInProgress) return this; 196 | fieldView.parent = this; 197 | fieldView.render(); 198 | if (this.autoAppend) this.fieldContainerEl.appendChild(fieldView.el); 199 | }, 200 | 201 | getValue: function(name) { 202 | var field = this.getField(name, true); 203 | return field.value; 204 | }, 205 | 206 | setValue: function(name, value) { 207 | var field = this.getField(name, true); 208 | field.setValue(value); 209 | return this; 210 | }, 211 | 212 | // deprecated 213 | getData: function() { 214 | console.warn('deprecation warning: ampersand-form-view `.getData()` replaced by `.data`'); 215 | return this.data; 216 | } 217 | 218 | }); 219 | -------------------------------------------------------------------------------- /test/basic.js: -------------------------------------------------------------------------------- 1 | var test = require('tape').test; 2 | var AmpersandModel = require('ampersand-model'); 3 | var AmpersandView = require('ampersand-view'); 4 | var AmpersandInputView = require('ampersand-input-view'); 5 | var AmpersandCheckboxView = require('ampersand-checkbox-view'); 6 | var AmpersandFormView = require('../ampersand-form-view'); 7 | 8 | var Model = AmpersandModel.extend({ 9 | props: { 10 | text: 'string', 11 | textarea: 'string' 12 | } 13 | }); 14 | 15 | var isCheckbox = function(el) { 16 | return el.type === 'checkbox'; 17 | }; 18 | 19 | var getView = function (opts) { 20 | var formOpts; 21 | opts = opts || {}; 22 | formOpts = opts.form || {}; 23 | var FormView = AmpersandFormView.extend({ 24 | fields: function () { 25 | return [ 26 | new AmpersandCheckboxView({ 27 | name: 'check', 28 | value: true 29 | }), 30 | new AmpersandInputView({ 31 | name: 'text', 32 | type: 'text', 33 | value: 'Original value' 34 | }), 35 | new AmpersandInputView({ 36 | name: 'textarea', 37 | type: 'textarea', 38 | value: 'Original value' 39 | }) 40 | ]; 41 | } 42 | }); 43 | 44 | // Create a View with a nested FormView. 45 | var View = AmpersandView.extend({ 46 | template: opts.template || '
', 47 | render: function () { 48 | this.renderWithTemplate(); 49 | this.form = new FormView({ 50 | autoRender: formOpts.autoRender, 51 | autoAppend: formOpts.autoAppend, 52 | fieldContainerEl: formOpts.fieldContainerEl, 53 | el: this.queryByHook('test-form'), 54 | model: this.model, 55 | values: { 56 | text: 'Overriden value' 57 | } 58 | }); 59 | this.registerSubview(this.form); 60 | return this; 61 | } 62 | }); 63 | 64 | var view = new View({ 65 | model: new Model() 66 | }); 67 | 68 | return view.render(); 69 | }; 70 | 71 | test('reset', function (t) { 72 | var view = getView({ form: { autoRender: true } }); 73 | 74 | view.form._fieldViewsArray.forEach(function (field) { 75 | field.input.value = 'New value'; 76 | }); 77 | view.form.reset(); 78 | view.form._fieldViewsArray.forEach(function (field) { 79 | var input = field.input; 80 | if (isCheckbox(input)) { 81 | return; 82 | } 83 | t.equal(input.value, 'Original value', input.name + ' field value should be original value'); 84 | }); 85 | 86 | t.end(); 87 | }); 88 | 89 | test('clear', function (t) { 90 | var view = getView({ form: { autoRender: true } }); 91 | 92 | view.form._fieldViewsArray.forEach(function (field) { 93 | if (isCheckbox(field.input)) { 94 | field.setValue(true); 95 | } else { 96 | field.setValue('New value'); 97 | } 98 | 99 | }); 100 | view.form.clear(); 101 | view.form._fieldViewsArray.forEach(function (field) { 102 | var input = field.input; 103 | if (isCheckbox(input)) { 104 | t.equal(field.value, false, input.name + ' field value should be unchecked'); 105 | } else { 106 | t.equal(field.value, '', input.name + ' field value should be empty'); 107 | } 108 | }); 109 | 110 | t.end(); 111 | }); 112 | 113 | test('autoRender', function (t) { 114 | var view = getView({ form: { autoRender: false } }); 115 | var form = view.form; 116 | t.ok(!form.rendered, 'form did not autoRender'); 117 | t.ok(!form._fieldViewsArray[0].rendered, 'form field did not autoRender'); 118 | t.end(); 119 | }); 120 | 121 | test('do not render twice', function (t) { 122 | var view = getView({ form: { autoRender: true } }); 123 | var form = view.form; 124 | 125 | var fieldViewElsCopy = form._fieldViewsArray.map(function(field) { 126 | return field.el; 127 | }); 128 | 129 | t.ok(form.rendered, 'form did render'); 130 | //try rendering again 131 | form.render(); 132 | //confirm that field view elements are the same 133 | form._fieldViewsArray.every(function(field, i) { 134 | t.equal(fieldViewElsCopy[i], field.el); 135 | }); 136 | 137 | t.end(); 138 | }); 139 | 140 | test('remove', function (t) { 141 | var view = getView({ form: { autoRender: true } }); 142 | var form = view.form; 143 | t.ok(form.rendered, 'form did render'); 144 | 145 | form.remove(); 146 | 147 | t.ok(!form.rendered, 'form removed'); 148 | 149 | form._fieldViewsArray.forEach(function (field) { 150 | t.ok(!field.rendered, 'field removed'); 151 | }); 152 | 153 | t.end(); 154 | }); 155 | 156 | test('removeField', function (t) { 157 | var view = getView({ form: { autoRender: true } }); 158 | var form = view.form; 159 | t.ok(form.rendered, 'form did render'); 160 | var textField = form.getField('text'); 161 | t.ok(textField); 162 | t.ok(form._fieldViews.text); 163 | t.ok(textField.rendered, 'text field did render'); 164 | 165 | form.removeField('foobar');//noop, but tests that it doesn't break 166 | form.removeField('text'); 167 | 168 | t.ok(!textField.rendered, 'text field did get removed'); 169 | t.equal(form._fieldViewsArray.length, 2); 170 | t.ok(!form._fieldViews.text); 171 | 172 | t.end(); 173 | }); 174 | 175 | test('setValues', function(t) { 176 | var view = getView({ form: { autoRender: true }}); 177 | var form = view.form; 178 | var checkField = form.getField('check'); 179 | var newCheckValue = !checkField.value; 180 | var textField = form.getField('text'); 181 | var newTextValue = 'newTextValue'; 182 | 183 | t.equal(textField.value, 'Overriden value', 'setValues() updates fields on construction'); 184 | 185 | form.setValues({ 186 | check: newCheckValue, 187 | text: newTextValue 188 | }); 189 | 190 | t.equal(checkField.value, newCheckValue, 'setValues() updates field [input type="checkbox"]'); 191 | t.equal(textField.value, newTextValue, 'setValues() updates field [input type="text"]'); 192 | t.end(); 193 | }); 194 | 195 | test('field value setter/getter', function(t) { 196 | var view = getView({ form: { autoRender: true }}); 197 | 198 | t.throws( 199 | function() { view.form.getValue('invalidField'); }, 200 | 'getValue() errors when no field present' 201 | ); 202 | t.throws( 203 | function() { view.form.setValue('invalidField', 1); }, 204 | 'setValue() errors when no field present' 205 | ); 206 | 207 | t.equal(view.form.getValue('textarea'), 'Original value', 'getValue() extracts value from provided field'); 208 | view.form.setValue('textarea', 'newValue'); 209 | t.equal(view.form.getValue('textarea'), 'newValue', 'setValue() sets value on provided field'); 210 | t.end(); 211 | }); 212 | 213 | test('autoAppend === false', function(t) { 214 | var view = getView({ form: { autoRender: true, autoAppend: false }}); 215 | 216 | t.equal(view.form.el.children.length, 0); 217 | 218 | t.ok(view.form._fieldViewsArray.length, 3); 219 | 220 | view.form._fieldViewsArray.every(function(field) { 221 | t.ok(field.rendered); 222 | }); 223 | 224 | t.end(); 225 | }); 226 | 227 | test('default fieldContainerEl', function(t) { 228 | var view = getView({ 229 | form: { 230 | autoRender: true 231 | }, 232 | template: '
' 233 | }); 234 | 235 | var fieldContainer = view.form.queryByHook('field-container'); 236 | 237 | t.equal(view.form.el.children.length, 1); 238 | t.ok(fieldContainer); 239 | t.equal(fieldContainer.children.length, 3); 240 | 241 | t.end(); 242 | }); 243 | 244 | test('custom fieldContainerEl', function(t) { 245 | var view = getView({ 246 | form: { 247 | autoRender: true, 248 | fieldContainerEl: '[data-hook=foobar-container]' 249 | }, 250 | template: '
' 251 | }); 252 | 253 | var fieldContainer = view.form.queryByHook('foobar-container'); 254 | 255 | t.equal(view.form.el.children.length, 1); 256 | t.ok(fieldContainer); 257 | t.equal(fieldContainer.children.length, 3); 258 | 259 | t.end(); 260 | }); 261 | 262 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ampersand-form-view 2 | 3 | Lead Maintainer: [Michael Garvin](https://github.com/wraithgar) 4 | 5 | # overview 6 | 7 | ampersand-form-view is a wrapper view for easily building complex forms on the clientside with awesome clientside validation and UX. 8 | 9 | It would work quite well with backbone apps or anything else really, it has no external dependencies. 10 | 11 | At a high level, the way it works is you define a view object (by making an object that following the simple view conventions of ampersand). 12 | 13 | That form can be given an array of field views. 14 | 15 | These fields are also views but just follow a few more conventions in order to be able to work with our form view. 16 | 17 | Those rules are as follows: 18 | 19 | - It maintains a `value` property that is the current value of the field. 20 | - It should also store a `value` property if passed in as part of the config/options object when the view is created. 21 | - It maintains a `valid` property that is a boolean. The parent form checks this to know whether it can submit the form or not. 22 | - It has a `name` property that is a string of the name of the field. 23 | - It reports changes to its parent when it deems appropriate by calling `this.parent.update(this)` **note that it passes itsef to the parent. You would typically do this when the `this.value` has changed or the `this.valid` has changed. 24 | - When rendered by a form-view, the form view creates a `parent` property that is a reference to the containing form view. 25 | - It can optionally also define a `beforeSubmit` method. This gets called by the parent if it exists. This can be useful for stuff like a required text input that you don't want to show an error for if empty until the user tries to submit the form. 26 | 27 | 28 | ## install 29 | 30 | ``` 31 | npm install ampersand-form-view 32 | ``` 33 | 34 | ## Example: Defining a form view 35 | 36 | Here's how you might draw a form view as a subview. 37 | 38 | ```javascript 39 | // we'll just use an ampersand-view here as an 40 | // example parent view 41 | var View = require('ampersand-view'); 42 | var FormView = require('ampersand-form-view'); 43 | var InputView = require('ampersand-input-view'); 44 | 45 | var AwesomeFormView = View.extend({ 46 | template: '

App form

', 47 | render: function () { 48 | this.renderWithTemplate(); 49 | this.form = new FormView({ 50 | autoRender: true, 51 | el: this.queryByHook('app-edit-form'), 52 | submitCallback: function (obj) { 53 | console.log('form submitted! Your data:', obj); 54 | }, 55 | // this valid callback gets called (if it exists) 56 | // when the form first loads and any time the form 57 | // changes from valid to invalid or vice versa. 58 | // You might use this to disable the "submit" button 59 | // any time the form is invalid, for example. 60 | validCallback: function (valid) { 61 | if (valid) { 62 | console.log('The form is valid!'); 63 | } else { 64 | console.log('The form is not valid!'); 65 | } 66 | }, 67 | // This is just an array of field views that follow 68 | // the rules described above. I'm using an input-view 69 | // here, but again, *this could be anything* you would 70 | // pass it whatever config items needed to instantiate 71 | // the field view you made. 72 | fields: [ 73 | new InputView({ 74 | name: 'client_name', 75 | label: 'App Name', 76 | placeholder: 'My Awesome App', 77 | // an initial value if it has one 78 | value: 'hello', 79 | // this one takes an array of tests 80 | tests: [ 81 | function (val) { 82 | if (val.length < 5) return "Must be 5+ characters."; 83 | } 84 | ] 85 | }) 86 | ], 87 | // optional initial form values specified by 88 | // {"field-name": "value"} pairs. Overwrites default 89 | // `value`s provided in your FieldView constructors, only 90 | // after the form is rendered. You can set form values 91 | // in bulk after the form is rendered using setValues(). 92 | values: { 93 | client_name: 'overrides "hello" from above' 94 | } 95 | }); 96 | 97 | // registering the form view as a subview ensures that 98 | // its `remove` method will get called when the parent 99 | // view is removed. 100 | this.registerSubview(this.form); 101 | } 102 | }); 103 | 104 | var awesomeFormView = new AwesomeFormView(); 105 | awesomeFormView.render(); 106 | ``` 107 | 108 | ## FormView Options `FormView.extend(options)` 109 | Standard view conventions apply, with the following options added: 110 | * `autoRender` : boolean (default: true) 111 | * Render the form immediately on construction. 112 | * `autoAppend` : boolean (default: true) 113 | * Adds new nodes for all fields defined in the `fields` array. Use `autoAppend: false` in conjuction with `el: yourElement` in order to use your own form layout. 114 | * `fields` : array 115 | * Array of `FieldView`s. If `autoAppend` is true, nodes defined by the view are built and appended to the end of the FormView. 116 | * `submitCallback` : function 117 | * Called on form submit 118 | * `validCallback` : function 119 | * This valid callback gets called (if it exists) when the form first loads and any time the form changes from valid to invalid or vice versa. You might use this to disable the "submit" button any time the form is invalid, for example. 120 | * `clean` : function 121 | * Lets you provide a function which will clean or modify what is returned by `getData` and passed to `submitCallback`. 122 | * `fieldContainerEl` : Element | string 123 | * Container to append fields to (when `autoAppend` is `true`). 124 | * This can either be a DOM element or a CSS selector string. If a string is passed, ampersand-view runs `this.query('YOUR STRING')` to try to find the element that should contain the fields. If you don't supply a `fieldContainerEl`, it will first try to find an element with the selector `'[data-hook~=field-container]'`. If no element for appending fields to is found, it will fallback to `this.el`. 125 | 126 | ======= 127 | ## API Reference 128 | 129 | ### setValue `formView.setValue(name, value)` 130 | 131 | Sets the provided value on the field matching the provided name. Throws when invalid field name specified. 132 | 133 | 134 | ### getValue `formView.getValue(name)` 135 | 136 | Gets the value from the associated field matching the provided name. Throws when invalid field name specified. 137 | 138 | ### setValues `formView.setValues([values])` 139 | 140 | For each key corresponding to a field's `name` found in `values`, the corresponding `value` will be set onto the FieldView. Executes when the the formView is **rendered**. 141 | 142 | ```js 143 | myForm = new FormView({ 144 | fields: function() { 145 | return [ 146 | new CheckboxView({ 147 | name: 'startsTrue', 148 | value: true 149 | }), 150 | new CheckboxView({ 151 | name: 'startsFalse', 152 | value: false 153 | }), 154 | ]; 155 | } 156 | }); 157 | myForm.render(); 158 | 159 | // bulk update form values 160 | myForm.setValues({ 161 | startsTrue: true, //=> no change 162 | startsFalse: true //=> becomes true 163 | }); 164 | ``` 165 | 166 | ### reset `formView.reset()` 167 | 168 | Calls reset on all fields in the form that have the method. Intended to be used to set form back to original state. 169 | 170 | ### clear `formView.clear()` 171 | 172 | Calls clear on all fields in the form that have the method. Intended to be used to clear out the contents of the form. 173 | 174 | ## Properties 175 | The following are FormView observables, thus emit "change" events: 176 | 177 | - `valid` - the valid state of the form 178 | - `data` - form field view values in `{ fieldName: value, fieldName2: value2 }` format 179 | 180 | ## Verbose forms 181 | 182 | For verbose forms used to edit nested data, you can write field names as paths. Doing so, the `data` observable is nested according to the paths you specified so you can `set` or `save` this data to a state or collection more easily. 183 | 184 | ### Example 185 | A form with a persons first and last name and an array of phone numbers, each of which has fields for *type* and *number*: 186 | 187 | ```javascript 188 | var form = new FormView({ 189 | fields: [ 190 | new InputView({name: 'name.first', value: 'Michael'}), 191 | new InputView({name: 'name.last', value: 'Mustermann'}), 192 | new InputView({name: 'phone[0].type', value: 'home'}), 193 | new InputView({name: 'phone[0].number', value: '1234567'}), 194 | new InputView({name: 'phone[1].type', value: 'mobile'}), 195 | new InputView({name: 'phone[1].number', value: '7654321'}) 196 | ] 197 | }); 198 | 199 | console.log(form.data); 200 | // { 201 | // name: {first: 'Michael', last: 'Mustermann'}, 202 | // phone: [ 203 | // {type: 'home', number: '1234567'}, 204 | // {type: 'mobile', number: '7654321'} 205 | // ] 206 | // } 207 | ``` 208 | 209 | 210 | 211 | ## Special Events 212 | 213 | - `submit` - triggered when a form is submitted. Returns the `data` of the form as the only argument 214 | 215 | ## Changelog 216 | 217 | ## changelog 218 | - 6.0.0 - Upgrade to &-view 9.x 219 | - 5.1.0 - Add `submit` and `valid` events 220 | - 5.0.0 - Extend `ampersand-view` to add state, adds `setValues()`, `setValue()`, & `getValue()`. Change to not render() during construction by default. 221 | - 4.0.0 - (skipped) 222 | - 3.0.0 - Initialize prior to render, and permit `autoRender: false` 223 | - 2.2.3 - Adding `reset`. Starting in on building API reference. 224 | 225 | ## credits 226 | 227 | Created by [@HenrikJoreteg](http://twitter.com/henrikjoreteg) 228 | 229 | 230 | 231 | ## license 232 | 233 | MIT 234 | 235 | --------------------------------------------------------------------------------