From: Matthew Flaschen Date: Tue, 31 Mar 2015 03:28:11 +0000 (-0400) Subject: Add pluggable talk page poster and use it for mediawiki.feedback X-Git-Tag: 1.31.0-rc.0~11826^2 X-Git-Url: https://git.cyclocoop.org/%7B%24www_url%7Dadmin/compta/banques/?a=commitdiff_plain;h=061b987f1cb9ba248978f28a2c68ff30905de3cf;p=lhc%2Fweb%2Fwiklou.git Add pluggable talk page poster and use it for mediawiki.feedback 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 --- diff --git a/CREDITS b/CREDITS index f58fabb15e..54890fe32b 100644 --- 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 diff --git a/jsduck.json b/jsduck.json index 53c69133fe..2b0c8fb295 100644 --- a/jsduck.json +++ b/jsduck.json @@ -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", diff --git a/languages/i18n/en.json b/languages/i18n/en.json index fb7056c39e..4bd56d42bd 100644 --- a/languages/i18n/en.json +++ b/languages/i18n/en.json @@ -3539,6 +3539,7 @@ "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", diff --git a/languages/i18n/qqq.json b/languages/i18n/qqq.json index b7c31fc7cf..1593f58580 100644 --- a/languages/i18n/qqq.json +++ b/languages/i18n/qqq.json @@ -3704,6 +3704,7 @@ "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}}", diff --git a/maintenance/jsduck/categories.json b/maintenance/jsduck/categories.json index 732bdc00cd..eab2b6325f 100644 --- a/maintenance/jsduck/categories.json +++ b/maintenance/jsduck/categories.json @@ -23,6 +23,7 @@ "classes": [ "mw.Title", "mw.Uri", + "mw.messagePoster.*", "mw.notification", "mw.Notification_", "mw.user", diff --git a/resources/Resources.php b/resources/Resources.php index f9d2eacae6..cbe6b82e6f 100644 --- a/resources/Resources.php +++ b/resources/Resources.php @@ -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 index 0000000000..b021558193 --- /dev/null +++ b/resources/src/mediawiki.messagePoster/mediawiki.messagePoster.MessagePoster.js @@ -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 index 0000000000..296576b4df --- /dev/null +++ b/resources/src/mediawiki.messagePoster/mediawiki.messagePoster.WikitextMessagePoster.js @@ -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 index 0000000000..098bc88075 --- /dev/null +++ b/resources/src/mediawiki.messagePoster/mediawiki.messagePoster.factory.js @@ -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 ) ); diff --git a/resources/src/mediawiki/mediawiki.feedback.js b/resources/src/mediawiki/mediawiki.feedback.js index 9a671c08b1..d9401001cc 100644 --- a/resources/src/mediawiki/mediawiki.feedback.js +++ b/resources/src/mediawiki/mediawiki.feedback.js @@ -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 @@ -53,12 +52,13 @@ 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'; @@ -120,6 +120,7 @@ case 'error1': case 'error2': case 'error3': + case 'error4': dialogConfig = { title: mw.msg( 'feedback-error-title' ), message: mw.msg( 'feedback-' + status ), @@ -147,8 +148,8 @@ * 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 @@ -171,7 +172,7 @@ { title: mw.msg( this.dialogTitleMessageKey ), settings: { - api: this.api, + messagePosterPromise: this.messagePosterPromise, title: this.feedbackPageTitle, dialogTitleMessageKey: this.dialogTitleMessageKey, bugsTaskSubmissionLink: this.bugsTaskSubmissionLink, @@ -339,7 +340,7 @@ 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(); @@ -418,38 +419,13 @@ 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 ); @@ -458,6 +434,40 @@ 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 */ diff --git a/tests/qunit/QUnitTestResources.php b/tests/qunit/QUnitTestResources.php index 9a3dab6c04..17b8b6329d 100644 --- a/tests/qunit/QUnitTestResources.php +++ b/tests/qunit/QUnitTestResources.php @@ -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 index 0000000000..61bab03f69 --- /dev/null +++ b/tests/qunit/suites/resources/mediawiki/mediawiki.messagePoster.factory.test.js @@ -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 ) );