Add pluggable talk page poster and use it for mediawiki.feedback
authorMatthew Flaschen <mflaschen@wikimedia.org>
Tue, 31 Mar 2015 03:28:11 +0000 (23:28 -0400)
committerMatthew Flaschen <mflaschen@wikimedia.org>
Mon, 6 Apr 2015 21:10:23 +0000 (14:10 -0700)
The core implementation will only support wikitext.
Flow will add its own implementation, and it can be used for any talk
page system identifiable by content model.

Bug: T91805
Change-Id: Ic69acafb24aa737536fe3a074e1958690732f0a7

12 files changed:
CREDITS
jsduck.json
languages/i18n/en.json
languages/i18n/qqq.json
maintenance/jsduck/categories.json
resources/Resources.php
resources/src/mediawiki.messagePoster/mediawiki.messagePoster.MessagePoster.js [new file with mode: 0644]
resources/src/mediawiki.messagePoster/mediawiki.messagePoster.WikitextMessagePoster.js [new file with mode: 0644]
resources/src/mediawiki.messagePoster/mediawiki.messagePoster.factory.js [new file with mode: 0644]
resources/src/mediawiki/mediawiki.feedback.js
tests/qunit/QUnitTestResources.php
tests/qunit/suites/resources/mediawiki/mediawiki.messagePoster.factory.test.js [new file with mode: 0644]

diff --git a/CREDITS b/CREDITS
index f58fabb..54890fe 100644 (file)
--- a/CREDITS
+++ b/CREDITS
@@ -56,6 +56,7 @@ following names for their contribution to the product.
 * Marius Hoch
 * Matěj Grabovský
 * Matt Johnston
+* Matthew Flaschen
 * Max Semenik
 * Meno25
 * MinuteElectron
index 53c6913..2b0c8fb 100644 (file)
@@ -15,6 +15,7 @@
                "resources/src/mediawiki.action",
                "resources/src/mediawiki.api",
                "resources/src/mediawiki.language",
+               "resources/src/mediawiki.messagePoster",
                "resources/src/mediawiki.page",
                "resources/src/mediawiki.special",
                "resources/src/mediawiki.toolbar",
index fb7056c..4bd56d4 100644 (file)
        "feedback-error1": "Error: Unrecognized result from API",
        "feedback-error2": "Error: Edit failed",
        "feedback-error3": "Error: No response from API",
+       "feedback-error4": "Error: Unable to post to given feedback title",
        "feedback-message": "Message:",
        "feedback-subject": "Subject:",
        "feedback-submit": "Submit",
index b7c31fc..1593f58 100644 (file)
        "feedback-error1": "Error message, appears when an unknown error occurs submitting feedback",
        "feedback-error2": "Error message, appears when we could not add feedback",
        "feedback-error3": "Error message, appears when we lose our connection to the wiki",
+       "feedback-error4": "Error message, appears when mediawiki.feedback or one of its dependencies is misconfigured or there is a problem fetching one of the modules",
        "feedback-message": "Label for a textarea; signature refers to a Wikitext signature.\n{{Identical|Message}}",
        "feedback-subject": "Label for a text input\n{{Identical|Subject}}",
        "feedback-submit": "Button label\n{{Identical|Submit}}",
index 732bdc0..eab2b63 100644 (file)
@@ -23,6 +23,7 @@
                                "classes": [
                                        "mw.Title",
                                        "mw.Uri",
+                                       "mw.messagePoster.*",
                                        "mw.notification",
                                        "mw.Notification_",
                                        "mw.user",
index f9d2eac..cbe6b82 100644 (file)
@@ -823,6 +823,7 @@ return array(
                        'mediawiki.Title',
                        'user.tokens',
                ),
+               'targets' => array( 'desktop', 'mobile' ),
        ),
        'mediawiki.api.login' => array(
                'scripts' => 'resources/src/mediawiki.api/mediawiki.api.login.js',
@@ -877,7 +878,7 @@ return array(
                'scripts' => 'resources/src/mediawiki/mediawiki.feedback.js',
                'styles' => 'resources/src/mediawiki/mediawiki.feedback.css',
                'dependencies' => array(
-                       'mediawiki.api.edit',
+                       'mediawiki.messagePoster',
                        'mediawiki.Title',
                        'oojs-ui',
                ),
@@ -896,6 +897,7 @@ return array(
                        'feedback-error1',
                        'feedback-error2',
                        'feedback-error3',
+                       'feedback-error4',
                        'feedback-message',
                        'feedback-subject',
                        'feedback-submit',
@@ -955,6 +957,26 @@ return array(
                ),
                'targets' => array( 'desktop', 'mobile' ),
        ),
+       'mediawiki.messagePoster' => array(
+               'scripts' => array(
+                       'resources/src/mediawiki.messagePoster/mediawiki.messagePoster.factory.js',
+                       'resources/src/mediawiki.messagePoster/mediawiki.messagePoster.MessagePoster.js',
+               ),
+               'dependencies' => array(
+                       'oojs',
+                       'mediawiki.api',
+               ),
+               'targets' => array( 'desktop', 'mobile' ),
+       ),
+       'mediawiki.messagePoster.wikitext' => array(
+               'scripts' => array(
+                       'resources/src/mediawiki.messagePoster/mediawiki.messagePoster.WikitextMessagePoster.js',
+               ),
+               'dependencies' => array(
+                       'mediawiki.api.edit',
+               ),
+               'targets' => array( 'desktop', 'mobile' ),
+       ),
        'mediawiki.notification' => array(
                'styles' => array(
                        'resources/src/mediawiki/mediawiki.notification.css',
diff --git a/resources/src/mediawiki.messagePoster/mediawiki.messagePoster.MessagePoster.js b/resources/src/mediawiki.messagePoster/mediawiki.messagePoster.MessagePoster.js
new file mode 100644 (file)
index 0000000..b021558
--- /dev/null
@@ -0,0 +1,38 @@
+/*global OO*/
+( function ( mw ) {
+       /**
+        * This is the abstract base class for MessagePoster implementations.
+        *
+        * @abstract
+        * @class
+        *
+        * @constructor
+        * @param {mw.Title} title Title to post to
+        */
+       mw.messagePoster.MessagePoster = function MwMessagePoster() {};
+
+       OO.initClass( mw.messagePoster.MessagePoster );
+
+       /**
+        * Post a message (with subject and body) to a talk page.
+        *
+        * @param {string} subject Subject/topic title; plaintext only (no wikitext or HTML)
+        * @param {string} body Body, as wikitext.  Signature code will automatically be added
+        *   by MessagePosters that require one, unless the message already contains the string
+        *   ~~~.
+        * @return {jQuery.Promise} Promise completing when the post succeeds or fails.
+        * @return {Function} return.done
+        * @return {Function} return.fail
+        * @return {string} return.fail.primaryError Primary error code.  For a mw.Api failure,
+        *   this should be 'api-fail'.
+        * @return {string} return.fail.secondaryError Secondary error code.  For a mw.Api failure,
+        *   this, should be mw.Api's code, e.g. 'http', 'ok-but-empty', or the error passed through
+        *   from the server.
+        * @return {Mixed} return.fail.details Further details about the error
+        *
+        * @localdoc
+        * The base class currently does nothing, but could be used for shared analytics or
+        * something.
+        */
+       mw.messagePoster.MessagePoster.prototype.post = function () {};
+}( mediaWiki ) );
diff --git a/resources/src/mediawiki.messagePoster/mediawiki.messagePoster.WikitextMessagePoster.js b/resources/src/mediawiki.messagePoster/mediawiki.messagePoster.WikitextMessagePoster.js
new file mode 100644 (file)
index 0000000..296576b
--- /dev/null
@@ -0,0 +1,53 @@
+/*global OO*/
+( function ( mw, $ ) {
+       /**
+        * This is an implementation of MessagePoster for wikitext talk pages.
+        *
+        * @class mw.messagePoster.WikitextMessagePoster
+        * @extends mw.messagePoster.MessagePoster
+        *
+        * @constructor
+        * @param {mw.Title} title Wikitext page in a talk namespace, to post to
+        */
+       function WikitextMessagePoster( title ) {
+               this.api = new mw.Api();
+               this.title = title;
+       }
+
+       OO.inheritClass(
+               WikitextMessagePoster,
+               mw.messagePoster.MessagePoster
+       );
+
+       /**
+        * @inheritdoc
+        */
+       WikitextMessagePoster.prototype.post = function ( subject, body ) {
+               mw.messagePoster.WikitextMessagePoster.parent.prototype.post.call( this, subject, body );
+
+               // Add signature if needed
+               if ( body.indexOf( '~~~' ) === -1 ) {
+                       body += '\n\n~~~~';
+               }
+
+               return this.api.newSection(
+                       this.title,
+                       subject,
+                       body,
+                       { redirect: true }
+               ).then( function ( resp, jqXHR ) {
+                       if ( resp.edit.result === 'Success' ) {
+                               return $.Deferred().resolve( resp, jqXHR );
+                       } else {
+                               // mediawiki.api.js checks for resp.error.  Are there actually cases where the
+                               // request fails, but it's not caught there?
+                               return $.Deferred().reject( 'api-unexpected' );
+                       }
+               }, function ( code, details ) {
+                       return $.Deferred().reject( 'api-fail', code, details );
+               } ).promise();
+       };
+
+       mw.messagePoster.factory.register( 'wikitext', WikitextMessagePoster );
+       mw.messagePoster.WikitextMessagePoster = WikitextMessagePoster;
+}( mediaWiki, jQuery ) );
diff --git a/resources/src/mediawiki.messagePoster/mediawiki.messagePoster.factory.js b/resources/src/mediawiki.messagePoster/mediawiki.messagePoster.factory.js
new file mode 100644 (file)
index 0000000..098bc88
--- /dev/null
@@ -0,0 +1,108 @@
+/*global OO*/
+( function ( mw, $ ) {
+       /**
+        * This is a factory for MessagePoster objects, which allows a pluggable to way to script leaving a
+        * talk page message.
+        *
+        * @class mw.messagePoster.factory
+        * @singleton
+        */
+       function MwMessagePosterFactory() {
+               this.api = new mw.Api();
+               this.contentModelToClass = {};
+       }
+
+       OO.initClass( MwMessagePosterFactory );
+
+       // Note: This registration scheme is currently not compatible with LQT, since that doesn't
+       // have its own content model, just islqttalkpage.  LQT pages will be passed to the wikitext
+       // MessagePoster.
+       /**
+        * Registers a MessagePoster subclass for a given content model.
+        *
+        * @param {string} contentModel Content model of pages this MessagePoster can post to
+        * @param {Function} messagePosterConstructor Constructor for MessagePoster
+        */
+       MwMessagePosterFactory.prototype.register = function ( contentModel, messagePosterConstructor ) {
+               if ( this.contentModelToClass[contentModel] !== undefined ) {
+                       throw new Error( 'The content model \'' + contentModel + '\' is already registered.' );
+               }
+
+               this.contentModelToClass[contentModel] = messagePosterConstructor;
+       };
+
+       /**
+        * Unregisters a given content model
+        * This is exposed for testing and should not normally be needed.
+        *
+        * @param {string} contentModel Content model to unregister
+        */
+       MwMessagePosterFactory.prototype.unregister = function ( contentModel ) {
+               delete this.contentModelToClass[contentModel];
+       };
+
+       /**
+        * Creates a MessagePoster, given a title.  A promise for this is returned.
+        * This works by determining the content model, then loading the corresponding
+        * module (which will register the MessagePoster class), and finally constructing it.
+        *
+        * This does not require the message and should be called as soon as possible, so it does the
+        * API and ResourceLoader requests in the background.
+        *
+        * @param {mw.Title} title Title that will be posted to
+        * @return {jQuery.Promise} Promise for the MessagePoster
+        * @return {Function} return.done Called if MessagePoster is retrieved
+        * @return {mw.messagePoster.MessagePoster} return.done.poster MessagePoster
+        * @return {Function} return.fail Called if MessagePoster could not be constructed
+        * @return {string} return.fail.errorCode String error code
+        */
+       MwMessagePosterFactory.prototype.create = function ( title ) {
+               var pageId, page, contentModel, moduleName,
+                       factory = this;
+
+               return this.api.get( {
+                       action: 'query',
+                       prop: 'info',
+                       indexpageids: 1,
+                       titles: title.getPrefixedDb()
+               } ).then( function ( result ) {
+                       if ( result.query.pageids.length > 0 ) {
+                               pageId = result.query.pageids[0];
+                               page = result.query.pages[pageId];
+
+                               contentModel = page.contentmodel;
+                               moduleName = 'mediawiki.messagePoster.' + contentModel;
+                               return mw.loader.using( moduleName ).then( function () {
+                                       return factory.createForContentModel(
+                                               contentModel,
+                                               title
+                                       );
+                               }, function () {
+                                       return $.Deferred().reject( 'failed-to-load-module', 'Failed to load the \'' + moduleName + '\' module' );
+                               } );
+                       } else {
+                               return $.Deferred().reject( 'unexpected-response', 'Unexpected API response' );
+                       }
+               }, function ( errorCode, details ) {
+                       return $.Deferred().reject( 'content-model-query-failed', errorCode, details );
+               } ).promise();
+       };
+
+       /**
+        * Creates a MessagePoster instance, given a title and content model
+        *
+        * @private
+        *
+        * @param {string} contentModel Content model of title
+        * @param {mw.Title} title Title being posted to
+        * @return {mw.messagePoster.MessagePoster}
+        *
+        */
+       MwMessagePosterFactory.prototype.createForContentModel = function ( contentModel, title ) {
+               return new this.contentModelToClass[contentModel]( title );
+       };
+
+       mw.messagePoster = {
+               factory: new MwMessagePosterFactory()
+       };
+}( mediaWiki, jQuery ) );
index 9a671c0..d940100 100644 (file)
@@ -36,7 +36,6 @@
         * @class
         * @constructor
         * @param {Object} [config] Configuration object
-        * @cfg {mw.Api} [api] if omitted, will just create a standard API
         * @cfg {mw.Title} [title="Feedback"] The title of the page where you collect
         *  feedback.
         * @cfg {string} [dialogTitleMessageKey="feedback-dialog-title"] Message key for the
        mw.Feedback = function MwFeedback( config ) {
                config = config || {};
 
-               this.api = config.api || new mw.Api();
                this.dialogTitleMessageKey = config.dialogTitleMessageKey || 'feedback-dialog-title';
 
                // Feedback page title
                this.feedbackPageTitle = config.title || new mw.Title( 'Feedback' );
 
+               this.messagePosterPromise = mw.messagePoster.factory.create( this.feedbackPageTitle );
+
                // Links
                this.bugsTaskSubmissionLink = config.bugsLink || '//phabricator.wikimedia.org/maniphest/task/create/';
                this.bugsTaskListLink = config.bugsListLink || '//phabricator.wikimedia.org/maniphest/query/advanced';
                        case 'error1':
                        case 'error2':
                        case 'error3':
+                       case 'error4':
                                dialogConfig = {
                                        title: mw.msg( 'feedback-error-title' ),
                                        message: mw.msg( 'feedback-' + status ),
         * Modify the display form, and then open it, focusing interface on the subject.
         *
         * @param {Object} [contents] Prefilled contents for the feedback form.
-        * @param {string} [contents.subject] The subject of the feedback
-        * @param {string} [contents.message] The content of the feedback
+        * @param {string} [contents.subject] The subject of the feedback, as plaintext
+        * @param {string} [contents.message] The content of the feedback, as wikitext
         */
        mw.Feedback.prototype.launch = function ( contents ) {
                // Dialog
                        {
                                title: mw.msg( this.dialogTitleMessageKey ),
                                settings: {
-                                       api: this.api,
+                                       messagePosterPromise: this.messagePosterPromise,
                                        title: this.feedbackPageTitle,
                                        dialogTitleMessageKey: this.dialogTitleMessageKey,
                                        bugsTaskSubmissionLink: this.bugsTaskSubmissionLink,
                                this.feedbackMessageInput.setValue( data.contents.message );
 
                                this.status = '';
-                               this.api = settings.api;
+                               this.messagePosterPromise = settings.messagePosterPromise;
                                this.setBugReportLink( settings.bugsTaskSubmissionLink );
                                this.feedbackPageTitle = settings.title;
                                this.feedbackPageName = settings.title.getNameText();
                                        message = userAgentMessage + message;
                                }
 
-                               // Add signature if needed
-                               if ( message.indexOf( '~~~' ) === -1 ) {
-                                       message += '\n\n~~~~';
-                               }
-
-                               // Post the message, resolving redirects
-                               this.pushPending();
-                               this.api.newSection(
-                                       this.feedbackPageTitle,
-                                       subject,
-                                       message,
-                                       { redirect: true }
-                               )
-                               .done( function ( result ) {
-                                       if ( result.edit.result === 'Success' ) {
-                                               fb.status = 'submitted';
-                                       } else {
-                                               fb.status = 'error1';
-                                       }
-                                       fb.popPending();
-                                       fb.close();
-                               } )
-                               .fail( function ( code, result ) {
-                                       if ( code === 'http' ) {
-                                               fb.status = 'error3';
-                                               // ajax request failed
-                                               mw.log.warn( 'Feedback report failed with HTTP error: ' +  result.textStatus );
-                                       } else {
-                                               fb.status = 'error2';
-                                               mw.log.warn( 'Feedback report failed with API error: ' +  code );
-                                       }
-                                       fb.popPending();
+                               // Post the message
+                               return this.messagePosterPromise.then( function ( poster ) {
+                                       return fb.postMessage( poster, subject, message );
+                               }, function () {
+                                       fb.status = 'error4';
+                                       mw.log.warn( 'Feedback report failed because MessagePoster could not be fetched' );
+                               } ).always( function () {
                                        fb.close();
                                } );
                        }, this );
                return mw.Feedback.Dialog.super.prototype.getActionProcess.call( this, action );
        };
 
+       /**
+        * Posts the message
+        *
+        * @private
+        *
+        * @param {mw.messagePoster.MessagePoster} poster Poster implementation used to leave feedback
+        * @param {string} subject Subject of message
+        * @param {string} message Body of message
+        * @return {jQuery.Promise} Promise representing success of message posting action
+        */
+       mw.Feedback.Dialog.prototype.postMessage = function ( poster, subject, message ) {
+               var fb = this;
+
+               return poster.post(
+                       subject,
+                       message
+               ).then( function () {
+                       fb.status = 'submitted';
+               }, function ( mainCode, secondaryCode, details ) {
+                       if ( mainCode === 'api-fail' ) {
+                               if ( secondaryCode === 'http' ) {
+                                       fb.status = 'error3';
+                                       // ajax request failed
+                                       mw.log.warn( 'Feedback report failed with HTTP error: ' +  details.textStatus );
+                               } else {
+                                       fb.status = 'error2';
+                                       mw.log.warn( 'Feedback report failed with API error: ' +  secondaryCode );
+                               }
+                       } else {
+                               fb.status = 'error1';
+                       }
+               } );
+       };
+
        /**
         * @inheritdoc
         */
index 9a3dab6..17b8b63 100644 (file)
@@ -65,6 +65,7 @@ return array(
                        'tests/qunit/suites/resources/mediawiki/mediawiki.errorLogger.test.js',
                        'tests/qunit/suites/resources/mediawiki/mediawiki.jqueryMsg.test.js',
                        'tests/qunit/suites/resources/mediawiki/mediawiki.jscompat.test.js',
+                       'tests/qunit/suites/resources/mediawiki/mediawiki.messagePoster.factory.test.js',
                        'tests/qunit/suites/resources/mediawiki/mediawiki.template.test.js',
                        'tests/qunit/suites/resources/mediawiki/mediawiki.test.js',
                        'tests/qunit/suites/resources/mediawiki/mediawiki.Title.test.js',
@@ -106,6 +107,7 @@ return array(
                        'mediawiki.api.parse',
                        'mediawiki.api.watch',
                        'mediawiki.jqueryMsg',
+                       'mediawiki.messagePoster',
                        'mediawiki.Title',
                        'mediawiki.toc',
                        'mediawiki.Uri',
diff --git a/tests/qunit/suites/resources/mediawiki/mediawiki.messagePoster.factory.test.js b/tests/qunit/suites/resources/mediawiki/mediawiki.messagePoster.factory.test.js
new file mode 100644 (file)
index 0000000..61bab03
--- /dev/null
@@ -0,0 +1,28 @@
+( function ( mw ) {
+       var TEST_MODEL = 'test-content-model';
+
+       QUnit.module( 'mediawiki.messagePoster', QUnit.newMwEnvironment( {
+               teardown: function () {
+                       mw.messagePoster.factory.unregister( TEST_MODEL );
+               }
+       } ) );
+
+       QUnit.test( 'register', 2, function ( assert ) {
+               var testMessagePosterConstructor = function () {};
+
+               mw.messagePoster.factory.register( TEST_MODEL, testMessagePosterConstructor );
+               assert.strictEqual(
+                       mw.messagePoster.factory.contentModelToClass[TEST_MODEL],
+                       testMessagePosterConstructor,
+                       'Constructor is registered'
+               );
+
+               assert.throws(
+                       function () {
+                               mw.messagePoster.factory.register( TEST_MODEL, testMessagePosterConstructor );
+                       },
+                       new RegExp( 'The content model \'' + TEST_MODEL + '\' is already registered.' ),
+                       'Throws exception is same model is registered a second time'
+               );
+       } );
+}( mediaWiki ) );