Skip to content

Instantly share code, notes, and snippets.

@heycarsten
Last active December 23, 2015 12:59
Show Gist options
  • Save heycarsten/6638842 to your computer and use it in GitHub Desktop.
Save heycarsten/6638842 to your computer and use it in GitHub Desktop.
Ember-Validate: This is an initial spike for an approach to validations in Ember, it's extracted from an app I'm building and it works for me in my current use-case. I plan on developing this into a proper, tested library soon, but until then, I thought I'd share!

Ember-Validate

I've had to tackle the client validations problem in almost every Ember app I've ever written, and I've done it in a bunch of different ways. This is the way I ended up liking most. There are a few things that I really wanted to have in a validation framework:

  • Not tied to models or any modelling framework
  • Promise-based
  • Not reliant on extending Ember itself
  • No dependencies outside of Ember
  • Minimal view helpers (i.e. no form builder)

Scenario

Suppose we have a signup workflow in our Ember app. We have some validations that we'd like to run locally, some that require XHR, and when we decide to post our signup data, if some validation fails on the sever we'd like to display those errors to the user as well.

Controller

App.SignupController = Em.Controller.extend(Em.Validatable, {
  actions: {
    save: function() {
      this.save();
    }
  },

  email: null,
  username: null,
  bio: null,
  password: null,
  passwordConfirm: null,

  // Here's where your client-side validations go, this could also be placed
  // at the model level, but I'm just not quite sure yet if that's actually
  // a good idea.
  willValidate: function() {
    // There are some helpers to handle common use-cases:
    this.validatesEmail('email');
    this.validatesLength('bio', { allowBlank: true, max: 250 });
    this.validatesLength('username', { max: 20 });

    // It's very straight-forward to write your own validations:
    this.validates(function(outcome) {
      if (this.get('password') === this.get('passwordConfirm')) {
        outcome.valid();
      } else {
        outcome.invalid('passwordConfirm', 'does not match password');
      }
    });

    // Even ones that are a bit more involved:
    this.validates(function(outcome) {
      var xhr = $.getJSON('/api/users/exists', { u: this.get('username') });

      // Returning the promise here allows any crazy to be caught and handled.
      return xhr.then(function(payload) {
        if (payload.exists) {
          outcome.valid();
        } else {
          outcome.invalid('username', 'is already taken');
        }
      });
    });
  },

  // This brings us to persisting this data to the server and dealing with
  // any validation errors we might encounter.
  asJSON: function() {
    var json = this.getProperties([
      'email',
      'username',
      'bio',
      'password'
    ]);

    return { signup: json };
  },

  save: function() {
    var controller = this,
        validate   = this.validate();

    validate.then(function(isValid) {
      if (!isValid) return;

      var xhr = $.ajax({
        url: '/api/signups',
        data: this.asJSON(),
        type: 'post',
        context: controller
      });

      // If our API call succeeds, then we can do what we need to do:
      xhr.then(function(payload) {
        App.auth.logIn(payload.session);
      });

      // If our API call fails, then we can add the errors. It's probably a good
      // idea to check for the correct status code here before trying to parse
      // any errors. For example if you actually got a 5XX error instead of the
      // 422 you were expecting.
      //
      // It's also important to point out that the format I'm expecting back
      // from our pretend server is:
      //
      // {
      //   "errors": [
      //     {
      //       "field": "underscored_property_name",
      //       "message": "is not proper, proper is best"
      //     },
      //     ...
      //   ]
      // }
      //
      // Obviously you could munge whatever you get into what we need, which is:
      //
      // [{ property: 'nameOfProperty', message: 'is not valid' }, ...]
      xhr.fail(function(http) {
        var payload = JSON.parse(http.responseText);
            errors  = payload.errors.map(function(error) {
              return {
                property: error.field ? error.field.camelize() : null,
                message: error.message
              };
            });

        // The validationErrors object has an easy to use API
        this.get('validationErrors').add(errors);
      });
    });

    validate.fail(function(exception) {
      // Something went wonky during validation, such as an XHR request failed.
    });
  }
});

Template

Now, we need to display this stuff to the user, I really wanted this to be as minimal as possible, I ended up with three helpers: {{validation-errors}}, {{field}}, and {{label}}. Here's how they work:

{{vaidation-errors}}

Responsible for displaying error messages, by default it only displays errors that don't belong to a property. It's workings exist in Em.Validate.ErrorsView.

{{field}}

Responsible for displaying a form control and rendering any errors associated with it, also responsible for resetting errors on a field once it regains focus. The workings of {{field}} exist in Em.Validation.FieldView.

{{label}}

Associates a label to a field element, this is sort of a classic tricky problem in Ember.

<!-- signup.hbs -->

<form {{action "save" on="submit"}}>
  <fieldset>
    <legend>Sign Up For This Cloud Service</legend>
    <!-- Any errors that don't belong to a specific property are displayed here -->
    {{validation-errors}}
    <ul>
      <li>
        {{label for="email"}}
        {{field for="email" placeholder="[email protected]"}}
      </li>
      <li>
        {{label for="username"}}
        {{field for="username" placeholder="eg: tomdale, wycats, ebryn"}}
      </li>
      <li>
        {{label for="bio"}}
        {{field for="bio" type="textarea" placeholder="A short summary of yourself."}}
      </li>
      <li>
        {{label for="password"}}
        {{field for="password" type="password" placeholder="Password"}}
        {{field for="passwordConfirm" type="password" placeholder="Confirm Password"}}
      </li>
    </ul>
    <p>
      <button type="submit">
        Sign Up
      </button>
    </p>
  </fieldset>
</form>

TODO

  • Package
  • Full test suite
  • JSBin example
  • Make the {{field}} helper a lot better
  • More configurability
  • Discuss reflection and how to integrate it
  • More validation helpers based off real-world use-cases
  • Consider support for validating single properties without affecting others
  • Consider support for sequential validations per property

❤ ❤ ❤ @heycarsten ❤ ❤ ❤

void function() {
Em.TEMPLATES['ember/validate/field'] = Em.Handlebars.compile(
'{{view view.controlView viewName="control"}}\n' +
'{{#if view.isInvalid}}\n' +
'{{#unbound if view.isInline}}\n' +
'{{#view Em.Validate.InlineErrorView}}\n' +
'{{view.errorMessage}}\n' +
'{{/view}}\n' +
'{{/unbound}}\n' +
'{{/if}}\n'
);
Em.TEMPLATES['ember/validate/errors'] = Em.Handlebars.compile(
'{{#if view.errors}}\n' +
'<ul class="validation-error-messages">\n' +
'{{#each error in view.errors}}\n' +
'<li class="validation-error-message">\n' +
'{{#if error.property}}\n' +
'{{error.propertyName}}\n' +
'{{/if}}\n' +
'{{error.message}}\n' +
'</li>\n' +
'{{/each}}\n' +
'</ul>\n' +
'{{/if}}\n'
);
Em.Validate = {};
var decamelize = Em.String.decamelize,
capitalize = Em.String.capitalize,
trim = Em.$.trim,
utils,
computed,
config;
config = {
EMAIL_RE: /^[^@]+@[^@]+$/,
DEFAULT_INLINE_ERROR_TAG_NAME: 'div',
DEFAULT_FIELD_TAG_NAME: 'span',
DEFAULT_FIELD_TARGET: 'controller',
DEFAULT_FIELD_TEMPLATE_NAME: 'ember/validate/field',
DEFAULT_ERRORS_TEMPLATE_NAME: 'ember/validate/errors',
BLANK_ERROR_MESSAGE: 'must be provided'
};
config.lookup = function(name) {
var value = config[name];
if (value === undefined) {
throw new Error('Em.Validate.config.' + value + ' does not exist');
}
return value;
};
utils = {
isThenable: function(value) {
return (typeof value === 'object') && (value.then !== undefined);
},
isBlank: function(val) {
return trim(val) === '';
},
isPresent: function(val) {
return trim(val) !== '';
},
toHumanString: function(val) {
return capitalize(decamelize(val).replace(/_/g, ' '));
},
toTitleCase: function (str) {
return str.replace(/\w\S*/g, function(word) {
return capitalize(word);
});
},
resolveTarget: function(context, pathOrObj) {
if (!pathOrObj) {
pathOrObj = config.DEFAULT_FIELD_TARGET;
}
var isString = (typeof pathOrObj === 'string');
return isString ? context.get(pathOrObj) : pathOrObj;
}
};
computed = {
resolveTarget: function() {
return Em.computed(function() {
return utils.resolveTarget(this, this.get('target'));
}).property('target');
},
configLookup: function(name) {
return Em.computed(function() {
return config.lookup(name);
}).property();
}
};
Em.Validate.utils = utils;
Em.Validate.computed = computed;
Em.Validate.config = config;
}.call(this);
/// core
void function() {
var utils = Em.Validate.utils,
config = Em.Validate.config,
isThenable = utils.isThenable,
isBlank = utils.isBlank,
isPresent = utils.isPresent,
toHumanString = utils.toHumanString,
toTitleCase = utils.toTitleCase,
EMAIL_RE = config.EMAIL_RE,
BLANK_ERROR_MESSAGE = config.BLANK_ERROR_MESSAGE;
function buildErrorMessage(context, defaultMsg) {
var value = (context.options.message || defaultMsg),
type = Em.typeOf(value),
mkmsg = type === 'function' ? value : (function() { return value; });
return mkmsg(context);
}
function newOutcomePromise(validator) {
return new Em.RSVP.Promise(function(resolve, reject) {
var outcomeProxy = {
error: function(e) {
reject(e);
},
valid: function() {
resolve({ isValid: true });
},
invalid: function(property, message) {
var err = { isValid: false };
if (arguments.length === 1) {
err.property = null;
err.message = property;
} else {
err.property = property;
err.message = message;
}
resolve(err);
}
};
var response = validator(outcomeProxy);
if (isThenable(response)) {
response.then(null, function(e) {
outcomeProxy.error(e);
});
}
});
}
Em.Validate.Error = Em.Object.extend({
property: null,
message: null,
propertyName: Em.computed(function() {
var property = this.get('property');
if (!property) {
return null;
} else {
return toHumanString(property);
}
}).property('property')
});
Em.Validate.Errors = Em.ArrayProxy.extend({
hasItems: Em.computed.bool('length'),
isEmpty: Em.computed.not('hasItems'),
add: function(errors) {
[].concat(errors).forEach(function(error) {
this.pushObject(Em.Validate.Error.create(error));
}, this);
},
on: function(prop) {
return this.filterBy('property', prop);
},
clearOn: function(prop) {
var existing = this.on(prop);
existing.forEach(function(obj) {
this.removeObject(obj);
}, this);
}
});
Em.Validate.Validatable = Em.Mixin.create({
init: function() {
this._super();
this.set('validationErrors', Em.Validate.Errors.create({ content: [] }));
this.set('validationOutcomes', []);
},
isValidating: false,
isValid: Em.computed.oneWay('validationErrors.isEmpty'),
isInvalid: Em.computed.not('isValid'),
willValidate: Em.K,
didValidate: Em.K,
validates: function(validator) {
var promise = newOutcomePromise(validator.bind(this));
this.get('validationOutcomes').push(promise);
return promise;
},
clearValidations: function() {
this.get('validationErrors').clear();
this.get('validationOutcomes').clear();
},
validate: function() {
var obj = this,
outcomes = this.get('validationOutcomes'),
errors = obj.get('validationErrors');
return new Em.RSVP.Promise(function(resolve, reject) {
function done(results) {
results.forEach(function(result) {
if (!result.isValid) {
errors.add(result);
}
});
obj.set('isValidating', false);
obj.didValidate(obj.get('isValid'), errors);
resolve(obj.get('isValid'), errors);
}
function fail(exception) {
obj.set('isValidating', false);
reject(exception);
}
obj.set('isValidating', true);
obj.clearValidations();
obj.willValidate();
Em.RSVP.all(outcomes).then(done, fail);
});
},
validatesProperty: function(property, options, validator) {
if (!options) {
options = {};
}
var value = this.get(property);
this.validates(function(outcome) {
if (options.allowBlank && !isBlank(value)) {
return outcome.valid();
} else if (isBlank(value)) {
return outcome.invalid(property, BLANK_ERROR_MESSAGE);
}
validator.call({
options: options,
property: {
name: property,
value: value
},
valid: function() {
outcome.valid();
},
invalid: function(defaultMsg) {
outcome.invalid(property, buildErrorMessage({
property: property,
value: value,
options: options
}, defaultMsg));
}
}, value);
});
},
validatesFormat: function(property, options) {
this.validatesProperty(property, options, function(value) {
var regex = this.options.regex;
if (regex.test(value)) {
this.valid();
} else {
this.invalid('has incorrect format');
}
});
},
validatesLength: function(property, options) {
if (!options) {
options = {};
}
var min = options.min,
max = options.max;
this.validatesProperty(property, options, function(value) {
if (!value || !value.length) {
return this.invalid('must be provided');
}
if (value.length === 1 && min === 1) {
return this.invalid('must be provided');
}
if (value.length < min) {
return this.invalid('must be at least ' + min + ' characters');
}
if (value.length > max) {
return this.invalid('must be no longer than ' + max + ' characters');
}
this.valid();
});
},
validatesEmail: function(property, options) {
if (!options) {
options = {};
}
if (!options.message) {
options.message = 'is not a valid address';
}
if (!options.regex) {
options.regex = EMAIL_RE;
}
this.validatesFormat(property, options);
},
validatesNumeric: function(property, options) {
this.validatesProperty(property, options, function(value) {
if (Em.$.isNumeric(value)) {
this.valid();
} else {
this.invalid('is not a number');
}
});
},
validatesPresence: function(property, options) {
if (!options) {
options = {};
}
options.allowBlank = false;
this.validatesProperty(property, options, function(value) {
if (isPresent(value)) {
this.valid();
} else {
this.invalid('must be provided');
}
});
}
});
Em.Validatable = Em.Validate.Validatable;
}.call(this);
// helpers
void function() {
var propertiesFromHTMLOptions = Em.Handlebars.ViewHelper.propertiesFromHTMLOptions,
fmt = Em.String.fmt,
config = Em.Validate.config,
utils = Em.Validate.utils,
computed = Em.Validate.computed,
toHumanString = utils.toHumanString,
toTitleCase = utils.toTitleCase;
var EXTENDED_CONTROLS = [
'TextField', // Em.Validate.TextField
'TextArea', // Em.Validate.TextArea
'Select', // Em.Validate.Select
'Checkbox' // Em.Validate.Checkbox
];
Em.Validate.ControlSupport = Em.Mixin.create({
value: Em.computed.alias('parentView.value'),
isValid: Em.computed.oneWay('parentView.isValid'),
classNameBindings: ['isValid:valid:invalid'],
focusIn: function() {
this.get('parentView').clearErrors();
}
});
EXTENDED_CONTROLS.forEach(function(name) {
Em.Validate[name] = Em[name].extend(Em.Validate.ControlSupport);
});
Em.Validate.FieldView = Em.View.extend({
templateName: computed.configLookup('DEFAULT_FIELD_TEMPLATE_NAME'),
classNameBindings: ['isValid:valid:invalid'],
controlProperties: null,
controlType: null,
isInline: true,
inline: Em.computed.alias('isInline'),
isInvalid: Em.computed.oneWay('_target.isInvalid'),
firstError: Em.computed.oneWay('errors.firstObject'),
errorMessage: Em.computed.oneWay('firstError.message'),
_target: computed.resolveTarget(),
init: function() {
var errorsPath = '_target.validationErrors',
propertyName = this.get('for'),
valuePath = '_target.' + propertyName;
this.reopen({
errors: Em.computed.filterBy(errorsPath, 'property', propertyName),
value: Em.computed.alias(valuePath)
});
this.set('tagName', config.lookup('DEFAULT_FIELD_TAG_NAME'));
this._super();
},
clearErrors: function() {
var property = this.get('for'),
errors = this.get('_target.validationErrors');
errors.clearOn(property);
},
controlView: Em.computed(function() {
var props = this.get('controlProperties'),
type = this.get('controlType'),
view;
if (type === 'checkbox') {
view = 'Checkbox';
} else if (type === 'select') {
view = 'Select';
} else if (type === 'textarea') {
view = 'TextArea';
} else {
props.type = type;
view = 'TextField';
}
return Em.Validate[view].extend(props);
}).property('type')
});
Em.Validate.ErrorsView = Em.View.extend({
templateName: computed.configLookup('DEFAULT_ERRORS_TEMPLATE_NAME'),
isInline: true,
inline: Em.computed.alias('isInline'),
allErrors: Em.computed.oneWay('_target.validationErrors'),
_target: computed.resolveTarget(),
init: function() {
var cp;
if (this.get('isInline')) {
cp = Em.computed.filter('allErrors', function(error) {
return !error.property;
});
} else {
cp = Em.computed.oneWay('allErrors');
}
this.reopen({ errors: cp });
this._super();
}
});
Em.Validate.InlineErrorView = Em.View.extend({
classNames: ['validation-error-message'],
errorMessage: Em.computed.oneWay('parentView.errorMessage'),
init: function() {
this.set('tagName', config.lookup('DEFAULT_INLINE_ERROR_TAG_NAME'));
this._super();
}
});
Em.Validate.LabelView = Em.View.extend({
tagName: 'label',
'for': null,
caption: null,
template: Em.Handlebars.compile('{{unbound view._caption}}'),
attributeBindings: ['controlElementId:for'],
_caption: Em.computed(function() {
var caption = this.get('caption'),
property;
if (caption) {
return caption;
}
property = this.get('for');
return property && toTitleCase(toHumanString(property));
}).property('caption'),
init: function() {
var fieldName = this.get('for') + 'Field',
controlIdPath = fmt('parentView.%@.control.elementId', [fieldName]),
controlIdCP = Em.computed(function() {
return this.get(controlIdPath);
}).property(controlIdPath);
this.reopen({
controlElementId: controlIdCP
});
this._super();
}
});
Em.Handlebars.registerHelper('field', function(options) {
Ember.assert('You can only pass attributes to the `field` helper, not arguments', arguments.length < 2);
Ember.assert('You must specify a `for` attribute to define the property to be used for the field helper', !options.for);
var hash = options.hash,
type = hash.type,
view,
controlProperties;
delete hash.type;
controlProperties = propertiesFromHTMLOptions(options, this);
hash.viewName = (controlProperties['for'] + 'Field');
view = Em.Validate.FieldView.extend({
controlType: type,
controlProperties: controlProperties
});
return Em.Handlebars.ViewHelper.helper(this, view, options);
});
Em.Handlebars.helper('validation-errors', Em.Validate.ErrorsView);
Em.Handlebars.helper('label', Em.Validate.LabelView);
}.call(this);
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment