Skip to content

Commit

Permalink
feat(nesteddirty): adds NestedDirtyModel plugin
Browse files Browse the repository at this point in the history
  • Loading branch information
Chris Hanson committed Dec 10, 2014
1 parent 87bfee8 commit 04b6178
Show file tree
Hide file tree
Showing 3 changed files with 237 additions and 0 deletions.
2 changes: 2 additions & 0 deletions Gruntfile.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ module.exports = function(grunt) {
'dist/plugins/paged.js': 'src/plugins/paged.js',
'dist/plugins/find-many.js': 'src/plugins/find-many.js',
'dist/plugins/preload.js': 'src/plugins/preload.js',
'dist/plugins/nested-dirty.js': 'src/plugins/nested-dirty.js',
'dist/styles/ams.js': 'src/styles/ams.js'
}
}
Expand All @@ -58,6 +59,7 @@ module.exports = function(grunt) {
'dist/plugins/paged.min.js': 'dist/plugins/paged.js',
'dist/plugins/find-many.min.js': 'dist/plugins/find-many.js',
'dist/plugins/preload.min.js': 'dist/plugins/preload.js',
'dist/plugins/nested-dirty.min.js': 'dist/plugins/nested-dirty.js',
'dist/styles/ams.min.js': 'dist/styles/ams.js'
}
}
Expand Down
149 changes: 149 additions & 0 deletions src/plugins/nested-dirty.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
/**
* @mixin NestedDirtyModel
*
* @description Adds the `$dirty` method to a model`s instances. Acts in the same way as the DirtyModel plugin, but supports nested objects.
*/

'use strict';

angular.module('restmod').factory('NestedDirtyModel', ['restmod', function(restmod) {

// Helper function to check model for changes properties
function findChanged(_model, _original, _keys, _comparator) {
var changes = [], keys = angular.copy(_keys);

if(_original) {
for(var key in _original) {
// isObject returns true if value is an array
if(angular.isObject(_original[key]) && !angular.isArray(_original[key])) {
keys.push(key);
changes = changes.concat(findChanged(_model[key], _original[key], keys, _comparator));
} else {
if(isDirtyValue(_model, _original, [key], _comparator)) {
changes.push(keys.length ? (keys.push(key) && keys.join('.')) : key);
}
}

keys = angular.copy(_keys);
}
}

return changes;
}

// Compares original value with current value
function isDirtyValue(_model, _original, _keys, _comparator) {
var isDirty = false,
prop = _keys.pop(),
propChain = buildPropChain(_model, _original, _keys);

_model = propChain[0];
_original = propChain[1];

if(_original.hasOwnProperty(prop)) {
if(_comparator && angular.isFunction(_comparator)) {
isDirty = !!_comparator(_model[prop], _original[prop]);
} else { // Check via equality
// Join arrays before comparing them
if(angular.isArray(_original[prop])) {
isDirty = _model[prop].join('|') !== _original[prop].join('|');
} else {
isDirty = _model[prop] !== _original[prop];
}
}
}

return isDirty;
}

// Helper function to build a property chain from a set of keys
function buildPropChain(_model, _original, _keys) {
var key;

while(_keys.length) {
key = _keys.shift();
_model = _model[key];
_original = _original[key];
}

return [_model, _original];
}

return restmod.mixin(function() {
this.on('after-feed', function(_original) {
// store original information in a model's special property
this.$cmStatus = angular.copy(_original);
})
/**
* @method $dirty
* @memberof NestedDirtyModel#
*
* @description Retrieves the model changes
*
* Property changes are determined using the strict equality operator if no comparator
* function is provided.
*
* If given a property name, this method will return true if property has changed
* or false if it has not.
*
* The comparator function can be passed either as the first or second parameter.
* If first, this function will compare all properties using the comparator.
*
* Called without arguments, this method will return a list of changed property names.
*
* @param {string|function} _prop Property to query or function to compare all properties
* @param {function} _comparator Function to compare property
* @return {boolean|array} Property state or array of changed properties
*/
.define('$dirty', function(_prop, _comparator) {
var original = this.$cmStatus, keys;

if(_prop && !angular.isFunction(_prop)) {
keys = _prop.split('.');
return isDirtyValue(this, original, keys, _comparator);
} else {
if(angular.isFunction(_prop)) _comparator = _prop;
return findChanged(this, original, [], _comparator);
}
})
/**
* @method $restore
* @memberof NestedDirtyModel#
*
* @description Restores the model's last fetched values.
*
* Usage:
*
* ```javascript
* bike = Bike.$create({ brand: 'Trek' });
* // later on...
* bike.brand = 'Giant';
* bike.$restore();
*
* console.log(bike.brand); // outputs 'Trek'
* ```
*
* @param {string} _prop If provided, only _prop is restored
* @return {Model} self
*/
.define('$restore', function(_prop) {
return this.$action(function() {
var original = this.$cmStatus,
model = this;

if(_prop) {
var keys = _prop.split('.'), propChain;

_prop = keys.pop();
propChain = buildPropChain(model, original, keys);

propChain[0][_prop] = angular.copy(propChain[1][_prop]);
} else {
for(var key in original) {
if(original.hasOwnProperty(key)) model[key] = angular.copy(original[key]);
}
}
});
});
});
}]);
86 changes: 86 additions & 0 deletions test/plugins/nested-dirty-spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
'use strict';

describe('Plugin: Nested Dirty Model', function() {

var bike;

beforeEach(module('restmod'));

beforeEach(module(function($provide, restmodProvider) {
restmodProvider.rebase('NestedDirtyModel');
$provide.factory('Bike', function(restmod) {
return restmod.model('/api/bikes');
});
}));

beforeEach(inject(function($httpBackend, Bike) {
// generate a model instance with server loaded data:
$httpBackend.when('GET', '/api/bikes/1').respond(200, {
model: 'Meta 2',
colours: { frame: 'red' },
brandName: 'Commencal',
stickers: ['bmx', 'bandits'],
customisations: { wheels: 2, seat: { material: 'lycra' } }
});
bike = Bike.$find(1);
$httpBackend.flush();
}));

describe('`$dirty` function', function() {
it('should list changes on nested objects', function() {
bike.brandName = 'BMX';
expect(bike.$dirty()).not.toContain('customisations.wheels');

bike.customisations.wheels = 3;
bike.colours.frame = 'green';
expect(bike.$dirty()).toContain('customisations.wheels');
expect(bike.$dirty()).toContain('colours.frame');

bike.customisations.seat.material = 'leather';
expect(bike.$dirty()).toContain('customisations.seat.material');
});

it('should compare arrays', function() {
bike.brandName = 'BMX';
expect(bike.$dirty()).not.toContain('stickers');
bike.stickers.pop();
expect(bike.$dirty()).toContain('stickers');
});

it('should compare with comparator function', function() {
bike.customisations.wheels = 3;
expect(bike.$dirty('customisations.wheels', function (newVal, oldVal) {
return newVal * oldVal > 5;
})).toBe(true);
});

it('should let you pass comparator as first argument', function() {
bike.colours.frame = 'Brown';
bike.brandName = 'BMX';
bike.model = 'Strider';

expect(bike.$dirty(function (newVal) {
return angular.isString(newVal) && newVal.match(/^B/);
}).length).toEqual(2);
});

});

describe('$restore', function() {
it('should restore nested property', function() {
bike.customisations.wheels = 4;
bike.customisations.seat.material = 'leather';
bike.$restore('customisations.wheels');
expect(bike.customisations.wheels).toEqual(2);
expect(bike.customisations.seat.material).toEqual('leather');
});

it('should restore an entire nested object', function() {
bike.customisations.wheels = 8;
bike.customisations.seat.material = 'cloth';
bike.$restore('customisations');
expect(bike.customisations.wheels).toEqual(2);
expect(bike.customisations.seat.material).toEqual('lycra');
});
});
});

0 comments on commit 04b6178

Please sign in to comment.