From 2e970257deedbea55987935146b5bc71af21525c Mon Sep 17 00:00:00 2001 From: Timo Tijhof Date: Wed, 13 Feb 2013 21:56:33 +0100 Subject: [PATCH] mw.hook: Implement callback framework MIME-Version: 1.0 Content-Type: text/plain; charset=utf8 Content-Transfer-Encoding: 8bit We have various interface components that are extended, enhanced or manipulated in some other way by extensions, gadgets and even in core itself. This framework helps streamlining the timing of when these other code paths fire their plugins (instead of using document-ready, which can and should be limited to firing only once). Features like navigating to other wiki pages, previewing an edit and editing itself – without a refresh – can then retrigger these hooks accordingly to ensure everything still works as expected. For the server side this has been possible already, but for the client side we've been limited to running only once on the original page load. Further documentation and examples are in the code documentation. Bug: 23580 Bug: 30713 Change-Id: Ic73a3efe53d6fb731e7f1e531d5f51530cd7e4fe --- maintenance/jsduck/categories.json | 3 +- resources/mediawiki/mediawiki.js | 77 ++++++++++++++++- resources/mediawiki/mediawiki.util.js | 2 + .../resources/mediawiki/mediawiki.test.js | 86 +++++++++++++++++++ 4 files changed, 166 insertions(+), 2 deletions(-) diff --git a/maintenance/jsduck/categories.json b/maintenance/jsduck/categories.json index c29c91c9b5..f20289edaf 100644 --- a/maintenance/jsduck/categories.json +++ b/maintenance/jsduck/categories.json @@ -11,7 +11,8 @@ "mw.loader", "mw.html", "mw.html.Cdata", - "mw.html.Raw" + "mw.html.Raw", + "mw.hook" ] }, { diff --git a/resources/mediawiki/mediawiki.js b/resources/mediawiki/mediawiki.js index 6c7e697889..32251988d6 100644 --- a/resources/mediawiki/mediawiki.js +++ b/resources/mediawiki/mediawiki.js @@ -1734,7 +1734,82 @@ var mw = ( function ( $, undefined ) { user: { options: new Map(), tokens: new Map() - } + }, + + /** + * Registry and firing of events. + * + * MediaWiki has various interface components that are extended, enhanced + * or manipulated in some other way by extensions, gadgets and even + * in core itself. + * + * This framework helps streamlining the timing of when these other + * code paths fire their plugins (instead of using document-ready, + * which can and should be limited to firing only once). + * + * Features like navigating to other wiki pages, previewing an edit + * and editing itself – without a refresh – can then retrigger these + * hooks accordingly to ensure everything still works as expected. + * + * Example usage: + * + * mw.hook( 'wikipage.content' ).add( fn ).remove( fn ); + * mw.hook( 'wikipage.content' ).fire( $content ); + * + * Handlers can be added and fired for arbitrary event names at any time. The same + * event can be fired multiple times. The last run of an event is memorized + * (similar to `$(document).ready` and `$.Deferred().done`). + * This means if an event is fired, and a handler added afterwards, the added + * function will be fired right away with the last given event data. + * + * Like Deferreds and Promises, the mw.hook object is both detachable and chainable. + * Thus allowing flexible use and optimal maintainability and authority control. + * You can pass around the `add` and/or `fire` method to another piece of code + * without it having to know the event name (or `mw.hook` for that matter). + * + * var h = mw.hook( 'bar.ready' ); + * new mw.Foo( .. ).fetch( { callback: h.fire } ); + * + * @class mw.hook + */ + hook: ( function () { + var lists = {}; + + /** + * @method hook + * @member mw + * @param {string} name Name of hook. + * @return {mw.hook} + */ + return function ( name ) { + var list = lists[name] || ( lists[name] = $.Callbacks( 'memory' ) ); + + return { + /** + * Register a hook handler + * @param {Function...} handler Function to bind. + * @chainable + */ + add: list.add, + + /** + * Unregister a hook handler + * @param {Function...} handler Function to unbind. + * @chainable + */ + remove: list.remove, + + /** + * Run a hook. + * @param {Mixed...} data + * @chainable + */ + fire: function () { + return list.fireWith( null, slice.call( arguments ) ); + } + }; + }; + }() ) }; }( jQuery ) ); diff --git a/resources/mediawiki/mediawiki.util.js b/resources/mediawiki/mediawiki.util.js index 60ef7584a2..bd8e5961e6 100644 --- a/resources/mediawiki/mediawiki.util.js +++ b/resources/mediawiki/mediawiki.util.js @@ -104,6 +104,8 @@ return util.$content; } )(); + mw.hook( 'wikipage.content' ).fire( util.$content ); + // Table of contents toggle $tocTitle = $( '#toctitle' ); $tocToggleLink = $( '#togglelink' ); diff --git a/tests/qunit/suites/resources/mediawiki/mediawiki.test.js b/tests/qunit/suites/resources/mediawiki/mediawiki.test.js index e3386756cd..73dcf34a14 100644 --- a/tests/qunit/suites/resources/mediawiki/mediawiki.test.js +++ b/tests/qunit/suites/resources/mediawiki/mediawiki.test.js @@ -817,4 +817,90 @@ } ); + QUnit.test( 'mw.hook', 10, function ( assert ) { + var hook, add, fire, chars, callback; + + mw.hook( 'test.hook.unfired' ).add( function () { + assert.ok( false, 'Unfired hook' ); + } ); + + mw.hook( 'test.hook.basic' ).add( function () { + assert.ok( true, 'Basic callback' ); + } ); + mw.hook( 'test.hook.basic' ).fire(); + + mw.hook( 'test.hook.data' ).add( function ( data1, data2 ) { + assert.equal( data1, 'example', 'Fire with data (string param)' ); + assert.deepEqual( data2, ['two'], 'Fire with data (array param)' ); + } ); + mw.hook( 'test.hook.data' ).fire( 'example', ['two'] ); + + mw.hook( 'test.hook.chainable' ).add( function () { + assert.ok( true, 'Chainable' ); + } ).fire(); + + hook = mw.hook( 'test.hook.detach' ); + add = hook.add; + fire = hook.fire; + add( function ( x, y ) { + assert.deepEqual( [x, y], ['x', 'y'], 'Detached (contextless) with data' ); + } ); + fire( 'x', 'y' ); + + mw.hook( 'test.hook.fireBefore' ).fire().add( function () { + assert.ok( true, 'Invoke handler right away if it was fired before' ); + } ); + + mw.hook( 'test.hook.fireTwiceBefore' ).fire().fire().add( function () { + assert.ok( true, 'Invoke handler right away if it was fired before (only last one)' ); + } ); + + chars = []; + + mw.hook( 'test.hook.many' ) + .add( function ( chr ) { + chars.push( chr ); + } ) + .fire( 'x' ).fire( 'y' ).fire( 'z' ) + .add( function ( chr ) { + assert.equal( chr, 'z', 'Adding callback later invokes right away with last data' ); + } ); + + assert.deepEqual( chars, ['x', 'y', 'z'], 'Multiple callbacks with multiple fires' ); + + chars = []; + callback = function ( chr ) { + chars.push( chr ); + }; + + mw.hook( 'test.hook.variadic' ) + .add( + callback, + callback, + function ( chr ) { + chars.push( chr ); + }, + callback + ) + .fire( 'x' ) + .remove( + function () { + 'not-added'; + }, + callback + ) + .fire( 'y' ) + .remove( callback ) + .fire( 'z' ); + + assert.deepEqual( + chars, + ['x', 'x', 'x', 'x', 'y', 'z'], + '"add" and "remove" support variadic arguments. ' + + '"add" does not filter unique. ' + + '"remove" removes all equal by reference. ' + + '"remove" is silent if the function is not found' + ); + } ); + }( mediaWiki, jQuery ) ); -- 2.20.1