npm install validated-changeset --save
tl;dr
This library is the base class for functionality in ember-changeset but could be used with any front end framework. Example uses in template assume handlebars.
import { Changeset } from 'validated-changeset';
let changeset = Changeset(user, validatorFn);
user.firstName;
user.lastName;
changeset.set('firstName', 'Jim');
changeset.set('lastName', 'B');
changeset.get('isInvalid');
changeset.get('errors');
changeset.set('lastName', 'Bob');
changeset.get('isValid');
user.firstName;
user.lastName;
changeset.save();
user.firstName;
user.lastName;
Usage
First, create a new Changeset
through JavaScript:
import { Changeset } from 'validated-changeset';
export default class FormComponent {
constructor(...args) {
let validatorFn = this.validate;
this.changeset = Changeset(this.model, validatorFn);
}
}
The helper receives any Object 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.
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.
Full API
Changeset(model, lookupValidator(validationMap), validationMap, { skipValidate: boolean, initValidate: boolean, changesetKeys: string[] });
-
model
(required)
-
validationFunc
(optional) - see ember-changeset-validations for usage.
-
validationMap
(optional) - see ember-changeset-validations for usage.
note: validationMap
might not be inclusive of all keys that can be set on an object.
-
changesetKeys
(optional) - will ensure your changeset and related isDirty
state is contained to a specific enum of keys. If a key that is not in changesetKeys
is set on the changeset, it will not dirty the changeset.
-
initValidate
(optional) - will run the validations and set the validation state when the changeset is created. This option does not support async validations.
Alternative Changeset
We now ship a ValidatedChangeset that is a proposed new API we would like to ship. The goal of this refactor is to remove confusing APIs and externalize validations.
- ✂️
save
- ✂️
cast
- ✂️
merge
errors
are required to be added to the Changeset manually after validate
validate
takes a callback with the sum of changes. In user land you will call changeset.validate((changes) => yupSchema.validate(changes))
import { ValidationChangeset } from 'validated-changeset';
import { array, object, string, number, date } from 'yup';
const UserSchema = object({
name: string().required(),
age: number()
.required()
.positive()
.integer(),
email: string().email(),
website: string()
.url()
.nullable(),
createdOn: date().default(() => new Date())
});
export default class FormComponent {
constructor(...args) {
this.changeset = ValidationChangeset(this.model);
}
onSubmit() {
try {
await this.changeset.validate(changes => UserSchema.validate(changes));
this.changeset.removeError()
} catch (e) {
dummyChangeset.addError(e.path, { value: changeset.get(e.path), validation: e.message });
}
}
}
Examples
API
- Properties
- Methods
- Events
error
Returns the error object.
{
firstName: {
value: 'Jim',
validation: 'First name must be greater than 7 characters'
}
}
Note that keys can be arbitrarily nested:
{
address: {
zipCode: {
value: '123',
validation: 'Zip code must have 5 digits'
}
}
}
If you have multiple validations, validation
will be an array:
{
address: {
zipCode: {
value: '123',
validation: ['Zip code must have 5 digits', 'too short']
}
}
}
You can use this property to locate a single error:
{{#if changeset.error.firstName}}
<p>{{changeset.error.firstName.validation}}</p>
{{/if}}
{{#if changeset.error.address.zipCode}}
<p>{{changeset.error.address.zipCode.validation}}</p>
{{/if}}
⬆️ back to top
change
Returns the change object.
{
firstName: 'Jim'
}
Note that keys can be arbitrarily nested:
{
address: {
zipCode: '10001'
}
}
You can use this property to locate a single change:
{{changeset.change.firstName}}
{{changeset.change.address.zipCode}}
⬆️ 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'
},
{
key: 'address.zipCode',
value: '123',
validation: 'Zip code must have 5 digits'
}
]
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'
},
{
key: 'address.zipCode',
value: 10001
}
]
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
data
Returns the Object that was wrapped in the changeset.
let user = { name: 'Bobby', age: 21, address: { zipCode: '10001' } };
let changeset = Changeset(user);
changeset.get('data');
⬆️ back to top
isValid
Returns a Boolean value of the changeset's validity.
changeset.get('isValid');
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.
changeset.get('isInvalid');
You can use this property in the template:
{{#if changeset.isInvalid}}
<p>There were one or more errors in your form</p>
{{/if}}
⬆️ back to top
isPristine
Returns a Boolean value of the changeset's state. A pristine changeset is one with no changes.
changeset.get('isPristine');
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 necessarily vice versa:
let user = { name: 'Bobby', age: 21, address: { zipCode: '10001' } };
changeset.set('name', 'Bobby');
changeset.get('isPristine');
changeset.set('address.zipCode', '10001');
changeset.get('isPristine');
changeset.set('foo', 'bar');
changeset.get('isPristine');
⬆️ back to top
isDirty
Returns a Boolean value of the changeset's state. A dirty changeset is one with changes.
changeset.get('isDirty');
⬆️ 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.
changeset.get('firstName');
changeset.set('firstName', 'Billy');
changeset.get('firstName');
changeset.get('address.zipCode');
changeset.set('address.zipCode', '94016');
changeset.get('address.zipCode');
You can use and bind this property in the template:
{{input value=changeset.firstName}}
Note that using Ember.get
will not necessarily work if you're expecting an Object. On the other hand, using changeset.get
will work just fine:
get(changeset, 'momentObj').format('dddd');
changeset.get('momentObj').format('dddd');
This is because Changeset
wraps an Object with Ember.ObjectProxy
internally, and overrides Ember.Object.get
to hide this implementation detail.
Because an Object is wrapped with Ember.ObjectProxy
, the following (although more verbose) will also work:
get(changeset, 'momentObj.content').format('dddd');
⬆️ back to top
set
Exactly the same semantics as Ember.set
. This stores the change on the changeset. It is recommended to use changeset.set(...)
instead of Ember.set(changeset, ...)
. Ember.set
will set the property for nested keys on the underlying model.
changeset.set('firstName', 'Milton');
changeset.set('address.zipCode', '10001');
You can use and bind this property in the template:
{{input value=changeset.firstName}}
{{input value=changeset.address.country}}
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) => {
let modified = {};
for (let key in changes) {
let newKey = key.split('.').map(underscore).join('.')
modified[newKey] = changes[key];
}
return modified;
});
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();
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.
Moreover, if you need to perform additional work on changeset.execute
, you can register a callback with a key 'execute' and we will ensure it is carried out whenever changeset.execute
is called.
function callback() {
...
}
changeset.on('execute', callback);
changeset.execute();
unexecute
Undo changes made to underlying Object for changeset. This is often useful if you want to remove changes from underlying Object if save
fails.
changeset
.save()
.catch(() => {
dummyChangeset.unexecute();
})
⬆️ back to top
save
Executes changes, then proxies to the underlying Object's save
method, if one exists. If it does, the method can either return a Promise
or a non-Promise
value. Either way, the changeset's save
method will return
a promise.
changeset.save();
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 Changeset with the same underlying content and validator as the origin. Both changesets must point to the same underlying object. For example:
let changesetA = Changeset(user, validatorFn);
let changesetB = Changeset(user, validatorFn);
changesetA.set('firstName', 'Jim');
changesetA.set('address.zipCode', '94016');
changesetB.set('firstName', 'Jimmy');
changesetB.set('lastName', 'Fallon');
changesetB.set('address.zipCode', '10112');
let changesetC = changesetA.merge(changesetB);
changesetC.execute();
user.firstName;
user.lastName;
user.address.zipCode;
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
Rolls back all unsaved changes and resets all errors.
changeset.rollback();
⬆️ back to top
rollbackInvalid
Rolls back all invalid unsaved changes and resets all errors. Valid changes will be kept on the changeset.
changeset.rollbackInvalid();
⬆️ back to top
rollbackProperty
Rolls back unsaved changes for the specified property only. All other changes will be kept on the changeset.
let changeset = Changeset(user);
changeset.set('firstName', 'Jimmy');
changeset.set('lastName', 'Fallon');
changeset.rollbackProperty('lastName');
changeset.execute();
user.firstName;
user.lastName;
⬆️ back to top
validate
Validates all, single or multiple fields 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.
Note: This method requires a validation map to be passed in when the changeset is first instantiated.
user.lastName = 'B';
user.address = {
zipCode: '123'
};
let validationMap = {
lastName: validateLength({ min: 8 }),
'address.zipCode': validateLength({ is: 5 }),
};
let changeset = Changeset(user, validatorFn, validationMap);
changeset.get('isValid');
changeset.validate('lastName');
changeset.validate('address.zipCode');
changeset.validate('lastName', 'address.zipCode');
changeset.validate().then(() => {
changeset.get('isInvalid');
changeset.get('errors');
});
⬆️ back to top
addError
Manually add an error to the changeset.
changeset.addError('email', {
value: 'jim@bob.com',
validation: 'Email already taken'
});
changeset.addError('address.zip', {
value: '123',
validation: 'Must be 5 digits'
});
changeset.addError('email', 'Email already taken');
changeset.addError('address.zip', 'Must be 5 digits');
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.
⬆️ back to top
pushErrors
Manually push errors to the changeset.
changeset.pushErrors('age', 'Too short', 'Not a valid number', 'Must be greater than 18');
changeset.pushErrors('dogYears.age', 'Too short', 'Not a valid number', 'Must be greater than 2.5');
⬆️ 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();
⬆️ back to top
restore
Restores a snapshot of changes and errors to the changeset. This overrides existing changes and errors.
let user = { name: 'Adam', address: { country: 'United States' } };
let changeset = Changeset(user, validatorFn);
changeset.set('name', 'Jim Bob');
changeset.set('address.country', 'North Korea');
let snapshot = changeset.snapshot();
changeset.set('name', 'Poteto');
changeset.set('address.country', 'Australia')
changeset.restore(snapshot);
changeset.get('name');
changeset.get('address.country');
⬆️ 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', 'address.country'];
let changeset = Changeset(user, validatorFn);
changeset.set('name', 'Jim Bob');
changeset.set('address.country', 'United States');
changeset.set('unwantedProp', 'foo');
changeset.set('address.unwantedProp', 123);
changeset.get('unwantedProp');
changeset.get('address.unwantedProp');
changeset.cast(allowed);
changeset.get('unwantedProp');
changeset.get('address.country');
changeset.get('another.unwantedProp');
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:
export const schema = {
name: attr('string'),
password: attr('string')
};
export default Model.extend(schema);
import { schema } from '../models/user';
export default Controller.extend({
actions: {
save(changeset) {
return changeset
.cast(Object.keys(schema))
.save();
}
}
});
⬆️ back to top
isValidating
Checks to see if async validator for a given key has not resolved. If no key is provided it will check to see if any async validator is running.
changeset.set('lastName', 'Appleseed');
changeset.set('firstName', 'Johnny');
changeset.set('address.city', 'Anchorage');
changeset.validate();
changeset.isValidating();
changeset.isValidating('lastName');
changeset.isValidating('address.city');
changeset.validate().then(() => {
changeset.isValidating();
});
back to top
beforeValidation
This event is triggered after isValidating is set to true for a key, but before the validation is complete.
changeset.on('beforeValidation', key => {
console.log(`${key} is validating...`);
});
changeset.validate();
changeset.isValidating();
⬆️ back to top
afterValidation
This event is triggered after async validations are complete and isValidating is set to false for a key.
changeset.on('afterValidation', key => {
console.log(`${key} has completed validating`);
});
changeset.validate().then(() => {
changeset.isValidating();
});
⬆️ back to top
afterRollback
This event is triggered after a rollback of the changeset.
This can be used for some advanced use cases
where it is necessary to separately track all changes that are made to the changeset.
changeset.on('afterRollback', () => {
console.log("changeset has rolled back");
});
changeset.rollback();
⬆️ 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:
import { action } from '@ember/object';
export default class FormController {
@action
validate({ key, newValue, oldValue, changes, content }) {
}
}
{{! application/template.hbs}}
<DummyForm @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 'validated-changeset';
if (isChangeset(model)) {
model.execute();
}
<input
type={{type}}
value={{get model valuePath}}
oninput={{action (action "checkValidity" changeset) value="target.value"}}
onblur={{action "validateProperty" changeset valuePath}}
disabled={{disabled}}
placeholder={{placeholder}}>
Contributors
See all the wonderful contributors who have made this library possible.
Installation
git clone
this repositorynpm install
Running Tests
npm run test
npm run test:all
will run the current commit sha against both ember-changeset
and ember-changeset-validations
.
Building