adds inline errors to validation

closes #5336
- creates gh-form-group component to handle form group status
- refactors current validation methods to work on a per-property basis
- adds gh-error-message component to render error message
- removes (comments out) tests that pertain to the old notifications until the new inline validation is added
This commit is contained in:
Austin Burdine 2015-06-06 22:19:19 -05:00
parent 5dd186dd70
commit 8f831a180d
20 changed files with 346 additions and 191 deletions

View File

@ -0,0 +1,34 @@
import Ember from 'ember';
/**
* Renders one random error message when passed a DS.Errors object
* and a property name. The message will be one of the ones associated with
* that specific property. If there are no errors associated with the property,
* nothing will be rendered.
* @param {DS.Errors} errors The DS.Errors object
* @param {string} property The property name
*/
export default Ember.Component.extend({
tagName: 'p',
classNames: ['response'],
errors: null,
property: '',
isVisible: Ember.computed.notEmpty('errors'),
message: Ember.computed('errors.[]', 'property', function () {
var property = this.get('property'),
errors = this.get('errors'),
messages = [],
index;
if (!Ember.isEmpty(errors) && errors.get(property)) {
errors.get(property).forEach(function (error) {
messages.push(error);
});
index = Math.floor(Math.random() * messages.length);
return messages[index].message;
}
})
});

View File

@ -0,0 +1,25 @@
import Ember from 'ember';
/**
* Handles the CSS necessary to show a specific property state. When passed a
* DS.Errors object and a property name, if the DS.Errors object has errors for
* the specified property, it will change the CSS to reflect the error state
* @param {DS.Errors} errors The DS.Errors object
* @param {string} property Name of the property
*/
export default Ember.Component.extend({
classNames: 'form-group',
classNameBindings: ['errorClass'],
errors: null,
property: '',
errorClass: Ember.computed('errors.[]', 'property', function () {
var property = this.get('property'),
errors = this.get('errors');
if (errors) {
return errors.get(property) ? 'error' : 'success';
}
})
});

View File

@ -1,6 +1,8 @@
import Ember from 'ember';
import TextInputMixin from 'ghost/mixins/text-input';
var Input = Ember.TextField.extend(TextInputMixin);
var Input = Ember.TextField.extend(TextInputMixin, {
classNames: 'gh-input'
});
export default Input;

View File

@ -1,6 +1,8 @@
import Ember from 'ember';
import TextInputMixin from 'ghost/mixins/text-input';
var TextArea = Ember.TextArea.extend(TextInputMixin);
var TextArea = Ember.TextArea.extend(TextInputMixin, {
classNames: 'gh-input'
});
export default TextArea;

View File

@ -31,19 +31,20 @@ export default Ember.Controller.extend(ValidationEngine, {
return 'background-image: url(' + this.get('userImage') + ')';
}),
invalidMessage: 'The password fairy does not approve',
// ValidationEngine settings
validationType: 'setup',
actions: {
setup: function () {
var self = this,
notifications = this.get('notifications'),
data = self.getProperties('blogTitle', 'name', 'email', 'password');
notifications.closePassive();
data = self.getProperties('blogTitle', 'name', 'email', 'password'),
notifications = this.get('notifications');
this.toggleProperty('submitting');
this.validate({format: false}).then(function () {
this.validate().then(function () {
self.set('showError', false);
ajax({
url: self.get('ghostPaths.url').api('authentication', 'setup'),
type: 'POST',
@ -70,9 +71,9 @@ export default Ember.Controller.extend(ValidationEngine, {
self.toggleProperty('submitting');
notifications.showAPIError(resp);
});
}).catch(function (errors) {
}).catch(function () {
self.toggleProperty('submitting');
notifications.showErrors(errors);
self.set('showError', true);
});
}
}

View File

@ -34,7 +34,9 @@ export default Ember.Controller.extend(ValidationEngine, {
self.get('notifications').closePassive();
self.send('authenticate');
}).catch(function (errors) {
self.get('notifications').showErrors(errors);
if (errors) {
self.get('notifications').showErrors(errors);
}
});
},

View File

@ -115,7 +115,9 @@ export default Ember.Controller.extend({
return model;
}).catch(function (errors) {
self.get('notifications').showErrors(errors);
if (errors) {
self.get('notifications').showErrors(errors);
}
});
this.set('lastPromise', promise);

View File

@ -15,6 +15,8 @@ import TagSettingsValidator from 'ghost/validators/tag-settings';
// our extensions to the validator library
ValidatorExtensions.init();
// This is here because it is used by some things that format errors from api responses
// This function should be removed in the notifications refactor
// format errors to be used in `notifications.showErrors`.
// result is [{message: 'concatenated error messages'}]
function formatErrors(errors, opts) {
@ -79,17 +81,21 @@ export default Ember.Mixin.create({
tag: TagSettingsValidator
},
// This adds the Errors object to the validation engine, and shouldn't affect
// ember-data models because they essentially use the same thing
errors: DS.Errors.create(),
/**
* Passes the model to the validator specified by validationType.
* Returns a promise that will resolve if validation succeeds, and reject if not.
* Some options can be specified:
*
* `format: false` - doesn't use formatErrors to concatenate errors for notifications.showErrors.
* will return whatever the specified validator returns.
* since notifications are a common usecase, `format` is true by default.
*
* `model: Object` - you can specify the model to be validated, rather than pass the default value of `this`,
* the class that mixes in this mixin.
*
* `property: String` - you can specify a specific property to validate. If
* no property is specified, the entire model will be
* validated
*/
validate: function (opts) {
// jscs:disable safeContextKeyword
@ -113,23 +119,23 @@ export default Ember.Mixin.create({
opts.validationType = type;
return new Ember.RSVP.Promise(function (resolve, reject) {
var validationErrors;
var passed;
if (!type || !validator) {
validationErrors = ['The validator specified, "' + type + '", did not exist!'];
} else {
validationErrors = validator.check(model);
return reject(['The validator specified, "' + type + '", did not exist!']);
}
if (Ember.isEmpty(validationErrors)) {
passed = validator.check(model, opts.property);
if (passed) {
if (opts.property) {
model.get('errors').remove(opts.property);
} else {
model.get('errors').clear();
}
return resolve();
}
if (opts.format !== false) {
validationErrors = formatErrors(validationErrors, opts);
}
return reject(validationErrors);
return reject();
});
},
@ -172,5 +178,10 @@ export default Ember.Mixin.create({
return Ember.RSVP.reject(result);
});
},
actions: {
validate: function (property) {
this.validate({property: property});
}
}
});

View File

@ -1,6 +1,7 @@
import Ember from 'ember';
import Configuration from 'simple-auth/configuration';
import styleBody from 'ghost/mixins/style-body';
import DS from 'ember-data';
var SigninRoute = Ember.Route.extend(styleBody, {
titleToken: 'Sign In',
@ -16,7 +17,8 @@ var SigninRoute = Ember.Route.extend(styleBody, {
model: function () {
return Ember.Object.create({
identification: '',
password: ''
password: '',
errors: DS.Errors.create()
});
},

View File

@ -0,0 +1 @@
{{message}}

View File

@ -18,22 +18,24 @@
</div>
<a class="edit-account-image" href="#"><i class="icon-photos"><span class="sr-only">Upload an image</span></i></a>
</figure>
<div class="form-group">
{{#gh-form-group errors=errors property="email"}}
<label for="email-address">Email address</label>
<span class="input-icon icon-mail">
{{input type="email" name="email" placeholder="Eg. john@example.com" class="gh-input" autofocus="autofocus" autocorrect="off" value=email}}
{{gh-input type="email" name="email" placeholder="Eg. john@example.com" class="gh-input" autofocus="autofocus" autocorrect="off" value=email focusOut=(action "validate" "email")}}
</span>
</div>
<div class="form-group">
{{gh-error-message errors=errors property="email"}}
{{/gh-form-group}}
{{#gh-form-group errors=errors property="name"}}
<label for="full-name">Full name</label>
<span class="input-icon icon-user">
{{input type="text" name="name" placeholder="Eg. John H. Watson" class="gh-input" autofocus="autofocus" autocorrect="off" value=name }}
{{gh-input type="text" name="name" placeholder="Eg. John H. Watson" class="gh-input" autofocus="autofocus" autocorrect="off" value=name focusOut=(action "validate" "name")}}
</span>
</div>
<div class="form-group">
{{gh-error-message errors=errors property="name"}}
{{/gh-form-group}}
{{#gh-form-group errors=errors property="password"}}
<label for="password">Password</label>
<span class="input-icon icon-lock">
{{input type="password" name="password" placeholder="At least 8 characters" class="gh-input" autofocus="autofocus" autocorrect="off" value=password }}
{{gh-input type="password" name="password" placeholder="At least 8 characters" class="gh-input" autofocus="autofocus" autocorrect="off" value=password focusOut=(action "validate" "password")}}
<div class="pw-strength">
<div class="pw-strength-dot"></div>
<div class="pw-strength-dot"></div>
@ -42,14 +44,19 @@
<div class="pw-strength-dot <!--pw-strength-activedot-->"></div>
</div>
</span>
</div>
<div class="form-group">
{{gh-error-message errors=errors property="password"}}
{{/gh-form-group}}
{{#gh-form-group errors=errors property="blogTitle"}}
<label for="blog-title">Blog title</label>
<span class="input-icon icon-content">
{{input type="text" name="blog-title" placeholder="Eg. The Daily Awesome" class="gh-input" autofocus="autofocus" autocorrect="off" value=blogTitle }}
{{gh-input type="text" name="blog-title" placeholder="Eg. The Daily Awesome" class="gh-input" autofocus="autofocus" autocorrect="off" value=blogTitle focusOut=(action "validate" "blogTitle")}}
</span>
</div>
{{gh-error-message errors=errors property="blogTitle"}}
{{/gh-form-group}}
</form>
<button type="submit" class="btn btn-green btn-lg btn-block" {{action "setup"}} {{submitting}}>Last step: Invite your team <i class="icon-chevron"></i></button>
{{#if showError}}
<p class="main-error">{{invalidMessage}}</p>
{{/if}}
</section>

40
app/validators/base.js Normal file
View File

@ -0,0 +1,40 @@
import Ember from 'ember';
/**
* Base validator that all validators should extend
* Handles checking of individual properties or the entire model
*/
var BaseValidator = Ember.Object.extend({
properties: [],
passed: false,
/**
* When passed a model and (optionally) a property name,
* checks it against a list of validation functions
* @param {Ember.Object} model Model to validate
* @param {string} prop Property name to check
* @return {boolean} True if the model passed all (or one) validation(s),
* false if not
*/
check: function (model, prop) {
var self = this;
this.set('passed', true);
if (prop && this[prop]) {
this[prop](model);
} else {
this.get('properties').forEach(function (property) {
if (self[property]) {
self[property](model);
}
});
}
return this.get('passed');
},
invalidate: function () {
this.set('passed', false);
}
});
export default BaseValidator;

View File

@ -1,28 +1,31 @@
import Ember from 'ember';
var NewUserValidator = Ember.Object.extend({
check: function (model) {
var data = model.getProperties('name', 'email', 'password'),
validationErrors = [];
import BaseValidator from './base';
if (!validator.isLength(data.name, 1)) {
validationErrors.push({
message: 'Please enter a name.'
});
var NewUserValidator = BaseValidator.extend({
properties: ['name', 'email', 'password'],
name: function (model) {
var name = model.get('name');
if (!validator.isLength(name, 1)) {
model.get('errors').add('name', 'Please enter a name.');
this.invalidate();
}
},
email: function (model) {
var email = model.get('email');
if (!validator.isEmail(data.email)) {
validationErrors.push({
message: 'Invalid Email.'
});
if (!validator.isEmail(email)) {
model.get('errors').add('email', 'Invalid Email.');
this.invalidate();
}
},
password: function (model) {
var password = model.get('password');
if (!validator.isLength(data.password, 8)) {
validationErrors.push({
message: 'Password must be at least 8 characters long.'
});
if (!validator.isLength(password, 8)) {
model.get('errors').add('password', 'Password must be at least 8 characters long');
this.invalidate();
}
return validationErrors;
}
});

View File

@ -1,28 +1,33 @@
import Ember from 'ember';
var PostValidator = Ember.Object.create({
check: function (model) {
var validationErrors = [],
data = model.getProperties('title', 'meta_title', 'meta_description');
import BaseValidator from './base';
if (validator.empty(data.title)) {
validationErrors.push({
message: 'You must specify a title for the post.'
});
var PostValidator = BaseValidator.create({
properties: ['title', 'metaTitle', 'metaDescription'],
title: function (model) {
var title = model.get('title');
if (validator.empty(title)) {
model.get('errors').add('title', 'You must specify a title for the post.');
this.invalidate();
}
},
if (!validator.isLength(data.meta_title, 0, 150)) {
validationErrors.push({
message: 'Meta Title cannot be longer than 150 characters.'
});
metaTitle: function (model) {
var metaTitle = model.get('meta_title');
if (!validator.isLength(metaTitle, 0, 150)) {
model.get('errors').add('meta_title', 'Meta Title cannot be longer than 150 characters.');
this.invalidate();
}
},
if (!validator.isLength(data.meta_description, 0, 200)) {
validationErrors.push({
message: 'Meta Description cannot be longer than 200 characters.'
});
metaDescription: function (model) {
var metaDescription = model.get('meta_description');
if (!validator.isLength(metaDescription, 0, 200)) {
model.get('errors').add('meta_description', 'Meta Description cannot be longer than 200 characters.');
this.invalidate();
}
return validationErrors;
}
});

View File

@ -1,22 +1,19 @@
import Ember from 'ember';
var ResetValidator = Ember.Object.create({
check: function (model) {
import BaseValidator from './base';
var ResetValidator = BaseValidator.create({
properties: ['newPassword'],
newPassword: function (model) {
var p1 = model.get('newPassword'),
p2 = model.get('ne2Password'),
validationErrors = [];
p2 = model.get('ne2Password');
if (!validator.equals(p1, p2)) {
validationErrors.push({
message: 'The two new passwords don\'t match.'
});
model.get('errors').add('ne2Password', 'The two new passwords don\'t match.');
this.invalidate();
}
if (!validator.isLength(p1, 8)) {
validationErrors.push({
message: 'The password is not long enough.'
});
model.get('errors').add('newPassword', 'The password is not long enough.');
this.invalidate();
}
return validationErrors;
}
});

View File

@ -1,38 +1,49 @@
import Ember from 'ember';
var SettingValidator = Ember.Object.create({
check: function (model) {
var validationErrors = [],
title = model.get('title'),
description = model.get('description'),
postsPerPage = model.get('postsPerPage'),
isPrivate = model.get('isPrivate'),
password = model.get('password');
import BaseValidator from './base';
var SettingValidator = BaseValidator.create({
properties: ['title', 'description', 'password', 'postsPerPage'],
title: function (model) {
var title = model.get('title');
if (!validator.isLength(title, 0, 150)) {
validationErrors.push({message: 'Title is too long'});
model.get('errors').add('title', 'Title is too long');
this.invalidate();
}
},
description: function (model) {
var desc = model.get('description');
if (!validator.isLength(description, 0, 200)) {
validationErrors.push({message: 'Description is too long'});
if (!validator.isLength(desc, 0, 200)) {
model.get('errors').add('description', 'Description is too long');
this.invalidate();
}
},
password: function (model) {
var isPrivate = model.get('isPrivate'),
password = this.get('password');
if (isPrivate && password === '') {
validationErrors.push({message: 'Password must be supplied'});
model.get('errors').add('password', 'Password must be supplied');
this.invalidate();
}
},
postsPerPage: function (model) {
var postsPerPage = model.get('postsPerPage');
if (postsPerPage > 1000) {
validationErrors.push({message: 'The maximum number of posts per page is 1000'});
model.get('errors').add('postsPerPage', 'The maximum number of posts per page is 1000');
this.invalidate();
}
if (postsPerPage < 1) {
validationErrors.push({message: 'The minimum number of posts per page is 1'});
model.get('errors').add('postsPerPage', 'The minimum number of posts per page is 1');
this.invalidate();
}
if (!validator.isInt(postsPerPage)) {
validationErrors.push({message: 'Posts per page must be a number'});
model.get('errors').add('postsPerPage', 'Posts per page must be a number');
this.invalidate();
}
return validationErrors;
}
});

View File

@ -1,18 +1,16 @@
import NewUserValidator from 'ghost/validators/new-user';
var SetupValidator = NewUserValidator.extend({
check: function (model) {
var data = model.getProperties('blogTitle'),
validationErrors = this._super(model);
var SetupValidator = NewUserValidator.create({
properties: ['name', 'email', 'password', 'blogTitle'],
if (!validator.isLength(data.blogTitle, 1)) {
validationErrors.push({
message: 'Please enter a blog title.'
});
blogTitle: function (model) {
var blogTitle = model.get('blogTitle');
if (!validator.isLength(blogTitle, 1)) {
model.get('errors').add('blogTitle', 'Please enter a blog title.');
this.invalidate();
}
return validationErrors;
}
}).create();
});
export default SetupValidator;

View File

@ -1,20 +1,24 @@
import Ember from 'ember';
var SigninValidator = Ember.Object.create({
check: function (model) {
var data = model.getProperties('identification', 'password'),
validationErrors = [];
import BaseValidator from './base';
if (validator.empty(data.identification)) {
validationErrors.push('Please enter an email');
} else if (!validator.isEmail(data.identification)) {
validationErrors.push('Invalid Email');
var SigninValidator = BaseValidator.create({
properties: ['identification', 'password'],
identification: function (model) {
var id = model.get('identification');
if (validator.empty(id)) {
model.get('errors').add('identification', 'Please enter an email');
} else if (!validator.isEmail(id)) {
model.get('errors').add('identification', 'Invalid email');
this.invalidate();
}
},
password: function (model) {
var password = model.get('password') || '';
if (!validator.isLength(data.password || '', 1)) {
validationErrors.push('Please enter a password');
if (!validator.isLength(password, 1)) {
model.get('errors').add('password', 'Please enter a password');
this.invalidate();
}
return validationErrors;
}
});

View File

@ -1,28 +1,30 @@
import Ember from 'ember';
var TagSettingsValidator = Ember.Object.create({
check: function (model) {
var validationErrors = [],
data = model.getProperties('name', 'meta_title', 'meta_description');
import BaseValidator from './base';
if (validator.empty(data.name)) {
validationErrors.push({
message: 'You must specify a name for the tag.'
});
var TagSettingsValidator = BaseValidator.create({
properties: ['name', 'metaTitle', 'metaDescription'],
name: function (model) {
var name = model.get('name');
if (validator.empty(name)) {
model.get('errors').add('name', 'You must specify a name for the tag.');
this.invalidate();
}
},
metaTitle: function (model) {
var metaTitle = model.get('meta_title');
if (!validator.isLength(data.meta_title, 0, 150)) {
validationErrors.push({
message: 'Meta Title cannot be longer than 150 characters.'
});
if (!validator.isLength(metaTitle, 0, 150)) {
model.get('errors').add('meta_title', 'Meta Title cannot be longer than 150 characters.');
this.invalidate();
}
},
metaDescription: function (model) {
var metaDescription = model.get('meta_description');
if (!validator.isLength(data.meta_description, 0, 200)) {
validationErrors.push({
message: 'Meta Description cannot be longer than 200 characters.'
});
if (!validator.isLength(metaDescription, 0, 200)) {
model.get('errors').add('meta_description', 'Meta Description cannot be longer than 200 characters.');
this.invalidate();
}
return validationErrors;
}
});

View File

@ -1,63 +1,69 @@
import BaseValidator from './base';
import Ember from 'ember';
var UserValidator = Ember.Object.create({
check: function (model) {
var validator = this.validators[model.get('status')];
if (typeof validator !== 'function') {
return [];
}
return validator(model);
var UserValidator = BaseValidator.create({
properties: ['name', 'bio', 'email', 'location', 'website', 'roles'],
isActive: function (model) {
return (model.get('status') === 'active');
},
name: function (model) {
var name = model.get('name');
validators: {
invited: function (model) {
var validationErrors = [],
email = model.get('email'),
roles = model.get('roles');
if (!validator.isEmail(email)) {
validationErrors.push({message: 'Please supply a valid email address'});
}
if (roles.length < 1) {
validationErrors.push({message: 'Please select a role'});
}
return validationErrors;
},
active: function (model) {
var validationErrors = [],
name = model.get('name'),
bio = model.get('bio'),
email = model.get('email'),
location = model.get('location'),
website = model.get('website');
if (this.isActive(model)) {
if (!validator.isLength(name, 0, 150)) {
validationErrors.push({message: 'Name is too long'});
model.get('errors').add('name', 'Name is too long');
this.invalidate();
}
}
},
bio: function (model) {
var bio = model.get('bio');
if (this.isActive(model)) {
if (!validator.isLength(bio, 0, 200)) {
validationErrors.push({message: 'Bio is too long'});
model.get('errors').add('bio', 'Bio is too long');
this.invalidate();
}
}
},
email: function (model) {
var email = model.get('email');
if (!validator.isEmail(email)) {
validationErrors.push({message: 'Please supply a valid email address'});
}
if (!validator.isEmail(email)) {
model.get('errors').add('email', 'Please supply a valid email address');
this.invalidate();
}
},
location: function (model) {
var location = model.get('location');
if (this.isActive(model)) {
if (!validator.isLength(location, 0, 150)) {
validationErrors.push({message: 'Location is too long'});
model.get('errors').add('location', 'Location is too long');
this.invalidate();
}
}
},
website: function (model) {
var website = model.get('website');
if (this.isActive(model)) {
if (!Ember.isEmpty(website) &&
(!validator.isURL(website, {require_protocol: false}) ||
!validator.isLength(website, 0, 2000))) {
validationErrors.push({message: 'Website is not a valid url'});
model.get('errors').add('website', 'Website is not a valid url');
this.invalidate();
}
}
},
roles: function (model) {
if (!this.isActive(model)) {
var roles = model.get('roles');
return validationErrors;
if (roles.length < 1) {
model.get('errors').add('role', 'Please select a role');
this.invalidate();
}
}
}
});