Henning Snater has submitted this change and it was merged. Change subject: Introduces jQuery.PurposedCallbacks constructor ......................................................................
Introduces jQuery.PurposedCallbacks constructor An instance of jQuery.PurposedCallbacks is a list of $.Callbacks instances, one per "purpose". A purpose is defined by a string. When registering a callback, an associated purpose has to be given as well. Change-Id: I312c0d52979cb276d6ed0336c53347236369a64c --- M ValueView/ValueView.resources.mw.php M ValueView/ValueView.tests.qunit.php A ValueView/resources/jquery/jquery.PurposedCallbacks.js A ValueView/tests/qunit/jquery/jquery.PurposedCallbacks.tests.js 4 files changed, 558 insertions(+), 0 deletions(-) Approvals: Henning Snater: Looks good to me, approved diff --git a/ValueView/ValueView.resources.mw.php b/ValueView/ValueView.resources.mw.php index 317b46c..eee38bf 100644 --- a/ValueView/ValueView.resources.mw.php +++ b/ValueView/ValueView.resources.mw.php @@ -37,6 +37,15 @@ ), // Dependencies required by jQuery.valueview library: + 'jquery.PurposedCallbacks' => $moduleTemplate + array( + 'scripts' => array( + 'jquery/jquery.PurposedCallbacks.js', + ), + 'dependencies' => array( + 'jquery', + ), + ), + 'jquery.eachchange' => $moduleTemplate + array( 'scripts' => array( 'jquery/jquery.eachchange.js' diff --git a/ValueView/ValueView.tests.qunit.php b/ValueView/ValueView.tests.qunit.php index ed91251..3c1809f 100644 --- a/ValueView/ValueView.tests.qunit.php +++ b/ValueView/ValueView.tests.qunit.php @@ -20,6 +20,15 @@ $bp = 'tests/qunit'; return array( + 'jquery.PurposedCallbacks.tests' => array( + 'scripts' => array( + "$bp/jquery/jquery.PurposedCallbacks.tests.js", + ), + 'dependencies' => array( + 'jquery.PurposedCallbacks', + ), + ), + 'jquery.eachchange.tests' => array( 'scripts' => array( "$bp/jquery/jquery.eachchange.tests.js", diff --git a/ValueView/resources/jquery/jquery.PurposedCallbacks.js b/ValueView/resources/jquery/jquery.PurposedCallbacks.js new file mode 100644 index 0000000..671b585 --- /dev/null +++ b/ValueView/resources/jquery/jquery.PurposedCallbacks.js @@ -0,0 +1,242 @@ +/** + * jQuery.PurposedCallbacks and jQuery.PurposedCallbacks.Facade constructors. + * + * @licence GNU GPL v2+ + * @author Daniel Werner < daniel.a.r.wer...@gmail.com > + * + * @dependency jQuery + */ +jQuery.PurposedCallbacks = ( function( $ ) { + 'use strict'; + + /** + * An instance of jQuery.PurposedCallbacks is a list of $.Callbacks instances, one per + * "purpose". The purposes are string identifiers for groups of callbacks. Callbacks can be + * registered for a purpose. Callbacks registered for one purpose will be executed together. + * + * This is conceptually similar to jQuery.Deferred but more flexible since it allows to define + * custom purposes other than "done", "fail" and "progress". + * Also, there is an equivalent to jQuery.Deferred.prototype.promise which is + * jQuery.PurposedCallbacks.prototype.facade. + * + * @constructor + * + * @example <code> + * function someAction() { + * var callbacks = $.PurposedCallbacks( [ 'done', 'fail' ] ); + * + * someAsynchronousAction( + * function() { + * callbacks.fire( 'done' ); + * }, + * function( errorMsg ) { + * callbacks.fire( 'fail', errorMsg ); + * } + * ); + * + * // Only expose object for registering more callbacks, but not for firing them: + * return callbacks.facade(); + * } + * someAction() + * .add( 'done', function() { alert( 'done' ) } ); + * .add( 'fail', function( reason ) { alert( 'error: ' + reason ) } ); + * .add( 'fail', function( reason ) { alert( 'ERROR!' } ); + * </code> + * + * @param {string[]} [predefinedPurposes] Allows to predefine which purposes are allowed. + * @param {string} [callbackOptions] Same options as for jQuery.Callbacks, will be forwarded. + * @return {jQuery.PurposedCallbacks} Can be instantiated without "new". + */ + var SELF = function PurposedCallbacks( predefinedPurposes, callbackOptions ) { + if( !( this instanceof SELF ) ) { + return new SELF( predefinedPurposes, callbackOptions ); + } + + if( typeof predefinedPurposes === 'string' ) { + callbackOptions = predefinedPurposes; + predefinedPurposes = undefined; + } + + /** + * cache for .facade() member. + * @type jQuery.PurposedCallbacks.Facade + */ + var facade; + + /** + * @type {Object} Field names are purposes, each holding a jQuery.Callbacks instance. + */ + var callbacksPerPurpose = {}; + + /** + * Registers a callback for some purpose. + * + * @param {string} purpose + * @param {Function|Function[]} callbacks + * @return {*} + * + * @throws {Error} If purposes are predefined via the constructor and "purpose" is not + * one of them. + */ + this.add = function( purpose, callbacks ) { + if( predefinedPurposes && !this.has( purpose ) ) { + throw new Error( 'Unknown purpose "' + purpose + '".' ); + } + + if( !callbacksPerPurpose[ purpose ] ) { + callbacksPerPurpose[ purpose ] = $.Callbacks( callbackOptions ); + } + callbacksPerPurpose[ purpose ].add( callbacks ); + + return this; + }; + + /** + * Removes a callback from a purpose. + * + * @param {string} purpose + * @param {Function|Function[]} callbacks + * @return {*} + */ + this.remove = function( purpose, callbacks ) { + var callbackForPurpose = callbacksPerPurpose[ purpose ]; + if( callbackForPurpose ) { + // NOTE: We can't remove( callbacks ) even though it is documented to work this way. + // This is a bug (behavior not according to jQuery API documentation) which is + // still present in jQuery 2.0 + callbacks = $.isArray( callbacks ) ? callbacks : [ callbacks ]; + callbackForPurpose.remove.apply( callbackForPurpose, callbacks ); + } + return this; + }; + + /** + * Returns whether a given purpose is known or whether a specific callback has been + * registered for a purpose. + * + * @param {string} purpose + * @param {Function} [callback] + * @return {boolean} + */ + this.has = function( purpose, callback ) { + if( callback ) { + var callbacksForPurpose = callbacksPerPurpose[ purpose ]; + return callbacksForPurpose && callbacksForPurpose.has( callback ); + } + return $.inArray( purpose, this.purposes() ) > -1; + }; + + /** + * Returns all purposes used for registering callbacks. If purposes got defined via the + * constructor, then all those purposes will be returned. If purposes were not defined via + * the constructor, then all purposes used to register a callback via "add" will be returned, + * even if the callback got removed again and there are no callbacks left for the queue of + * that purpose. + * + * @return {string[]} + */ + this.purposes = function() { + if( predefinedPurposes ) { + return predefinedPurposes.slice(); + } + var usedPurposes = []; + for( var purpose in callbacksPerPurpose ) { + usedPurposes.push( purpose ); + } + return usedPurposes; + }; + + /** + * Fires the callbacks of the given purposes or of all purposes with the provided + * arguments. Context for the callbacks will be the PurposedCallbacks or the + * PurposedCallbacks.Facade instance the callback has been added with. + * + * @param {string|string[]} purposes + * @param {array} [args] + */ + this.fire = function( purposes, args ) { + this.fireWith( this, purposes, args ); + return this; + }; + + /** + * same as fire() but with the callbacks called in a given context. + * + * @param {*} context + * @param {string|string[]} purposes + * @param [args] + * @return {*} + * + * @throws {Error} In case purposes were defined in the constructor and a purpose given + * here is not one of them. + */ + this.fireWith = function( context, purposes, args ) { + if( !$.isArray( purposes ) ) { + purposes = [ purposes ]; + } + args = args || []; + var missingPurposes = []; + + $.each( purposes, function( i, purpose ) { + var callbacksForPurpose = callbacksPerPurpose[ purpose ]; + if( callbacksForPurpose ) { + callbacksForPurpose.fireWith( context, args ); + } else { + missingPurposes.push( purpose ); + } + } ); + + if( predefinedPurposes && missingPurposes.length ) { + var unknownPurposes = $( missingPurposes ).not( predefinedPurposes ); + if( unknownPurposes.length ) { + throw new Error( 'Can not fire callbacks for unknown purposes.' ); + } + } + return this; + }; + + /** + * Returns a facade to the object, only allowing for adding/removing new callbacks but not + * allowing to fire them. Similar to jQuery.Deferred's promise(). + * The object returned here could for example be exposed as return value of a function + * which is initiating some asynchronous action. After the asynchronous action is done, + * the "fire" function will be called by the function. The code which received the facade + * by the function can only add and remove callbacks but not fire them. + * + * @return jQuery.PurposedCallbacks.Facade + */ + this.facade = function() { + if( !facade ) { + facade = SELF.Facade( this ); + } + return facade; + }; + }; + + /** + * Facade of jQuery.PurposedCallbacks which only allows access to the "add", "remove", "has" + * and "purposes" members. + * This is to jQuery.PurposedCallbacks what the "Promise" is to jQuery.Deferred. + * + * @constructor + * + * @param {jQuery.PurposedCallbacks} base + * @return {jQuery.PurposedCallbacks.Facade} Can be instantiated without "new". + */ + SELF.Facade = function PurposedCallbacksFacade( base ) { + if( !( this instanceof SELF.Facade ) ) { + return new SELF.Facade( base ); + } + var self = this; + + $.each( [ 'add', 'remove', 'has', 'purposes' ], function( i, field ) { + self[ field ] = function() { + var result = base[ field ].apply( base, arguments ); + return result === base ? this : result; + }; + } ); + }; + + return SELF; + +}( jQuery ) ); diff --git a/ValueView/tests/qunit/jquery/jquery.PurposedCallbacks.tests.js b/ValueView/tests/qunit/jquery/jquery.PurposedCallbacks.tests.js new file mode 100644 index 0000000..80a887c --- /dev/null +++ b/ValueView/tests/qunit/jquery/jquery.PurposedCallbacks.tests.js @@ -0,0 +1,298 @@ +/** + * QUnit tests for 'jQuery.PurposedCallbacks'. + * + * @file + * @licence GNU GPL v2+ + * @author Daniel Werner < daniel.wer...@wikimedia.de > + */ +( function( $, QUnit, PurposedCallbacks ) { + 'use strict'; + /* jshint newcap: false */ + + QUnit.module( 'jquery.PurposedCallbacks' ); + + QUnit.test( 'construction', function( assert ) { + var pc = PurposedCallbacks(); + + assert.ok( + pc instanceof PurposedCallbacks, + 'Instantiated without "new".' + ); + } ); + + QUnit.test( 'facade()', function( assert ) { + var pc = PurposedCallbacks(); + + assert.ok( + pc.facade() instanceof PurposedCallbacks.Facade, + 'Returns instance of PurposedCallbacks.Facade.' + ); + + assert.ok( + pc.facade() === pc.facade(), + 'Always returns the same facade instance and does not create a new one.' + ); + } ); + + QUnit.test( 'puposes() on instance without predefined purposes', function( assert ) { + var pcf = PurposedCallbacks().facade(); + + assert.ok( + $.isArray( pcf.purposes() ) && pcf.purposes().length === 0, + 'Returns an empty array.' + ); + + pcf.add( 'foo', $.noop ); + pcf.purposes().length = 0; + assert.deepEqual( + pcf.purposes(), + [ 'foo' ], + 'Returns a newly added purpose "foo", modifying the returned array has no effect ' + + 'on next returned array (no reference to internal object).' + ); + + pcf.add( 'bar', $.noop ); + assert.deepEqual( + pcf.purposes(), + [ 'foo', 'bar' ], + 'Returns a newly added purpose "bar" and the old "foo".' + ); + + pcf.remove( 'foo', $.noop ); + assert.deepEqual( + pcf.purposes(), + [ 'foo', 'bar' ], + 'Still returns "foo" as known purpose after removing only callback of "foo".' + ); + } ); + + QUnit.test( 'purposes() on instance with predefined purposes', function( assert ) { + var purposes = [ 'foo', 'bar' ]; + var pcf = PurposedCallbacks( purposes ).facade(); + + assert.deepEqual( + pcf.purposes(), + purposes, + 'Returns all predefined purposes initially.' + ); + + assert.ok( + pcf.purposes() !== purposes, + 'Returned array is a copy of the predefined purposes, not the same object.' + ); + + pcf.add( 'foo', $.noop ); + pcf.remove( 'foo', $.noop ); + assert.deepEqual( + pcf.purposes(), + purposes, + 'Still returns all purposes after adding and removing a callback .' + ); + } ); + + QUnit.test( 'chainable members', function( assert ) { + var pc = PurposedCallbacks(); + var pcf = pc.facade(); + + assert.ok( + pcf.add( 'foo', $.noop ) === pcf, + 'add() is chainable.' + ); + assert.ok( + pcf.remove( 'foo', $.noop ) === pcf, + 'remove() is chainable.' + ); + + // Chainable on base only, not a member of the facade: + assert.ok( + pc.fire( 'foo' ), + 'fire() is chainable' + ); + assert.ok( + pc.fireWith( 'foo', this ), + 'fireWith() is chainable' + ); + } ); + + QUnit.test( 'add() callback of unknown purpose', function( assert ) { + var pcf = PurposedCallbacks( [ 'bar' ] ).facade(); + assert.throws( + function() { + pcf.add( 'foo', $.noop ); + }, + 'Can not add callback for purpose not stated in list of predefined purposes.' + ); + } ); + + QUnit.test( 'fire()', function( assert ) { + var pc = PurposedCallbacks(); + var pcf = pc.facade(); + + var fired1 = 0; + var fired2 = 0; + var resetFired = function() { fired1 = fired2 = 0; }; + + assert.ok( + pcf.fire === undefined, + 'fire() is not a member of the facade.' + ); + + pcf.add( '1', function() { + fired1++; + } ); + pcf.add( '2', function() { + fired2++; + } ); + + pc.fire( '1' ); + assert.ok( + fired1 === 1 && fired2 === 0, + 'Fired callback of stated purpose. Purpose can be given as string.' + ); + + resetFired(); + + pc.fire( [ '2' ] ); + assert.ok( + fired1 === 0 && fired2 === 1, + 'Fired callback of another stated purpose. Purpose can be given as array of strings.' + ); + + resetFired(); + + pc.fire( [ '1', '2' ] ); + assert.ok( + fired1 === 1 && fired2 === 1, + 'Fired callback of both stated purposes together' + ); + } ); + + QUnit.test( 'fire() with custom arguments', 1, function( assert ) { + var pc = PurposedCallbacks(); + var pcf = pc.facade(); + var args = [ {}, [] ]; + + pcf.add( 'foo', function( arg1, arg2 ) { + assert.ok( + arg1 === args[0] && arg2 === args[1], + 'Arguments get passed to the callback.' + ); + } ); + pc.fire( 'foo', args ); + } ); + + // Helper callbacks for the following tests: + var fn1 = function() { this.push( 1 ); }; + var fn2 = function() { this.push( 2 ); }; + var fn3 = function() { this.push( 3 ); }; + var fn4 = function() { this.push( 4 ); }; + + QUnit.test( 'add() verified by fireWith()', function( assert ) { + var pc = PurposedCallbacks(); + var pcf = pc.facade(); + + pcf.add( 'foo', fn1 ); + pcf.add( 'bar', [ fn2, fn3 ] ); + pcf.add( 'foo', fn4 ); + + var feedback = []; + pc.fireWith( feedback, [ 'foo', 'bar' ] ); + assert.deepEqual( + feedback, + [ 1, 4, 2, 3 ], + 'Executed all callbacks in expected order. Verified add with single function and ' + + 'with array of functions.' + ); + + feedback = []; + pc.fireWith( feedback, [ 'bar', 'foo' ] ); + assert.deepEqual( + feedback, + [ 2, 3, 1, 4 ], + 'Executed all callbacks in expected order. Verified callbacks of first purpose given ' + + 'in "fireWith" are fired first.' + ); + } ); + + QUnit.test( 'remove() verified by fireWith()', function( assert ) { + var pc = PurposedCallbacks(); + var pcf = pc.facade(); + + pcf.add( 'foo', fn1 ); + pcf.add( 'foo', fn2 ); + pcf.remove( 'foo', fn2 ); + pcf.add( 'foo', fn3 ); + pcf.add( 'bar', fn4 ); + pcf.remove( 'foo', fn4 ); // Not registered for the purpose, so fn4 should still be called. + pcf.add( 'bar', fn1 ); + + var feedback = []; + pc.fireWith( feedback, [ 'foo', 'bar' ] ); + assert.deepEqual( + feedback, + [ 1, 3, 4, 1 ], + 'Executed all callbacks registered for purpose, except the ones removed again.' + ); + + var feedback = []; + pcf.remove( 'bar', [ fn1, fn4 ] ); + pc.fireWith( feedback, [ 'foo', 'bar' ] ); + assert.deepEqual( + feedback, + [ 1, 3 ], + 'remove() can remove all callbacks given in an array.' + ); + } ); + + QUnit.test( 'has()', function( assert ) { + var pc = PurposedCallbacks(); + var pcf = pc.facade(); + + pcf.add( 'foo', $.noop ); + assert.ok( + pcf.has( 'foo' ), + 'Newly defined purpose exists.' + ); + assert.ok( + pcf.has( 'foo', $.noop ), + 'Callback for that purpose is recognized as existent as well.' + ); + assert.ok( + !pcf.has( 'bar' ), + 'Purpose without callbacks does not exist.' + ); + + pcf.remove( 'foo', $.noop ); + assert.ok( + pcf.has( 'foo' ), + 'Purpose still exists after removing only callback from it.' + ); + } ); + + QUnit.test( 'has() on instance with predefined purposes', function( assert ) { + var pc = PurposedCallbacks( [ 'foo' ] ); + var pcf = pc.facade(); + + assert.ok( + pcf.has( 'foo' ), + 'Predefined purpose exists.' + ); + + pcf.add( 'foo', $.noop ); + pcf.remove( 'foo', $.noop ); + assert.ok( + pcf.has( 'foo' ), + 'Predefined purpose exists still exists after adding and removing callback from it.' + ); + assert.ok( + !pcf.has( 'foo', $.noop ), + 'Callback got removed though.' + ); + + assert.ok( + !pcf.has( 'bar' ), + 'Purpose without callbacks does not exist.' + ); + } ); + +}( jQuery, QUnit, jQuery.PurposedCallbacks ) ); -- To view, visit https://gerrit.wikimedia.org/r/79145 To unsubscribe, visit https://gerrit.wikimedia.org/r/settings Gerrit-MessageType: merged Gerrit-Change-Id: I312c0d52979cb276d6ed0336c53347236369a64c Gerrit-PatchSet: 8 Gerrit-Project: mediawiki/extensions/DataValues Gerrit-Branch: master Gerrit-Owner: Daniel Werner <daniel.wer...@wikimedia.de> Gerrit-Reviewer: Henning Snater <henning.sna...@wikimedia.de> Gerrit-Reviewer: jenkins-bot _______________________________________________ MediaWiki-commits mailing list MediaWiki-commits@lists.wikimedia.org https://lists.wikimedia.org/mailman/listinfo/mediawiki-commits