Show:

File: platform/plugins/Streams/web/js/tools/related.js

(function (Q, $) {

/**
 * @module Streams-tools
 */

var Users = Q.Users;
var Streams = Q.Streams;

/**
 * Renders a bunch of Stream/preview tools for streams related to the given stream.
 * Has options for adding new related streams, as well as sorting the relations, etc.
 * Also can integrate with Q/tabs tool to render tabs "related" to some category.
 * @class Streams related
 * @constructor
 * @param {Object} [options] options for the tool
 *   @param {String} [options.publisherId] Either this or "stream" is required. Publisher id of the stream to which the others are related
 *   @param {String} [options.streamName] Either this or "stream" is required. Name of the stream to which the others are related
 *   @param {String} [options.tag="div"] The type of element to contain the preview tool for each related stream.
 *   @param {Q.Streams.Stream} [options.stream] You can pass a Streams.Stream object here instead of "publisherId" and "streamName"
 *   @param {String} options.relationType=null The type of the relation.
 *   @param {Boolean} [options.isCategory=true] Whether to show the streams related TO this stream, or the ones it is related to.
 *   @param {Object} [options.relatedOptions] Can include options like 'limit', 'offset', 'ascending', 'min', 'max', 'prefix' and 'fields'
 *   @param {Boolean} [options.editable] Set to false to avoid showing even authorized users an interface to replace the image or text of related streams
 *   @param {Boolean} [options.closeable] Set to false to avoid showing even authorized users an interface to close related streams
 *   @param {Object} [options.creatable]  Optional pairs of {streamType: toolOptions} to render Streams/preview tools create new related streams.
 *   The params typically include at least a "title" field which you can fill with values such as "New" or "New ..."
 *   @param {Function} [options.toolName] Function that takes (streamType, options) and returns the name of the tool to render (and then activate) for that stream. That tool should reqire the "Streams/preview" tool, and work with it as documented in "Streams/preview".
 *   @param {Boolean} [options.realtime=false] Whether to refresh every time a relation is added, removed or updated by anyone
 *   @param {Object} [options.sortable] Options for "Q/sortable" jQuery plugin. Pass false here to disable sorting interface. If streamName is not a String, this interface is not shown.
 *   @param {Function} [options.tabs] Function for interacting with any parent "Q/tabs" tool. Format is function (previewTool, tabsTool) { return urlOrTabKey; }
 *   @param {Object} [options.updateOptions] Options for onUpdate such as duration of the animation, etc.
 *   @param {Q.Event} [options.onUpdate] Event that receives parameters "data", "entering", "exiting", "updating"
 *   @param {Q.Event} [options.onRefresh] Event that occurs when the tool is completely refreshed, the "this" is the tool
 */
Q.Tool.define("Streams/related", function _Streams_related_tool (options) {
	// check for required options
	var state = this.state;
	if ((!state.publisherId || !state.streamName)
	&& (!state.stream || Q.typeOf(state.stream) !== 'Streams.Stream')) {
		throw new Q.Error("Streams/related tool: missing publisherId or streamName");
	}
	if (!state.relationType) {
		throw new Q.Error("Streams/related tool: missing relationType");
	}
	if (state.sortable && typeof state.sortable !== 'object') {
		throw new Q.Error("Streams/related tool: sortable must be an object or false");
	}

	state.publisherId = state.publisherId || state.stream.fields.publisherId;
	state.streamName = state.streamName || state.stream.fields.streamName;
	
	state.refreshCount = 0;

	// render the tool
	this.refresh();
},

{
	publisherId: Q.info.app,
	isCategory: true,
	relationType: null,
	realtime: false,
	editable: true,
	closeable: true,
	creatable: {},
	sortable: {
		draggable: '.Streams_related_stream',
		droppable: '.Streams_related_stream'
	},
	tabs: function (previewTool, tabsTool) {
		return Streams.key(previewTool.state.publisherId, previewTool.state.streamName);
	},
	toolName: function (streamType) {
		return streamType+'/preview';
	},
	onUpdate: new Q.Event(
	function _Streams_related_onUpdate(result, entering, exiting, updating) {
		function addComposer(streamType, params, creatable, oldElement) {
			// TODO: test whether the user can really create streams of this type
			// and otherwise do not append this element
			if (params && !Q.isPlainObject(params)) {
				params = {};
			}
			params.streamType = streamType;
			var element = tool.elementForStream(
				tool.state.publisherId, "", streamType, null, 
				{ creatable: params }
			).addClass('Streams_related_composer Q_contextual_inactive');
			if (tool.tabs) {
				element.addClass('Q_tabs_tab');
			}
			if (oldElement) {
				$(oldElement).before(element);
				var $last = tool.$('.Streams_related_composer:last');
				if ($last.length) {
					$(oldElement).insertAfter($last);
				}
			} else {
				var $prev = $container.find('.Streams_related_stream:first').prev();
				if ($prev.length) {
					$prev.after(element);
				} else {
					$container.append(element);
				}
			}
			Q.activate(element, function () {
				var rc = tool.state.refreshCount;
				var preview = Q.Tool.from(element, 'Streams/preview');
				var ps = preview.state;
				tool.integrateWithTabs([element], true);
				preview.state.beforeCreate.set(function () {
					// workaround for now
					if (tool.state.refreshCount > rc) {
						return;
					}
					$(this.element)
						.addClass('Streams_related_loading')
						.removeClass('Streams_related_composer');
					addComposer(streamType, params, null, element);
					ps.beforeCreate.remove(tool);
				}, tool);
				preview.state.onCreate.set(function () {
					$(this.element)
						.removeClass('Streams_related_loading')
						.removeClass('Streams_related_composer')
						.addClass('Streams_related_stream');
					ps.onCreate.remove(tool);
				}, tool);
				Q.handle(state.onComposer, tool, [preview]);
			});
		}
		
		var tool = this;
		var state = tool.state;
		var $te = $(tool.element);
		var $container = $te;
		var isTabs = $te.hasClass('Q_tabs_tool');
		if (isTabs) {
			$container = $te.find('.Q_tabs_tabs');
		}
		Q.removeElement($container.find('.Streams_preview_tool'), true);
		++state.refreshCount;
		
		if (result.stream.testWriteLevel('relate')) {
			Q.each(state.creatable, addComposer);
			if (state.sortable && result.stream.testWriteLevel('edit')) {
				if (state.realtime) {
					alert("Streams/related: can't mix realtime and sortable options yet");
					return;
				}
				var sortableOptions = Q.extend({}, state.sortable);
				var $t = tool.$();
				$t.plugin('Q/sortable', sortableOptions, function () {
					$t.state('Q/sortable').onSuccess.set(function ($item, data) {
						if (!data.direction) return;
						var p = new Q.Pipe(['timeout', 'updated'], function () {
							if (state.realtime) return;
							Streams.related.cache.removeEach(
								[state.publisherId, state.streamName]
							);
							// TODO: replace with animation?
							tool.refresh();
						});
						var s = Q.Tool.from(data.target, 'Streams/preview').state;
						var i = Q.Tool.from($item[0], 'Streams/preview').state;
						var r = i.related;
						setTimeout(
							p.fill('timeout'),
							this.state('Q/sortable').drop.duration
						);
						Streams.updateRelation(
							r.publisherId,
							r.streamName,
							r.type,
							i.publisherId,
							i.streamName,
							s.related.weight,
							1,
							p.fill('updated')
						);
					}, tool);
				});
			}
		}
		
		tool.previewElements = {};
		var elements = [];
		Q.each(result.relations, function (i) {
			if (!this.from) return;
			var tff = this.from.fields;
			var element = tool.elementForStream(
				tff.publisherId, 
				tff.name, 
				tff.type, 
				this.weight
			);
			elements.push(element);
			$(element).addClass('Streams_related_stream');
			Q.setObject([tff.publisherId, tff.name], element, tool.previewElements);
			$container.append(element);
		});
		// activate the elements one by one, asynchronously
		var previews = [];
		var map = {};
		var i=0;
		setTimeout(function _activatePreview() {
			var element = elements[i++];
			if (!element) {
				if (tool.tabs) {
					tool.tabs.refresh();
				}
				tool.state.onRefresh.handle.call(tool, previews, map, entering, exiting, updating);
				return;
			}
			Q.activate(element, null, function () {
				var index = previews.push(this) - 1;
				var key = Streams.key(this.state.publisherId, this.state.streamName);
				map[key] = index;
				tool.integrateWithTabs([element], true);
				setTimeout(_activatePreview, 0);
			});
		}, 0);
		// The elements should animate to their respective positions, like in D3.

	}, "Streams/related"),
	onRefresh: new Q.Event()
},

{
	/**
	 * Call this method to refresh the contents of the tool, requesting only
	 * what's needed and redrawing only what's needed.
	 * @method refresh
	 * @param {Function} onUpdate An optional callback to call after the update has completed.
	 *  It receives (result, entering, exiting, updating) arguments.
	 *  The child tools may still be refreshing after this. If you want to call a function
	 *  after they have all refreshed, use the tool.state.onRefresh event.
	 */
	refresh: function (onUpdate) {
		var tool = this;
		var state = tool.state;
		var publisherId = state.publisherId;
		var streamName = state.streamName;
		Streams.retainWith(tool).related(
			publisherId, 
			streamName, 
			state.relationType, 
			state.isCategory, 
			state.relatedOptions,
			relatedResult
		);
		
		function relatedResult(errorMessage) {
			if (errorMessage) {
				console.warn("Streams/related refresh: " + errorMessage);
				return;
			}
			var result = this;
			var entering, exiting, updating;
			entering = exiting = updating = null;
			function comparator(s1, s2, i, j) {
				return s1 && s2 && s1.fields && s2.fields
					&& s1.fields.publisherId === s2.fields.publisherId
					&& s1.fields.name === s2.fields.name;
			}
			var tsr = tool.state.result;
			if (tsr) {
				exiting = Q.diff(tsr.relatedStreams, result.relatedStreams, comparator);
				entering = Q.diff(result.relatedStreams, tsr.relatedStreams, comparator);
				updating = Q.diff(result.relatedStreams, entering, exiting, comparator);
			} else {
				exiting = updating = [];
				entering = result.relatedStreams;
			}
			tool.state.onUpdate.handle.apply(tool, [result, entering, exiting, updating]);
			Q.handle(onUpdate, tool, [result, entering, exiting, updating]);
			
			// Now that we have the stream, we can update the event listeners again
			var dir = tool.state.isCategory ? 'To' : 'From';
			var eventNames = ['onRelated'+dir, 'onUnrelated'+dir, 'onUpdatedRelate'+dir];
			if (tool.state.realtime) {
				Q.each(eventNames, function (i, eventName) {
					result.stream[eventName]().set(onChangedRelations, tool);
				});
			} else {
				Q.each(eventNames, function (i, eventName) {
					result.stream[eventName]().remove(tool);
				});
			}
			tool.state.result = result;
			tool.state.lastMessageOrdinal = result.stream.fields.messageCount;
		}
		function onChangedRelations(msg, fields) {
			// TODO: REPLACE THIS WITH AN ANIMATED UPDATE BY LOOKING AT THE ARRAYS entering, exiting, updating
			var isCategory = tool.state.isCategory;
			if (fields.type !== tool.state.relationType) {
				return;
			}
			if (!Users.loggedInUser
			|| msg.byUserId != Users.loggedInUser.id
			|| msg.byClientId != Q.clientId()
			|| msg.ordinal !== tool.state.lastMessageOrdinal + 1) {
				tool.refresh();
			} else {
				tool.refresh(); // TODO: make the weights of the items in between update in the client
			}
			tool.state.lastMessageOrdinal = msg.ordinal;
		}
	},

	/**
	 * You don't normally have to call this method, since it's called automatically.
	 * Sets up an element for the stream with the tag and toolName provided to the
	 * Streams/related tool. Also populates "publisherId", "streamName" and "related" 
	 * options for the tool.
	 * @method elementForStream
	 * @param {String } publisherId
	 * @param {String} streamName
	 * @param {String} streamType
	 * @param {Number} weight The weight of the relation
	 * @param {Object} options
	 *  The elements of the tools representing the related streams
	 * @return {HTMLElement} An element ready for Q.activate
	 */
	elementForStream: function (publisherId, streamName, streamType, weight, options) {
		var state = this.state;
		var o = Q.extend({
			publisherId: publisherId,
			streamName: streamName,
			related: {
				publisherId: state.publisherId,
				streamName: state.streamName,
				type: state.relationType,
				weight: weight
			},
			editable: state.editable,
			closeable: state.closeable
		}, options);
		var f = state.toolName;
		if (typeof f === 'string') {
			f = Q.getObject(state.toolName) || f;
		}
		var toolName = (typeof f === 'function') ? f(streamType, o) : f;
		var e = Q.Tool.setUpElement(
			state.tag || 'div', 
			['Streams/preview', toolName], 
			[o, {}], 
			null, this.prefix
		);
 		return e;
	},

	/**
	 * You don't normally have to call this method, since it's called automatically.
	 * It integrates the tool with a Q/tabs tool on the same element or a parent element,
	 * turning each Streams/preview of a related stream into a tab.
	 * @method integrateWithTabs
	 * @param elements
	 *  The elements of the tools representing the related streams
	 */
	integrateWithTabs: function (elements, skipRefresh) {
		var id, tabs, i;
		var tool = this;
		var state = tool.state;
		if (typeof state.tabs === 'string') {
			state.tabs = Q.getObject(state.tabs);
			if (typeof state.tabs !== 'function') {
				throw new Q.Error("Q/related tool: state.tabs does not refer to a function");
			}
		}
		var t = tool;
		if (!tool.tabs) {
			do {
				tool.tabs = t.sibling('Q/tabs');
				if (tool.tabs) {
					break;
				}
			} while (t = t.parent());
		}
		if (!tool.tabs) {
			return;
		}
		var tabs = tool.tabs;
		var $composer = tool.$('.Streams_related_composer');
		$composer.addClass('Q_tabs_tab');
		Q.each(elements, function (i) {
			var element = this;
			element.addClass("Q_tabs_tab");
			var preview = Q.Tool.from(element, 'Streams/preview');
			var key = preview.state.onRefresh.add(function () {
				var value = state.tabs.call(tool, preview, tabs);
				var attr = value.isUrl() ? 'href' : 'data-name';
				element.setAttribute(attr, value);
				if (!tabs.$tabs.is(element)) {
					tabs.$tabs = tabs.$tabs.add(element);
				}
				var onLoad = preview.state.onLoad;
				if (onLoad) {
					onLoad.add(function () {
						// all the related tabs have loaded, process them
						tabs.refresh();
					});
				}
				preview.state.onRefresh.remove(key);
			});
			var key2 = preview.state.onComposer.add(function () {
				tabs.refresh();
			});
		});
		if (!skipRefresh) {
			tabs.refresh();
		}
	},
	previewElement: function (publisherId, streamName) {
		return Q.getObject([publisherId, streamName], this.previewElements);
	},
	previewTool: function (publisherId, streamName) {
		return Q.getObject([publisherId, streamName, 'Q', 'tool'], this.previewElements);
	},
	Q: {
		beforeRemove: function () {
			$(this.element).plugin('Q/sortable', 'remove');
			this.state.onUpdate.remove("Streams/related");
		}
	}
}

);

})(Q, jQuery);