UNPKG

angular-formly

Version:

AngularJS directive which takes JSON representing a form and renders to HTML

1,581 lines (1,365 loc) 69.6 kB
/* eslint no-shadow:0 */ /* eslint max-statements:[2, 50] */ /* eslint max-len:0 */ import angular from 'angular-fix' import apiCheck from 'api-check' import testUtils from '../test.utils.js' import _ from 'lodash' const {getNewField, input, basicForm, multiNgModelField, shouldWarn, shouldNotWarn} = testUtils describe('formly-field', function() { /* jshint maxstatements:100 */ /* jshint maxlen:300 */ let $compile, scope, el, node, formlyConfig, $q, isolateScope, field, $timeout beforeEach(window.module('formly')) beforeEach(inject((_$compile_, $rootScope, _formlyConfig_, _$q_, _$timeout_) => { $compile = _$compile_ scope = $rootScope.$new() formlyConfig = _formlyConfig_ $q = _$q_ $timeout = _$timeout_ })) describe('with template wrapper', function() { beforeEach(() => { formlyConfig.setWrapper([ { types: 'text', template: ` <div class="my-template-wrapper"> <label for="{{id}}">{{options.label}}</label> <formly-transclude></formly-transclude> </div> `, }, { types: 'other', template: ` <div class="my-other-template-wrapper"> <formly-transclude></formly-transclude> <div> This is great for ng-messages </div> </div> `, }, ]) formlyConfig.setType({ name: 'text', template: `<input name="{{id}}" ng-model="model[options.key]" />`, }) scope.model = {} }) it('should take the entire wrapper, not just the contents of the wrapper', function() { scope.fields = [ { type: 'text', key: 'text', templateOptions: { label: 'Text input', }, }, ] const el = compileAndDigest() expect(el[0].querySelector('.my-template-wrapper')).to.exist }) it('should wrap arrays of wrappers', () => { scope.fields = [ { type: 'text', key: 'text', wrapper: ['text', 'other'], templateOptions: { label: 'Text input', }, }, ] const el = compileAndDigest() const outerEl = el[0].querySelector('.my-other-template-wrapper') expect(outerEl).to.exist expect(outerEl.querySelector('.my-template-wrapper')).to.exist }) it(`should allow for specifying null for the wrappers of a field`, () => { scope.fields = [ { type: 'text', key: 'text', wrapper: null, templateOptions: { label: 'Text input', }, }, ] const el = compileAndDigest() expect(el[0].querySelector('.my-template-wrapper')).to.not.exist }) }) describe('api check', () => { beforeEach(() => { /* eslint no-console:0 */ const originalWarn = console.warn console.warn = () => {} formlyConfig.setType({ name: 'text', template: `<input name="{{id}}" ng-model="model[options.key]" />`, }) scope.model = {} console.warn = originalWarn }) it('should throw an error when a field has extra properties', () => { scope.fields = [ { type: 'text', extraProp: 'whatever', }, ] expect(() => compileAndDigest()).to.throw(/extra.*properties.*extraProp/) }) }) describe('default type options', () => { beforeEach(() => { scope.model = {} formlyConfig.setType({ name: 'ipAddress', template: '<input name="{{id}}" ng-model="model[options.key]" />', defaultOptions: { data: { usingDefaultOptions: true, }, validators: { ipAddress: function(viewValue, modelValue) { const value = modelValue || viewValue return /(\d{1,3}\.){3}\d{1,3}/.test(value) }, }, }, }) formlyConfig.setType({ name: 'text', template: '<input name="{{id}}" ng-model="model[options.key]" />', defaultOptions: { data: { hasPropertiesFromTextType: true, }, }, }) formlyConfig.setType({ name: 'phone', defaultOptions: { ngModelAttrs: { '/^1[2-9]\\d{2}[2-9]\\d{6}$/': { value: 'ng-pattern', }, }, }, }) formlyConfig.setType({ name: 'required', defaultOptions: { ngModelAttrs: { '/overwriting stuff is fun for tests/': { value: 'ng-pattern', }, required: { bound: 'ng-required', attribute: 'required', }, myChange: { statement: 'ng-change', }, }, templateOptions: { required: true, }, }, }) }) it('should default to the ipAddress type options', () => { const field = {type: 'ipAddress'} scope.fields = [field] compileAndDigest() expect(field.data.usingDefaultOptions).to.be.true expect(field.validators.ipAddress).to.be.a('function') }) it('should be possible to specify defaultOptions-only types (non-template types)', () => { const field = { type: 'text', optionsTypes: ['phone', 'required'], templateOptions: {myChange: 'model.otherThing = true'}, } scope.fields = [field] const el = compileAndDigest() const input = el.find('input') expect(field.data.hasPropertiesFromTextType).to.be.true expect(input.attr('ng-pattern')).to.equal('/overwriting stuff is fun for tests/') expect(input.attr('ng-change')).to.contain('myChange') }) }) describe('templateManipulators', () => { testTemplateManipulators(true) testTemplateManipulators(false) function testTemplateManipulators(isPre) { describe(isPre ? 'preWrapper' : 'postWrapper', () => { let manipulators const textTemplate = '<input class="text-template" name="{{id}}" ng-model="model[options.key]">' beforeEach(() => { manipulators = formlyConfig.templateManipulators[isPre ? 'preWrapper' : 'postWrapper'] formlyConfig.setWrapper([ { types: 'text', template: '<div class="my-template-wrapper"><formly-transclude></formly-transclude></div>', }, ]) formlyConfig.setType({ name: 'text', template: textTemplate, }) scope.model = {} scope.fields = [ {type: 'text'}, ] }) const when = isPre ? 'before' : 'after' it(`should call the manipulators when compiling a field ${when} the element is wrapped in wrappers`, () => { let manipulatedTemplate manipulators.push((templateToManipulate, fieldOptions, scope) => { if (isPre) { expect(templateToManipulate).to.contain('text-template') } expect(fieldOptions).to.equal(scope.fields[0]) expect(scope.options).to.equal(fieldOptions) if (isPre) { expect(templateToManipulate).to.not.contain('my-template-wrapper') } else { expect(templateToManipulate).to.contain('my-template-wrapper') } manipulatedTemplate = angular.element(templateToManipulate).addClass('manipulated') return manipulatedTemplate }) manipulators.push((templateToManipulate, fieldOptions, scope) => { if (isPre) { expect(asHtml(manipulatedTemplate)).to.equal(templateToManipulate) } expect(fieldOptions).to.equal(scope.fields[0]) expect(scope.options).to.equal(fieldOptions) if (isPre) { expect(templateToManipulate).to.not.contain('my-template-wrapper') } else { expect(templateToManipulate).to.contain('my-template-wrapper') } expect(templateToManipulate).to.contain('manipulated') return angular.element(templateToManipulate).addClass('manipulated-twice') }) compileAndDigest() scope.$digest() expect(el[0].querySelector('.manipulated')).to.exist expect(el[0].querySelector('.manipulated-twice')).to.exist function asHtml(el) { return angular.element('<a></a>').append(el).html() } }) }) } }) describe('type controllers and link functions', () => { let controllerFn, linkFn beforeEach(() => { controllerFn = function($scope) { $scope.setInTypeController = true } linkFn = function(scope, el, attrs) { scope.setInTypeLink = true scope.el = el scope.attrs = attrs } formlyConfig.setType({ name: 'text', template: `<input name="{{id}}" ng-model="model[options.key]" />`, controller: ['$scope', controllerFn], link: linkFn, }) scope.model = {} }) it('should run the controller function of a type', () => { scope.fields = [ {type: 'text'}, ] const el = compileAndDigest() const fieldEl = angular.element(el[0].querySelector('.formly-field')) expect(fieldEl.isolateScope().setInTypeController).to.be.true }) it('should run the link function of a type', () => { scope.fields = [ {type: 'text'}, ] const el = compileAndDigest() const fieldEl = angular.element(el[0].querySelector('.formly-field')) const fieldScope = fieldEl.isolateScope() expect(fieldScope.setInTypeLink).to.be.true expect(fieldScope.el[0]).to.equal(fieldEl[0]) }) it('should run the controller of the specific field', () => { scope.fields = [ {template: 'sweet mercy', controller: ['$scope', controllerFn]}, ] const el = compileAndDigest() const fieldEl = angular.element(el[0].querySelector('.formly-field')) expect(fieldEl.isolateScope().setInTypeController).to.be.true }) it('should run the link function of a type', () => { scope.fields = [ {template: 'sweet mercy', link: linkFn}, ] const el = compileAndDigest() const fieldEl = angular.element(el[0].querySelector('.formly-field')) const fieldScope = fieldEl.isolateScope() expect(fieldScope.setInTypeLink).to.be.true expect(fieldScope.el[0]).to.equal(fieldEl[0]) }) }) describe(`template and templateUrl properties`, () => { let $templateCache const expectedTemplateText = 'sweet mercy' beforeEach(inject((_$templateCache_) => { $templateCache = _$templateCache_ $templateCache.put('templateUrlTest.html', expectedTemplateText) })) it('should allow template property to be a function', () => { scope.fields = [ { template: function(options) { expect(options).to.eq(scope.fields[0]) return expectedTemplateText }, }, ] const el = compileAndDigest() const fieldEl = angular.element(el[0].querySelector('.formly-field')) expect(fieldEl.text()).to.equal(expectedTemplateText) }) it(`should allow template property to be a function that returns a promise`, () => { scope.fields = [ { template: function(options) { expect(options).to.eq(scope.fields[0]) return $q.when(expectedTemplateText) }, }, ] const el = compileAndDigest() const fieldEl = angular.element(el[0].querySelector('.formly-field')) expect(fieldEl.text()).to.equal(expectedTemplateText) }) it('should allow template property to be a string', () => { scope.fields = [ {template: expectedTemplateText}, ] const el = compileAndDigest() const fieldEl = angular.element(el[0].querySelector('.formly-field')) expect(fieldEl.text()).to.equal(expectedTemplateText) }) it('should allow template property to be an empty string', () => { scope.fields = [ {template: ''}, ] const el = compileAndDigest() const fieldEl = angular.element(el[0].querySelector('.formly-field')) expect(fieldEl.text()).to.equal('') }) it('should allow templateUrl property to be a function', () => { scope.fields = [ { templateUrl: function(options) { expect(options).to.eq(scope.fields[0]) return 'templateUrlTest.html' }, }, ] const el = compileAndDigest() const fieldEl = angular.element(el[0].querySelector('.formly-field')) expect(fieldEl.text()).to.equal(expectedTemplateText) }) it('should allow templateUrl property to be a function that returns a promise', () => { scope.fields = [ { templateUrl: function(options) { expect(options).to.eq(scope.fields[0]) return $q.when('templateUrlTest.html') }, }, ] const el = compileAndDigest() const fieldEl = angular.element(el[0].querySelector('.formly-field')) expect(fieldEl.text()).to.equal(expectedTemplateText) }) it('should allow templateUrl property to be a string', () => { scope.fields = [ {templateUrl: 'templateUrlTest.html'}, ] const el = compileAndDigest() const fieldEl = angular.element(el[0].querySelector('.formly-field')) expect(fieldEl.text()).to.equal(expectedTemplateText) }) }) describe(`defaultValue`, () => { const key = 'foo' const defaultValue = '~=[,,_,,]:3' beforeEach(() => { scope.fields = [ {template: input, key, defaultValue}, ] scope.model = {} }) it(`should default the model's value to the specified value if it is not defined`, () => { compileAndDigest() expect(scope.model[key]).to.equal(defaultValue) }) it(`should not have a problem if the model starts out as undefined`, () => { scope.model = undefined compileAndDigest() expect(scope.model[key]).to.equal(defaultValue) }) it(`should not change the model's value if the specified value is defined`, () => { const presetValue = 'ಠ_ರೃ' scope.model[key] = presetValue compileAndDigest() expect(scope.model[key]).to.equal(presetValue) }) it(`should be exactly equal to a non-primative`, () => { const complexDefaultValue = {foo: 'bar'} scope.fields[0].defaultValue = complexDefaultValue compileAndDigest() expect(scope.model[key]).to.eq(complexDefaultValue) }) it(`should be set even if the defaultValue is falsy`, () => { const falsyValue = 0 scope.fields[0].defaultValue = falsyValue compileAndDigest() expect(scope.model[key]).to.eq(falsyValue) }) it(`should be set as the initialValue`, () => { compileAndDigest() expect(scope.fields[0].initialValue).to.eq(defaultValue) }) it(`should be set if the key is 0`, () => { scope.fields[0].key = 0 compileAndDigest() expect(scope.fields[0].initialValue).to.eq(defaultValue) }) describe(`nested keys`, () => { const nestedObject = 'foo.bar' const nestedArray = 'baz[0]' beforeEach(() => { const firstField = scope.fields[0] firstField.key = nestedObject const secondField = {template: input, key: nestedArray, defaultValue} scope.fields.push(secondField) }) it(`should set the default value for nested keys`, () => { compileAndDigest() expect(scope.model.foo.bar).to.equal(defaultValue) expect(scope.model.baz[0]).to.equal(defaultValue) }) }) }) describe('getterSetters', () => { it('should get and set values when key is all alpha', () => { const key = 'foo' const defaultValue = 'bar' scope.fields = [ {template: input, key, defaultValue}, ] scope.model = {} compileAndDigest() expect(scope.model[key]).to.eq(defaultValue) }) it('should get and set values when key is all numeric', () => { const key = '1333' const defaultValue = 'bar' scope.fields = [ {template: input, key, defaultValue}, ] scope.model = {} compileAndDigest() expect(scope.model[key]).to.eq(defaultValue) }) it('should get and set values when key is 0', () => { const key = 0 const defaultValue = 'bar' scope.fields = [ {template: input, key, defaultValue}, ] scope.model = {} compileAndDigest() expect(scope.model[key]).to.eq(defaultValue) }) it('should handle arrays properly when formlyConfig.extras.parseKeyArrays is set', () => { const key = 'foo[0]' const defaultValue = 'bar' formlyConfig.extras.parseKeyArrays = true scope.fields = [ {template: input, key, defaultValue}, ] scope.model = {} compileAndDigest() expect(scope.model.foo).to.be.instanceof(Array) }) it('should get and set values when key is alpha numeric with alpha first', () => { const key = 'A1' const defaultValue = 'bar' scope.fields = [ {template: input, key, defaultValue}, ] scope.model = {} compileAndDigest() expect(scope.model[key]).to.eq(defaultValue) }) it('should get and set values when key is alpha numeric with numeric first', () => { const key = '1A' const defaultValue = 'bar' scope.fields = [ {template: input, key, defaultValue}, ] scope.model = {} compileAndDigest() expect(scope.model[key]).to.eq(defaultValue) }) it('should work with dashes in the key', () => { const key = 'address-1st-line' const defaultValue = 'baz' scope.fields = [ {template: input, key, defaultValue}, ] scope.model = {} compileAndDigest() expect(scope.model[key]).to.eq(defaultValue) }) it('should work with dashes and numerics in the key', () => { const key = 'b141c66a-2857-4196-847b-b2096fa6170d' const defaultValue = 'baz' scope.fields = [ {template: input, key, defaultValue}, ] scope.model = {} compileAndDigest() expect(scope.model[key]).to.eq(defaultValue) }) it('should work with nested keys with numbers in the key', () => { const key = 'foo3bar.baz4foobar' const defaultValue = 'baz' scope.fields = [ {template: input, key, defaultValue}, ] scope.model = {} compileAndDigest() expect(scope.model.foo3bar.baz4foobar).to.eq(defaultValue) }) }) describe(`id property`, () => { it(`should default to a semi-random id that you cannot rely on and don't have to think about`, () => { scope.fields = [getNewField()] compileAndDigest() const fieldNode = getFieldNgModelNode() expect(field.id).to.eq(fieldNode.id) expect(fieldNode.id).to.match(/^formly_\d+_template_\d+_\d+$/) expect(fieldNode.id).to.eq(fieldNode.getAttribute('name')) }) it(`should allow you to specify a custom id if you want to`, () => { scope.fields = [getNewField({id: 'ᕕ( ᐛ )ᕗ'})] compileAndDigest() const fieldNode = getFieldNgModelNode() expect(field.id).to.eq(fieldNode.id) expect(fieldNode.id).to.eq('ᕕ( ᐛ )ᕗ') expect(fieldNode.id).to.eq(fieldNode.getAttribute('name')) }) }) describe(`modelOptions property`, () => { it(`should be able to handle modelOptions with debouce as a number`, () => { scope.fields = [getNewField({modelOptions: {debounce: 500}})] compileAndDigest() const fieldNode = getFieldNgModelNode() expect(fieldNode.getAttribute('ng-model-options')).to.exist }) it(`should be able to compile modelOptions with debounce as an object of numbers`, () => { scope.fields = [getNewField({modelOptions: {debounce: {blur: 500, default: 0}}})] compileAndDigest() const fieldNode = getFieldNgModelNode() expect(fieldNode.getAttribute('ng-model-options')).to.exist }) it(`should throw an error when modelOptions with debounce as a string`, () => { scope.fields = [getNewField({modelOptions: {debounce: 'foo'}})] expect(() => compileAndDigest()).to.throw() }) it(`should throw an error when modelOptions with debounce as an object of strings`, () => { scope.fields = [getNewField({modelOptions: {debounce: {blur: 'foo', default: 'bar'}}})] expect(() => compileAndDigest()).to.throw() }) describe(`value function`, () => { let value it(`should be overrideable via value option`, () => { compileDigestAndSetValueFunction({value: customGetterSetter}) expect(value).to.eq(customGetterSetter) function customGetterSetter() { } }) it(`should be a getter/setter`, () => { compileDigestAndSetValueFunction() expect(value()).to.eq(undefined) expect(value('foo')).to.eq('foo') expect(value()).to.eq('foo') }) it(`should not throw an error when the model is undefined`, inject(($rootScope) => { const formlyField = `<div formly-field options="field" model="model"> </div>` scope = $rootScope.$new() _.assign(scope, { field: getNewField(), model: undefined, // <-- this is the key }) el = $compile(formlyField)(scope) scope.$digest() expect(() => scope.field.value()).to.not.throw() })) function compileDigestAndSetValueFunction(fieldOverrides) { scope.fields = [getNewField(_.merge({modelOptions: {getterSetter: true}}, fieldOverrides))] compileAndDigest() value = getIsolateScope().options.value } }) }) describe(`type apiCheck`, () => { let inputType const type = 'input' beforeEach(() => { inputType = formlyConfig.setType({ name: type, template: '<label>{{to.label}}</label><input class="{{to.className}}" ng-model="model[options.key]" />', apiCheck(check) { return { templateOptions: { label: check.string, className: check.string, }, } }, apiCheckInstance: apiCheck({ output: {prefix: 'custom-api-check'}, }), apiCheckOptions: { url: 'http://example.com/some-custom-url', }, }) scope.model = {} }) it(`should default to the built-in formlyApiCheck`, inject((formlyApiCheck) => { const type = formlyConfig.setType({ name: 'someOtherType', template: '<hr />', apiCheck: sinon.spy(), }) scope.fields = [{type: 'someOtherType'}] compileAndDigest() expect(type.apiCheck).to.have.been.calledWith(formlyApiCheck) })) it(`should not warn if everything's fine`, () => { scope.fields = [ {type, templateOptions: {label: 'string', className: 'string'}}, ] shouldNotWarn(compileAndDigest) }) it(`should warn if everything's not fine`, () => { scope.fields = [ {type, templateOptions: {label: 'string'}}, ] shouldWarn( /custom-api-check formly-field type input for property templateOptions apiCheck failed.*?className.*?some-custom-url/, compileAndDigest ) }) it(`should throw if the apiCheckFunction is set to "throw" and everything's not fine`, () => { formlyConfig.getType(type).apiCheckFunction = 'throw' scope.fields = [ {type, templateOptions: {label: 'string'}}, ] expect(compileAndDigest).to.throw( /custom-api-check formly-field type input for property templateOptions apiCheck failed.*?.className.*?some-custom-url/ ) }) it(`should work with wrappers as well`, () => { formlyConfig.setWrapper({ name: 'mywrapper', template: 'wrapped!<formly-transclude></formly-transclude>wrapped!', apiCheck(check) { return { templateOptions: { foo: check.bool, }, } }, apiCheckInstance: apiCheck({output: {prefix: 'my own'}}), apiCheckOptions: {prefix: 'options prefix'}, }) scope.fields = [ {type, wrapper: 'mywrapper', templateOptions: {label: 'string', className: 'string'}}, ] shouldWarn( /my own options prefix apiCheck failed/, compileAndDigest ) }) describe(`apiCheckInstance`, () => { describe(`disabled`, () => { it(`should not do anything if the given instance is disabled`, () => { inputType.apiCheckInstance.config.disabled = true scope.fields = [ {type, templateOptions: {label: 'string'}}, ] shouldNotWarn(compileAndDigest) }) it(`should not do anything if no instance is provided and the formly instance is disabled`, inject((formlyApiCheck) => { formlyApiCheck.config.disabled = true formlyConfig.setType({ name: 'someOtherType', template: '<hr />', apiCheck: checker => ({data: {foo: checker.bool}}), }) scope.fields = [{type: 'someOtherType'}] shouldNotWarn(compileAndDigest) formlyApiCheck.config.disabled = false })) it(`should not do anything if the global instance is disabled`, () => { apiCheck.globalConfig.disabled = true scope.fields = [ {type, templateOptions: {label: 'string'}}, ] shouldNotWarn(compileAndDigest) apiCheck.globalConfig.disabled = false }) }) describe(`formlyConfig.extras.apiCheckInstance`, () => { it(`should default to this instance when specified and no specific type instance is specified`, () => { const globalApiCheckInstance = apiCheck({ output: {prefix: 'custom-api-check'}, }) const warnSpy = sinon.spy(globalApiCheckInstance, 'warn') formlyConfig.extras.apiCheckInstance = globalApiCheckInstance delete inputType.apiCheckInstance scope.fields = [ {type, templateOptions: {label: 'string', className: 'valid'}}, ] compileAndDigest() expect(warnSpy).to.have.been.calledOnce }) }) }) describe(`extended scenario`, () => { let childType, pristineOptions beforeEach(() => { sinon.spy(inputType, 'apiCheck') childType = formlyConfig.setType({ name: type + 'Child', extends: type, template: '<hr />', apiCheck(check) { return { data: { foo: check.string, }, } }, apiCheckFunction: 'throw', apiCheckInstance: apiCheck({ output: {suffix: 'my own'}, }), apiCheckOptions: {url: 'http://other-url.example.com', prefix: type + 'Child type checker'}, }) sinon.spy(childType, 'apiCheck') pristineOptions = {type: type + 'Child', templateOptions: {label: 'foo', className: 'bar'}, data: {foo: 'bar'}} }) it(`should pass if everything is ok`, () => { compileDigestAndMatchError() expect(childType.apiCheck).to.have.been.calledWith(childType.apiCheckInstance) expect(inputType.apiCheck).to.have.been.calledWith(inputType.apiCheckInstance) }) it(`should throw if the child has a problem`, () => { compileDigestAndMatchError( {data: {foo: false}}, /inputChild type checker apiCheck failed.*?`foo`.*?`String`.*?my own.*?other-url\.example\.com/ ) }) it(`should invoke the apiCheck for all extended types if an error is not thrown`, () => { childType.apiCheckFunction = 'warn' compileDigestAndMatchError() expect(childType.apiCheck).to.have.been.calledWith(childType.apiCheckInstance) expect(inputType.apiCheck).to.have.been.calledWith(inputType.apiCheckInstance) }) function compileDigestAndMatchError(fieldOverrides, error) { scope.fields = [_.merge(pristineOptions, fieldOverrides)] if (error) { expect(compileAndDigest).to.throw(error) } else { expect(compileAndDigest).to.not.throw() } } }) it(`should have good default options`, () => { formlyConfig.setType({ name: 'someType', template: '<hr />', apiCheck(check) { return { templateOptions: { label: check.string, className: check.string, }, } }, apiCheckInstance: apiCheck({ output: {prefix: 'custom-api-check'}, }), }) scope.fields = [ {type: 'someType', templateOptions: {label: 'string'}}, ] shouldWarn( /custom-api-check formly-field type someType for property templateOptions apiCheck failed.*?Required.*?className/, compileAndDigest ) }) }) describe(`wrapper apiCheck`, () => { const name = 'input' const wrapper = name beforeEach(() => { formlyConfig.setWrapper({ name, template: '<div class="to.className"><label>{{to.label}}</label><formly-transclude></formly-transclude></div>', apiCheck(check) { return { templateOptions: { label: check.string, className: check.string, }, } }, apiCheckInstance: apiCheck({ output: {prefix: 'custom-api-check'}, }), }) scope.model = {} scope.fields = [ {template: input, wrapper, templateOptions: {}}, ] }) it(`should not warn if everything's fine`, () => { scope.fields[0].templateOptions = {label: 'string', className: 'string'} shouldNotWarn(compileAndDigest) }) it(`should warn if everything's not fine`, () => { scope.fields[0].templateOptions = {label: 'string'} shouldWarn(/custom-api-check.*?formly-field(.|\n)*?className/, compileAndDigest) }) it(`should throw if the apiCheckFunction is set to "throw" and everything's not fine`, () => { formlyConfig.getWrapper(name).apiCheckFunction = 'throw' scope.fields[0].templateOptions = {label: 'string'} expect(compileAndDigest).to.throw(/custom-api-check.*?formly-field(.|\n)*?className/) }) }) describe(`formControl`, () => { beforeEach(() => { scope.fields = [{template: input}] }) it(`should be placed onto field's options`, () => { compileAndDigest() expect(field.formControl).to.exist }) it(`should be placed onto the isolate scope for the formly-field`, () => { compileAndDigest() expect(isolateScope.fc).to.exist }) it(`should add a formControl even on a field with an ng-if on the ng-model`, () => { const template = '<input ng-model="model[options.key]" ng-if="to.if" />' const field = {template, templateOptions: {if: false}} scope.fields = [field] compileAndDigest() expect(isolateScope.fc).to.not.exist field.templateOptions.if = true scope.$digest() expect(isolateScope.fc).to.exist }) it(`should be used to add the formControl watcher if set to false even if there is no ng-model`, () => { const radioTemplate = ` <div class="radio-group"> <div ng-repeat="(key, option) in to.options" class="radio"> <label> <input type="radio" id="{{id + '_'+ $index}}" tabindex="0" ng-value="option[to.valueProp || 'value']" ng-model="model[options.key]"> {{option[to.labelProp || 'name']}} </label> </div> </div> ` scope.fields = [ { template: radioTemplate, templateOptions: { options: [{name: 'Name', value: 'name'}], }, }, ] compileAndDigest() expect(isolateScope.fc).to.exist }) describe(`noFormControl`, () => { it(`should skip adding the formControl if set to true`, () => { scope.fields = [{template: input, noFormControl: true}] compileAndDigest() expect(isolateScope.fc).to.not.exist }) }) describe(`name`, () => { it(`should be almost random`, () => { compileAndDigest() expect(field.formControl.$name).to.match(/formly_\d+_template_.*?_\d+/) }) it(`should be overrideable when a different name is specified`, () => { scope.fields[0].template = `<input ng-model="model[options.key]" name="myCustomName" />` compileAndDigest() makeNameExpectations('myCustomName') }) it(`should handle interpolated names`, () => { scope.fields[0].template = `<input ng-model="model[options.key]" name="{{'myCustomName'}}" />` compileAndDigest() makeNameExpectations('myCustomName') }) function makeNameExpectations(name) { expect(field.formControl).to.exist expect(isolateScope.fc).to.exist expect(field.formControl.$name).to.eq(name) expect(scope.theForm).to.have.property(name) } }) describe(`multiple ng-models`, () => { it(`should be an array`, () => { scope.fields = [{ template: multiNgModelField, }] compileAndDigest() expect(isolateScope.fc).to.be.instanceof(Array) }) }) }) describe(`parsers/formatters`, () => { describe(`parsers`, () => { it(`should be merged in the right order`, () => { testParsersOrFormatters('parsers') }) it(`should handle a formlyExpression as a string`, () => { scope.fields = [getNewField({ key: 'myKey', parsers: ['$viewValue + options.data.extraThing'], data: {extraThing: ' boo!'}, })] compileAndDigest() const ctrl = getNgModelCtrl() expect(ctrl.$parsers).to.have.length(1) ctrl.$setViewValue('hello!') expect(scope.model.myKey).to.equal('hello! boo!') }) }) describe(`formatters`, () => { it(`should be merged in the right order`, () => { testParsersOrFormatters('formatters') }) it(`should handle a formlyExpression as a string`, () => { scope.fields = [getNewField({ key: 'myKey', formatters: ['$viewValue + options.data.extraThing'], data: {extraThing: ' boo!'}, })] compileAndDigest() scope.model.myKey = 'hello!' scope.$digest() const ctrl = getNgModelCtrl() expect(ctrl.$formatters).to.have.length(2) // ngModel adds one expect(ctrl.$viewValue).to.equal('hello! boo!') }) it(`should format a model value right from the start and the controller should still be pristine`, () => { scope.model = {myKey: 'hello'} scope.fields = [getNewField({ key: 'myKey', formatters: ['"!" + $viewValue + "!"'], })] compileAndDigest() const ctrl = getNgModelCtrl() expect(ctrl.$viewValue).to.equal('!hello!') expect(ctrl.$dirty).to.equal(false) expect(ctrl.$pristine).to.equal(true) }) it(`should format a model value on initilization and keep the form state dirty if it was already dirty`, () => { scope.model = {myKey: 'hello'} scope.fields = [getNewField({ key: 'myKey', formatters: ['"!" + $viewValue + "!"'], })] compileAndDigest() scope.theForm.$setDirty() const ctrl = getNgModelCtrl() expect(ctrl.$viewValue).to.equal('!hello!') expect(scope.theForm.$dirty).to.equal(true) }) it(`should format a model value on initilization and keep the form state pristine if it was already pristine`, () => { scope.model = {myKey: 'hello'} scope.fields = [getNewField({ key: 'myKey', formatters: ['"!" + $viewValue + "!"'], })] compileAndDigest() const ctrl = getNgModelCtrl() expect(ctrl.$viewValue).to.equal('!hello!') expect(scope.theForm.$pristine).to.equal(true) }) it.skip(`should handle multiple form controllers when formatting a model value right from the start`, () => { scope.model = { multiNgModel: { start: 'start', stop: 'stop', }, } const field = getNewField({ key: 'multiNgModel', template: multiNgModelField, formatters: ['"!" + $viewValue + "!"'], }) scope.fields = [field] compileAndDigest() const ctrl1 = field.formControl[0] const ctrl2 = field.formControl[1] expect(ctrl1.$viewValue).to.equal('!start!') expect(ctrl2.$viewValue).to.equal('!stop!') }) }) function testParsersOrFormatters(which) { let originalThingProp = 'originalParser' if (which === 'formatters') { originalThingProp = 'originalFormatter' } const parent1Thing1 = sinon.spy(function parent1Thing1() { }) const parent1Thing2 = sinon.spy(function parent1Thing2() { }) const parent2Thing1 = sinon.spy(function parent2Thing1() { }) const parent2Thing2 = sinon.spy(function parent2Thing2() { }) const childThing1 = sinon.spy(function childThing1() { }) const childThing2 = sinon.spy(function childThing2() { }) const optionType1Thing1 = sinon.spy(function optionType1Thing1() { }) const optionType1Thing2 = sinon.spy(function optionType1Thing2() { }) const optionType2Thing1 = sinon.spy(function optionType2Thing1() { }) const optionType2Thing2 = sinon.spy(function optionType2Thing2() { }) const fieldThing1 = sinon.spy(function fieldThing1() { }) const fieldThing2 = sinon.spy(function fieldThing2() { }) formlyConfig.setType({ name: 'parent1', defaultOptions: { [which]: [parent1Thing1, parent1Thing2], }, }) formlyConfig.setType({ name: 'parent2', defaultOptions: { [which]: [parent2Thing1, parent2Thing2], }, }) formlyConfig.setType({ name: 'child', template: '<input ng-model="model[options.key]" />', extends: 'parent1', // <-- note this! defaultOptions: { [which]: [childThing1, childThing2], }, }) formlyConfig.setType({ name: 'optionType1', extends: 'parent2', // <-- note this! defaultOptions: { [which]: [optionType1Thing1, optionType1Thing2], }, }) formlyConfig.setType({ name: 'optionType2', defaultOptions: { [which]: [optionType2Thing1, optionType2Thing2], }, }) scope.fields = [ { type: 'child', optionsTypes: ['optionType1', 'optionType2'], [which]: [fieldThing1, fieldThing2], }, ] compileAndDigest() const ctrl = getNgModelCtrl() const originalThings = ctrl['$' + which].map(thing => thing[originalThingProp]) if (which === 'formatters') { // all ngModelControllers have a default formatter, remove that from the originalThings for our test originalThings.shift() } expect(originalThings).to.eql([ parent1Thing1, parent1Thing2, childThing1, childThing2, parent2Thing1, parent2Thing2, optionType1Thing1, optionType1Thing2, optionType2Thing1, optionType2Thing2, fieldThing1, fieldThing2, ]) } }) describe(`link`, () => { describe(`addClasses`, () => { it(`should add the type class`, () => { formlyConfig.setType({ name: 'input', template: input, }) scope.fields = [{type: 'input'}] compileAndDigest() expect(el[0].querySelector('[formly-field].formly-field-input')).to.exist }) it(`should add the className class`, () => { scope.fields = [getNewField({className: 'classy'}), getNewField({className: 'very-classy'})] compileAndDigest() expect(el[0].querySelector('[formly-field].classy')).to.exist expect(el[0].querySelector('[formly-field].very-classy')).to.exist }) }) }) describe(`elementAttributes`, () => { it(`should allow fields to have attributes which will be applied to the [formly-field]`, () => { scope.fields = [getNewField({elementAttributes: {foo: 'bar', baz: 'eggs'}})] compileAndDigest() expect(el[0].querySelector('[formly-field][foo=bar][baz=eggs]')).to.exist }) it(`should allow fieldGroups to have attributes which will be applied to the ng-form`, () => { scope.fields = [ {elementAttributes: {foo: 'bar', baz: 'eggs'}, fieldGroup: [getNewField()]}, ] compileAndDigest() expect(el[0].querySelector('ng-form[foo=bar][baz=eggs]')).to.exist }) }) describe(`resetModel`, () => { it(`should reset the form state`, () => { const field = getNewField({key: 'foo'}) scope.fields = [field] compileAndDigest() // initial state expect(field.formControl.$dirty).to.be.false expect(field.formControl.$touched).to.be.false // modification scope.model.foo = '~=[,,_,,]:3' field.formControl.$setTouched() field.formControl.$setDirty() scope.$digest() // expect modification expect(field.formControl.$dirty).to.be.true expect(field.formControl.$touched).to.be.true expect(field.formControl.$modelValue).to.eq('~=[,,_,,]:3') // reset state field.resetModel() // expect reset expect(field.formControl.$modelValue).to.be.empty expect(field.formControl.$touched).to.be.false expect(field.formControl.$dirty).to.be.false }) it(`should reset the form state with a deep model`, () => { const field = getNewField({key: 'foo.bar'}) scope.fields = [field] compileAndDigest() // initial state expect(field.formControl.$dirty).to.be.false expect(field.formControl.$touched).to.be.false // modification scope.model.foo = { bar: '~=[,,_,,]:3', } field.formControl.$setTouched() field.formControl.$setDirty() scope.$digest() // expect modification expect(field.formControl.$dirty).to.be.true expect(field.formControl.$touched).to.be.true expect(field.formControl.$modelValue).to.eq('~=[,,_,,]:3') // Set new initialValue scope.options.updateInitialValue() // Modify again scope.model.foo.bar = 'l33t' field.formControl.$setTouched() field.formControl.$setDirty() scope.$digest() // expect modification expect(field.formControl.$dirty).to.be.true expect(field.formControl.$touched).to.be.true expect(field.formControl.$modelValue).to.eq('l33t') // reset state scope.options.resetModel() // expect reset expect(field.formControl.$modelValue).to.eq('~=[,,_,,]:3') expect(field.formControl.$touched).to.be.false expect(field.formControl.$dirty).to.be.false }) it(`should reset the form state for an field with multiple ng-models`, () => { const field = { key: 'multiNgModel', template: multiNgModelField, } scope.fields = [field] compileAndDigest() // initial state expect(field.formControl[0].$dirty).to.be.false expect(field.formControl[0].$touched).to.be.false expect(field.formControl[1].$dirty).to.be.false expect(field.formControl[1].$touched).to.be.false scope.model.multiNgModel = { start: 0, stop: 20, } field.formControl[0].$setDirty() field.formControl[0].$setTouched() field.formControl[1].$setDirty() field.formControl[1].$setTouched() scope.$digest() // expect modification expect(field.formControl[0].$dirty).to.be.true expect(field.formControl[0].$touched).to.be.true expect(field.formControl[0].$modelValue).to.eq(0) expect(field.formControl[1].$dirty).to.be.true expect(field.formControl[1].$touched).to.be.true expect(field.formControl[1].$modelValue).to.eq(20) // reset state field.resetModel() // expect reset expect(field.formControl[0].$modelValue).to.be.empty expect(field.formControl[0].$touched).to.be.false expect(field.formControl[0].$dirty).to.be.false expect(field.formControl[1].$modelValue).to.be.empty expect(field.formControl[1].$touched).to.be.false expect(field.formControl[1].$dirty).to.be.false }) it(`should work just fine to call resetModel on a field that has no formControl`, () => { const field = {template: '<hr />'} scope.fields = [field] compileAndDigest() expect(field.formControl).to.not.exist expect(() => field.resetModel()).to.not.throw() }) it('should reset the form state on the input and form both', () => { const field = getNewField({key: 'foo'}) scope.fields = [field] compileAndDigest(` <form name="theForm"> <formly-form form="theForm" model="model" fields="fields" options="options"></formly-form> </form> `) // initial state expect(field.formControl.$dirty).to.be.false expect(field.formControl.$touched).to.be.false expect(scope.theForm.$dirty).to.be.false expect(scope.theForm.$pristine).to.be.true // modification scope.model.foo = '~=[,,_,,]:3' field.formControl.$setTouched() field.formControl.$setDirty() scope.$digest() // expect modification expect(field.formControl.$dirty).to.be.true expect(field.formControl.$touched).to.be.true expect(scope.theForm.$dirty).to.be.true expect(scope.theForm.$pristine).to.be.false expect(field.formControl.$modelValue).to.eq('~=[,,_,,]:3') // reset state field.resetModel() // expect reset expect(field.formControl.$modelValue).to.be.empty expect(field.formControl.$touched).to.be.false expect(field.formControl.$dirty).to.be.false expect(scope.theForm.$dirty).to.be.false expect(scope.theForm.$pristine).to.be.true }) it(`should not digest if there's a digest in progress`, () => { scope.fields = [getNewField()] compileAndDigest() scope.$root.$$phase = '$digest' expect(() => field.resetModel()).to.not.throw() }) }) describe(`with a div ng-model`, () => { it(`should have a form-controller`, () => { const template = `<div ng-model="model[options.key]"> </div>` scope.fields = [getNewField({template})] compileAndDigest() expect(isolateScope.fc).to.exist expect(field.formControl).to.exist }) }) describe(`with a div data-ng-model`, () => { it(`should have a form-controller`, () => { const template = `<div data-ng-model="model[options.key]"> </div>` scope.fields = [getNewField({template})] compileAndDigest() expect(isolateScope.fc).to.exist expect(field.formControl).to.exist }) }) describe(`options.validation.errorExistsAndShouldBeVisible`, () => { describe(`multiple ng-model elements`, () => { beforeEach(() => { scope.fields = [ { template: ` <input ng-model="model[options.data.firstKey]" /> <input ng-model="model[options.data.secondKey]" /> `, // we'll just give it a validator that depends on a value we // can change in our tests validators: {foo: '!options.data.invalid'}, }, ] }) it(`should set showError to true when one of them is invalid`, () => { compileAndDigest() expect(field.validation.errorExistsAndShouldBeVisible, 'initially false').to.be.false invalidateAndTouchFields() expect(field.formControl[0].$error.foo, '$error on the first formControl').be.true expect(field.validation.errorExistsAndShouldBeVisible, 'now true').to.be.true }) it(`should work with a custom errorExistsAndShouldBeVisibleExpression`, () => { const spy = sinon.spy() formlyConfig.extras.errorExistsAndShouldBeVisibleExpression = spy compileAndDigest() invalidateAndTouchFields() expect(spy).to.have.been.calledTwice // once for each form control. }) function invalidateAndTouchFields() { field.data.invalid = true // force $touched and revalidation of both form controls field.formControl.forEach(fc => { fc.$setTouched() fc.$validate() }) // redigest to set the showError prop scope.$digest() } }) describe(`with custom errorExistsAndShouldBeVisible expression`, () => { beforeEach(() => { scope.fields = [getNewField({validators: {foo: 'false'}})] }) it(`should set errorExistsAndShouldBeVisible to true when the expression function says so`, () => { formlyConfig.extras.errorExistsAndShouldBeVisibleExpression = '!!options.data.