| Current File : /home/jvzmxxx/wiki/extensions/Flow/modules/engine/components/common/flow-component-events.js |
/*!
* 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.
* @class
* @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, 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
* @return {Array}
*/
function emitWithReturn( event, args ) {
var i, len, binding, bindings, method, retVal,
returns = [];
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 ) {
// 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
* @return {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
* @return 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.
*
* <a data-flow-interactive-handler="apiRequest" data-flow-api-handler="loadMore" data-flow-api-target="< .flow-component div" href="...">...</a>
*
* @param {Event} event
* @return {jQuery.Promise}
*/
function flowEventsMixinApiRequestInteractiveHandler( event ) {
var $deferred = $.Deferred(),
deferreds = [ $deferred ],
$target,
self = event.currentTarget || event.delegateTarget || event.target,
$this = $( self ),
flowComponent = mw.flow.getPrototypeMethod( 'component', 'getInstanceByElement' )( $this ),
dataParams = $this.data(),
handlerName = dataParams.flowApiHandler,
info = {
$target: null,
status: null,
component: flowComponent
},
args = Array.prototype.slice.call( arguments, 0 ),
queryMap = flowComponent.Api.getQueryMap( self.href || self ),
preHandlers = [];
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;
}
// insert queryMap & info into args for prehandler
info.$target = $target;
args.splice( 1, 0, info );
args.splice( 2, 0, queryMap );
$deferred.resolve( args );
// chain apiPreHandler callbacks
preHandlers = _getApiPreHandlers( self, handlerName );
$.each( preHandlers, function ( i, callback ) {
$deferred = $deferred.then( callback );
} );
// mark the element as "in progress" (we're only doing this after running
// preHandlers since they may reject the API call)
$deferred = $deferred.then( function ( args ) {
// Protect against repeated or nested API calls for the same handler
var inProgress = $target.data( 'inProgress' ) || [];
if ( inProgress.indexOf( handlerName ) !== -1 ) {
return $.Deferred().reject( 'fail-api-inprogress', { error: { info: 'apiRequest already in progress' } } );
}
inProgress.push( handlerName );
$target.data( 'inProgress', inProgress );
// 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' );
// Remove existing errors from previous attempts
flowComponent.emitWithReturn( 'removeError', $this );
return args;
} );
// execute API call
$deferred = $deferred.then( function ( args ) {
var queryMap = args[ 2 ];
return flowComponent.Api.requestFromNode( self, queryMap ).then(
// alter API response: apiHandler expects a 1st param info (that
// includes 'status') & `this` being the target element
function () {
var args = Array.prototype.slice.call( arguments, 0 );
info.status = 'done';
args.unshift( info );
return $.Deferred().resolveWith( self, args );
},
// failure: display the error message to end-user & turn the rejected
// deferred back into resolve: apiHandlers may want to wrap up
function ( code, result ) {
var errorMsg,
args = Array.prototype.slice.call( arguments, 0 ),
$form = $this.closest( 'form' );
if ( code === 'http' && result.textStatus === 'abort' ) {
// don't show error for aborted API requests & don't turn
// into resolved: we don't want callbacks to run here!
return $.Deferred().rejectWith( self, args );
}
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 );
flowComponent.Api.abortOldRequestFromNode( self, queryMap, null );
// keep going & process those apiHandlers; based on info.status,
// they'll know if they're dealing with successful submissions,
// or cleaning up after error
return $.Deferred().resolveWith( self, args );
}
);
} );
// chain apiHandler callbacks (it can distinguish in how it needs to wrap up
// depending on info.status)
if ( flowComponent.UI.events.apiHandlers[ handlerName ] ) {
$.each( flowComponent.UI.events.apiHandlers[ handlerName ], function ( i, callback ) {
/*
* apiHandlers will return promises that won't resolve until
* the apiHandler has completed all it needs to do.
* These handlers aren't chainable, though (although we only
* have 1 per call, AFAIK), they don't return the same data the
* next handler assumes.
* In order to suspend something until all of these apiHandlers
* have completed, we'll combine them in an array which we can
* keep tabs on until all of these promises are done ($.when)
*/
deferreds.push( $deferred.then( callback ) );
} );
}
// all-purpose error handling: whichever step in this chain rejects, we'll send it to console
$deferred.fail( function ( code, result ) {
var errorMsg = flowComponent.constructor.static.getApiErrorMessage( code, result );
flowComponent.debug( false, errorMsg, handlerName, args );
} );
// cleanup after successfully completing the request & handler(s)
return $.when.apply( $, deferreds ).done( function () {
var inProgress = $target.data( 'inProgress' ) || [];
inProgress.splice( inProgress.indexOf( handlerName ), 1 );
$target.data( 'inProgress', inProgress );
if ( inProgress.length === 0 ) {
$target.removeClass( 'flow-api-inprogress' );
$this.removeClass( 'flow-api-inprogress' );
}
} );
}
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 ),
initialState = $this.data( 'flow-initial-state' );
// 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 ) );
}
} );
if ( initialState === 'collapsed' ) {
component.emitWithReturn( 'hideForm', $this );
} else if ( initialState === 'expanded' ) {
component.emitWithReturn( 'showForm', $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 a flow form and/or its actions.
* @param {jQuery} $form
* @todo Move this to a separate file
*/
function flowEventsMixinHideForm( $form ) {
var component = mw.flow.getPrototypeMethod( 'component', 'getInstanceByElement' )( $form );
$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 );
}
} );
// Hide its actions
// @todo Use TemplateEngine to find and hide actions?
$form.find( '.flow-form-collapsible' ).toggleClass( 'flow-form-collapsible-collapsed', true );
}
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 a flow form and/or its actions.
* @param {jQuery} $form
*/
function flowEventsMixinShowForm( $form ) {
var self = this;
// Show its actions
$form.find( '.flow-form-collapsible' ).toggleClass( 'flow-form-collapsible-collapsed', false );
// 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 );
}
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;
$container
.find( '.flow-editor:not(:has(.flow-editor-initialized)) textarea:not(.flow-input-compressed)' )
.each( function () {
var $textarea = $( this ),
$form = $textarea.closest( 'form' ),
content = $textarea.val();
// Mark the editor as initialized so we don't try to init it again
$textarea.addClass( 'flow-editor-initialized' );
// Blank editor while loading
$textarea.val( '' );
mw.loader.using( 'ext.flow.editor', function () {
mw.flow.editor.load( $textarea, content );
// Kill editor instance when the form it's in is cancelled
flowComponent.emitWithReturn( 'addFormCancelCallback', $form, function () {
$textarea.removeClass( 'flow-editor-initialized' );
if ( mw.flow.editor.exists( $textarea ) ) {
mw.flow.editor.destroy( $textarea );
}
} );
} );
} );
}
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-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', 'right', 'up' or 'down'
*/
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,
user: mw.user
}
)
).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 {HTMLElement|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
* @return 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;
}
/**
* @param {HTMLElement} target
* @param {string} handlerName
* @return {Function[]}
* @private
*/
function _getApiPreHandlers( target, handlerName ) {
var flowComponent = mw.flow.getPrototypeMethod( 'component', 'getInstanceByElement' )( $( target ) ),
preHandlers = [];
// Compile a list of all preHandlers to be run
$.each( flowComponent.UI.events.globalApiPreHandlers, function ( key, callbackArray ) {
Array.prototype.push.apply( preHandlers, callbackArray );
} );
if ( flowComponent.UI.events.apiPreHandlers[ handlerName ] ) {
Array.prototype.push.apply( preHandlers, flowComponent.UI.events.apiPreHandlers[ handlerName ] );
}
preHandlers = $.map( preHandlers, function ( callback ) {
/*
* apiPreHandlers aren't properly set up to serve as chained promise
* callbacks (they'll return false instead of returning a rejected
* promise, the incoming & outgoing params don't line up)
* This will wrap all those callbacks into callbacks we can chain.
*/
return function ( args ) {
var queryMap = callback.apply( target, args );
if ( queryMap === false ) {
return $.Deferred().reject( 'fail-prehandler', { error: { info: 'apiPreHandler returned false' } } );
}
if ( $.isPlainObject( queryMap ) ) {
args[ 2 ] = queryMap;
}
return args;
};
} );
return preHandlers;
}
// Copy static and prototype from mixin to main class
mw.flow.mixinComponent( 'component', FlowComponentEventsMixin );
}( jQuery, mediaWiki ) );