diff --git a/src/app/build.json b/src/app/build.json index c1f02fdb9e7..5e035f9fd1c 100644 --- a/src/app/build.json +++ b/src/app/build.json @@ -21,6 +21,11 @@ "model.js" ] }, + "model-sync-local": { + "jsfiles": [ + "model-extensions/model-sync-local.js" + ] + }, "model-sync-rest": { "jsfiles": [ "model-extensions/model-sync-rest.js" diff --git a/src/app/docs/model/index.mustache b/src/app/docs/model/index.mustache index a449c9c4461..e63fb4f1cff 100644 --- a/src/app/docs/model/index.mustache +++ b/src/app/docs/model/index.mustache @@ -1020,6 +1020,79 @@ If you're sending and receiving content other than JSON, you can override these For more information on the `Y.ModelSync.REST` extension, refer to its API docs.

+

Local Storage Synchronization

+ +

+`Y.ModelSync.Local` is an extension which provides a sync implementation through locally stored key value pairs, either through the HTML localStorage API or falling back onto an in-memory cache, that can be mixed into a Model or ModelList subclass. +

+ +

+This follows a similar API as `Y.ModelSync.REST`, so in most cases, you'll again only need to provide a value for `root` when sub-classing `Y.Model`. +

+ +```javascript +// Create `Y.User`, a `Y.Model` subclass and mix-in the Local Storage sync layer +Y.User = Y.Base.create('user', Y.Model, [Y.ModelSync.Local], { + // The root key for local storage or the in-memory cache + root: 'users' +}); + +var existingUser = new Y.User({id: 'users-1'}), + oldUser = new Y.User({id: 'users-2'}), + newUser = new Y.User({name: 'Eric Ferraiuolo'}); + +// Get the existing user data from: "{'users': 'users-1': { /* data */ }}" +existingUser.load(function () { + Y.log(existingUser.get('name')); // => "Ron Swannson" + + // Correct the user's `name` and update the set of key-value pairs + existingUser.set('name', 'Ron Swanson').save(); +}); + +// Destroy the old user data at: "{'users': 'users-2': { /* data */ }}" +oldUser.destroy({remove: true}); + +// Set the new user data to "{'users'}" +newUser.save(function () { + // The sync layer can return the user data with an `id` assigned. + // By default, this uses the root and Y.guid() internally + Y.log(newUser.get('id')); + // => "users_yui_3_8_0_1_1357185522298_106" +}); +``` + +

+As mentioned in the example, the IDs that are automatically generated and used inside of either Local Storage or the in-memory cache are provided by Y.guid() internally. If you would like to use your own method of generating unique IDs with this sync layer, you can override the `generateID` method to do so. +

+ +

+For Model Lists, the `root` property is by convention inherited from the `root` property found in the default Model class that can be provided as a configuration parameter. Otherwise, it defaults to an empty string. +

+ +``` +Y.User = Y.Base.create('user', Y.Model, [Y.ModelSync.Local], { + root: 'users' +}); + +Y.Users = Y.Base.create('users', Y.ModelList, [Y.ModelSync.Local], { + // By convention `Y.User`'s `root` will be used for `Y.Users` as well. + model: Y.User +}); + +var users = new Y.Users(); + +// Get users list from: {"users": /* data */ } +users.load(function () { + var firstUser = users.item(0); + + Y.log(firstUser.get('id')); + // => "users-1" + + // Update user data at "users-1" + firstUser.set('name', 'Eric').save(); +}); +``` +

Implementing a Model Sync Layer

diff --git a/src/app/js/model-extensions/model-sync-local.js b/src/app/js/model-extensions/model-sync-local.js new file mode 100644 index 00000000000..7083c5c9571 --- /dev/null +++ b/src/app/js/model-extensions/model-sync-local.js @@ -0,0 +1,272 @@ +/* +An extension which provides a sync implementation through locally stored +key value pairs, either through the HTML localStorage API or falling back +onto an in-memory cache, that can be mixed into a Model or ModelList subclass. + +@module app +@submodule model-sync-local +@since 3.6.0 +**/ + +/** +An extension which provides a sync implementation through locally stored +key value pairs, either through the HTML localStorage API or falling back +onto an in-memory cache, that can be mixed into a Model or ModelList subclass. + +A group of Models/ModelLists is serialized in localStorage by either its +class name, or a specified 'root' that is provided. + + var User = Y.Base.create('user', Y.Model, [Y.ModelSync.Local], { + root: 'user' + }); + + var Users = Y.Base.create('users', Y.ModelList, [Y.ModelSync.Local], { + model: User, + }); + +@class ModelSync.Local +@extensionfor Model +@extensionfor ModelList +@since 3.6.0 +**/ +function LocalSync() {} + +/** +Properties that shouldn't be turned into ad-hoc attributes when passed to a +Model or ModelList constructor. + +@property _NON_ATTRS_CFG +@type Array +@default ['root''] +@static +@protected +@since 3.6.0 +**/ +LocalSync._NON_ATTRS_CFG = ['root']; + +/** +Object of key/value pairs to fall back on when localStorage is not available. + +@property _data +@type Object +@private +**/ +LocalSync._data = {}; + +LocalSync.prototype = { + + // -- Public Methods ------------------------------------------------------- + + /** + Root used as the key inside of localStorage and/or the in-memory store. + + @property root + @type String + @default "" + @since 3.6.0 + **/ + root: '', + + /** + Shortcut for access to localStorage. + + @property storage + @type Storage + @default null + @since 3.6.0 + **/ + storage: null, + + // -- Lifecycle Methods ----------------------------------------------------- + initializer: function (config) { + var store; + + config || (config = {}); + + if ('root' in config) { + this.root = config.root || ''; + } + + if (this.model && this.model.prototype.root) { + this.root = this.model.prototype.root; + } + + try { + this.storage = Y.config.win.localStorage; + store = this.storage.getItem(this.root); + } catch (e) { + Y.log("Could not access localStorage.", "warn"); + } + + // Pull in existing data from localStorage, if possible + LocalSync._data[this.root] = (store && Y.JSON.parse(store)) || {}; + }, + + // -- Public Methods ----------------------------------------------------------- + + /** + Creates a synchronization layer with the localStorage API, if available. + Otherwise, falls back to a in-memory data store. + + This method is called internally by load(), save(), and destroy(). + + @method sync + @param {String} action Sync action to perform. May be one of the following: + + * **create**: Store a newly-created model for the first time. + * **read** : Load an existing model. + * **update**: Update an existing model. + * **delete**: Delete an existing model. + + @param {Object} [options] Sync options + @param {callback} [callback] Called when the sync operation finishes. + @param {Error|null} callback.err If an error occurred, this parameter will + contain the error. If the sync operation succeeded, _err_ will be + falsy. + @param {Any} [callback.response] The response from our sync. This value will + be passed to the parse() method, which is expected to parse it and + return an attribute hash. + **/ + sync: function (action, options, callback) { + options || (options = {}); + var response, errorInfo; + + try { + switch (action) { + case 'read': + if (this._isYUIModelList) { + response = this._index(options); + } else { + response = this._show(options); + } + break; + case 'create': + response = this._create(options); + break; + case 'update': + response = this._update(options); + break; + case 'delete': + response = this._destroy(options); + break; + } + } catch (error) { + errorInfo = error.message; + } + + if (response) { + callback(null, response); + } else { + if (errorInfo) { + callback(errorInfo); + } else { + callback("Data not found in LocalStorage"); + } + } + }, + + /** + Generate a random GUID for our Models. This can be overriden if you have + another method of generating different IDs. + + @method generateID + @protected + @param {String} pre Optional GUID prefix + **/ + generateID: function (pre) { + return Y.guid(pre + '_'); + }, + + // -- Protected Methods ---------------------------------------------------- + + /** + Sync method correlating to the "read" operation, for a Model List + + @method _index + @return {Object[]} Array of objects found for that root key + @protected + @since 3.6.0 + **/ + _index: function (options) { + return Y.Object.values(LocalSync._data[this.root]); + }, + + /** + Sync method correlating to the "read" operation, for a Model + + @method _show + @return {Object} Object found for that root key and model ID + @protected + @since 3.6.0 + **/ + _show: function (options) { + return LocalSync._data[this.root][this.get('id')]; + }, + + /** + Sync method correlating to the "create" operation + + @method _show + @return {Object} The new object created. + @protected + @since 3.6.0 + **/ + _create: function (options) { + var hash = this.toJSON(); + hash.id = this.generateID(this.root); + LocalSync._data[this.root][hash.id] = hash; + + this._save(); + return hash; + }, + + /** + Sync method correlating to the "update" operation + + @method _update + @return {Object} The updated object. + @protected + @since 3.6.0 + **/ + _update: function (options) { + var hash = Y.merge(this.toJSON(), options); + LocalSync._data[this.root][this.get('id')] = hash; + + this._save(); + return hash; + }, + + /** + Sync method correlating to the "delete" operation. Deletes the data + from the in-memory object, and saves into localStorage if available. + + @method _destroy + @return {Object} The deleted object. + @protected + @since 3.6.0 + **/ + _destroy: function (options) { + delete LocalSync._data[this.root][this.get('id')]; + this._save(); + return this.toJSON(); + }, + + /** + Saves the current in-memory store into a localStorage key/value pair + if localStorage is available; otherwise, does nothing. + + @method _save + @protected + @since 3.6.0 + **/ + _save: function () { + this.storage && this.storage.setItem( + this.root, + Y.JSON.stringify(LocalSync._data[this.root]) + ); + } +}; + +// -- Namespace --------------------------------------------------------------- + +Y.namespace('ModelSync').Local = LocalSync; diff --git a/src/app/meta/app.json b/src/app/meta/app.json index fe00acda4b8..23aaac785ad 100644 --- a/src/app/meta/app.json +++ b/src/app/meta/app.json @@ -8,6 +8,7 @@ "model", "model-list", "model-sync-rest", + "model-sync-local", "router", "view", "view-node-map" @@ -68,6 +69,13 @@ ] }, + "model-sync-local": { + "requires": [ + "model", + "json-stringify" + ] + }, + "model-sync-rest": { "requires": [ "model", diff --git a/src/app/tests/unit/app.html b/src/app/tests/unit/app.html index 4fab0c0760a..b80d9e91159 100644 --- a/src/app/tests/unit/app.html +++ b/src/app/tests/unit/app.html @@ -32,6 +32,7 @@ 'lazy-model-list-test', 'model-test', 'model-list-test', + 'model-sync-local-test', 'model-sync-rest-test', 'router-test', 'view-test', @@ -63,6 +64,11 @@ fullpath: 'assets/model-list-test.js', requires: ['model-list', 'test'] }, + + 'model-sync-local-test': { + fullpath: 'assets/model-sync-local-test.js', + requires: ['model', 'model-list', 'model-sync-local', 'test'] + }, 'model-sync-rest-test': { fullpath: 'assets/model-sync-rest-test.js', diff --git a/src/app/tests/unit/assets/model-sync-local-test.js b/src/app/tests/unit/assets/model-sync-local-test.js new file mode 100644 index 00000000000..ef751c230ac --- /dev/null +++ b/src/app/tests/unit/assets/model-sync-local-test.js @@ -0,0 +1,201 @@ +YUI.add('model-sync-local-test', function (Y) { + +var ArrayAssert = Y.ArrayAssert, + Assert = Y.Assert, + ObjectAssert = Y.ObjectAssert, + + suite, + modelSyncLocalSuite; + +// -- Global Suite ------------------------------------------------------------- +suite = Y.AppTestSuite || (Y.AppTestSuite = new Y.Test.Suite('App Framework')); + +// -- ModelSync.Local Suite ---------------------------------------------------- +modelSyncLocalSuite = new Y.Test.Suite('ModelSync.Local'); + +// -- ModelSync.Local: Lifecycle ----------------------------------------------- +modelSyncLocalSuite.add(new Y.Test.Case({ + name: 'Lifecycle', + + setUp: function () { + var store; + + try { + store = Y.config.win.localStorage; + store.clear(); + } catch (e) { + Y.log("Could not access localStorage.", "warn"); + } + + Y.TestModel = Y.Base.create('customModel', Y.Model, [Y.ModelSync.Local]); + + Y.TestModelList = Y.Base.create('testModelList', Y.ModelList, [Y.ModelSync.Local], { + model: Y.TestModel + }) + }, + + tearDown: function () { + delete Y.TestModel; + delete Y.TestModelList; + }, + + 'initializer should set the `root` property on the instance': function () { + var model = new Y.TestModel({root: 'model'}), + modelList = new Y.TestModelList({root: 'list'}); + + Assert.areSame('model', model.root); + Assert.areSame('list', modelList.root); + }, + + '`root` property should be an empty string by default': function () { + var model = new Y.TestModel(), + modelList = new Y.TestModelList(); + + Assert.areSame('', model.root); + Assert.areSame('', modelList.root); + }, + + '`localStorage` should be set to the `storage` property': function () { + var model = new Y.TestModel(), + modelList = new Y.TestModelList(); + test = 'test', + hasStorage = function(context) { + try { + context.storage.setItem(test, test); + context.storage.removeItem(test); + return true; + } catch (e) { + return false; + } + }; + + Assert.isTrue(hasStorage(model), 'Model storage not properly set'); + Assert.isTrue(hasStorage(modelList), 'List storage not properly set'); + }, + + '`data` property should be filled with any existing `localStorage` data': function () { + var testStore; + try { + testStore = Y.config.win.localStorage; + testStore.setItem('users', '{"users-1":{"id":"users-1","name":"clarle"},"users-2":{"id":"users-2","name":"eric"}}'); + } catch (e) { + Y.log("Could not access localStorage.", "warn"); + } + + var model = new Y.TestModel({root: 'users', id: 'users-1'}), + modelList = new Y.TestModelList({ root: 'users'}), + data = Y.ModelSync.Local._data; + + Assert.areSame('clarle', data['users']['users-1']['name']); + } +})); + +// -- ModelSync.Local: Sync ---------------------------------------------------- +modelSyncLocalSuite.add(new Y.Test.Case({ + name: 'Sync', + + setUp: function () { + try { + testStore = Y.config.win.localStorage; + testStore.clear(); + testStore.setItem('users', '{"users-1":{"id":"users-1","name":"clarle"},"users-2":{"id":"users-2","name":"eric"}}'); + + } catch (e) { + Y.ModelSync._data = {"users": {"users-1":{"id":"users-1","name":"clarle"},"users-2":{"id":"users-2","name":"eric"}}}; + } + + Y.TestModel = Y.Base.create('user', Y.Model, [Y.ModelSync.Local], { + root: 'users' + }); + + Y.TestModelList = Y.Base.create('users', Y.ModelList, [Y.ModelSync.Local], { + model: Y.TestModel + }) + }, + + tearDown: function () { + delete Y.TestModel; + delete Y.TestModelList; + try { + Y.config.win.storage.clear(); + } catch (e) { + Y.log("Could not access localStorage.", "warn"); + } + }, + + 'load() of Model should get the stored local object': function () { + var model = new Y.TestModel({id: 'users-1'}); + model.load(); + Assert.areSame('clarle', model.get('name')); + }, + + 'load() of ModelList should get all stored local objects': function () { + var modelList = new Y.TestModelList(); + + Assert.areSame('users', modelList.root); + + modelList.load(); + + Assert.areSame(2, modelList.size()); + Assert.areSame('users-1', modelList.item(0).get('id')); + Assert.areSame('clarle', modelList.item(0).get('name')); + }, + + 'save() of a new Model should create a new object with an ID': function () { + var model = new Y.TestModel({name: 'dav'}); + + Assert.isUndefined(model.get('id'), 'Initial model ID should be undefined.'); + model.save(); + Assert.isNotNull(model.get('id'), 'Model ID should not be null'); + Assert.areSame('dav', model.get('name'), 'Model should have correct name'); + }, + + 'save() of an existing Model should update the object': function () { + var model = new Y.TestModel({id: 'users-2'}); + model.load(); + Assert.areSame('eric', model.get('name'), 'Model should have correct name'); + model.set('name', 'satyen'); + model.save(); + Assert.areSame('satyen', model.get('name'), 'Model should have updated name'); + }, + + 'destroy({remove: true}) of an existing Model should delete the object': function () { + var model = new Y.TestModel({id: 'users-1'}), + data; + + model.load(); + Assert.areSame('clarle', model.get('name'), 'Model should have correct name'); + model.destroy({remove: true}); + + data = Y.ModelSync.Local._data; + Assert.isUndefined(data['users']['users-1'], 'Data should be deleted'); + }, + + 'Failed lookups should pass an error message to the callback': function () { + var model = new Y.TestModel({id: 'users-3'}); + + model.sync('read', {}, function (err, res) { + Assert.areSame('Data not found in LocalStorage', err); + }); + }, + + 'Failed syncs due to errors should pass an error message to the callback': function () { + var model = new Y.TestModel({id: 'users-4'}); + + model._save = function () { + throw new Error('Failed sync'); + } + + model.set('name', 'jeff'); + model.save(function (err, res) { + Assert.areSame('Failed sync', err); + }); + } +})); + + +suite.add(modelSyncLocalSuite); + +}, '@VERSION@', { + requires: ['model-sync-local', 'model', 'model-list', 'test'] +});