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