summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
Diffstat (limited to 'Flow/modules/engine')
-rw-r--r--Flow/modules/engine/components/board/base/flow-board-api-events.js920
-rw-r--r--Flow/modules/engine/components/board/base/flow-board-interactive-events.js213
-rw-r--r--Flow/modules/engine/components/board/base/flow-board-load-events.js42
-rw-r--r--Flow/modules/engine/components/board/base/flow-board-misc.js127
-rw-r--r--Flow/modules/engine/components/board/base/flow-boardandhistory-base.js190
-rw-r--r--Flow/modules/engine/components/board/features/flow-board-loadmore.js664
-rw-r--r--Flow/modules/engine/components/board/features/flow-board-navigation.js282
-rw-r--r--Flow/modules/engine/components/board/features/flow-board-preview.js237
-rw-r--r--Flow/modules/engine/components/board/features/flow-board-switcheditor.js52
-rw-r--r--Flow/modules/engine/components/board/features/flow-board-toc.js354
-rw-r--r--Flow/modules/engine/components/board/features/flow-board-visualeditor.js37
-rw-r--r--Flow/modules/engine/components/board/flow-board.js196
-rw-r--r--Flow/modules/engine/components/board/flow-boardhistory.js59
-rw-r--r--Flow/modules/engine/components/common/flow-component-engines.js39
-rw-r--r--Flow/modules/engine/components/common/flow-component-events.js917
-rw-r--r--Flow/modules/engine/components/common/flow-component-menus.js142
-rw-r--r--Flow/modules/engine/components/flow-component.js251
-rw-r--r--Flow/modules/engine/components/flow-registry.js165
-rw-r--r--Flow/modules/engine/misc/flow-api.js411
-rw-r--r--Flow/modules/engine/misc/flow-baseconvert.js70
-rw-r--r--Flow/modules/engine/misc/flow-eventlog.js45
-rw-r--r--Flow/modules/engine/misc/flow-handlebars.js581
-rw-r--r--Flow/modules/engine/misc/jquery.conditionalScroll.js51
-rw-r--r--Flow/modules/engine/misc/jquery.findWithParent.js45
-rw-r--r--Flow/modules/engine/misc/mw-ui.enhance.js451
-rw-r--r--Flow/modules/engine/misc/mw-ui.modal.js410
26 files changed, 6951 insertions, 0 deletions
diff --git a/Flow/modules/engine/components/board/base/flow-board-api-events.js b/Flow/modules/engine/components/board/base/flow-board-api-events.js
new file mode 100644
index 00000000..015cc5c9
--- /dev/null
+++ b/Flow/modules/engine/components/board/base/flow-board-api-events.js
@@ -0,0 +1,920 @@
+/*!
+ * @todo break this down into mixins for each callback section (eg. post actions, read topics)
+ */
+
+( function ( $, mw ) {
+ /**
+ * Binds API events to FlowBoardComponent
+ * @param {jQuery} $container
+ * @extends FlowComponent
+ * @constructor
+ */
+ function FlowBoardComponentApiEventsMixin( $container ) {
+ // Bind event callbacks
+ this.bindNodeHandlers( FlowBoardComponentApiEventsMixin.UI.events );
+ }
+ OO.initClass( FlowBoardComponentApiEventsMixin );
+
+ /** Event handlers are stored here, but are registered in the constructor */
+ FlowBoardComponentApiEventsMixin.UI = {
+ events: {
+ globalApiPreHandlers: {},
+ apiPreHandlers: {},
+ apiHandlers: {}
+ }
+ };
+
+ //
+ // pre-api callback handlers, to do things before the API call
+ //
+
+ /**
+ * Textareas are turned into editor objects, so we can't rely on
+ * textareas to properly return the real content we're looking for (the
+ * real editor can be anything, depending on the type of editor)
+ *
+ * @param {Event} event
+ * @return {Object}
+ */
+ FlowBoardComponentApiEventsMixin.UI.events.globalApiPreHandlers.prepareEditor = function ( event ) {
+ var $textareas = $( this ).closest( 'form' ).find( 'textarea' ),
+ override = {};
+
+ $textareas.each( function() {
+ var $editor = $( this );
+
+ // Doublecheck that this textarea is actually an editor instance
+ // (the editor may have added a textarea itself...)
+ if ( mw.flow.editor && mw.flow.editor.exists( $editor ) ) {
+ override[$editor.attr( 'name' )] = mw.flow.editor.getRawContent( $editor );
+ override.flow_format = mw.flow.editor.getFormat( $editor );
+ }
+
+ // @todo: we have to make sure we get rid of all unwanted data
+ // in the form (whatever "editor instance" may have added)
+ // because we'll $form.serializeArray() to get the content.
+ // This is currently not an issue since we only have "none"
+ // editor type, which just uses the existing textarea. Someday,
+ // however, we may have VE (or wikieditor or ...) which could
+ // add its own nodes, which may be picked up by serializeArray()
+ } );
+
+ return override;
+ };
+
+ /**
+ * When presented with an error conflict, the conflicting content can
+ * subsequently be re-submitted (to overwrite the conflicting content)
+ * This will prepare the data-to-be-submitted so that the override is
+ * submitted against the most current revision ID.
+ * @param {Event} event
+ * @return {Object}
+ */
+ FlowBoardComponentApiEventsMixin.UI.events.globalApiPreHandlers.prepareEditConflict = function ( event ) {
+ var $form = $( this ).closest( 'form' ),
+ prevRevisionId = $form.data( 'flow-prev-revision' );
+
+ if ( !prevRevisionId ) {
+ return {};
+ }
+
+ // Get rid of the temp-saved new revision ID
+ $form.removeData( 'flow-prev-revision' );
+
+ /*
+ * This is prev_revision in "generic" form. Each Flow API has its
+ * own unique prefix, which (in FlowApi.prototype.getQueryMap) will
+ * be properly applied for the respective API call; e.g.
+ * epprev_revision (for edit post)
+ */
+ return {
+ flow_prev_revision: prevRevisionId
+ };
+ };
+
+ /**
+ * Before activating header, sends an overrideObject to the API to modify the request params.
+ * @param {Event} event
+ * @return {Object}
+ */
+ FlowBoardComponentApiEventsMixin.UI.events.apiPreHandlers.activateEditHeader = function () {
+ return {
+ submodule: 'view-header', // href submodule is edit-header
+ vhcontentFormat: 'wikitext' // href does not have this param
+ };
+ };
+
+ /**
+ * Before activating topic, sends an overrideObject to the API to modify the request params.
+ *
+ * @param {Event} event
+ * @return {Object}
+ */
+ FlowBoardComponentApiEventsMixin.UI.events.apiPreHandlers.activateEditTitle = function ( event ) {
+ // Use view-post API for topic as well; we only want this on
+ // particular (title) post revision, not the full topic
+ return {
+ submodule: "view-post",
+ vppostId: $( this ).closest( '.flow-topic' ).data( 'flow-id' ),
+ vpcontentFormat: "wikitext"
+ };
+ };
+
+ /**
+ * Before activating post, sends an overrideObject to the API to modify the request params.
+ * @param {Event} event
+ * @return {Object}
+ */
+ FlowBoardComponentApiEventsMixin.UI.events.apiPreHandlers.activateEditPost = function ( event ) {
+ return {
+ submodule: 'view-post',
+ vppostId: $( this ).closest( '.flow-post' ).data( 'flow-id' ),
+ vpcontentFormat: 'wikitext'
+ };
+ };
+
+ /**
+ * Adjusts query params to use global watch action, and specifies it should use a watch token.
+ * @param {Event} event
+ * @returns {Function}
+ */
+ FlowBoardComponentApiEventsMixin.UI.events.apiPreHandlers.watchItem = function ( event ) {
+ return function ( queryMap ) {
+ var params = {
+ action: 'watch',
+ titles: queryMap.page,
+ _internal: {
+ tokenType: 'watch'
+ }
+ };
+ if ( queryMap.submodule === 'unwatch' ) {
+ params.unwatch = 1;
+ }
+ return params;
+ };
+ };
+
+ /**
+ * Before activating summarize topic, sends an overrideObject to the
+ * API to modify the request params.
+ * @param {Event} event
+ * @param {Object} info
+ * @return {Object}
+ */
+ FlowBoardComponentApiEventsMixin.UI.events.apiPreHandlers.activateSummarizeTopic = function ( event, info ) {
+ if ( info.$target.find( 'form' ).length ) {
+ // Form already open; cancel the old form
+ var flowBoard = mw.flow.getPrototypeMethod( 'board', 'getInstanceByElement' )( $( this ) );
+ flowBoard.emitWithReturn( 'cancelForm', info.$target );
+ return false;
+ }
+
+ return {
+ // href submodule is edit-topic-summary
+ submodule: 'view-topic-summary',
+ // href does not have this param
+ vtscontentFormat: 'wikitext'
+ };
+ };
+
+ /**
+ * Before activating lock/unlock edit form, sends an overrideObject
+ * to the API to modify the request params.
+ * @param {Event} event
+ * @return {Object}
+ */
+ FlowBoardComponentApiEventsMixin.UI.events.apiPreHandlers.activateLockTopic = function ( event ) {
+ return {
+ // href submodule is lock-topic
+ submodule: 'view-post',
+ // href does not have this param
+ vpcontentFormat: 'wikitext',
+ // request just the data for this topic
+ vppostId: $( this ).data( 'flow-id' )
+ };
+ };
+
+ //
+ // api callback handlers
+ //
+
+ /**
+ * On complete board reprocessing through view-topiclist (eg. change topic sort order), re-render any given blocks.
+ * @param {Object} info
+ * @param {string} info.status "done" or "fail"
+ * @param {jQuery} info.$target
+ * @param {Object} data
+ * @param {jqXHR} jqxhr
+ * @returns {$.Promise}
+ */
+ FlowBoardComponentApiEventsMixin.UI.events.apiHandlers.board = function ( info, data, jqxhr ) {
+ var $rendered,
+ flowBoard = info.component,
+ dfd = $.Deferred();
+
+ if ( info.status !== 'done' ) {
+ // Error will be displayed by default, nothing else to wrap up
+ return dfd.reject().promise();
+ }
+
+ $rendered = $(
+ flowBoard.constructor.static.TemplateEngine.processTemplateGetFragment(
+ 'flow_block_loop',
+ { blocks: data.flow[ 'view-topiclist' ].result }
+ )
+ ).children();
+
+ // Run this on a short timeout so that the other board handler in FlowBoardComponentLoadMoreFeatureMixin can run
+ // TODO: Using a timeout doesn't seem like the right way to do this.
+ setTimeout( function () {
+ // Reinitialize the whole board with these nodes
+ flowBoard.reinitializeContainer( $rendered );
+ dfd.resolve();
+ }, 50 );
+
+ return dfd.promise();
+ };
+
+ /**
+ * @returns {$.Promise}
+
+ return $.Deferred().resolve().promise();
+ * Renders the editable board header with the given API response.
+ * @param {Object} info
+ * @param {string} info.status "done" or "fail"
+ * @param {jQuery} info.$target
+ * @param {Object} data
+ * @param {jqXHR} jqxhr
+ * @returns {$.Promise}
+ */
+ FlowBoardComponentApiEventsMixin.UI.events.apiHandlers.activateEditHeader = function ( info, data, jqxhr ) {
+ var $rendered,
+ flowBoard = mw.flow.getPrototypeMethod( 'board', 'getInstanceByElement' )( $( this ) ),
+ $oldBoardNodes;
+
+ if ( info.status !== 'done' ) {
+ // Error will be displayed by default & edit conflict handled, nothing else to wrap up
+ return $.Deferred().reject().promise();
+ }
+
+ // Change "header" to "header_edit" so that it loads up flow_block_header_edit
+ data.flow[ 'view-header' ].result.header.type = 'header_edit';
+
+ $rendered = $(
+ flowBoard.constructor.static.TemplateEngine.processTemplateGetFragment(
+ 'flow_block_loop',
+ { blocks: data.flow[ 'view-header' ].result }
+ )
+ ).children();
+
+ // Set the cancel callback on this form so that it returns the old content back if needed
+ flowBoard.emitWithReturn( 'addFormCancelCallback', $rendered.find( 'form' ), function () {
+ flowBoard.reinitializeContainer( $oldBoardNodes );
+ } );
+
+ // Reinitialize the whole board with these nodes, and hold onto the replaced header
+ $oldBoardNodes = flowBoard.reinitializeContainer( $rendered );
+
+ return $.Deferred().resolve().promise();
+ };
+
+ /**
+ * After submit of the board header edit form, process the new header data.
+ * @param {Object} info (status:done|fail, $target: jQuery)
+ * @param {Object} data
+ * @param {jqXHR} jqxhr
+ * @returns {$.Deferred}
+ */
+ FlowBoardComponentApiEventsMixin.UI.events.apiHandlers.submitHeader = function ( info, data, jqxhr ) {
+ var $rendered,
+ flowBoard = mw.flow.getPrototypeMethod( 'board', 'getInstanceByElement' )( $( this ) );
+
+ if ( info.status !== 'done' ) {
+ // Error will be displayed by default & edit conflict handled, nothing else to wrap up
+ return $.Deferred().reject();
+ }
+
+ $rendered = $(
+ flowBoard.constructor.static.TemplateEngine.processTemplateGetFragment(
+ 'flow_block_loop',
+ { blocks: data.flow[ 'edit-header' ].result }
+ )
+ ).children();
+
+ // Reinitialize the whole board with these nodes
+ flowBoard.reinitializeContainer( $rendered );
+
+ return $.Deferred().resolve();
+ };
+
+ /**
+ * Renders the editable lock/unlock text area with the given API response.
+ * Allows a user to lock or unlock an entire topic.
+ * @param {Object} info
+ * @param {Object} data
+ * @param {jqXHR} jqxhr
+ * @returns {$.Promise}
+ */
+ FlowBoardComponentApiEventsMixin.UI.events.apiHandlers.activateLockTopic = function ( info, data ) {
+ var result, revision, postId, revisionId,
+ $target = info.$target,
+ $old = $target,
+ flowBoard = mw.flow.getPrototypeMethod( 'board', 'getInstanceByElement' )( $( this ) );
+
+ $( this ).closest( '.flow-menu' ).removeClass( 'focus' );
+
+ if ( info.status !== 'done' ) {
+ // Error will be displayed by default & edit conflict handled, nothing else to wrap up
+ return $.Deferred().reject().promise();
+ }
+
+ // FIXME: API should take care of this for me.
+ result = data.flow[ 'view-post' ].result.topic;
+ postId = result.roots[0];
+ revisionId = result.posts[postId];
+ revision = result.revisions[revisionId];
+
+ // Enable the editable summary
+ $target = $( flowBoard.constructor.static.TemplateEngine.processTemplateGetFragment(
+ 'flow_topic_titlebar_lock.partial', revision
+ ) ).children();
+
+ // Ensure that on a cancel the form gets destroyed.
+ flowBoard.emitWithReturn( 'addFormCancelCallback', $target.find( 'form' ), function () {
+ // xxx: Can this use replaceWith()? If so, use it because it saves the browser
+ // from having to reflow the document view twice (once with both elements on the
+ // page and then again after its removed, which causes bugs like losing your
+ // scroll offset on long pages).
+ $target.before( $old ).remove();
+ } );
+
+ // Replace the old one
+ $old.before( $target ).detach();
+
+ flowBoard.emitWithReturn( 'makeContentInteractive', $target );
+
+ // Focus on first form field
+ $target.find( 'input, textarea' ).filter( ':visible:first' ).focus();
+
+ return $.Deferred().resolve().promise();
+ };
+
+ /**
+ * After submit of the lock/unlock topic form, process the new summary data and re-render
+ * the title bar.
+ * @param {String} status
+ * @param {Object} data
+ * @param {jqXHR} jqxhr
+ * @returns {$.Promise}
+ */
+ FlowBoardComponentApiEventsMixin.UI.events.apiHandlers.lockTopic = function ( info, data ) {
+ var $replacement,
+ $target = info.$target,
+ $this = $( this ),
+ $deferred = $.Deferred(),
+ flowBoard = mw.flow.getPrototypeMethod( 'board', 'getInstanceByElement' )( $this ),
+ flowId = $this.closest( '.flow-topic' ).data( 'flow-id' );
+
+ if ( info.status !== 'done' ) {
+ // Error will be displayed by default & edit conflict handled, nothing else to wrap up
+ return $deferred.reject().promise();
+ }
+
+ // We couldn't make lock-topic to return topic data after a successful
+ // post submission because lock-topic is used for no-js support as well.
+ // If we make it return topic data, that means it has to return wikitext format
+ // for edit form in no-js mode. This is a performance problem for wikitext
+ // conversion since topic data returns all children data as well. So we need to
+ // make lock-topic return a single post for topic then fire
+ // another request to topic data in html format
+ //
+ // @todo the html could json encode the parameters including topics, the js
+ // could then import that and continuously update it with new revisions from
+ // api calls. Rendering a topic would then just be pointing the template at
+ // the right part of that data instead of requesting it.
+ flowBoard.Api.apiCall( {
+ action: 'flow',
+ submodule: 'view-topic',
+ workflow: flowId,
+ // Flow topic title, in Topic:<topicId> format (2600 is topic namespace id)
+ page: mw.Title.newFromText( flowId, 2600 ).getPrefixedDb()
+ // @todo fixme
+ // - mw.Title.newFromText can return null. If you're not going to check its return
+ // value, use 'new mw.Title' instead so that you get an exception for 'invalid title'
+ // instead of an exception for 'property of null'.
+ // - The second parameter to mw.Title is 'defaultNamespace' not 'namespace'.
+ // E.g. mw.Title.newFromText( 'User:Example', 6 ) -> 'User:Example', not 'File:
+ // If you need to prefix/enforce a namespace, use the canonical prefix instead.
+ } ).done( function( result ) {
+ // Update view of the full topic
+ $replacement = $( flowBoard.constructor.static.TemplateEngine.processTemplateGetFragment(
+ 'flow_topiclist_loop.partial',
+ result.flow['view-topic'].result.topic
+ ) ).children();
+
+ $target.replaceWith( $replacement );
+ flowBoard.emitWithReturn( 'makeContentInteractive', $replacement );
+
+ $deferred.resolve();
+ } ).fail( function( code, result ) {
+ /*
+ * At this point, the lock/unlock actually worked, but failed
+ * fetching the new data to be displayed.
+ */
+ flowBoard.emitWithReturn( 'removeError', $target );
+ var errorMsg = flowBoard.constructor.static.getApiErrorMessage( code, result );
+ errorMsg = mw.msg( 'flow-error-fetch-after-open-lock', errorMsg );
+ flowBoard.emitWithReturn( 'showError', $target, errorMsg );
+
+ $deferred.reject();
+ } );
+
+ return $deferred.promise();
+ };
+
+ /**
+ * @param {Object} info
+ * @param {string} info.status "done" or "fail"
+ * @param {jQuery} info.$target
+ * @param {Object} data
+ * @param {jqXHR} jqxhr
+ * @returns {$.Promise}
+ */
+ FlowBoardComponentApiEventsMixin.UI.events.apiHandlers.submitTopicTitle = function( info, data, jqxhr ) {
+ var
+ topicData,
+ rootId,
+ revisionId,
+ $this = $( this ),
+ $topic = info.$target,
+ $oldTopicTitleBar, $newTopicTitleBar,
+ flowBoard = mw.flow.getPrototypeMethod( 'board', 'getInstanceByElement' )( $this );
+
+ if ( info.status !== 'done' ) {
+ // Error will be displayed by default & edit conflict handled, nothing else to wrap up
+ return $.Deferred().reject().promise();
+ }
+
+ $oldTopicTitleBar = $topic.find( '.flow-topic-titlebar' );
+ topicData = data.flow['edit-title'].result.topic;
+ rootId = topicData.roots[0];
+ revisionId = topicData.posts[rootId][0];
+ $newTopicTitleBar = $( flowBoard.constructor.static.TemplateEngine.processTemplateGetFragment(
+ 'flow_topic_titlebar.partial',
+ topicData.revisions[revisionId]
+ ) ).children();
+
+ $oldTopicTitleBar
+ .replaceWith( $newTopicTitleBar );
+
+ flowBoard.emitWithReturn( 'makeContentInteractive', $newTopicTitleBar );
+
+ $newTopicTitleBar.conditionalScrollIntoView();
+
+ return $.Deferred().resolve().promise();
+ };
+
+ /**
+ * After submit of the topic title edit form, process the response.
+ *
+ * @param {Object} info
+ * @param {string} info.status "done" or "fail"
+ * @param {jQuery} info.$target
+ * @param {Object} data
+ * @param {jqXHR} jqxhr
+ * @returns {$.Promise}
+ */
+ FlowBoardComponentApiEventsMixin.UI.events.apiHandlers.submitEditPost = function( info, data, jqxhr ) {
+ var result;
+
+ if ( info.status !== 'done' ) {
+ // Error will be displayed by default & edit conflict handled, nothing else to wrap up
+ return $.Deferred().reject().promise();
+ }
+
+ result = data.flow['edit-post'].result.topic;
+ // clear out submitted data, otherwise it would re-trigger an edit
+ // form in the refreshed topic
+ result.submitted = {};
+
+ _flowBoardComponentRefreshTopic( info.$target, result );
+
+ return $.Deferred().resolve().promise();
+ };
+
+ /**
+ * After submitting a new topic, process the response.
+ * @param {Object} info
+ * @param {string} info.status "done" or "fail"
+ * @param {jQuery} info.$target
+ * @param {Object} data
+ * @param {jqXHR} jqxhr
+ * @returns {$.Promise}
+ */
+ FlowBoardComponentApiEventsMixin.UI.events.apiHandlers.newTopic = function ( info, data, jqxhr ) {
+ var result, fragment,
+ schemaName = $( this ).data( 'flow-eventlog-schema' ),
+ funnelId = $( this ).data( 'flow-eventlog-funnel-id' ),
+ flowBoard = mw.flow.getPrototypeMethod( 'board', 'getInstanceByElement' )( $( this ) );
+
+ if ( info.status !== 'done' ) {
+ // Error will be displayed by default, nothing else to wrap up
+ return $.Deferred().reject().promise();
+ }
+
+ flowBoard.logEvent( schemaName, { action: 'save-success', funnelId: funnelId } );
+
+ result = data.flow['new-topic'].result.topiclist;
+
+ // render only the new topic
+ result.roots = [result.roots[0]];
+ fragment = mw.flow.TemplateEngine.processTemplateGetFragment( 'flow_topiclist_loop.partial', result );
+
+ flowBoard.emitWithReturn( 'cancelForm', $( this ).closest( 'form' ) );
+
+ // Everything must be reset before re-initializing
+ // @todo un-hardcode
+ flowBoard.reinitializeContainer(
+ flowBoard.$container.find( '.flow-topics' ).prepend( fragment )
+ );
+
+ // remove focus - title input field may still have focus
+ // (submitted via enter key), which it needs to lose:
+ // the form will only re-activate if re-focused
+ document.activeElement.blur();
+
+ return $.Deferred().resolve().promise();
+ };
+
+ /**
+ * @param {Object} info (status:done|fail, $target: jQuery)
+ * @param {Object} data
+ * @param {jqXHR} jqxhr
+ * @returns {$.Promise}
+ */
+ FlowBoardComponentApiEventsMixin.UI.events.apiHandlers.submitReply = function ( info, data, jqxhr ) {
+ var $form = $( this ).closest( 'form' ),
+ flowBoard = mw.flow.getPrototypeMethod( 'board', 'getInstanceByElement' )( $form ),
+ schemaName = $( this ).data( 'flow-eventlog-schema' ),
+ funnelId = $( this ).data( 'flow-eventlog-funnel-id' );
+
+ if ( info.status !== 'done' ) {
+ // Error will be displayed by default, nothing else to wrap up
+ return $.Deferred().reject().promise();
+ }
+
+ flowBoard.logEvent( schemaName, { action: 'save-success', funnelId: funnelId } );
+
+ // Execute cancel callback to destroy form
+ flowBoard.emitWithReturn( 'cancelForm', $form );
+
+ // Target should be flow-topic
+ _flowBoardComponentRefreshTopic( info.$target, data.flow.reply.result.topic );
+
+ return $.Deferred().resolve().promise();
+ };
+
+ /**
+ * @param {Object} info
+ * @param {string} info.status "done" or "fail"
+ * @param {jQuery} info.$target
+ * @param {Object} data
+ * @param {jqXHR} jqxhr
+ * @returns {$.Promise}
+ */
+ FlowBoardComponentApiEventsMixin.UI.events.apiHandlers.watchItem = function ( info, data, jqxhr ) {
+ var watchUrl, unwatchUrl,
+ watchType, watchLinkTemplate, $newLink,
+ $target = $( this ),
+ $tooltipTarget = $target.closest( '.flow-watch-link' ),
+ flowBoard = mw.flow.getPrototypeMethod( 'board', 'getInstanceByElement' )( $tooltipTarget ),
+ isWatched = false,
+ url = $( this ).prop( 'href' ),
+ links = {};
+
+ if ( info.status !== 'done' ) {
+ // Error will be displayed by default, nothing else to wrap up
+ return $.Deferred().reject().promise();
+ }
+
+ if ( $tooltipTarget.is( '.flow-topic-watchlist' ) ) {
+ watchType = 'topic';
+ watchLinkTemplate = 'flow_topic_titlebar_watch.partial';
+ }
+
+ if ( data.watch[0].watched !== undefined ) {
+ unwatchUrl = url.replace( 'watch', 'unwatch' );
+ watchUrl = url;
+ isWatched = true;
+ } else {
+ watchUrl = url.replace( 'unwatch', 'watch' );
+ unwatchUrl = url;
+ }
+ links['unwatch-' + watchType] = { url : unwatchUrl };
+ links['watch-' + watchType] = { url : watchUrl };
+
+ // Render new icon
+ // This will hide any tooltips if present
+ $newLink = $(
+ flowBoard.constructor.static.TemplateEngine.processTemplateGetFragment(
+ watchLinkTemplate,
+ {
+ isWatched: isWatched,
+ links: links,
+ watchable: true
+ }
+ )
+ ).children();
+ $tooltipTarget.replaceWith( $newLink );
+
+ if ( data.watch[0].watched !== undefined ) {
+ // Successful watch: show tooltip
+ flowBoard.emitWithReturn( 'showSubscribedTooltip', $newLink.find( '.wikiglyph' ), watchType );
+ }
+
+ return $.Deferred().resolve().promise();
+ };
+
+ /**
+ * Activate the editable summarize topic form with given api request
+ * @param {Object} info (status:done|fail, $target: jQuery)
+ * @param {Object} data
+ * @param {jqXHR} jqxhr
+ * @returns {$.Promise}
+ */
+ FlowBoardComponentApiEventsMixin.UI.events.apiHandlers.activateSummarizeTopic = function ( info, data, jqxhr ) {
+ var $target = info.$target,
+ $old = $target,
+ flowBoard = mw.flow.getPrototypeMethod( 'board', 'getInstanceByElement' )( $( this ) );
+
+ if ( info.status !== 'done' ) {
+ // Error will be displayed by default, nothing else to wrap up
+ return $.Deferred().reject().promise();
+ }
+
+ // Create the new topic_summary_edit template
+ $target = $( flowBoard.constructor.static.TemplateEngine.processTemplateGetFragment(
+ 'flow_block_topicsummary_edit',
+ data.flow[ 'view-topic-summary' ].result.topicsummary
+ ) ).children();
+
+ // On cancel, put the old topicsummary back
+ flowBoard.emitWithReturn( 'addFormCancelCallback', $target.find( 'form' ), function() {
+ $target.before( $old ).remove();
+ } );
+
+ // Replace the old one
+ $old.before( $target ).detach();
+
+ flowBoard.emitWithReturn( 'makeContentInteractive', $target );
+
+ // Focus on first form field
+ $target.find( 'input, textarea' ).filter( ':visible:first' ).focus();
+
+ return $.Deferred().resolve().promise();
+ };
+
+ /**
+ * After submit of the summarize topic edit form, process the new topic summary data.
+ * @param {Object} info
+ * @param {string} info.status "done" or "fail"
+ * @param {jQuery} info.$target
+ * @param {Object} data
+ * @param {jqXHR} jqxhr
+ * @returns {$.Promise}
+ */
+ FlowBoardComponentApiEventsMixin.UI.events.apiHandlers.summarizeTopic = function ( info, data, jqxhr ) {
+ if ( info.status !== 'done' ) {
+ // Error will be displayed by default, nothing else to wrap up
+ return $.Deferred().reject().promise();
+ }
+
+ _flowBoardComponentRefreshTopic(
+ info.$target,
+ data.flow['edit-topic-summary'].result.topic,
+ '.flow-topic-titlebar'
+ );
+
+ return $.Deferred().resolve().promise();
+ };
+
+ /**
+ * Shows the form for editing a topic title, it's not already showing.
+ *
+ * @param {Object} info (status:done|fail, $target: jQuery)
+ * @param {Object} data
+ * @param {jqXHR} jqxhr
+ * @returns {$.Promise}
+ */
+ FlowBoardComponentApiEventsMixin.UI.events.apiHandlers.activateEditTitle = function ( info, data, jqxhr ) {
+ var flowBoard, $form, cancelCallback,
+ $link = $( this ),
+ activeClass = 'flow-topic-title-activate-edit',
+ rootBlock = data.flow['view-post'].result.topic,
+ revision = rootBlock.revisions[rootBlock.posts[rootBlock.roots[0]]];
+
+ if ( info.status !== 'done' ) {
+ // Error will be displayed by default, nothing else to wrap up
+ return $.Deferred().reject().promise();
+ }
+
+ $form = info.$target.find( 'form' );
+
+ if ( $form.length === 0 ) {
+ // Add class to identify title is being edited (so we can hide the
+ // current title in CSS)
+ info.$target.addClass( activeClass );
+
+ cancelCallback = function() {
+ $form.remove();
+ info.$target.removeClass( activeClass );
+ };
+
+ flowBoard = mw.flow.getPrototypeMethod( 'board', 'getInstanceByElement' )( $link );
+ $form = $( flowBoard.constructor.static.TemplateEngine.processTemplateGetFragment(
+ 'flow_edit_topic_title.partial',
+ {
+ 'actions' : {
+ 'edit' : {
+ 'url' : $link.attr( 'href' )
+ }
+ },
+ 'content': {
+ 'content' : revision.content.content
+ },
+ 'revisionId' : revision.revisionId
+ }
+ ) ).children();
+
+ flowBoard.emitWithReturn( 'addFormCancelCallback', $form, cancelCallback );
+ $form
+ .data( 'flow-initial-state', 'hidden' )
+ .prependTo( info.$target );
+ }
+
+ $form.find( '.mw-ui-input' ).focus();
+
+ return $.Deferred().resolve().promise();
+ };
+
+ /**
+ * Renders the editable post with the given API response.
+ * @param {Object} info
+ * @param {string} info.status "done" or "fail"
+ * @param {jQuery} info.$target
+ * @param {Object} data
+ * @param {jqXHR} jqxhr
+ * @returns {$.Promise}
+ */
+ FlowBoardComponentApiEventsMixin.UI.events.apiHandlers.activateEditPost = function ( info, data, jqxhr ) {
+ var $rendered, rootBlock,
+ flowBoard = mw.flow.getPrototypeMethod( 'board', 'getInstanceByElement' )( $( this ) ),
+ $post = info.$target;
+
+ if ( info.status !== 'done' ) {
+ // Error will be displayed by default, nothing else to wrap up
+ return $.Deferred().reject().promise();
+ }
+
+ // The API returns with the entire topic, but we only want to render the edit form
+ // for a singular post
+ rootBlock = data.flow['view-post'].result.topic;
+ $rendered = $(
+ flowBoard.constructor.static.TemplateEngine.processTemplateGetFragment(
+ 'flow_edit_post_ajax.partial',
+ {
+ revision: rootBlock.revisions[rootBlock.posts[rootBlock.roots[0]]],
+ rootBlock: rootBlock
+ }
+ )
+ ).children();
+
+ // Set the cancel callback on this form so that it returns to the post
+ flowBoard.emitWithReturn( 'addFormCancelCallback',
+ $rendered.find( 'form' ).addBack( 'form' ),
+ function () {
+ $rendered.replaceWith( $post );
+ }
+ );
+
+ $post.replaceWith( $rendered );
+ $rendered.find( 'textarea' ).conditionalScrollIntoView().focus();
+
+ return $.Deferred().resolve().promise();
+ };
+
+ /**
+ * Callback from the topic moderation dialog.
+ */
+ FlowBoardComponentApiEventsMixin.UI.events.apiHandlers.moderateTopic = _genModerateHandler(
+ 'moderate-topic',
+ function ( $target, revision, apiResult ) {
+ var $replacement,
+ flowBoard = mw.flow.getPrototypeMethod( 'board', 'getInstanceByElement' )( $( this ) );
+ if ( revision.isModerated && !flowBoard.constructor.static.inTopicNamespace( $target ) ) {
+ $replacement = $( $.parseHTML( mw.flow.TemplateEngine.processTemplate(
+ 'flow_moderate_topic_confirmation.partial',
+ revision
+ ) ) );
+
+ $target.closest( '.flow-topic' ).replaceWith( $replacement );
+ flowBoard.emitWithReturn( 'makeContentInteractive', $replacement );
+ } else {
+ _flowBoardComponentRefreshTopic( $target, apiResult );
+ }
+ }
+ );
+
+ /**
+ * Callback from the post moderation dialog.
+ */
+ FlowBoardComponentApiEventsMixin.UI.events.apiHandlers.moderatePost = _genModerateHandler(
+ 'moderate-post',
+ function ( $target, revision, apiResult ) {
+ var $replacement,
+ flowBoard = mw.flow.getPrototypeMethod( 'board', 'getInstanceByElement' )( $( this ) );
+
+ if ( revision.isModerated ) {
+ $replacement = $( $.parseHTML( flowBoard.constructor.static.TemplateEngine.processTemplate(
+ 'flow_moderate_post_confirmation.partial',
+ revision
+ ) ) );
+ $target.closest( '.flow-post-main' ).replaceWith( $replacement );
+ flowBoard.emitWithReturn( 'makeContentInteractive', $replacement );
+ } else {
+ _flowBoardComponentRefreshTopic( $target, apiResult );
+ }
+ }
+ );
+
+ //
+ // Private functions
+ //
+
+ /**
+ * Generate a moderation handler callback
+ *
+ * @param {string} Action to expect in api response
+ * @param {Function} Method to call on api success
+ */
+ function _genModerateHandler( action, successCallback ) {
+ /**
+ * After submit of a moderation form, process the response.
+ *
+ * @param {Object} info (status:done|fail, $target: jQuery)
+ * @param {Object} data
+ * @param {jqXHR} jqxhr
+ * @returns {$.Promise}
+ */
+ return function ( info, data, jqxhr ) {
+ if ( info.status !== 'done' ) {
+ // Error will be displayed by default, nothing else to wrap up
+ return $.Deferred().reject().promise();
+ }
+
+ var result = data.flow[action].result.topic,
+ $this = $( this ),
+ $form = $this.closest( 'form' ),
+ id = result.submitted.postId || result.postId || result.roots[0],
+ flowBoard = mw.flow.getPrototypeMethod( 'board', 'getInstanceByElement' )( $this );
+
+ successCallback.call(
+ this,
+ $form.data( 'flow-dialog-owner' ) || $form,
+ result.revisions[result.posts[id]],
+ result
+ );
+
+ flowBoard.emitWithReturn( 'cancelForm', $form );
+
+ return $.Deferred().resolve().promise();
+ };
+ }
+
+ /**
+ * Refreshes the titlebar of a topic given an API response.
+ * @param {jQuery} $targetElement An element in the topic.
+ * @param {Object} apiResult Plain object containing the API response to build from.
+ * @param {String} [selector] Select specific element to replace
+ */
+ function _flowBoardComponentRefreshTopic( $targetElement, apiResult, selector ) {
+ var $target = $targetElement.closest( '.flow-topic' ),
+ flowBoard = mw.flow.getPrototypeMethod( 'board', 'getInstanceByElement' )( $targetElement ),
+ $newContent = $( flowBoard.constructor.static.TemplateEngine.processTemplateGetFragment(
+ 'flow_topiclist_loop.partial',
+ apiResult
+ ) ).children();
+
+ if ( selector ) {
+ $newContent = $newContent.find( selector );
+ $target = $target.find( selector );
+ }
+
+ $target.replaceWith( $newContent );
+ // Run loadHandlers
+ flowBoard.emitWithReturn( 'makeContentInteractive', $newContent );
+ }
+
+ // Mixin to FlowBoardComponent
+ mw.flow.mixinComponent( 'board', FlowBoardComponentApiEventsMixin );
+}( jQuery, mediaWiki ) );
diff --git a/Flow/modules/engine/components/board/base/flow-board-interactive-events.js b/Flow/modules/engine/components/board/base/flow-board-interactive-events.js
new file mode 100644
index 00000000..6e10caae
--- /dev/null
+++ b/Flow/modules/engine/components/board/base/flow-board-interactive-events.js
@@ -0,0 +1,213 @@
+/*!
+ * Implements element interactive handler callbacks for FlowBoardComponent
+ */
+
+( function ( $, mw ) {
+ /**
+ * Binds element interactive (click) handlers for FlowBoardComponent
+ * @param {jQuery} $container
+ * @extends FlowComponent
+ * @constructor
+ */
+ function FlowBoardComponentInteractiveEventsMixin( $container ) {
+ this.bindNodeHandlers( FlowBoardComponentInteractiveEventsMixin.UI.events );
+ }
+ OO.initClass( FlowBoardComponentInteractiveEventsMixin );
+
+ FlowBoardComponentInteractiveEventsMixin.UI = {
+ events: {
+ interactiveHandlers: {}
+ }
+ };
+
+ //
+ // interactive handlers
+ //
+
+ /**
+ * Toggles collapse state
+ *
+ * @param {Event} event
+ */
+ FlowBoardComponentInteractiveEventsMixin.UI.events.interactiveHandlers.collapserCollapsibleToggle = function ( event ) {
+ var $target = $( this ).closest( '.flow-element-collapsible' ),
+ $deferred = $.Deferred();
+
+ if ( $target.is( '.flow-element-collapsed' ) ) {
+ $target.removeClass( 'flow-element-collapsed' ).addClass( 'flow-element-expanded' );
+ } else {
+ $target.addClass( 'flow-element-collapsed' ).removeClass( 'flow-element-expanded' );
+ }
+
+ return $deferred.resolve().promise();
+ };
+
+ /**
+ * @param {Event} event
+ * @returns {$.Promise}
+ */
+ FlowBoardComponentInteractiveEventsMixin.UI.events.interactiveHandlers.activateReplyTopic = function ( event ) {
+ var $topic = $( this ).closest( '.flow-topic' ),
+ topicId = $topic.data( 'flow-id' ),
+ component;
+
+ // The reply form is used in multiple places. This will check if it was
+ // triggered from inside the topic reply form.
+ if ( $( this ).closest( '#flow-reply-' + topicId ).length === 0 ) {
+ // Not in topic reply form
+ return $.Deferred().reject();
+ }
+
+ // Only if the textarea is compressed, is it being activated. Otherwise,
+ // it has already expanded and this focus is now just re-focussing the
+ // already active form
+ if ( !$( this ).hasClass( 'flow-input-compressed' ) ) {
+ // Form already activated
+ return $.Deferred().reject();
+ }
+
+ component = mw.flow.getPrototypeMethod( 'component', 'getInstanceByElement' )( $( this ) );
+ component.logEvent(
+ 'FlowReplies',
+ // log data
+ {
+ entrypoint: 'reply-bottom',
+ action: 'initiate'
+ },
+ // nodes to forward funnel to
+ $( this ).findWithParent(
+ '< .flow-reply-form [data-role="cancel"],' +
+ '< .flow-reply-form [data-role="action"][name="preview"],' +
+ '< .flow-reply-form [data-role="submit"]'
+ )
+ );
+
+ return $.Deferred().resolve();
+ };
+
+ /**
+ * @param {Event} event
+ */
+ FlowBoardComponentInteractiveEventsMixin.UI.events.interactiveHandlers.activateNewTopic = function ( event ) {
+ var $form = $( this ).closest( '.flow-newtopic-form' ),
+ component;
+
+ // Only if the textarea is compressed, is it being activated. Otherwise,
+ // it has already expanded and this focus is now just re-focussing the
+ // already active form
+ if ( $form.find( '.flow-input-compressed' ).length === 0 ) {
+ // Form already activated
+ return $.Deferred().reject();
+ }
+
+ component = mw.flow.getPrototypeMethod( 'component', 'getInstanceByElement' )( $( this ) );
+ component.logEvent(
+ 'FlowReplies',
+ // log data
+ {
+ entrypoint: 'new-topic',
+ action: 'initiate'
+ },
+ // nodes to forward funnel to
+ $( this ).findWithParent(
+ '< .flow-newtopic-form [data-role="cancel"],' +
+ '< .flow-newtopic-form [data-role="action"][name="preview"],' +
+ '< .flow-newtopic-form [data-role="submit"]'
+ )
+ );
+
+ return $.Deferred().resolve();
+ };
+
+ /**
+ * @param {Event} event
+ */
+ FlowBoardComponentInteractiveEventsMixin.UI.events.interactiveHandlers.activateReplyPost = function ( event ) {
+ event.preventDefault();
+
+ var $form,
+ $this = $( this ),
+ topicId = $this.closest( '.flow-topic' ).data( 'flow-id' ),
+ flowBoard = mw.flow.getPrototypeMethod( 'board', 'getInstanceByElement' )( $this ),
+ $post = $this.closest( '.flow-post' ),
+ href = $this.attr( 'href' ),
+ uri = new mw.Uri( href ),
+ postId = uri.query.topic_postId,
+ $targetPost = $( '#flow-post-' + postId ),
+ topicTitle = $post.closest( '.flow-topic' ).find( '.flow-topic-title' ).text(),
+ replyToContent = $post.find( '.flow-post-content' ).filter( ':first' ).text() || topicTitle,
+ author = $.trim( $post.find( '.flow-author' ).filter( ':first' ).find( '.mw-userlink' ).text() ),
+ $deferred = $.Deferred();
+
+ if ( $targetPost.length === 0 ) {
+ $targetPost = $( '#flow-topic-' + postId );
+ }
+
+ // forward all top level replys to the topic reply box
+ if ( $targetPost.is( '.flow-topic' ) ) {
+ $targetPost.find( '#flow-post-' + postId + '-form-content' ).trigger( 'focus' );
+ return $deferred.resolve().promise();
+ }
+
+ // Check if reply form has already been opened
+ if ( $post.data( 'flow-replying' ) ) {
+ return $deferred.reject().promise();
+ }
+ $post.data( 'flow-replying', true );
+
+ $form = $( flowBoard.constructor.static.TemplateEngine.processTemplateGetFragment(
+ 'flow_reply_form.partial',
+ // arguments can be empty: we just want an empty reply form
+ {
+ actions: {
+ reply: {
+ url: href,
+ text: mw.msg( 'flow-reply-link', author )
+ }
+ },
+ postId: postId,
+ author: {
+ name: author
+ },
+ // text for flow-reply-topic-title-placeholder placeholder
+ properties: {
+ 'topic-of-post': $.trim( replyToContent ).substr( 0, 200 )
+ },
+ // Topic:UUID
+ articleTitle: mw.config.get( 'wgFormattedNamespaces' )[2600] + ':' + topicId[0].toUpperCase() + topicId.slice(1)
+ }
+ ) ).children();
+
+ // Set the cancel callback on this form so that it gets rid of the form.
+ // We have to make sure the data attribute is added to the form; the
+ // addBack is failsafe for when form is actually the root node in $form
+ // already (there may or may not be parent containers)
+ flowBoard.emitWithReturn( 'addFormCancelCallback', $form.find( 'form' ).addBack( 'form' ), function () {
+ $post.removeData( 'flow-replying' );
+ $form.remove();
+ } );
+
+ // Add reply form below the post being replied to (WRT max depth)
+ $targetPost.children( '.flow-replies' ).append( $form );
+ $form.conditionalScrollIntoView();
+
+ // focus the input
+ $form.find('textarea').focus();
+
+ return $deferred.resolve().promise();
+ };
+
+ // @todo remove these data-flow handler forwarder callbacks when data-mwui handlers are implemented
+ $( [ 'close', 'prevOrClose', 'nextOrSubmit', 'prev', 'next' ] ).each( function ( i, fn ) {
+ // Assigns each handler with the prefix 'modal', eg. 'close' becomes 'modalClose'
+ FlowBoardComponentInteractiveEventsMixin.UI.events.interactiveHandlers[ 'modal' + fn.charAt(0).toUpperCase() + fn.substr( 1 ) ] = function ( event ) {
+ event.preventDefault();
+
+ // eg. call mw.Modal.close( this );
+ mw.Modal[ fn ]( this );
+ };
+ } );
+
+ // Mixin to FlowBoardComponent
+ mw.flow.mixinComponent( 'board', FlowBoardComponentInteractiveEventsMixin );
+}( jQuery, mediaWiki ) );
diff --git a/Flow/modules/engine/components/board/base/flow-board-load-events.js b/Flow/modules/engine/components/board/base/flow-board-load-events.js
new file mode 100644
index 00000000..9fe19d78
--- /dev/null
+++ b/Flow/modules/engine/components/board/base/flow-board-load-events.js
@@ -0,0 +1,42 @@
+/*!
+ * Implements element on-load callbacks for FlowBoardComponent
+ */
+
+( function ( $, mw ) {
+ /**
+ * Binds element load handlers for FlowBoardComponent
+ * @param {jQuery} $container
+ * @extends FlowComponent
+ * @constructor
+ */
+ function FlowBoardComponentLoadEventsMixin( $container ) {
+ this.bindNodeHandlers( FlowBoardComponentLoadEventsMixin.UI.events );
+ }
+ OO.initClass( FlowBoardComponentLoadEventsMixin );
+
+ FlowBoardComponentLoadEventsMixin.UI = {
+ events: {
+ loadHandlers: {}
+ }
+ };
+
+ //
+ // On element-load handlers
+ //
+
+ /**
+ * Replaces $time with a new flow-timestamp element generated by TemplateEngine
+ * @param {jQuery} $time
+ */
+ FlowBoardComponentLoadEventsMixin.UI.events.loadHandlers.timestamp = function ( $time ) {
+ $time.replaceWith(
+ mw.flow.TemplateEngine.callHelper(
+ 'timestamp',
+ parseInt( $time.attr( 'datetime' ), 10) * 1000
+ )
+ );
+ };
+
+ // Mixin to FlowBoardComponent
+ mw.flow.mixinComponent( 'board', FlowBoardComponentLoadEventsMixin );
+}( jQuery, mediaWiki ) );
diff --git a/Flow/modules/engine/components/board/base/flow-board-misc.js b/Flow/modules/engine/components/board/base/flow-board-misc.js
new file mode 100644
index 00000000..3eb7ebcb
--- /dev/null
+++ b/Flow/modules/engine/components/board/base/flow-board-misc.js
@@ -0,0 +1,127 @@
+/*!
+ * Contains miscellaneous functionality needed for FlowBoardComponents.
+ * @todo Find a better place for this code.
+ */
+
+( function ( $, mw ) {
+ /**
+ *
+ * @param {jQuery} $container
+ * @extends FlowComponent
+ * @constructor
+ */
+ function FlowBoardComponentMiscMixin( $container ) {
+ }
+ OO.initClass( FlowBoardComponentMiscMixin );
+
+ //
+ // Methods
+ //
+
+ /**
+ * Removes the preview and unhides the form fields.
+ * @param {jQuery} $cancelButton
+ * @return {bool} true if success
+ * @todo genericize into FlowComponent
+ */
+ function flowBoardComponentResetPreview( $cancelButton ) {
+ var $form = $cancelButton.closest( 'form' ),
+ $button = $form.find( '[name=preview]' ),
+ oldData = $button.data( 'flow-return-to-edit' );
+
+ if ( oldData ) {
+ // We're in preview mode. Revert it back.
+ $button.text( oldData.text );
+
+ // Show the inputs again
+ $form.find( '.flow-preview-target-hidden' ).removeClass( 'flow-preview-target-hidden' ).focus();
+
+ // Remove the preview
+ oldData.$nodes.remove();
+
+ // Remove this reset info
+ $button.removeData( 'flow-return-to-edit' );
+
+ return true;
+ }
+ return false;
+ }
+ FlowBoardComponentMiscMixin.prototype.resetPreview = flowBoardComponentResetPreview;
+
+ /**
+ * This will trigger an eventLog call to the given schema with the given
+ * parameters (along with other info about the user & page.)
+ * A unique funnel ID will be created for all new EventLog calls.
+ *
+ * There may be multiple subsequent calls in the same "funnel" (and share
+ * same info) that you want to track. It is possible to forward funnel data
+ * from one node to another once the first has been clicked. It'll then
+ * log new calls with the same data (schema & entrypoint) & funnel ID as the
+ * initial logged event.
+ *
+ * @param {string} schemaName
+ * @param {object} data Data to be logged
+ * @param {string} data.action Schema's action parameter. Always required!
+ * @param {string} [data.entrypoint] Schema's entrypoint parameter (can be
+ * omitted if already logged in funnel - will inherit)
+ * @param {string} [data.funnelId] Schema's funnelId parameter (can be
+ * omitted if starting new funnel - will be generated)
+ * @param {jQuery} [$forward] Nodes to forward funnel to
+ * @returns {object} Logged data
+ */
+ function logEvent( schemaName, data, $forward ) {
+ var // Get existing (forwarded) funnel id, or generate a new one if it does not yet exist
+ funnelId = data.funnelId || mw.flow.FlowEventLogRegistry.generateFunnelId(),
+ // Fetch existing EventLog object for this funnel (if any)
+ eventLog = mw.flow.FlowEventLogRegistry.funnels[funnelId];
+
+ // Optional argument, may not want/need to forward funnel to other nodes
+ $forward = $forward || $();
+
+ if ( !eventLog ) {
+ // Add some more data to log!
+ data = $.extend( data, {
+ isAnon: mw.user.isAnon(),
+ sessionId: mw.user.sessionId(),
+ funnelId: funnelId,
+ pageNs: mw.config.get( 'wgNamespaceNumber' ),
+ pageTitle: ( new mw.Title( mw.config.get( 'wgPageName' ) ) ).getMain()
+ } );
+
+ // A funnel with this id does not yet exist, create it!
+ eventLog = new mw.flow.EventLog( schemaName, data );
+
+ // Store this particular eventLog - we may want to log more things
+ // in this funnel
+ mw.flow.FlowEventLogRegistry.funnels[funnelId] = eventLog;
+ }
+
+ // Log this action
+ eventLog.logEvent( { action: data.action } );
+
+ // Forward the event
+ this.forwardEvent( $forward, schemaName, funnelId );
+
+ return data;
+ }
+ FlowBoardComponentMiscMixin.prototype.logEvent = logEvent;
+
+ /**
+ * Forward funnel data to other places.
+ *
+ * @param {jQuery} $forward Nodes to forward funnel to
+ * @param {string} schemaName
+ * @param {string} funnelId Schema's funnelId parameter
+ */
+ function forwardEvent( $forward, schemaName, funnelId ) {
+ // Not using data() - it somehow gets lost on some nodes
+ $forward.attr( {
+ 'data-flow-eventlog-schema': schemaName,
+ 'data-flow-eventlog-funnel-id': funnelId
+ } );
+ }
+ FlowBoardComponentMiscMixin.prototype.forwardEvent = forwardEvent;
+
+ // Mixin to FlowBoardComponent
+ mw.flow.mixinComponent( 'board', FlowBoardComponentMiscMixin );
+}( jQuery, mediaWiki ) );
diff --git a/Flow/modules/engine/components/board/base/flow-boardandhistory-base.js b/Flow/modules/engine/components/board/base/flow-boardandhistory-base.js
new file mode 100644
index 00000000..5d3c7085
--- /dev/null
+++ b/Flow/modules/engine/components/board/base/flow-boardandhistory-base.js
@@ -0,0 +1,190 @@
+/*!
+ * Contains the base class for both FlowBoardComponent and FlowBoardHistoryComponent.
+ * This is functionality that is used by both types of page, but not any other components.
+ */
+
+( function ( $, mw ) {
+ /**
+ *
+ * @param {jQuery} $container
+ * @constructor
+ */
+ function FlowBoardAndHistoryComponentBase( $container ) {
+ this.bindNodeHandlers( FlowBoardAndHistoryComponentBase.UI.events );
+ }
+ OO.initClass( FlowBoardAndHistoryComponentBase );
+
+ FlowBoardAndHistoryComponentBase.UI = {
+ events: {
+ apiPreHandlers: {},
+ apiHandlers: {},
+ interactiveHandlers: {}
+ }
+ };
+
+ // Register
+ mw.flow.registerComponent( 'boardAndHistoryBase', FlowBoardAndHistoryComponentBase );
+
+ //
+ // Methods
+ //
+
+ /**
+ * Sets up the board and base properties on this class.
+ * Returns either FALSE for failure, or jQuery object of old nodes that were replaced.
+ * @param {jQuery|boolean} $container
+ * @return {boolean|jQuery}
+ */
+ FlowBoardAndHistoryComponentBase.prototype.reinitializeContainer = function ( $container ) {
+ if ( $container === false ) {
+ return false;
+ }
+
+ // Progressively enhance the board and its forms
+ // @todo Needs a ~"liveUpdateComponents" method, since the functionality in makeContentInteractive needs to also run when we receive new content or update old content.
+ // @todo move form stuff
+ if ( $container.data( 'flow-component' ) !== 'board' ) {
+ // Don't do this for FlowBoardComponent, because that runs makeContentInteractive in its own reinit
+ this.emitWithReturn( 'makeContentInteractive', this );
+ }
+
+ // We don't replace anything with this method (we do with flowBoardComponentReinitializeContainer)
+ return $();
+ };
+
+ //
+ // Interactive handlers
+ //
+
+ /**
+ * @param {Event} event
+ * @returns {$.Promise}
+ */
+ FlowBoardAndHistoryComponentBase.UI.events.interactiveHandlers.moderationDialog = function ( event ) {
+ var $form,
+ $this = $( this ),
+ flowComponent = mw.flow.getPrototypeMethod( 'boardAndHistoryBase', 'getInstanceByElement' )( $this ),
+ // hide, delete, suppress
+ // @todo this could just be detected from the url
+ role = $this.data( 'role' ),
+ template = $this.data( 'flow-template' ),
+ params = {
+ editToken: mw.user.tokens.get( 'editToken' ), // might be unnecessary
+ submitted: {
+ moderationState: role
+ },
+ actions: {}
+ },
+ $deferred = $.Deferred(),
+ modal;
+
+ event.preventDefault();
+
+ params.actions[role] = { url: $this.attr( 'href' ), title: $this.attr( 'title' ) };
+
+ // Render the modal itself with mw-ui-modal
+ modal = mw.Modal( {
+ open: $( mw.flow.TemplateEngine.processTemplateGetFragment( template, params ) ).children(),
+ disableCloseOnOutsideClick: true
+ } );
+
+ // @todo remove this data-flow handler forwarder when data-mwui handlers are implemented
+ // Have the events begin bubbling up from $board
+ flowComponent.assignSpawnedNode( modal.getNode(), flowComponent.$board );
+
+ // Run loadHandlers
+ flowComponent.emitWithReturn( 'makeContentInteractive', modal.getContentNode() );
+
+ // Set flowDialogOwner for API callback @todo find a better way of doing this with mw.Modal
+ $form = modal.getContentNode().find( 'form' ).data( 'flow-dialog-owner', $this );
+ // Bind the cancel callback on the form
+ flowComponent.emitWithReturn( 'addFormCancelCallback', $form, function () {
+ mw.Modal.close( this );
+ } );
+
+ modal = null; // avoid permanent reference
+
+ return $deferred.resolve().promise();
+ };
+
+ /**
+ * Cancels and closes a form. If text has been entered, issues a warning first.
+ * @param {Event} event
+ * @returns {$.Promise}
+ */
+ FlowBoardAndHistoryComponentBase.UI.events.interactiveHandlers.cancelForm = function ( event ) {
+ var target = this,
+ $form = $( this ).closest( 'form' ),
+ flowComponent = mw.flow.getPrototypeMethod( 'boardAndHistoryBase', 'getInstanceByElement' )( $form ),
+ $fields = $form.find( 'textarea, :text' ),
+ changedFieldCount = 0,
+ $deferred = $.Deferred(),
+ callbacks = $form.data( 'flow-cancel-callback' ) || [],
+ schemaName = $( this ).data( 'flow-eventlog-schema' ),
+ funnelId = $( this ).data( 'flow-eventlog-funnel-id' );
+
+ event.preventDefault();
+
+ // Only log cancel attempt if it was user-initiated, not when the cancel
+ // was triggered by code (as part of a post-submit form destroy)
+ if ( event.which ) {
+ flowComponent.logEvent( schemaName, { action: 'cancel-attempt', funnelId: funnelId } );
+ }
+
+ // Check for non-empty fields of text
+ $fields.each( function () {
+ if ( $( this ).val() !== this.defaultValue ) {
+ changedFieldCount++;
+ return false;
+ }
+ } );
+
+ // Only log if user had already entered text (= confirmation was requested)
+ if ( changedFieldCount ) {
+ if ( confirm( flowComponent.constructor.static.TemplateEngine.l10n( 'flow-cancel-warning' ) ) ) {
+ flowComponent.logEvent( schemaName, { action: 'cancel-success', funnelId: funnelId } );
+ } else {
+ flowComponent.logEvent( schemaName, { action: 'cancel-abort', funnelId: funnelId } );
+
+ // User aborted cancel, quit this function & don't destruct the form!
+ return $deferred.reject().promise();
+ }
+ }
+
+ // Reset the form content
+ $form[0].reset();
+
+ // Trigger for flow-actions-disabler
+ $form.find( 'textarea, :text' ).trigger( 'keyup' );
+
+ // Hide the form
+ flowComponent.emitWithReturn( 'hideForm', $form );
+
+ // Get rid of existing error messages
+ flowComponent.emitWithReturn( 'removeError', $form );
+
+ // Trigger the cancel callback
+ $.each( callbacks, function ( idx, fn ) {
+ fn.call( target, event );
+ } );
+
+ return $deferred.resolve().promise();
+ };
+
+ //
+ // Static methods
+ //
+
+ /**
+ * Return true page is in topic namespace,
+ * and if $el is given, that if $el is also within .flow-post.
+ * @param {jQuery} [$el]
+ * @returns {boolean}
+ */
+ function flowBoardInTopicNamespace( $el ) {
+ return inTopicNamespace && ( !$el || $el.closest( '.flow-post' ).length === 0 );
+ }
+ FlowBoardAndHistoryComponentBase.static.inTopicNamespace = flowBoardInTopicNamespace;
+
+ var inTopicNamespace = mw.config.get( 'wgNamespaceNumber' ) === mw.config.get( 'wgNamespaceIds' ).topic;
+}( jQuery, mediaWiki ) );
diff --git a/Flow/modules/engine/components/board/features/flow-board-loadmore.js b/Flow/modules/engine/components/board/features/flow-board-loadmore.js
new file mode 100644
index 00000000..10daaf42
--- /dev/null
+++ b/Flow/modules/engine/components/board/features/flow-board-loadmore.js
@@ -0,0 +1,664 @@
+/*!
+ * Contains loadMore, jumpToTopic, and topic titles list functionality.
+ */
+
+( function ( $, mw, moment ) {
+ /**
+ * Bind UI events and infinite scroll handler for load more and titles list functionality.
+ * @param {jQuery} $container
+ * @this FlowBoardComponent
+ * @constructor
+ */
+ function FlowBoardComponentLoadMoreFeatureMixin( $container ) {
+ /** Stores a reference to each topic element currently on the page */
+ this.renderedTopics = {};
+ /** Stores a list of all topics titles by ID */
+ this.topicTitlesById = {};
+ /** Stores a list of all topic IDs in order */
+ this.orderedTopicIds = [];
+
+ this.bindNodeHandlers( FlowBoardComponentLoadMoreFeatureMixin.UI.events );
+ }
+ OO.initClass( FlowBoardComponentLoadMoreFeatureMixin );
+
+ FlowBoardComponentLoadMoreFeatureMixin.UI = {
+ events: {
+ apiPreHandlers: {},
+ apiHandlers: {},
+ loadHandlers: {}
+ }
+ };
+
+ //
+ // Prototype methods
+ //
+
+ /**
+ * Scrolls up or down to a specific topic, and loads any topics it needs to.
+ * 1. If topic is rendered, scrolls to it.
+ * 2. Otherwise, we load the topic itself
+ * 3b. When the user scrolls up, we begin loading the topics in between.
+ * @param {String} topicId
+ */
+ function flowBoardComponentLoadMoreFeatureJumpTo( topicId ) {
+ /** @type FlowBoardComponent*/
+ var flowBoard = this, apiParameters,
+ // Scrolls to the given topic, but disables infinite scroll loading while doing so
+ _scrollWithoutInfinite = function () {
+ var $renderedTopic = flowBoard.renderedTopics[ topicId ];
+
+ if ( $renderedTopic && $renderedTopic.length ) {
+ flowBoard.infiniteScrollDisabled = true;
+
+ // Get out of the way of the affixed navigation
+ // Not going the full $( '.flow-board-navigation' ).height()
+ // because then the load more button (above the new topic)
+ // would get in sight and any scroll would fire it
+ $( 'html, body' ).scrollTop( $renderedTopic.offset().top - 20 );
+
+ // Focus on given topic
+ $renderedTopic.click().focus();
+
+ /*
+ * Re-enable infinite scroll. Only doing that after a couple
+ * of milliseconds because we've just executed some
+ * scrolling (to the selected topic) and the very last
+ * scroll event may only just still be getting fired.
+ * To prevent an immediate scroll (above the new topic),
+ * let's only re-enable infinite scroll until we're sure
+ * that event has been fired.
+ */
+ setTimeout( function() {
+ delete flowBoard.infiniteScrollDisabled;
+ }, 1 );
+ } else {
+ flowBoard.debug( 'Rendered topic not found when attempting to scroll!' );
+ }
+ };
+
+ // 1. Topic is already on the page; just scroll to it
+ if ( flowBoard.renderedTopics[ topicId ] ) {
+ _scrollWithoutInfinite();
+ return;
+ }
+
+ // 2a. Topic is not rendered; do we know about this topic ID?
+ if ( flowBoard.topicTitlesById[ topicId ] === undefined ) {
+ // We don't. Abort!
+ return flowBoard.debug( 'Unknown topicId', arguments );
+ }
+
+ // 2b. Load that topic and jump to it
+ apiParameters = {
+ action: 'flow',
+ submodule: 'view-topiclist',
+ 'vtloffset-dir': 'fwd', // @todo support "middle" dir
+ 'vtlinclude-offset': true,
+ vtlsortby: this.topicIdSort
+ };
+
+ if ( this.topicIdSort === 'newest' ) {
+ apiParameters['vtloffset-id'] = topicId;
+ } else {
+ // TODO: It would seem to be safer to pass 'offset-id' for both (what happens
+ // if there are two posts at the same timestamp?). (Also, that would avoid needing
+ // the timestamp in the TOC-only API response). However,
+ // apparently, we must pass 'offset' for 'updated' order to get valid
+ // results (e.g. by passing offset-id for 'updated', it doesn't even include
+ // the item requested despite include-offset). However, the server
+ // does not throw an exception for 'offset-id' + 'sortby'='updated', which it
+ // should if this analysis is correct.
+
+ apiParameters.vtloffset = moment.utc( this.updateTimestampsByTopicId[ topicId ] ).format( 'YYYYMMDDHHmmss' );
+ }
+
+ flowBoard.Api.apiCall( apiParameters )
+ // TODO: Finish this error handling or remove the empty functions.
+ // Remove the load indicator
+ .always( function () {
+ // @todo support for multiple indicators on same target
+ //$target.removeClass( 'flow-api-inprogress' );
+ //$this.removeClass( 'flow-api-inprogress' );
+ } )
+ // On success, render the topic
+ .done( function( data ) {
+ _flowBoardComponentLoadMoreFeatureRenderTopics(
+ flowBoard,
+ data.flow[ 'view-topiclist' ].result.topiclist,
+ false,
+ null,
+ '',
+ '',
+ 'flow_topiclist_loop' // @todo clean up the way we pass these 3 params ^
+ );
+
+ _scrollWithoutInfinite();
+ } )
+ // On fail, render an error
+ .fail( function( code, data ) {
+ flowBoard.debug( true, 'Failed to load topics: ' + code );
+ // Failed fetching the new data to be displayed.
+ // @todo render the error at topic position and scroll to it
+ // @todo how do we render this?
+ // $target = ????
+ // flowBoard.emitWithReturn( 'removeError', $target );
+ // var errorMsg = flowBoard.constructor.static.getApiErrorMessage( code, result );
+ // errorMsg = mw.msg( '????', errorMsg );
+ // flowBoard.emitWithReturn( 'showError', $target, errorMsg );
+ } );
+ }
+ FlowBoardComponentLoadMoreFeatureMixin.prototype.jumpToTopic = flowBoardComponentLoadMoreFeatureJumpTo;
+
+ //
+ // API pre-handlers
+ //
+
+ /**
+ * On before board reloading (eg. change sort).
+ * This method only clears the storage in preparation for it to be reloaded.
+ * @param {Event} event
+ * @param {Object} info
+ * @param {jQuery} info.$target
+ * @param {FlowBoardComponent} info.component
+ */
+ function flowBoardComponentLoadMoreFeatureBoardApiPreHandler( event, info ) {
+ // Backup the topic data
+ info.component.renderedTopicsBackup = info.component.renderedTopics;
+ info.component.topicTitlesByIdBackup = info.component.topicTitlesById;
+ info.component.orderedTopicIdsBackup = info.component.orderedTopicIds;
+ // Reset the topic data
+ info.component.renderedTopics = {};
+ info.component.topicTitlesById = {};
+ info.component.orderedTopicIds = [];
+ }
+ FlowBoardComponentLoadMoreFeatureMixin.UI.events.apiPreHandlers.board = flowBoardComponentLoadMoreFeatureBoardApiPreHandler;
+
+ //
+ // API callback handlers
+ //
+
+ /**
+ * On failed board reloading (eg. change sort), restore old data.
+ * @param {Object} info
+ * @param {string} info.status "done" or "fail"
+ * @param {jQuery} info.$target
+ * @param {FlowBoardComponent} info.component
+ * @param {Object} data
+ * @param {jqXHR} jqxhr
+ * @returns {$.Promise}
+ */
+ function flowBoardComponentLoadMoreFeatureBoardApiCallback( info, data, jqxhr ) {
+ if ( info.status !== 'done' ) {
+ // Failed; restore the topic data
+ info.component.renderedTopics = info.component.renderedTopicsBackup;
+ info.component.topicTitlesById = info.component.topicTitlesByIdBackup;
+ info.component.orderedTopicIds = info.component.orderedTopicIdsBackup;
+ }
+
+ // Delete the backups
+ delete info.component.renderedTopicsBackup;
+ delete info.component.topicTitlesByIdBackup;
+ delete info.component.orderedTopicIdsBackup;
+ }
+ FlowBoardComponentLoadMoreFeatureMixin.UI.events.apiHandlers.board = flowBoardComponentLoadMoreFeatureBoardApiCallback;
+
+ /**
+ * Loads more content
+ * @param {Object} info
+ * @param {string} info.status "done" or "fail"
+ * @param {jQuery} info.$target
+ * @param {FlowBoardComponent} info.component
+ * @param {Object} data
+ * @param {jqXHR} jqxhr
+ */
+ function flowBoardComponentLoadMoreFeatureTopicsApiCallback( info, data, jqxhr ) {
+ if ( info.status !== 'done' ) {
+ // Error will be displayed by default, nothing else to wrap up
+ return $.Deferred().reject().promise();
+ }
+
+ var $this = $( this ),
+ $target = info.$target,
+ flowBoard = info.component,
+ scrollTarget = $this.data( 'flow-scroll-target' ),
+ $scrollContainer = $.findWithParent( $this, $this.data( 'flow-scroll-container' ) ),
+ topicsData = data.flow[ 'view-topiclist' ].result.topiclist,
+ readingTopicPosition;
+
+ if ( scrollTarget === 'window' && flowBoard.readingTopicId ) {
+ // Store the current position of the topic you are reading
+ readingTopicPosition = { id: flowBoard.readingTopicId };
+ // Where does the topic start?
+ readingTopicPosition.topicStart = flowBoard.renderedTopics[ readingTopicPosition.id ].offset().top;
+ // Where am I within the topic?
+ readingTopicPosition.topicPlace = $( window ).scrollTop() - readingTopicPosition.topicStart;
+ }
+
+ // Render topics
+ _flowBoardComponentLoadMoreFeatureRenderTopics(
+ flowBoard,
+ topicsData,
+ flowBoard.$container.find( flowBoard.$loadMoreNodes ).last()[ 0 ] === this, // if this is the last load more button
+ $target,
+ scrollTarget,
+ $this.data( 'flow-scroll-container' ),
+ $this.data( 'flow-template' )
+ );
+
+ // Remove the old load button (necessary if the above load_more template returns nothing)
+ $target.remove();
+
+ if ( scrollTarget === 'window' ) {
+ scrollTarget = $( window );
+
+ if ( readingTopicPosition ) {
+ readingTopicPosition.anuStart = flowBoard.renderedTopics[ readingTopicPosition.id ].offset().top;
+ if ( readingTopicPosition.anuStart > readingTopicPosition.topicStart ) {
+ // Looks like the topic we are reading got pushed down. Let's jump to where we were before
+ scrollTarget.scrollTop( readingTopicPosition.anuStart + readingTopicPosition.topicPlace );
+ }
+ }
+ } else {
+ scrollTarget = $.findWithParent( this, scrollTarget );
+ }
+
+ /*
+ * Fire infinite scroll check again - if no (or few) topics were
+ * added (e.g. because they're moderated), we should immediately
+ * fetch more instead of waiting for the user to scroll again (when
+ * there's no reason to scroll)
+ */
+ _flowBoardComponentLoadMoreFeatureInfiniteScrollCheck.call( flowBoard, $scrollContainer, scrollTarget );
+ return $.Deferred().resolve().promise();
+ }
+ FlowBoardComponentLoadMoreFeatureMixin.UI.events.apiHandlers.loadMoreTopics = flowBoardComponentLoadMoreFeatureTopicsApiCallback;
+
+ /**
+ * Loads up the topic titles list.
+ * Saves the topic titles to topicTitlesById and orderedTopicIds, and adds timestamps
+ * to updateTimestampsByTopicId.
+ *
+ * @param {Object} info
+ * @param {string} info.status "done" or "fail"
+ * @param {jQuery} info.$target
+ * @param {FlowBoardComponent} info.component
+ * @param {Object} data
+ * @param {jqXHR} jqxhr
+ */
+ function flowBoardComponentLoadMoreFeatureTopicListApiCallback( info, data, jqxhr ) {
+ if ( info.status !== 'done' ) {
+ // Error will be displayed by default, nothing else to wrap up
+ return;
+ }
+
+ var i = 0,
+ topicsData = data.flow[ 'view-topiclist' ].result.topiclist,
+ topicId, revisionId,
+ flowBoard = info.component;
+
+ // Iterate over every topic
+ for ( ; i < topicsData.roots.length; i++ ) {
+ // Get the topic ID
+ topicId = topicsData.roots[ i ];
+ // Get the revision ID
+ revisionId = topicsData.posts[ topicId ][0];
+
+ if ( $.inArray( topicId, flowBoard.orderedTopicIds ) === -1 ) {
+ // Append to the end, we will sort after the insert loop.
+ flowBoard.orderedTopicIds.push( topicId );
+ }
+
+ if ( flowBoard.topicTitlesById[ topicId ] === undefined ) {
+ // Store the title from the revision object
+ flowBoard.topicTitlesById[ topicId ] = topicsData.revisions[ revisionId ].content.content;
+ }
+
+ if ( flowBoard.updateTimestampsByTopicId[ topicId ] === undefined ) {
+ flowBoard.updateTimestampsByTopicId[ topicId ] = topicsData.revisions[ revisionId ].last_updated;
+ }
+ }
+
+ _flowBoardSortTopicIds( flowBoard );
+
+ // we need to re-trigger scroll.flow-load-more if there are not enough items in the
+ // toc for it to scroll and trigger on its own. Without this TOC never triggers
+ // the initial loadmore to expand from the number of topics on page to topics
+ // available from the api.
+ if ( this.$loadMoreNodes ) {
+ this.$loadMoreNodes
+ .filter( '[data-flow-api-handler=topicList]' )
+ .trigger( 'scroll.flow-load-more', { forceNavigationUpdate: true } );
+ }
+ }
+ FlowBoardComponentLoadMoreFeatureMixin.UI.events.apiHandlers.topicList = flowBoardComponentLoadMoreFeatureTopicListApiCallback;
+
+ //
+ // On element-load handlers
+ //
+
+ /**
+ * Stores the load more button for use with infinite scroll.
+ * @example <button data-flow-scroll-target="< ul"></button>
+ * @param {jQuery} $button
+ */
+ function flowBoardComponentLoadMoreFeatureElementLoadCallback( $button ) {
+ var scrollTargetSelector = $button.data( 'flow-scroll-target' ),
+ $target,
+ scrollContainerSelector = $button.data( 'flow-scroll-container' ),
+ $scrollContainer = $.findWithParent( $button, scrollContainerSelector ),
+ board = this;
+
+ if ( !this.$loadMoreNodes ) {
+ // Create a new $loadMoreNodes list
+ this.$loadMoreNodes = $();
+ } else {
+ // Remove any loadMore nodes that are no longer in the body
+ this.$loadMoreNodes = this.$loadMoreNodes.filter( function () {
+ var $this = $( this );
+
+ // @todo unbind scroll handlers
+ if ( !$this.closest( 'body' ).length ) {
+ // Get rid of this and its handlers
+ $this.remove();
+ // Delete from list
+ return false;
+ }
+
+ return true;
+ } );
+ }
+
+ // Store this new loadMore node
+ this.$loadMoreNodes = this.$loadMoreNodes.add( $button );
+
+ // Make sure we didn't already bind to this element's scroll previously
+ if ( $scrollContainer.data( 'scrollIsBound' ) ) {
+ return;
+ }
+ $scrollContainer.data( 'scrollIsBound', true );
+
+ // Bind the event for this
+ if ( scrollTargetSelector === 'window' ) {
+ this.on( 'windowScroll', function () {
+ _flowBoardComponentLoadMoreFeatureInfiniteScrollCheck.call( board, $scrollContainer, $( window ) );
+ } );
+ } else {
+ $target = $.findWithParent( $button, scrollTargetSelector );
+ $target.on( 'scroll.flow-load-more', $.throttle( 50, function ( evt ) {
+ _flowBoardComponentLoadMoreFeatureInfiniteScrollCheck.call( board, $scrollContainer, $target );
+ } ) );
+ }
+ }
+ FlowBoardComponentLoadMoreFeatureMixin.UI.events.loadHandlers.loadMore = flowBoardComponentLoadMoreFeatureElementLoadCallback;
+
+ /**
+ * Stores a list of all topics currently visible on the page.
+ * @param {jQuery} $topic
+ */
+ function flowBoardComponentLoadMoreFeatureElementLoadTopic( $topic ) {
+ var self = this,
+ currentTopicId = $topic.data( 'flow-id' );
+
+ // Store this topic by ID
+ this.renderedTopics[ currentTopicId ] = $topic;
+
+ // Remove any topics that are no longer on the page, just in case
+ $.each( this.renderedTopics, function ( topicId, $topic ) {
+ if ( !$topic.closest( self.$board ).length ) {
+ delete self.renderedTopics[ topicId ];
+ }
+ } );
+ }
+ FlowBoardComponentLoadMoreFeatureMixin.UI.events.loadHandlers.topic = flowBoardComponentLoadMoreFeatureElementLoadTopic;
+
+ /**
+ * Stores a list of all topics titles currently visible on the page.
+ * @param {jQuery} $topicTitle
+ */
+ function flowBoardComponentLoadMoreFeatureElementLoadTopicTitle( $topicTitle ) {
+ var currentTopicId = $topicTitle.closest( '[data-flow-id]' ).data( 'flowId' );
+
+ // If topic doesn't exist in topic titles list, add it (only happens at page load)
+ // @todo this puts the wrong order
+ if ( this.topicTitlesById[ currentTopicId ] === undefined ) {
+ this.topicTitlesById[ currentTopicId ] = $topicTitle.data( 'flow-topic-title' );
+
+ if ( $.inArray( currentTopicId, this.orderedTopicIds ) === -1 ) {
+ this.orderedTopicIds.push( currentTopicId );
+ _flowBoardSortTopicIds( this );
+ }
+ }
+ }
+ FlowBoardComponentLoadMoreFeatureMixin.UI.events.loadHandlers.topicTitle = flowBoardComponentLoadMoreFeatureElementLoadTopicTitle;
+
+ //
+ // Private functions
+ //
+
+
+ /**
+ * Re-sorts the orderedTopicIds after insert
+ *
+ * @param {Object} flowBoard
+ */
+ function _flowBoardSortTopicIds( flowBoard ) {
+ if ( flowBoard.topicIdSortCallback ) {
+ // Custom sorts
+ flowBoard.orderedTopicIds.sort( flowBoard.topicIdSortCallback );
+ } else {
+ // Default sort, takes advantage of topic ids monotonically increasing
+ // which allows for the newest sort to be the default utf-8 string sort
+ // in reverse.
+ // TODO: This can be optimized (to avoid two in-place operations that affect
+ // the whole array by doing a descending sort (with a custom comparator)
+ // rather than sorting then reversing.
+ flowBoard.orderedTopicIds.sort().reverse();
+ }
+ }
+
+ /**
+ * Called on scroll. Checks to see if a FlowBoard needs to have more content loaded.
+ * @param {jQuery} $searchContainer Container to find 'load more' buttons in
+ * @param {jQuery} $calculationContainer Container to do scroll calculations on (height, scrollTop, offset, etc.)
+ */
+ function _flowBoardComponentLoadMoreFeatureInfiniteScrollCheck( $searchContainer, $calculationContainer ) {
+ if ( this.infiniteScrollDisabled ) {
+ // This happens when the topic navigation is used to jump to a topic
+ // We should not infinite-load anything when we are scrolling to a topic
+ return;
+ }
+
+ var calculationContainerHeight = $calculationContainer.height(),
+ calculationContainerScroll = $calculationContainer.scrollTop(),
+ calculationContainerThreshold = ( $calculationContainer.offset() || { top: calculationContainerScroll } ).top;
+
+ // Find load more buttons within our search container, and they must be visible
+ $searchContainer.find( this.$loadMoreNodes ).filter( ':visible' ).each( function () {
+ var $this = $( this ),
+ nodeOffset = $this.offset().top,
+ nodeHeight = $this.outerHeight( true );
+
+ // First, is this element above or below us?
+ if ( nodeOffset <= calculationContainerThreshold ) {
+ // Top of element is above the viewport; don't use it.
+ return;
+ }
+
+ // @todo: this ignores that TOC also obscures the button: load more
+ // also shouldn't be triggered if it's still behind TOC!
+
+ // Is this element in the viewport?
+ if ( nodeOffset - nodeHeight <= calculationContainerThreshold + calculationContainerHeight ) {
+ // Element is almost in viewport, click it.
+ $( this ).trigger( 'click' );
+ }
+ } );
+ }
+
+ /**
+ * Renders and inserts a list of new topics.
+ * @param {FlowBoardComponent} flowBoard
+ * @param {Object} topicsData
+ * @param {boolean} [forceShowLoadMore]
+ * @param {jQuery} [$insertAt]
+ * @param {String} [scrollTarget]
+ * @param {String} [scrollContainer]
+ * @param {String} [scrollTemplate]
+ * @private
+ */
+ function _flowBoardComponentLoadMoreFeatureRenderTopics( flowBoard, topicsData, forceShowLoadMore, $insertAt, scrollTarget, scrollContainer, scrollTemplate ) {
+ if ( !topicsData.roots.length ) {
+ flowBoard.debug( 'No topics returned from API', arguments );
+ return;
+ }
+
+ /** @private
+ */
+ function _createRevPagination( $target ) {
+ if ( !topicsData.links.pagination.fwd && !topicsData.links.pagination.rev ) {
+ return;
+ }
+
+ if ( !topicsData.links.pagination.rev && topicsData.links.pagination.fwd ) {
+ // This is a fix for the fact that a "rev" is not available here (TODO: Why not?)
+ // We can create one by overriding dir=rev
+ topicsData.links.pagination.rev = $.extend( true, {}, topicsData.links.pagination.fwd, { title: 'rev' } );
+ topicsData.links.pagination.rev.url = topicsData.links.pagination.rev.url.replace( '_offset-dir=fwd', '_offset-dir=rev' );
+ }
+
+ $allRendered = $allRendered.add(
+ $( flowBoard.constructor.static.TemplateEngine.processTemplateGetFragment(
+ 'flow_load_more.partial',
+ {
+ loadMoreObject: topicsData.links.pagination.rev,
+ loadMoreApiHandler: 'loadMoreTopics',
+ loadMoreTarget: scrollTarget,
+ loadMoreContainer: scrollContainer,
+ loadMoreTemplate: scrollTemplate
+ }
+ ) ).children()
+ .insertBefore( $target.first() )
+ );
+ }
+
+ /** @private
+ */
+ function _createFwdPagination( $target ) {
+ if ( forceShowLoadMore || topicsData.links.pagination.fwd ) {
+ // Add the load more to the end of the stack
+ $allRendered = $allRendered.add(
+ $( flowBoard.constructor.static.TemplateEngine.processTemplateGetFragment(
+ 'flow_load_more.partial',
+ {
+ loadMoreObject: topicsData.links.pagination.fwd,
+ loadMoreApiHandler: 'loadMoreTopics',
+ loadMoreTarget: scrollTarget,
+ loadMoreContainer: scrollContainer,
+ loadMoreTemplate: scrollTemplate
+ }
+ ) ).children()
+ .insertAfter( $target.last() )
+ );
+ }
+ }
+
+ /**
+ * Renders topics by IDs from topicsData, and returns the elements.
+ * @param {Array} toRender List of topic IDs in topicsData
+ * @returns {jQuery}
+ * @private
+ */
+ function _render( toRender ) {
+ var rootsBackup = topicsData.roots,
+ $newTopics;
+
+ // Temporarily set roots to our subset to be rendered
+ topicsData.roots = toRender;
+
+ try {
+ $newTopics = $( flowBoard.constructor.static.TemplateEngine.processTemplateGetFragment(
+ scrollTemplate,
+ topicsData
+ ) ).children();
+ } catch( e ) {
+ flowBoard.debug( true, 'Failed to render new topic' );
+ $newTopics = $();
+ }
+
+ topicsData.roots = rootsBackup;
+
+ return $newTopics;
+ }
+
+ var i, j, $topic, topicId,
+ $allRendered = $( [] ),
+ toInsert = [];
+
+ for ( i = 0; i < topicsData.roots.length; i++ ) {
+ topicId = topicsData.roots[ i ];
+
+ if ( !flowBoard.renderedTopics[ topicId ] ) {
+ flowBoard.renderedTopics[ topicId ] = _render( [ topicId ] );
+ $allRendered.push( flowBoard.renderedTopics[ topicId ][0] );
+ toInsert.push( topicId );
+ if ( $.inArray( topicId, flowBoard.orderedTopicIds ) === -1 ) {
+ flowBoard.orderedTopicIds.push( topicId );
+ }
+ // @todo this is already done elsewhere, but it runs after insert
+ // to the DOM instead of before. Not sure how to fix ordering.
+ if ( !flowBoard.updateTimestampsByTopicId[ topicId ] ) {
+ flowBoard.updateTimestampsByTopicId[ topicId ] = topicsData.revisions[topicsData.posts[topicId][0]].last_updated;
+ }
+ }
+ }
+
+ if ( toInsert.length ) {
+ _flowBoardSortTopicIds( flowBoard );
+
+ // This uses the assumption that there will be at least one pre-existing
+ // topic above the topics to be inserted. This should hold true as the
+ // initial page load starts at the begining.
+ for ( i = 1; i < flowBoard.orderedTopicIds.length; i++ ) {
+ // topic is not to be inserted yet.
+ if ( $.inArray( flowBoard.orderedTopicIds[ i ], toInsert ) === -1 ) {
+ continue;
+ }
+
+ // find the most recent topic in the list that exists and insert after it.
+ for ( j = i - 1; j >= 0; j-- ) {
+ $topic = flowBoard.renderedTopics[ flowBoard.orderedTopicIds[ j ] ];
+ if ( $topic && $topic.length && $.contains( document.body, $topic[0] ) ) {
+ break;
+ }
+ }
+
+ // Put the new topic after the found topic above it
+ if ( j >= 0 ) {
+ $topic.after( flowBoard.renderedTopics[ flowBoard.orderedTopicIds[ i ] ] );
+ }
+ }
+
+ // This works because orderedTopicIds includes not only the topics on
+ // page but also the ones loaded by the toc. If these topics are due
+ // to a jump rather than forward auto-pagination the prior topic will
+ // not be rendered.
+ i = $.inArray( topicsData.roots[0], flowBoard.orderedTopicIds );
+ if ( i > 0 && flowBoard.renderedTopics[ flowBoard.orderedTopicIds[ i - 1 ] ] === undefined ) {
+ _createRevPagination( flowBoard.renderedTopics[ topicsData.roots[0] ] );
+ }
+ // Same for forward pagination, if we jumped and then scrolled backwards the
+ // topic after the last will already be rendered, and forward pagination
+ // will not be necessary.
+ i = $.inArray( topicsData.roots[ topicsData.roots.length - 1 ], flowBoard.orderedTopicIds );
+ if ( i === flowBoard.orderedTopicIds.length - 1 || flowBoard.renderedTopics[ flowBoard.orderedTopicIds[ i + 1 ] ] === undefined ) {
+ _createFwdPagination( flowBoard.renderedTopics[ topicsData.roots[ topicsData.roots.length - 1 ] ] );
+ }
+ }
+
+ // Run loadHandlers
+ flowBoard.emitWithReturn( 'makeContentInteractive', $allRendered );
+ }
+
+ // Mixin to FlowBoardComponent
+ mw.flow.mixinComponent( 'board', FlowBoardComponentLoadMoreFeatureMixin );
+}( jQuery, mediaWiki, moment ) );
diff --git a/Flow/modules/engine/components/board/features/flow-board-navigation.js b/Flow/modules/engine/components/board/features/flow-board-navigation.js
new file mode 100644
index 00000000..7b82230f
--- /dev/null
+++ b/Flow/modules/engine/components/board/features/flow-board-navigation.js
@@ -0,0 +1,282 @@
+/*!
+ * Contains board navigation header, which affixes to the viewport on scroll.
+ */
+
+( function ( $, mw ) {
+ /**
+ * Binds handlers for the board header itself.
+ * @param {jQuery} $container
+ * @this FlowComponent
+ * @constructor
+ */
+ function FlowBoardComponentBoardHeaderFeatureMixin( $container ) {
+ // Bind element handlers
+ this.bindNodeHandlers( FlowBoardComponentBoardHeaderFeatureMixin.UI.events );
+
+ /** {String} topic ID currently being read in viewport */
+ this.readingTopicId = null;
+
+ /** {Object} Map from topic id to its last update timestamp for sorting */
+ this.updateTimestampsByTopicId = {};
+ }
+ OO.initClass( FlowBoardComponentBoardHeaderFeatureMixin );
+
+ FlowBoardComponentBoardHeaderFeatureMixin.UI = {
+ events: {
+ apiPreHandlers: {},
+ apiHandlers: {},
+ interactiveHandlers: {},
+ loadHandlers: {}
+ }
+ };
+
+ //
+ // Prototype methods
+ //
+
+ //
+ // API pre-handlers
+ //
+
+ //
+ // On element-click handlers
+ //
+
+ //
+ // On element-load handlers
+ //
+
+ /**
+ * Bind the navigation header bar to the window.scroll event.
+ * @param {jQuery} $boardNavigation
+ */
+ function flowBoardLoadEventsBoardNavigation( $boardNavigation ) {
+ this
+ .off( 'windowScroll', _flowBoardAdjustTopicNavigationHeader )
+ .off( 'windowResize', _flowBoardAdjustTopicNavigationHeader )
+ .on( 'windowScroll', _flowBoardAdjustTopicNavigationHeader, [ $boardNavigation ] )
+ .on( 'windowResize', _flowBoardAdjustTopicNavigationHeader, [ $boardNavigation ] );
+
+ // remove any existing state about the affixed navigation, it has been replaced
+ // with a new $boardNavigation to clone from.
+ if ( this.$boardNavigationClone ) {
+ this.$boardNavigationClone.remove();
+ delete this.$boardNavigationClone;
+ }
+ // The topic navigation header becomes fixed to the window beyond its position
+ _flowBoardAdjustTopicNavigationHeader.call( this, $boardNavigation, {} );
+
+ // initialize the board topicId sorting callback. This expects to be rendered
+ // as a sibling of the topiclist component. The topiclist component includes
+ // information about how it is currently sorted, so we can maintain that in the
+ // TOC. This is typically either 'newest' or 'updated'.
+ this.topicIdSort = $boardNavigation.siblings('[data-flow-sortby]').data( 'flow-sortby' );
+ this.updateTopicIdSortCallback();
+
+ // This allows the toc to initialize eagerly before the user looks at it.
+ $boardNavigation.find( '[data-flow-api-handler=topicList]' )
+ .trigger( 'click', { skipMenuToggle: true, forceNavigationUpdate: true } );
+ }
+ FlowBoardComponentBoardHeaderFeatureMixin.UI.events.loadHandlers.boardNavigation = flowBoardLoadEventsBoardNavigation;
+
+ /**
+ * Stores the board navigation title.
+ * @param {jQuery} $boardNavigationTitle
+ */
+ function flowBoardLoadEventsBoardNavigationTitle( $boardNavigationTitle ) {
+ this.boardNavigationOriginalTitle = $boardNavigationTitle.text();
+ this.$boardNavigationTitle = $boardNavigationTitle;
+ }
+ FlowBoardComponentBoardHeaderFeatureMixin.UI.events.loadHandlers.boardNavigationTitle = flowBoardLoadEventsBoardNavigationTitle;
+
+ /**
+ * @param {jQuery} $topic
+ */
+ function flowBoardLoadEventsTopic( $topic ) {
+ var id = $topic.data( 'flow-id' ),
+ updated = $topic.data( 'flow-topic-timestamp-updated' );
+
+ this.updateTimestampsByTopicId[id] = updated;
+ }
+ FlowBoardComponentBoardHeaderFeatureMixin.UI.events.loadHandlers.topic = flowBoardLoadEventsTopic;
+
+ //
+ // Private functions
+ //
+
+ /**
+ * Initialize the topic id sort callback
+ */
+ function _flowBoardUpdateTopicIdSortCallback() {
+ if ( this.topicIdSort === 'newest' ) {
+ // the sort callback takes advantage of the default utf-8
+ // sort in this case
+ this.topicIdSortCallback = undefined;
+ } else if ( this.topicIdSort === 'updated' ) {
+ this.topicIdSortCallback = flowBoardTopicIdGenerateSortRecentlyActive( this );
+ } else {
+ throw new Error( 'this.topicIdSort has an invalid value' );
+ }
+ }
+ FlowBoardComponentBoardHeaderFeatureMixin.prototype.updateTopicIdSortCallback = _flowBoardUpdateTopicIdSortCallback;
+
+ /**
+ * Generates Array#sort callback for sorting a list of topic ids
+ * by the 'recently active' sort order. This is a numerical
+ * comparison of related timestamps held within the board object.
+ * Also note that this is a reverse sort from newest to oldest.
+ * @param {Object} board Object from which to source
+ * timestamps which map from topicId to its last updated timestamp
+ * @return {Function}
+ */
+ function flowBoardTopicIdGenerateSortRecentlyActive( board ) {
+ /**
+ * @param {String} a
+ * @param {String} b
+ * @return {integer} Per Array#sort callback rules
+ */
+ return function ( a, b ) {
+ var aTimestamp = board.updateTimestampsByTopicId[a],
+ bTimestamp = board.updateTimestampsByTopicId[b];
+
+ if ( aTimestamp === undefined && bTimestamp === undefined ) {
+ return 0;
+ } else if ( aTimestamp === undefined ) {
+ return 1;
+ } else if ( bTimestamp === undefined ) {
+ return -1;
+ } else {
+ return bTimestamp - aTimestamp;
+ }
+ };
+ }
+
+ // TODO: Let's look at decoupling the event handler part from the parts that actually do the
+ // work. (Already, event is not used.)
+ /**
+ * On window.scroll, we clone the nav header bar and fix the original to the window top.
+ * We clone so that we have one which always remains in the same place for calculation purposes,
+ * as it can vary depending on whether or not new content is rendered or the window is resized.
+ * @param {jQuery} $boardNavigation board navigation element
+ * @param {Event} event Event passed to windowScroll (unused)
+ * @param {Object} extraParameters
+ * @param {boolean} extraParameters.forceNavigationUpdate True to force a change to the
+ * active item and TOC scroll.
+ */
+ function _flowBoardAdjustTopicNavigationHeader( $boardNavigation, event, extraParameters ) {
+ var bottomScrollPosition, topicText, newReadingTopicId,
+ self = this,
+ boardNavigationPosition = ( this.$boardNavigationClone || $boardNavigation ).offset(),
+ windowVerticalScroll = $( window ).scrollTop();
+
+ extraParameters = extraParameters || {};
+
+ if ( windowVerticalScroll <= boardNavigationPosition.top ) {
+ // Board nav is still in view; don't affix it
+ if ( this.$boardNavigationClone ) {
+ // Un-affix this
+ $boardNavigation
+ .removeClass( 'flow-board-navigation-affixed' )
+ .css( 'left', '' );
+ // Remove the old clone if it exists
+ this.$boardNavigationClone.remove();
+ delete this.$boardNavigationClone;
+ }
+
+ if ( this.boardNavigationOriginalTitle && this.$boardNavigationTitle ) {
+ this.$boardNavigationTitle.text( this.boardNavigationOriginalTitle );
+ }
+
+ return;
+ }
+
+ if ( !this.$boardNavigationClone ) {
+ // Make a new clone
+ this.$boardNavigationClone = $boardNavigation.clone();
+
+ // Add new classes, and remove the main load handler so we don't trigger it again
+ this.$boardNavigationClone
+ .removeData( 'flow-load-handler' )
+ .removeClass( 'flow-load-interactive' )
+ // Also get rid of any menus, in case they were open
+ .find( '.flow-menu' )
+ .remove();
+
+ $boardNavigation
+ // Insert it
+ .before( this.$boardNavigationClone )
+ // Affix the original one
+ .addClass( 'flow-board-navigation-affixed' );
+
+ // After cloning a new navigation we must always update the sort
+ extraParameters.forceNavigationUpdate = true;
+ }
+
+ boardNavigationPosition = this.$boardNavigationClone.offset();
+
+ // The only thing that needs calculating is its left offset
+ if ( parseInt( $boardNavigation.css( 'left' ) ) !== boardNavigationPosition.left ) {
+ $boardNavigation.css( {
+ left: boardNavigationPosition.left
+ } );
+ }
+
+ // Find out what the bottom of the board nav is touching
+ // XXX: One of the IE 8 problems seems to be that $boardNavigation.outerHeight( true ) is about 10 times too big (35 on Firefox, 349 on IE 8).
+ // I think this also causes visual problems.
+ bottomScrollPosition = windowVerticalScroll + $boardNavigation.outerHeight( true );
+
+ $.each( this.orderedTopicIds || [], function ( idx, topicId, $topic ) {
+ $topic = self.renderedTopics[ topicId ];
+
+ if ( !$topic ) {
+ return;
+ }
+
+ var target = $topic.data( 'flow-toc-scroll-target' ),
+ $target = $.findWithParent( $topic, target );
+
+ if ( $target.offset().top - parseInt( $target.css( "padding-top" ) ) > bottomScrollPosition ) {
+ return false; // stop, this topic is too far
+ }
+
+ topicText = self.topicTitlesById[ topicId ];
+ newReadingTopicId = topicId;
+ } );
+
+ self.readingTopicId = newReadingTopicId;
+
+ if ( !this.$boardNavigationTitle ) {
+ return;
+ }
+
+ function calculateUpdatedTitleText( board, topicText ) {
+ // Find out if we need to change the title
+ if ( topicText !== undefined ) {
+ if ( board.$boardNavigationTitle.text() !== topicText ) {
+ // Change it
+ return topicText;
+ }
+ } else if ( board.$boardNavigationTitle.text() !== board.boardNavigationOriginalTitle ) {
+ return board.boardNavigationOriginalTitle;
+ }
+ }
+
+ // We still need to trigger movemnt when the topic title has not changed
+ // in instances where new data has been loaded.
+ topicText = calculateUpdatedTitleText( this, topicText );
+ if ( topicText !== undefined ) {
+ // We only reach this if the visible topic has changed
+ this.$boardNavigationTitle.text( topicText );
+ } else if ( extraParameters.forceNavigationUpdate !== true ) {
+ // If the visible topic has not changed and we are not forced
+ // to update(due to new items or other situations), exit early.
+ return;
+ }
+
+ this.scrollTocToActiveItem();
+ }
+
+ // Mixin to FlowComponent
+ mw.flow.mixinComponent( 'component', FlowBoardComponentBoardHeaderFeatureMixin );
+}( jQuery, mediaWiki ) );
diff --git a/Flow/modules/engine/components/board/features/flow-board-preview.js b/Flow/modules/engine/components/board/features/flow-board-preview.js
new file mode 100644
index 00000000..c706acf7
--- /dev/null
+++ b/Flow/modules/engine/components/board/features/flow-board-preview.js
@@ -0,0 +1,237 @@
+/*!
+ * @todo break this down into mixins for each callback section (eg. post actions, read topics)
+ */
+
+( function ( $, mw ) {
+ /**
+ * Binds API events to FlowBoardComponent
+ * @param {jQuery} $container
+ * @extends FlowComponent
+ * @constructor
+ */
+ function FlowBoardComponentPreviewMixin( $container ) {
+ // Bind event callbacks
+ this.bindNodeHandlers( FlowBoardComponentPreviewMixin.UI.events );
+ }
+ OO.initClass( FlowBoardComponentPreviewMixin );
+
+ /** Event handlers are stored here, but are registered in the constructor */
+ FlowBoardComponentPreviewMixin.UI = {
+ events: {
+ apiPreHandlers: {},
+ apiHandlers: {}
+ }
+ };
+
+ //
+ // pre-api callback handlers, to do things before the API call
+ //
+
+ /**
+ * First, resets the previous preview (if any).
+ * Then, using the form fields, finds the content element to be sent to Parsoid by looking
+ * for one ending in "content", or, failing that, with data-role=content.
+ * @param {Event} event The event being handled
+ * @return {Function} Callback to modify the API request
+ * @todo genericize into FlowComponent
+ */
+ FlowBoardComponentPreviewMixin.UI.events.apiPreHandlers.preview = function ( event ) {
+ var callback,
+ $this = $( this ),
+ $target = $this.findWithParent( $this.data( 'flow-api-target' ) ),
+ previewTitleGenerator = $target.data( 'flow-preview-title-generator' ),
+ previewTitle = $target.data( 'flow-preview-title' ),
+ flowBoard = mw.flow.getPrototypeMethod( 'board', 'getInstanceByElement' )( $this ),
+ schemaName = $this.data( 'flow-eventlog-schema' ),
+ funnelId = $this.data( 'flow-eventlog-funnel-id' ),
+ logAction = $this.data( 'flow-return-to-edit' ) ? 'keep-editing' : 'preview',
+ generators = {
+ newTopic: function() {
+ // Convert current timestamp to base-2
+ var namespace = mw.config.get( 'wgFormattedNamespaces' )[2600],
+ timestamp = mw.flow.baseConvert( Date.now(), 10, 2 );
+ // Pad base-2 out to 88 bits (@todo why 84?)
+ timestamp += [ 84 - timestamp.length ].join( '0' );
+ // convert base-2 to base-36
+ return namespace + ':' + mw.flow.baseConvert( timestamp, 2, 36 );
+ },
+ wgPageName: function() {
+ return mw.config.get( 'wgPageName' );
+ }
+ };
+
+ if ( !previewTitleGenerator || !generators.hasOwnProperty( previewTitleGenerator ) ) {
+ previewTitleGenerator = 'wgPageName';
+ }
+
+ flowBoard.logEvent( schemaName, { action: logAction, funnelId: funnelId } );
+
+ callback = function ( queryMap ) {
+ var content = null;
+
+ // XXX: Find the content parameter
+ $.each( queryMap, function( key, value ) {
+ var piece = key.slice( -7 );
+ if ( piece === 'content' || piece === 'summary' ) {
+ content = value;
+ return false;
+ }
+ } );
+
+ // If we fail to find a content param, look for a field that is the "content" role and use that
+ if ( content === null ) {
+ content = $this.closest( 'form' ).find( 'input, textarea' ).filter( '[data-role="content"]' ).val();
+ }
+
+ queryMap = {
+ 'action': 'flow-parsoid-utils',
+ 'from': 'wikitext',
+ 'to': 'html',
+ 'content': content
+ };
+
+ if ( previewTitle ) {
+ queryMap.title = previewTitle;
+ } else {
+ queryMap.title = generators[previewTitleGenerator]();
+ }
+
+ return queryMap;
+ };
+
+ // Reset the preview state if already in it
+ if ( flowBoard.resetPreview( $this ) ) {
+ // Special way of cancelling a request, other than returning false outright
+ callback._abort = true;
+ }
+
+ return callback;
+ };
+
+ /**
+ * Triggers a preview of the given content.
+ * @param {Object} info (status:done|fail, $target: jQuery)
+ * @param {Object} data
+ * @param {jqXHR} jqxhr
+ * @returns {$.Promise}
+ */
+ FlowBoardComponentPreviewMixin.UI.events.apiHandlers.preview = function( info, data, jqxhr ) {
+ var revision, creator,
+ $previewContainer,
+ templateParams,
+ $button = $( this ),
+ $form = $button.closest( 'form' ),
+ $cancelButton = $form.find('.mw-ui-button[data-role="cancel"]'),
+ flowBoard = mw.flow.getPrototypeMethod( 'board', 'getInstanceByElement' )( $form ),
+ $titleField = $form.find( 'input' ).filter( '[data-role=title]' ),
+ $target = info.$target,
+ username = $target.data( 'flow-creator' ) || mw.user.getName(),
+ id = Math.random(),
+ previewTemplate = $target.data( 'flow-preview-template' ),
+ contentNode = $target.data( 'flow-preview-node' ) || 'content';
+
+ if ( info.status !== 'done' ) {
+ // Error will be displayed by default, nothing else to wrap up
+ return $.Deferred().reject().promise();
+ }
+
+ creator = {
+ links: {
+ userpage: {
+ url: mw.util.getUrl( 'User:' + username ),
+ // FIXME: Assume, as we don't know at this point...
+ exists: true
+ },
+ talk: {
+ url: mw.util.getUrl( 'User talk:' + username ),
+ // FIXME: Assume, as we don't know at this point...
+ exists: true
+ },
+ contribs: {
+ url: mw.util.getUrl( 'Special:Contributions/' + username ),
+ exists: true,
+ title: username
+ }
+ },
+ name: username || flowBoard.constructor.static.TemplateEngine.l10n( 'flow-anonymous' )
+ };
+
+ revision = {
+ postId: id,
+ creator: creator,
+ replies: [ id ],
+ isPreview: true
+ };
+ templateParams = {};
+
+ // This is for most previews which expect a "revision" key
+ revision[contentNode] = {
+ content: data['flow-parsoid-utils'].content,
+ format: data['flow-parsoid-utils'].format
+ };
+ // This fixes summarize which expects a key "summary"
+ templateParams[contentNode] = revision[contentNode];
+
+ $.extend( templateParams, {
+ // This fixes titlebar which expects a key "content" for title
+ content: {
+ content: $titleField.val() || '',
+ format: 'content'
+ },
+ creator: creator,
+ posts: {},
+ // @todo don't do these. it's a catch-all for the templates which expect a revision key, and those that don't.
+ revision: revision,
+ reply_count: 1,
+ last_updated: +new Date(),
+ replies: [ id ],
+ revisions: {}
+ } );
+ templateParams.posts[id] = { 0: id };
+ templateParams.revisions[id] = revision;
+
+ // Render the preview warning
+ $previewContainer = $( flowBoard.constructor.static.TemplateEngine.processTemplateGetFragment(
+ 'flow_preview_warning.partial'
+ ) ).children();
+
+ // @todo Perhaps this should be done in each template, and not here?
+ $previewContainer.addClass( 'flow-preview' );
+
+ // Render this template with the preview data
+ $previewContainer = $previewContainer.add(
+ $( flowBoard.constructor.static.TemplateEngine.processTemplateGetFragment(
+ previewTemplate,
+ templateParams
+ ) ).children()
+ );
+
+ // Hide any input fields and anon warning
+ $form.find( 'input, textarea, .flow-anon-warning' )
+ .addClass( 'flow-preview-target-hidden' );
+
+ // Insert the new preview before the form
+ $target
+ .parent( 'form' )
+ .before( $previewContainer );
+
+ // Hide cancel button on preview screen
+ $cancelButton.hide();
+
+ // Assign the reset-preview information for later use
+ $button
+ .data( 'flow-return-to-edit', {
+ text: $button.text(),
+ $nodes: $previewContainer
+ } )
+ .text( flowBoard.constructor.static.TemplateEngine.l10n( 'flow-preview-return-edit-post' ) )
+ .one( 'click', function() {
+ $cancelButton.show();
+ } );
+
+ return $.Deferred().resolve().promise();
+ };
+
+ // Mixin to FlowBoardComponent
+ mw.flow.mixinComponent( 'component', FlowBoardComponentPreviewMixin );
+}( jQuery, mediaWiki ) );
diff --git a/Flow/modules/engine/components/board/features/flow-board-switcheditor.js b/Flow/modules/engine/components/board/features/flow-board-switcheditor.js
new file mode 100644
index 00000000..b08eb7bf
--- /dev/null
+++ b/Flow/modules/engine/components/board/features/flow-board-switcheditor.js
@@ -0,0 +1,52 @@
+/*!
+ * Handlers for the switching the editor from wikitext to visualeditor
+ */
+
+( function ( $, mw ) {
+ /**
+ * Binds handlers for switching from wikitext to visualeditor
+ *
+ * @param {jQuery} $container
+ * @this FlowComponent
+ * @constructor
+ */
+ function FlowBoardComponentSwitchEditorFeatureMixin( $container ) {
+ // Bind element handlers
+ this.bindNodeHandlers( FlowBoardComponentSwitchEditorFeatureMixin.UI.events );
+ }
+ OO.initClass( FlowBoardComponentSwitchEditorFeatureMixin );
+
+ FlowBoardComponentSwitchEditorFeatureMixin.UI = {
+ events: {
+ interactiveHandlers: {},
+ loadHandlers: {}
+ }
+ };
+
+ /**
+ * Toggle between possible editors.
+ *
+ * Currently the only options are visualeditor, and none. Visualeditor has its own
+ * code for switching, so this is only run by clicking the switch button from 'none'.
+ * If we add more editors later this will have to be revisited.
+ *
+ * @param {Event} event
+ * @returns {jQuery.Promise}
+ */
+ FlowBoardComponentSwitchEditorFeatureMixin.UI.events.interactiveHandlers.switchEditor = function ( event ) {
+ var $this = $( this ),
+ $target = $this.findWithParent( $this.data( 'flow-target' ) );
+
+ event.preventDefault();
+
+ if ( !$target.length ) {
+ mw.flow.debug( '[switchEditor] No target located' );
+ return $.Deferred().reject().promise();
+ }
+
+ return mw.flow.editor.switchEditor( $target, 'visualeditor' );
+ };
+
+ // Mixin to FlowComponent
+ mw.flow.mixinComponent( 'component', FlowBoardComponentSwitchEditorFeatureMixin );
+}( jQuery, mediaWiki ) );
diff --git a/Flow/modules/engine/components/board/features/flow-board-toc.js b/Flow/modules/engine/components/board/features/flow-board-toc.js
new file mode 100644
index 00000000..4d64730a
--- /dev/null
+++ b/Flow/modules/engine/components/board/features/flow-board-toc.js
@@ -0,0 +1,354 @@
+/*!
+ * Contains Table of Contents functionality.
+ */
+
+( function ( $, mw ) {
+ /**
+ * Binds handlers for TOC in board header.
+ * @param {jQuery} $container
+ * @this FlowComponent
+ * @constructor
+ */
+ function FlowBoardComponentTocFeatureMixin( $container ) {
+ /** Stores a list of topic IDs rendered in our TOC */
+ this.topicIdsInToc = {};
+ /** Used to define an offset to optimize fetching of TOC when we already have some items in it */
+ this.lastTopicIdInToc = null;
+
+ // Bind element handlers
+ this.bindNodeHandlers( FlowBoardComponentTocFeatureMixin.UI.events );
+ }
+ OO.initClass( FlowBoardComponentTocFeatureMixin );
+
+ FlowBoardComponentTocFeatureMixin.UI = {
+ events: {
+ apiPreHandlers: {},
+ apiHandlers: {},
+ interactiveHandlers: {},
+ loadHandlers: {}
+ }
+ };
+
+ //
+ // API pre-handlers
+ //
+
+ /**
+ * Empties out TOC menu when board is being refreshed.
+ * @param {Event} event
+ * @param {Object} info
+ * @param {jQuery} info.$target
+ * @param {FlowBoardComponent} info.component
+ */
+ function flowBoardComponentTocFeatureMixinBoardApiPreHandler( event, info ) {
+ info.component.topicIdsInTocBackup = info.component.topicIdsInToc;
+ info.component.lastTopicIdInTocBackup = info.component.lastTopicIdInToc;
+
+ info.component.topicIdsInToc = {};
+ info.component.lastTopicIdInToc = null;
+
+ if ( info.component.$tocMenu ) {
+ info.component.$tocMenuChildBackup = info.component.$tocMenu.children().detach();
+ info.component.$tocMenuBackup = info.component.$tocMenu;
+ info.component.$tocMenu = null;
+ }
+ }
+ FlowBoardComponentTocFeatureMixin.UI.events.apiPreHandlers.board = flowBoardComponentTocFeatureMixinBoardApiPreHandler;
+
+ /**
+ *
+ * @param {Event} event
+ * @param {Object} info
+ * @param {jQuery} info.$target
+ * @param {FlowBoardComponent} info.component
+ */
+ function flowBoardComponentTocFeatureMixinTopicListApiPreHandler( event, info, extraParameters ) {
+ var $this = $( this ),
+ isLoadMoreButton = $this.data( 'flow-load-handler' ) === 'loadMore',
+ overrides;
+
+ if ( !isLoadMoreButton && !( extraParameters || {} ).skipMenuToggle ) {
+ // Re-scroll the TOC (in case the scroll that tracks the page scroll failed
+ // due to insufficient elements making the desired scrollTop not work (T78572)).
+ info.component.scrollTocToActiveItem();
+
+ // Actually open/close the TOC menu on this node.
+ $this.trigger( 'click', { interactiveHandler: 'menuToggle' } );
+ }
+
+ if ( !isLoadMoreButton && info.component.doneInitialTocApiCall ) {
+ // Triggers load more if we didn't load enough content to fill the viewport
+ info.$target.trigger( 'scroll.flow-load-more', { forceNavigationUpdate: true } );
+ return false;
+ }
+
+ // Send some overrides to this API request
+ overrides = {
+ topiclist_sortby: info.component.$board.data( 'flow-sortby' ),
+ topiclist_limit: 50,
+ topiclist_toconly: true
+ };
+
+ // @todo verify that this works
+ //if ( info.component.lastTopicIdInToc ) {
+ // overrides.topiclist_offset = false;
+ // overrides['topiclist_offset-id'] = info.component.lastTopicIdInToc;
+ //}
+
+ if ( !overrides.topiclist_sortby ) {
+ delete overrides.topiclist_sortby;
+ }
+
+ return overrides;
+ }
+ FlowBoardComponentTocFeatureMixin.UI.events.apiPreHandlers.topicList = flowBoardComponentTocFeatureMixinTopicListApiPreHandler;
+
+ //
+ // API handlers
+ //
+
+ /**
+ * Restores TOC stuff if board reload fails.
+ * @param {Object} info
+ * @param {string} info.status "done" or "fail"
+ * @param {jQuery} info.$target
+ * @param {FlowBoardComponent} info.component
+ * @param {Object} data
+ * @param {jqXHR} jqxhr
+ */
+ function flowBoardComponentTocFeatureMixinBoardApiCallback( info, data, jqxhr ) {
+ if ( info.status !== 'done' ) {
+ // Failed; restore the topic data
+ info.component.topicIdsInToc = info.component.topicIdsInTocBackup;
+ info.component.lastTopicIdInToc = info.component.lastTopicIdInTocBackup;
+ if ( info.component.$tocMenuBackup ) {
+ info.component.$tocMenu = info.component.$tocMenuBackup;
+ }
+
+ if ( info.component.$tocMenu ) {
+ info.component.$tocMenu.append( info.component.$tocMenuChildBackup );
+ }
+ }
+
+ // Delete the backups
+ delete info.component.topicIdsInTocBackup;
+ delete info.component.lastTopicIdInTocBackup;
+ info.component.$tocMenuChildBackup.remove();
+ delete info.component.$tocMenuChildBackup;
+ delete info.component.$tocMenuBackup;
+
+ // Allow reopening
+ info.component.doneInitialTocApiCall = false;
+ }
+ FlowBoardComponentTocFeatureMixin.UI.events.apiHandlers.board = flowBoardComponentTocFeatureMixinBoardApiCallback;
+
+ /**
+ * The actual storage is in FlowBoardComponentLoadMoreFeatureMixin.UI.events.apiHandlers.topicList.
+ * @param {Object} info
+ * @param {string} info.status "done" or "fail"
+ * @param {jQuery} info.$target
+ * @param {Object} data
+ * @param {jqXHR} jqxhr
+ */
+ function flowBoardComponentTocFeatureMixinTopicListApiHandler( info, data, jqxhr ) {
+ if ( info.status !== 'done' ) {
+ // Error will be displayed by default, nothing else to wrap up
+ return;
+ }
+
+ var $kids, i,
+ $this = $( this ),
+ template = info.component.tocTemplate,
+ topicsData = data.flow['view-topiclist'].result.topiclist,
+ isLoadMoreButton = $this.data( 'flow-load-handler' ) === 'loadMore';
+
+ // Iterate over every topic
+ for ( i = 0; i < topicsData.roots.length; i++ ) {
+ // Do this until we find a topic that is not in our TOC
+ if ( info.component.topicIdsInToc[ topicsData.roots[ i ] ] ) {
+ // And then remove all the ones that are already in our TOC
+ topicsData.roots.splice( i, 1 );
+ i--;
+ } else {
+ // For any other subsequent IDs, just mark them as being in the TOC now
+ info.component.topicIdsInToc[ topicsData.roots[ i ] ] = true;
+ }
+ }
+
+ if ( topicsData.roots.length ) {
+ // Store the last topic ID for optimal offset use
+ info.component.lastTopicIdInToc = topicsData.roots[i];
+ } // render even if we have no roots, because another load-more button could appear
+
+ // Render the topic titles
+ $kids = $( mw.flow.TemplateEngine.processTemplateGetFragment(
+ template,
+ topicsData
+ ) ).children();
+
+ if ( isLoadMoreButton ) {
+ // Insert the new topic titles
+ info.$target.replaceWith( $kids );
+ } else {
+ // Prevent this API call from happening again
+ info.component.doneInitialTocApiCall = true;
+
+ // Insert the new topic titles
+ info.$target.append( $kids );
+ }
+
+ info.component.emitWithReturn( 'makeContentInteractive', $kids );
+
+ if ( isLoadMoreButton ) {
+ // Remove the old load button (necessary if the above load_more template returns nothing)
+ $this.remove();
+ }
+
+ // Triggers load more if we didn't load enough content to fill the viewport
+ $kids.trigger( 'scroll.flow-load-more', { forceNavigationUpdate: true } );
+
+ }
+ FlowBoardComponentTocFeatureMixin.UI.events.apiHandlers.topicList = flowBoardComponentTocFeatureMixinTopicListApiHandler;
+
+ //
+ // On element-click handlers
+ //
+
+ /**
+ *
+ * @param {Event} event
+ */
+ function flowBoardComponentTocFeatureMixinJumpToTopicCallback( event ) {
+ var $this = $( this ),
+ flowBoard = mw.flow.getPrototypeMethod( 'board', 'getInstanceByElement' )( $this );
+
+ event.preventDefault();
+
+ // Load and scroll to topic
+ flowBoard.jumpToTopic( $this.data( 'flow-id' ) );
+ }
+ FlowBoardComponentTocFeatureMixin.UI.events.interactiveHandlers.jumpToTopic = flowBoardComponentTocFeatureMixinJumpToTopicCallback;
+
+ //
+ // On element-load handlers
+ //
+
+ // This is a confusing name since this.$tocMenu is set to flow-board-toc-list, whereas you
+ // would expect flow-board-toc-menu.
+ /**
+ * Stores the TOC menu for later use.
+ * @param {jQuery} $tocMenu
+ */
+ function flowBoardComponentTocFeatureTocMenuLoadCallback( $tocMenu ) {
+ this.$tocMenu = $tocMenu;
+ this.tocTarget = $tocMenu.data( 'flow-toc-target' );
+ this.tocTemplate = $tocMenu.data( 'flow-template' );
+ }
+ FlowBoardComponentTocFeatureMixin.UI.events.loadHandlers.tocMenu = flowBoardComponentTocFeatureTocMenuLoadCallback;
+
+ /**
+ * Checks if this title is already in TOC, and if not, adds it to the end of the stack.
+ * @param {jQuery} $topicTitle
+ */
+ function flowBoardComponentTocFeatureElementLoadTopicTitle( $topicTitle ) {
+ var currentTopicId, topicData, $kids, $target;
+
+ if ( !this.$tocMenu ) {
+ // No TOC (expected if we're on Topic page)
+
+ return;
+ }
+
+ currentTopicId = $topicTitle.closest( '[data-flow-id]' ).data( 'flowId' );
+ topicData = {
+ posts: {},
+ revisions: {},
+ roots: [ currentTopicId ],
+ noLoadMore: true
+ };
+
+ if ( !this.topicIdsInToc[ currentTopicId ] ) {
+ // If we get in here, this must have been loaded by topics infinite scroll and NOT by jumpTo
+ this.topicIdsInToc[ currentTopicId ] = true;
+ this.lastTopicIdInToc = currentTopicId;
+
+ // Magic to set the revision data
+ topicData.posts[ currentTopicId ] = [ currentTopicId ];
+ topicData.revisions[ currentTopicId ] = {
+ content: {
+ content: $topicTitle.data( 'flow-topic-title' ),
+ format: 'plaintext'
+ }
+ };
+
+ // Render the topic title
+ $kids = $( mw.flow.TemplateEngine.processTemplateGetFragment(
+ this.tocTemplate,
+ topicData
+ ) ).children();
+
+ // Find out where/how to insert the title
+ $target = $.findWithParent( this.$tocMenu, this.tocTarget );
+ if ( !$target.length ) {
+ this.$tocMenu.append( $kids );
+ } else {
+ $target.after( $kids );
+ }
+
+ this.emitWithReturn( 'makeContentInteractive', $kids );
+ }
+ }
+ FlowBoardComponentTocFeatureMixin.UI.events.loadHandlers.topicTitle = flowBoardComponentTocFeatureElementLoadTopicTitle;
+
+ //
+ // Public functions
+ //
+
+ /**
+ * Scroll the TOC to the active item
+ */
+ function flowBoardComponentTocFeatureScrollTocToActiveItem() {
+ // Set TOC active item
+ var $tocContainer = this.$tocMenu,
+ requestedScrollTop, afterScrollTop, // For debugging
+ $scrollTarget = $tocContainer.find( 'a[data-flow-id]' )
+ .removeClass( 'active' )
+ .filter( '[data-flow-id=' + this.readingTopicId + ']' )
+ .addClass( 'active' )
+ .closest( 'li' )
+ .next();
+
+ if ( !$scrollTarget.length ) {
+ // we are at the last list item; use the current one instead
+ $scrollTarget = $scrollTarget.end();
+ }
+ // Scroll to the active item
+ if ( $scrollTarget.length ) {
+ requestedScrollTop = $scrollTarget.offset().top - $tocContainer.offset().top + $tocContainer.scrollTop();
+ $tocContainer.scrollTop( requestedScrollTop );
+ afterScrollTop = $tocContainer.scrollTop();
+ // the above may not trigger the scroll.flow-load-more event within the TOC if the $tocContainer
+ // does not have a scrollbar. If that happens you could have a TOC without a scrollbar
+ // that refuses to autoload anything else. Fire it again(wasteful) untill we find
+ // a better way.
+ // This does not seem to work for the initial load, that is handled in flow-boad-loadmore.js
+ // when it runs this same code. This seems to be required for subsequent loads after
+ // the initial call.
+ if ( this.$loadMoreNodes ) {
+ this.$loadMoreNodes
+ .filter( '[data-flow-api-handler=topicList]' )
+ .trigger( 'scroll.flow-load-more', { forceNavigationUpdate: true } );
+ }
+ }
+
+ }
+
+ FlowBoardComponentTocFeatureMixin.prototype.scrollTocToActiveItem = flowBoardComponentTocFeatureScrollTocToActiveItem;
+
+ //
+ // Private functions
+ //
+
+ // Mixin to FlowComponent
+ mw.flow.mixinComponent( 'component', FlowBoardComponentTocFeatureMixin );
+}( jQuery, mediaWiki ) );
diff --git a/Flow/modules/engine/components/board/features/flow-board-visualeditor.js b/Flow/modules/engine/components/board/features/flow-board-visualeditor.js
new file mode 100644
index 00000000..0fcb319a
--- /dev/null
+++ b/Flow/modules/engine/components/board/features/flow-board-visualeditor.js
@@ -0,0 +1,37 @@
+/*!
+ * Expose some functionality on the board object that is needed for VisualEditor.
+ */
+
+( function ( $, mw, OO ) {
+ /**
+ * FlowBoardComponentVisualEditorFeatureMixin
+ *
+ * @this FlowBoardComponent
+ * @constructor
+ *
+ */
+ function FlowBoardComponentVisualEditorFeatureMixin( $container ) {
+ }
+
+ // This is not really VE-specific, but I'm not sure where best to put it.
+ // Also, should we pre-compute this in a loadHandler?
+ /**
+ * Finds topic authors for the given node
+ *
+ * @return Array List of usernames
+ */
+ function flowVisualEditorGetTopicPosters( $node ) {
+ var $topic = $node.closest( '.flow-topic' ),
+ duplicatedArray;
+
+ // Could use a data attribute to avoid trim.
+ duplicatedArray = $.map( $topic.find( '.flow-author .mw-userlink' ).get(), function ( el ) {
+ return $.trim( $( el ).text() );
+ } );
+ return OO.unique( duplicatedArray );
+ }
+
+ FlowBoardComponentVisualEditorFeatureMixin.prototype.getTopicPosters = flowVisualEditorGetTopicPosters;
+
+ mw.flow.mixinComponent( 'board', FlowBoardComponentVisualEditorFeatureMixin );
+}( jQuery, mediaWiki, OO ) );
diff --git a/Flow/modules/engine/components/board/flow-board.js b/Flow/modules/engine/components/board/flow-board.js
new file mode 100644
index 00000000..6f3839ae
--- /dev/null
+++ b/Flow/modules/engine/components/board/flow-board.js
@@ -0,0 +1,196 @@
+/*!
+ * Contains the base constructor for FlowBoardComponent.
+ * @todo Clean up the remaining code that may not need to be here.
+ */
+
+( function ( $, mw ) {
+ /**
+ * Constructor class for instantiating a new Flow board.
+ * @example <div class="flow-component" data-flow-component="board" data-flow-id="rqx495tvz888x5ur">...</div>
+ * @param {jQuery} $container
+ * @extends FlowBoardAndHistoryComponentBase
+ * @mixins FlowComponentEventsMixin
+ * @mixins FlowComponentEnginesMixin
+ * @mixins FlowBoardComponentApiEventsMixin
+ * @mixins FlowBoardComponentInteractiveEventsMixin
+ * @mixins FlowBoardComponentLoadEventsMixin
+ * @mixins FlowBoardComponentMiscMixin
+ * @mixins FlowBoardComponentLoadMoreFeatureMixin
+ * @mixins FlowBoardComponentVisualEditorFeatureMixin
+ *
+ * @constructor
+ */
+ function FlowBoardComponent( $container ) {
+ var uri = new mw.Uri( location.href ),
+ uid = String( location.hash.match( /[0-9a-z]{16,19}$/i ) || '' );
+
+ // Default API submodule for FlowBoard URLs is to fetch a topiclist
+ this.Api.setDefaultSubmodule( 'view-topiclist' );
+
+ // Set up the board
+ if ( this.reinitializeContainer( $container ) === false ) {
+ // Failed to init for some reason
+ return false;
+ }
+
+ // Handle URL parameters
+ if ( uid ) {
+ if ( uri.query.fromnotif ) {
+ _flowHighlightPost( $container, uid, 'newer' );
+ } else {
+ _flowHighlightPost( $container, uid );
+ }
+ }
+
+ _overrideWatchlistNotification();
+ }
+ OO.initClass( FlowBoardComponent );
+
+ // Register
+ mw.flow.registerComponent( 'board', FlowBoardComponent, 'boardAndHistoryBase' );
+
+ //
+ // Methods
+ //
+
+ /**
+ * Sets up the board and base properties on this class.
+ * Returns either FALSE for failure, or jQuery object of old nodes that were replaced.
+ * @param {jQuery|boolean} $container
+ * @return {Boolean|jQuery}
+ */
+ function flowBoardComponentReinitializeContainer( $container ) {
+ if ( $container === false ) {
+ return false;
+ }
+
+ // Trigger this on FlowBoardAndHistoryComponentBase
+ // @todo use EventEmitter to do this?
+ var $retObj = FlowBoardComponent.parent.prototype.reinitializeContainer.call( this, $container ),
+ // Find any new (or previous) elements
+ $header = $container.find( '.flow-board-header' ).addBack().filter( '.flow-board-header:first' ),
+ $boardNavigation = $container.find( '.flow-board-navigation' ).addBack().filter( '.flow-board-navigation:first' ),
+ $board = $container.find( '.flow-board' ).addBack().filter( '.flow-board:first' );
+
+ if ( $retObj === false ) {
+ return false;
+ }
+
+ // Remove any of the old elements that are still in use
+ if ( $header.length ) {
+ if ( this.$header ) {
+ $retObj = $retObj.add( this.$header.replaceWith( $header ) );
+ this.$header.remove();
+ }
+
+ this.$header = $header;
+ }
+ if ( $boardNavigation.length ) {
+ if ( this.$boardNavigation ) {
+ $retObj = $retObj.add( this.$boardNavigation.replaceWith( $boardNavigation ) );
+ this.$boardNavigation.remove();
+ }
+
+ this.$boardNavigation = $boardNavigation;
+ }
+ if ( $board.length ) {
+ if ( this.$board ) {
+ $retObj = $retObj.add( this.$board.replaceWith( $board ) );
+ this.$board.remove();
+ }
+
+ this.$board = $board;
+ }
+
+ // Second, verify that this board in fact exists
+ if ( !this.$board || !this.$board.length ) {
+ // You need a board, dammit!
+ this.debug( 'Could not find .flow-board', arguments );
+ return false;
+ }
+
+ this.emitWithReturn( 'makeContentInteractive', this );
+
+ // Initialize editors, turning them from textareas into editor objects
+ if ( typeof this.editorTimer === 'undefined' ) {
+ /*
+ * When this method is first run, all page elements are initialized.
+ * We probably don't need editor immediately, so defer loading it
+ * to speed up the rest of the work that needs to be done.
+ */
+ this.editorTimer = setTimeout( $.proxy( function ( $container ) { this.emitWithReturn( 'initializeEditors', $container ); }, this, $container ), 20000 );
+ } else {
+ /*
+ * Subsequent calls here (e.g. when rendering the edit header form)
+ * should immediately initialize the editors!
+ */
+ clearTimeout( this.editorTimer );
+ this.emitWithReturn( 'initializeEditors', $container );
+ }
+
+ return $retObj;
+ }
+ FlowBoardComponent.prototype.reinitializeContainer = flowBoardComponentReinitializeContainer;
+
+ //
+ // Private functions
+ //
+
+ /**
+ * Helper receives
+ * @param {jQuery} $container
+ * @param {string} uid
+ * @param {string} option
+ * @return {jQuery}
+ */
+ function _flowHighlightPost( $container, uid, option ) {
+ var $target = $container.find( '#flow-post-' + uid );
+
+ // reset existing highlights
+ $container.find( '.flow-post-highlighted' ).removeClass( 'flow-post-highlighted' );
+
+ if ( option === 'newer' ) {
+ $target.addClass( 'flow-post-highlight-newer' );
+ if ( uid ) {
+ $container.find( '.flow-post' ).each( function( idx, el ) {
+ var $el = $( el ),
+ id = $el.data( 'flow-id' );
+ if ( id && id > uid ) {
+ $el.addClass( 'flow-post-highlight-newer' );
+ }
+ } );
+ }
+ } else {
+ $target.addClass( 'flow-post-highlighted' );
+ }
+
+ return $target;
+ }
+
+ /**
+ * We want the default behavior of watch/unwatch for page. However, we
+ * do want to show our own tooltip after this has happened.
+ * We'll override mw.notify, which is fired after successfully
+ * (un)watchlisting, to stop the notification from being displayed.
+ * If the action we just intercepted was after succesful watching, we'll
+ * want to show our own tooltip instead.
+ */
+ function _overrideWatchlistNotification() {
+ var _notify = mw.notify;
+ mw.notify = function( message, options ) {
+ // override message when we've just watched the board
+ if ( options.tag === 'watch-self' && $( '#ca-watch' ).length ) {
+ // Render a div telling the user that they have subscribed
+ message = $( mw.flow.TemplateEngine.processTemplateGetFragment(
+ 'flow_subscribed.partial',
+ {
+ type: 'board',
+ username: mw.user.getName()
+ }
+ ) ).children();
+ }
+
+ _notify.apply( this, arguments );
+ };
+ }
+}( jQuery, mediaWiki ) );
diff --git a/Flow/modules/engine/components/board/flow-boardhistory.js b/Flow/modules/engine/components/board/flow-boardhistory.js
new file mode 100644
index 00000000..b9f63d07
--- /dev/null
+++ b/Flow/modules/engine/components/board/flow-boardhistory.js
@@ -0,0 +1,59 @@
+/*!
+ *
+ */
+
+( function ( $, mw ) {
+ /**
+ *
+ * @example <div class="flow-component" data-flow-component="boardHistory" data-flow-id="rqx495tvz888x5ur">...</div>
+ * @param {jQuery} $container
+ * @extends FlowBoardAndHistoryComponentBase
+ * @constructor
+ */
+ function FlowBoardHistoryComponent( $container ) {
+ this.bindNodeHandlers( FlowBoardHistoryComponent.UI.events );
+ }
+ OO.initClass( FlowBoardHistoryComponent );
+
+ FlowBoardHistoryComponent.UI = {
+ events: {
+ apiHandlers: {}
+ }
+ };
+
+ mw.flow.registerComponent( 'boardHistory', FlowBoardHistoryComponent, 'boardAndHistoryBase' );
+
+ //
+ // API handlers
+ //
+
+ /**
+ * After submit of a moderation form, process the response.
+ *
+ * @param {Object} info
+ * @param {string} info.status "done" or "fail"
+ * @param {jQuery} info.$target
+ * @param {Object} data
+ * @param {jqXHR} jqxhr
+ * @returns {$.Promise}
+ */
+ function flowBoardHistoryModerationCallback( info, data, jqxhr ) {
+ if ( info.status !== 'done' ) {
+ // Error will be displayed by default, nothing else to wrap up
+ return $.Deferred().reject().promise();
+ }
+
+ var flowBoardHistory = mw.flow.getPrototypeMethod( 'boardHistory', 'getInstanceByElement' )( $( this ) );
+
+ // Clear the form so we can refresh without the confirmation dialog
+ flowBoardHistory.emitWithReturn( 'cancelForm', $( this ).closest( 'form' ) );
+
+ // @todo implement dynamic updating of the history page instead of this
+ location.reload();
+
+ return $.Deferred().resolve().promise();
+ }
+
+ FlowBoardHistoryComponent.UI.events.apiHandlers.moderateTopic = flowBoardHistoryModerationCallback;
+ FlowBoardHistoryComponent.UI.events.apiHandlers.moderatePost = flowBoardHistoryModerationCallback;
+}( jQuery, mediaWiki ) );
diff --git a/Flow/modules/engine/components/common/flow-component-engines.js b/Flow/modules/engine/components/common/flow-component-engines.js
new file mode 100644
index 00000000..3d8a9282
--- /dev/null
+++ b/Flow/modules/engine/components/common/flow-component-engines.js
@@ -0,0 +1,39 @@
+/*!
+ * Initializes StorageEngine (Storer), TemplateEngine (Handlebars), and API (FlowApi).
+ */
+
+( function ( $, mw, initStorer ) {
+ /**
+ * Initializes Storer, Handlebars, and FlowApi.
+ * @constructor
+ */
+ function FlowComponentEnginesMixin() {}
+ OO.initClass( FlowComponentEnginesMixin );
+
+ /**
+ * Contains Storer.js's (fallback) storage engines.
+ * @type {{ cookieStorage: Storer.cookieStorage, memoryStorage: Storer.memoryStorage, sessionStorage: Storer.sessionStorage, localStorage: Storer.localStorage }}
+ */
+ mw.flow.StorageEngine = FlowComponentEnginesMixin.static.StorageEngine = initStorer( { 'prefix': '_WMFLOW_' } );
+
+ /**
+ * Contains the Flow templating engine translation class (in case we change templating engines).
+ * @type {FlowHandlebars}
+ */
+ mw.flow.TemplateEngine = FlowComponentEnginesMixin.static.TemplateEngine = new mw.flow.FlowHandlebars( FlowComponentEnginesMixin.static.StorageEngine );
+
+ /**
+ * Flow API singleton
+ * @type {FlowApi}
+ */
+ mw.flow.Api = new mw.flow.FlowApi( FlowComponentEnginesMixin.static.StorageEngine );
+
+ /**
+ * EventLogging wrapper
+ * @type {FlowEventLog}
+ */
+ mw.flow.EventLog = mw.flow.FlowEventLog;
+
+ // Copy static and prototype from mixin to main class
+ mw.flow.mixinComponent( 'component', FlowComponentEnginesMixin );
+}( jQuery, mediaWiki, mediaWiki.flow.vendor.initStorer ) );
diff --git a/Flow/modules/engine/components/common/flow-component-events.js b/Flow/modules/engine/components/common/flow-component-events.js
new file mode 100644
index 00000000..f3b97701
--- /dev/null
+++ b/Flow/modules/engine/components/common/flow-component-events.js
@@ -0,0 +1,917 @@
+/*!
+ * Contains the code which registers and handles event callbacks.
+ * In addition, it contains some common callbacks (eg. apiRequest)
+ * @todo Find better places for a lot of the callbacks that have been placed here
+ */
+
+( function ( $, mw ) {
+ var _isGlobalBound;
+
+ /**
+ * This implements functionality for being able to capture the return value from a called event.
+ * In addition, this handles Flow event triggering and binding.
+ * @extends oo.EventEmitter
+ * @constructor
+ */
+ function FlowComponentEventsMixin( $container ) {
+ var self = this;
+
+ /**
+ * Stores event callbacks.
+ */
+ this.UI = {
+ events: {
+ globalApiPreHandlers: {},
+ apiPreHandlers: {},
+ apiHandlers: {},
+ interactiveHandlers: {},
+ loadHandlers: {}
+ }
+ };
+
+ // Init EventEmitter
+ OO.EventEmitter.call( this );
+
+ // Bind events to this instance
+ this.bindComponentHandlers( FlowComponentEventsMixin.eventHandlers );
+
+ // Bind element handlers
+ this.bindNodeHandlers( FlowComponentEventsMixin.UI.events );
+
+ // Container handlers
+ // @todo move some to FlowBoardComponent events, rename the others to FlowComponent
+ $container
+ .off( '.FlowBoardComponent' )
+ .on(
+ 'click.FlowBoardComponent keypress.FlowBoardComponent',
+ 'a, input, button, .flow-click-interactive',
+ this.getDispatchCallback( 'interactiveHandler' )
+ )
+ .on(
+ 'focusin.FlowBoardComponent',
+ 'a, input, button, .flow-click-interactive',
+ this.getDispatchCallback( 'interactiveHandlerFocus' )
+ )
+ .on(
+ 'focusin.FlowBoardComponent',
+ 'input.mw-ui-input, textarea',
+ this.getDispatchCallback( 'focusField' )
+ )
+ .on(
+ 'click.FlowBoardComponent keypress.FlowBoardComponent',
+ '[data-flow-eventlog-action]',
+ this.getDispatchCallback( 'eventLogHandler' )
+ );
+
+ if ( _isGlobalBound ) {
+ // Don't bind window.scroll again.
+ return;
+ }
+ _isGlobalBound = true;
+
+ // Handle scroll and resize events globally
+ $( window )
+ .on(
+ // Normal scroll events on elements do not bubble. However, if they
+ // are triggered, jQuery will do so. To avoid this affecting the
+ // global scroll handler, trigger scroll events on elements only with
+ // scroll.flow-something, where 'something' is not 'window-scroll'.
+ 'scroll.flow-window-scroll',
+ $.throttle( 50, function ( evt ) {
+ if ( evt.target !== window && evt.target !== document ) {
+ throw new Error( 'Target is "' + evt.target.nodeName + '", not window or document.' );
+ }
+
+ self.getDispatchCallback( 'windowScroll' ).apply( self, arguments );
+ } )
+ )
+ .on(
+ 'resize.flow',
+ $.throttle( 50, this.getDispatchCallback( 'windowResize' ) )
+ );
+ }
+ OO.mixinClass( FlowComponentEventsMixin, OO.EventEmitter );
+
+ FlowComponentEventsMixin.eventHandlers = {};
+ FlowComponentEventsMixin.UI = {
+ events: {
+ interactiveHandlers: {}
+ }
+ };
+
+ //
+ // Prototype methods
+ //
+
+ /**
+ * Same as OO.EventEmitter.emit, except that it returns an array of results.
+ * If something returns false (or an object with _abort:true), we stop processing the rest of the callbacks, if any.
+ * @param {String} event Name of the event to trigger
+ * @param {...*} [args] Arguments to pass to event callback
+ * @returns {Array}
+ */
+ function emitWithReturn( event, args ) {
+ var i, len, binding, bindings, method,
+ returns = [], retVal;
+
+ if ( event in this.bindings ) {
+ // Slicing ensures that we don't get tripped up by event handlers that add/remove bindings
+ bindings = this.bindings[event].slice();
+ args = Array.prototype.slice.call( arguments, 1 );
+ for ( i = 0, len = bindings.length; i < len; i++ ) {
+ binding = bindings[i];
+
+ if ( typeof binding.method === 'string' ) {
+ // Lookup method by name (late binding)
+ method = binding.context[ binding.method ];
+ } else {
+ method = binding.method;
+ }
+
+ // Call function
+ retVal = method.apply(
+ binding.context || this,
+ binding.args ? binding.args.concat( args ) : args
+ );
+
+ // Add this result to our list of return vals
+ returns.push( retVal );
+
+ if ( retVal === false || ( retVal && retVal._abort === true ) ) {
+ // Returned false; stop running callbacks
+ break;
+ }
+ }
+ return returns;
+ }
+ return [];
+ }
+ FlowComponentEventsMixin.prototype.emitWithReturn = emitWithReturn;
+
+ /**
+ *
+ * @param {Object} handlers
+ */
+ function bindFlowComponentHandlers( handlers ) {
+ var self = this;
+
+ // Bind class event handlers, triggered by .emit
+ $.each( handlers, function ( key, fn ) {
+ self.on( key, function () {
+ // Trigger callback with class instance context
+ try {
+ return fn.apply( self, arguments );
+ } catch ( e ) {
+ mw.flow.debug( 'Error in component handler:', key, e, arguments );
+ return false;
+ }
+ } );
+ } );
+ }
+ FlowComponentEventsMixin.prototype.bindComponentHandlers = bindFlowComponentHandlers;
+
+ /**
+ * handlers can have keys globalApiPreHandlers, apiPreHandlers, apiHandlers, interactiveHandlers, loadHandlers
+ * @param {Object} handlers
+ */
+ function bindFlowNodeHandlers( handlers ) {
+ var self = this;
+
+ // eg. { interactiveHandlers: { foo: Function } }
+ $.each( handlers, function ( type, callbacks ) {
+ // eg. { foo: Function }
+ $.each( callbacks, function ( name, fn ) {
+ // First time for this callback name, instantiate the callback list
+ if ( !self.UI.events[type][name] ) {
+ self.UI.events[type][name] = [];
+ }
+ if ( $.isArray( fn ) ) {
+ // eg. UI.events.interactiveHandlers.foo concat [Function, Function];
+ self.UI.events[type][name] = self.UI.events[type][name].concat( fn );
+ } else {
+ // eg. UI.events.interactiveHandlers.foo = [Function];
+ self.UI.events[type][name].push( fn );
+ }
+ } );
+ } );
+ }
+ FlowComponentEventsMixin.prototype.bindNodeHandlers = bindFlowNodeHandlers;
+
+ /**
+ * Returns a callback function which passes off arguments to the emitter.
+ * This only exists to clean up the FlowComponentEventsMixin constructor,
+ * by preventing it from having too many anonymous functions.
+ * @param {String} name
+ * @returns {Function}
+ * @private
+ */
+ function flowComponentGetDispatchCallback( name ) {
+ var context = this;
+
+ return function () {
+ var args = Array.prototype.slice.call( arguments, 0 );
+
+ // Add event name as first arg of emit
+ args.unshift( name );
+
+ return context.emitWithReturn.apply( context, args );
+ };
+ }
+ FlowComponentEventsMixin.prototype.getDispatchCallback = flowComponentGetDispatchCallback;
+
+ //
+ // Static methods
+ //
+
+ /**
+ * Utility to get error message for API result.
+ *
+ * @param string code
+ * @param Object result
+ * @returns string
+ */
+ function flowGetApiErrorMessage( code, result ) {
+ if ( result.error && result.error.info ) {
+ return result.error.info;
+ } else {
+ if ( code === 'http' ) {
+ // XXX: some network errors have English info in result.exception and result.textStatus.
+ return mw.msg( 'flow-error-http' );
+ } else {
+ return mw.msg( 'flow-error-external', code );
+ }
+ }
+ }
+ FlowComponentEventsMixin.static.getApiErrorMessage = flowGetApiErrorMessage;
+
+ //
+ // Interactive Handlers
+ //
+
+ /**
+ * Triggers an API request based on URL and form data, and triggers the callbacks based on flow-api-handler.
+ * @example <a data-flow-interactive-handler="apiRequest" data-flow-api-handler="loadMore" data-flow-api-target="< .flow-component div" href="...">...</a>
+ * @param {Event} event
+ * @returns {$.Promise}
+ */
+ function flowEventsMixinApiRequestInteractiveHandler( event ) {
+ var $deferred,
+ $handlerDeferred,
+ handlerPromises = [],
+ $target,
+ preHandlerReturn,
+ self = event.currentTarget || event.delegateTarget || event.target,
+ $this = $( self ),
+ flowComponent = mw.flow.getPrototypeMethod( 'component', 'getInstanceByElement' )( $this ),
+ dataParams = $this.data(),
+ handlerName = dataParams.flowApiHandler,
+ preHandlerReturns = [],
+ info = {
+ $target: null,
+ status: null,
+ component: flowComponent
+ },
+ args = Array.prototype.slice.call( arguments, 0 );
+
+ event.preventDefault();
+
+ // Find the target node
+ if ( dataParams.flowApiTarget ) {
+ // This fn supports finding parents
+ $target = $this.findWithParent( dataParams.flowApiTarget );
+ }
+ if ( !$target || !$target.length ) {
+ // Assign a target node if none
+ $target = $this;
+ }
+
+ info.$target = $target;
+ args.splice( 1, 0, info ); // insert info into args for prehandler
+
+ // Make sure an API call is not already in progress for this target
+ if ( $target.closest( '.flow-api-inprogress' ).length ) {
+ flowComponent.debug( false, 'apiRequest already in progress', arguments );
+ return $.Deferred().reject().promise();
+ }
+
+ // Mark the target node as "in progress" to disallow any further API calls until it finishes
+ $target.addClass( 'flow-api-inprogress' );
+ $this.addClass( 'flow-api-inprogress' );
+
+ // Let generic pre-handler take care of edit conflicts
+ $.each( flowComponent.UI.events.globalApiPreHandlers, function( key, callbackArray ) {
+ $.each( callbackArray, function ( i, callbackFn ) {
+ preHandlerReturns.push( callbackFn.apply( self, args ) );
+ } );
+ } );
+
+ // We'll return a deferred object that won't resolve before apiHandlers
+ // are resolved
+ $handlerDeferred = $.Deferred();
+
+ // Use the pre-callback to find out if we should process this
+ if ( flowComponent.UI.events.apiPreHandlers[ handlerName ] ) {
+ // apiPreHandlers can return FALSE to prevent processing,
+ // nothing at all to proceed,
+ // or OBJECT to add param overrides to the API
+ // or FUNCTION to modify API params
+ $.each( flowComponent.UI.events.apiPreHandlers[ handlerName ], function ( i, callbackFn ) {
+ preHandlerReturn = callbackFn.apply( self, args );
+ preHandlerReturns.push( preHandlerReturn );
+
+ if ( preHandlerReturn === false || ( preHandlerReturn && preHandlerReturn._abort === true ) ) {
+ // Callback returned false; break out of this loop
+ return false;
+ }
+ } );
+
+ if ( preHandlerReturn === false || ( preHandlerReturn && preHandlerReturn._abort === true ) ) {
+ // Last callback returned false
+ flowComponent.debug( false, 'apiPreHandler returned false', handlerName, args );
+
+ // Abort any old request in flight; this is normally done automatically by requestFromNode
+ flowComponent.Api.abortOldRequestFromNode( self, null, null, preHandlerReturns );
+
+ // @todo support for multiple indicators on same target
+ $target.removeClass( 'flow-api-inprogress' );
+ $this.removeClass( 'flow-api-inprogress' );
+
+ return $.Deferred().reject().promise();
+ }
+ }
+
+ // Make the request
+ $deferred = flowComponent.Api.requestFromNode( self, preHandlerReturns );
+ if ( !$deferred ) {
+ mw.flow.debug( '[FlowApi] [interactiveHandlers] apiRequest element is not anchor or form element' );
+ $deferred = $.Deferred();
+ $deferred.rejectWith( { error: { info: 'Not an anchor or form' } } );
+ }
+
+ // Remove the load indicator
+ $deferred.always( function () {
+ // @todo support for multiple indicators on same target
+ $target.removeClass( 'flow-api-inprogress' );
+ $this.removeClass( 'flow-api-inprogress' );
+ } );
+
+ // Remove existing errors from previous attempts
+ flowComponent.emitWithReturn( 'removeError', $this );
+
+ // We'll return a deferred object that won't resolve before apiHandlers
+ // are resolved
+ $handlerDeferred = $.Deferred();
+
+ // If this has a special api handler, bind it to the callback.
+ if ( flowComponent.UI.events.apiHandlers[ handlerName ] ) {
+ $deferred
+ .done( function () {
+ var args = Array.prototype.slice.call( arguments, 0 );
+ info.status = 'done';
+ args.unshift( info );
+ $.each( flowComponent.UI.events.apiHandlers[ handlerName ], function ( i, callbackFn ) {
+ handlerPromises.push( callbackFn.apply( self, args ) );
+ } );
+ } )
+ .fail( function ( code, result ) {
+ var errorMsg,
+ args = Array.prototype.slice.call( arguments, 0 ),
+ $form = $this.closest( 'form' );
+
+ info.status = 'fail';
+ args.unshift( info );
+
+ /*
+ * In the event of edit conflicts, store the previous
+ * revision id so we can re-submit an edit against the
+ * current id later.
+ */
+ if ( result.error && result.error.prev_revision ) {
+ $form.data( 'flow-prev-revision', result.error.prev_revision.revision_id );
+ }
+
+ /*
+ * Generic error handling: displays error message in the
+ * nearest error container.
+ *
+ * Errors returned by MW/Flow should always be in the
+ * same format. If the request failed without a specific
+ * error message, just fall back to some default error.
+ */
+ errorMsg = flowComponent.constructor.static.getApiErrorMessage( code, result );
+ flowComponent.emitWithReturn( 'showError', $this, errorMsg );
+
+ $.each( flowComponent.UI.events.apiHandlers[ handlerName ], function ( i, callbackFn ) {
+ handlerPromises.push( callbackFn.apply( self, args ) );
+ } );
+ } )
+ .always( function() {
+ // Resolve/reject the promised deferreds when all apiHandler
+ // deferreds have been resolved/rejected
+ $.when.apply( $, handlerPromises )
+ .done( $handlerDeferred.resolve )
+ .fail( $handlerDeferred.reject );
+ } );
+ }
+
+ // Return an aggregate promise that resolves when all are resolved, or
+ // rejects once one of them is rejected
+ return $handlerDeferred.promise();
+ }
+ FlowComponentEventsMixin.UI.events.interactiveHandlers.apiRequest = flowEventsMixinApiRequestInteractiveHandler;
+
+ //
+ // Event handler methods
+ //
+
+ /**
+ *
+ * @param {FlowComponent|jQuery} $container or entire FlowComponent
+ * @todo Perhaps use name="flow-load-handler" for performance in older browsers
+ */
+ function flowMakeContentInteractiveCallback( $container ) {
+ if ( !$container.jquery ) {
+ $container = $container.$container;
+ }
+
+ if ( !$container.length ) {
+ // Prevent erroring out with an empty node set
+ return;
+ }
+
+ // Get the FlowComponent
+ var component = mw.flow.getPrototypeMethod( 'component', 'getInstanceByElement' )( $container );
+
+ // Find all load-handlers and trigger them
+ $container.find( '.flow-load-interactive' ).add( $container.filter( '.flow-load-interactive' ) ).each( function () {
+ var $this = $( this ),
+ handlerName = $this.data( 'flow-load-handler' );
+
+ if ( $this.data( 'flow-load-handler-called' ) ) {
+ return;
+ }
+ $this.data( 'flow-load-handler-called', true );
+
+ // If this has a special load handler, run it.
+ component.emitWithReturn( 'loadHandler', handlerName, $this );
+ } );
+
+ // Find all the forms
+ // @todo move this into a flow-load-handler
+ $container.find( 'form' ).add( $container.filter( 'form' ) ).each( function () {
+ var $this = $( this );
+
+ // Trigger for flow-actions-disabler
+ $this.find( 'input, textarea' ).trigger( 'keyup' );
+
+ // Find this form's inputs
+ $this.find( 'textarea' ).filter( '[data-flow-expandable]' ).each( function () {
+ // Compress textarea if:
+ // the textarea isn't already focused
+ // and the textarea doesn't have text typed into it
+ if ( !$( this ).is( ':focus' ) && this.value === this.defaultValue ) {
+ component.emitWithReturn( 'compressTextarea', $( this ) );
+ }
+ } );
+
+ component.emitWithReturn( 'hideForm', $this );
+ } );
+ }
+ FlowComponentEventsMixin.eventHandlers.makeContentInteractive = flowMakeContentInteractiveCallback;
+
+ /**
+ * Triggers load handlers.
+ */
+ function flowLoadHandlerCallback( handlerName, args, context ) {
+ args = $.isArray( args ) ? args : ( args ? [args] : [] );
+ context = context || this;
+
+ if ( this.UI.events.loadHandlers[handlerName] ) {
+ $.each( this.UI.events.loadHandlers[handlerName], function ( i, fn ) {
+ fn.apply( context, args );
+ } );
+ }
+ }
+ FlowComponentEventsMixin.eventHandlers.loadHandler = flowLoadHandlerCallback;
+
+ /**
+ * Executes interactive handlers.
+ *
+ * @param {array} args
+ * @param {jQuery} $context
+ * @param {string} interactiveHandlerName
+ * @param {string} apiHandlerName
+ */
+ function flowExecuteInteractiveHandler( args, $context, interactiveHandlerName, apiHandlerName ) {
+ var promises = [];
+
+ // Call any matching interactive handlers
+ if ( this.UI.events.interactiveHandlers[interactiveHandlerName] ) {
+ $.each( this.UI.events.interactiveHandlers[interactiveHandlerName], function ( i, fn ) {
+ promises.push( fn.apply( $context[0], args ) );
+ } );
+ } else if ( this.UI.events.apiHandlers[apiHandlerName] ) {
+ // Call any matching API handlers
+ $.each( this.UI.events.interactiveHandlers.apiRequest, function ( i, fn ) {
+ promises.push( fn.apply( $context[0], args ) );
+ } );
+ } else if ( interactiveHandlerName ) {
+ this.debug( 'Failed to find interactiveHandler', interactiveHandlerName, arguments );
+ } else if ( apiHandlerName ) {
+ this.debug( 'Failed to find apiHandler', apiHandlerName, arguments );
+ }
+
+ // Add aggregate deferred object as data attribute, so we can hook into
+ // the element when the handlers have run
+ $context.data( 'flow-interactive-handler-promise', $.when.apply( $, promises ) );
+ }
+
+ /**
+ * Triggers both API and interactive handlers.
+ * To manually trigger a handler on an element, you can use extraParameters via $el.trigger.
+ * @param {Event} event
+ * @param {Object} [extraParameters]
+ * @param {String} [extraParameters.interactiveHandler]
+ * @param {String} [extraParameters.apiHandler]
+ */
+ function flowInteractiveHandlerCallback( event, extraParameters ) {
+ // Only trigger with enter key & no modifier keys, if keypress
+ if ( event.type === 'keypress' && ( event.charCode !== 13 || event.metaKey || event.shiftKey || event.ctrlKey || event.altKey )) {
+ return;
+ }
+
+ var args = Array.prototype.slice.call( arguments, 0 ),
+ $context = $( event.currentTarget || event.delegateTarget || event.target ),
+ // Have either of these been forced via trigger extraParameters?
+ interactiveHandlerName = ( extraParameters || {} ).interactiveHandler || $context.data( 'flow-interactive-handler' ),
+ apiHandlerName = ( extraParameters || {} ).apiHandler || $context.data( 'flow-api-handler' );
+
+ return flowExecuteInteractiveHandler.call( this, args, $context, interactiveHandlerName, apiHandlerName );
+ }
+ FlowComponentEventsMixin.eventHandlers.interactiveHandler = flowInteractiveHandlerCallback;
+ FlowComponentEventsMixin.eventHandlers.apiRequest = flowInteractiveHandlerCallback;
+
+ /**
+ * Triggers both API and interactive handlers, on focus.
+ */
+ function flowInteractiveHandlerFocusCallback( event ) {
+ var args = Array.prototype.slice.call( arguments, 0 ),
+ $context = $( event.currentTarget || event.delegateTarget || event.target ),
+ interactiveHandlerName = $context.data( 'flow-interactive-handler-focus' ),
+ apiHandlerName = $context.data( 'flow-api-handler-focus' );
+
+ return flowExecuteInteractiveHandler.call( this, args, $context, interactiveHandlerName, apiHandlerName );
+ }
+ FlowComponentEventsMixin.eventHandlers.interactiveHandlerFocus = flowInteractiveHandlerFocusCallback;
+
+ /**
+ * Callback function for when a [data-flow-eventlog-action] node is clicked.
+ * This will trigger a eventLog call to the given schema with the given
+ * parameters.
+ * A unique funnel ID will be created for all new EventLog calls.
+ *
+ * There may be multiple subsequent calls in the same "funnel" (and share
+ * same info) that you want to track. It is possible to forward funnel data
+ * from one attribute to another once the first has been clicked. It'll then
+ * log new calls with the same data (schema & entrypoint) & funnel ID as the
+ * initial logged event.
+ *
+ * Required parameters (as data-attributes) are:
+ * * data-flow-eventlog-schema: The schema name
+ * * data-flow-eventlog-entrypoint: The schema's entrypoint parameter
+ * * data-flow-eventlog-action: The schema's action parameter
+ *
+ * Additionally:
+ * * data-flow-eventlog-forward: Selectors to forward funnel data to
+ */
+ function flowEventLogCallback( event ) {
+ // Only trigger with enter key & no modifier keys, if keypress
+ if ( event.type === 'keypress' && ( event.charCode !== 13 || event.metaKey || event.shiftKey || event.ctrlKey || event.altKey )) {
+ return;
+ }
+
+ var $context = $( event.currentTarget ),
+ data = $context.data(),
+ component = mw.flow.getPrototypeMethod( 'component', 'getInstanceByElement' )( $context ),
+ $promise = data.flowInteractiveHandlerPromise || $.Deferred().resolve().promise(),
+ eventInstance = {},
+ key, value;
+
+ // Fetch loggable data: everything prefixed flowEventlog except
+ // flowEventLogForward and flowEventLogSchema
+ for ( key in data ) {
+ if ( key.indexOf( 'flowEventlog' ) === 0 ) {
+ // @todo Either the data or this config should have separate prefixes,
+ // it shouldn't be shared and then handled here.
+ if ( key === 'flowEventlogForward' || key === 'flowEventlogSchema' ) {
+ continue;
+ }
+
+ // Strips "flowEventlog" and lowercases first char after that
+ value = data[key];
+ key = key.substr( 12, 1 ).toLowerCase() + key.substr( 13 );
+
+ eventInstance[key] = value;
+ }
+ }
+
+ // Log the event
+ eventInstance = component.logEvent( data.flowEventlogSchema, eventInstance );
+
+ // Promise resolves once all interactiveHandlers/apiHandlers are done,
+ // so all nodes we want to forward to are bound to be there
+ $promise.always( function() {
+ // Now find all nodes to forward to
+ var $forward = data.flowEventlogForward ? $context.findWithParent( data.flowEventlogForward ) : $();
+
+ // Forward the funnel
+ eventInstance = component.forwardEvent( $forward, data.flowEventlogSchema, eventInstance.funnelId );
+ } );
+ }
+ FlowComponentEventsMixin.eventHandlers.eventLogHandler = flowEventLogCallback;
+
+ /**
+ * When the whole class has been instantiated fully (after every constructor has been called).
+ * @param {FlowComponent} component
+ */
+ function flowEventsMixinInstantiationComplete( component ) {
+ $( window ).trigger( 'scroll.flow-window-scroll' );
+ }
+ FlowComponentEventsMixin.eventHandlers.instantiationComplete = flowEventsMixinInstantiationComplete;
+
+
+ /**
+ * Compress and hide a flow form and/or its actions, depending on data-flow-initial-state.
+ * @param {jQuery} $form
+ * @todo Move this to a separate file
+ */
+ function flowEventsMixinHideForm( $form ) {
+ var initialState = $form.data( 'flow-initial-state' ),
+ component = mw.flow.getPrototypeMethod( 'component', 'getInstanceByElement' )( $form );
+
+ // Store state
+ $form.data( 'flow-state', 'hidden' );
+
+ // If any preview is visible cancel it
+ // Must be done before compressing text areas because
+ // the preview may have manipulated them.
+ if ( $form.parent().find( '.flow-preview-warning' ).length ) {
+ component.resetPreview(
+ $form.find( 'button[data-role="cancel"]' )
+ );
+ }
+
+ $form.find( 'textarea' ).each( function () {
+ var $editor = $( this );
+
+ // Kill editor instances
+ if ( mw.flow.editor && mw.flow.editor.exists( $editor ) ) {
+ mw.flow.editor.destroy( $editor );
+ }
+
+ // Drop the new input in place if:
+ // the textarea isn't already focused
+ // and the textarea doesn't have text typed into it
+ if ( !$editor.is( ':focus' ) && this.value === this.defaultValue ) {
+ component.emitWithReturn( 'compressTextarea', $editor );
+ }
+ } );
+
+ if ( initialState === 'collapsed' ) {
+ // Hide its actions
+ // @todo Use TemplateEngine to find and hide actions?
+ $form.find( '.flow-form-collapsible' ).hide();
+ $form.data( 'flow-form-collapse-state', 'collapsed' );
+ } else if ( initialState === 'hidden' ) {
+ // Hide the form itself
+ $form.hide();
+ }
+ }
+ FlowComponentEventsMixin.eventHandlers.hideForm = flowEventsMixinHideForm;
+
+ /**
+ * "Compresses" a textarea by adding a class to it, which CSS will pick up
+ * to force a smaller display size.
+ * @param {jQuery} $textarea
+ * @todo Move this to a separate file
+ */
+ function flowEventsMixinCompressTextarea( $textarea ) {
+ $textarea.addClass( 'flow-input-compressed' );
+ if ( mw.flow.editor && mw.flow.editor.exists( $textarea ) ) {
+ mw.flow.editor.destroy( $textarea );
+ }
+ }
+ FlowComponentEventsMixin.eventHandlers.compressTextarea = flowEventsMixinCompressTextarea;
+
+ /**
+ * If input is focused, expand it if compressed (into textarea).
+ * Otherwise, trigger the form to unhide.
+ * @param {Event} event
+ * @todo Move this to a separate file
+ */
+ function flowEventsMixinFocusField( event ) {
+ var $context = $( event.currentTarget || event.delegateTarget || event.target ),
+ component = mw.flow.getPrototypeMethod( 'component', 'getInstanceByElement' )( $context );
+
+ // Show the form (and swap it for textarea if needed)
+ component.emitWithReturn( 'showForm', $context.closest( 'form' ) );
+ }
+ FlowComponentEventsMixin.eventHandlers.focusField = flowEventsMixinFocusField;
+
+ /**
+ * Expand and make visible a flow form and/or its actions, depending on data-flow-initial-state.
+ * @param {jQuery} $form
+ */
+ function flowEventsMixinShowForm( $form ) {
+ var initialState = $form.data( 'flow-initial-state' ),
+ self = this;
+
+ if ( initialState === 'collapsed' ) {
+ // Show its actions
+ if ( $form.data( 'flow-form-collapse-state' ) === 'collapsed' ) {
+ $form.removeData( 'flow-form-collapse-state' );
+ $form.find( '.flow-form-collapsible' ).show();
+ }
+ } else if ( initialState === 'hidden' ) {
+ // Show the form itself
+ $form.show();
+ }
+
+ // Expand all textareas if needed
+ $form.find( '.flow-input-compressed' ).each( function () {
+ self.emitWithReturn( 'expandTextarea', $( this ) );
+ } );
+
+ // Initialize editors, turning them from textareas into editor objects
+ self.emitWithReturn( 'initializeEditors', $form );
+
+ // Store state
+ $form.data( 'flow-state', 'visible' );
+ }
+ FlowComponentEventsMixin.eventHandlers.showForm = flowEventsMixinShowForm;
+
+ /**
+ * Expand the textarea by removing the CSS class that will make it appear
+ * smaller.
+ * @param {jQuery} $textarea
+ */
+ function flowEventsMixinExpandTextarea( $textarea ) {
+ $textarea.removeClass( 'flow-input-compressed' );
+ }
+ FlowComponentEventsMixin.eventHandlers.expandTextarea = flowEventsMixinExpandTextarea;
+
+ /**
+ * Initialize all editors, turning them from textareas into editor objects.
+ *
+ * @param {jQuery} $container
+ */
+ function flowEventsMixinInitializeEditors( $container ) {
+ var flowComponent = this, $form;
+
+ mw.loader.using( 'ext.flow.editor', function() {
+ var $editors = $container.find( 'textarea:not(.flow-input-compressed)' );
+
+ $editors.each( function() {
+ var $editor = $( this );
+
+ $form = $editor.closest( 'form' );
+ // All editors already have their content in wikitext-format
+ // (mostly because we need to prefill them server-side so that
+ // JS-less users can interact)
+ mw.flow.editor.load( $editor, $editor.val(), 'wikitext' ).done( function () {
+ $form.toggleClass( 'flow-editor-supports-preview', mw.flow.editor.editor.static.usesPreview() );
+ } );
+
+ // Kill editor instance when the form it's in is cancelled
+ flowComponent.emitWithReturn( 'addFormCancelCallback', $form, function() {
+ if ( mw.flow.editor.exists( $editor ) ) {
+ mw.flow.editor.destroy( $editor );
+ }
+ } );
+ } );
+ } );
+ }
+ FlowComponentEventsMixin.eventHandlers.initializeEditors = flowEventsMixinInitializeEditors;
+
+ /**
+ * Adds a flow-cancel-callback to a given form, to be triggered on click of the "cancel" button.
+ * @param {jQuery} $form
+ * @param {Function} callback
+ */
+ function flowEventsMixinAddFormCancelCallback( $form, callback ) {
+ var fns = $form.data( 'flow-cancel-callback' ) || [];
+ fns.push( callback );
+ $form.data( 'flow-cancel-callback', fns );
+ }
+ FlowComponentEventsMixin.eventHandlers.addFormCancelCallback = flowEventsMixinAddFormCancelCallback;
+
+ /**
+ * @param {FlowBoardComponent|jQuery} $node or entire FlowBoard
+ */
+ function flowEventsMixinRemoveError( $node ) {
+ _flowFindUpward( $node, '.flow-error-container' ).filter( ':first' ).empty();
+ }
+ FlowComponentEventsMixin.eventHandlers.removeError = flowEventsMixinRemoveError;
+
+ /**
+ * @param {FlowBoardComponent|jQuery} $node or entire FlowBoard
+ * @param {String} msg The error that occurred. Currently hardcoded.
+ */
+ function flowEventsMixinShowError( $node, msg ) {
+ var fragment = mw.flow.TemplateEngine.processTemplate( 'flow_errors.partial', { errors: [ { message: msg } ] } );
+
+ if ( !$node.jquery ) {
+ $node = $node.$container;
+ }
+
+ _flowFindUpward( $node, '.flow-content-preview' ).hide();
+ _flowFindUpward( $node, '.flow-error-container' ).filter( ':first' ).replaceWith( fragment );
+ }
+ FlowComponentEventsMixin.eventHandlers.showError = flowEventsMixinShowError;
+
+ /**
+ * Shows a tooltip telling the user that they have subscribed
+ * to this topic|board
+ * @param {jQuery} $tooltipTarget Element to attach tooltip to.
+ * @param {string} type 'topic' or 'board'
+ * @param {string} dir Direction to point the pointer. 'left' or 'up'
+ */
+ function flowEventsMixinShowSubscribedTooltip( $tooltipTarget, type, dir ) {
+ dir = dir || 'left';
+
+ mw.tooltip.show(
+ $tooltipTarget,
+ // tooltipTarget will not always be part of a FlowBoardComponent
+ $( mw.flow.TemplateEngine.processTemplateGetFragment(
+ 'flow_tooltip_subscribed.partial',
+ {
+ unsubscribe: false,
+ type: type,
+ direction: dir,
+ username: mw.user.getName()
+ }
+ )
+ ).children(),
+ {
+ tooltipPointing: dir
+ }
+ );
+
+ // Hide after 5s
+ setTimeout( function () {
+ mw.tooltip.hide( $tooltipTarget );
+ }, 5000 );
+ }
+ FlowComponentEventsMixin.eventHandlers.showSubscribedTooltip = flowEventsMixinShowSubscribedTooltip;
+
+ /**
+ * If a form has a cancelForm handler, we clear the form and trigger it. This allows easy cleanup
+ * and triggering of form events after successful API calls.
+ * @param {Element|jQuery} formElement
+ */
+ function flowEventsMixinCancelForm( formElement ) {
+ var $form = $( formElement ),
+ $button = $form.find( 'button, input, a' ).filter( '[data-flow-interactive-handler="cancelForm"]' );
+
+ if ( $button.length ) {
+ // Clear contents to not trigger the "are you sure you want to
+ // discard your text" warning
+ $form.find( 'textarea, :text' ).each( function() {
+ $( this ).val( this.defaultValue );
+ } );
+
+ // Trigger a click on cancel to have it destroy the form the way it should
+ $button.trigger( 'click' );
+ }
+ }
+ FlowComponentEventsMixin.eventHandlers.cancelForm = flowEventsMixinCancelForm;
+
+ //
+ // Private functions
+ //
+
+ /**
+ * Given node & a selector, this will return the result closest to $node
+ * by first looking inside $node, then travelling up the DOM tree to
+ * locate the first result in a common ancestor.
+ *
+ * @param {jQuery} $node
+ * @param {String} selector
+ * @returns jQuery
+ */
+ function _flowFindUpward( $node, selector ) {
+ // first check if result can already be found inside $node
+ var $result = $node.find( selector );
+
+ // then keep looking up the tree until a result is found
+ while ( $result.length === 0 && $node.length !== 0 ) {
+ $node = $node.parent();
+ $result = $node.children( selector );
+ }
+
+ return $result;
+ }
+
+ // Copy static and prototype from mixin to main class
+ mw.flow.mixinComponent( 'component', FlowComponentEventsMixin );
+}( jQuery, mediaWiki ) );
diff --git a/Flow/modules/engine/components/common/flow-component-menus.js b/Flow/modules/engine/components/common/flow-component-menus.js
new file mode 100644
index 00000000..3239a851
--- /dev/null
+++ b/Flow/modules/engine/components/common/flow-component-menus.js
@@ -0,0 +1,142 @@
+/*!
+ * Contains flow-menu functionality.
+ */
+
+( function ( $, mw ) {
+ /**
+ * Binds handlers for flow-menu.
+ * @param {jQuery} $container
+ * @this FlowComponent
+ * @constructor
+ */
+ function FlowComponentMenusFeatureMixin( $container ) {
+ // Bind events to this instance
+ this.bindComponentHandlers( FlowComponentMenusFeatureMixin.eventHandlers );
+
+ // Bind element handlers
+ this.bindNodeHandlers( FlowComponentMenusFeatureMixin.UI.events );
+
+ // Bind special toggle menu handler
+ $container
+ .on(
+ 'click.FlowBoardComponent mousedown.FlowBoardComponent mouseup.FlowBoardComponent focusin.FlowBoardComponent focusout.FlowBoardComponent',
+ '.flow-menu',
+ this.getDispatchCallback( 'toggleHoverMenu' )
+ );
+ }
+ OO.initClass( FlowComponentMenusFeatureMixin );
+
+ FlowComponentMenusFeatureMixin.eventHandlers = {};
+ FlowComponentMenusFeatureMixin.UI = {
+ events: {
+ loadHandlers: {},
+ interactiveHandlers: {}
+ }
+ };
+
+ //
+ // Event handler methods
+ //
+
+ /**
+ * On click, focus, and blur of hover menu events, decides whether or not to hide or show the expanded menu
+ * @param {Event} event
+ */
+ function flowComponentMenusFeatureMixinToggleHoverMenuCallback( event ) {
+ var $this = $( event.target ),
+ $menu = $this.closest( '.flow-menu' );
+
+ if ( event.type === 'click' ) {
+ // If the caret was clicked, toggle focus
+ if ( $this.closest( '.flow-menu-js-drop' ).length ) {
+ $menu.toggleClass( 'focus' );
+
+ // This trick lets us wait for a blur event from A instead on body, to later hide the menu on outside click
+ if ( $menu.hasClass( 'focus' ) ) {
+ $menu.find( '.flow-menu-js-drop' ).find( 'a' ).focus();
+ }
+ } else if ( $this.is( 'a, button' ) ) {
+ // Remove the focus from the menu so it can hide after clicking on a link or button
+ setTimeout( function () {
+ if ( $this.is( ':focus' ) ) {
+ $this.blur();
+ }
+ }, 50 );
+ }
+
+ $menu.removeData( 'mousedown' );
+ } else if ( event.type === 'mousedown' ) {
+ // Fix for Chrome: it triggers blur when you click on the scrollbar! Let's prevent that.
+ $menu.data( 'mousedown', true );
+ } else if ( event.type === 'mouseup' ) {
+ // Chrome fix ^
+ $menu.removeData( 'mousedown' );
+ } else if ( event.type === 'focusin' ) {
+ // If we are focused on a menu item (eg. tabbed in), open the whole menu
+ $menu.addClass( 'focus' );
+ } else if ( event.type === 'focusout' && !$menu.find( 'a' ).filter( ':focus' ).length ) {
+ // If we lost focus, make sure no other element in this menu has focus, and then hide the menu
+ setTimeout( function () {
+ if ( !$menu.data( 'mousedown' ) && !$menu.find( 'a' ).filter( ':focus' ).length ) {
+ $menu.removeClass( 'focus' );
+ }
+ }, 250 );
+ }
+ }
+ FlowComponentMenusFeatureMixin.eventHandlers.toggleHoverMenu = flowComponentMenusFeatureMixinToggleHoverMenuCallback;
+
+ //
+ // On element-click handlers
+ //
+
+ /**
+ * Allows you to open a flow-menu from a secondary click handler elsewhere.
+ * Uses data-flow-menu-target="< foo .flow-menu"
+ * @param {Event} event
+ */
+ function flowComponentMenusFeatureElementMenuToggleCallback( event ) {
+ var $this = $( this ),
+ flowComponent = mw.flow.getPrototypeMethod( 'component', 'getInstanceByElement' )( $this ),
+ target = $this.data( 'flowMenuTarget' ),
+ $target = $.findWithParent( $this, target ),
+ $deferred = $.Deferred();
+
+
+ event.preventDefault();
+
+ if ( !$target || !$target.length ) {
+ flowComponent.debug( 'Could not find openFlowMenu target', arguments );
+ return $deferred.reject().promise();
+ }
+
+ $target.find( '.flow-menu-js-drop' ).trigger( 'click' );
+
+ return $deferred.resolve().promise();
+ }
+ FlowComponentMenusFeatureMixin.UI.events.interactiveHandlers.menuToggle = flowComponentMenusFeatureElementMenuToggleCallback;
+
+ //
+ // On element-load handlers
+ //
+
+ /**
+ * When a menu appears, check if it's already got the focus class. If so, re-focus it.
+ * @param {jQuery} $menu
+ */
+ function flowComponentMenusFeatureElementLoadCallback( $menu ) {
+ // For some reason, this menu is visible, but lacks physical focus
+ // This happens when you clone an activated flow-menu
+ if ( $menu.hasClass( 'focus' ) && !$menu.find( 'a' ).filter( ':focus' ).length ) {
+ // Give it focus again
+ $menu.find( '.flow-menu-js-drop' ).find( 'a' ).focus();
+ }
+ }
+ FlowComponentMenusFeatureMixin.UI.events.loadHandlers.menu = flowComponentMenusFeatureElementLoadCallback;
+
+ //
+ // Private functions
+ //
+
+ // Mixin to FlowComponent
+ mw.flow.mixinComponent( 'component', FlowComponentMenusFeatureMixin );
+}( jQuery, mediaWiki ) );
diff --git a/Flow/modules/engine/components/flow-component.js b/Flow/modules/engine/components/flow-component.js
new file mode 100644
index 00000000..ffba356f
--- /dev/null
+++ b/Flow/modules/engine/components/flow-component.js
@@ -0,0 +1,251 @@
+/*!
+ * Contains base FlowComponent class.
+ */
+
+( function ( $, mw ) {
+ var _totalInstanceCount = 0;
+
+ /**
+ * Inherited base class. Stores the instance in the class's instance registry.
+ * @param {jQuery} $container
+ * @mixins FlowComponentEventsMixin
+ * @mixins FlowComponentEnginesMixin
+ * @mixins FlowComponentMenusFeatureMixin
+ * @constructor
+ */
+ function FlowComponent( $container ) {
+ var parent = this.constructor.parent;
+
+ // Run progressive enhancements if any are needed by this container
+ mw.flow.TemplateEngine.processProgressiveEnhancement( $container );
+
+ // Store the container for later use
+ this.$container = $container;
+
+ // Get this component's ID
+ this.id = $container.data( 'flow-id' );
+ if ( !this.id ) {
+ // Generate an ID for this component
+ this.id = 'flow-generated-' + _totalInstanceCount;
+ $container.data( 'flow-id', this.id );
+ // @todo throw an exception here instead of generating an id?
+ } else if ( this.getInstanceByElement( $container ) ) {
+ // Check if this board was already instantiated, and return that instead
+ return this.getInstanceByElement( $container );
+ }
+
+ // Give this board its own API instance @todo do this with OOjs
+ this.Api = new mw.flow.FlowApi( FlowComponent.static.StorageEngine, this.id );
+
+ // Keep this in the registry to find it by other means
+ while ( parent ) {
+ parent._instanceRegistryById[this.id] = parent._instanceRegistry.push( this ) - 1;
+ parent = parent.parent; // and add it to every instance registry
+ }
+ _totalInstanceCount++;
+ }
+ OO.initClass( FlowComponent );
+
+ //
+ // PROTOTYPE METHODS
+ //
+
+ /**
+ * Takes any length of arguments, and passes it off to console.log.
+ * Only renders if window.flow_debug OR localStorage.flow_debug == true OR user is Admin or (WMF).
+ * @param {Boolean} [isError=true]
+ * @param {...*} args
+ */
+ mw.flow.debug = FlowComponent.prototype.debug = function ( isError, args ) {
+ if ( window.console ) {
+ args = Array.prototype.slice.call( arguments, 0 );
+
+ if ( typeof isError === 'boolean' ) {
+ args.shift();
+ } else {
+ isError = true;
+ }
+
+ args.unshift( '[FLOW] ' );
+
+ if ( isError && console.error ) {
+ // If console.error is supported, send that, because it gives a stack trace
+ return console.error.apply( console, args );
+ }
+
+ // Otherwise, use console.log
+ console.log.apply( console, args );
+ }
+ };
+
+ /**
+ * Converts a Flow UUID to a UNIX timestamp.
+ * @param {String} uuid
+ * @return {int} UNIX time
+ */
+ mw.flow.uuidToTime = FlowComponent.prototype.uuidToTime = function ( uuid ) {
+ var timestamp = parseInt( uuid, 36 ).toString( 2 ); // Parse from base-36, then serialize to base-2
+ timestamp = Array( 88 + 1 - timestamp.length ).join( '0' ) + timestamp; // left pad 0 to 88 chars
+ timestamp = parseInt( timestamp.substr( 0, 46 ), 2 ); // first 46 chars base-2 to base-10
+
+ return timestamp;
+ };
+
+ /**
+ * Returns all the registered instances of a given FlowComponent.
+ * @returns {FlowComponent[]}
+ */
+ FlowComponent.prototype.getInstances = function () {
+ // Use the correct context (instance vs prototype)
+ return ( this.constructor.parent || this )._instanceRegistry;
+ };
+
+ /**
+ * Goes up the DOM tree to find which FlowComponent $el belongs to, via .flow-component[flow-id].
+ * @param {jQuery} $el
+ * @returns {FlowComponent|bool}
+ */
+ FlowComponent.prototype.getInstanceByElement = function ( $el ) {
+ var $container = $el.closest( '.flow-component' ),
+ context = this.constructor.parent || this, // Use the correct context (instance vs prototype)
+ id;
+
+ // This element isn't _within_ any actual component; was it spawned _by_ a component?
+ if ( !$container.length ) {
+ // Find any parents of this element with the flowSpawnedBy data attribute
+ $container = $el.parents().addBack().filter( function () {
+ return $( this ).data( 'flowSpawnedBy' );
+ } ).last()
+ // Get the flowSpawnedBy node
+ .data( 'flowSpawnedBy' );
+ // and then return the closest flow-component of it
+ $container = $container ? $container.closest( '.flow-component' ) : $();
+ }
+
+ // Still no parent component. Crap out!
+ if ( !$container.length ) {
+ mw.flow.debug( 'Failed to getInstanceByElement: no $container.length', arguments );
+ return false;
+ }
+
+ id = $container.data( 'flow-id' );
+
+ return context._instanceRegistry[ context._instanceRegistryById[ id ] ] || false;
+ };
+
+ /**
+ * Sets the FlowComponent's $container element as the data-flow-spawned-by attribute on $el.
+ * Fires ALL events from within $el onto $eventTarget, albeit with the whole event intact.
+ * This allows us to listen for events from outside of FlowComponent's nodes, but still trigger them within.
+ * @param {jQuery} $el
+ * @param {jQuery} [$eventTarget]
+ */
+ FlowComponent.prototype.assignSpawnedNode = function ( $el, $eventTarget ) {
+ // Target defaults to .flow-component
+ $eventTarget = $eventTarget || this.$container;
+
+ // Assign flowSpawnedBy data attribute
+ $el.data( 'flowSpawnedBy', $eventTarget );
+
+ // Forward all events (except mouse movement) to $eventTarget
+ $el.on(
+ 'blur change click dblclick error focus focusin focusout keydown keypress keyup load mousedown mouseenter mouseleave mouseup resize scroll select submit',
+ '*',
+ { flowSpawnedBy: this.$container, flowSpawnedFrom: $el },
+ function ( event ) {
+ // Let's forward these events in an unusual way, similar to how jQuery propagates events...
+ // First, only take the very first, top-level event, as the rest of the propagation is handled elsewhere
+ if ( event.target === this ) {
+ // Get all the parent nodes of our target,
+ // but do not include any nodes we will already be bubbling up to (eg. body)
+ var $nodes = $eventTarget.parents().addBack().not( $( this ).parents().addBack() ),
+ i = $nodes.length;
+
+ // For every node between $eventTarget and window that was not filtered out above...
+ while ( i-- ) {
+ // Trigger a bubbling event on each one, with the correct context
+ _eventForwardDispatch.call( $nodes[i], event, $el[0] );
+ }
+ }
+ }
+ );
+ };
+
+ //
+ // PRIVATE FUNCTIONS
+ //
+
+ /**
+ * This method is mostly cloned from jQuery.event.dispatch, except that it has been modified to use container
+ * as its base for finding event handlers (via jQuery.event.handlers). This allows us to trigger events on said
+ * container (and its parents, bubbling up), as if the event originated from within it.
+ * jQuery itself doesn't allow for this, as the context (this & event.currentTarget) become the actual element you
+ * are triggering an event on, instead of the element which matched the selector.
+ *
+ * @example _eventForwardDispatch.call( Element, Event, Element );
+ *
+ * @param {jQuery.Event} event
+ * @param {Element} container
+ * @returns {*}
+ * @private
+ */
+ function _eventForwardDispatch( event, container ) {
+ // Make a writable jQuery.Event from the native event object
+ event = jQuery.event.fix( event );
+
+ var i, ret, handleObj, matched, j,
+ handlerQueue = [],
+ args = Array.prototype.slice.call( arguments, 0 ),
+ handlers = ( jQuery._data( this, 'events' ) || {} )[ event.type ] || [],
+ special = jQuery.event.special[ event.type ] || {};
+
+ // Use the fix-ed jQuery.Event rather than the (read-only) native event
+ args[0] = event;
+ event.delegateTarget = this;
+
+ // Call the preDispatch hook for the mapped type, and let it bail if desired
+ if ( special.preDispatch && special.preDispatch.call( this, event ) === false ) {
+ return;
+ }
+
+ // Determine handlers
+ // The important modification: we use container instead of this as the context
+ handlerQueue = jQuery.event.handlers.call( container, event, handlers );
+
+ // Run delegates first; they may want to stop propagation beneath us
+ i = 0;
+ while ( ( matched = handlerQueue[ i++ ] ) && !event.isPropagationStopped() ) {
+ event.currentTarget = matched.elem;
+
+ j = 0;
+ while ( ( handleObj = matched.handlers[ j++ ] ) && !event.isImmediatePropagationStopped() ) {
+ // Triggered event must either 1) have no namespace, or
+ // 2) have namespace(s) a subset or equal to those in the bound event (both can have no namespace).
+ if ( !event.namespace_re || event.namespace_re.test( handleObj.namespace ) ) {
+
+ event.handleObj = handleObj;
+ event.data = handleObj.data;
+
+ ret = ( ( jQuery.event.special[ handleObj.origType ] || {} ).handle || handleObj.handler )
+ .apply( matched.elem, args );
+
+ if ( ret !== undefined ) {
+ if ( ( event.result = ret ) === false ) {
+ event.preventDefault();
+ event.stopPropagation();
+ }
+ }
+ }
+ }
+ }
+
+ // Call the postDispatch hook for the mapped type
+ if ( special.postDispatch ) {
+ special.postDispatch.call( this, event );
+ }
+
+ return event.result;
+ }
+
+ mw.flow.registerComponent( 'component', FlowComponent );
+}( jQuery, mediaWiki ) );
diff --git a/Flow/modules/engine/components/flow-registry.js b/Flow/modules/engine/components/flow-registry.js
new file mode 100644
index 00000000..3b2a9498
--- /dev/null
+++ b/Flow/modules/engine/components/flow-registry.js
@@ -0,0 +1,165 @@
+/*!
+ * Creates and manages the component registry.
+ * We expand upon OOjs in several ways here:
+ * 1. Allow mixinClasses to have their constructor functions to be called (at initComponent).
+ * 2. Automatically call all parent constructors from inheritClasses (at initComponent).
+ * 3. Create a global instance registry of components on a page, and also create a registry for each component type.
+ * 4. Have the ability to fetch individual prototype methods from classes in the registry, as they are out of scope.
+ */
+
+( function ( $, mw ) {
+ mw.flow = mw.flow || {}; // create mw.flow globally
+
+ var _componentRegistry = new OO.Registry();
+
+ /**
+ * Instantiate one or more new FlowComponents.
+ * Uses data-flow-component to find the right class, and returns that new instance.
+ * Accepts one or more container elements in $container. If multiple, returns an array of FlowBoardComponents.
+ * @param {jQuery} $container
+ * @returns {FlowComponent|boolean|Array} The created FlowComponent instance, or an
+ * array of FlowComponent instances, or boolean false in case of an error.
+ */
+ function initFlowComponent( $container ) {
+ var a, i, componentName, componentBase,
+ /** @private
+ * Deep magic: This crazy little function becomes the "real" top-level constructor
+ * It recursively calls every parent so that we don't have to do it manually in a Component constructor
+ * @returns {FlowComponent}
+ */
+ _RecursiveConstructor = function () {
+ var constructors = [],
+ parent = this.constructor.parent,
+ i, j, parentReturn;
+
+ // Find each parent class
+ while ( parent ) {
+ constructors.push( parent );
+ parent = parent.parent;
+ }
+
+ // Call each parent in reverse (starting with the base class and moving up the chain)
+ for ( i = constructors.length; i--; ) {
+ // Call each mixin constructor
+ for ( j = 0; j < constructors[i].static.mixinConstructors.length; j++ ) {
+ constructors[i].static.mixinConstructors[j].apply( this, arguments );
+ }
+
+ // Call this class constructor
+ parentReturn = constructors[i].apply( this, arguments );
+
+ if ( parentReturn && parentReturn.constructor ) {
+ // If the parent returned an instantiated class (cached), return that instead
+ return parentReturn;
+ }
+ }
+
+ // Run any post-instantiation handlers
+ this.emitWithReturn( 'instantiationComplete', this );
+ };
+
+ if ( !$container || !$container.length ) {
+ // No containers found
+ mw.flow.debug( 'Will not instantiate: no $container.length', arguments );
+ return false;
+ } else if ( $container.length > 1 ) {
+ // Too many elements; instantiate them all
+ for ( a = [], i = $container.length; i--; ) {
+ a.push( initFlowComponent( $( $container[ i ] ) ) );
+ }
+ return a;
+ }
+
+ // Find out which component this is
+ componentName = $container.data( 'flow-component' );
+ // Get that component
+ componentBase = _componentRegistry.lookup( componentName );
+ if ( componentBase ) {
+ // Return the new instance of that FlowComponent, via our _RecursiveConstructor method
+ OO.inheritClass( _RecursiveConstructor, componentBase );
+ return new _RecursiveConstructor( $container );
+ }
+
+ // Don't know what kind of component this is.
+ mw.flow.debug( 'Unknown FlowComponent: ', componentName, arguments );
+ return false;
+ }
+ mw.flow.initComponent = initFlowComponent;
+
+ /**
+ * Registers a given FlowComponent into the component registry, and also has it inherit another class using the
+ * prototypeName argument (defaults to 'component', which returns FlowComponent).
+ * @param {String} name Name of component to register
+ * @param {Function} constructorClass Actual class to link to that name
+ * @param {String} [prototypeName='component'] A base class which this one will inherit
+ */
+ function registerFlowComponent( name, constructorClass, prototypeName ) {
+ if ( name !== 'component' ) {
+ // Inherit a base class; defaults to FlowComponent
+ OO.inheritClass( constructorClass, _componentRegistry.lookup( prototypeName || 'component' ) );
+ }
+
+ // Create the instance registry for this component
+ constructorClass._instanceRegistry = [];
+ constructorClass._instanceRegistryById = {};
+
+ // Assign the OOjs static name to this class
+ constructorClass.static.name = name;
+
+ // Allow mixins to use their constructor
+ constructorClass.static.mixinConstructors = [];
+
+ // Register the component class
+ _componentRegistry.register( name, constructorClass );
+ }
+ mw.flow.registerComponent = registerFlowComponent;
+
+ /**
+ * For when you want to call a specific function from a class's prototype.
+ * @example mw.flow.getPrototypeMethod( 'board', 'getInstanceByElement' )( $el );
+ * @param {String} className
+ * @param {String} methodName
+ * @param {*} [context]
+ * @return {Function}
+ */
+ function getFlowPrototypeMethod( className, methodName, context ) {
+ var registeredClass = _componentRegistry.lookup( className ),
+ method;
+
+ if ( !registeredClass ) {
+ mw.flow.debug( 'Failed to find FlowComponent.', arguments );
+ return $.noop;
+ }
+
+ method = registeredClass.prototype[methodName];
+ if ( !method ) {
+ mw.flow.debug( 'Failed to find FlowComponent method.', arguments );
+ return $.noop;
+ }
+
+ return $.proxy( method, context || registeredClass );
+ }
+ mw.flow.getPrototypeMethod = getFlowPrototypeMethod;
+
+ /**
+ * Mixes in the given mixinClass to be copied to an existing class, by name.
+ * @param {String} targetName Target component
+ * @param {Function} mixinClass Class with extension to add to target
+ */
+ function mixinFlowComponent( targetName, mixinClass ) {
+ var registeredClass = _componentRegistry.lookup( targetName );
+
+ if ( !registeredClass ) {
+ mw.flow.debug( 'Failed to find FlowComponent to extend.', arguments );
+ return;
+ }
+
+ OO.mixinClass( registeredClass, mixinClass );
+
+ // Allow mixins to use their constructors (in init)
+ if ( typeof mixinClass === 'function' ) {
+ registeredClass.static.mixinConstructors.push( mixinClass );
+ }
+ }
+ mw.flow.mixinComponent = mixinFlowComponent;
+}( jQuery, mediaWiki ) );
diff --git a/Flow/modules/engine/misc/flow-api.js b/Flow/modules/engine/misc/flow-api.js
new file mode 100644
index 00000000..229a2556
--- /dev/null
+++ b/Flow/modules/engine/misc/flow-api.js
@@ -0,0 +1,411 @@
+( function ( mw, $ ) {
+ mw.flow = mw.flow || {}; // create mw.flow globally
+
+ var apiTransformMap = {
+ // Map of API submodule name, block name, and prefix name
+ 'moderate-post': [ 'topic_', 'mp' ],
+ 'new-topic': [ 'topiclist_', 'nt' ],
+ 'edit-header': [ 'header_', 'eh' ],
+ 'edit-post': [ 'topic_', 'ep' ],
+ 'reply': [ 'topic_', 'rep' ],
+ 'moderate-topic': [ 'topic_', 'mt' ],
+ 'edit-title': [ 'topic_', 'et' ],
+ 'lock-topic': [ 'topic_', 'cot' ],
+ 'view-topiclist': [ 'topiclist_', 'vtl' ],
+ 'view-post': [ 'topic', 'vp' ],
+ 'view-topic': [ 'topic', 'vt' ],
+ 'view-header': [ 'header_', 'vh' ],
+ 'view-topic-summary': [ 'topicsummary_', 'vts' ],
+ 'edit-topic-summary': [ 'topicsummary_', 'ets' ]
+ };
+
+ /**
+ * Handles Flow API calls. Each FlowComponent has its own instance of FlowApi as component.Api,
+ * so that it can store a workflowId and pageName permanently for simplicity.
+ * @param {String} [workflowId]
+ * @param {String} [pageName]
+ * @returns {FlowApi}
+ * @constructor
+ */
+ function FlowApi( storageEngine, workflowId, pageName ) {
+ this.StorageEngine = storageEngine;
+ this.workflowId = workflowId;
+ this.pageName = pageName;
+
+ /**
+ * Makes the actual API call and returns
+ * @param {Object|String} [params] May be a JSON object string
+ * @param {String} [pageName]
+ * @returns {$.Deferred}
+ */
+ function flowApiCall( params, method ) {
+ var mwApi, tokenType,
+ $deferred = $.Deferred();
+
+ // IE8 caches POST under some conditions, prevent that here.
+ // IE8 is most likely the only browser we support that doesn't
+ // have addEventListener, and anything else that gets caught
+ // up isn't that bad off.
+ if ( !document.addEventListener ) {
+ mwApi = new mw.Api( { ajax: { cache: false } } );
+ } else {
+ mwApi = new mw.Api();
+ }
+
+ params = params || {};
+ // Server is using page instead of title
+ // @todo this should not be necessary
+ params.page = params.page || this.pageName || mw.config.get( 'wgPageName' );
+ method = method ? method.toUpperCase() : 'GET';
+
+ if ( !params.action ) {
+ mw.flow.debug( '[FlowApi] apiCall error: missing action string', arguments );
+ return $deferred.rejectWith({ error: 'Invalid action' });
+ }
+ if ( !params.page ) {
+ mw.flow.debug( '[FlowApi] apiCall error: missing page string', [ mw.config.get( 'wgPageName' ) ], arguments );
+ return $deferred.rejectWith({ error: 'Invalid title' });
+ }
+
+ if ( method === 'POST' ) {
+ if ( params._internal && params._internal.tokenType ) {
+ tokenType = params._internal.tokenType;
+ } else {
+ tokenType = 'edit';
+ }
+
+ delete params._internal;
+
+ return mwApi.postWithToken( tokenType, params );
+ } else if ( method !== 'GET' ) {
+ return $deferred.rejectWith({ error: 'Unknown submission method: ' + method });
+ } else {
+ return mwApi.get( params );
+ }
+ }
+
+ this.apiCall = flowApiCall;
+ }
+
+ /** @type {Storer} */
+ FlowApi.prototype.StorageEngine = null;
+ /** @type {String} */
+ FlowApi.prototype.pageName = null;
+ /** @type {String} */
+ FlowApi.prototype.workflowId = null;
+ /** @type {String} */
+ FlowApi.prototype.defaultSubmodule = null;
+
+ /**
+ * Sets the fixed pageName for this API instance.
+ * @param {String} pageName
+ */
+ function flowApiSetPageName( pageName ) {
+ this.pageName = pageName;
+ }
+
+ FlowApi.prototype.setPageName = flowApiSetPageName;
+
+ /**
+ * Sets the fixed workflowId for this API instance.
+ * @param {String} workflowId
+ */
+ function flowApiSetWorkflowId( workflowId ) {
+ this.workflowId = workflowId;
+ }
+
+ FlowApi.prototype.setWorkflowId = flowApiSetWorkflowId;
+
+ /**
+ * Transforms URL request parameters into API params
+ * @todo fix it server-side so we don't need this client-side
+ * @param {Object} queryMap
+ * @returns {Object}
+ */
+ function flowApiTransformMap( queryMap ) {
+ var key,
+ map = apiTransformMap[queryMap.submodule];
+ if ( !map ) {
+ return queryMap;
+ }
+ for ( key in queryMap ) {
+ if ( queryMap.hasOwnProperty( key ) ) {
+ if ( key.indexOf( map[0] ) === 0 ) {
+ queryMap[ key.replace( map[0], map[1] ) ] = queryMap[ key ];
+ delete queryMap[ key ];
+ }
+ if ( key.indexOf( 'flow_' ) === 0 ) {
+ queryMap[ key.replace( 'flow_', map[1] ) ] = queryMap[ key ];
+ delete queryMap[ key ];
+ }
+ }
+ }
+
+ return queryMap;
+ }
+
+ /**
+ * Sets the fixed defaultSubmodule for this API instance.
+ * @param {String} defaultSubmodule
+ */
+ function flowApiSetDefaultSubmodule( defaultSubmodule ) {
+ this.defaultSubmodule = defaultSubmodule;
+ }
+
+ FlowApi.prototype.setDefaultSubmodule = flowApiSetDefaultSubmodule;
+
+ /**
+ * Overrides (values of) queryMap with a provided override, which can come
+ * in the form of an object (which the queryMap will be extended with) or as
+ * a function (whose return value will replace queryMap)
+ *
+ * @param {Object} [queryMap]
+ * @param {Function|Object} [override]
+ * @returns {Object}
+ */
+ function flowOverrideQueryMap( queryMap, override ) {
+ if ( override ) {
+ switch ( typeof override ) {
+ // If given an override object, extend our queryMap with it
+ case 'object':
+ $.extend( queryMap, override );
+ break;
+ // If given an override function, call it and make it return the new queryMap
+ case 'function':
+ queryMap = override( queryMap );
+ break;
+ }
+ }
+
+ return queryMap;
+ }
+
+ /**
+ * With a url (a://b.c/d?e=f&g#h) will return an object of key-value pairs ({e:'f', g:''}).
+ * @param {String|Element} url
+ * @param {Object} [queryMap]
+ * @param {Array<(Function|Object)>} [overrides]
+ * @returns {Object}
+ */
+ function flowApiGetQueryMap( url, queryMap, overrides ) {
+ var uri,
+ queryKey,
+ queryValue,
+ i = 0,
+ $node, $form, formData;
+
+ queryMap = queryMap || {};
+ overrides = overrides || [];
+
+ // If URL is an Element...
+ if ( typeof url !== 'string' ) {
+ $node = $( url );
+
+ // Get the data-flow-api-action override from the node itself
+ queryMap.submodule = $node.data( 'flow-api-action' );
+
+ if ( $node.is( 'form, input, button, textarea, select, option' ) ) {
+ // We are processing a form
+ $form = $node.closest( 'form' );
+ formData = $form.serializeArray();
+
+ // Get the data-flow-api-action override from the form
+ queryMap.submodule = queryMap.submodule || $form.data( 'flow-api-action' );
+
+ // Build the queryMap manually from a serialized form
+ for ( i = 0; i < formData.length; i++ ) {
+ // skip wpEditToken, its handle independently
+ if ( formData[ i ].name !== 'wpEditToken' ) {
+ queryMap[ formData[ i ].name ] = formData[ i ].value;
+ }
+ }
+
+ // Add the given button to the queryMap as well
+ if ( $node.is( 'button, input' ) && $node.prop( 'name' ) ) {
+ queryMap[ $node.prop( 'name' ) ] = $node.val();
+ }
+
+ // Now process the form action as the URL
+ url = $form.attr( 'action' );
+ } else if ( $node.is( 'a' ) ) {
+ // It's an anchor, process the href as the URL
+ url = $node.prop( 'href' );
+ } else {
+ // Somebody set up us the bomb
+ url = '';
+ }
+ }
+
+ // Parse the URL query params
+ uri = new mw.Uri( url );
+
+ for ( queryKey in uri.query ) {
+ queryValue = uri.query[queryKey];
+ if ( queryKey === 'action' ) {
+ // Submodule is the action
+ queryKey = 'submodule';
+ }
+ if ( queryKey === 'title' ) {
+ // Server is using page
+ queryKey = 'page';
+ }
+
+ // Only add this to the query map if it didn't already exist, eg. in a form input
+ if ( !queryMap[ queryKey ] ) {
+ queryMap[ queryKey ] = queryValue;
+ }
+ }
+
+ // Use the default submodule if no action in URL
+ queryMap.submodule = queryMap.submodule || this.defaultSubmodule;
+ // Default action is flow
+ queryMap.action = queryMap.action || 'flow';
+
+ // Override the automatically generated queryMap
+ for ( i = 0; i < overrides.length; i++ ) {
+ queryMap = flowOverrideQueryMap( queryMap, overrides[i] );
+ }
+
+ // Use the API map to transform this data if necessary, eg.
+ queryMap = flowApiTransformMap( queryMap );
+
+ return queryMap;
+ }
+
+ FlowApi.prototype.getQueryMap = flowApiGetQueryMap;
+
+ /**
+ * Using a given form, parses its action, serializes the data, and sends it as GET or POST depending on form method.
+ * With button, its name=value is serialized in. If button is an Event, it will attempt to find the clicked button.
+ * Additional params can be set with data-flow-api-params on both the clicked button or the form.
+ * @param {Event|Element} [button]
+ * @param {Array<(Function|Object)>} [overrides]
+ * @return {$.Deferred}
+ */
+ function flowApiRequestFromForm( button, overrides ) {
+ var $deferred = $.Deferred(),
+ $button = $( button ),
+ method = $button.closest( 'form' ).attr( 'method' ) || 'GET',
+ queryMap;
+
+ // Parse the form action to get the rest of the queryMap
+ if ( !( queryMap = this.getQueryMap( button, null, overrides ) ) ) {
+ return $deferred.rejectWith( { error: 'Invalid form action' } );
+ }
+
+ if ( !( queryMap.action ) ) {
+ return $deferred.rejectWith( { error: 'Unknown action for form' } );
+ }
+
+ // Cancel any old form request, and also trigger a new one
+ return this.abortOldRequestFromNode( $button, queryMap, method );
+ }
+
+ FlowApi.prototype.requestFromForm = flowApiRequestFromForm;
+
+ /**
+ * Using a given anchor, parses its URL and sends it as a GET (default) or POST depending on data-flow-api-method.
+ * Additional params can be set with data-flow-api-params.
+ * @param {Element} anchor
+ * @param {Array<(Function|Object)>} [overrides]
+ * @return {$.Deferred}
+ */
+ function flowApiRequestFromAnchor( anchor, overrides ) {
+ var $anchor = $( anchor ),
+ $deferred = $.Deferred(),
+ queryMap,
+ method = $anchor.data( 'flow-api-method' ) || 'GET';
+
+ // Build the query map from this anchor's HREF
+ if ( !( queryMap = this.getQueryMap( anchor.href, null, overrides ) ) ) {
+ mw.flow.debug( '[FlowApi] requestFromAnchor error: invalid href', arguments );
+ return $deferred.rejectWith( { error: 'Invalid href' } );
+ }
+
+ // Abort any old requests, and have it issue a new one via GET or POST
+ return this.abortOldRequestFromNode( $anchor, queryMap, method );
+ }
+
+ FlowApi.prototype.requestFromAnchor = flowApiRequestFromAnchor;
+
+ /**
+ * Automatically calls requestFromAnchor or requestFromForm depending on the type of node given.
+ * @param {Element} node
+ * @param {Array<(Function|Object)>} [overrides]
+ * @return {$.Deferred|bool}
+ */
+ function flowApiRequestFromNode( node, overrides ) {
+ var $node = $( node );
+
+ if ( $node.is( 'a' ) ) {
+ return this.requestFromAnchor.apply( this, arguments );
+ } else if ( $node.is( 'form, input, button, textarea, select, option' ) ) {
+ return this.requestFromForm.apply( this, arguments );
+ } else {
+ return false;
+ }
+ }
+
+ FlowApi.prototype.requestFromNode = flowApiRequestFromNode;
+
+ /**
+ * Handles aborting an old in-flight API request.
+ * If startNewMethod is given, this method also STARTS a new API call and stores it for later abortion if needed.
+ * @param {jQuery|Element} $node
+ * @param {Object} [queryMap]
+ * @param {String} [startNewMethod] If given: starts, stores, and returns a new API call
+ * @param {Array<(Function|Object)>} [overrides]
+ * @return {undefined|$.Deferred}
+ */
+ function flowApiAbortOldRequestFromNode( $node, queryMap, startNewMethod, overrides ) {
+ $node = $( $node );
+
+ if ( !queryMap ) {
+ // Get the queryMap automatically if one wasn't given
+ if ( !( queryMap = this.getQueryMap( $node, null, overrides ) ) ) {
+ mw.flow.debug( '[FlowApi] abortOldRequestFromNode failed to find a queryMap', arguments );
+ return;
+ }
+ }
+
+ // If this anchor already has a request in flight, abort it
+ var str = 'flow-api-query-temp-' + queryMap.action + '-' + queryMap.submodule,
+ prevApiCall = $node.data( str ),
+ newApiCall;
+
+ // If a previous API call was found, let's abort it
+ if ( prevApiCall ) {
+ $node.removeData( str );
+
+ if ( prevApiCall.abort ) {
+ prevApiCall.abort();
+ }
+
+ mw.flow.debug( '[FlowApi] apiCall abort request in flight: ' + str, arguments );
+ }
+
+ // If a method was given, we want to also issue a new API request now
+ if ( startNewMethod ) {
+ // Make a new request with this info
+ newApiCall = this.apiCall( queryMap, startNewMethod );
+
+ // Store this request on the node if it needs to be aborted
+ $node.data(
+ 'flow-api-query-temp-' + queryMap.action + '-' + queryMap.submodule,
+ newApiCall
+ );
+
+ // Remove the request on success
+ newApiCall.always( function () {
+ $node.removeData( 'flow-api-query-temp-' + queryMap.action + '-' + queryMap.submodule );
+ } );
+
+ return newApiCall;
+ }
+ }
+
+ FlowApi.prototype.abortOldRequestFromNode = flowApiAbortOldRequestFromNode;
+
+ // Export
+ mw.flow.FlowApi = FlowApi;
+}( mw, jQuery ) );
diff --git a/Flow/modules/engine/misc/flow-baseconvert.js b/Flow/modules/engine/misc/flow-baseconvert.js
new file mode 100644
index 00000000..ae6e755f
--- /dev/null
+++ b/Flow/modules/engine/misc/flow-baseconvert.js
@@ -0,0 +1,70 @@
+( function ( mw, $ ) {
+ mw.flow = mw.flow || {}; // create mw.flow globally
+
+ // Direct translation of wfBaseConvert. Can't be done with parseInt and
+ // Integer.toString because javascript uses doubles for math, giving only
+ // 53 bits of precision.
+ mw.flow.baseConvert = function ( input, sourceBase, destBase ) {
+ var regex = new RegExp( "^[" + '0123456789abcdefghijklmnopqrstuvwxyz'.substr( 0, sourceBase ) + "]+$" ),
+ baseChars = {
+ '10': 'a', '11': 'b', '12': 'c', '13': 'd', '14': 'e', '15': 'f',
+ '16': 'g', '17': 'h', '18': 'i', '19': 'j', '20': 'k', '21': 'l',
+ '22': 'm', '23': 'n', '24': 'o', '25': 'p', '26': 'q', '27': 'r',
+ '28': 's', '29': 't', '30': 'u', '31': 'v', '32': 'w', '33': 'x',
+ '34': 'y', '35': 'z',
+
+ '0': 0, '1': 1, '2': 2, '3': 3, '4': 4, '5': 5,
+ '6': 6, '7': 7, '8': 8, '9': 9, 'a': 10, 'b': 11,
+ 'c': 12, 'd': 13, 'e': 14, 'f': 15, 'g': 16, 'h': 17,
+ 'i': 18, 'j': 19, 'k': 20, 'l': 21, 'm': 22, 'n': 23,
+ 'o': 24, 'p': 25, 'q': 26, 'r': 27, 's': 28, 't': 29,
+ 'u': 30, 'v': 31, 'w': 32, 'x': 33, 'y': 34, 'z': 35
+ },
+ inDigits = [],
+ result = [],
+ i, work, workDigits;
+
+ input = String( input );
+ if ( sourceBase < 2 ||
+ sourceBase > 36 ||
+ destBase < 2 ||
+ destBase > 36 ||
+ sourceBase !== parseInt( sourceBase, 10 ) ||
+ destBase !== parseInt( destBase, 10 ) ||
+ !regex.test( input )
+ ) {
+ return false;
+ }
+
+ for ( i in input ) {
+ inDigits.push( baseChars[input[i]] );
+ }
+
+ // Iterate over the input, modulo-ing out an output digit
+ // at a time until input is gone.
+ while( inDigits.length ) {
+ work = 0;
+ workDigits = [];
+
+ // Long division...
+ for ( i in inDigits ) {
+ work *= sourceBase;
+ work += inDigits[i];
+
+ if ( workDigits.length || work >= destBase ) {
+ workDigits.push( parseInt( work / destBase, 10 ) );
+ }
+ work %= destBase;
+ }
+
+ // All that division leaves us with a remainder,
+ // which is conveniently our next output digit
+ result.push( baseChars[work] );
+
+ // And we continue
+ inDigits = workDigits;
+ }
+
+ return result.reverse().join("");
+ };
+}( mw, jQuery ) );
diff --git a/Flow/modules/engine/misc/flow-eventlog.js b/Flow/modules/engine/misc/flow-eventlog.js
new file mode 100644
index 00000000..aafa7515
--- /dev/null
+++ b/Flow/modules/engine/misc/flow-eventlog.js
@@ -0,0 +1,45 @@
+( function ( mw, $ ) {
+ /**
+ * @param {String} schemaName Canonical schema name.
+ * @param {Object} [eventInstance] Shared event instance data.
+ * @returns {FlowEventLog}
+ * @constructor
+ */
+ function FlowEventLog( schemaName, eventInstance ) {
+ this.schemaName = schemaName;
+ this.eventInstance = eventInstance || {};
+
+ /**
+ * @param {object} eventInstance Additional event instance data for this
+ * particular event.
+ * @returns {$.Deferred}
+ */
+ function logEvent( eventInstance ) {
+ // Ensure eventLog & this schema exist, or return a stub deferred
+ if ( !mw.eventLog || !mw.eventLog.schemas[this.schemaName] ) {
+ return $.Deferred().promise();
+ }
+
+ return mw.eventLog.logEvent(
+ this.schemaName,
+ $.extend( this.eventInstance, eventInstance )
+ );
+ }
+ this.logEvent = logEvent;
+ }
+
+ var FlowEventLogRegistry = {
+ funnels: {},
+
+ /**
+ * Generates a unique id.
+ *
+ * @returns {string}
+ */
+ generateFunnelId: mw.user.generateRandomSessionId
+ };
+
+ // Export
+ mw.flow.FlowEventLog = FlowEventLog;
+ mw.flow.FlowEventLogRegistry = FlowEventLogRegistry;
+}( mw, jQuery ) );
diff --git a/Flow/modules/engine/misc/flow-handlebars.js b/Flow/modules/engine/misc/flow-handlebars.js
new file mode 100644
index 00000000..b3f21925
--- /dev/null
+++ b/Flow/modules/engine/misc/flow-handlebars.js
@@ -0,0 +1,581 @@
+/*!
+ * Implements a Handlebars layer for FlowBoard.TemplateEngine
+ */
+
+( function ( mw, $, moment, Handlebars ) {
+ mw.flow = mw.flow || {}; // create mw.flow globally
+
+ var _tplcache = {},
+ _timestamp = {
+ list: [],
+ currentIndex: 0
+ };
+
+
+ /**
+ * Instantiates a FlowHandlebars instance for TemplateEngine.
+ * @param {Object} FlowStorageEngine
+ * @returns {FlowHandlebars}
+ * @constructor
+ */
+ function FlowHandlebars( FlowStorageEngine ) {
+ return this;
+ }
+
+ mw.flow.FlowHandlebars = FlowHandlebars;
+
+ /**
+ * Returns a given template function. If template is missing, the template function is noop with mw.flow.debug.
+ * @param {String|Function} templateName
+ * @returns {Function}
+ */
+ FlowHandlebars.prototype.getTemplate = function ( templateName ) {
+ // If a template is already being passed, use it
+ if ( typeof templateName === 'function' ) {
+ return templateName;
+ }
+
+ if ( _tplcache[ templateName ] ) {
+ // Return cached compiled template
+ return _tplcache[ templateName ];
+ }
+
+ _tplcache[ templateName ] = mw.template.get( 'ext.flow.templating', 'handlebars/' + templateName + '.handlebars' );
+ if ( _tplcache[ templateName ] ) {
+ // Try to get this template
+ _tplcache[ templateName ] = _tplcache[ templateName ].render;
+ }
+
+ return _tplcache[ templateName ] || function () { mw.flow.debug( '[Handlebars] Missing template', arguments ); };
+ };
+
+ /**
+ * Processes a given template and returns the HTML generated by it.
+ * @param {String} templateName
+ * @param {*} [args]
+ * @returns {String}
+ */
+ FlowHandlebars.prototype.processTemplate = function ( templateName, args ) {
+ return FlowHandlebars.prototype.getTemplate( templateName )( args );
+ };
+
+ /**
+ * Runs processTemplate inside, but returns a DocumentFragment instead of an HTML string.
+ * This should be used for runtime parsing of a template, as it triggers processProgressiveEnhancement on the
+ * fragment, which allows progressiveEnhancement blocks to be instantiated.
+ * @param {String} templateName
+ * @param {*} [args]
+ * @returns {DocumentFragment}
+ */
+ FlowHandlebars.prototype.processTemplateGetFragment = function ( templateName, args ) {
+ var fragment = document.createDocumentFragment(),
+ div = document.createElement( 'div' );
+
+ div.innerHTML = FlowHandlebars.prototype.processTemplate( templateName, args );
+
+ FlowHandlebars.prototype.processProgressiveEnhancement( div );
+
+ while ( div.firstChild ) {
+ fragment.appendChild( div.firstChild );
+ }
+
+ div = null;
+
+ return fragment;
+ };
+
+ /**
+ * A method to call helper functions from outside templates. This removes Handlebars.SafeString wrappers.
+ * @param {String} helperName
+ * @param {...*} [args]
+ * @return mixed
+ */
+ FlowHandlebars.prototype.callHelper = function ( helperName, args ) {
+ var result = this[ helperName ].apply( this, Array.prototype.slice.call( arguments, 1 ) );
+ if ( result && result.string ) {
+ return result.string;
+ }
+ return result;
+ };
+
+ /**
+ * Finds scripts of x-handlebars-template-progressive-enhancement type, compiles its innerHTML as a Handlebars
+ * template, and then replaces the whole script tag with it. This is used to "progressively enhance" a page with
+ * elements that are only necessary with JavaScript. On a non-JS page, these elements are never rendered at all.
+ * @param {Element|jQuery} target
+ * @todo Lacks args, lacks functionality, full support. (see also FlowHandlebars.prototype.progressiveEnhancement)
+ */
+ FlowHandlebars.prototype.processProgressiveEnhancement = function ( target ) {
+ $( target ).find( 'script' ).addBack( 'script' ).filter( '[type="text/x-handlebars-template-progressive-enhancement"]' ).each( function () {
+ var $this = $( this ),
+ data = $this.data(),
+ target = $.trim( data.target ),
+ $target = $this,
+ content, $prevTarg, $nextTarg;
+
+ // Find new target, if not the script tag itself
+ if ( target ) {
+ $target = $this.findWithParent( target );
+
+ if ( !$target.length ) {
+ mw.flow.debug( '[processProgressiveEnhancement] Failed to find target', target, arguments );
+ return;
+ }
+ }
+
+ // Replace the nested flowprogressivescript tag with a real script tag for recursive progressiveEnhancement
+ content = this.innerHTML.replace( /<\/flowprogressivescript>/g, '</script>' );
+
+ // Inject the content
+ switch ( data.type ) {
+ case 'content':
+ // Insert
+ $target.empty().append( content );
+ // Get all new nodes
+ $target = $target.children();
+ break;
+
+ case 'insert':
+ // Store sibling before adding new content
+ $prevTarg = $target.prev();
+ // Insert
+ $target.before( content );
+ // Get all new nodes
+ $target = $target.prevUntil( $prevTarg );
+ break;
+
+ case 'replace':
+ /* falls through */
+ default:
+ // Store siblings before adding new content
+ $prevTarg = $target.prev();
+ $nextTarg = $target.next();
+ // Insert
+ $target.replaceWith( content );
+ // Get all new nodes
+ $target = $prevTarg.nextUntil( $nextTarg );
+ }
+
+ // $target now contains all the new elements inserted; let's recursively do progressiveEnhancement if needed
+ FlowHandlebars.prototype.processProgressiveEnhancement( $target );
+
+ // Remove script tag
+ $this.remove();
+ } );
+ };
+
+ /**
+ * Parameters could be Message::rawParam (in PHP) object, which will
+ * translate into a { raw: "string" } object in JS.
+ * @todo: this does not exactly match the behavior in PHP yet (no parse,
+ * no escape), but at least it won't print an [Object object] param.
+ *
+ * @param {Array} parameters
+ * @return {Array}
+ */
+ function flowNormalizeL10nParameters( parameters ) {
+ return $.map( parameters, function ( arg ) {
+ return arg ? ( arg.raw || arg.plaintext || arg ) : '';
+ } );
+ }
+
+ /**
+ * Calls flowMessages to get localized message strings.
+ * @todo use mw.message
+ * @example {{l10n "reply_count" 12}}
+ * @param {String} str
+ * @param {...*} [args]
+ * @param {Object} [options]
+ * @returns {String}
+ */
+ FlowHandlebars.prototype.l10n = function ( str /*, args..., options */ ) {
+ // chop off str and options leaving just args
+ var args = flowNormalizeL10nParameters( Array.prototype.slice.call( arguments, 1, -1 ) );
+
+ return mw.message( str ).params( args ).text();
+ };
+
+ /**
+ * HTML-safe version of l10n.
+ * @returns {String|Handlebars.SafeString}
+ */
+ FlowHandlebars.prototype.l10nParse = function ( str /*, args..., options */ ) {
+ var args = flowNormalizeL10nParameters( Array.prototype.slice.call( arguments, 1, -1 ) );
+
+ return FlowHandlebars.prototype.html(
+ mw.message( str ).params( args ).parse()
+ );
+ };
+
+ /**
+ * Parses the timestamp out of a base-36 UUID, and calls timestamp with it.
+ * @example {{uuidTimestamp id "flow-message-x-"}}
+ * @param {String} uuid id
+ * @param {bool} [timeAgoOnly]
+ * @returns {String}
+ */
+ FlowHandlebars.prototype.uuidTimestamp = function ( uuid, timeAgoOnly ) {
+ var timestamp = mw.flow.uuidToTime( uuid );
+
+ return FlowHandlebars.prototype.timestamp( timestamp, timeAgoOnly );
+ };
+
+ /**
+ * Generates markup for an "nnn sssss ago" and date/time string.
+ * @example {{timestamp start_time}}
+ * @param {int} timestamp milliseconds
+ * @returns {String}
+ */
+ FlowHandlebars.prototype.timestamp = function ( timestamp ) {
+ if ( isNaN( timestamp ) ) {
+ mw.flow.debug( '[timestamp] Invalid arguments', arguments );
+ return;
+ }
+
+ var guid,
+ formatter = moment( timestamp );
+
+ // Generate a GUID for this element to find it later
+ guid = ( Math.random() + 1 ).toString( 36 ).substring( 2 );
+
+ // Store this in the timestamps auto-updater array
+ _timestamp.list.push( { guid: guid, timestamp: timestamp, failcount: 0 } );
+
+ // Render the timestamp template
+ return FlowHandlebars.prototype.html(
+ FlowHandlebars.prototype.processTemplate(
+ 'timestamp',
+ {
+ time_iso: timestamp,
+ time_ago: formatter.fromNow(),
+ time_readable: formatter.format( 'LLL' ),
+ guid: guid
+ }
+ )
+ );
+ };
+
+ /**
+ * Updates one flow-timestamp node at a time every 100ms, until finishing, and then sleeps 5s.
+ * Nodes do not get updated again until they have changed.
+ * @todo Perhaps only update elements within the viewport?
+ * @todo Maybe updating elements every few seconds is distracting? Think about this.
+ */
+ function timestampAutoUpdate() {
+ var arrayItem, $ago, failed, secondsAgo, text, formatter,
+ currentTime = +new Date() / 1000;
+
+ // Only update elements that need updating (eg. only update minutes every 60s)
+ do {
+ arrayItem = _timestamp.list[ _timestamp.list._currentIndex ];
+
+ if ( !arrayItem || !arrayItem.nextUpdate || currentTime >= arrayItem.nextUpdate ) {
+ break;
+ }
+
+ // Find the next array item
+ _timestamp.list._currentIndex++;
+ } while ( arrayItem );
+
+ if ( !arrayItem ) {
+ // Finished array; reset loop
+ _timestamp.list._currentIndex = 0;
+
+ // Run again in 5s
+ setTimeout( timestampAutoUpdate, 5000 );
+ return;
+ }
+
+ $ago = $( document.getElementById( arrayItem.guid ) );
+ failed = true;
+ secondsAgo = currentTime - ( arrayItem.timestamp / 1000 );
+
+ if ( $ago && $ago.length ) {
+ formatter = moment( arrayItem.timestamp );
+ text = formatter.fromNow();
+
+ // Returned a valid "n ago" string?
+ if ( text ) {
+ // Reset the failcount
+ failed = arrayItem.failcount = 0;
+
+ // Set the next update time
+ arrayItem.nextUpdate = currentTime + ( secondsAgo > 604800 ? 604800 - currentTime % 604800 : ( secondsAgo > 86400 ? 86400 - currentTime % 86400 : ( secondsAgo > 3600 ? 3600 - currentTime % 3600 : ( secondsAgo > 60 ? 60 - currentTime % 60 : 1 ) ) ) );
+
+ // Only touch the DOM if the text has actually changed
+ if ( $ago.text() !== text ) {
+ $ago.text( text );
+ }
+ }
+ }
+
+ if ( failed && ++arrayItem.failcount > 9 ) {
+ // Remove this array item if we failed this 10 times in a row
+ _timestamp.list.splice( _timestamp.list._currentIndex, 1 );
+ } else {
+ // Go to next item
+ _timestamp.list._currentIndex++;
+ }
+
+ // Run every 100ms until we update all nodes
+ setTimeout( timestampAutoUpdate, 100 );
+ }
+
+ $( document ).ready( timestampAutoUpdate );
+
+ /**
+ * Do not escape HTML string. Used as a Handlebars helper.
+ * @example {{html "<div/>"}}
+ * @param {String} string
+ * @returns {String|Handlebars.SafeString}
+ */
+ FlowHandlebars.prototype.html = function ( string ) {
+ return new Handlebars.SafeString( string );
+ };
+
+ /**
+ *
+ * @example {{block this}}
+ * @param {Object} context
+ * @param {Object} options
+ * @returns {String}
+ */
+ FlowHandlebars.prototype.workflowBlock = function ( context, options ) {
+ return FlowHandlebars.prototype.html( FlowHandlebars.prototype.processTemplate(
+ 'flow_block_' + context.type + ( context['block-action-template'] || '' ),
+ context
+ ) );
+ };
+
+ /**
+ * @example {{post ../../../../rootBlock this}}
+ * @param {Object} context
+ * @param {Object} revision
+ * @param {Object} options
+ * @returns {String}
+ */
+ FlowHandlebars.prototype.postBlock = function ( context, revision, options ) {
+ return FlowHandlebars.prototype.html( FlowHandlebars.prototype.processTemplate(
+ 'flow_post',
+ {
+ revision: revision,
+ rootBlock: context
+ }
+ ) );
+ };
+
+ /**
+ * @example {{#each topics}}{{#eachPost this}}{{content}}{{/eachPost}}{{/each}}
+ * @param {String} context
+ * @param {String} postId
+ * @param {Object} options
+ * @returns {String}
+ * @todo support multiple postIds in an array
+ */
+ FlowHandlebars.prototype.eachPost = function ( context, postId, options ) {
+ var revId = ( context.posts && context.posts[postId] && context.posts[postId][0] ),
+ revision = ( context.revisions && context.revisions[revId] ) || { content: null };
+
+ if ( revision.content === null ) {
+ mw.flow.debug( '[eachPost] Failed to find revision object', arguments );
+ }
+
+ return options.fn ? options.fn( revision ) : revision;
+ };
+
+ /**
+ * The progressiveEnhancement helper essentially does one of replace things:
+ * 1. type="replace": (target="selector") Replaces target entirely with rendered template.
+ * 2. type="content": (target="selector") Replaces target's content with rendered template.
+ * 3. type="insert": Inserts rendered template at the helper's location.
+ *
+ * This template is used to simplify server-side and client-side rendering. Client-side renders a
+ * progressiveEnhancement helper instantly, in the post-process stage. The server-side renders only a script tag
+ * with a template inside. This script tag is found ondomready, and then the post-processing occurs at that time.
+ *
+ * Option keys:
+ * * type=String (replace, content, insert)
+ * * target=String (jQuery selector; needed for replace and content -- defaults to self)
+ * * id=String
+ * @example {{#progressiveEnhancement type="content"}}{{> ok}}{{/progressiveEnhancement}}
+ * @param {Object} options
+ * @return {String}
+ * @todo Implement support for full functionality, perhaps revisit the implementation.
+ */
+ FlowHandlebars.prototype.progressiveEnhancement = function ( options ) {
+ var hash = options.hash,
+ // Replace nested script tag with placeholder tag for
+ // recursive progresiveEnhancement
+ inner = options.fn( this ).replace( /<\/script>/g, '</flowprogressivescript>' );
+
+ if ( !hash.type ) {
+ hash.type = 'insert';
+ }
+
+ return FlowHandlebars.prototype.html(
+ '<scr' + 'ipt' +
+ ' type="text/x-handlebars-template-progressive-enhancement"' +
+ ' data-type="' + hash.type + '"' +
+ ( hash.target ? ' data-target="' + hash.target + '"' : '' ) +
+ ( hash.id ? ' id="' + hash.id + '"' : '' ) +
+ '>' +
+ inner +
+ '</scr' + 'ipt>'
+ );
+ };
+
+ /**
+ * Runs a callback when user is anonymous
+ * @param array $options which must contain fn and inverse key mapping to functions.
+ *
+ * @return mixed result of callback
+ */
+ FlowHandlebars.prototype.ifAnonymous = function( options ) {
+ if ( mw.user.isAnon() ) {
+ return options.fn( this );
+ }
+ return options.inverse( this );
+ };
+
+ /**
+ * Adds returnto parameter pointing to given Title to an existing URL
+ * @param string $title
+ *
+ * @return string modified url
+ */
+ FlowHandlebars.prototype.linkWithReturnTo = function( title ) {
+ return mw.util.getUrl( title, {
+ returntoquery: encodeURIComponent( window.location.search ),
+ returnto: mw.config.get( 'wgPageName' )
+ } );
+ };
+
+ /**
+ * Accepts the contentType and content properties returned from the api
+ * for individual revisions and ensures that content is included in the
+ * final html page in an XSS safe maner.
+ *
+ * It is expected that all content with contentType of html has been
+ * processed by parsoid and is safe for direct output into the document.
+ *
+ * Usage:
+ * {{escapeContent revision.contentType revision.content}}
+ *
+ * @param {string} contentType
+ * @param {string} content
+ * @return {string}
+ */
+ FlowHandlebars.prototype.escapeContent = function ( contentType, content ) {
+ if ( contentType === 'html' ) {
+ return FlowHandlebars.prototype.html( content );
+ }
+ return content;
+ };
+
+ /**
+ * Renders a tooltip node.
+ * @example {{#tooltip positionClass="up" contextClass="progressive" extraClass="flow-my-tooltip"}}what{{/tooltip}}
+ * @param {Object} options
+ * @returns {String}
+ */
+ FlowHandlebars.prototype.tooltip = function ( options ) {
+ var params = options.hash;
+
+ return FlowHandlebars.prototype.html( FlowHandlebars.prototype.processTemplate(
+ 'flow_tooltip',
+ {
+ positionClass: params.positionClass ? 'flow-ui-tooltip-' + params.positionClass : null,
+ contextClass: params.contextClass ? 'mw-ui-' + params.contextClass : null,
+ extraClass: params.extraClass,
+ blockClass: params.isBlock ? 'flow-ui-tooltip-block' : null,
+ content: options.fn( this )
+ }
+ ) );
+ };
+
+ /**
+ * Return url for putting post into the specified moderation state. If the user
+ * cannot put the post into the specified state a blank string is returned.
+ *
+ * @param {Object}
+ * @param {string}
+ * @return {string}
+ */
+ FlowHandlebars.prototype.moderationAction = function ( actions, moderationState ) {
+ return actions[moderationState] ? actions[moderationState].url : '';
+ };
+
+ /**
+ * Concatenate all unnamed handlebars arguments
+ *
+ * @return {string}
+ */
+ FlowHandlebars.prototype.concat = function () {
+ // handlebars puts an options argument at the end of
+ // user supplied parameters, pop that off
+ return Array.prototype.slice.call( arguments, 0, -1 ).join( '' );
+ };
+
+ /**
+ * Renders block if condition is true
+ *
+ * @param {string} value
+ * @param {string} operator supported values: 'or'
+ * @param {string} value
+ */
+ FlowHandlebars.prototype.ifCond = function ( value, operator, value2, options ) {
+ if ( operator === 'or' ) {
+ return value || value2 ? options.fn( this ) : options.inverse( this );
+ }
+ if ( operator === '===' ) {
+ return value === value2 ? options.fn( this ) : options.inverse( this );
+ }
+ if ( operator === '!==' ) {
+ return value !== value2 ? options.fn( this ) : options.inverse( this );
+ }
+ return '';
+ };
+
+ /**
+ * Outputs debugging information
+ *
+ * For development use only
+ */
+ FlowHandlebars.prototype.debug = function () {
+ mw.flow.debug( '[Handlebars] debug', arguments );
+ };
+
+ // Load partials
+ $.each( mw.templates.values, function( moduleName ) {
+ $.each( this, function( name ) {
+ // remove extension
+ var partialMatch, partialName;
+
+ partialMatch = name.match( /handlebars\/(.*)\.partial\.handlebars$/ );
+ if ( partialMatch ) {
+ partialName = partialMatch[1];
+ Handlebars.partials[ partialName ] = mw.template.get( moduleName, name ).render;
+ }
+ } );
+ } );
+
+ // Register helpers
+ Handlebars.registerHelper( 'l10n', FlowHandlebars.prototype.l10n );
+ Handlebars.registerHelper( 'l10nParse', FlowHandlebars.prototype.l10nParse );
+ Handlebars.registerHelper( 'uuidTimestamp', FlowHandlebars.prototype.uuidTimestamp );
+ Handlebars.registerHelper( 'timestamp', FlowHandlebars.prototype.timestamp );
+ Handlebars.registerHelper( 'html', FlowHandlebars.prototype.html );
+ Handlebars.registerHelper( 'block', FlowHandlebars.prototype.workflowBlock );
+ Handlebars.registerHelper( 'post', FlowHandlebars.prototype.postBlock );
+ Handlebars.registerHelper( 'eachPost', FlowHandlebars.prototype.eachPost );
+ Handlebars.registerHelper( 'progressiveEnhancement', FlowHandlebars.prototype.progressiveEnhancement );
+ Handlebars.registerHelper( 'ifAnonymous', FlowHandlebars.prototype.ifAnonymous );
+ Handlebars.registerHelper( 'linkWithReturnTo', FlowHandlebars.prototype.linkWithReturnTo );
+ Handlebars.registerHelper( 'escapeContent', FlowHandlebars.prototype.escapeContent );
+ Handlebars.registerHelper( 'tooltip', FlowHandlebars.prototype.tooltip );
+ Handlebars.registerHelper( 'moderationAction', FlowHandlebars.prototype.moderationAction );
+ Handlebars.registerHelper( 'concat', FlowHandlebars.prototype.concat );
+ Handlebars.registerHelper( 'ifCond', FlowHandlebars.prototype.ifCond );
+ Handlebars.registerHelper( 'debug', FlowHandlebars.prototype.debug );
+
+}( mediaWiki, jQuery, moment, Handlebars ) );
diff --git a/Flow/modules/engine/misc/jquery.conditionalScroll.js b/Flow/modules/engine/misc/jquery.conditionalScroll.js
new file mode 100644
index 00000000..27d4791f
--- /dev/null
+++ b/Flow/modules/engine/misc/jquery.conditionalScroll.js
@@ -0,0 +1,51 @@
+( function ( $ ) {
+ /**
+ * Scrolls the viewport to fit $el into view only if necessary. Scenarios:
+ * 1. If el starts above viewport, scrolls to put top of el at top of viewport.
+ * 2. If el ends below viewport and fits into viewport, scrolls to put bottom of el at bottom of viewport.
+ * 3. If el ends below viewport but is taller than the viewport, scrolls to put top of el at top of viewport.
+ * @param {string|int} [speed='fast']
+ */
+ $.fn.conditionalScrollIntoView = function ( speed ) {
+ speed = speed !== undefined ? speed : 'fast';
+
+ // We queue this to happen on the element, because we need to wait for it to finish performing its own
+ // animations (eg. it might be doing a slideDown), even though THIS actual animation occurs on body.
+ this.queue( function () {
+ var $this = $( this ),
+ viewportY = $( window ).scrollTop(),
+ viewportHeight = $( window ).height(),
+ elOffset = $this.offset(),
+ elHeight = $this.outerHeight(),
+ scrollTo = -1;
+
+ if ( elOffset.top < viewportY ) {
+ // Element starts above viewport; put el top at top
+ scrollTo = elOffset.top;
+ } else if ( elOffset.top + elHeight > viewportY + viewportHeight ) {
+ // Element ends below viewport
+ if ( elHeight > viewportHeight ) {
+ // Too tall to fit into viewport; put el top at top
+ scrollTo = elOffset.top;
+ } else {
+ // Fits into viewport; put el bottom at bottom
+ scrollTo = elOffset.top + elHeight - viewportHeight;
+ }
+ } // else: element is already in viewport.
+
+ if ( scrollTo > -1 ) {
+ // Scroll the viewport to display this element
+ $( 'html, body' ).animate( { scrollTop: scrollTo }, speed, function () {
+ // Fire off the next fx queue on the main element when we finish scrolling the window
+ $this.dequeue();
+ } );
+ } else {
+ // If we don't have to scroll, continue to the next fx queue item immediately
+ $this.dequeue();
+ }
+ } );
+
+ // Do nothing
+ return this;
+ };
+}( jQuery ) );
diff --git a/Flow/modules/engine/misc/jquery.findWithParent.js b/Flow/modules/engine/misc/jquery.findWithParent.js
new file mode 100644
index 00000000..c2c60a5f
--- /dev/null
+++ b/Flow/modules/engine/misc/jquery.findWithParent.js
@@ -0,0 +1,45 @@
+( function ( $ ) {
+ /**
+ * Gives support to find parent elements using .closest with less-than selector syntax.
+ * @example $.findWithParent( $div, "< html div < body" ); // find closest parent of $div "html", find child "div" of it, find closest parent "body" of that, return "body"
+ * @example $( '#foo' ).findWithParent( '.bar < .baz' ); // find child ".bar" of "#foo", return closest parent ".baz" from there
+ * @param {jQuery|Element|String} $context
+ * @param {String} selector
+ * @returns {jQuery}
+ */
+ function jQueryFindWithParent( $context, selector ) {
+ var matches;
+
+ $context = $( $context );
+ selector = $.trim( selector );
+
+ while ( selector && ( matches = selector.match(/(.*?(?:^|[>\s+~]))(<\s*[^>\s+~]+)(.*?)$/) ) ) {
+ if ( $.trim( matches[ 1 ] ) ) {
+ $context = $context.find( matches[ 1 ] );
+ }
+ if ( $.trim( matches[ 2 ] ) ) {
+ $context = $context.closest( matches[ 2 ].substr( 1 ) );
+ }
+ selector = $.trim( matches[ 3 ] );
+ }
+
+ if ( selector ) {
+ $context = $context.find( selector );
+ }
+
+ return $context;
+ }
+
+ $.findWithParent = jQueryFindWithParent;
+ $.fn.findWithParent = function ( selector ) {
+ var selectors = selector.split( ',' ),
+ $elements = $(),
+ self = this;
+
+ $.each( selectors, function( i, selector ) {
+ $elements = $elements.add( jQueryFindWithParent( self, selector ) );
+ } );
+
+ return $elements;
+ };
+}( jQuery ) );
diff --git a/Flow/modules/engine/misc/mw-ui.enhance.js b/Flow/modules/engine/misc/mw-ui.enhance.js
new file mode 100644
index 00000000..3da4e70d
--- /dev/null
+++ b/Flow/modules/engine/misc/mw-ui.enhance.js
@@ -0,0 +1,451 @@
+/*!
+ * Enhances mediawiki-ui style elements with JavaScript.
+ */
+
+( function ( mw, $ ) {
+ /*
+ * Reduce eye-wandering due to adjacent colorful buttons
+ * This will make unhovered and unfocused sibling buttons become faded and blurred
+ * Usage: Buttons must be in a form, or in a parent with mw-ui-button-container, or they must be siblings
+ */
+ $( document ).ready( function () {
+ function onMwUiButtonFocus( event ) {
+ var $el, $form, $siblings;
+
+ if ( event.target.className.indexOf( 'mw-ui-button' ) === -1 ) {
+ // Not a button event
+ return;
+ }
+
+ $el = $( event.target );
+
+ if ( event.type !== 'keyup' || $el.is( ':focus' ) ) {
+ // Reset style
+ $el.removeClass( 'mw-ui-button-althover' );
+
+ $form = $el.closest( 'form, .mw-ui-button-container' );
+ if ( $form.length ) {
+ // If this button is in a form, apply this to all the form's buttons.
+ $siblings = $form.find( '.mw-ui-button' );
+ } else {
+ // Otherwise, try to find neighboring buttons
+ $siblings = $el.siblings( '.mw-ui-button' );
+ }
+
+ // Add fade/blur to unfocused sibling buttons
+ $siblings.not( $el ).filter( ':not(:focus)' )
+ .addClass( 'mw-ui-button-althover' );
+ }
+ }
+
+ function onMwUiButtonBlur( event ) {
+ if ( event.target.className.indexOf( 'mw-ui-button' ) === -1 ) {
+ // Not a button event
+ return;
+ }
+
+ var $el = $( event.target ),
+ $form, $siblings, $focused;
+
+ $form = $el.closest( 'form, .mw-ui-button-container' );
+ if ( $form.length ) {
+ // If this button is in a form, apply this to all the form's buttons.
+ $siblings = $form.find( '.mw-ui-button' );
+ } else {
+ // Otherwise, try to find neighboring buttons
+ $siblings = $el.siblings( '.mw-ui-button' );
+ }
+
+ // Add fade/blur to unfocused sibling buttons
+ $focused = $siblings.not( $el ).filter( ':focus' );
+
+ if ( event.type === 'mouseleave' && $el.is( ':focus' ) ) {
+ // If this button is still focused, but the mouse left it, keep siblings faded
+ return;
+ } else if ( $focused.length ) {
+ // A sibling has focus; have it trigger the restyling
+ $focused.trigger( 'mouseenter.mw-ui-enhance' );
+ } else {
+ // No other siblings are focused; removing button fading
+ $siblings.removeClass( 'mw-ui-button-althover' );
+ }
+ }
+
+ // Attach the mouseenter and mouseleave handlers on document
+ $( document )
+ .on( 'mouseenter.mw-ui-enhance', '.mw-ui-button', onMwUiButtonFocus )
+ .on( 'mouseleave.mw-ui-enhance', '.mw-ui-button', onMwUiButtonBlur );
+
+ // Attach these independently, because jQuery doesn't support useCapture mode (focus propagation)
+ if ( document.attachEvent ) {
+ document.attachEvent( 'focusin', onMwUiButtonFocus );
+ document.attachEvent( 'focusout', onMwUiButtonBlur );
+ } else {
+ document.body.addEventListener( 'focus', onMwUiButtonFocus, true );
+ document.body.addEventListener( 'blur', onMwUiButtonBlur, true );
+ }
+ } );
+
+ /**
+ * Disables action and submit buttons when a form has required fields
+ * @param {jQuery} $form jQuery object corresponding to a form element.
+ */
+ function enableFormWithRequiredFields( $form ) {
+ var
+ $fields = $form.find( 'input, textarea' ).filter( '[required]' ),
+ ready = true;
+
+ $fields.each( function () {
+ var $this = $( this );
+ if ( mw.flow.editor.exists( $this ) ) {
+ if ( mw.flow.editor.getEditor( $this ).isEmpty() ) {
+ ready = false;
+ }
+ } else if ( this.value === '' ) {
+ ready = false;
+ }
+ } );
+
+ // @todo scrap data-role? use submit types? or a single role=action?
+ $form.find( '.mw-ui-button' ).filter( '[data-role=action], [data-role=submit]' )
+ .prop( 'disabled', !ready );
+ }
+ /*
+ * Disable / enable preview and submit buttons without/with text in field.
+ * Usage: field needs required attribute
+ */
+ $( document ).ready( function () {
+ // We should probably not use this change detection method for VE
+ $( document ).on( 'keyup.flow-actions-disabler', '.mw-ui-input', function () {
+ enableFormWithRequiredFields( $( this ).closest( 'form' ) );
+ } );
+ } );
+
+
+ /*
+ * mw-ui-tooltip
+ * Renders tooltips on over, and also via mw.tooltip.
+ */
+ $( document ).ready( function () {
+ var _$tooltip = $(
+ '<span class="flow-ui-tooltip flow-ui-tooltip-left">' +
+ '<span class="flow-ui-tooltip-content"></span>' +
+ '<span class="flow-ui-tooltip-triangle"></span>' +
+ '<span class="flow-ui-tooltip-close"></span>' +
+ '</span>'
+ ),
+ $activeTooltips = $(),
+ _mwUiTooltipExpireTimer;
+
+ /**
+ * Renders a tooltip at target.
+ * Options (either given as param, or fetched from target as data-tooltip-x params):
+ * tooltipSize=String (small,large,block)
+ * tooltipContext=String (constructive,destructive,progressive,regressive)
+ * tooltipPointing=String (up,down,left,right)
+ * tooltipClosable=Boolean
+ * tooltipContentCallback=Function
+ *
+ * @param {jQuery|Element} target
+ * @param {jQuery|Element|String} [content] A jQuery set, an element, or a string of
+ * HTML. If omitted, first tries tooltipContentCallback, then target.title
+ * @param {Object} [options]
+ */
+ function mwUiTooltipShow( target, content, options ) {
+ var $target = $( target ),
+ // Find previous tooltip for this el
+ $tooltip = $target.data( '$tooltip' ),
+
+ // Get window size and scroll details
+ windowWidth = $( window ).width(),
+ windowHeight = $( window ).height(),
+ scrollX = Math.max( window.pageXOffset, document.documentElement.scrollLeft, document.body.scrollLeft ),
+ scrollY = Math.max( window.pageYOffset, document.documentElement.scrollTop, document.body.scrollTop ),
+
+ // Store target and tooltip details
+ tooltipWidth, tooltipHeight,
+ targetPosition,
+ locationOrder, tooltipLocation = {},
+ insertFn = 'append',
+
+ // Options, no longer by objet reference
+ optionsUnreferenced = {},
+
+ i = 0;
+
+ options = options || {};
+ // Do this so that we don't alter the data object by reference
+ optionsUnreferenced.tooltipSize = options.tooltipSize || $target.data( 'tooltipSize' );
+ optionsUnreferenced.tooltipContext = options.tooltipContext || $target.data( 'tooltipContext' );
+ optionsUnreferenced.tooltipPointing = options.tooltipPointing || $target.data( 'tooltipPointing' );
+ optionsUnreferenced.tooltipContentCallback = options.tooltipContentCallback || $target.data( 'tooltipContentCallback' );
+ // @todo closable
+ optionsUnreferenced.tooltipClosable = options.tooltipClosable || $target.data( 'tooltipClosable' );
+
+ // Support passing jQuery as argument
+ target = $target[0];
+
+ if ( !content ) {
+ if ( optionsUnreferenced.tooltipContentCallback ) {
+ // Use content callback to get the content for this element
+ content = optionsUnreferenced.tooltipContentCallback( target, optionsUnreferenced );
+
+ if ( !content ) {
+ return false;
+ }
+ } else {
+ // Check to see if we're simply using target.title as the content
+ if ( !target.title ) {
+ return false;
+ }
+
+ content = target.title;
+ $target.data( 'tooltipTitle', content ); // store title
+ target.title = ''; // and hide it so it doesn't appear
+ insertFn = 'text';
+
+ if ( !optionsUnreferenced.tooltipSize ) {
+ // Default size for title tooltip is small
+ optionsUnreferenced.tooltipSize = 'small';
+ }
+ }
+ }
+
+ // No previous tooltip
+ if ( !$tooltip ) {
+ // See if content itself is a tooltip
+ try {
+ if ( $.type( content ) === 'string' ) {
+ $tooltip = $( $.parseHTML( content ) );
+ } else {
+ $tooltip = $( content );
+ }
+ } catch ( e ) {}
+ if ( !$tooltip || !$tooltip.is( '.flow-ui-tooltip' ) && !$tooltip.find( '.flow-ui-tooltip' ).length ) {
+ // Content is not and does not contain a tooltip, so instead, put content inside a new tooltip wrapper
+ $tooltip = _$tooltip.clone();
+ }
+ }
+
+ // Try to inherit tooltipContext from the target's classes
+ if ( !optionsUnreferenced.tooltipContext ) {
+ if ( $target.hasClass( 'mw-ui-progressive' ) ) {
+ optionsUnreferenced.tooltipContext = 'progressive';
+ } else if ( $target.hasClass( 'mw-ui-constructive' ) ) {
+ optionsUnreferenced.tooltipContext = 'constructive';
+ } else if ( $target.hasClass( 'mw-ui-destructive' ) ) {
+ optionsUnreferenced.tooltipContext = 'destructive';
+ }
+ }
+
+ $tooltip
+ // Add the content to it
+ .find( '.flow-ui-tooltip-content' )
+ .empty()
+ [ insertFn ]( content )
+ .end()
+ // Move this off-page before rendering it, so that we can calculate its real dimensions
+ // @todo use .parent() loop to check for z-index and + that to this if needed
+ .css( { position: 'absolute', zIndex: 1000, top: 0, left: '-999em' } )
+ // Render
+ // @todo inject at #bodyContent to inherit (font-)styling
+ .appendTo( 'body' );
+
+ // Tooltip style context
+ if ( optionsUnreferenced.tooltipContext ) {
+ $tooltip.removeClass( 'mw-ui-progressive mw-ui-constructive mw-ui-destructive' );
+ $tooltip.addClass( 'mw-ui-' + optionsUnreferenced.tooltipContext );
+ }
+
+ // Tooltip size (small, large)
+ if ( optionsUnreferenced.tooltipSize ) {
+ $tooltip.removeClass( 'flow-ui-tooltip-sm flow-ui-tooltip-lg' );
+ $tooltip.addClass( 'flow-ui-tooltip-' + optionsUnreferenced.tooltipSize );
+ }
+
+ // Remove the old pointing direction
+ $tooltip.removeClass( 'flow-ui-tooltip-up flow-ui-tooltip-down flow-ui-tooltip-left flow-ui-tooltip-right' );
+
+ // tooltip width and height with the new content
+ tooltipWidth = $tooltip.outerWidth( true );
+ tooltipHeight = $tooltip.outerHeight( true );
+
+ // target positioning info
+ targetPosition = $target.offset();
+ targetPosition.width = $target.outerWidth( true );
+ targetPosition.height = $target.outerHeight( true );
+ targetPosition.leftEnd = targetPosition.left + targetPosition.width;
+ targetPosition.topEnd = targetPosition.top + targetPosition.height;
+ targetPosition.leftMiddle = targetPosition.left + targetPosition.width / 2;
+ targetPosition.topMiddle = targetPosition.top + targetPosition.height / 2;
+
+ // Use the preferred pointing direction first
+ switch ( optionsUnreferenced.tooltipPointing ) {
+ case 'left': locationOrder = [ 'left', 'right', 'left' ]; break;
+ case 'right': locationOrder = [ 'right', 'left', 'right' ]; break;
+ case 'down': locationOrder = [ 'down', 'up', 'down' ]; break;
+ default: locationOrder = [ 'up', 'down', 'up' ];
+ }
+
+ do {
+ // Position of the POINTER, not the tooltip itself
+ switch ( locationOrder[ i ] ) {
+ case 'left':
+ tooltipLocation.left = targetPosition.leftEnd;
+ tooltipLocation.top = targetPosition.topMiddle - tooltipHeight / 2;
+ break;
+ case 'right':
+ tooltipLocation.left = targetPosition.left - tooltipWidth;
+ tooltipLocation.top = targetPosition.topMiddle - tooltipHeight / 2;
+ break;
+ case 'down':
+ tooltipLocation.left = targetPosition.leftMiddle - tooltipWidth / 2;
+ tooltipLocation.top = targetPosition.top - tooltipHeight;
+ break;
+ case 'up':
+ tooltipLocation.left = targetPosition.leftMiddle - tooltipWidth / 2;
+ tooltipLocation.top = targetPosition.topEnd;
+ break;
+ }
+
+ // Verify tooltip will be mostly visible in viewport
+ if (
+ tooltipLocation.left > scrollX - 5 &&
+ tooltipLocation.top > scrollY - 5 &&
+ tooltipLocation.left + tooltipWidth < windowWidth + scrollX + 5 &&
+ tooltipLocation.top + tooltipHeight < windowHeight + scrollY + 5
+ ) {
+ break;
+ }
+ if ( i + 1 === locationOrder.length ) {
+ break;
+ }
+ } while ( ++i <= locationOrder.length );
+
+ // Add the pointing direction class from the loop
+ $tooltip.addClass( 'flow-ui-tooltip-' + locationOrder[ i ] );
+
+ // Apply the new location CSS
+ $tooltip.css( tooltipLocation );
+
+ // Store this tooltip onto target
+ $target.data( '$tooltip', $tooltip );
+ // Store this target onto tooltip
+ $tooltip.data( '$target', $target );
+ // Add this tooltip to our set of active tooltips
+ $activeTooltips = $activeTooltips.add( $tooltip );
+
+ // Start the expiry timer
+ _mwUiTooltipExpire();
+
+ return $tooltip;
+ }
+
+ /**
+ * Hides the tooltip associated with target instantly.
+ * @param {Element|jQuery} target
+ */
+ function mwUiTooltipHide( target ) {
+ var $target = $( target ),
+ $tooltip = $target.data( '$tooltip' ),
+ tooltipTitle = $target.data( 'tooltipTitle' );
+
+ // Remove tooltip from DOM
+ if ( $tooltip ) {
+ $target.removeData( '$tooltip' );
+ $activeTooltips = $activeTooltips.not( $tooltip );
+ $tooltip.remove();
+ }
+
+ // Restore old title; was used for tooltip
+ if ( tooltipTitle ) {
+ $target[0].title = tooltipTitle;
+ $target.removeData( 'tooltipTitle' );
+ }
+ }
+
+ /**
+ * Runs on a timer to expire tooltips. This is useful in scenarios where a tooltip's target
+ * node has disappeared (removed from page), and didn't trigger a mouseout event. We detect
+ * the target disappearing, and as such remove the tooltip node.
+ */
+ function _mwUiTooltipExpire() {
+ clearTimeout( _mwUiTooltipExpireTimer );
+
+ $activeTooltips.each( function () {
+ var $this = $( this ),
+ $target = $this.data( '$target' );
+
+ // Remove the tooltip if this tooltip has been removed,
+ // or if target is not visible (hidden or removed from DOM)
+ if ( !this.parentNode || !$target.is( ':visible' ) ) {
+ // Remove the tooltip from the DOM
+ $this.remove();
+ // Unset tooltip from target
+ $target.removeData( '$tooltip' );
+ // Remove the tooltip from our active tooltips list
+ $activeTooltips = $activeTooltips.not( $this );
+ }
+ } );
+
+ if ( $activeTooltips.length ) {
+ // Check again in 500ms if we still have active tooltips
+ _mwUiTooltipExpireTimer = setTimeout( _mwUiTooltipExpire, 500 );
+ }
+ }
+
+ /**
+ * MW UI Tooltip access through JS API.
+ */
+ mw.tooltip = {
+ show: mwUiTooltipShow,
+ hide: mwUiTooltipHide
+ };
+
+ /**
+ * Event handler for mouse entering on a .flow-ui-tooltip-target
+ * @param {Event} event
+ */
+ function onMwUiTooltipFocus( event ) {
+ mw.tooltip.show( this );
+ }
+
+ /**
+ * Event handler for mouse leaving a .flow-ui-tooltip-target
+ * @param {Event} event
+ */
+ function onMwUiTooltipBlur( event ) {
+ mw.tooltip.hide( this );
+ }
+
+ // Attach the mouseenter and mouseleave handlers on document
+ $( document )
+ .on( 'mouseenter.mw-ui-enhance focus.mw-ui-enhance', '.flow-ui-tooltip-target', onMwUiTooltipFocus )
+ .on( 'mouseleave.mw-ui-enhance blur.mw-ui-enhance click.mw-ui-enhance', '.flow-ui-tooltip-target', onMwUiTooltipBlur );
+ } );
+
+ /**
+ * Ask a user to confirm navigating away from a page when they have entered unsubmitted changes to a form.
+ */
+ var _oldOnBeforeUnload = window.onbeforeunload;
+ window.onbeforeunload = function () {
+ var uncommitted;
+
+ $( 'input, textarea' ).filter( '.mw-ui-input:visible' ).each( function () {
+ if ( $.trim( this.value ) && this.value !== this.defaultValue ) {
+ uncommitted = true;
+ return false;
+ }
+ } );
+
+ // Ask the user if they want to navigate away
+ if ( uncommitted ) {
+ return mw.msg( 'mw-ui-unsubmitted-confirm' );
+ }
+
+ // Run the old on beforeunload fn if it exists
+ if ( _oldOnBeforeUnload ) {
+ return _oldOnBeforeUnload();
+ }
+ };
+}( mw, jQuery ) );
diff --git a/Flow/modules/engine/misc/mw-ui.modal.js b/Flow/modules/engine/misc/mw-ui.modal.js
new file mode 100644
index 00000000..c549db03
--- /dev/null
+++ b/Flow/modules/engine/misc/mw-ui.modal.js
@@ -0,0 +1,410 @@
+/*!
+ * mw-ui-modal
+ * Implements mw.Modal functionality.
+ */
+
+( function ( mw, $ ) {
+ // Make it easier to remove this later on, should it be implemented in Core
+ if ( mw.Modal ) {
+ return;
+ }
+
+ /**
+ * Accepts an element or HTML string as contents. If none given,
+ * modal will start in hidden state.
+ * Settings keys:
+ * - open (same arguments as open method)
+ * - title String
+ * - disableCloseOnOutsideClick Boolean (if true, ESC and background clicks do not close it)
+ *
+ * @todo Implement multi-step
+ * @todo Implement data-mwui handlers
+ * @todo Implement OOJS & events
+ *
+ * @example modal = mw.Modal();
+ * @example modal = mw.Modal( { open: 'Contents!!', title: 'Title!!' } );
+ * @example modal = mw.Modal( 'special_modal' );
+ *
+ * @param {String} [name] Name of modal (may be omitted)
+ * @param {Object} [settings]
+ * @return MwUiModal
+ */
+ function MwUiModal( name, settings ) {
+ // allow calling this method with or without "new" keyword
+ if ( this.constructor !== MwUiModal ) {
+ return new MwUiModal( name, settings );
+ }
+
+ // Defaults and ordering
+ if ( !settings && typeof name === 'object' ) {
+ settings = name;
+ name = null;
+ }
+ settings = settings || {};
+
+ // Set name
+ this.name = name;
+
+ // Set title
+ this.setTitle( settings.title );
+
+ // Set disableCloseOnOutsideClick
+ this.disableCloseOnOutsideClick = !!settings.disableCloseOnOutsideClick;
+
+ // Auto-open
+ if ( settings.open ) {
+ this.open( settings.open );
+ }
+
+ return this;
+ }
+
+ /** Stores template
+ * @todo use data-mwui attributes instead of data-flow **/
+ MwUiModal.prototype.template = '' +
+ '<div class="flow-ui-modal">' +
+ '<div class="flow-ui-modal-layout">' +
+ '<div class="flow-ui-modal-heading">' +
+ '<a href="#" class="mw-ui-anchor mw-ui-quiet mw-ui-destructive flow-ui-modal-heading-prev" data-flow-interactive-handler="modalPrevOrClose"><span class="wikiglyph wikiglyph-x"></span></a>' +
+ '<a href="#" class="mw-ui-anchor mw-ui-quiet mw-ui-constructive flow-ui-modal-heading-next" data-flow-interactive-handler="modalNextOrSubmit"><span class="wikiglyph wikiglyph-tick"></span></a>' +
+ // title
+ '</div>' +
+
+ '<div class="flow-ui-modal-content">' +
+ // content
+ '</div>' +
+ '</div>' +
+ '</div>';
+
+ /** Stores modal wrapper selector **/
+ MwUiModal.prototype.wrapperSelector = '.flow-ui-modal';
+ /** Stores content wrapper selector **/
+ MwUiModal.prototype.contentSelector = '.flow-ui-modal-content';
+ /** Stores heading wrapper selector, which contains prev/next links **/
+ MwUiModal.prototype.headingSelector = '.flow-ui-modal-heading';
+ /** Stores prev link selector **/
+ MwUiModal.prototype.prevSelector = '.flow-ui-modal-heading-prev';
+ /** Stores next link selector **/
+ MwUiModal.prototype.nextSelector = '.flow-ui-modal-heading-next';
+
+ // Primary functions
+
+ /**
+ * Closes and destroys the given instance of mw.Modal.
+ *
+ * @return {Boolean} false on failure, true on success
+ */
+ MwUiModal.prototype.close = function () {
+ // Remove references
+ this._contents = this._title = null;
+
+ if ( this.$node ) {
+ // Remove whole thing from page
+ this.getNode().remove();
+
+ return true;
+ }
+
+ return false;
+ };
+
+ /**
+ * You can visually render the modal using this method. Opens up by displaying it on the page.
+ *
+ * - Multi-step modals with an Array. You can pass [ Element, Element ] to have two steps.
+ * - Multi-step modals with an Object to have named step keys. Pass this for three steps:
+ * { steps: [ 'first', 'second', 'foobar' ], first: Element, second: Element, foobar: Element }
+ *
+ * @todo Currently only supports String|jQuery|Element. Implement multi-step modals.
+ *
+ * @param {Array|Object|Element|jQuery|String} [contents]
+ * @return MwUiModal
+ */
+ MwUiModal.prototype.open = function ( contents ) {
+ var $node = this.getNode(),
+ $contentNode = this.getContentNode(),
+ $fields;
+
+ // Only update content if it's new
+ if ( contents && contents !== this._contents ) {
+ this._contents = contents;
+
+ $contentNode
+ // Remove children (this way we can unbind events)
+ .children()
+ .remove()
+ .end()
+ // Remove any plain text left over
+ .empty()
+ // Add the new content
+ .append( contents );
+ }
+
+ // Drop it into the page
+ $node.appendTo( 'body' );
+
+ // Hide the tick box @todo implement multi-step and event handling / form binding
+ $node.find( this.nextSelector ).hide();
+
+ // If something in here did not auto-focus, let's focus something
+ $fields = $node.find( 'textarea, input, select' ).filter( ':visible' );
+ if ( !$fields.filter( ':focus' ).length ) {
+ // Try to focus on an autofocus field
+ $fields = $fields.filter( '[autofocus]' );
+ if ( $fields.length ) {
+ $fields.trigger( 'focus' );
+ } else {
+ // Try to focus on ANY input
+ $fields = $fields.end().filter( ':first' );
+ if ( $fields.length ) {
+ $fields.trigger( 'focus' );
+ } else {
+ // Give focus to the wrapper itself
+ $node.focus();
+ }
+ }
+ }
+
+ return this;
+ };
+
+ /**
+ * Changes the title of the modal.
+ *
+ * @param {String|null} title
+ * @return MwUiModal
+ */
+ MwUiModal.prototype.setTitle = function ( title ) {
+ var $heading = this.getNode().find( this.headingSelector ),
+ $children;
+
+ title = title || '';
+
+ // Only update title if it's new
+ if ( title !== this._title ) {
+ this._title = title;
+
+ // Remove any element children temporarily, so we can set the title here
+ $children = $heading.children().detach();
+
+ $heading
+ // Set the new title
+ .text( title )
+ // Add the child nodes back
+ .prepend( $children );
+ }
+
+ // Show the heading if there's a title; hide otherwise
+ $heading[ title ? 'show' : 'hide' ]();
+
+ return this;
+ };
+
+ /**
+ * @todo Implement data-mwui handlers, currently using data-flow
+ */
+ MwUiModal.prototype.setInteractiveHandler = function () {
+ return false;
+ };
+
+ /**
+ * Returns modal name.
+ */
+ MwUiModal.prototype.getName = function () {
+ return this.name;
+ };
+
+ // Nodes
+
+ /**
+ * Returns the modal's wrapper Element, which contains the header node and content node.
+ * @returns {jQuery}
+ */
+ MwUiModal.prototype.getNode = function () {
+ var self = this,
+ $node = this.$node;
+
+ // Create our template instance
+ if ( !$node ) {
+ $node = this.$node = $( this.template );
+
+ // Store a self-reference
+ $node.data( 'MwUiModal', this );
+
+ // Bind close handlers
+ $node.on( 'click', function ( event ) {
+ // If we are clicking on the modal itself, it's the outside area, so close it;
+ // make sure we aren't clicking INSIDE the modal content!
+ if ( !self.disableCloseOnOutsideClick && this === $node[ 0 ] && event.target === $node[ 0 ] ) {
+ self.close();
+ }
+ } );
+ }
+
+ return $node;
+ };
+
+ /**
+ * Returns the wrapping Element on which you can bind bubbling events for your content.
+ * @returns {jQuery}
+ */
+ MwUiModal.prototype.getContentNode = function () {
+ return this.getNode().find( this.contentSelector );
+ };
+
+ // Step creation
+
+ /**
+ * Adds one or more steps, using the same arguments as modal.open.
+ * May overwrite steps if any exist with the same key in Object mode.
+ *
+ * @todo Implement multi-step.
+ *
+ * @param {Array|Object|Element|jQuery|String} contents
+ * @return MwUiModal
+ */
+ MwUiModal.prototype.addSteps = function ( contents ) {
+ return false;
+ };
+
+ /**
+ * Changes a given step. If String to does not exist in the list of steps, throws an exception;
+ * int to always succeeds. If the given step is the currently-active one, rerenders the modal contents.
+ * Theoretically, you could use setStep to keep changing step 1 to create a pseudo-multi-step modal.
+ *
+ * @todo Implement multi-step.
+ *
+ * @param {int|String} to
+ * @param {Element|jQuery|String} contents
+ * @return MwUiModal
+ */
+ MwUiModal.prototype.setStep = function ( to, contents ) {
+ return false;
+ };
+
+ /**
+ * Returns an Object with steps, and their contents.
+ *
+ * @todo Implement multi-step.
+ *
+ * @return Object
+ */
+ MwUiModal.prototype.getSteps = function ( to, contents ) {
+ return {};
+ };
+
+ // Step interaction
+
+ /**
+ * For a multi-step modal, goes to the previous step, otherwise, closes the modal.
+ *
+ * @return {MwUiModal|Boolean} false if none, MwUiModal on prev, true on close
+ */
+ MwUiModal.prototype.prevOrClose = function () {
+ if ( this.prev() === false ) {
+ return this.close();
+ }
+ };
+
+ /**
+ * For a multi-step modal, goes to the next step (if any), otherwise, submits the form.
+ *
+ * @return {MwUiModal|Boolean} false if no next step and no button to click, MwUiModal on success
+ */
+ MwUiModal.prototype.nextOrSubmit = function () {
+ var $button;
+
+ if ( this.next() === false && this.$node ) {
+ // Find an anchor or button with role=primary
+ $button = this.$node.find( this.contentSelector ).find( 'a, input, button' ).filter( ':visible' ).filter( '[type=submit], [data-role=submit]' );
+
+ if ( !$button.length ) {
+ return false;
+ }
+
+ $button.trigger( 'click' );
+ }
+ };
+
+ /**
+ * For a multi-step modal, goes to the previous step, if any are left.
+ *
+ * @todo Implement multi-step.
+ *
+ * @return {MwUiModal|Boolean} false if invalid step, MwUiModal on success
+ */
+ MwUiModal.prototype.prev = function () {
+ return false;
+ };
+
+ /**
+ * For a multi-step modal, goes to the next step, if any are left.
+ *
+ * @todo Implement multi-step.
+ *
+ * @return {MwUiModal|Boolean} false if invalid step, MwUiModal on success
+ */
+ MwUiModal.prototype.next = function () {
+ return false;
+ };
+
+ /**
+ * For a multi-step modal, goes to a specific step by number or name.
+ *
+ * @todo Implement multi-step.
+ *
+ * @param {int|String} to
+ * @return {MwUiModal|Boolean} false if invalid step, MwUiModal on success
+ */
+ MwUiModal.prototype.go = function ( to ) {
+ return false;
+ };
+
+ /**
+ * MW UI Modal access through JS API.
+ * @example mw.Modal( "<p>lorem</p>" );
+ */
+ mw.Modal = MwUiModal;
+
+ /**
+ * Returns an instance of mw.Modal if one is currently being displayed on the page.
+ * If node is given, tries to find which modal (if any) that node is within.
+ * Returns false if none found.
+ *
+ * @param {Element|jQuery} [node]
+ * @return {Boolean|MwUiModal}
+ */
+ mw.Modal.getModal = function ( node ) {
+ if ( node ) {
+ // Node was given; try to find a parent modal
+ return $( node ).closest( MwUiModal.prototype.wrapperSelector ).data( 'MwUiModal') || false;
+ }
+
+ // No node given; return the last-opened modal on the page
+ return $( 'body' ).children( MwUiModal.prototype.wrapperSelector ).filter( ':last' ).data( 'MwUiModal' ) || false;
+ };
+
+ // Transforms: automatically map these functions to call their mw.Modal methods globally, on any active instance
+ $.each( [ 'close', 'getName', 'prev', 'next', 'prevOrClose', 'nextOrSubmit', 'go' ], function ( i, fn ) {
+ mw.Modal[ fn ] = function () {
+ var args = Array.prototype.splice.call( arguments, 0, arguments.length - 1 ),
+ node = arguments[ arguments.length - 1 ],
+ modal;
+
+ // Find the node, if any was given
+ if ( !node || ( typeof node.is === 'function' && !node.is( '*' ) ) || node.nodeType !== 1 ) {
+ // The last argument to this function was not a node, assume none was intended to be given
+ node = null;
+ args = arguments;
+ }
+
+ // Try to find that modal
+ modal = mw.Modal.getModal( node );
+
+ // Call the intended function locally
+ if ( modal ) {
+ modal[ fn ].apply( modal, args );
+ }
+ };
+ } );
+}( mw, jQuery ) );