diff options
Diffstat (limited to 'Flow/modules/engine')
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 ) ); |