diff --git a/addon/validations/factory.js b/addon/validations/factory.js index 32a77809..659dfc43 100644 --- a/addon/validations/factory.js +++ b/addon/validations/factory.js @@ -393,7 +393,23 @@ function createTopLevelPropsMixin(validatableAttrs) { }).readOnly(), message: computed('messages.[]', cycleBreaker(function () { - return get(this, 'messages.0'); + return get(this, 'messages.firstObject'); + })).readOnly(), + + warningMessages: computed(...validatableAttrs.map(attr => `attrs.${attr}.warningMessages`), function () { + return emberArray(flatten(validatableAttrs.map(attr => get(this, `attrs.${attr}.warningMessages`)))).compact(); + }).readOnly(), + + warningMessage: computed('warningMessages.[]', cycleBreaker(function () { + return get(this, 'warningMessages.firstObject'); + })).readOnly(), + + warnings: computed(...validatableAttrs.map(attr => `attrs.${attr}.@each.warnings`), function () { + return emberArray(flatten(validatableAttrs.map(attr => get(this, `attrs.${attr}.warnings`)))).compact(); + }).readOnly(), + + warning: computed('warning.[]', cycleBreaker(function () { + return get(this, 'warning.firstObject'); })).readOnly(), errors: computed(...validatableAttrs.map(attr => `attrs.${attr}.@each.errors`), function () { @@ -401,7 +417,7 @@ function createTopLevelPropsMixin(validatableAttrs) { }).readOnly(), error: computed('errors.[]', cycleBreaker(function () { - return get(this, 'errors.0'); + return get(this, 'errors.firstObject'); })).readOnly(), _promise: computed(...validatableAttrs.map(attr => `attrs.${attr}._promise`), function () { diff --git a/addon/validations/result-collection.js b/addon/validations/result-collection.js index b1bd5cb6..dbf7cb76 100644 --- a/addon/validations/result-collection.js +++ b/addon/validations/result-collection.js @@ -64,9 +64,9 @@ export default Ember.Object.extend({ * * @property isInvalid * @readOnly - * @type {Ember.ComputedProperty | Boolean} + * @type {Boolean} */ - isInvalid: computed.not('isValid'), + isInvalid: computed.not('isValid').readOnly(), /** * ```javascript @@ -78,11 +78,11 @@ export default Ember.Object.extend({ * @property isValid * @default true * @readOnly - * @type {Ember.ComputedProperty | Boolean} + * @type {Boolean} */ - isValid: computed('content.@each.isValid', cycleBreaker(function () { - return get(this, 'content').isEvery('isValid', true); - }, true)), + isValid: computed('_errorContent.@each.isValid', cycleBreaker(function () { + return get(this, '_errorContent').isEvery('isValid', true); + }, true)).readOnly(), /** * This property is toggled only if there is an async validation @@ -96,11 +96,11 @@ export default Ember.Object.extend({ * @property isValidating * @default false * @readOnly - * @type {Ember.ComputedProperty | Boolean} + * @type {Boolean} */ isValidating: computed('content.@each.isValidating', cycleBreaker(function () { return !get(this, 'content').isEvery('isValidating', false); - }, false)), + }, false)).readOnly(), /** * Will be true only if isValid is `true` and isValidating is `false` @@ -114,11 +114,11 @@ export default Ember.Object.extend({ * @property isTruelyValid * @default true * @readOnly - * @type {Ember.ComputedProperty | Boolean} + * @type {Boolean} */ - isTruelyValid: computed('content.@each.isTruelyValid', cycleBreaker(function () { - return get(this, 'content').isEvery('isTruelyValid', true); - }, true)), + isTruelyValid: computed('_errorContent.@each.isTruelyValid', cycleBreaker(function () { + return get(this, '_errorContent').isEvery('isTruelyValid', true); + }, true)).readOnly(), /** * Will be true is the attribute in question is not `null` or `undefined`. If the object being @@ -134,11 +134,11 @@ export default Ember.Object.extend({ * @property isDirty * @default false * @readOnly - * @type {Ember.ComputedProperty | Boolean} + * @type {Boolean} */ - isDirty: computed('content.@each.isDirty', cycleBreaker(function () { - return !get(this, 'content').isEvery('isDirty', false); - }, false)), + isDirty: computed('_errorContent.@each.isDirty', cycleBreaker(function () { + return !get(this, '_errorContent').isEvery('isDirty', false); + }, false)).readOnly(), /** * Will be `true` only if a validation returns a promise @@ -152,11 +152,11 @@ export default Ember.Object.extend({ * @property isAsync * @default false * @readOnly - * @type {Ember.ComputedProperty | Boolean} + * @type {Boolean} */ isAsync: computed('content.@each.isAsync', cycleBreaker(function () { return !get(this, 'content').isEvery('isAsync', false); - }, false)), + }, false)).readOnly(), /** * A collection of all error messages on the object in question @@ -169,13 +169,13 @@ export default Ember.Object.extend({ * * @property messages * @readOnly - * @type {Ember.ComputedProperty | Array} + * @type {Array} */ - messages: computed('content.@each.messages', cycleBreaker(function () { - const messages = flatten(get(this, 'content').getEach('messages')); + messages: computed('_errorContent.@each.messages', cycleBreaker(function () { + const messages = flatten(get(this, '_errorContent').getEach('messages')); return uniq(compact(messages)); - })), + })).readOnly(), /** * An alias to the first message in the messages collection. @@ -188,11 +188,82 @@ export default Ember.Object.extend({ * * @property message * @readOnly - * @type {Ember.ComputedProperty | String} + * @type {String} */ message: computed('messages.[]', cycleBreaker(function () { - return get(this, 'messages.0'); - })), + return get(this, 'messages.firstObject'); + })).readOnly(), + + /** + * A collection of all warning messages on the object in question + * + * ```javascript + * // Examples + * get(user, 'validations.warningMessages') + * get(user, 'validations.attrs.username.warningMessages') + * ``` + * + * @property warningMessages + * @readOnly + * @type {Array} + */ + warningMessages: computed('_warningContent.@each.messages', cycleBreaker(function () { + const messages = flatten(get(this, '_warningContent').getEach('messages')); + + return uniq(compact(messages)); + })).readOnly(), + + /** + * An alias to the first message in the warningMessages collection. + * + * ```javascript + * // Example + * get(user, 'validations.warningMessage') + * get(user, 'validations.attrs.username.warningMessage') + * ``` + * + * @property warningMessage + * @readOnly + * @type {String} + */ + warningMessage: computed('warningMessages.[]', cycleBreaker(function () { + return get(this, 'warningMessages.firstObject'); + })).readOnly(), + + /** + * A collection of all {{#crossLink "Error"}}Warnings{{/crossLink}} on the object in question. + * Each warning object includes the warning message and it's associated attribute name. + * + * ```javascript + * // Example + * get(user, 'validations.warnings') + * get(user, 'validations.attrs.username.warnings') + * ``` + * + * @property warnings + * @readOnly + * @type {Array} + */ + warnings: computed('attribute', '_warningContent.@each.errors', cycleBreaker(function () { + return this._computeErrorCollection(get(this, '_warningContent')); + })).readOnly(), + + /** + * An alias to the first {{#crossLink "Warning"}}{{/crossLink}} in the warnings collection. + * + * ```javascript + * // Example + * get(user, 'validations.warning') + * get(user, 'validations.attrs.username.warning') + * ``` + * + * @property warning + * @readOnly + * @type {Error} + */ + warning: computed('warnings.[]', cycleBreaker(function () { + return get(this, 'warnings.firstObject'); + })).readOnly(), /** * A collection of all {{#crossLink "Error"}}Errors{{/crossLink}} on the object in question. @@ -206,21 +277,11 @@ export default Ember.Object.extend({ * * @property errors * @readOnly - * @type {Ember.ComputedProperty | Array} + * @type {Array} */ - errors: computed('attribute', 'content.@each.errors', cycleBreaker(function () { - const attribute = get(this, 'attribute'); - let errors = flatten(get(this, 'content').getEach('errors')); - - errors = uniq(compact(errors)); - errors.forEach(e => { - if(e.get('attribute') !== attribute) { - e.set('parentAttribute', attribute); - } - }); - - return errors; - })), + errors: computed('attribute', '_errorContent.@each.errors', cycleBreaker(function () { + return this._computeErrorCollection(get(this, '_errorContent')); + })).readOnly(), /** * An alias to the first {{#crossLink "Error"}}{{/crossLink}} in the errors collection. @@ -233,11 +294,11 @@ export default Ember.Object.extend({ * * @property error * @readOnly - * @type {Ember.ComputedProperty | Error} + * @type {Error} */ error: computed('errors.[]', cycleBreaker(function () { - return get(this, 'errors.0'); - })), + return get(this, 'errors.firstObject'); + })).readOnly(), /** * All built options of the validators associated with the results in this collection grouped by validator type @@ -269,26 +330,26 @@ export default Ember.Object.extend({ * * @property options * @readOnly - * @type {Ember.ComputedProperty | Object} + * @type {Object} */ options: computed('_contentValidators.[]', '_contentValidators.@each._cachedOptions', function () { return this._groupValidatorOptions(); - }), + }).readOnly(), /** * @property value - * @type {Ember.ComputedProperty} + * @type {ResultCollection | Promise} * @private */ value: computed('isAsync', cycleBreaker(function () { return get(this, 'isAsync') ? get(this, '_promise') : this; - })), + })).readOnly(), /** * @property _promise * @async * @private - * @type {Ember.ComputedProperty | Promise} + * @type {Promise} */ _promise: computed('content.@each._promise', cycleBreaker(function () { const promises = get(this, 'content').getEach('_promise'); @@ -296,14 +357,47 @@ export default Ember.Object.extend({ if (!isEmpty(promises)) { return RSVP.all(compact(flatten(promises))); } - })), + })).readOnly(), /** * @property _contentValidators - * @type {Ember.ComputedProperty} + * @type {Array} * @private */ - _contentValidators: computed.mapBy('content', '_validator').readOnly(), + _contentValidators: computed.mapBy('_errorContent', '_validator').readOnly(), + + /** + * @property _errorContent + * @type {Array} + * @private + */ + _errorContent: computed.filterBy('content', 'isWarning', false).readOnly(), + + /** + * @property _warningContent + * @type {Array} + * @private + */ + _warningContent: computed.filterBy('content', 'isWarning', true).readOnly(), + + /** + * @method _computeErrorCollection + * @return {Object} + * @private + */ + _computeErrorCollection(content) { + const attribute = get(this, 'attribute'); + let errors = flatten(content.getEach('errors')); + + errors = uniq(compact(errors)); + errors.forEach(e => { + if(e.get('attribute') !== attribute) { + e.set('parentAttribute', attribute); + } + }); + + return errors; + }, /** * Used by the `options` property to create a hash from the `content` that is grouped by validator type. diff --git a/addon/validations/result.js b/addon/validations/result.js index 4654ee74..82dfad10 100644 --- a/addon/validations/result.js +++ b/addon/validations/result.js @@ -28,7 +28,7 @@ const { * @private */ -export default Ember.Object.extend({ +const Result = Ember.Object.extend({ /** * @property model @@ -58,73 +58,80 @@ export default Ember.Object.extend({ */ _validator: null, + /** + * @property isWarning + * @readOnly + * @type {Boolean} + */ + isWarning: readOnly('_validator.isWarning'), + /** * @property isValid * @readOnly - * @type {Ember.ComputedProperty} + * @type {Boolean} */ isValid: readOnly('_validations.isValid'), /** * @property isInvalid * @readOnly - * @type {Ember.ComputedProperty} + * @type {Boolean} */ isInvalid: readOnly('_validations.isInvalid'), /** * @property isValidating * @readOnly - * @type {Ember.ComputedProperty} + * @type {Boolean} */ isValidating: readOnly('_validations.isValidating'), /** * @property isTruelyValid * @readOnly - * @type {Ember.ComputedProperty} + * @type {Boolean} */ isTruelyValid: readOnly('_validations.isTruelyValid'), /** * @property isAsync * @readOnly - * @type {Ember.ComputedProperty} + * @type {Boolean} */ isAsync: readOnly('_validations.isAsync'), /** * @property isDirty * @readOnly - * @type {Ember.ComputedProperty} + * @type {Boolean} */ isDirty: readOnly('_validations.isDirty'), /** * @property message * @readOnly - * @type {Ember.ComputedProperty} + * @type {String} */ message: readOnly('_validations.message'), /** * @property messages * @readOnly - * @type {Ember.ComputedProperty} + * @type {Array} */ messages: readOnly('_validations.messages'), /** * @property error * @readOnly - * @type {Ember.ComputedProperty} + * @type {Object} */ error: readOnly('_validations.error'), /** * @property errors * @readOnly - * @type {Ember.ComputedProperty} + * @type {Array} */ errors: readOnly('_validations.errors'), @@ -134,7 +141,7 @@ export default Ember.Object.extend({ * @private * @type {Result} */ - _validations: computed('model', 'attribute', '_promise', function () { + _validations: computed('model', 'attribute', '_promise', '_validator', function () { return InternalResultObject.create(getProperties(this, ['model', 'attribute', '_promise', '_validator'])); }), @@ -161,6 +168,8 @@ export default Ember.Object.extend({ */ update(result) { const validations = get(this, '_validations'); + const validator = get(this, '_validator'); + const { model, attribute } = getProperties(this, ['model', 'attribute']); if (isNone(result)) { this.update(false); @@ -171,8 +180,13 @@ export default Ember.Object.extend({ set(this, '_validations', result); } else if (isArray(result)) { const validationResultsCollection = ValidationResultCollection.create({ - attribute: get(this, 'attribute'), - content: result + attribute, + content: result.map(r => Result.create({ + attribute, + model, + _validator: validator, + _validations: r + })) }); set(this, '_validations', validationResultsCollection); } else if (typeof result === 'string') { @@ -207,3 +221,5 @@ export default Ember.Object.extend({ }); } }); + +export default Result; diff --git a/addon/validators/base.js b/addon/validators/base.js index f0c343b6..6edf40b3 100644 --- a/addon/validators/base.js +++ b/addon/validators/base.js @@ -11,7 +11,8 @@ import { unwrapString } from 'ember-cp-validations/utils/utils'; const { get, set, - isNone + isNone, + computed } = Ember; const assign = Ember.assign || Ember.merge; @@ -64,6 +65,12 @@ const Base = Ember.Object.extend({ */ errorMessages: null, + /** + * @property isWarning + * @type {Boolean} + */ + isWarning: computed.bool('_cachedOptions.isWarning').readOnly(), + /** * Validator type * @property _type diff --git a/tests/dummy/app/components/validated-input.js b/tests/dummy/app/components/validated-input.js index 18e35296..7f9b0e96 100644 --- a/tests/dummy/app/components/validated-input.js +++ b/tests/dummy/app/components/validated-input.js @@ -6,6 +6,7 @@ import Ember from 'ember'; const { + isEmpty, computed, defineProperty, } = Ember; @@ -34,7 +35,11 @@ export default Ember.Component.extend({ isValid: computed.and('hasContent', 'validation.isValid', 'notValidating'), isInvalid: computed.oneWay('validation.isInvalid'), showErrorClass: computed.and('notValidating', 'showMessage', 'hasContent', 'validation'), - showMessage: computed('validation.isDirty', 'isInvalid', 'didValidate', function() { + showErrorMessage: computed('validation.isDirty', 'isInvalid', 'didValidate', function() { return (this.get('validation.isDirty') || this.get('didValidate')) && this.get('isInvalid'); + }), + + showWarningMessage: computed('validation.isDirty', 'validation.warnings.[]', 'isValid', 'didValidate', function() { + return (this.get('validation.isDirty') || this.get('didValidate')) && this.get('isValid') && !isEmpty(this.get('validation.warnings')); }) }); diff --git a/tests/dummy/app/models/user.js b/tests/dummy/app/models/user.js index 748a838c..d5cb47b8 100644 --- a/tests/dummy/app/models/user.js +++ b/tests/dummy/app/models/user.js @@ -25,11 +25,16 @@ var Validations = buildValidations({ validator('presence', true), validator('length', { min: 4, - max: 8 + max: 10 }), validator('format', { regex: /^(?=.*\d)(?=.*[a-z])(?=.*[A-Z]).{4,8}$/, message: '{description} must include at least one upper case letter, one lower case letter, and a number' + }), + validator('length', { + isWarning: true, + min: 6, + message: 'What kind of weak password is that?' }) ] }, diff --git a/tests/dummy/app/styles/app.css b/tests/dummy/app/styles/app.css index f96757e8..49a7086b 100644 --- a/tests/dummy/app/styles/app.css +++ b/tests/dummy/app/styles/app.css @@ -264,12 +264,20 @@ body { font-size: 12px; } -.validated-input .input-error .error { +.validated-input .input-error .error, +.validated-input .input-error .warning { padding: 8px 5px 0 0; - color: rgb(255, 65, 31); text-align: left; } +.validated-input .input-error .error { + color: rgb(255, 65, 31); +} + +.validated-input .input-error .warning { + color: rgb(255, 165, 31); +} + .demo a.show-code { cursor: pointer; } diff --git a/tests/dummy/app/templates/components/validated-input.hbs b/tests/dummy/app/templates/components/validated-input.hbs index 7aa7effa..de32bc9e 100644 --- a/tests/dummy/app/templates/components/validated-input.hbs +++ b/tests/dummy/app/templates/components/validated-input.hbs @@ -5,10 +5,16 @@ {{/if}}
- {{#if showMessage}} -
- {{v-get model valuePath 'message'}} -
- {{/if}} + {{#if showErrorMessage}} +
+ {{v-get model valuePath 'message'}} +
+ {{/if}} + + {{#if showWarningMessage}} +
+ {{v-get model valuePath 'warningMessage'}} +
+ {{/if}}
- \ No newline at end of file + diff --git a/tests/integration/validations/factory-general-test.js b/tests/integration/validations/factory-general-test.js index a67967fa..430fe66f 100644 --- a/tests/integration/validations/factory-general-test.js +++ b/tests/integration/validations/factory-general-test.js @@ -756,3 +756,49 @@ test("multiple mixins", function(assert) { assert.equal(object.get('validations.attrs.lastName.isValid'), true); assert.equal(object.get('validations.isValid'), true); }); + +test("warning validators api", function(assert) { + this.register('validator:length', LengthValidator); + this.register('validator:presence', PresenceValidator); + + var Validations = buildValidations({ + password: { + description: 'Password', + validators: [ + validator('presence', { + presence: true, + isWarning: true, + message: '{description} should not be empty' + }), + validator('length', { + min: 4, + isWarning: true, + message: '{description} is weak' + }), + validator('length', { + min: 1, + max: 10 + }) + ] + } + }); + + var object = setupObject(this, Ember.Object.extend(Validations), { + password: '' + }); + + assert.equal(object.get('validations.isValid'), false); + assert.equal(object.get('validations.attrs.password.isValid'), false); + assert.equal(object.get('validations.attrs.password.warnings.length'), 2); + assert.equal(object.get('validations.attrs.password.warningMessage'), 'Password should not be empty'); + assert.equal(object.get('validations.attrs.password.message'), 'Password is too short (minimum is 1 characters)'); + + object.set('password', 'wat'); + + assert.equal(object.get('validations.isValid'), true); + assert.equal(object.get('validations.attrs.password.isValid'), true); + assert.equal(object.get('validations.attrs.password.warnings.length'), 1); + assert.equal(object.get('validations.attrs.password.warningMessage'), 'Password is weak'); + + +}); diff --git a/tests/integration/validations/model-relationships-test.js b/tests/integration/validations/model-relationships-test.js index 84eed1fc..86e07c80 100644 --- a/tests/integration/validations/model-relationships-test.js +++ b/tests/integration/validations/model-relationships-test.js @@ -94,7 +94,7 @@ test("belong to validation - with cycle", function(assert) { }); test("has-many relationship is sync", function(assert) { - this.register('validator:belongs-to', BelongsToValidator); + this.register('validator:has-many', HasManyValidator); var friend = setupObject(this, Ember.Object.extend(Validations), { firstName: 'John' @@ -119,7 +119,7 @@ test("has-many relationship is sync", function(assert) { }); test("has-many relationship is sync with proxy", function(assert) { - this.register('validator:belongs-to', BelongsToValidator); + this.register('validator:has-many', HasManyValidator); var friend = setupObject(this, Ember.Object.extend(Validations), { firstName: 'John' @@ -175,6 +175,42 @@ test("has-many relationship is async", function(assert) { return validations; }); +test("has-many relationship is async and isWarning", function(assert) { + this.register('validator:has-many', HasManyValidator); + + var HasManyValidations = buildValidations({ + friends: validator('has-many', { isWarning: true }) + }); + + + var friend = setupObject(this, Ember.Object.extend(Validations), { + firstName: 'Offir' + }); + + var user = setupObject(this, Ember.Object.extend(HasManyValidations), { + friends: new Ember.RSVP.Promise((resolve, reject) => { + resolve([friend]); + }) + }); + + var validations = user.get('validations').validate(); + assert.equal(user.get('validations.isAsync'), true); + assert.equal(user.get('validations.isValidating'), true); + + validations.then(({ + model, validations + }) => { + assert.equal(model, user, 'expected model to be the correct model'); + assert.deepEqual(validations.get('content').getEach('attribute').sort(), ['friends'].sort()); + + let friends = validations.get('content').findBy('attribute', 'friends'); + + assert.equal(friends.get('isValid'), true); + }); + + return validations; +}); + test("belongs-to relationship is async", function(assert) { this.register('validator:belongs-to', BelongsToValidator); @@ -207,6 +243,41 @@ test("belongs-to relationship is async", function(assert) { return validations; }); +test("belongs-to relationship is async and isWarning", function(assert) { + this.register('validator:belongs-to', BelongsToValidator); + + var BelongsToValidations = buildValidations({ + friend: validator('belongs-to', { isWarning: true }) + }); + + var friend = setupObject(this, Ember.Object.extend(Validations), { + firstName: 'Offir' + }); + + var user = setupObject(this, Ember.Object.extend(BelongsToValidations), { + friend: new Ember.RSVP.Promise((resolve, reject) => { + resolve(friend); + }) + }); + + var validations = user.get('validations').validate(); + assert.equal(user.get('validations.isAsync'), true); + assert.equal(user.get('validations.isValidating'), true); + + validations.then(({ + model, validations + }) => { + assert.equal(model, user, 'expected model to be the correct model'); + assert.deepEqual(validations.get('content').getEach('attribute').sort(), ['friend'].sort()); + + let friend = validations.get('content').findBy('attribute', 'friend'); + + assert.equal(friend.get('isValid'), true); + }); + + return validations; +}); + test("belongs-to relationship is async and does not exist", function(assert) { this.register('validator:belongs-to', BelongsToValidator);