From 4bc476a57a1e2d4e1cc22abb9ef59c198c997d7f Mon Sep 17 00:00:00 2001 From: Andy Gup Date: Thu, 19 Nov 2015 17:48:03 -0700 Subject: [PATCH] simple edit library initial commit --- lib/edit/OfflineFeatureLayer.js | 1003 +++++++++++++++++++++++++++++++ lib/edit/editStorePOLS.js | 420 +++++++++++++ lib/edit/offlineJSOptions.js | 13 + 3 files changed, 1436 insertions(+) create mode 100644 lib/edit/OfflineFeatureLayer.js create mode 100644 lib/edit/editStorePOLS.js create mode 100644 lib/edit/offlineJSOptions.js diff --git a/lib/edit/OfflineFeatureLayer.js b/lib/edit/OfflineFeatureLayer.js new file mode 100644 index 0000000..6973bfa --- /dev/null +++ b/lib/edit/OfflineFeatureLayer.js @@ -0,0 +1,1003 @@ +/*jshint -W030 */ +/** + * This library is optimized for Partial Offline Support ONLY + */ +define([ + "dojo/Evented", + "dojo/_base/Deferred", + "dojo/promise/all", + "dojo/_base/declare", + "dojo/_base/array", + "dojo/dom-attr", + "dojo/dom-style", + "dojo/query", + "dojo/on", + "esri/config", + "esri/layers/GraphicsLayer", + "esri/layers/FeatureLayer", + "esri/graphic"], + function (Evented, Deferred, all, declare, array, domAttr, domStyle, query, on, + esriConfig, GraphicsLayer, FeatureLayer, Graphic) { + "use strict"; + return declare("O.esri.Edit.OfflineFeatureLayer", [Evented], + { + _onlineStatus: "online", + _featureLayers: {}, + _editStore: new O.esri.Edit.EditStorePOLS(), + _defaultXhrTimeout: 15000, // ms + _autoOfflineDetect: false, + + ONLINE: "online", // all edits will directly go to the server + OFFLINE: "offline", // edits will be enqueued + RECONNECTING: "reconnecting", // sending stored edits to the server + proxyPath: null, // by default we use CORS and therefore proxyPath is null + + // Database properties + DB_NAME: "features_store", // Sets the database name. + DB_OBJECTSTORE_NAME: "features",// Represents an object store that allows access to a set of data in the IndexedDB database + DB_UID: "objectid", // Set this based on the unique identifier is set up in the feature service + + // manager emits event when... + events: { + EDITS_SENT: "edits-sent", // ...whenever any edit is actually sent to the server + EDITS_ENQUEUED: "edits-enqueued", // ...when an edit is enqueued (and not sent to the server) + EDITS_ENQUEUED_ERROR: "edits-enqueued-error", // ...when there is an error during the queing process + }, + + constructor: function(options){ + if(options && options.hasOwnProperty("autoDetect")){ + this._autoOfflineDetect = options.autoDetect; + } + }, + + /** + * Overrides a feature layer. Call this AFTER the FeatureLayer's 'update-end' event. + * objects such as [esri.Graphic] will need to be serialized or you will get an IndexedDB error. + * @param layer + * @param updateEndEvent The FeatureLayer's update-end event object + * @param callback {true, null} or {false, errorString} Traps whether or not the database initialized + * @returns deferred + */ + extend: function (layer, callback) { + + var extendPromises = []; // deferred promises related to initializing this method + + var self = this; + layer.offlineExtended = true; // to identify layer has been extended + + if(!layer.loaded || layer._url === null) { + console.error("Make sure to initialize OfflineFeaturesManager after layer loaded and feature layer update-end event."); + } + + // NOTE: At v2.6.1 we've discovered that not all feature layers support objectIdField. + // However, to try to be consistent here with how the library is managing Ids + // we force the layer.objectIdField to DB_UID. This should be consistent with + // how esri.Graphics assign a unique ID to a graphic. If it is not, then this + // library will break and we'll have to re-architect how it manages UIDs. + layer.objectIdField = this.DB_UID; + + var url = null; + + // There have been reproducible use cases showing when a browser is restarted offline that + // for some reason the layer.url may be undefined. + // This is an attempt to minimize the possibility of that situation causing errors. + if(layer.url) { + url = layer.url; + // we keep track of the FeatureLayer object + this._featureLayers[layer.url] = layer; + } + + // Initialize the database as well as set offline data. + if(!this._editStore._isDBInit) { + extendPromises.push(this._initializeDB(url)); + } + + if(this._autoOfflineDetect){ + Offline.on('up', self.goOnline(function(success,error){ // jshint ignore:line + + })); + + Offline.on('down', self.goOffline()); // jshint ignore:line + } + + // replace the applyEdits() method + layer._applyEdits = layer.applyEdits; + + /** + * Overrides the ArcGIS API for JavaSccript applyEdits() method. + * @param adds Creates a new edit entry. + * @param updates Updates an existing entry. + * @param deletes Deletes an existing entry. + * @param callback Called when the operation is complete. + * @param errback An error object is returned if an error occurs + * @returns {*} deferred + * @event EDITS_ENQUEUED boolean if all edits successfully stored while offline + * @event EDITS_ENQUEUED_ERROR string message if there was an error while storing an edit while offline + */ + layer.applyEdits = function (adds, updates, deletes, callback, errback) { + // inside this method, 'this' will be the FeatureLayer + // and 'self' will be the offlineFeatureLayer object + var promises = []; + + if (self.getOnlineStatus() === self.ONLINE) { + var def = layer._applyEdits(adds, updates, deletes, + function () { + self.emit(self.events.EDITS_SENT, arguments); + callback && callback.apply(this, arguments); + }, + errback); + return def; + } + + var deferred1 = new Deferred(); + var results = {addResults: [], updateResults: [], deleteResults: []}; + var updatesMap = {}; + + var _adds = adds || []; + _adds.forEach(function (addEdit) { + var deferred = new Deferred(); + + var objectId = this._getNextTempId(); + + addEdit.attributes[this.objectIdField] = objectId; + + var thisLayer = this; + + // We need to run some validation tests against each feature being added. + // Adding the same feature multiple times results in the last edit wins. LIFO. + this._validateFeature(addEdit,this.url,self._editStore.ADD).then(function(result){ + console.log("EDIT ADD IS BACK!!! " ); + + if(result.success){ + thisLayer._pushValidatedAddFeatureToDB(thisLayer,addEdit,result.operation,results,objectId,deferred); + } + else{ + // If we get here then we deleted an edit that was added offline. + deferred.resolve(true); + } + + },function(error){ + console.log("_validateFeature: Unable to validate!"); + deferred.reject(error); + }); + + promises.push(deferred); + }, this); + + updates = updates || []; + updates.forEach(function (updateEdit) { + var deferred = new Deferred(); + + var objectId = updateEdit.attributes[this.objectIdField]; + updatesMap[objectId] = updateEdit; + + var thisLayer = this; + + // We need to run some validation tests against each feature being updated. + // If we have added a feature and we need to update it then we change it's operation type to "add" + // and the last edits wins. LIFO. + this._validateFeature(updateEdit,this.url,self._editStore.UPDATE).then(function(result){ + console.log("EDIT UPDATE IS BACK!!! " ); + + if(result.success){ + thisLayer._pushValidatedUpdateFeatureToDB(thisLayer,updateEdit,result.operation,results,objectId,deferred); + } + else{ + // If we get here then we deleted an edit that was added offline. + deferred.resolve(true); + } + + },function(error){ + console.log("_validateFeature: Unable to validate!"); + deferred.reject(error); + }); + + promises.push(deferred); + }, this); + + deletes = deletes || []; + deletes.forEach(function (deleteEdit) { + var deferred = new Deferred(); + + var objectId = deleteEdit.attributes[this.objectIdField]; + + var thisLayer = this; + + // We need to run some validation tests against each feature being deleted. + // If we have added a feature and then deleted it in the app + this._validateFeature(deleteEdit,this.url,self._editStore.DELETE).then(function(result){ + console.log("EDIT DELETE IS BACK!!! " ); + + if(result.success){ + thisLayer._pushValidatedDeleteFeatureToDB(thisLayer,deleteEdit,result.operation,results,objectId,deferred); + } + else{ + // If we get here then we deleted an edit that was added offline. + deferred.resolve(true); + } + + },function(error){ + console.log("_validateFeature: Unable to validate!"); + deferred.reject(error); + }); + + promises.push(deferred); + }, this); + + all(promises).then(function (r) { + // Make sure all edits were successful. If not throw an error. + var promisesSuccess = true; + for (var v = 0; v < r.length; v++) { + if (r[v] === false) { + promisesSuccess = false; + } + } + + promisesSuccess === true ? self.emit(self.events.EDITS_ENQUEUED, results) : self.emit(self.events.EDITS_ENQUEUED_ERROR, results); + // EDITS_ENQUEUED = callback(true, edit), and EDITS_ENQUEUED_ERROR = callback(false, /*String */ error) + this._editHandler(results, _adds, updatesMap, callback, errback, deferred1); + }.bind(this)); + + return deferred1; + + }; // layer.applyEdits() + + /** + * Returns the approximate size of the edits database in bytes + * @param callback callback({usage}, error) Whereas, the usage Object is {sizeBytes: number, editCount: number} + */ + layer.getUsage = function(callback){ + self._editStore.getUsage(function(usage,error){ + callback(usage,error); + }); + }; + + /** + * Full edits database reset. + * CAUTION! If some edits weren't successfully sent, then their record + * will still exist in the database. If you use this function you + * will also delete those records. + * @param callback (boolean, error) + */ + layer.resetDatabase = function(callback){ + self._editStore.resetEditsQueue(function(result,error){ + callback(result,error); + }); + }; + + /** + * Returns the number of edits pending in the database. + * @param callback callback( int ) + */ + layer.pendingEditsCount = function(callback){ + self._editStore.pendingEditsCount(function(count){ + callback(count); + }); + }; + + /** + * Create a featureDefinition + * @param featureLayer + * @param featuresArr + * @param geometryType + * @param callback + */ + layer.getFeatureDefinition = function (/* Object */ featureLayer, /* Array */ featuresArr, /* String */ geometryType, callback) { + + var featureDefinition = { + "layerDefinition": featureLayer, + "featureSet": { + "features": featuresArr, + "geometryType": geometryType + } + + }; + + callback(featureDefinition); + }; + + /** + * Returns an iterable array of all edits stored in the database + * Each item in the array is an object and contains: + * { + * id: "internal ID", + * operation: "add, update or delete", + * layer: "layerURL", + * type: "esri Geometry Type", + * graphic: "esri.Graphic converted to JSON then serialized" + * } + * @param callback (true, array) or (false, errorString) + */ + layer.getAllEditsArray = function(callback){ + self._editStore.getAllEditsArray(function(array,message){ + if(message == "end"){ + callback(true,array); + } + else{ + callback(false,message); + } + }); + }; + + /* internal methods */ + + /** + * Pushes a DELETE request to the database after it's been validated + * @param layer + * @param deleteEdit + * @param operation + * @param resultsArray + * @param objectId + * @param deferred + * @private + */ + layer._pushValidatedDeleteFeatureToDB = function(layer,deleteEdit,operation,resultsArray,objectId,deferred){ + self._editStore.pushEdit(operation, layer.url, deleteEdit, function (result, error) { + + if(result){ + resultsArray.deleteResults.push({success: true, error: null, objectId: objectId}); + + // Use the correct key as set by self.DB_UID + var tempIdObject = {}; + tempIdObject[self.DB_UID] = objectId; + } + else{ + resultsArray.deleteResults.push({success: false, error: error, objectId: objectId}); + } + + deferred.resolve(result); + }); + }; + + /** + * Pushes an UPDATE request to the database after it's been validated + * @param layer + * @param updateEdit + * @param operation + * @param resultsArray + * @param objectId + * @param deferred + * @private + */ + layer._pushValidatedUpdateFeatureToDB = function(layer,updateEdit,operation,resultsArray,objectId,deferred){ + self._editStore.pushEdit(operation, layer.url, updateEdit, function (result, error) { + + if(result){ + resultsArray.updateResults.push({success: true, error: null, objectId: objectId}); + + // Use the correct key as set by self.DB_UID + var tempIdObject = {}; + tempIdObject[self.DB_UID] = objectId; + } + else{ + resultsArray.updateResults.push({success: false, error: error, objectId: objectId}); + } + + deferred.resolve(result); + }); + }; + + /** + * Pushes an ADD request to the database after it's been validated + * @param layer + * @param addEdit + * @param operation + * @param resultsArray + * @param objectId + * @param deferred + * @private + */ + layer._pushValidatedAddFeatureToDB = function(layer,addEdit,operation,resultsArray,objectId,deferred){ + self._editStore.pushEdit(operation, layer.url, addEdit, function (result, error) { + if(result){ + resultsArray.addResults.push({success: true, error: null, objectId: objectId}); + + // Use the correct key as set by self.DB_UID + var tempIdObject = {}; + tempIdObject[self.DB_UID] = objectId; + } + else{ + resultsArray.addResults.push({success: false, error: error, objectId: objectId}); + } + + deferred.resolve(result); + }); + }; + + /** + * Validates duplicate entries. Last edit on same feature can overwite any previous values. + * Note: if an edit was already added offline and you delete it then we return success == false + * @param graphic esri.Graphic. + * @param layerUrl the URL of the feature service + * @param operation add, update or delete action on an edit + * @returns deferred {success:boolean,graphic:graphic,operation:add|update|delete} + * @private + */ + layer._validateFeature = function (graphic,layerUrl,operation) { + + var deferred = new Deferred(); + + var id = layerUrl + "/" + graphic.attributes[self.DB_UID]; + + self._editStore.getEdit(id,function(success,result){ + if (success) { + switch( operation ) + { + case self._editStore.ADD: + // Not good - however we'll allow the new ADD to replace/overwrite existing edit + // and pass it through unmodified. Last ADD wins. + deferred.resolve({"success":true,"graphic":graphic,"operation":operation}); + break; + case self._editStore.UPDATE: + // If we are doing an update on a feature that has not been added to + // the server yet, then we need to maintain its operation as an ADD + // and not an UPDATE. This avoids the potential for an error if we submit + // an update operation on a feature that has not been added to the + // database yet. + if(result.operation == self._editStore.ADD){ + graphic.operation = self._editStore.ADD; + operation = self._editStore.ADD; + } + deferred.resolve({"success":true,"graphic":graphic,"operation":operation}); + break; + case self._editStore.DELETE: + + var resolved = true; + + if(result.operation == self._editStore.ADD){ + // If we are deleting a new feature that has not been added to the + // server yet we need to delete it + layer._deleteTemporaryFeature(graphic,function(success, error){ + if(!success){ + resolved = false; + console.log("Unable to delete feature: " + JSON.stringify(error)); + } + }); + } + deferred.resolve({"success":resolved,"graphic":graphic,"operation":operation}); + break; + } + } + else if(result == "Id not found"){ + // Let's simply pass the graphic back as good-to-go. + // No modifications needed because the graphic does not + // already exist in the database. + deferred.resolve({"success":true,"graphic":graphic,"operation":operation}); + } + else{ + deferred.reject(graphic); + } + }); + + return deferred; + }; + + /** + * Delete a graphic that has been added while offline. + * @param graphic + * @param callback + * @private + */ + layer._deleteTemporaryFeature = function(graphic,callback){ + self._editStore.delete(layer.url,graphic,function(success,error){ + callback(success, error); + }); + }; + + layer._getFilesFromForm = function (formNode) { + var files = []; + var inputNodes = array.filter(formNode.elements, function (node) { + return node.type === "file"; + }); + inputNodes.forEach(function (inputNode) { + files.push.apply(files, inputNode.files); + }, this); + return files; + }; + + // we need to identify ADDs before sending them to the server + // we assign temporary ids (using negative numbers to distinguish them from real ids) + layer._nextTempId = -1; + layer._getNextTempId = function () { + return this._nextTempId--; + }; + + // We are currently only passing in a single deferred. + all(extendPromises).then(function (r) { + callback(true, null); + }); + + }, // extend + + /** + * Forces library into an offline state. Any edits applied during this condition will be stored locally + */ + goOffline: function () { + console.log("offlineFeatureManager going offline"); + this._onlineStatus = this.OFFLINE; + }, + + /** + * Forces library to return to an online state. If there are pending edits, + * an attempt will be made to sync them with the remote feature server + * @param callback callback( boolean, errors ) + */ + goOnline: function (callback) { + console.log("offlineFeaturesManager going online"); + this._onlineStatus = this.RECONNECTING; + this._replayStoredEdits(function (success, responses) { + //var result = {success: success, responses: responses}; + this._onlineStatus = this.ONLINE; + + //this._onlineStatus = this.ONLINE; + callback && callback(success,responses); + + }.bind(this)); + }, + + /** + * Determines if offline or online condition exists + * @returns {string} ONLINE or OFFLINE + */ + getOnlineStatus: function () { + return this._onlineStatus; + }, + + /* internal methods */ + + /** + * Initialize the database and push featureLayer JSON to DB if required. + * @param url Feature Layer's url. This is used by this library for internal feature identification. + * @return deferred + * @private + */ + _initializeDB: function(url){ + var deferred = new Deferred(); + + var editStore = this._editStore; + + // Configure the database + editStore.dbName = this.DB_NAME; + editStore.objectStoreName = this.DB_OBJECTSTORE_NAME; + editStore.objectId = this.DB_UID; + + // Attempt to initialize the database + editStore.init(function (result, error) { + + if(result){ + deferred.resolve({success:true, error: null}); + } + else{ + deferred.reject({success:false, error: null}); + } + }); + + return deferred; + }, + + // + // methods to send features back to the server + // + + /** + * Attempts to send any edits in the database. Monitor events for success or failure. + * @param callback + * @event ALL_EDITS_SENT when all edits have been successfully sent. Contains {[addResults],[updateResults],[deleteResults]} + * @event EDITS_SENT_ERROR some edits were not sent successfully. Contains {msg: error} + * @private + */ + _replayStoredEdits: function (callback) { + var promises = {}; + var that = this; + + // + // send edits for each of the layers + // + var layer; + var adds = [], updates = [], deletes = []; + var tempObjectIds = []; + var tempArray = []; + var featureLayers = this._featureLayers; + + var editStore = this._editStore; + + this._editStore.getAllEditsArray(function (result, err) { + if (result.length > 0) { + tempArray = result; + + var length = tempArray.length; + + for (var n = 0; n < length; n++) { + layer = featureLayers[tempArray[n].layer]; + layer.__onEditsComplete = layer.onEditsComplete; + layer.onEditsComplete = function () { + console.log("intercepting events onEditsComplete"); + }; + + // Let's zero everything out + adds = [], updates = [], deletes = [], tempObjectIds = []; + + // IMPORTANT: reconstitute the graphic JSON into an actual esri.Graphic object + // NOTE: we are only sending one Graphic per loop! + var graphic = new Graphic(tempArray[n].graphic); + + switch (tempArray[n].operation) { + case editStore.ADD: + for (var i = 0; i < layer.graphics.length; i++) { + var g = layer.graphics[i]; + if (g.attributes[layer.objectIdField] === graphic.attributes[layer.objectIdField]) { + layer.remove(g); + break; + } + } + tempObjectIds.push(graphic.attributes[layer.objectIdField]); + delete graphic.attributes[layer.objectIdField]; + adds.push(graphic); + break; + case editStore.UPDATE: + updates.push(graphic); + break; + case editStore.DELETE: + deletes.push(graphic); + break; + } + + // Note: when the feature layer is created with a feature collection we have to handle applyEdits() differently + // TO-DO rename this method. + promises[n] = that._internalApplyEditsAll(layer, tempArray[n].id, tempObjectIds, adds, updates, deletes); + } + + // wait for all requests to finish + // responses contain {id,layer,tempId,addResults,updateResults,deleteResults} + var allPromises = all(promises); + allPromises.then( + function (responses) { + console.log("OfflineFeaturesManager sync - all responses are back"); + callback(true, responses); + }, + function (errors) { + console.log("OfflineFeaturesManager._replayStoredEdits - ERROR!!"); + callback(false, errors); + } + ); + + } + else{ + // No edits were found + callback(true,[]); + } + }); + }, + + /** + * DEPRECATED as of v2.11 - + * TO-DO remove in next release + * Only delete items from database that were verified as successfully updated on the server. + * @param responses Object + * @param callback callback(true, responses) or callback(false, responses) + * @private + */ + _cleanSuccessfulEditsDatabaseRecords: function (responses, callback) { + if (Object.keys(responses).length !== 0) { + + var editsArray = []; + var editsFailedArray = []; + + for (var key in responses) { + if (responses.hasOwnProperty(key)) { + + var edit = responses[key]; + var tempResult = {}; + + if (edit.updateResults.length > 0) { + if (edit.updateResults[0].success) { + tempResult.layer = edit.layer; + tempResult.id = edit.updateResults[0].objectId; + editsArray.push(tempResult); + } + else { + editsFailedArray.push(edit); + } + } + if (edit.deleteResults.length > 0) { + if (edit.deleteResults[0].success) { + tempResult.layer = edit.layer; + tempResult.id = edit.deleteResults[0].objectId; + editsArray.push(tempResult); + } + else { + editsFailedArray.push(edit); + } + } + if (edit.addResults.length > 0) { + if (edit.addResults[0].success) { + tempResult.layer = edit.layer; + tempResult.id = edit.tempId; + editsArray.push(tempResult); + } + else { + editsFailedArray.push(edit); + } + } + } + } + + var promises = {}; + var length = editsArray.length; + for (var i = 0; i < length; i++) { + promises[i] = this._updateDatabase(editsArray[i]); + } + //console.log("EDIT LIST " + JSON.stringify(editsArray)); + + // wait for all requests to finish + // + var allPromises = all(promises); + allPromises.then( + function (responses) { + editsFailedArray.length > 0 ? callback(false, responses) : callback(true, responses); + }, + function (errors) { + callback(false, errors); + } + ); + } + else { + callback(true, {}); + } + }, + + /** + * Deletes edits from database. + * @param edit + * @returns {l.Deferred.promise|*|c.promise|q.promise|promise} + * @private + */ + _updateDatabase: function (edit) { + var dfd = new Deferred(); + var fakeGraphic = {}; + fakeGraphic.attributes = {}; + + // Use the correct attributes key! + fakeGraphic.attributes[this.DB_UID] = edit.id; + + this._editStore.delete(edit.layer, fakeGraphic, function (success, error) { + if (success) { + dfd.resolve({success: true, error: null}); + } + else { + dfd.reject({success: false, error: error}); + } + }.bind(this)); + + return dfd.promise; + + }, + + /** + * Applies edits. This works with both standard feature layers and when a feature layer is created + * using a feature collection. + * + * This works around specific behaviors in esri.layers.FeatureLayer when using the pattern + * new FeatureLayer(featureCollectionObject). + * + * Details on the specific behaviors can be found here: + * https://developers.arcgis.com/javascript/jsapi/featurelayer-amd.html#featurelayer2 + * + * @param layer + * @param id + * @param tempObjectIds + * @param adds + * @param updates + * @param deletes + * @returns {*|r} + * @private + */ + _internalApplyEditsAll: function (layer, id, tempObjectIds, adds, updates, deletes) { + var that = this; + var dfd = new Deferred(); + + this._makeEditRequest(layer, adds, updates, deletes, + function (addResults, updateResults, deleteResults) { + + if(addResults.length > 0) { + var graphic = new Graphic(adds[0].geometry,null,adds[0].attributes); + layer.add(graphic); + } + + that._cleanDatabase(layer, tempObjectIds, addResults, updateResults, deleteResults).then(function(results){ + dfd.resolve({ + id: id, + layer: layer.url, + tempId: tempObjectIds, // let's us internally match an ADD to it's new ObjectId + addResults: addResults, + updateResults: updateResults, + deleteResults: deleteResults, + databaseResults: results, + databaseErrors: null, + syncError: null + }); + }, function(error) { + dfd.resolve({ + id: id, + layer: layer.url, + tempId: tempObjectIds, // let's us internally match an ADD to it's new ObjectId + addResults: addResults, + updateResults: updateResults, + deleteResults: deleteResults, + databaseResults: null, + databaseErrors: error, + syncError: error + }); + }); + + }, + function (error) { + layer.onEditsComplete = layer.__onEditsComplete; + delete layer.__onEditsComplete; + + dfd.reject(error); + } + ); + return dfd.promise; + }, + + _cleanDatabase: function(layer, tempId, addResults, updateResults, deleteResults) { + + var dfd = new Deferred(); + var id = null; + + if (updateResults.length > 0) { + if (updateResults[0].success) { + id = updateResults[0].objectId; + } + } + if (deleteResults.length > 0) { + if (deleteResults[0].success) { + id = deleteResults[0].objectId; + } + } + if (addResults.length > 0) { + if (addResults[0].success) { + id = tempId; + } + } + + var fakeGraphic = {}; + fakeGraphic.attributes = {}; + + // Use the correct attributes key! + fakeGraphic.attributes[this.DB_UID] = id; + + // Delete the edit from the database + this._editStore.delete(layer.url, fakeGraphic, function (success, error) { + if (success) { + dfd.resolve({success: true, error: null, id: id}); + } + else { + dfd.reject({success: false, error: error, id: id}); + } + }); + + return dfd.promise; + }, + + /** + * Used when a feature layer is created with a feature collection. + * + * In the current version of the ArcGIS JSAPI 3.12+ the applyEdit() method doesn't send requests + * to the server when a feature layer is created with a feature collection. + * + * The use case for using this is: clean start app > go offline and make edits > offline restart browser > + * go online. + * + * @param layer + * @param adds + * @param updates + * @param deletes + * @returns {*|r} + * @private + */ + _makeEditRequest: function(layer,adds, updates, deletes, callback, errback) { + + var f = "f=json", a = "", u = "", d = ""; + + if(adds.length > 0) { + array.forEach(adds, function(add){ + if(add.hasOwnProperty("infoTemplate")){ // if the add has an infoTemplate attached, + delete add.infoTemplate; // delete it to reduce payload size. + } + }, this); + a = "&adds=" + JSON.stringify((adds)); + } + if(updates.length > 0) { + array.forEach(updates, function(update){ + if(update.hasOwnProperty("infoTemplate")){ // if the update has an infoTemplate attached, + delete update.infoTemplate; // delete it to reduce payload size. + } + }, this); + u = "&updates=" + JSON.stringify(updates); + } + if(deletes.length > 0) { + var id = deletes[0].attributes[this.DB_UID]; + d = "&deletes=" + id; + } + + var params = f + a + u + d; + + if(layer.hasOwnProperty("credential") && layer.credential){ + if(layer.credential.hasOwnProperty("token") && layer.credential.token){ + params = params + "&token=" + layer.credential.token; + } + } + + var req = new XMLHttpRequest(); + req.open("POST", layer.url + "/applyEdits", true); + req.setRequestHeader("Content-type", "application/x-www-form-urlencoded"); + req.onload = function() + { + if( req.status === 200 && req.responseText !== "") + { + try { + var obj = JSON.parse(this.response); + callback(obj.addResults, obj.updateResults, obj.deleteResults); + } + catch(err) { + console.error("EDIT REQUEST REPONSE WAS NOT SUCCESSFUL:", req); + errback("Unable to parse xhr response", req); + } + } + + }; + req.onerror = function(e) + { + console.error("_makeEditRequest failed: " + e); + errback(e); + }; + req.ontimeout = function() { + errback("xhr timeout error"); + }; + req.timeout = this._defaultXhrTimeout; + req.send(params); + }, + + /** + * Parses the respones related to going back online and cleaning up the database. + * @param responses + * @returns {promise} True means all was successful. False indicates there was a problem. + * @private + */ + _parseResponsesArray: function(responses,callback) { + + var err = 0; + + for (var key in responses) { + if (responses.hasOwnProperty(key)) { + responses[key].addResults.forEach(function(result){ + if(!result.success) { + err++; + } + }); + + responses[key].updateResults.forEach(function(result){ + if(!result.success) { + err++; + } + }); + + responses[key].deleteResults.forEach(function(result){ + if(!result.success) { + err++; + } + }); + } + } + + if(err > 0){ + callback(false); + } + else { + callback(true); + } + } + }); // declare + }); // define \ No newline at end of file diff --git a/lib/edit/editStorePOLS.js b/lib/edit/editStorePOLS.js new file mode 100644 index 0000000..4018d77 --- /dev/null +++ b/lib/edit/editStorePOLS.js @@ -0,0 +1,420 @@ +/*global indexedDB */ +/*jshint -W030 */ +/** + * This library is optimized for Partial Offline Support ONLY + * @constructor + */ +O.esri.Edit.EditStorePOLS = function () { + + "use strict"; + + this._db = null; + this._isDBInit = false; + + // Public properties + + this.dbName = "features_store"; + this.objectStoreName = "features"; + this.objectId = "objectid"; // set this depending on how your feature service is configured; + + //var _dbIndex = "featureId"; // @private + + // ENUMs + + this.ADD = "add"; + this.UPDATE = "update"; + this.DELETE = "delete"; + + this.FEATURE_LAYER_JSON_ID = "feature-layer-object-1001"; + this.FEATURE_COLLECTION_ID = "feature-collection-object-1001"; + + this.isSupported = function () { + if (!window.indexedDB) { + return false; + } + return true; + }; + + /** + * Commit an edit to the database + * @param operation add, update or delete + * @param layerUrl the URL of the feature layer + * @param graphic esri/graphic. The method will serialize to JSON + * @param callback callback(true, edit) or callback(false, error) + */ + this.pushEdit = function (operation, layerUrl, graphic, callback) { + + var edit = { + id: layerUrl + "/" + graphic.attributes[this.objectId], + operation: operation, + layer: layerUrl, + type: graphic.geometry.type, + graphic: graphic.toJson() + }; + + if(typeof graphic.attributes[this.objectId] === "undefined") { + console.error("editsStore.pushEdit() - failed to insert undefined objectId into database. Did you set offlineFeaturesManager.DB_UID? " + JSON.stringify(graphic.attributes)); + callback(false,"editsStore.pushEdit() - failed to insert undefined objectId into database. Did you set offlineFeaturesManager.DB_UID? " + JSON.stringify(graphic.attributes)); + } + else{ + var transaction = this._db.transaction([this.objectStoreName], "readwrite"); + + transaction.oncomplete = function (event) { + callback(true); + }; + + transaction.onerror = function (event) { + callback(false, event.target.error.message); + }; + + var objectStore = transaction.objectStore(this.objectStoreName); + objectStore.put(edit); + } + }; + + /** + * Retrieve an edit by its internal ID + * @param id String identifier + * @param callback callback(true,graphic) or callback(false, error) + */ + this.getEdit = function(id,callback){ + + console.assert(this._db !== null, "indexeddb not initialized"); + var objectStore = this._db.transaction([this.objectStoreName], "readwrite").objectStore(this.objectStoreName); + + if(typeof id === "undefined"){ + callback(false,"id is undefined."); + return; + } + + //Get the entry associated with the graphic + var objectStoreGraphicRequest = objectStore.get(id); + + objectStoreGraphicRequest.onsuccess = function () { + var graphic = objectStoreGraphicRequest.result; + if (graphic && (graphic.id == id)) { + callback(true,graphic); + } + else { + callback(false,"Id not found"); + } + }; + + objectStoreGraphicRequest.onerror = function (msg) { + callback(false,msg); + }; + }; + + /** + * Returns all the edits as a single Array via the callback + * @param callback {array, messageString} or {null, messageString} + */ + this.getAllEditsArray = function (callback) { + + console.assert(this._db !== null, "indexeddb not initialized"); + var editsArray = []; + + if (this._db !== null) { + + var fLayerJSONId = this.FEATURE_LAYER_JSON_ID; + var fCollectionId = this.FEATURE_COLLECTION_ID; + + var transaction = this._db.transaction([this.objectStoreName]) + .objectStore(this.objectStoreName) + .openCursor(); + + transaction.onsuccess = function (event) { + var cursor = event.target.result; + if (cursor && cursor.value && cursor.value.id) { + + // Make sure we are not return FeatureLayer JSON data + if (cursor.value.id !== fLayerJSONId && cursor.value.id !== fCollectionId) { + editsArray.push(cursor.value); + + } + cursor.continue(); + } + else { + callback(editsArray, "end"); + } + }.bind(this); + transaction.onerror = function (err) { + callback(null, err); + }; + } + else { + callback(null, "no db"); + } + }; + + /** + * Update an edit already exists in the database + * @param operation add, update or delete + * @param layer the URL of the feature layer + * @param graphic esri/graphic. The method will serialize to JSON + * @param callback {true, edit} or {false, error} + */ + this.updateExistingEdit = function (operation, layer, graphic, callback) { + + console.assert(this._db !== null, "indexeddb not initialized"); + + var objectStore = this._db.transaction([this.objectStoreName], "readwrite").objectStore(this.objectStoreName); + + //Let's get the entry associated with the graphic + var objectStoreGraphicRequest = objectStore.get(graphic.attributes[this.objectId]); + objectStoreGraphicRequest.onsuccess = function () { + + //Grab the data object returned as a result + // TO-DO Do we keep this?? + objectStoreGraphicRequest.result; + + //Create a new update object + var update = { + id: layer + "/" + graphic.attributes[this.objectId], + operation: operation, + layer: layer, + graphic: graphic.toJson() + }; + + // Insert the update into the database + var updateGraphicRequest = objectStore.put(update); + + updateGraphicRequest.onsuccess = function () { + callback(true); + }; + + updateGraphicRequest.onerror = function (err) { + callback(false, err); + }; + }.bind(this); + }; + + /** + * Delete a pending edit's record from the database. + * IMPORTANT: Be aware of false negatives. See Step 4 in this function. + * + * @param layerUrl + * @param graphic Graphic + * @param callback {boolean, error} + */ + this.delete = function (layerUrl, graphic, callback) { + + // NOTE: the implementation of the IndexedDB spec has a design fault with respect to + // handling deletes. The result of a delete operation is always designated as undefined. + // What this means is that there is no way to tell if an operation was successful or not. + // And, it will always return 'true.' + // + // In order to get around this we have to verify if after the attempted deletion operation + // if the record is or is not in the database. Kinda dumb, but that's how IndexedDB works. + //http://stackoverflow.com/questions/17137879/is-there-a-way-to-get-information-on-deleted-record-when-calling-indexeddbs-obj + + var db = this._db; + var deferred = null; + var self = this; + + var id = layerUrl + "/" + graphic.attributes[this.objectId]; + + require(["dojo/Deferred"], function (Deferred) { + deferred = new Deferred(); + + // Step 1 - lets see if record exits. If it does then return callback. + self.editExists(id).then(function (result) { + + // Step 4 - Then we check to see if the record actually exists or not. + deferred.then(function (result) { + + // IF the delete was successful, then the record should return 'false' because it doesn't exist. + self.editExists(id).then(function (results) { + callback(false); + }, + function (err) { + callback(true); //because we want this test to throw an error. That means item deleted. + }); + }, + // There was a problem with the delete operation on the database + function (err) { + callback(false, err); + }); + + var objectStore = db.transaction([self.objectStoreName], "readwrite").objectStore(self.objectStoreName); + + // Step 2 - go ahead and delete graphic + var objectStoreDeleteRequest = objectStore.delete(id); + + // Step 3 - We know that the onsuccess will always fire unless something serious goes wrong. + // So we go ahead and resolve the deferred here. + objectStoreDeleteRequest.onsuccess = function () { + deferred.resolve(true); + }; + + objectStoreDeleteRequest.onerror = function (msg) { + deferred.reject({success: false, error: msg}); + }; + + }, + // If there is an error in editExists() + function (err) { + callback(false, err); + }); + }); + }; + + /** + * Full database reset. + * CAUTION! If some edits weren't successfully sent, then their record + * will still exist in the database. If you use this function you + * will also delete those records. + * @param callback boolean + */ + this.resetEditsQueue = function (callback) { + console.assert(this._db !== null, "indexeddb not initialized"); + + var request = this._db.transaction([this.objectStoreName], "readwrite") + .objectStore(this.objectStoreName) + .clear(); + request.onsuccess = function (event) { + setTimeout(function () { + callback(true); + }, 0); + }; + request.onerror = function (err) { + callback(false, err); + }; + }; + + this.pendingEditsCount = function (callback) { + console.assert(this._db !== null, "indexeddb not initialized"); + + var count = 0; + var id = this.FEATURE_LAYER_JSON_ID; + var fCollectionId = this.FEATURE_COLLECTION_ID; + + var transaction = this._db.transaction([this.objectStoreName], "readwrite"); + var objectStore = transaction.objectStore(this.objectStoreName); + objectStore.openCursor().onsuccess = function (evt) { + var cursor = evt.target.result; + if (cursor && cursor.value && cursor.value.id) { + if (cursor.value.id !== id && cursor.value.id !== fCollectionId) { + count++; + } + cursor.continue(); + } + else { + callback(count); + } + }; + }; + + /** + * Verify is an edit already exists in the database. Checks the objectId. + * @param id + * @returns {deferred} {success: boolean, error: message} + * @private + */ + this.editExists = function (id) { + + var db = this._db; + var deferred = null; + var self = this; + + require(["dojo/Deferred"], function (Deferred) { + deferred = new Deferred(); + + var objectStore = db.transaction([self.objectStoreName], "readwrite").objectStore(self.objectStoreName); + + //Get the entry associated with the graphic + var objectStoreGraphicRequest = objectStore.get(id); + + objectStoreGraphicRequest.onsuccess = function () { + var graphic = objectStoreGraphicRequest.result; + if (graphic && (graphic.id == id)) { + deferred.resolve({success: true, error: null}); + } + else { + deferred.reject({success: false, error: "objectId is not a match."}); + } + }; + + objectStoreGraphicRequest.onerror = function (msg) { + deferred.reject({success: false, error: msg}); + }; + }); + + //We return a deferred object so that when calling this function you can chain it with a then() statement. + return deferred; + }; + + /** + * Returns the approximate size of the database in bytes + * IMPORTANT: Currently requires all data be serialized! + * @param callback callback({usage}, error) Whereas, the usage Object is {sizeBytes: number, editCount: number} + */ + this.getUsage = function (callback) { + console.assert(this._db !== null, "indexeddb not initialized"); + + var id = this.FEATURE_LAYER_JSON_ID; + var fCollectionId = this.FEATURE_COLLECTION_ID; + + var usage = {sizeBytes: 0, editCount: 0}; + + var transaction = this._db.transaction([this.objectStoreName]) + .objectStore(this.objectStoreName) + .openCursor(); + + console.log("dumping keys"); + + transaction.onsuccess = function (event) { + var cursor = event.target.result; + if (cursor && cursor.value && cursor.value.id) { + var storedObject = cursor.value; + var json = JSON.stringify(storedObject); + usage.sizeBytes += json.length; + + if (cursor.value.id !== id && cursor.value.id !== fCollectionId) { + usage.editCount += 1; + } + + cursor.continue(); + } + else { + callback(usage, null); + } + }; + transaction.onerror = function (err) { + callback(null, err); + }; + }; + + this.init = function (callback) { + console.log("init editsStore.js"); + + var request = indexedDB.open(this.dbName, 11); + callback = callback || function (success) { + console.log("EditsStore::init() success:", success); + }.bind(this); + + request.onerror = function (event) { + console.log("indexedDB error: " + event.target.errorCode); + callback(false, event.target.errorCode); + }.bind(this); + + request.onupgradeneeded = function (event) { + var db = event.target.result; + + if (db.objectStoreNames.contains(this.objectStoreName)) { + db.deleteObjectStore(this.objectStoreName); + } + + db.createObjectStore(this.objectStoreName, {keyPath: "id"}); + }.bind(this); + + request.onsuccess = function (event) { + this._db = event.target.result; + this._isDBInit = true; + console.log("database opened successfully"); + callback(true, null); + }.bind(this); + }; +}; + + diff --git a/lib/edit/offlineJSOptions.js b/lib/edit/offlineJSOptions.js new file mode 100644 index 0000000..f56f62d --- /dev/null +++ b/lib/edit/offlineJSOptions.js @@ -0,0 +1,13 @@ +// Configure offline/online detection +// Requires: http://github.hubspot.com/offline/docs/welcome/ + +Offline.options = { // jshint ignore:line + checks: { + image: { + url: function() { + return 'http://esri.github.io/offline-editor-js/tiny-image.png?_=' + (Math.floor(Math.random() * 1000000000)); + } + }, + active: 'image' + } +}; \ No newline at end of file