1 include(bbq.lang.Watchable); 2 include(bbq.util.BBQUtil); 3 include(bbq.ajax.JSONRequest); 4 5 bbq.domain.Entity = new Class.create(bbq.lang.Watchable, /** @lends bbq.domain.Entity.prototype */ { 6 options: null, 7 _data: null, 8 _dataLoaded: null, 9 _partialLoad: null, 10 _loadedFields: null, 11 _retrieveURL: null, 12 _propertyDisplays: null, 13 _propertyDisplayCleanupInterval: null, 14 _loadingData: null, 15 16 /** 17 * <p>Base class for domain objects. Supports being partially loaded - that is, properties can be asked for from 18 * this object in advance of them being loaded. If an unloaded property is requested, a full object load 19 * will be trigged.</p> 20 * 21 * <p>Extending classes should implement:</p> 22 * 23 * <ol> 24 * <li>The _retrieveURL property. This should be a URL that will answer to a JSON request of the 25 * form {id: value} where value is returned from getId() being called on this object</li> 26 * <li>The _getDefaultObject method. This should return an object with keys for every property this 27 * object expects to have filled after a call to _retrieveURL.</li> 28 * </ol> 29 * 30 * @constructs 31 * @extends bbq.lang.Watchable 32 * @example 33 * <pre><code class="language-javascript"> 34 * include(bbq.domain.Entity); 35 * 36 * myapp.domain.Room = new Class.create(bbq.domain.Entity, { 37 * _retrieveURL: "/rooms/get", 38 * 39 * _getDefaultObject: function() { 40 * return { 41 * id: null, 42 * name: null, 43 * bookings: null 44 * } 45 * } 46 * }); 47 * </code></pre> 48 * @param {Object} options 49 * @param {Object} options.data A set of key/value pairs to pre-populate the default object with 50 */ 51 initialize: function($super, options) { 52 $super(options); 53 54 this.options = options ? options : {}; 55 this._data = {}; 56 this._propertyDisplays = new Hash(); 57 this._loadedFields = new Hash(); 58 59 if(!Object.isUndefined(this.options.data)) { 60 this.processData(this.options.data); 61 } 62 }, 63 64 /** 65 * Should return an object similar to the data structure of this class. 66 * 67 * The object should contain properties that mirror the output of getArray() in the PHP on the server. 68 * @return {Object} 69 */ 70 _getDefaultObject: function() { 71 return { 72 73 } 74 }, 75 76 /** 77 * Sets properties on this object and creates getters/setters for accessing them 78 * 79 * Also used by child classes to replace IDs with references to the actual objects 80 * 81 * @param {Object} data 82 */ 83 processData: function(data) { 84 var defaultObject = this._getDefaultObject(); 85 86 if(Object.isUndefined(defaultObject["id"])) { 87 Log.warn("Object loading data from " + this._retrieveURL + " should declare an id property in _getDefaultObject"); 88 } 89 90 for(var key in data) { 91 // skip fields not defined on this object - this way we do not end up with erroneous getters and setters 92 if(typeof(defaultObject[key]) == "undefined") { 93 Log.warn("skipping " + key + " on object loading data from " + this._retrieveURL); 94 continue; 95 } 96 97 var camel = BBQUtil.capitalize(key); 98 99 if(this["set" + camel] instanceof Function) { 100 this["set" + camel](data[key]); 101 } else { 102 this["get" + camel] = (new Function("return this._get(\"" + key + "\");")).bind(this); 103 this["set" + camel] = (new Function("this._set(\"" + key + "\", arguments[0]);")).bind(this); 104 105 this["set" + camel](data[key]); 106 } 107 108 // save that we have loaded this field 109 this._loadedFields.set(key, true); 110 } 111 112 this._partialLoad = false; 113 114 if(Log.debugging) { 115 // tell the developer which properties we are missing 116 var missingProperties = []; 117 118 // ensure we have loaded all of our properties 119 for(var key in defaultObject) { 120 if(typeof(data[key]) == "undefined") { 121 missingProperties.push(key); 122 } 123 } 124 125 if(missingProperties.length > 0) { 126 Log.warn(this._retrieveURL + " missing properties " + missingProperties.join(", ")); 127 } 128 } 129 130 // have we loaded all of our properties? 131 for(var key in defaultObject) { 132 if(Object.isUndefined(data[key])) { 133 this._partialLoad = true; 134 135 // missing at least one property, get out of loop 136 break; 137 } 138 } 139 140 // store if we've loaded all properties 141 this._dataLoaded = !this._partialLoad; 142 }, 143 144 /** 145 * Returns true if this Entity is fully loaded. An Entity is considered fully loaded if bbq.domain.Entity#processData has 146 * processed every field in the object returned by bbq.domain.Entity#getDefaultObject 147 * 148 * @returns {boolean} True if this Entity is fully loaded. 149 */ 150 dataLoaded: function() { 151 return this._dataLoaded === true; 152 }, 153 154 /** 155 * <p>Forces an object to load it's data from the server. If the object is not fully loaded an immediate request to _retrieveURL 156 * will be sent.</p> 157 * 158 * <p>Register for listener "onDataLoaded" to interact with this object after data has been loaded.</p> 159 */ 160 loadData: function() { 161 if(this._loadingData) { 162 return; 163 } 164 165 this._loadingData = true; 166 167 if(!this._retrieveURL) { 168 Log.error("Objects must specify where to load data from", this); 169 170 return; 171 } 172 173 this._dataLoaded = false; 174 this._partialLoad = false; 175 176 new bbq.ajax.JSONRequest({ 177 url: this._retrieveURL, 178 args: {id: this.getId()}, 179 onSuccess: this._loadedData.bind(this), 180 method: "post" 181 }); 182 }, 183 184 /** 185 * @return void 186 */ 187 _loadedData: function(serverResponse, json) { 188 try { 189 this._loadingData = false; 190 191 this.processData(json); 192 193 if(this.dataLoaded()) { 194 this.notifyListeners("onDataLoaded"); 195 } 196 } catch(e) { 197 Log.error("Error encountered while processing data", e); 198 } 199 }, 200 201 /** 202 * @return {String} 203 */ 204 registerListener: function($super, type, callback) { 205 var callbackKey = $super(type, callback); 206 207 if(type == "onDataLoaded") { 208 if(this.dataLoaded()) { 209 // if the callback is onDataLoaded and we've already loaded our data, call the callback immediately 210 this.notifyListener(type, callbackKey, [this]); 211 } 212 } 213 214 return callbackKey; 215 }, 216 217 /** 218 * @private 219 * @param {String} key 220 * @return {mixed} 221 */ 222 _get: function(key) { 223 //Log.info("returning " + this._data[key] + " for key " + key); 224 return typeof(this._data[key]) != "undefined" ? this._data[key] : null; 225 }, 226 227 /** 228 * @private 229 * @param {String} key 230 * @param {mixed} value 231 */ 232 _set: function(key, value) { 233 this._data[key] = value; 234 235 // update property displays for this property 236 // set a timeout so that all the properties on this object have been updated by the time the first propertyDisplay update occurs 237 setTimeout(this._updatePropertyDisplays.bind(this, key), 500); 238 239 return this._data[key]; 240 }, 241 242 /** 243 * Returns true if the passed object is equal to this one. An object is considered equal if it is either equal (==) or 244 * implements a getId property and the output of which is equal (==) to the output of this object's getId method. 245 * 246 * @param {Object} other 247 * @returns {boolean} True if the passed object is considered equal to this one 248 */ 249 equals: function(other) { 250 if(!other) { 251 return false; 252 } 253 254 if(this == other) { 255 return true; 256 } 257 258 if(this.getId && other.getId) { 259 return this.getId() == other.getId(); 260 } 261 262 return false; 263 }, 264 265 /** 266 * Can be used instead of a getter by passing the name of the property you want as a string. 267 * 268 * @param {String} property A property of this object 269 * @returns {Object} 270 */ 271 getProperty: function(property) { 272 try { 273 if(property.indexOf(".") != -1) { 274 property = "" + property.split(".", 1); 275 } 276 277 if(this["get" + BBQUtil.capitalize(property)] instanceof Function) { 278 return this["get" + BBQUtil.capitalize(property)](); 279 } 280 } catch(e) { 281 Log.error("getProperty threw a wobbly on " + property + " " + this._createURL, e); 282 Log.error("this.get" + BBQUtil.capitalize(property) + " = " + this["get" + BBQUtil.capitalize(property)]); 283 Log.dir(this); 284 } 285 }, 286 287 /** 288 * <p>Returns a DOM element (by default a SPAN) that contains a textual representation of the requested property of this element.</p> 289 * 290 * <p>Will be updated automatically to contain the most recent value.</p> 291 * 292 * <p>Supports getting properties of sub objects. For example, if we want the name of this objects's creator, we can do:</p> 293 * 294 * <pre><code class="language-javascript"> 295 * object.getPropertyDisplay({property: "creator.fullname"}); 296 * </code></pre> 297 * 298 * <p>This will have the same effect as:</p> 299 * 300 * <pre><code class="language-javascript"> 301 * var creator = object.getCreator(); 302 * creator.getPropertyDisplay({property: "fullname"}); 303 * </code></pre> 304 * 305 * <p>If the property requested has not yet been loaded, a call to Entity#loadData will occur.</p> 306 * 307 * @param {Object} options 308 * @param {String} options.property The name of the property that is to be displayed 309 * @param {Function} [options.formatter] A function that takes the property value as an argument and returns a String or a Node 310 * @param {String} [options.nodeName] Will be used in place of SPAN 311 * @param {String} [options.className] Will be applied to the node 312 * @param {Function} [options.createNode] Should return a DOM node. Omit this to use a SPAN. If passed you should also pass a function for updateNode 313 * @param {Function} [options.updateNode] Expect two arguments - a node and the property value. Return nothing. If passed you should also pass a function for createNode 314 */ 315 getPropertyDisplay: function(options) { 316 try { 317 if(options.property.indexOf(".") != -1) { 318 // pass request on to child object 319 var parts = options.property.split("."); 320 var myProperty = parts.shift(); 321 var object = this.getProperty(myProperty) 322 323 options.property = parts.join("."); 324 325 if(object) { 326 // object is valid, pass the request on 327 return object.getPropertyDisplay(options); 328 } else { 329 // an attempt to call a function on a child object that has not been set. return a placeholder 330 var node = options.createNode instanceof Function ? options.createNode() : DOMUtil.createElement("span"); 331 332 if(options.formatter) { 333 var value = options.formatter(null); 334 335 if(value) { 336 DOMUtil.append(value, node); 337 } 338 } 339 340 return node; 341 } 342 } 343 344 var propertyDisplay = { 345 node: options.createNode instanceof Function ? options.createNode() : DOMUtil.createElement(options.nodeName ? options.nodeName : "span", {className: options.className ? options.className : ""}), 346 formatter: options.formatter, 347 property: options.property, 348 updateNode: options.updateNode 349 }; 350 351 if(this["get" + BBQUtil.capitalize(options.property)] instanceof Function) { 352 // we've loaded our data already, update current property displays 353 this._updatePropertyDisplay(propertyDisplay); 354 } else { 355 // ensure that this property display will be updated 356 this.registerOneTimeListener("onDataLoaded", function() { 357 this._updatePropertyDisplay(propertyDisplay); 358 }.bind(this)); 359 360 // if this object is partially loaded and requested property has not been loaded yet, trigger loading of this objects data 361 if(this._partialLoad && !this._loadedFields.get(options.property)) { 362 Log.info("Partially loaded object triggering loadData"); 363 this.loadData(); 364 } 365 } 366 367 // create array for the requested property 368 if(!this._propertyDisplays.get(options.property)) { 369 this._propertyDisplays.set(options.property, []); 370 } 371 372 // add it to our list of property diplays 373 this._propertyDisplays.get(options.property).push(propertyDisplay); 374 375 // clean up previously created property displays that are no longer in the DOM 376 this._cleanUpPropertyDisplays(); 377 378 // return the node for addition to the DOM 379 return propertyDisplay.node; 380 } catch(e) { 381 Log.error("Exception thrown while trying to retrieve property display " + this._createURL, e); 382 Log.dir(options); 383 } 384 }, 385 386 /** 387 * Loops through all existing property displays and updates them. 388 * 389 * @private 390 */ 391 _updatePropertyDisplays: function(property) { 392 if(this._propertyDisplays.get(property)) { 393 this._propertyDisplays.get(property).each(function(displayProperty) { 394 this._updatePropertyDisplay(displayProperty); 395 }.bind(this)); 396 } 397 }, 398 399 /** 400 * Updates stored property displays to contain the current value as known by this object 401 * 402 * @private 403 */ 404 _updatePropertyDisplay: function(propertyDisplay) { 405 DOMUtil.emptyNode(propertyDisplay.node); 406 407 var value = this.getProperty(propertyDisplay.property); 408 409 if(propertyDisplay.formatter instanceof Function) { 410 value = propertyDisplay.formatter(value); 411 } 412 413 if(propertyDisplay.updateNode instanceof Function) { 414 propertyDisplay.updateNode(propertyDisplay.node, value); 415 } else { 416 if(!value) { 417 value = " "; 418 } 419 420 // default action is to treat node as if it is a SPAN 421 if(Object.isString(value)) { 422 var theText = value.split("\n"); 423 424 for(var i = 0; i < theText.length; i++) { 425 propertyDisplay.node.appendChild(document.createTextNode(theText[i])); 426 427 if(theText.length > 1 && i != (theText.length - 1)) { 428 propertyDisplay.node.appendChild(document.createElement("br")); 429 } 430 } 431 } else { 432 DOMUtil.append(value, propertyDisplay.node); 433 } 434 } 435 }, 436 437 /** 438 * Loops through all stored property displays, removing any references to nodes that have been removed from the DOM 439 * 440 * @private 441 */ 442 _cleanUpPropertyDisplays: function() { 443 if(this._propertyDisplayCleanupInterval) { 444 clearInterval(this._propertyDisplayCleanupInterval); 445 } 446 447 // set an interval to allow whatever rendering action that is currently underway to complete 448 // use interval instead of timeout so that we only do this once 449 this._propertyDisplayCleanupInterval = setInterval(function() { 450 this._propertyDisplays.keys().each(function(key) { 451 for(var i = 0; i < this._propertyDisplays.get(key).length; i++) { 452 if(!DOMUtil.isInDOM(this._propertyDisplays.get(key)[i].node)) { 453 // element has been removed from DOM, delete our reference 454 this._propertyDisplays.get(key).splice(i, 1); 455 i--; 456 } 457 } 458 }.bind(this)); 459 460 clearInterval(this._propertyDisplayCleanupInterval); 461 }.bind(this), 1000); 462 } 463 }); 464