RollbackAction: Implement AJAX interface and require POST
authorTimo Tijhof <krinklemail@gmail.com>
Tue, 29 Sep 2015 02:53:20 +0000 (19:53 -0700)
committerTimo Tijhof <krinklemail@gmail.com>
Mon, 23 May 2016 19:00:48 +0000 (20:00 +0100)
Similar to WatchAction (converted in commit 77cdf1919).

* Make FormAction::getFormFields not abstract.
  In most cases this will just be an empty array.

* Convert RollbackAction from FormlessAction to FormAction and implement the
  required error handling scenarios (mostly moved out of from the old method, or
  duplicated from the WikiPage method where necessary).

* In most cases the in-between form is never used since a JavaScript handler
  takes over the link and uses the API over AJAX instead. In the no-js fallback
  (as well as for any existing tokenless rollback links) copy the GET parameters
  into the form for re-submission as POST (plus token, added by HTMLForm).

* Remove the distinction between WebUI and API tokens. This stronger token salt made it
  unnecessarily complex and was only there because it used GET until now. This streamlining of
  tokens matches what we already do for 'watch', 'edit', 'patrol' and other actions.

* Fix form submission bugs when 'from' query parameter is missing.

  - Ensure the required 'from' query parameter is present before showing a form.
    No need for the user to submit a form we know will fail.

  - Plain GET request to action=rollback (with no parameters) is now a 400 Bad Request
    instead of a form that would fail when submitted.

  - Submitting the form without 'form' field now correctly says why it failed.
    Previously it emitted a session error, which was a lie.

Bug: T88044
Change-Id: Ia457802fec2e90573c8e7d552bc1f3cee258f10b

12 files changed:
RELEASE-NOTES-1.28
includes/Linker.php
includes/actions/RollbackAction.php
includes/api/ApiRollback.php
includes/page/WikiPage.php
languages/i18n/en.json
languages/i18n/qqq.json
resources/Resources.php
resources/src/mediawiki/api/rollback.js [new file with mode: 0644]
resources/src/mediawiki/page/patrol.ajax.js
resources/src/mediawiki/page/rollback.js [new file with mode: 0644]
resources/src/mediawiki/page/watch.js

index e365486..76728c8 100644 (file)
@@ -13,7 +13,7 @@ production.
 === New features in 1.28 ===
 * User::isBot() method for checking if an account is a bot role account.
 * Added a new hook, 'UserIsBot', to aid in determining if a user is a bot.
-
+* (T88044) Implemented one-click rollback handling via AJAX.
 
 === External library changes in 1.28 ===
 
index 6a869dd..b81218f 100644 (file)
@@ -1872,7 +1872,9 @@ class Linker {
         * work if $wgShowRollbackEditCount is disabled, so this can only function
         * as an additional check.
         *
-        * If the option noBrackets is set the rollback link wont be enclosed in []
+        * If the option noBrackets is set the rollback link wont be enclosed in "[]".
+        *
+        * See the "mediawiki.page.rollback" module for the client-side handling of this link.
         *
         * @since 1.16.3. $context added in 1.20. $options added in 1.21
         *
@@ -1902,6 +1904,8 @@ class Linker {
                        $inner = $context->msg( 'brackets' )->rawParams( $inner )->escaped();
                }
 
+               $context->getOutput()->addModules( 'mediawiki.page.rollback' );
+
                return '<span class="mw-rollback-link">' . $inner . '</span>';
        }
 
@@ -1996,11 +2000,13 @@ class Linker {
                $query = [
                        'action' => 'rollback',
                        'from' => $rev->getUserText(),
-                       'token' => $context->getUser()->getEditToken( [
-                               $title->getPrefixedText(),
-                               $rev->getUserText()
-                       ] ),
                ];
+               $attrs = [
+                       'data-mw' => 'interface',
+                       'title' => $context->msg( 'tooltip-rollback' )->text(),
+               ];
+               $options = [ 'known', 'noclasses' ];
+
                if ( $context->getRequest()->getBool( 'bot' ) ) {
                        $query['bot'] = '1';
                        $query['hidediff'] = '1'; // bug 15999
@@ -2025,27 +2031,16 @@ class Linker {
                        }
 
                        if ( $editCount > $wgShowRollbackEditCount ) {
-                               $editCount_output = $context->msg( 'rollbacklinkcount-morethan' )
+                               $html = $context->msg( 'rollbacklinkcount-morethan' )
                                        ->numParams( $wgShowRollbackEditCount )->parse();
                        } else {
-                               $editCount_output = $context->msg( 'rollbacklinkcount' )->numParams( $editCount )->parse();
+                               $html = $context->msg( 'rollbacklinkcount' )->numParams( $editCount )->parse();
                        }
 
-                       return self::link(
-                               $title,
-                               $editCount_output,
-                               [ 'title' => $context->msg( 'tooltip-rollback' )->text() ],
-                               $query,
-                               [ 'known', 'noclasses' ]
-                       );
+                       return self::link( $title, $html, $attrs, $query, $options );
                } else {
-                       return self::link(
-                               $title,
-                               $context->msg( 'rollbacklink' )->escaped(),
-                               [ 'title' => $context->msg( 'tooltip-rollback' )->text() ],
-                               $query,
-                               [ 'known', 'noclasses' ]
-                       );
+                       $html = $context->msg( 'rollbacklink' )->escaped();
+                       return self::link( $title, $html, $attrs, $query, $options );
                }
        }
 
index d002da8..e32582e 100644 (file)
@@ -25,7 +25,7 @@
  *
  * @ingroup Actions
  */
-class RollbackAction extends FormlessAction {
+class RollbackAction extends FormAction {
 
        public function getName() {
                return 'rollback';
@@ -35,39 +35,79 @@ class RollbackAction extends FormlessAction {
                return 'rollback';
        }
 
-       public function onView() {
-               // TODO: use $this->useTransactionalTimeLimit(); when POST only
-               wfTransactionalTimeLimit();
+       protected function preText() {
+               return $this->msg( 'confirm-rollback-top' )->parse();
+       }
+
+       protected function alterForm( HTMLForm $form ) {
+               $form->setSubmitTextMsg( 'confirm-rollback-button' );
+               $form->setTokenSalt( 'rollback' );
+
+               // Copy parameters from GET to confirmation form
+               $from = $this->getRequest()->getVal( 'from' );
+               if ( $from === null ) {
+                       throw new BadRequestError( 'rollbackfailed', 'rollback-missingparam' );
+               }
+               foreach ( [ 'from', 'bot', 'hidediff', 'summary' ] as $param ) {
+                       $val = $this->getRequest()->getVal( $param );
+                       if ( $val !== null ) {
+                               $form->addHiddenField( $param, $val );
+                       }
+               }
+       }
 
-               $details = null;
+       /**
+        * This must return true so that HTMLForm::show() will not display the form again after
+        * submission. For rollback, display either the form or the result (success/error)
+        * not both.
+        *
+        * @return bool
+        * @throws ErrorPageError
+        */
+       public function onSubmit( $data ) {
+               $this->useTransactionalTimeLimit();
 
                $request = $this->getRequest();
                $user = $this->getUser();
+               $from = $request->getVal( 'from' );
+               $rev = $this->page->getRevision();
+               if ( $from === null || $from === '' ) {
+                       throw new ErrorPageError( 'rollbackfailed', 'rollback-missingparam' );
+               }
+               if ( $from !== $rev->getUserText() ) {
+                       throw new ErrorPageError( 'rollbackfailed', 'alreadyrolled', [
+                               $this->getTitle()->getPrefixedText(),
+                               $from,
+                               $rev->getUserText()
+                       ] );
+               }
 
-               $result = $this->page->doRollback(
-                       $request->getVal( 'from' ),
+               $data = null;
+               $errors = $this->page->doRollback(
+                       $from,
                        $request->getText( 'summary' ),
-                       $request->getVal( 'token' ),
+                       // Provided by HTMLForm
+                       $request->getVal( 'wpEditToken' ),
                        $request->getBool( 'bot' ),
-                       $details,
+                       $data,
                        $this->getUser()
                );
 
-               if ( in_array( [ 'actionthrottledtext' ], $result ) ) {
+               if ( in_array( [ 'actionthrottledtext' ], $errors ) ) {
                        throw new ThrottledError;
                }
 
-               if ( isset( $result[0][0] ) &&
-                       ( $result[0][0] == 'alreadyrolled' || $result[0][0] == 'cantrollback' )
+               if ( isset( $errors[0][0] ) &&
+                       ( $errors[0][0] == 'alreadyrolled' || $errors[0][0] == 'cantrollback' )
                ) {
                        $this->getOutput()->setPageTitle( $this->msg( 'rollbackfailed' ) );
-                       $errArray = $result[0];
+                       $errArray = $errors[0];
                        $errMsg = array_shift( $errArray );
                        $this->getOutput()->addWikiMsgArray( $errMsg, $errArray );
 
-                       if ( isset( $details['current'] ) ) {
+                       if ( isset( $data['current'] ) ) {
                                /** @var Revision $current */
-                               $current = $details['current'];
+                               $current = $data['current'];
 
                                if ( $current->getComment() != '' ) {
                                        $this->getOutput()->addHTML( $this->msg( 'editcomment' )->rawParams(
@@ -75,25 +115,24 @@ class RollbackAction extends FormlessAction {
                                }
                        }
 
-                       return;
+                       return true;
                }
 
                # NOTE: Permission errors already handled by Action::checkExecute.
-
-               if ( $result == [ [ 'readonlytext' ] ] ) {
+               if ( $errors == [ [ 'readonlytext' ] ] ) {
                        throw new ReadOnlyError;
                }
 
                # XXX: Would be nice if ErrorPageError could take multiple errors, and/or a status object.
-               #     Right now, we only show the first error
-               foreach ( $result as $error ) {
+               #      Right now, we only show the first error
+               foreach ( $errors as $error ) {
                        throw new ErrorPageError( 'rollbackfailed', $error[0], array_slice( $error, 1 ) );
                }
 
                /** @var Revision $current */
-               $current = $details['current'];
-               $target = $details['target'];
-               $newId = $details['newid'];
+               $current = $data['current'];
+               $target = $data['target'];
+               $newId = $data['newid'];
                $this->getOutput()->setPageTitle( $this->msg( 'actioncomplete' ) );
                $this->getOutput()->setRobotPolicy( 'noindex,nofollow' );
 
@@ -121,6 +160,12 @@ class RollbackAction extends FormlessAction {
                        );
                        $de->showDiff( '', '' );
                }
+               return true;
+       }
+
+       public function onSuccess() {
+               // Required by parent class, but redundant because onSubmit already shows
+               // the success message when needed.
        }
 
        protected function getDescription() {
index 55f7143..b9911da 100644 (file)
@@ -45,16 +45,6 @@ class ApiRollback extends ApiBase {
                $user = $this->getUser();
                $params = $this->extractRequestParams();
 
-               // WikiPage::doRollback needs a Web UI token, so get one of those if we
-               // validated based on an API rollback token.
-               $token = $params['token'];
-               if ( $user->matchEditToken( $token, 'rollback', $this->getRequest() ) ) {
-                       $token = $this->getUser()->getEditToken(
-                               $this->getWebUITokenSalt( $params ),
-                               $this->getRequest()
-                       );
-               }
-
                $titleObj = $this->getRbTitle( $params );
                $pageObj = WikiPage::factory( $titleObj );
                $summary = $params['summary'];
@@ -72,15 +62,30 @@ class ApiRollback extends ApiBase {
                $retval = $pageObj->doRollback(
                        $this->getRbUser( $params ),
                        $summary,
-                       $token,
+                       $params['token'],
                        $params['markbot'],
                        $details,
                        $user,
                        $params['tags']
                );
 
+               // We don't care about multiple errors, just report one of them
                if ( $retval ) {
-                       // We don't care about multiple errors, just report one of them
+                       if ( isset( $retval[0][0] ) &&
+                               ( $retval[0][0] == 'alreadyrolled' || $retval[0][0] == 'cantrollback' )
+                       ) {
+                               $error = $retval[0];
+                               $userMessage = $this->msg( $error[0], array_slice( $error, 1 ) );
+                               // dieUsageMsg() doesn't support $extraData
+                               $errorCode = $error[0];
+                               $errorInfo = isset( ApiBase::$messageMap[$errorCode] ) ?
+                                       ApiBase::$messageMap[$errorCode]['info'] :
+                                       $errorCode;
+                               $this->dieUsage( $errorInfo, $errorCode, 0, [
+                                       'messageHtml' => $userMessage->parseAsBlock()
+                               ] );
+                       }
+
                        $this->dieUsageMsg( reset( $retval ) );
                }
 
@@ -97,10 +102,23 @@ class ApiRollback extends ApiBase {
                        'pageid' => intval( $details['current']->getPage() ),
                        'summary' => $details['summary'],
                        'revid' => intval( $details['newid'] ),
+                       // The revision being reverted (previously the current revision of the page)
                        'old_revid' => intval( $details['current']->getID() ),
+                       // The revision being restored (the last revision before revision(s) by the reverted user)
                        'last_revid' => intval( $details['target']->getID() )
                ];
 
+               $oldUser = $details['current']->getUserText( Revision::FOR_THIS_USER );
+               $lastUser = $details['target']->getUserText( Revision::FOR_THIS_USER );
+               $diffUrl = $titleObj->getFullURL( [
+                       'diff' => $info['revid'],
+                       'oldid' => $info['old_revid'],
+                       'diffonly' => '1'
+               ] );
+               $info['messageHtml'] = $this->msg( 'rollback-success-notify' )
+                       ->params( $oldUser, $lastUser, $diffUrl )
+                       ->parseAsBlock();
+
                $this->getResult()->addValue( null, $this->getModuleName(), $info );
        }
 
@@ -148,13 +166,6 @@ class ApiRollback extends ApiBase {
                return 'rollback';
        }
 
-       protected function getWebUITokenSalt( array $params ) {
-               return [
-                       $this->getRbTitle( $params )->getPrefixedText(),
-                       $this->getRbUser( $params )
-               ];
-       }
-
        /**
         * @param array $params
         *
index 8702156..cf533d6 100644 (file)
@@ -2993,6 +2993,7 @@ class WikiPage implements Page, IDBAccessObject {
         * to do the dirty work
         *
         * @todo Separate the business/permission stuff out from backend code
+        * @todo Remove $token parameter. Already verified by RollbackAction and ApiRollback.
         *
         * @param string $fromP Name of the user whose edits to rollback.
         * @param string $summary Custom summary. Set to default summary if empty.
@@ -3023,7 +3024,7 @@ class WikiPage implements Page, IDBAccessObject {
                $rollbackErrors = $this->mTitle->getUserPermissionsErrors( 'rollback', $user );
                $errors = array_merge( $editErrors, wfArrayDiff2( $rollbackErrors, $editErrors ) );
 
-               if ( !$user->matchEditToken( $token, [ $this->mTitle->getPrefixedText(), $fromP ] ) ) {
+               if ( !$user->matchEditToken( $token, 'rollback' ) ) {
                        $errors[] = [ 'sessionfailure' ];
                }
 
index 3000a54..2ae80e2 100644 (file)
        "rollbacklinkcount": "rollback $1 {{PLURAL:$1|edit|edits}}",
        "rollbacklinkcount-morethan": "rollback more than $1 {{PLURAL:$1|edit|edits}}",
        "rollbackfailed": "Rollback failed",
+       "rollback-missingparam": "Missing required parameters on request.",
        "cantrollback": "Cannot revert edit;\nlast contributor is only author of this page.",
        "alreadyrolled": "Cannot rollback last edit of [[:$1]] by [[User:$2|$2]] ([[User talk:$2|talk]]{{int:pipe-separator}}[[Special:Contributions/$2|{{int:contribslink}}]]);\nsomeone else has edited or rolled back the page already.\n\nThe last edit to the page was by [[User:$3|$3]] ([[User talk:$3|talk]]{{int:pipe-separator}}[[Special:Contributions/$3|{{int:contribslink}}]]).",
        "editcomment": "The edit summary was: <em>$1</em>.",
        "revertpage": "Reverted edits by [[Special:Contributions/$2|$2]] ([[User talk:$2|talk]]) to last revision by [[User:$1|$1]]",
        "revertpage-nouser": "Reverted edits by a hidden user to last revision by {{GENDER:$1|[[User:$1|$1]]}}",
        "rollback-success": "Reverted edits by $1;\nchanged back to last revision by $2.",
+       "rollback-success-notify": "Reverted edits by $1;\nchanged back to last revision by $2. [$3 Show changes]",
        "sessionfailure-title": "Session failure",
        "sessionfailure": "There seems to be a problem with your login session;\nthis action has been canceled as a precaution against session hijacking.\nGo back to the previous page, reload that page and then try again.",
        "changecontentmodel" : "Change content model of a page",
        "confirm-watch-top": "Add this page to your watchlist?",
        "confirm-unwatch-button": "OK",
        "confirm-unwatch-top": "Remove this page from your watchlist?",
+       "confirm-rollback-button": "OK",
+       "confirm-rollback-top": "Revert edits to this page?",
        "semicolon-separator": ";&#32;",
        "comma-separator": ",&#32;",
        "colon-separator": ":&#32;",
index f6d2f41..c301616 100644 (file)
        "rollbacklinkcount": "{{doc-actionlink}}\nText of the rollback link showing the number of edits to be rolled back. See also {{msg-mw|rollbacklink}}.\n\nParameters:\n* $1 - the number of edits that will be rolled back. If $1 is over the value of <code>$wgShowRollbackEditCount</code> (default: 10) {{msg-mw|rollbacklinkcount-morethan}} is used.\n\nThe rollback link is displayed with a tooltip {{msg-mw|Tooltip-rollback}}",
        "rollbacklinkcount-morethan": "{{doc-actionlink}}\nText of the rollback link when a greater number of edits is to be rolled back. See also {{msg-mw|rollbacklink}}.\n\nWhen the number of edits rolled back is smaller than [[mw:Special:MyLanguage/Manual:$wgShowRollbackEditCount|$wgShowRollbackEditCount]], {{msg-mw|rollbacklinkcount}} is used instead.\n\nParameters:\n* $1 - number of edits",
        "rollbackfailed": "{{Identical|Rollback}}",
-       "cantrollback": "Used as error message when rolling back.\n\nSee also:\n* {{msg-mw|Notvisiblerev}}\n{{Identical|Revert}}\n{{Identical|Rollback}}",
+       "rollback-missingparam": "Used as error message rollback is accessed without the required parameters\n\nSee also:\n* {{msg-mw|Rollbackfailed}}\n.",
+       "cantrollback": "Used as error message when rollback fails due to there not being a valid revision to revert back to.\n\nSee also:\n* {{msg-mw|Notvisiblerev}}\n{{Identical|Revert}}\n{{Identical|Rollback}}",
        "alreadyrolled": "Appear when there's rollback and/or edit collision.\n\nRefers to:\n* {{msg-mw|Pipe-separator}}\n* {{msg-mw|Contribslink}}\nParameters:\n* $1 - the page to be rolled back\n* $2 - the editor to be rolled-back of that page\n* $3 - the editor that cause collision\n{{Identical|Rollback}}",
        "editcomment": "Only shown if there is an edit {{msg-mw|Summary}}. Parameters:\n* $1 - the edit summary",
        "revertpage": "Parameters:\n* $1 - username 1\n* $2 - username 2\n* $3 - (Optional) revision ID of the revision reverted to\n* $4 - (Optional) timestamp of the revision reverted to\n* $5 - (Optional) revision ID of the revision reverted from\n* $6 - (Optional) timestamp of the revision reverted from\nSee also:\n* {{msg-mw|Revertpage-nouser}}\n{{Identical|Revert}}",
        "revertpage-nouser": "This is a confirmation message a user sees after reverting, when the username of the version is hidden with RevisionDelete.\n\nIn other cases the message {{msg-mw|Revertpage}} is used.\n\nParameters:\n* $1 - username 1, can be used for GENDER\n* $2 - (Optional) username 2\n* $3 - (Optional) revision ID of the revision reverted to\n* $4 - (Optional) timestamp of the revision reverted to\n* $5 - (Optional) revision ID of the revision reverted from\n* $6 - (Optional) timestamp of the revision reverted from",
        "rollback-success": "This message shows up on screen after successful revert (generally visible only to admins). $1 describes user whose changes have been reverted, $2 describes user which produced version, which replaces reverted version.\n{{Identical|Revert}}\n{{Identical|Rollback}}",
+       "rollback-success-notify": "Notification shown after a successful revert.\n* $1 - User whose changes have been reverted\n* $2 - User that made the edit that was restored\n* $3 - Url to the diff of the rollback\nSee also:\n*{{mw-msg|showdiff}}\n{{Identical|rollback-success}}\n{{Format|jquerymsg}}",
        "sessionfailure-title": "Used as title of the error message {{msg-mw|Sessionfailure}}.",
        "sessionfailure": "Used as error message.\n\nThe title for this error message is {{msg-mw|Sessionfailure-title}}.",
        "changecontentmodel": "Title of the change content model special page",
        "confirm-watch-top": "Used as confirmation message.",
        "confirm-unwatch-button": "Used as Submit button text.\n{{Identical|OK}}",
        "confirm-unwatch-top": "Used as confirmation message.",
+       "confirm-rollback-button": "Used as Submit button text.\n{{Identical|OK}}",
+       "confirm-rollback-top": "Used as confirmation message.",
        "semicolon-separator": "{{optional}}",
        "comma-separator": "{{optional}}\n\nWarning: languages have different usages of punctuation, and sometimes they are swapped (e.g. openining and closing quotation marks, or full stop and colon in Armenian), or change their form (the full stop in Chinese and Japanese, the prefered \"colon\" in Armenian used in fact as the regular full stop, the comma in Arabic, Armenian, and Chinese...)\n\nTheir spacing (before or after) may also vary across languages (for example French requires a non-breaking space, preferably narrow if the browser supports NNBSP, on the inner side of some punctuations like quotation/question/exclamation marks, colon, and semicolons).",
        "colon-separator": "{{optional}}\nChange it only if your language uses another character for ':' or it needs an extra space before the colon.",
index 9a5931f..5dde2f2 100644 (file)
@@ -933,6 +933,12 @@ return [
                        'mediawiki.api',
                ],
        ],
+       'mediawiki.api.rollback' => [
+               'scripts' => 'resources/src/mediawiki/api/rollback.js',
+               'dependencies' => [
+                       'mediawiki.api',
+               ],
+       ],
        'mediawiki.content.json' => [
                'position' => 'top',
                'styles' => 'resources/src/mediawiki/mediawiki.content.json.css',
@@ -1692,6 +1698,18 @@ return [
                        'watcherrortext',
                ],
        ],
+       'mediawiki.page.rollback' => [
+               'scripts' => 'resources/src/mediawiki/page/rollback.js',
+               'dependencies' => [
+                       'mediawiki.api.rollback',
+                       'mediawiki.notify',
+                       'jquery.spinner',
+               ],
+               'messages' => [
+                       'rollbackfailed',
+                       'actioncomplete',
+               ],
+       ],
        'mediawiki.page.image.pagination' => [
                'scripts' => 'resources/src/mediawiki/page/image-pagination.js',
                'dependencies' => [
diff --git a/resources/src/mediawiki/api/rollback.js b/resources/src/mediawiki/api/rollback.js
new file mode 100644 (file)
index 0000000..eb2b3fc
--- /dev/null
@@ -0,0 +1,34 @@
+/**
+ * @class mw.Api.plugin.rollback
+ * @since 1.27
+ */
+( function ( mw, $ ) {
+
+       $.extend( mw.Api.prototype, {
+               /**
+                * Convenience method for `action=rollback`.
+                *
+                * @param {string|mw.Title} page
+                * @param {string} user
+                * @param {Object} [params] Additional parameters
+                * @return {jQuery.Promise}
+                */
+               rollback: function ( page, user, params ) {
+                       return this.postWithToken( 'rollback', $.extend( {
+                               action: 'rollback',
+                               title: String( page ),
+                               user: user,
+                               uselang: mw.config.get( 'wgUserLanguage' )
+                       }, params ) )
+                       .then( function ( data ) {
+                               return data.rollback;
+                       } );
+               }
+       } );
+
+       /**
+        * @class mw.Api
+        * @mixins mw.Api.plugin.rollback
+        */
+
+}( mediaWiki, jQuery ) );
index ec68b3c..30b73e4 100644 (file)
@@ -16,7 +16,7 @@
                $patrolLinks.on( 'click', function ( e ) {
                        var $spinner, rcid, apiRequest;
 
-                       // Start preloading the notification module (normally loaded by mw.notify())
+                       // Preload the notification module for mw.notify
                        mw.loader.load( 'mediawiki.notification' );
 
                        // Hide the link and create a spinner to show it inside the brackets.
diff --git a/resources/src/mediawiki/page/rollback.js b/resources/src/mediawiki/page/rollback.js
new file mode 100644 (file)
index 0000000..d973d07
--- /dev/null
@@ -0,0 +1,66 @@
+/*!
+ * Enhance rollback links by using asynchronous API requests,
+ * rather than navigating to an action page.
+ *
+ * @since 1.27
+ * @author Timo Tijhof
+ */
+( function ( mw, $ ) {
+
+       $( function () {
+               $( '.mw-rollback-link' ).on( 'click', 'a[data-mw="interface"]', function ( e ) {
+                       var api, $spinner,
+                               $link = $( this ),
+                               url = this.href,
+                               page = mw.util.getParamValue( 'title', url ),
+                               user = mw.util.getParamValue( 'from', url );
+
+                       if ( !page || !user ) {
+                               // Let native browsing handle the link
+                               return true;
+                       }
+
+                       // Preload the notification module for mw.notify
+                       mw.loader.load( 'mediawiki.notification' );
+
+                       // Remove event handler so that next click (re-try) uses server action
+                       $( e.delegateTarget ).off( 'click' );
+
+                       // Hide the link and create a spinner to show it inside the brackets.
+                       $spinner = $.createSpinner( { size: 'small', type: 'inline' } );
+                       $link.hide().after( $spinner );
+
+                       api = new mw.Api();
+                       api.rollback( page, user )
+                               .then( function ( data ) {
+                                       mw.notify( $.parseHTML( data.messageHtml ), {
+                                               title: mw.msg( 'actioncomplete' )
+                                       } );
+
+                                       // Remove link container and the subsequent text node containing " | ".
+                                       if ( e.delegateTarget.nextSibling && e.delegateTarget.nextSibling.nodeType === Node.TEXT_NODE ) {
+                                               $( e.delegateTarget.nextSibling ).remove();
+                                       }
+                                       $( e.delegateTarget ).remove();
+                               }, function ( errorCode, data ) {
+                                       var message = data && data.error && data.error.messageHtml
+                                               ? $.parseHTML( data.error.messageHtml )
+                                               : mw.msg( 'rollbackfailed' ),
+                                               type = errorCode === 'alreadyrolled' ? 'warn' : 'error';
+
+                                       mw.notify( message, {
+                                               type: type,
+                                               title: mw.msg( 'rollbackfailed' ),
+                                               autoHide: false
+                                       } );
+
+                                       // Restore the link (enables user to try again)
+                                       $spinner.remove();
+                                       $link.show();
+                               } );
+
+                       e.preventDefault();
+               } );
+       } );
+
+}( mediaWiki, jQuery ) );
index a57d5c7..c59f5ba 100644 (file)
                $links.click( function ( e ) {
                        var action, api, $link;
 
-                       // Start preloading the notification module (normally loaded by mw.notify())
+                       // Preload the notification module for mw.notify
                        mw.loader.load( 'mediawiki.notification' );
 
                        action = mwUriGetAction( this.href );
 
                        if ( action !== 'watch' && action !== 'unwatch' ) {
-                               // Could not extract target action from link url,
-                               // let native browsing handle it further
+                               // Let native browsing handle the link
                                return true;
                        }
                        e.preventDefault();