From 45b746ce23b038593e3f71a03094c5149a05dcd8 Mon Sep 17 00:00:00 2001 From: Oliver Nightingale Date: Thu, 23 Sep 2010 10:10:17 +0100 Subject: [PATCH 01/14] rough version of validation helpers --- lib/bundler.rb | 1 + src/model.js | 2 +- src/model_instance_methods.js | 10 ------- src/model_validations.js | 46 +++++++++++++++++++++++++++++++++ test/tests/model_validations.js | 41 +++++++++++++++++++++++++++++ test/views/index.erb | 1 + 6 files changed, 90 insertions(+), 11 deletions(-) create mode 100644 src/model_validations.js create mode 100644 test/tests/model_validations.js diff --git a/lib/bundler.rb b/lib/bundler.rb index 1d2ee35..98c4c19 100644 --- a/lib/bundler.rb +++ b/lib/bundler.rb @@ -37,6 +37,7 @@ def files model_log model_rest_persistence model_uid + model_validations model_version ) end diff --git a/src/model.js b/src/model.js index c631628..bf1d071 100644 --- a/src/model.js +++ b/src/model.js @@ -30,7 +30,7 @@ var Model = function(name, class_methods, instance_methods) { if (persistence) model.persistence = persistence(model) // Add default and custom instance methods. - jQuery.extend(model.prototype, Model.Callbacks, Model.InstanceMethods, + jQuery.extend(model.prototype, Model.Callbacks, Model.InstanceMethods, Model.Validations, instance_methods); return model; diff --git a/src/model_instance_methods.js b/src/model_instance_methods.js index f88e37e..bb37256 100644 --- a/src/model_instance_methods.js +++ b/src/model_instance_methods.js @@ -110,15 +110,5 @@ Model.InstanceMethods = { update: function(attributes) { this.merge(attributes).trigger("update"); return this; - }, - - valid: function() { - this.errors.clear(); - this.validate(); - return this.errors.size() === 0; - }, - - validate: function() { - return this; } }; diff --git a/src/model_validations.js b/src/model_validations.js new file mode 100644 index 0000000..e9ea612 --- /dev/null +++ b/src/model_validations.js @@ -0,0 +1,46 @@ +Model.Validations = { + runValidations: function () { + var self = this; + var validations = { + validatesPresenceOf: function (attrValue, attrName, options) { + if (!attrValue) { + self.errors.add(attrName, "should not be blank"); + }; + }, + + validatesLengthOf: function (attrValue, attrName, options) { + if (attrValue.length < options.min || attrValue.length > options.max) { + self.errors.add(attrName, "is too short or too long"); + }; + } + }; + + for (rule in validations) { + var attributesToValidate = this[rule] || []; + for (var i=0; i < attributesToValidate.length; i++) { + if (typeof(attributesToValidate[i]) == "string") { + // no options passed, just attribute names + var attrName = attributesToValidate[i]; + var options = {}; + } else { + // options passed + var attrName = attributesToValidate[i].attr; + var options = attributesToValidate[i]; + }; + validations[rule](this.attr(attrName), attrName, options); + }; + }; + + }, + + valid: function() { + this.errors.clear(); + this.validate(); + this.runValidations(); + return this.errors.size() === 0; + }, + + validate: function() { + return this; + } +}; \ No newline at end of file diff --git a/test/tests/model_validations.js b/test/tests/model_validations.js new file mode 100644 index 0000000..ac9c018 --- /dev/null +++ b/test/tests/model_validations.js @@ -0,0 +1,41 @@ +module("Model.Validations") + +test("validatesPresenceOf", function () { + var Post = Model("post", {}, { + validatesPresenceOf: ['title'] + }); + + var validPost = new Post ({ title: "Foo", body: "..." }); + var invalidPost_noTitle = new Post ({body: "..."}); + var invalidPost_blankTitle = new Post ({title: "", body: "..."}); + + ok(validPost.valid(), "should be valid with a title") + ok(!invalidPost_noTitle.valid(), "should be invalid without a title attribute") + ok(!invalidPost_blankTitle.valid(), "should be invalid with a blank title attribute") +}) + +test("validatesLengthOf", function () { + var Post = Model("post", {}, { + validatesLengthOf: [{ attr: 'title', min: 5, max: 10 }] + }); + + var validPost = new Post ({ title: "just right", body: "..." }); + var invalidPost_tooShort = new Post ({ title: "1", body: "..." }); + var invalidPost_tooLong = new Post ({ title: "this is too long!", body: "..." }); + + ok(validPost.valid(), "should be valid with the right length title") + ok(!invalidPost_tooShort.valid(), "should be invalid with a title that is too short") + ok(!invalidPost_tooLong.valid(), "should be invalid with a title that is too long") +}) + +test("validatesLengthOf with default options", function () { + var Post = Model("post", {}, { + validatesLengthOf: [{ attr: 'title', max: 10 }] + }); + + var validPost = new Post ({ title: "just right", body: "..." }); + var invalidPost_tooLong = new Post ({ title: "this is too long!", body: "..." }); + + ok(validPost.valid(), "should be valid with the right length title") + ok(!invalidPost_tooLong.valid(), "should be invalid with a title that is too long") +}) \ No newline at end of file diff --git a/test/views/index.erb b/test/views/index.erb index 62a5233..69945b7 100644 --- a/test/views/index.erb +++ b/test/views/index.erb @@ -23,6 +23,7 @@ +

js-model Tests

From fb6a91b6bf962bd0e23d27003f0e44d2b35198aa Mon Sep 17 00:00:00 2001 From: Oliver Nightingale Date: Fri, 24 Sep 2010 12:10:16 +0100 Subject: [PATCH 02/14] declare validation rules on the class not the instance --- src/model.js | 9 +++- src/model_instance_methods.js | 13 ++++++ src/model_validations.js | 73 ++++++++++++++++----------------- test/tests/model_validations.js | 25 +++++++---- 4 files changed, 74 insertions(+), 46 deletions(-) diff --git a/src/model.js b/src/model.js index bf1d071..62766e2 100644 --- a/src/model.js +++ b/src/model.js @@ -10,9 +10,11 @@ var Model = function(name, class_methods, instance_methods) { this.uid = [name, Model.UID.generate()].join("-") }; - // Persistence is special, remove it from class_methods. + // Persistence & validations are special, remove them from class_methods. var persistence = class_methods.persistence + var validation_rules = class_methods.validates; delete class_methods.persistence + delete class_methods.validates; // Apply class methods and extend with any custom class methods. Make sure // vitals are added last so they can't be overridden. @@ -29,6 +31,11 @@ var Model = function(name, class_methods, instance_methods) { // Initialise persistence with a reference to the class. if (persistence) model.persistence = persistence(model) + // Initialise a validator object for this class. + if (validation_rules) { + model.validator = Model.Validator(validation_rules); + }; + // Add default and custom instance methods. jQuery.extend(model.prototype, Model.Callbacks, Model.InstanceMethods, Model.Validations, instance_methods); diff --git a/src/model_instance_methods.js b/src/model_instance_methods.js index bb37256..830e23b 100644 --- a/src/model_instance_methods.js +++ b/src/model_instance_methods.js @@ -110,5 +110,18 @@ Model.InstanceMethods = { update: function(attributes) { this.merge(attributes).trigger("update"); return this; + }, + + valid: function() { + this.errors.clear(); + this.validate(); + if (this.constructor.validator) { + this.constructor.validator.run.call(this); + }; + return this.errors.size() === 0; + }, + + validate: function() { + return this; } }; diff --git a/src/model_validations.js b/src/model_validations.js index e9ea612..512b9c3 100644 --- a/src/model_validations.js +++ b/src/model_validations.js @@ -1,46 +1,43 @@ -Model.Validations = { - runValidations: function () { - var self = this; - var validations = { - validatesPresenceOf: function (attrValue, attrName, options) { - if (!attrValue) { - self.errors.add(attrName, "should not be blank"); - }; - }, +Model.Validator = function (rules) { + var rules = rules; - validatesLengthOf: function (attrValue, attrName, options) { - if (attrValue.length < options.min || attrValue.length > options.max) { - self.errors.add(attrName, "is too short or too long"); - }; - } - }; + var validationMethods = { + presenceOf: function (attrName, attrValue, options) { + if (!attrValue) { + this.errors.add(attrName, "should not be blank"); + }; + }, - for (rule in validations) { - var attributesToValidate = this[rule] || []; - for (var i=0; i < attributesToValidate.length; i++) { - if (typeof(attributesToValidate[i]) == "string") { - // no options passed, just attribute names - var attrName = attributesToValidate[i]; - var options = {}; - } else { - // options passed - var attrName = attributesToValidate[i].attr; - var options = attributesToValidate[i]; - }; - validations[rule](this.attr(attrName), attrName, options); + lengthOf: function (attrName, attrValue, options) { + if (attrValue.length < options.min || attrValue.length > options.max) { + this.errors.add(attrName, "is too short or too long"); }; + } + }; + + var ruleWithoutOptions = function (ruleName, ruleValue) { + for (var i=0; i < ruleValue.length; i++) { + validationMethods[ruleName].call(this, ruleValue[i], this.attr(ruleValue[i]), {}); }; + }; - }, + var ruleWithOptions = function (ruleName, ruleValue) { + for (attributeName in ruleValue) { + validationMethods[ruleName].call(this, attributeName, this.attr(attributeName), ruleValue[attributeName]); + }; + }; - valid: function() { - this.errors.clear(); - this.validate(); - this.runValidations(); - return this.errors.size() === 0; - }, + var run = function () { + for (rule in rules) { + if ($.isArray(rules[rule])) { + ruleWithoutOptions.call(this, rule, rules[rule]); + } else { + ruleWithOptions.call(this, rule, rules[rule]); + }; + }; + }; - validate: function() { - return this; - } + return { + run: run + }; }; \ No newline at end of file diff --git a/test/tests/model_validations.js b/test/tests/model_validations.js index ac9c018..c92edf8 100644 --- a/test/tests/model_validations.js +++ b/test/tests/model_validations.js @@ -1,9 +1,12 @@ module("Model.Validations") test("validatesPresenceOf", function () { - var Post = Model("post", {}, { - validatesPresenceOf: ['title'] - }); + + var Post = Model("post", { + validates: { + presenceOf: ['title'] + } + }, {}); var validPost = new Post ({ title: "Foo", body: "..." }); var invalidPost_noTitle = new Post ({body: "..."}); @@ -15,8 +18,12 @@ test("validatesPresenceOf", function () { }) test("validatesLengthOf", function () { - var Post = Model("post", {}, { - validatesLengthOf: [{ attr: 'title', min: 5, max: 10 }] + var Post = Model("post", { + validates: { + lengthOf: { + 'title': { min: 5, max: 10} + } + } }); var validPost = new Post ({ title: "just right", body: "..." }); @@ -29,8 +36,12 @@ test("validatesLengthOf", function () { }) test("validatesLengthOf with default options", function () { - var Post = Model("post", {}, { - validatesLengthOf: [{ attr: 'title', max: 10 }] + var Post = Model("post", { + validates: { + lengthOf: { + 'title': { max: 10 } + } + } }); var validPost = new Post ({ title: "just right", body: "..." }); From ee4ad93c7e71163347b769dcfd783f6c24dba279 Mon Sep 17 00:00:00 2001 From: Oliver Nightingale Date: Fri, 24 Sep 2010 15:34:35 +0100 Subject: [PATCH 03/14] tidy up validatesPresenceOf a little bit --- src/model_validations.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/model_validations.js b/src/model_validations.js index 512b9c3..c32e741 100644 --- a/src/model_validations.js +++ b/src/model_validations.js @@ -3,7 +3,7 @@ Model.Validator = function (rules) { var validationMethods = { presenceOf: function (attrName, attrValue, options) { - if (!attrValue) { + if ((attrValue !== undefined && attrValue.length == 0) || (attrValue === undefined)) { this.errors.add(attrName, "should not be blank"); }; }, From d185d54718477db858cea8c6e7a17f5563d25347 Mon Sep 17 00:00:00 2001 From: Oliver Nightingale Date: Fri, 24 Sep 2010 15:35:02 +0100 Subject: [PATCH 04/14] add basic validatesNumericalityOf --- src/model_validations.js | 6 ++++++ test/tests/model_validations.js | 16 ++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/src/model_validations.js b/src/model_validations.js index c32e741..89e745b 100644 --- a/src/model_validations.js +++ b/src/model_validations.js @@ -8,6 +8,12 @@ Model.Validator = function (rules) { }; }, + numericalityOf: function (attrName, attrValue, options) { + if (typeof attrValue != 'number') { + this.errors.add(attrName, "should be numeric"); + }; + }, + lengthOf: function (attrName, attrValue, options) { if (attrValue.length < options.min || attrValue.length > options.max) { this.errors.add(attrName, "is too short or too long"); diff --git a/test/tests/model_validations.js b/test/tests/model_validations.js index c92edf8..df1b451 100644 --- a/test/tests/model_validations.js +++ b/test/tests/model_validations.js @@ -17,6 +17,22 @@ test("validatesPresenceOf", function () { ok(!invalidPost_blankTitle.valid(), "should be invalid with a blank title attribute") }) +test("validatesNumericalityOf without options", function () { + var Post = Model("post", { + validates: { + numericalityOf: ['views'] + } + }, {}); + + var validPost = new Post({ views: 10 }) + var invalidPost_notNumeric = new Post({ views: 'not numeric' }) + var invalidPost_missingAttribute = new Post({ notViews: 1}) + + ok(validPost.valid(), "should be valid with a numeric attribute") + ok(!invalidPost_notNumeric.valid(), "should not be valid with a string value") + ok(!invalidPost_missingAttribute.valid(), "should not be valid if the attr is missing, by default") +}) + test("validatesLengthOf", function () { var Post = Model("post", { validates: { From 9a0c4dfc3ca6c53bedac1ed1050e0d0b21c855f7 Mon Sep 17 00:00:00 2001 From: Oliver Nightingale Date: Fri, 24 Sep 2010 16:25:17 +0100 Subject: [PATCH 05/14] allow numeric strings to pass validatesNumericalityOf --- src/model_validations.js | 14 ++++++++++++-- test/tests/model_validations.js | 17 +++++++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/src/model_validations.js b/src/model_validations.js index 89e745b..73a0035 100644 --- a/src/model_validations.js +++ b/src/model_validations.js @@ -8,9 +8,19 @@ Model.Validator = function (rules) { }; }, + // numeric strings will pass validation by default, i.e. numericalityOf: function (attrName, attrValue, options) { - if (typeof attrValue != 'number') { - this.errors.add(attrName, "should be numeric"); + var self = this; + var addError = function () { self.errors.add(attrName, "should be numeric"); }; + // http://dl.dropbox.com/u/35146/js/tests/isNumber.html + var isNumeric = function (n) { return !isNaN(parseFloat(n)) && isFinite(n); } + + if (options.allowNumericStrings) { + if (!isNumeric(attrValue)) { + addError(); + }; + } else if (typeof(attrValue) != "number" || isNaN(attrValue)) { + addError(); }; }, diff --git a/test/tests/model_validations.js b/test/tests/model_validations.js index df1b451..c6bbc14 100644 --- a/test/tests/model_validations.js +++ b/test/tests/model_validations.js @@ -25,14 +25,31 @@ test("validatesNumericalityOf without options", function () { }, {}); var validPost = new Post({ views: 10 }) + var validPost_stringReprOfNumber = new Post ({ views: "10"}) var invalidPost_notNumeric = new Post({ views: 'not numeric' }) var invalidPost_missingAttribute = new Post({ notViews: 1}) ok(validPost.valid(), "should be valid with a numeric attribute") + ok(!validPost_stringReprOfNumber.valid(), "should not be valid with a string representation of a number, by default") ok(!invalidPost_notNumeric.valid(), "should not be valid with a string value") ok(!invalidPost_missingAttribute.valid(), "should not be valid if the attr is missing, by default") }) +test("validatesNumericalityOf with options", function () { + + var Post = Model("post", { + validates: { + numericalityOf: { + 'views': { allowNumericStrings: true } + } + } + }, {}); + + var validPost_stringReprOfNumber = new Post ({ views: "10"}) + + ok(validPost_stringReprOfNumber.valid(), "should be valid with a string representation of a number") +}) + test("validatesLengthOf", function () { var Post = Model("post", { validates: { From cf0d6b0ecb908b05eff948b346bb1e469429cbbb Mon Sep 17 00:00:00 2001 From: Oliver Nightingale Date: Tue, 12 Oct 2010 14:32:55 +0100 Subject: [PATCH 06/14] update from upstream --- VERSION | 2 +- dist/js-model-0.8.4.js | 449 +++++++++++++++++++++++++++++++------ dist/js-model-0.8.4.min.js | 30 ++- 3 files changed, 401 insertions(+), 80 deletions(-) diff --git a/VERSION b/VERSION index 965065d..1550cb8 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.9.3 +0.9.3.custom \ No newline at end of file diff --git a/dist/js-model-0.8.4.js b/dist/js-model-0.8.4.js index 724a1ae..2d1fb95 100644 --- a/dist/js-model-0.8.4.js +++ b/dist/js-model-0.8.4.js @@ -7,36 +7,58 @@ var Model = function(name, class_methods, instance_methods) { class_methods = class_methods || {}; instance_methods = instance_methods || {}; + // The model constructor. var model = function(attributes) { this.attributes = attributes || {}; this.changes = {}; this.errors = new Model.Errors(this); + this.uid = [name, Model.UID.generate()].join("-") }; + // Persistence & validations are special, remove them from class_methods. + var persistence = class_methods.persistence + var validation_rules = class_methods.validates; + delete class_methods.persistence + delete class_methods.validates; + + // Apply class methods and extend with any custom class methods. Make sure + // vitals are added last so they can't be overridden. jQuery.extend(model, Model.Callbacks, Model.ClassMethods, class_methods, { _name: name, collection: [], + // Convenience method to allow a simple method of chaining class methods. chain: function(collection) { return jQuery.extend({}, this, { collection: collection }); } }); - jQuery.extend(model.prototype, Model.Callbacks, Model.InstanceMethods, + // Initialise persistence with a reference to the class. + if (persistence) model.persistence = persistence(model) + + // Initialise a validator object for this class. + if (validation_rules) { + model.validator = Model.Validator(validation_rules); + }; + + // Add default and custom instance methods. + jQuery.extend(model.prototype, Model.Callbacks, Model.InstanceMethods, Model.Validations, instance_methods); return model; }; -Model.Callbacks = { - callbacks: {}, +Model.Callbacks = { bind: function(event, callback) { + this.callbacks = this.callbacks || {} this.callbacks[event] = this.callbacks[event] || []; this.callbacks[event].push(callback); return this; }, trigger: function(name, data) { + this.callbacks = this.callbacks || {} + var callbacks = this.callbacks[name]; if (callbacks) { @@ -49,6 +71,8 @@ Model.Callbacks = { }, unbind: function(event, callback) { + this.callbacks = this.callbacks || {} + if (callback) { var callbacks = this.callbacks[event] || []; @@ -64,17 +88,19 @@ Model.Callbacks = { return this; } }; + Model.ClassMethods = { add: function() { var added = []; + var uids = this.uids() for (var i = 0; i < arguments.length; i++) { var model = arguments[i]; - var existing_elem = this.detect(function() { - return this.id() !== null && this.id() == model.id(); - }); - if (!existing_elem) { + if (jQuery.inArray(model, this.collection) === -1 && + !(model.id() && this.find(model.id())) && + jQuery.inArray(model.uid, uids) === -1) + { this.collection.push(model); added.push(model); } @@ -94,20 +120,24 @@ Model.ClassMethods = { }, detect: function(func) { - var model; - jQuery.each(this.all(), function(i) { - if (func.call(this, i)) { - model = this; - return false; - } - }); - return model || null; + var all = this.all(), + model + + for (var i = 0, length = all.length; i < length; i++) { + model = all[i] + if (func.call(model, i)) return model + } + + return null }, each: function(func) { - jQuery.each(this.all(), function(i) { - func.call(this, i); - }); + var all = this.all() + + for (var i = 0, length = all.length; i < length; i++) { + func.call(all[i], i) + } + return this; }, @@ -121,15 +151,49 @@ Model.ClassMethods = { return this.all()[0] || null; }, + load: function(callback) { + if (this.persistence) { + var self = this + + this.persistence.read(function(models) { + for (var i = 0, length = models.length; i < length; i++) { + self.add(models[i]) + } + + if (callback) callback(models) + }) + } + + return this + }, + last: function() { var all = this.all(); return all[all.length - 1] || null; }, - remove: function(id) { - var ids = _.invoke(this.collection, 'id'); - var index = _.indexOf(ids, id); - if (index > -1) { + pluck: function(attribute) { + var all = this.all() + var plucked = [] + + for (var i = 0, length = all.length; i < length; i++) { + plucked.push(all[i].attr(attribute)) + } + + return plucked + }, + + remove: function(model) { + var index + + for (var i = 0, length = this.collection.length; i < length; i++) { + if (this.collection[i] === model) { + index = i + break + } + } + + if (index != undefined) { this.collection.splice(index, 1); this.trigger("remove"); return true; @@ -139,20 +203,55 @@ Model.ClassMethods = { }, select: function(func) { - var selected = []; - jQuery.each(this.all(), function(i) { - if (func.call(this, i)) selected.push(this); - }); + var all = this.all(), + selected = [], + model + + for (var i = 0, length = all.length; i < length; i++) { + model = all[i] + if (func.call(model, i)) selected.push(model) + } + return this.chain(selected); }, sort: function(func) { - var sorted = _.sortBy(this.all(), function(model, i) { - return func.call(model, i); - }); + var sorted = this.all().slice().sort(func) return this.chain(sorted); + }, + + sortBy: function(attribute_or_func) { + var is_func = jQuery.isFunction(attribute_or_func) + var extract = function(model) { + return attribute_or_func.call(model) + } + + return this.sort(function(a, b) { + var a_attr = is_func ? extract(a) : a.attr(attribute_or_func) + var b_attr = is_func ? extract(b) : b.attr(attribute_or_func) + + if (a_attr < b_attr) { + return -1 + } else if (a_attr > b_attr) { + return 1 + } else { + return 0 + } + }) + }, + + uids: function() { + var all = this.all() + var uids = [] + + for (var i = 0, length = all.length; i < length; i++) { + uids.push(all[i].uid) + } + + return uids } }; + Model.Errors = function(model) { this.errors = {}; this.model = model; @@ -190,23 +289,29 @@ Model.Errors.prototype = { return count; } }; + Model.InstanceMethods = { attr: function(name, value) { if (arguments.length === 0) { + // Combined attributes/changes object. return jQuery.extend({}, this.attributes, this.changes); } else if (arguments.length === 2) { - if (_.isEqual(this.attributes[name], value)) { + // Don't write to attributes yet, store in changes for now. + if (this.attributes[name] === value) { + // Clean up any stale changes. delete this.changes[name]; } else { this.changes[name] = value; } return this; } else if (typeof name === "object") { + // Mass-assign attributes. for (var key in name) { this.attr(key, name[key]); } return this; } else { + // Changes take precedent over attributes. return (name in this.changes) ? this.changes[name] : this.attributes[name]; @@ -216,25 +321,37 @@ Model.InstanceMethods = { callPersistMethod: function(method, callback) { var self = this; + // Automatically manage adding and removing from the model's Collection. var manageCollection = function() { if (method === "create") { self.constructor.add(self); } else if (method === "destroy") { - self.constructor.remove(self.id()); + self.constructor.remove(self) } }; + // Wrap the existing callback in this function so we always manage the + // collection and trigger events from here rather than relying on the + // persistence adapter to do it for us. The persistence adapter is + // only required to execute the callback with a single argument - a + // boolean to indicate whether the call was a success - though any + // other arguments will also be forwarded to the original callback. var wrappedCallback = function(success) { if (success) { + // Merge any changes into attributes and clear changes. self.merge(self.changes).reset(); + // Add/remove from collection if persist was successful. manageCollection(); + // Trigger the event before executing the callback. self.trigger(method); } + // Store the return value of the callback. var value; + // Run the supplied callback. if (callback) value = callback.apply(self, arguments); return value; @@ -290,6 +407,9 @@ Model.InstanceMethods = { valid: function() { this.errors.clear(); this.validate(); + if (this.constructor.validator) { + this.constructor.validator.run.call(this); + }; return this.errors.size() === 0; }, @@ -297,24 +417,118 @@ Model.InstanceMethods = { return this; } }; + +Model.LocalStorage = function(klass) { + if (!window.localStorage) { + return { + create: function(model, callback) { + callback(true) + }, + + destroy: function(model, callback) { + callback(true) + }, + + read: function(callback) { + callback([]) + }, + + update: function(model, callback) { + callback(true) + } + } + } + + var collection_uid = [klass._name, "collection"].join("-") + var readIndex = function() { + var data = localStorage[collection_uid] + return data ? JSON.parse(data) : [] + } + var writeIndex = function(uids) { + localStorage.setItem(collection_uid, JSON.stringify(uids)) + } + var addToIndex = function(uid) { + var uids = readIndex() + + if (jQuery.inArray(uid, uids) === -1) { + uids.push(uid) + writeIndex(uids) + } + } + var removeFromIndex = function(uid) { + var uids = readIndex() + var index = jQuery.inArray(uid, uids) + + if (index > -1) { + uids.splice(index, 1) + writeIndex(uids) + } + } + var store = function(model) { + var uid = model.uid, + data = JSON.stringify(model.attr()) + localStorage.setItem(uid, data) + addToIndex(uid) + } + + return { + create: function(model, callback) { + store(model) + callback(true) + }, + + destroy: function(model, callback) { + localStorage.removeItem(model.uid) + removeFromIndex(model.uid) + callback(true) + }, + + read: function(callback) { + if (!callback) return false + + var uids = readIndex() + var models = [] + var attributes, model, uid + + for (var i = 0, length = uids.length; i < length; i++) { + uid = uids[i] + attributes = JSON.parse(localStorage[uid]) + model = new klass(attributes) + model.uid = uid + models.push(model) + } + + callback(models) + }, + + update: function(model, callback) { + store(model) + callback(true) + } + } +} + Model.Log = function() { if (window.console) window.console.log.apply(window.console, arguments); }; + Model.RestPersistence = function(resource, methods) { var PARAM_NAME_MATCHER = /:([\w\d]+)/g; + var resource_param_names = (function() { + var resource_param_names = [] + var param_name - var model_resource = function() { - this.resource = resource; - this.resource_param_names = []; while ((param_name = PARAM_NAME_MATCHER.exec(resource)) !== null) { - this.resource_param_names.push(param_name[1]); - }; - }; + resource_param_names.push(param_name[1]) + } + + return resource_param_names + })() - jQuery.extend(model_resource.prototype, { + var rest_persistence = jQuery.extend({ path: function(model) { - var path = this.resource; - $.each(this.resource_param_names, function(i, param) { + var path = resource; + $.each(resource_param_names, function(i, param) { path = path.replace(":" + param, model.attributes[param]); }); return path; @@ -349,14 +563,23 @@ Model.RestPersistence = function(resource, methods) { return params; }, - parseResponseData: function(xhr) { - try { - return /\S/.test(xhr.responseText) ? - jQuery.parseJSON(xhr.responseText) : - null; - } catch(e) { - Model.Log(e); - } + read: function(callback) { + var klass = this.klass + + return this.xhr("GET", this.read_path(), null, function(success, xhr, data) { + data = jQuery.makeArray(data) + var models = [] + + for (var i = 0, length = data.length; i < length; i++) { + models.push(new klass(data[i])) + } + + callback(models) + }) + }, + + read_path: function() { + return resource }, update: function(model, callback) { @@ -369,40 +592,130 @@ Model.RestPersistence = function(resource, methods) { xhr: function(method, url, model, callback) { var self = this; - var data = method === "DELETE" ? null : this.params(model); + var data = jQuery.inArray(method, ["DELETE", "GET"]) > -1 ? + null : this.params(model); return jQuery.ajax({ type: method, url: url, dataType: "json", data: data, + dataFilter: function(data, type) { + return /\S/.test(data) ? data : null; + }, complete: function(xhr, textStatus) { - self.xhrComplete(xhr, textStatus, model, callback); + self.xhrComplete(xhr, textStatus, model, callback) } }); }, xhrComplete: function(xhr, textStatus, model, callback) { - var data = this.parseResponseData(xhr); - var success = textStatus === "success"; - - if (data) { - if (success) { - model.attr(data); - } else if (xhr.status === 422) { - model.errors.clear(); - - for (var attribute in data) { - for (var i = 0; i < data[attribute].length; i++) { - model.errors.add(attribute, data[attribute][i]); - } - } - } + // Allow custom handlers to be defined per-HTTP status code. + var handler = Model.RestPersistence["handle" + xhr.status] + if (handler) handler.call(this, xhr, textStatus, model) + + var success = textStatus === "success" + var data = Model.RestPersistence.parseResponseData(xhr) + + // Remote data is the definitive source, update model. + if (success && model && data) model.attr(data) + + if (callback) callback.call(model, success, xhr, data) + } + }, methods) + + return function(klass) { + rest_persistence.klass = klass + return rest_persistence + } +}; + +// Rails' preferred failed validation response code, assume the response +// contains errors and replace current model errors with them. +Model.RestPersistence.handle422 = function(xhr, textStatus, model) { + var data = Model.RestPersistence.parseResponseData(xhr); + + if (data) { + model.errors.clear() + + for (var attribute in data) { + for (var i = 0; i < data[attribute].length; i++) { + model.errors.add(attribute, data[attribute][i]) } + } + } +} + +Model.RestPersistence.parseResponseData = function(xhr) { + try { + return /\S/.test(xhr.responseText) ? + jQuery.parseJSON(xhr.responseText) : + null; + } catch(e) { + Model.Log(e); + } +} + +Model.UID = { + counter: 0, + + generate: function() { + return [new Date().valueOf(), this.counter++].join("-") + }, + + reset: function() { + this.counter = 0 + return this + } +} - if (callback) callback.call(model, success, xhr); +Model.Validator = function (rules) { + var rules = rules; + + var validationMethods = { + presenceOf: function (attrName, attrValue, options) { + if ((attrValue !== undefined && attrValue.length == 0) || (attrValue === undefined)) { + this.errors.add(attrName, "should not be blank"); + }; + }, + + numericalityOf: function (attrName, attrValue, options) { + if (typeof attrValue != 'number') { + this.errors.add(attrName, "should be numeric"); + }; + }, + + lengthOf: function (attrName, attrValue, options) { + if (attrValue.length < options.min || attrValue.length > options.max) { + this.errors.add(attrName, "is too short or too long"); + }; } - }, methods); + }; + + var ruleWithoutOptions = function (ruleName, ruleValue) { + for (var i=0; i < ruleValue.length; i++) { + validationMethods[ruleName].call(this, ruleValue[i], this.attr(ruleValue[i]), {}); + }; + }; - return new model_resource(); + var ruleWithOptions = function (ruleName, ruleValue) { + for (attributeName in ruleValue) { + validationMethods[ruleName].call(this, attributeName, this.attr(attributeName), ruleValue[attributeName]); + }; + }; + + var run = function () { + for (rule in rules) { + if ($.isArray(rules[rule])) { + ruleWithoutOptions.call(this, rule, rules[rule]); + } else { + ruleWithOptions.call(this, rule, rules[rule]); + }; + }; + }; + + return { + run: run + }; }; +Model.VERSION = "0.8.4" diff --git a/dist/js-model-0.8.4.min.js b/dist/js-model-0.8.4.min.js index 2d37a43..78405b6 100644 --- a/dist/js-model-0.8.4.min.js +++ b/dist/js-model-0.8.4.min.js @@ -3,14 +3,22 @@ * * Released under MIT license. */ -var Model=function(a,b,c){b=b||{};c=c||{};var g=function(d){this.attributes=d||{};this.changes={};this.errors=new Model.Errors(this)};jQuery.extend(g,Model.Callbacks,Model.ClassMethods,b,{_name:a,collection:[],chain:function(d){return jQuery.extend({},this,{collection:d})}});jQuery.extend(g.prototype,Model.Callbacks,Model.InstanceMethods,c);return g}; -Model.Callbacks={callbacks:{},bind:function(a,b){this.callbacks[a]=this.callbacks[a]||[];this.callbacks[a].push(b);return this},trigger:function(a,b){if(a=this.callbacks[a])for(var c=0;c0&&this.trigger("add",a);return this},all:function(){return this.collection},count:function(){return this.collection.length},detect:function(a){var b;jQuery.each(this.all(),function(c){if(a.call(this,c)){b=this;return false}});return b||null},each:function(a){jQuery.each(this.all(),function(b){a.call(this, -b)});return this},find:function(a){return this.detect(function(){return this.id()==a})||null},first:function(){return this.all()[0]||null},last:function(){var a=this.all();return a[a.length-1]||null},remove:function(a){var b=_.invoke(this.collection,"id");a=_.indexOf(b,a);if(a>-1){this.collection.splice(a,1);this.trigger("remove");return true}else return false},select:function(a){var b=[];jQuery.each(this.all(),function(c){a.call(this,c)&&b.push(this)});return this.chain(b)},sort:function(a){return this.chain(_.sortBy(this.all(), -function(b,c){return a.call(b,c)}))}};Model.Errors=function(a){this.errors={};this.model=a};Model.Errors.prototype={add:function(a,b){this.errors[a]||(this.errors[a]=[]);this.errors[a].push(b)},all:function(){return this.errors},clear:function(){this.errors={}},each:function(a){for(var b in this.errors)for(var c=0;c0&&this.trigger("add",a);return this},all:function(){return this.collection},count:function(){return this.collection.length},detect:function(a){for(var d=this.all(),b,c=0,f=d.length;ce?1:0})},uids:function(){for(var a=this.all(),d=[],b=0,c=a.length;b-1){h.splice(g,1);localStorage.setItem(d,JSON.stringify(h))}e(true)},read:function(f){if(!f)return false;for(var e=b(),g=[],h,j,i=0,k=e.length;i-1?null:this.params(h);return jQuery.ajax({type:e,url:g,dataType:"json",data:k,dataFilter:function(l){return/\S/.test(l)?l:null},complete:function(l,m){i.xhrComplete(l,m,h,j)}})},xhrComplete:function(e,g,h,j){var i=Model.RestPersistence["handle"+e.status];i&&i.call(this,e,g,h);g=g==="success";i=Model.RestPersistence.parseResponseData(e);g&&h&&i&&h.attr(i);j&&j.call(h,g,e,i)}},d);return function(e){f.klass=e;return f}}; +Model.RestPersistence.handle422=function(a,d,b){if(a=Model.RestPersistence.parseResponseData(a)){b.errors.clear();for(var c in a)for(d=0;df.max)this.errors.add(b,"is too short or too long")}};return{run:function(){for(rule in a)if($.isArray(a[rule]))for(var b=rule,c=a[rule],f=0;f Date: Tue, 12 Oct 2010 16:25:36 +0100 Subject: [PATCH 07/14] add any() to class methods --- src/model_class_methods.js | 4 ++++ test/tests/model_class_methods.js | 6 +++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/model_class_methods.js b/src/model_class_methods.js index 633b0a4..aef9024 100644 --- a/src/model_class_methods.js +++ b/src/model_class_methods.js @@ -24,6 +24,10 @@ Model.ClassMethods = { return this.collection; }, + any: function () { + return this.count() > 0; + }, + count: function() { return this.collection.length; }, diff --git a/test/tests/model_class_methods.js b/test/tests/model_class_methods.js index b8c3b9b..2732b6e 100644 --- a/test/tests/model_class_methods.js +++ b/test/tests/model_class_methods.js @@ -66,15 +66,19 @@ test("maintaining a collection of unique models by object, id and uid", function equals(Post.count(), 2) }) -test("detect, select, first, last, count (with chaining)", function() { +test("detect, select, first, last, count, any (with chaining)", function() { var Post = Model('post'); + ok(!Post.any(), "there shouldn't be any posts when none have been added to the colleciton") + var post1 = new Post({ id: 1, title: "Foo" }); var post2 = new Post({ id: 2, title: "Bar" }); var post3 = new Post({ id: 3, title: "Bar" }); Post.add(post1, post2, post3); + ok(Post.any(), "there should be some posts once the instances have been added to the collection") + var indexes = []; equals(Post.detect(function(i) { From 7917a748cecaffec7c83379a2734319e29bc980b Mon Sep 17 00:00:00 2001 From: Oliver Nightingale Date: Tue, 12 Oct 2010 16:26:03 +0100 Subject: [PATCH 08/14] added basic uniqueness validation --- src/model_validations.js | 8 ++++ test/tests/model_validations.js | 70 ++++++++++++++++++++------------- 2 files changed, 51 insertions(+), 27 deletions(-) diff --git a/src/model_validations.js b/src/model_validations.js index 73a0035..af52a52 100644 --- a/src/model_validations.js +++ b/src/model_validations.js @@ -28,6 +28,14 @@ Model.Validator = function (rules) { if (attrValue.length < options.min || attrValue.length > options.max) { this.errors.add(attrName, "is too short or too long"); }; + }, + + uniquenessOf: function (attrName, attrValue, options) { + if (this.constructor.select(function () { + return this.attr(attrName) == attrValue; + }).any()) { + this.errors.add(attrName, "should be unique"); + }; } }; diff --git a/test/tests/model_validations.js b/test/tests/model_validations.js index c6bbc14..061b09d 100644 --- a/test/tests/model_validations.js +++ b/test/tests/model_validations.js @@ -1,4 +1,4 @@ -module("Model.Validations") +module("Model.Validations"); test("validatesPresenceOf", function () { @@ -6,34 +6,34 @@ test("validatesPresenceOf", function () { validates: { presenceOf: ['title'] } - }, {}); + }); var validPost = new Post ({ title: "Foo", body: "..." }); var invalidPost_noTitle = new Post ({body: "..."}); var invalidPost_blankTitle = new Post ({title: "", body: "..."}); - ok(validPost.valid(), "should be valid with a title") - ok(!invalidPost_noTitle.valid(), "should be invalid without a title attribute") - ok(!invalidPost_blankTitle.valid(), "should be invalid with a blank title attribute") -}) + ok(validPost.valid(), "should be valid with a title"); + ok(!invalidPost_noTitle.valid(), "should be invalid without a title attribute"); + ok(!invalidPost_blankTitle.valid(), "should be invalid with a blank title attribute"); +}); test("validatesNumericalityOf without options", function () { var Post = Model("post", { validates: { numericalityOf: ['views'] } - }, {}); + }); - var validPost = new Post({ views: 10 }) - var validPost_stringReprOfNumber = new Post ({ views: "10"}) - var invalidPost_notNumeric = new Post({ views: 'not numeric' }) - var invalidPost_missingAttribute = new Post({ notViews: 1}) + var validPost = new Post({ views: 10 }); + var validPost_stringReprOfNumber = new Post ({ views: "10"}); + var invalidPost_notNumeric = new Post({ views: 'not numeric' }); + var invalidPost_missingAttribute = new Post({ notViews: 1}); - ok(validPost.valid(), "should be valid with a numeric attribute") - ok(!validPost_stringReprOfNumber.valid(), "should not be valid with a string representation of a number, by default") - ok(!invalidPost_notNumeric.valid(), "should not be valid with a string value") - ok(!invalidPost_missingAttribute.valid(), "should not be valid if the attr is missing, by default") -}) + ok(validPost.valid(), "should be valid with a numeric attribute"); + ok(!validPost_stringReprOfNumber.valid(), "should not be valid with a string representation of a number, by default"); + ok(!invalidPost_notNumeric.valid(), "should not be valid with a string value"); + ok(!invalidPost_missingAttribute.valid(), "should not be valid if the attr is missing, by default"); +}); test("validatesNumericalityOf with options", function () { @@ -43,12 +43,12 @@ test("validatesNumericalityOf with options", function () { 'views': { allowNumericStrings: true } } } - }, {}); + }); - var validPost_stringReprOfNumber = new Post ({ views: "10"}) + var validPost_stringReprOfNumber = new Post ({ views: "10"}); - ok(validPost_stringReprOfNumber.valid(), "should be valid with a string representation of a number") -}) + ok(validPost_stringReprOfNumber.valid(), "should be valid with a string representation of a number"); +}); test("validatesLengthOf", function () { var Post = Model("post", { @@ -63,10 +63,10 @@ test("validatesLengthOf", function () { var invalidPost_tooShort = new Post ({ title: "1", body: "..." }); var invalidPost_tooLong = new Post ({ title: "this is too long!", body: "..." }); - ok(validPost.valid(), "should be valid with the right length title") - ok(!invalidPost_tooShort.valid(), "should be invalid with a title that is too short") - ok(!invalidPost_tooLong.valid(), "should be invalid with a title that is too long") -}) + ok(validPost.valid(), "should be valid with the right length title"); + ok(!invalidPost_tooShort.valid(), "should be invalid with a title that is too short"); + ok(!invalidPost_tooLong.valid(), "should be invalid with a title that is too long"); +}); test("validatesLengthOf with default options", function () { var Post = Model("post", { @@ -80,6 +80,22 @@ test("validatesLengthOf with default options", function () { var validPost = new Post ({ title: "just right", body: "..." }); var invalidPost_tooLong = new Post ({ title: "this is too long!", body: "..." }); - ok(validPost.valid(), "should be valid with the right length title") - ok(!invalidPost_tooLong.valid(), "should be invalid with a title that is too long") -}) \ No newline at end of file + ok(validPost.valid(), "should be valid with the right length title"); + ok(!invalidPost_tooLong.valid(), "should be invalid with a title that is too long"); +}); + +test("validatesUniqunessOf", function () { + var Post = Model("post", { + validates: { + uniquenessOf: ['title'] + } + }); + + Post.add(new Post({title: "foo"})); + + var validPost = new Post({title: "bar"}); + var invalidPost = new Post({title: "foo"}); + + ok(validPost.valid(), "should be valid with a unique title"); + ok(!invalidPost.valid(), "should be invalid with a duplicate title"); +}); \ No newline at end of file From cac014309d7d82802876de3ba3eea1341dbf97b1 Mon Sep 17 00:00:00 2001 From: Oliver Nightingale Date: Tue, 12 Oct 2010 16:49:38 +0100 Subject: [PATCH 09/14] should ignore the instance being validated when checking for duplicate values in validatesUniquenessOf --- src/model_validations.js | 12 +++++++++--- test/tests/model_validations.js | 4 ++++ 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/model_validations.js b/src/model_validations.js index af52a52..357c161 100644 --- a/src/model_validations.js +++ b/src/model_validations.js @@ -31,9 +31,15 @@ Model.Validator = function (rules) { }, uniquenessOf: function (attrName, attrValue, options) { - if (this.constructor.select(function () { - return this.attr(attrName) == attrValue; - }).any()) { + var instanceToValidat = this + if (this.constructor + .select(function () { + return this !== instanceToValidat; + }) + .select(function () { + return this.attr(attrName) == attrValue; + }) + .any()) { this.errors.add(attrName, "should be unique"); }; } diff --git a/test/tests/model_validations.js b/test/tests/model_validations.js index 061b09d..f0c1a15 100644 --- a/test/tests/model_validations.js +++ b/test/tests/model_validations.js @@ -98,4 +98,8 @@ test("validatesUniqunessOf", function () { ok(validPost.valid(), "should be valid with a unique title"); ok(!invalidPost.valid(), "should be invalid with a duplicate title"); + + Post.add(validPost) + + ok(validPost.valid(), "should still be valid when added to the colleciton") }); \ No newline at end of file From 327ef7bee225f7f2d8b334dd58cbf970fe440758 Mon Sep 17 00:00:00 2001 From: Oliver Nightingale Date: Wed, 13 Oct 2010 11:10:14 +0100 Subject: [PATCH 10/14] add custom validation messages and a conditional validations --- src/model_validations.js | 71 ++++++++++++++++++++++----------- test/tests/model_validations.js | 40 ++++++++++++++++++- 2 files changed, 86 insertions(+), 25 deletions(-) diff --git a/src/model_validations.js b/src/model_validations.js index 357c161..368d52b 100644 --- a/src/model_validations.js +++ b/src/model_validations.js @@ -1,59 +1,84 @@ Model.Validator = function (rules) { var rules = rules; + // http://dl.dropbox.com/u/35146/js/tests/isNumber.html + var isNumeric = function (n) { + return !isNaN(parseFloat(n)) && isFinite(n); + }; + + var defaultOptions = { + "condition": function () { return true; } + }; + var validationMethods = { presenceOf: function (attrName, attrValue, options) { - if ((attrValue !== undefined && attrValue.length == 0) || (attrValue === undefined)) { - this.errors.add(attrName, "should not be blank"); + if (options.condition.call(this)) { + if ((attrValue !== undefined && attrValue.length == 0) || (attrValue === undefined)) { + this.errors.add(attrName, options.message || "should not be blank"); + }; }; }, - // numeric strings will pass validation by default, i.e. + // numeric strings will pass validation by default, i.e. "1" numericalityOf: function (attrName, attrValue, options) { var self = this; - var addError = function () { self.errors.add(attrName, "should be numeric"); }; - // http://dl.dropbox.com/u/35146/js/tests/isNumber.html - var isNumeric = function (n) { return !isNaN(parseFloat(n)) && isFinite(n); } + var addError = function () { self.errors.add(attrName, options.message || "should be numeric"); }; - if (options.allowNumericStrings) { - if (!isNumeric(attrValue)) { + if (options.condition.call(this)) { + if (options.allowNumericStrings) { + if (!isNumeric(attrValue)) { + addError(); + }; + } else if (typeof(attrValue) != "number" || isNaN(attrValue)) { addError(); }; - } else if (typeof(attrValue) != "number" || isNaN(attrValue)) { - addError(); }; }, lengthOf: function (attrName, attrValue, options) { - if (attrValue.length < options.min || attrValue.length > options.max) { - this.errors.add(attrName, "is too short or too long"); + if (options.condition.call(this)) { + if (attrValue.length < options.min || attrValue.length > options.max) { + this.errors.add(attrName, options.message || "is too short or too long"); + }; }; }, uniquenessOf: function (attrName, attrValue, options) { var instanceToValidat = this - if (this.constructor - .select(function () { - return this !== instanceToValidat; - }) - .select(function () { - return this.attr(attrName) == attrValue; - }) - .any()) { - this.errors.add(attrName, "should be unique"); + if (options.condition.call(this)) { + if (this.constructor + .select(function () { + return this !== instanceToValidat; + }) + .select(function () { + return this.attr(attrName) == attrValue; + }) + .any()) { + this.errors.add(attrName, options.message || "should be unique"); + }; }; } }; var ruleWithoutOptions = function (ruleName, ruleValue) { for (var i=0; i < ruleValue.length; i++) { - validationMethods[ruleName].call(this, ruleValue[i], this.attr(ruleValue[i]), {}); + validationMethods[ruleName].call( + this, + ruleValue[i], + this.attr(ruleValue[i]), + defaultOptions + ); }; }; var ruleWithOptions = function (ruleName, ruleValue) { for (attributeName in ruleValue) { - validationMethods[ruleName].call(this, attributeName, this.attr(attributeName), ruleValue[attributeName]); + validationMethods[ruleName].call( + this, + attributeName, + this.attr(attributeName), + $.extend({}, defaultOptions, ruleValue[attributeName]) + ); }; }; diff --git a/test/tests/model_validations.js b/test/tests/model_validations.js index f0c1a15..d6a76d1 100644 --- a/test/tests/model_validations.js +++ b/test/tests/model_validations.js @@ -40,21 +40,31 @@ test("validatesNumericalityOf with options", function () { var Post = Model("post", { validates: { numericalityOf: { - 'views': { allowNumericStrings: true } + 'views': { + allowNumericStrings: true, + message: "custom message" + } } } }); var validPost_stringReprOfNumber = new Post ({ views: "10"}); + var invalidPost = new Post({views: "blah"}) ok(validPost_stringReprOfNumber.valid(), "should be valid with a string representation of a number"); + ok(!invalidPost.valid()) + equal(invalidPost.errors.errors.views[0], "custom message", "should use a custom validation message"); }); test("validatesLengthOf", function () { var Post = Model("post", { validates: { lengthOf: { - 'title': { min: 5, max: 10} + 'title': { + min: 5, + max: 10, + message: "custom message" + } } } }); @@ -66,6 +76,8 @@ test("validatesLengthOf", function () { ok(validPost.valid(), "should be valid with the right length title"); ok(!invalidPost_tooShort.valid(), "should be invalid with a title that is too short"); ok(!invalidPost_tooLong.valid(), "should be invalid with a title that is too long"); + + equal(invalidPost_tooLong.errors.errors.title[0], "custom message", "should use a custom validation message"); }); test("validatesLengthOf with default options", function () { @@ -102,4 +114,28 @@ test("validatesUniqunessOf", function () { Post.add(validPost) ok(validPost.valid(), "should still be valid when added to the colleciton") +}); + +test("validatesUniquenessOf with options", function () { + var Post = Model("post", { + validates: { + uniquenessOf: { + 'title': { + message: "custom message", + condition: function () { + return this.attr('doValidation'); + } + } + } + } + }); + + Post.add(new Post({title: "foo"})); + + var invalidPost = new Post({title: "foo", doValidation: true}); + var validPost = new Post({title: "foo", doValidation: false}); + + ok(!invalidPost.valid()); + ok(validPost.valid(), "should only validate if the if condition is true"); + equal(invalidPost.errors.errors.title[0], "custom message", "should use a custom validation message"); }); \ No newline at end of file From 5c8a02950fe91a0efd98944cad7816c468757abe Mon Sep 17 00:00:00 2001 From: Oliver Nightingale Date: Wed, 13 Oct 2010 11:19:20 +0100 Subject: [PATCH 11/14] basic validatesFormatOf --- src/model_validations.js | 8 ++++++++ test/tests/model_validations.js | 20 +++++++++++++++++++- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/src/model_validations.js b/src/model_validations.js index 368d52b..880b017 100644 --- a/src/model_validations.js +++ b/src/model_validations.js @@ -43,6 +43,14 @@ Model.Validator = function (rules) { }; }, + formatOf: function (attrName, attrValue, options) { + if (options.condition.call(this)) { + if (!options["with"].test(attrValue)) { + this.errors.add(attrName, options.message || "is the wrong format"); + }; + }; + }, + uniquenessOf: function (attrName, attrValue, options) { var instanceToValidat = this if (options.condition.call(this)) { diff --git a/test/tests/model_validations.js b/test/tests/model_validations.js index d6a76d1..dd0b303 100644 --- a/test/tests/model_validations.js +++ b/test/tests/model_validations.js @@ -138,4 +138,22 @@ test("validatesUniquenessOf with options", function () { ok(!invalidPost.valid()); ok(validPost.valid(), "should only validate if the if condition is true"); equal(invalidPost.errors.errors.title[0], "custom message", "should use a custom validation message"); -}); \ No newline at end of file +}); + +test("validatesFormatOf", function () { + var Post = Model("post", { + validates: { + formatOf: { + 'title': { + 'with': /^f/ + } + } + } + }); + + var validPost = new Post({ title: "foo" }); + var invalidPost = new Post({ title: "boo" }); + + ok(validPost.valid(), "should be valid when the regex matches the string"); + ok(!invalidPost.valid(), "should be invalid when the regex doesn't match the string"); +}) \ No newline at end of file From c39f781965d92683514d640f3dc88ad781082962 Mon Sep 17 00:00:00 2001 From: Oliver Nightingale Date: Wed, 13 Oct 2010 11:27:31 +0100 Subject: [PATCH 12/14] revert the version file to original --- VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION b/VERSION index 1550cb8..965065d 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.9.3.custom \ No newline at end of file +0.9.3 From 71d39e9105bda408df775f649535e23754753d04 Mon Sep 17 00:00:00 2001 From: Oliver Nightingale Date: Wed, 13 Oct 2010 11:33:24 +0100 Subject: [PATCH 13/14] revert the changes to v0.8.4 --- dist/js-model-0.8.4.js | 449 ++++++------------------------------- dist/js-model-0.8.4.min.js | 30 +-- 2 files changed, 79 insertions(+), 400 deletions(-) diff --git a/dist/js-model-0.8.4.js b/dist/js-model-0.8.4.js index 2d1fb95..724a1ae 100644 --- a/dist/js-model-0.8.4.js +++ b/dist/js-model-0.8.4.js @@ -7,58 +7,36 @@ var Model = function(name, class_methods, instance_methods) { class_methods = class_methods || {}; instance_methods = instance_methods || {}; - // The model constructor. var model = function(attributes) { this.attributes = attributes || {}; this.changes = {}; this.errors = new Model.Errors(this); - this.uid = [name, Model.UID.generate()].join("-") }; - // Persistence & validations are special, remove them from class_methods. - var persistence = class_methods.persistence - var validation_rules = class_methods.validates; - delete class_methods.persistence - delete class_methods.validates; - - // Apply class methods and extend with any custom class methods. Make sure - // vitals are added last so they can't be overridden. jQuery.extend(model, Model.Callbacks, Model.ClassMethods, class_methods, { _name: name, collection: [], - // Convenience method to allow a simple method of chaining class methods. chain: function(collection) { return jQuery.extend({}, this, { collection: collection }); } }); - // Initialise persistence with a reference to the class. - if (persistence) model.persistence = persistence(model) - - // Initialise a validator object for this class. - if (validation_rules) { - model.validator = Model.Validator(validation_rules); - }; - - // Add default and custom instance methods. - jQuery.extend(model.prototype, Model.Callbacks, Model.InstanceMethods, Model.Validations, + jQuery.extend(model.prototype, Model.Callbacks, Model.InstanceMethods, instance_methods); return model; }; - Model.Callbacks = { + callbacks: {}, + bind: function(event, callback) { - this.callbacks = this.callbacks || {} this.callbacks[event] = this.callbacks[event] || []; this.callbacks[event].push(callback); return this; }, trigger: function(name, data) { - this.callbacks = this.callbacks || {} - var callbacks = this.callbacks[name]; if (callbacks) { @@ -71,8 +49,6 @@ Model.Callbacks = { }, unbind: function(event, callback) { - this.callbacks = this.callbacks || {} - if (callback) { var callbacks = this.callbacks[event] || []; @@ -88,19 +64,17 @@ Model.Callbacks = { return this; } }; - Model.ClassMethods = { add: function() { var added = []; - var uids = this.uids() for (var i = 0; i < arguments.length; i++) { var model = arguments[i]; + var existing_elem = this.detect(function() { + return this.id() !== null && this.id() == model.id(); + }); - if (jQuery.inArray(model, this.collection) === -1 && - !(model.id() && this.find(model.id())) && - jQuery.inArray(model.uid, uids) === -1) - { + if (!existing_elem) { this.collection.push(model); added.push(model); } @@ -120,24 +94,20 @@ Model.ClassMethods = { }, detect: function(func) { - var all = this.all(), - model - - for (var i = 0, length = all.length; i < length; i++) { - model = all[i] - if (func.call(model, i)) return model - } - - return null + var model; + jQuery.each(this.all(), function(i) { + if (func.call(this, i)) { + model = this; + return false; + } + }); + return model || null; }, each: function(func) { - var all = this.all() - - for (var i = 0, length = all.length; i < length; i++) { - func.call(all[i], i) - } - + jQuery.each(this.all(), function(i) { + func.call(this, i); + }); return this; }, @@ -151,49 +121,15 @@ Model.ClassMethods = { return this.all()[0] || null; }, - load: function(callback) { - if (this.persistence) { - var self = this - - this.persistence.read(function(models) { - for (var i = 0, length = models.length; i < length; i++) { - self.add(models[i]) - } - - if (callback) callback(models) - }) - } - - return this - }, - last: function() { var all = this.all(); return all[all.length - 1] || null; }, - pluck: function(attribute) { - var all = this.all() - var plucked = [] - - for (var i = 0, length = all.length; i < length; i++) { - plucked.push(all[i].attr(attribute)) - } - - return plucked - }, - - remove: function(model) { - var index - - for (var i = 0, length = this.collection.length; i < length; i++) { - if (this.collection[i] === model) { - index = i - break - } - } - - if (index != undefined) { + remove: function(id) { + var ids = _.invoke(this.collection, 'id'); + var index = _.indexOf(ids, id); + if (index > -1) { this.collection.splice(index, 1); this.trigger("remove"); return true; @@ -203,55 +139,20 @@ Model.ClassMethods = { }, select: function(func) { - var all = this.all(), - selected = [], - model - - for (var i = 0, length = all.length; i < length; i++) { - model = all[i] - if (func.call(model, i)) selected.push(model) - } - + var selected = []; + jQuery.each(this.all(), function(i) { + if (func.call(this, i)) selected.push(this); + }); return this.chain(selected); }, sort: function(func) { - var sorted = this.all().slice().sort(func) + var sorted = _.sortBy(this.all(), function(model, i) { + return func.call(model, i); + }); return this.chain(sorted); - }, - - sortBy: function(attribute_or_func) { - var is_func = jQuery.isFunction(attribute_or_func) - var extract = function(model) { - return attribute_or_func.call(model) - } - - return this.sort(function(a, b) { - var a_attr = is_func ? extract(a) : a.attr(attribute_or_func) - var b_attr = is_func ? extract(b) : b.attr(attribute_or_func) - - if (a_attr < b_attr) { - return -1 - } else if (a_attr > b_attr) { - return 1 - } else { - return 0 - } - }) - }, - - uids: function() { - var all = this.all() - var uids = [] - - for (var i = 0, length = all.length; i < length; i++) { - uids.push(all[i].uid) - } - - return uids } }; - Model.Errors = function(model) { this.errors = {}; this.model = model; @@ -289,29 +190,23 @@ Model.Errors.prototype = { return count; } }; - Model.InstanceMethods = { attr: function(name, value) { if (arguments.length === 0) { - // Combined attributes/changes object. return jQuery.extend({}, this.attributes, this.changes); } else if (arguments.length === 2) { - // Don't write to attributes yet, store in changes for now. - if (this.attributes[name] === value) { - // Clean up any stale changes. + if (_.isEqual(this.attributes[name], value)) { delete this.changes[name]; } else { this.changes[name] = value; } return this; } else if (typeof name === "object") { - // Mass-assign attributes. for (var key in name) { this.attr(key, name[key]); } return this; } else { - // Changes take precedent over attributes. return (name in this.changes) ? this.changes[name] : this.attributes[name]; @@ -321,37 +216,25 @@ Model.InstanceMethods = { callPersistMethod: function(method, callback) { var self = this; - // Automatically manage adding and removing from the model's Collection. var manageCollection = function() { if (method === "create") { self.constructor.add(self); } else if (method === "destroy") { - self.constructor.remove(self) + self.constructor.remove(self.id()); } }; - // Wrap the existing callback in this function so we always manage the - // collection and trigger events from here rather than relying on the - // persistence adapter to do it for us. The persistence adapter is - // only required to execute the callback with a single argument - a - // boolean to indicate whether the call was a success - though any - // other arguments will also be forwarded to the original callback. var wrappedCallback = function(success) { if (success) { - // Merge any changes into attributes and clear changes. self.merge(self.changes).reset(); - // Add/remove from collection if persist was successful. manageCollection(); - // Trigger the event before executing the callback. self.trigger(method); } - // Store the return value of the callback. var value; - // Run the supplied callback. if (callback) value = callback.apply(self, arguments); return value; @@ -407,9 +290,6 @@ Model.InstanceMethods = { valid: function() { this.errors.clear(); this.validate(); - if (this.constructor.validator) { - this.constructor.validator.run.call(this); - }; return this.errors.size() === 0; }, @@ -417,118 +297,24 @@ Model.InstanceMethods = { return this; } }; - -Model.LocalStorage = function(klass) { - if (!window.localStorage) { - return { - create: function(model, callback) { - callback(true) - }, - - destroy: function(model, callback) { - callback(true) - }, - - read: function(callback) { - callback([]) - }, - - update: function(model, callback) { - callback(true) - } - } - } - - var collection_uid = [klass._name, "collection"].join("-") - var readIndex = function() { - var data = localStorage[collection_uid] - return data ? JSON.parse(data) : [] - } - var writeIndex = function(uids) { - localStorage.setItem(collection_uid, JSON.stringify(uids)) - } - var addToIndex = function(uid) { - var uids = readIndex() - - if (jQuery.inArray(uid, uids) === -1) { - uids.push(uid) - writeIndex(uids) - } - } - var removeFromIndex = function(uid) { - var uids = readIndex() - var index = jQuery.inArray(uid, uids) - - if (index > -1) { - uids.splice(index, 1) - writeIndex(uids) - } - } - var store = function(model) { - var uid = model.uid, - data = JSON.stringify(model.attr()) - localStorage.setItem(uid, data) - addToIndex(uid) - } - - return { - create: function(model, callback) { - store(model) - callback(true) - }, - - destroy: function(model, callback) { - localStorage.removeItem(model.uid) - removeFromIndex(model.uid) - callback(true) - }, - - read: function(callback) { - if (!callback) return false - - var uids = readIndex() - var models = [] - var attributes, model, uid - - for (var i = 0, length = uids.length; i < length; i++) { - uid = uids[i] - attributes = JSON.parse(localStorage[uid]) - model = new klass(attributes) - model.uid = uid - models.push(model) - } - - callback(models) - }, - - update: function(model, callback) { - store(model) - callback(true) - } - } -} - Model.Log = function() { if (window.console) window.console.log.apply(window.console, arguments); }; - Model.RestPersistence = function(resource, methods) { var PARAM_NAME_MATCHER = /:([\w\d]+)/g; - var resource_param_names = (function() { - var resource_param_names = [] - var param_name + var model_resource = function() { + this.resource = resource; + this.resource_param_names = []; while ((param_name = PARAM_NAME_MATCHER.exec(resource)) !== null) { - resource_param_names.push(param_name[1]) - } - - return resource_param_names - })() + this.resource_param_names.push(param_name[1]); + }; + }; - var rest_persistence = jQuery.extend({ + jQuery.extend(model_resource.prototype, { path: function(model) { - var path = resource; - $.each(resource_param_names, function(i, param) { + var path = this.resource; + $.each(this.resource_param_names, function(i, param) { path = path.replace(":" + param, model.attributes[param]); }); return path; @@ -563,23 +349,14 @@ Model.RestPersistence = function(resource, methods) { return params; }, - read: function(callback) { - var klass = this.klass - - return this.xhr("GET", this.read_path(), null, function(success, xhr, data) { - data = jQuery.makeArray(data) - var models = [] - - for (var i = 0, length = data.length; i < length; i++) { - models.push(new klass(data[i])) - } - - callback(models) - }) - }, - - read_path: function() { - return resource + parseResponseData: function(xhr) { + try { + return /\S/.test(xhr.responseText) ? + jQuery.parseJSON(xhr.responseText) : + null; + } catch(e) { + Model.Log(e); + } }, update: function(model, callback) { @@ -592,130 +369,40 @@ Model.RestPersistence = function(resource, methods) { xhr: function(method, url, model, callback) { var self = this; - var data = jQuery.inArray(method, ["DELETE", "GET"]) > -1 ? - null : this.params(model); + var data = method === "DELETE" ? null : this.params(model); return jQuery.ajax({ type: method, url: url, dataType: "json", data: data, - dataFilter: function(data, type) { - return /\S/.test(data) ? data : null; - }, complete: function(xhr, textStatus) { - self.xhrComplete(xhr, textStatus, model, callback) + self.xhrComplete(xhr, textStatus, model, callback); } }); }, xhrComplete: function(xhr, textStatus, model, callback) { - // Allow custom handlers to be defined per-HTTP status code. - var handler = Model.RestPersistence["handle" + xhr.status] - if (handler) handler.call(this, xhr, textStatus, model) - - var success = textStatus === "success" - var data = Model.RestPersistence.parseResponseData(xhr) - - // Remote data is the definitive source, update model. - if (success && model && data) model.attr(data) - - if (callback) callback.call(model, success, xhr, data) - } - }, methods) - - return function(klass) { - rest_persistence.klass = klass - return rest_persistence - } -}; - -// Rails' preferred failed validation response code, assume the response -// contains errors and replace current model errors with them. -Model.RestPersistence.handle422 = function(xhr, textStatus, model) { - var data = Model.RestPersistence.parseResponseData(xhr); - - if (data) { - model.errors.clear() - - for (var attribute in data) { - for (var i = 0; i < data[attribute].length; i++) { - model.errors.add(attribute, data[attribute][i]) + var data = this.parseResponseData(xhr); + var success = textStatus === "success"; + + if (data) { + if (success) { + model.attr(data); + } else if (xhr.status === 422) { + model.errors.clear(); + + for (var attribute in data) { + for (var i = 0; i < data[attribute].length; i++) { + model.errors.add(attribute, data[attribute][i]); + } + } + } } - } - } -} - -Model.RestPersistence.parseResponseData = function(xhr) { - try { - return /\S/.test(xhr.responseText) ? - jQuery.parseJSON(xhr.responseText) : - null; - } catch(e) { - Model.Log(e); - } -} - -Model.UID = { - counter: 0, - - generate: function() { - return [new Date().valueOf(), this.counter++].join("-") - }, - - reset: function() { - this.counter = 0 - return this - } -} -Model.Validator = function (rules) { - var rules = rules; - - var validationMethods = { - presenceOf: function (attrName, attrValue, options) { - if ((attrValue !== undefined && attrValue.length == 0) || (attrValue === undefined)) { - this.errors.add(attrName, "should not be blank"); - }; - }, - - numericalityOf: function (attrName, attrValue, options) { - if (typeof attrValue != 'number') { - this.errors.add(attrName, "should be numeric"); - }; - }, - - lengthOf: function (attrName, attrValue, options) { - if (attrValue.length < options.min || attrValue.length > options.max) { - this.errors.add(attrName, "is too short or too long"); - }; + if (callback) callback.call(model, success, xhr); } - }; - - var ruleWithoutOptions = function (ruleName, ruleValue) { - for (var i=0; i < ruleValue.length; i++) { - validationMethods[ruleName].call(this, ruleValue[i], this.attr(ruleValue[i]), {}); - }; - }; + }, methods); - var ruleWithOptions = function (ruleName, ruleValue) { - for (attributeName in ruleValue) { - validationMethods[ruleName].call(this, attributeName, this.attr(attributeName), ruleValue[attributeName]); - }; - }; - - var run = function () { - for (rule in rules) { - if ($.isArray(rules[rule])) { - ruleWithoutOptions.call(this, rule, rules[rule]); - } else { - ruleWithOptions.call(this, rule, rules[rule]); - }; - }; - }; - - return { - run: run - }; + return new model_resource(); }; -Model.VERSION = "0.8.4" diff --git a/dist/js-model-0.8.4.min.js b/dist/js-model-0.8.4.min.js index 78405b6..2d37a43 100644 --- a/dist/js-model-0.8.4.min.js +++ b/dist/js-model-0.8.4.min.js @@ -3,22 +3,14 @@ * * Released under MIT license. */ -var Model=function(a,d,b){d=d||{};b=b||{};var c=function(g){this.attributes=g||{};this.changes={};this.errors=new Model.Errors(this);this.uid=[a,Model.UID.generate()].join("-")},f=d.persistence,e=d.validates;delete d.persistence;delete d.validates;jQuery.extend(c,Model.Callbacks,Model.ClassMethods,d,{_name:a,collection:[],chain:function(g){return jQuery.extend({},this,{collection:g})}});if(f)c.persistence=f(c);if(e)c.validator=Model.Validator(e);jQuery.extend(c.prototype,Model.Callbacks,Model.InstanceMethods, -Model.Validations,b);return c}; -Model.Callbacks={bind:function(a,d){this.callbacks=this.callbacks||{};this.callbacks[a]=this.callbacks[a]||[];this.callbacks[a].push(d);return this},trigger:function(a,d){this.callbacks=this.callbacks||{};var b=this.callbacks[a];if(b)for(var c=0;c0&&this.trigger("add",a);return this},all:function(){return this.collection},count:function(){return this.collection.length},detect:function(a){for(var d=this.all(),b,c=0,f=d.length;ce?1:0})},uids:function(){for(var a=this.all(),d=[],b=0,c=a.length;b-1){h.splice(g,1);localStorage.setItem(d,JSON.stringify(h))}e(true)},read:function(f){if(!f)return false;for(var e=b(),g=[],h,j,i=0,k=e.length;i-1?null:this.params(h);return jQuery.ajax({type:e,url:g,dataType:"json",data:k,dataFilter:function(l){return/\S/.test(l)?l:null},complete:function(l,m){i.xhrComplete(l,m,h,j)}})},xhrComplete:function(e,g,h,j){var i=Model.RestPersistence["handle"+e.status];i&&i.call(this,e,g,h);g=g==="success";i=Model.RestPersistence.parseResponseData(e);g&&h&&i&&h.attr(i);j&&j.call(h,g,e,i)}},d);return function(e){f.klass=e;return f}}; -Model.RestPersistence.handle422=function(a,d,b){if(a=Model.RestPersistence.parseResponseData(a)){b.errors.clear();for(var c in a)for(d=0;df.max)this.errors.add(b,"is too short or too long")}};return{run:function(){for(rule in a)if($.isArray(a[rule]))for(var b=rule,c=a[rule],f=0;f0&&this.trigger("add",a);return this},all:function(){return this.collection},count:function(){return this.collection.length},detect:function(a){var b;jQuery.each(this.all(),function(c){if(a.call(this,c)){b=this;return false}});return b||null},each:function(a){jQuery.each(this.all(),function(b){a.call(this, +b)});return this},find:function(a){return this.detect(function(){return this.id()==a})||null},first:function(){return this.all()[0]||null},last:function(){var a=this.all();return a[a.length-1]||null},remove:function(a){var b=_.invoke(this.collection,"id");a=_.indexOf(b,a);if(a>-1){this.collection.splice(a,1);this.trigger("remove");return true}else return false},select:function(a){var b=[];jQuery.each(this.all(),function(c){a.call(this,c)&&b.push(this)});return this.chain(b)},sort:function(a){return this.chain(_.sortBy(this.all(), +function(b,c){return a.call(b,c)}))}};Model.Errors=function(a){this.errors={};this.model=a};Model.Errors.prototype={add:function(a,b){this.errors[a]||(this.errors[a]=[]);this.errors[a].push(b)},all:function(){return this.errors},clear:function(){this.errors={}},each:function(a){for(var b in this.errors)for(var c=0;c Date: Fri, 10 Dec 2010 15:08:54 +0000 Subject: [PATCH 14/14] add validatesInclusionOf validation and validatesExclusionOf --- src/model_validations.js | 17 ++++++++++++++++ test/tests/model_validations.js | 36 +++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/src/model_validations.js b/src/model_validations.js index 880b017..7b422f2 100644 --- a/src/model_validations.js +++ b/src/model_validations.js @@ -11,6 +11,23 @@ Model.Validator = function (rules) { }; var validationMethods = { + + exclusionOf: function (attrName, attrValue, options) { + if (options.condition.call(this)) { + if (options['in'].indexOf(attrValue) !== -1) { + this.errors.add(attrName, options.message || "should not be one of " + options['in'].join(', ')) + }; + }; + }, + + inclusionOf: function (attrName, attrValue, options) { + if (options.condition.call(this)) { + if (options['in'].indexOf(attrValue) === -1) { + this.errors.add(attrName, options.message || "should be one of " + options['in'].join(', ')) + }; + }; + }, + presenceOf: function (attrName, attrValue, options) { if (options.condition.call(this)) { if ((attrValue !== undefined && attrValue.length == 0) || (attrValue === undefined)) { diff --git a/test/tests/model_validations.js b/test/tests/model_validations.js index dd0b303..fd8d32e 100644 --- a/test/tests/model_validations.js +++ b/test/tests/model_validations.js @@ -156,4 +156,40 @@ test("validatesFormatOf", function () { ok(validPost.valid(), "should be valid when the regex matches the string"); ok(!invalidPost.valid(), "should be invalid when the regex doesn't match the string"); +}) + +test("validatesInclusionOf", function () { + var Post = Model("post", { + validates: { + inclusionOf: { + 'tags': { + 'in': ['awesome', 'life changing'] + } + } + } + }); + + var validPost = new Post({ tags: 'awesome' }); + var invalidPost = new Post({ tags: 'boring' }); + + ok(validPost.valid(), "should be valid when the attribute value is in the supplied list"); + ok(!invalidPost.valid(), "should be invalid when the attribute value is not in the supplied list"); +}) + +test("validatesExclusionOf", function () { + var Post = Model("post", { + validates: { + exclusionOf: { + 'name': { + 'in': ['bob', 'tom'] + } + } + } + }); + + var validPost = new Post({ name: 'xavier' }); + var invalidPost = new Post({ name: 'bob' }); + + ok(validPost.valid(), "should be valid when the attribute value is not in the supplied list"); + ok(!invalidPost.valid(), "should be invalid when the attribute value is in the supplied list"); }) \ No newline at end of file