Ember.js flavored changesets, inspired by Ecto
JavaScript HTML CSS
Latest commit 97e59b4 Feb 20, 2017 @poteto poteto Released v1.2.2

README.md

ember-changeset Download count all time CircleCI npm version Ember Observer Score

Ember.js flavored changesets, inspired by Ecto. To install:

ember install ember-changeset

Watch a free video intro presented by EmberScreencasts

Philosophy

The idea behind a changeset is simple: it represents a set of valid changes to be applied onto any Object (Ember.Object, DS.Model, POJOs, etc). Each change is tested against an optional validation, and if valid, the change is stored and applied when executed.

Given Ember's Data Down, Actions Up (DDAU) approach, a changeset is more appropriate compared to implicit 2 way bindings. Other validation libraries only validate a property after it is set on an Object, which means that your Object can enter an invalid state.

ember-changeset only allows valid changes to be set, so your Objects will never become invalid (assuming you have 100% validation coverage). Additionally, this addon is designed to be un-opinionated about your choice of form and/or validation library, so you can easily integrate it into an existing solution.

The simplest way to incorporate validations is to use ember-changeset-validations, a companion addon to this one. It has a simple mental model, and there are no Observers or CPs involved – just pure functions.

See also the plugins section for addons that extend ember-changeset.

tl;dr

let changeset = new Changeset(user, validatorFn);
user.get('firstName'); // "Michael"
user.get('lastName'); // "Bolton"

changeset.set('firstName', 'Jim');
changeset.set('lastName', 'B');
changeset.get('isInvalid'); // true
changeset.get('errors'); // [{ key: 'lastName', validation: 'too short', value: 'B' }]
changeset.set('lastName', 'Bob');
changeset.get('isValid'); // true

user.get('firstName'); // "Michael"
user.get('lastName'); // "Bolton"

changeset.save(); // sets and saves valid changes on the user
user.get('firstName'); // "Jim"
user.get('lastName'); // "Bob"

Usage

First, create a new Changeset using the changeset helper or through JavaScript:

{{! application/template.hbs}}
{{dummy-form
    changeset=(changeset model (action "validate"))
    submit=(action "submit")
    rollback=(action "rollback")
}}
import Ember from 'ember';
import Changeset from 'ember-changeset';

const { Component, get } = Ember;

export default Component.extend({
  init() {
    this._super(...arguments);
    let model = get(this, 'model');
    let validator = get(this, 'validate');
    this.changeset = new Changeset(model, validator);
  }
});

The helper receives any Object (including DS.Model, Ember.Object, or even POJOs) and an optional validator action. If a validator is passed into the helper, the changeset will attempt to call that function when a value changes.

// application/controller.js
import Ember from 'ember';

const { Controller } = Ember;

export default Controller.extend({
  actions: {
    submit(changeset) {
      return changeset.save();
    },

    rollback(changeset) {
      return changeset.rollback();
    },

    validate({ key, newValue, oldValue, changes, content }) {
      // lookup a validator function on your favorite validation library
      // should return a Boolean
    }
  }
});

Then, in your favorite form library, simply pass in the changeset in place of the original model.

{{! dummy-form/template.hbs}}
<form>
  {{input value=changeset.firstName}}
  {{input value=changeset.lastName}}

  <button {{action submit changeset}}>Submit</button>
  <button {{action rollback changeset}}>Cancel</button>
</form>

In the above example, when the input changes, only the changeset's internal values are updated. When the submit button is clicked, the changes are only executed if all changes are valid.

On rollback, all changes are dropped and the underlying Object is left untouched.

API

error

Returns the error object.

{
  firstName: {
    value: 'Jim',
    validation: 'First name must be greater than 7 characters'
  }
}

You can use this property to locate a single error:

{{#if changeset.error.firstName}}
  <p>{{changeset.error.firstName.validation}}</p>
{{/if}}

⬆️ back to top

change

Returns the change object.

{
  firstName: 'Jim'
}

You can use this property to locate a single change:

{{changeset.change.firstName}}

⬆️ back to top

errors

Returns an array of errors. If your validate function returns a non-boolean value, it is added here as the validation property.

[
  {
    key: 'firstName',
    value: 'Jim',
    validation: 'First name must be greater than 7 characters'
  }
]

You can use this property to render a list of errors:

{{#if changeset.isInvalid}}
  <p>There were errors in your form:</p>
  <ul>
    {{#each changeset.errors as |error|}}
      <li>{{error.key}}: {{error.validation}}</li>
    {{/each}}
  </ul>
{{/if}}

⬆️ back to top

changes

Returns an array of changes to be executed. Only valid changes will be stored on this property.

[
  {
    key: 'firstName',
    value: 'Jim'
  }
]

You can use this property to render a list of changes:

<ul>
  {{#each changeset.changes as |change|}}
    <li>{{change.key}}: {{change.value}}</li>
  {{/each}}
</ul>

⬆️ back to top

isValid

Returns a Boolean value of the changeset's validity.

get(changeset, 'isValid'); // true

You can use this property in the template:

{{#if changeset.isValid}}
  <p>Good job!</p>
{{/if}}

⬆️ back to top

isInvalid

Returns a Boolean value of the changeset's (in)validity.

get(changeset, 'isInvalid'); // true

You can use this property in the template:

{{#if changeset.isInvalid}}
  <p>There were one or more errors in your form</p>
{{/if}}

isPristine

Returns a Boolean value of the changeset's state. A pristine changeset is one with no changes.

get(changeset, 'isPristine'); // true

If changes present on the changeset are equal to the content's, this will return true. However, note that key/value pairs in the list of changes must all be present and equal on the content, but not vice versa:

let user = { name: 'Bobby', age: 21 };
changeset.set('name', 'Bobby');
changeset.get('isPristine'); // true

changeset.set('foo', 'bar');
changeset.get('isPristine'); // false

⬆️ back to top

isDirty

Returns a Boolean value of the changeset's state. A dirty changeset is one with changes.

get(changeset, 'isDirty'); // true

⬆️ back to top

get

Exactly the same semantics as Ember.get. This proxies first to the error value, the changed value, and finally to the underlying Object.

get(changeset, 'firstName'); // "Jim"
set(changeset, 'firstName', 'Billy'); // "Billy"
get(changeset, 'firstName'); // "Billy"

You can use and bind this property in the template:

{{input value=changeset.firstName}}

⬆️ back to top

set

Exactly the same semantics as Ember.set. This stores the change on the changeset.

set(changeset, 'firstName', 'Milton'); // "Milton"

You can use and bind this property in the template:

{{input value=changeset.firstName}}

Any updates on this value will only store the change on the changeset, even with 2 way binding.

⬆️ back to top

prepare

Provides a function to run before emitting changes to the model. The callback function must return a hash in the same shape:

changeset.prepare((changes) => {
  // changes = { firstName: "Jim", lastName: "Bob" };
  let modified = {};

  for (let key in changes) {
    modified[underscore(key)] = changes[key];
  }

  // don't forget to return, the original changes object is not mutated
  return modified; // { first_name: "Jim", last_name: "Bob" }
}); // returns changeset

The callback function is not validated – if you modify a value, it is your responsibility to ensure that it is valid.

Returns the changeset.

⬆️ back to top

execute

Applies the valid changes to the underlying Object.

changeset.execute(); // returns changeset

Note that executing the changeset will not remove the internal list of changes - instead, you should do so explicitly with rollback or save if that is desired.

⬆️ back to top

save

Executes changes, then proxies to the underlying Object's save method, if one exists. If it does, it expects the method to return a Promise.

changeset.save(); // returns Promise

The save method will also remove the internal list of changes if the save is successful.

⬆️ back to top

merge

Merges 2 changesets and returns a new changeset with the same underlying content and validator as the origin. Both changesets must point to the same underlying object. For example:

let changesetA = new Changeset(user, validatorFn);
let changesetB = new Changeset(user, validatorFn);
changesetA.set('firstName', 'Jim');
changesetB.set('firstName', 'Jimmy');
changesetB.set('lastName', 'Fallon');
let changesetC = changesetA.merge(changesetB);
changesetC.execute();
user.get('firstName'); // "Jimmy"
user.get('lastName'); // "Fallon"

Note that both changesets A and B are not destroyed by the merge, so you might want to call destroy() on them to avoid memory leaks.

⬆️ back to top

rollback

Rollsback all unsaved changes and resets all errors.

changeset.rollback(); // returns changeset

⬆️ back to top

validate

Validates all or a single field on the changeset. This will also validate the property on the underlying object, and is a useful method if you require the changeset to validate immediately on render. Requires a validation map to be passed in when the changeset is first instantiated.

user.set('lastName', 'B');
changeset.get('isValid'); // true
changeset.validate('lastName'); // validate single field; returns Promise
changeset.validate().then(() => {
  changeset.get('isInvalid'); // true
  changeset.get('errors'); // [{ key: 'lastName', validation: 'too short', value: 'B' }]
}); // validate all fields; returns Promise

⬆️ back to top

addError

Manually add an error to the changeset.

changeset.addError('email', {
  value: 'jim@bob.com',
  validation: 'Email already taken'
});

// shortcut
changeset.addError('email', 'Email already taken');

Adding an error manually does not require any special setup. The error will be cleared if the value for the key is subsequently set to a valid value. Adding an error will overwrite any existing error or change for key.

If using the shortcut method, the value in the changeset will be used as the value for the error.

pushErrors

Manually push errors to the changeset.

changeset.pushErrors('age', 'Too short', 'Not a valid number', 'Must be greater than 18');

This is compatible with ember-changeset-validations, and allows you to either add a new error with multiple validations messages or push to an existing array of validation messages.

⬆️ back to top

snapshot

Creates a snapshot of the changeset's errors and changes. This can be used to restore the changeset at a later time.

let snapshot = changeset.snapshot(); // snapshot

⬆️ back to top

restore

Restores a snapshot of changes and errors to the changeset. This overrides existing changes and errors.

let user = { name: 'Adam' };
let changeset = new Changeset(user, validatorFn);
changeset.set('name', 'Jim Bob');

let snapshot = changeset.snapshot();
changeset.set('name', 'Potato');
changeset.restore(snapshot);
changeset.get('name'); // "Jim Bob"

⬆️ back to top

cast

Unlike Ecto.Changeset.cast, cast will take an array of allowed keys and remove unwanted keys off of the changeset.

let allowed = ['name', 'password']
let changeset = new Changeset(user, validatorFn);
changeset.set('name', 'Jim Bob');
changeset.set('unwantedProp', 123);

changeset.get('unwantedProp'); // 123
changeset.cast(allowed); // returns changeset
changeset.get('unwantedProp'); // undefined

For example, this method can be used to only allow specified changes through prior to saving. This is especially useful if you also setup a schema object for your model (using Ember Data), which can then be exported and used as a list of allowed keys:

// models/user.js
export const schema = {
  name: attr('string'),
  password: attr('string')
};

export default Model.extend(schema);
// controllers/foo.js
import { schema } from '../models/user';
const { keys } = Object;

export default Controller.extend({
  // ...
  actions: {
    save(changeset) {
      return changeset
        .cast(keys(schema))
        .save();
    }
  }
});

⬆️ back to top

Validation signature

To use with your favorite validation library, you should create a custom validator action to be passed into the changeset:

// application/controller.js
import Ember from 'ember';

const { Controller } = Ember;

export default Controller.extend({
  actions: {
    validate({ key, newValue, oldValue, changes, content }) {
      // lookup a validator function on your favorite validation library
      // should return a Boolean
    }
  }
});
{{! application/template.hbs}}
{{dummy-form changeset=(changeset model (action "validate"))}}

Your action will receive a single POJO containing the key, newValue, oldValue, a one way reference to changes, and the original object content.

Handling Server Errors

When you run changeset.save(), under the hood this executes the changeset, and then runs the save method on your original content object, passing its return value back to you. You are then free to use this result to add additional errors to the changeset via the addError method, if applicable.

For example, if you are using an Ember Data model in your route, saving the changeset will save the model. If the save rejects, Ember Data will add errors to the model for you. To copy the model errors over to your changeset, add a handler like this:

changeset.save()
  .then(() => { /* ... */ })
  .catch(() => {
    get(this, 'model.errors').forEach(({ attribute, message }) => {
      changeset.pushErrors(attribute, message);
    });
  });

Detecting Changesets

If you're uncertain whether or not you're dealing with a Changeset, you can use the isChangeset util.

import isChangeset from 'ember-changeset/utils/is-changeset';

if (isChangeset(model)) {
  model.execute();
  // other changeset-specific code...
}

Plugins

Installation

  • git clone this repository
  • npm install
  • bower install

Running

  • ember server
  • Visit your app at http://localhost:4200.

Running Tests

  • npm test (Runs ember try:testall to test your addon against multiple Ember versions)
  • ember test
  • ember test --server

Building

  • ember build

For more information on using ember-cli, visit http://ember-cli.com/.