Created
June 13, 2015 20:58
-
-
Save kbjr/9cdef50a99f9cb77d290 to your computer and use it in GitHub Desktop.
Model With Change History
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
(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