mw.hook: Implement callback framework
authorTimo Tijhof <ttijhof@wikimedia.org>
Wed, 13 Feb 2013 20:56:33 +0000 (21:56 +0100)
committerTimo Tijhof <krinklemail@gmail.com>
Fri, 3 May 2013 20:33:55 +0000 (22:33 +0200)
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
resources/mediawiki/mediawiki.js
resources/mediawiki/mediawiki.util.js
tests/qunit/suites/resources/mediawiki/mediawiki.test.js

index c29c91c..f20289e 100644 (file)
@@ -11,7 +11,8 @@
                                        "mw.loader",
                                        "mw.html",
                                        "mw.html.Cdata",
-                                       "mw.html.Raw"
+                                       "mw.html.Raw",
+                                       "mw.hook"
                                ]
                        },
                        {
index 6c7e697..3225198 100644 (file)
@@ -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 ) );
index 60ef758..bd8e596 100644 (file)
                                return util.$content;
                        } )();
 
+                       mw.hook( 'wikipage.content' ).fire( util.$content );
+
                        // Table of contents toggle
                        $tocTitle = $( '#toctitle' );
                        $tocToggleLink = $( '#togglelink' );
index e338675..73dcf34 100644 (file)
 
        } );
 
+       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 ) );