Skip to content

Instantly share code, notes, and snippets.

@kbjr
Created June 13, 2015 20:58
Show Gist options
  • Save kbjr/9cdef50a99f9cb77d290 to your computer and use it in GitHub Desktop.
Save kbjr/9cdef50a99f9cb77d290 to your computer and use it in GitHub Desktop.
Model With Change History
(function() {
//
var isInheriting = false;
//
//
//
function Model(originalDTO) {
if (! isInheriting) {
defineHidden(this, 'dto', originalDTO);
defineHidden(this, 'originalDTO', deepCopy(originalDTO));
defineHidden(this, 'changeStack', new ChangeStack());
}
}
// -------------------------------------------------------------
// Expose
window.Model = Model;
// Define the inheritence function
Model.inherit = inherit(Model);
// -------------------------------------------------------------
//
//
//
Model.prototype.markUnsavedChanges = function() {
defineUnwritable(this, 'hasUnsavedChanges', true);
};
//
//
//
Model.prototype.markNoUnsavedChanges = function() {
defineUnwritable(this, 'hasUnsavedChanges', false);
};
//
//
//
Model.prototype.set = function(path, value) {
var change = new SetChange(path, value);
if (change.apply(this.dto)) {
this.markUnsavedChanges();
this.changeStack.push(change);
}
};
//
//
//
Model.prototype.get = function(path) {
return findObjectScope(this.dto, path).get();
};
// -------------------------------------------------------------
//
//
//
function ChangeStack() {
this.stack = [ ];
this.index = 0;
}
//
//
//
ChangeStack.prototype.push = function(path, change) {
// Slice off any of the stack that has been undone at this point as that
// history will be overwritten from this point on
this.stack.length = this.index;
// Push the next change into the stack
this.stack.push(change);
// Bump the index
this.index++;
};
//
//
//
ChangeStack.prototype.apply = function(obj, range) {
this.range(range).forEach(function(change) {
change.apply(obj);
});
};
//
// Step backwards through the given range of changes reverting them
//
// @param {obj} the object to apply to the change function
// @param {range} an inclusive range of change indexes
// @return void
//
ChangeStack.prototype.revert = function(obj, range) {
this.range(range).reverse().forEach(function(change) {
change.revert(obj);
});
};
//
//
//
ChangeStack.prototype.range = function(range) {
var changes;
// If given an actual range of changes, grab a list of changes inside that range
if (Array.isArray(range)) {
var min, max;
// The range could be given in either order, so determine the correct one
(range[0] < range[1])
? (min = range[0], max = range[1])
: (min = range[1], max = range[0]);
// The range is inclusive on both sides, so [1, 3] gives changes at the indexes 1, 2, and 3
changes = this.stack.slice(min, max + 1);
}
// If given a single change index, just wrap it in an array
else {
changes = [ this.stack[range] ];
}
return changes;
};
//
//
//
ChangeStack.prototype.undo = function(scope) {
if (this.index > 0) {
this.revert(scope, --this.index);
}
};
//
//
//
ChangeStack.prototype.redo = function(scope) {
if (this.index < this.stack.length) {
this.apply(scope, this.index++);
}
};
// -------------------------------------------------------------
//
// A class that represents a single change to the data set
//
// @param {path} the path being changed
// @param {apply} the function that applies the change
// @param {revert} the function that reverts the change
//
function Change(path, apply, revert) {
if (! isInheriting) {
this.path = path;
this.apply = apply;
this.revert = revert;
}
}
// Define the inherit function
Change.inherit = inherit(Change);
//
// Finds the correct scope on a data object
//
// @param {obj} the data object to look in
// @return object
//
Change.prototype.find = function(obj) {
return findObjectScope(obj, this.path);
};
// -------------------------------------------------------------
//
// A subclass of the Change class, represents a single property set
//
// @param {path} the path to set the value on
// @param {value} the new value to set
//
function SetChange(path, value) {
this.path = path;
this.value = value;
}
// Inherit from Change
SetChange.prototype = Change.inherit();
//
//
//
SetChange.prototype.apply = function(obj) {
var scope = this.find(obj);
this.previous = scope.get();
scope.set(this.value);
return (this.previous !== this.value);
};
//
//
//
SetChange.prototype.revert = function(obj) {
// If the previous property does not exist, this change has never been applied,
// and therefore cannot be reverted
if (this.hasOwnProperty('previous')) {
var scope = this.find(obj);
var old = scope.get();
scope.set(this.previous);
return (old !== this.previous);
}
};
// -------------------------------------------------------------
//
// Finds a specified property path in a nested object structure
//
// @param {obj} the object to search
// @param {path} the property path (eg. "foo.bar.[0].{baz:qux}.property")
// @return object
//
function findObjectScope(obj, path) {
var scope = obj;
var steps = path.split('.');
var property = steps.pop();
var current;
var parsed;
try {
while (current = steps.shift()) {
parsed = parseProperty(current);
parsed = stepDownKey(scope, parsed);
scope = scope[parsed];
}
current = property;
property = parseProperty(property);
property = stepDownKey(scope, property);
}
// If any error occurs during the parsing, return a dummy object
// with an error message
catch (err) {
return {
error: err,
current: current,
object: obj,
path: path,
scope: scope,
property: null,
get: function() { /* noop */ },
set: function() { /* noop */ }
};
}
// Return the "scoped" object property descriptor
return {
error: null,
current: current,
object: obj,
path: path,
scope: scope,
property: property,
get: function() {
return scope[property];
},
set: function(value) {
scope[property] = value;
}
};
}
//
// Parses a single property step in the path
//
// @param {prop} the property string (eg. "foo", "[0]", or "{bar:baz}")
// @return string|array
//
function parseProperty(prop) {
var wrapped;
// "Array" style bracket syntax
if (wrapped = wrappedIn(prop, '[', ']')) {
return wrapped;
}
// "Object" style key:value pair syntax
else if (wrapped = wrappedIn(prop, '{', '}')) {
return wrapped.split(':');
}
// "Standard" string property name
else {
return prop;
}
}
//
// Determines if a string is wrapped in the given characters, and returns the unwrapped
// string if it is
//
// @param {str} the string to check
// @param {begin} the opening string
// @param {end} the closing string
// @return string|false
//
function wrappedIn(str, begin, end) {
if (str[0] === begin && str.slice(-1) === end) {
return str.slice(1, -1);
}
return false;
}
//
// Given a parsed property from `parseProperty`, find the actual matching property name
// on the real object so we can step down into the next scope
//
// @param {obj} the object to look in
// @param {prop} the property to look for
// @return string
//
function stepDownKey(obj, prop) {
if (Array.isArray(prop)) {
Object.keys(obj).some(function(key) {
var value = obj[key];
if (value && typeof value === 'object' && value[prop[0]] === prop[1]) {
prop = key;
return true;
}
});
}
return prop;
}
// -------------------------------------------------------------
//
//
//
function defineHidden(obj, prop, value) {
Object.defineProperty(obj, prop, {
value: value,
writable: false,
configurable: true,
enumerable: false
});
}
//
//
//
function defineUnwritable(obj, prop, value) {
Object.defineProperty(obj, prop, {
value: value,
writable: false,
configurable: true,
enumerable: true
});
}
//
//
//
function deepCopy(obj) {
//
}
//
//
//
function inherit(Class) {
return function() {
isInheriting = true;
var proto = new Class();
isInheriting = false;
return proto;
};
}
}());
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment