Merge "mediawiki.ui: Button group active buttons unchanged on user interaction"
authorjenkins-bot <jenkins-bot@gerrit.wikimedia.org>
Thu, 3 Mar 2016 20:39:01 +0000 (20:39 +0000)
committerGerrit Code Review <gerrit@wikimedia.org>
Thu, 3 Mar 2016 20:39:01 +0000 (20:39 +0000)
13 files changed:
autoload.php
includes/specials/SpecialContributions.php
includes/templates/SpecialContributionsLine.mustache [new file with mode: 0644]
includes/widget/SearchInputWidget.php [new file with mode: 0644]
maintenance/jsduck/categories.json
resources/Resources.php
resources/src/mediawiki.less/mediawiki.mixins.less
resources/src/mediawiki.widgets/mw.widgets.SearchInputWidget.css [new file with mode: 0644]
resources/src/mediawiki.widgets/mw.widgets.SearchInputWidget.js [new file with mode: 0644]
resources/src/mediawiki.widgets/mw.widgets.TitleWidget.js
resources/src/mediawiki/mediawiki.viewport.js [new file with mode: 0644]
tests/qunit/QUnitTestResources.php
tests/qunit/suites/resources/mediawiki/mediawiki.viewport.test.js [new file with mode: 0644]

index e882547..e74df0a 100644 (file)
@@ -821,6 +821,7 @@ $wgAutoloadLocalClasses = [
        'MediaWiki\\Widget\\ComplexNamespaceInputWidget' => __DIR__ . '/includes/widget/ComplexNamespaceInputWidget.php',
        'MediaWiki\\Widget\\ComplexTitleInputWidget' => __DIR__ . '/includes/widget/ComplexTitleInputWidget.php',
        'MediaWiki\\Widget\\NamespaceInputWidget' => __DIR__ . '/includes/widget/NamespaceInputWidget.php',
+       'MediaWiki\\Widget\\SearchInputWidget' => __DIR__ . '/includes/widget/SearchInputWidget.php',
        'MediaWiki\\Widget\\TitleInputWidget' => __DIR__ . '/includes/widget/TitleInputWidget.php',
        'MediaWiki\\Widget\\UserInputWidget' => __DIR__ . '/includes/widget/UserInputWidget.php',
        'MemCachedClientforWiki' => __DIR__ . '/includes/compat/MemcachedClientCompat.php',
index 5a351a7..e7ef168 100644 (file)
@@ -1101,16 +1101,13 @@ class ContribsPager extends ReverseChronologicalPager {
                                $userlink = '';
                        }
 
+                       $flags = [];
                        if ( $rev->getParentId() === 0 ) {
-                               $nflag = ChangesList::flag( 'newpage' );
-                       } else {
-                               $nflag = '';
+                               $flags[] = ChangesList::flag( 'newpage' );
                        }
 
                        if ( $rev->isMinor() ) {
-                               $mflag = ChangesList::flag( 'minor' );
-                       } else {
-                               $mflag = '';
+                               $flags[] = ChangesList::flag( 'minor' );
                        }
 
                        $del = Linker::getRevDeleteLink( $user, $rev, $page );
@@ -1121,15 +1118,6 @@ class ContribsPager extends ReverseChronologicalPager {
                        $diffHistLinks = $this->msg( 'parentheses' )
                                ->rawParams( $difftext . $this->messages['pipe-separator'] . $histlink )
                                ->escaped();
-                       $ret = "{$del}{$d} {$diffHistLinks}{$chardiff}{$nflag}{$mflag} ";
-                       $ret .= "{$link}{$userlink} {$comment} {$topmarktext}";
-
-                       # Denote if username is redacted for this edit
-                       if ( $rev->isDeleted( Revision::DELETED_USER ) ) {
-                               $ret .= " <strong>" .
-                                       $this->msg( 'rev-deleted-user-contribs' )->escaped() .
-                                       "</strong>";
-                       }
 
                        # Tags, if any.
                        list( $tagSummary, $newClasses ) = ChangeTags::formatSummaryRow(
@@ -1138,20 +1126,48 @@ class ContribsPager extends ReverseChronologicalPager {
                                $this->getContext()
                        );
                        $classes = array_merge( $classes, $newClasses );
-                       $ret .= " $tagSummary";
+
+                       $templateParams = [
+                               'articleLink' => $link,
+                               'charDifference' => $chardiff,
+                               'classes' => $classes,
+                               'diffHistLinks' => $diffHistLinks,
+                               'flags' => $flags,
+                               'logText' => $comment,
+                               'revDeleteLink' => $del,
+                               'tagSummary' => $tagSummary,
+                               'timestamp' => $d,
+                               'topmarktext' => $topmarktext,
+                               'userlink' => $userlink,
+                       ];
+
+                       # Denote if username is redacted for this edit
+                       if ( $rev->isDeleted( Revision::DELETED_USER ) ) {
+                               $templateParams['rev-deleted-user-contribs'] =
+                                       $this->msg( 'rev-deleted-user-contribs' )->escaped();
+                       }
+
+                       $templateParser = new TemplateParser();
+                       $ret = $templateParser->processTemplate(
+                               'SpecialContributionsLine',
+                               $templateParams
+                       );
                }
 
                // Let extensions add data
-               Hooks::run( 'ContributionsLineEnding', [ $this, &$ret, $row, &$classes ] );
+               Hooks::run( 'ContributionsLineEnding', [ $this, &$ret, $row, &$templateParams['classes'] ] );
 
-               if ( $classes === [] && $ret === '' ) {
+               // TODO: Handle exceptions in the catch block above.  Do any extensions rely on
+               // receiving empty rows?
+
+               if ( $templateParams['classes'] === [] && $ret === '' ) {
                        wfDebug( "Dropping Special:Contribution row that could not be formatted\n" );
-                       $ret = "<!-- Could not format Special:Contribution row. -->\n";
-               } else {
-                       $ret = Html::rawElement( 'li', [ 'class' => $classes ], $ret ) . "\n";
+                       return "<!-- Could not format Special:Contribution row. -->\n";
                }
 
-               return $ret;
+               // FIXME: The signature of the ContributionsLineEnding hook makes it
+               // very awkward to move this LI wrapper into the template.
+               return Html::rawElement( 'li', [ 'class' => $templateParams['classes'] ], $ret ) . "\n";
        }
 
        /**
diff --git a/includes/templates/SpecialContributionsLine.mustache b/includes/templates/SpecialContributionsLine.mustache
new file mode 100644 (file)
index 0000000..7a33401
--- /dev/null
@@ -0,0 +1,6 @@
+{{{ del }}}{{{ timestamp }}}
+{{{ diffHistLinks }}}{{{ charDifference }}}{{# flags }}{{{ . }}}{{/ flags }}
+{{{ articleLink }}}{{{ userlink }}}
+{{{ logText }}}
+{{{ topmarktext }}}{{# rev-deleted-user-contribs }} <strong>{{{ . }}}</strong>{{/ rev-deleted-user-contribs }}
+{{{ tagSummary }}}
diff --git a/includes/widget/SearchInputWidget.php b/includes/widget/SearchInputWidget.php
new file mode 100644 (file)
index 0000000..7b3de4a
--- /dev/null
@@ -0,0 +1,52 @@
+<?php
+/**
+ * MediaWiki Widgets – SearchInputWidget class.
+ *
+ * @copyright 2011-2015 MediaWiki Widgets Team and others; see AUTHORS.txt
+ * @license The MIT License (MIT); see LICENSE.txt
+ */
+namespace MediaWiki\Widget;
+
+/**
+ * Search input widget.
+ */
+class SearchInputWidget extends TitleInputWidget {
+
+       protected $pushPending = false;
+       protected $validateTitle = false;
+       protected $highlightFirst = false;
+
+       /**
+        * @param array $config Configuration options
+        * @param int|null $config['pushPending'] Whether the input should be visually marked as
+        *  "pending", while requesting suggestions (default: true)
+        */
+       public function __construct( array $config = [] ) {
+               // Parent constructor
+               parent::__construct(
+                       array_merge( [
+                               'infusable' => true,
+                               'maxLength' => null,
+                               'type' => 'search',
+                               'icon' => 'search'
+                       ], $config )
+               );
+
+               // Properties, which are ignored in PHP and just shipped back to JS
+               if ( isset( $config['pushPending'] ) ) {
+                       $this->pushPending = $config['pushPending'];
+               }
+
+               // Initialization
+               $this->addClasses( [ 'mw-widget-searchInputWidget' ] );
+       }
+
+       protected function getJavaScriptClassName() {
+               return 'mw.widgets.SearchInputWidget';
+       }
+
+       public function getConfig( &$config ) {
+               $config['pushPending'] = $this->pushPending;
+               return parent::getConfig( $config );
+       }
+}
index 41b56f6..d9e2c50 100644 (file)
@@ -32,7 +32,8 @@
                                        "mw.util",
                                        "mw.plugin.*",
                                        "mw.cookie",
-                                       "mw.experiments"
+                                       "mw.experiments",
+                                       "mw.viewport"
                                ]
                        },
                        {
index dcc02b7..bdf95a7 100644 (file)
@@ -1335,6 +1335,11 @@ return [
                'position' => 'top', // For $wgPreloadJavaScriptMwUtil
                'targets' => [ 'desktop', 'mobile' ],
        ],
+       'mediawiki.viewport' => [
+               'scripts' => 'resources/src/mediawiki/mediawiki.viewport.js',
+               'position' => 'top',
+               'targets' => [ 'desktop', 'mobile' ],
+       ],
        'mediawiki.checkboxtoggle' => [
                'scripts' => 'resources/src/mediawiki/mediawiki.checkboxtoggle.js',
                'position' => 'top',
@@ -2213,6 +2218,25 @@ return [
                ],
                'targets' => [ 'desktop', 'mobile' ],
        ],
+       'mediawiki.widgets.SearchInputWidget' => [
+               'scripts' => [
+                       'resources/src/mediawiki.widgets/mw.widgets.SearchInputWidget.js',
+               ],
+               'dependencies' => [
+                       'mediawiki.searchSuggest',
+                       // FIXME: Needs TitleInputWidget only
+                       'mediawiki.widgets',
+               ],
+       ],
+       'mediawiki.widgets.SearchInputWidget.styles' => [
+               'skinStyles' => [
+                       'default' => [
+                               'resources/src/mediawiki.widgets/mw.widgets.SearchInputWidget.css',
+                       ],
+               ],
+               'position' => 'top',
+               'targets' => [ 'desktop', 'mobile' ],
+       ],
 
        /* es5-shim */
        'es5-shim' => [
index a4dca02..3535be8 100644 (file)
        -ms-flex-order: @order; // IE 10
        order: @order;
 }
+
+
+// Screen Reader Helper Mixin
+.mixin-screen-reader-text() {
+       display: block;
+       position: absolute !important;
+       clip: rect( 1px, 1px, 1px, 1px );
+       width: 1px;
+       height: 1px;
+       margin: -1px;
+       border: 0;
+       padding: 0;
+       overflow: hidden;
+}
diff --git a/resources/src/mediawiki.widgets/mw.widgets.SearchInputWidget.css b/resources/src/mediawiki.widgets/mw.widgets.SearchInputWidget.css
new file mode 100644 (file)
index 0000000..3da5d31
--- /dev/null
@@ -0,0 +1,3 @@
+.mw-widget-searchInputWidget {
+       display: inline-block;
+}
diff --git a/resources/src/mediawiki.widgets/mw.widgets.SearchInputWidget.js b/resources/src/mediawiki.widgets/mw.widgets.SearchInputWidget.js
new file mode 100644 (file)
index 0000000..1f526e2
--- /dev/null
@@ -0,0 +1,111 @@
+/*!
+ * MediaWiki Widgets - SearchInputWidget class.
+ *
+ * @copyright 2011-2015 MediaWiki Widgets Team and others; see AUTHORS.txt
+ * @license The MIT License (MIT); see LICENSE.txt
+ */
+( function ( $, mw ) {
+
+       /**
+        * Creates a mw.widgets.SearchInputWidget object.
+        *
+        * @class
+        * @extends mw.widgets.TitleInputWidget
+        *
+        * @constructor
+        * @cfg {boolean} [pushPending=true] Visually mark the input field as "pending", while
+        *  requesting suggestions.
+        */
+       mw.widgets.SearchInputWidget = function MwWidgetsSearchInputWidget( config ) {
+               config = $.extend( {
+                       type: 'search',
+                       icon: 'search',
+                       maxLength: undefined
+               }, config );
+
+               // Parent constructor
+               mw.widgets.SearchInputWidget.parent.call( this, config );
+
+               // Initialization
+               this.$element.addClass( 'mw-widget-searchInputWidget' );
+               this.lookupMenu.$element.addClass( 'mw-widget-searchWidget-menu' );
+               if ( !config.pushPending ) {
+                       this.pushPending = false;
+               }
+               this.setLookupsDisabled( !this.suggestions );
+       };
+
+       /* Setup */
+
+       OO.inheritClass( mw.widgets.SearchInputWidget, mw.widgets.TitleInputWidget );
+
+       /* Methods */
+
+       /**
+        * @inheritdoc mw.widgets.TitleWidget
+        */
+       mw.widgets.SearchInputWidget.prototype.getSuggestionsPromise = function () {
+               var api = new mw.Api();
+
+               // reuse the searchSuggest function from mw.searchSuggest
+               return mw.searchSuggest.request( api, this.getQueryValue(), $.noop, this.limit );
+       };
+
+       /**
+        * @inheritdoc mw.widgets.TitleInputWidget
+        */
+       mw.widgets.SearchInputWidget.prototype.getLookupCacheDataFromResponse = function ( response ) {
+               // mw.widgets.TitleInputWidget uses response.query, which doesn't exist for opensearch,
+               // so return the whole response (titles only, and links)
+               return response || {};
+       };
+
+       /**
+        * @inheritdoc mw.widgets.TitleWidget
+        */
+       mw.widgets.SearchInputWidget.prototype.getOptionsFromData = function ( data ) {
+               var items = [],
+                       self = this;
+
+               // mw.widgets.TitleWidget does a lot more work here, because the TitleOptionWidgets can
+               // differ a lot, depending on the returned data from the request. With the request used here
+               // we get only the search results.
+               $.each( data[ 1 ], function ( i, result ) {
+                       items.push( new mw.widgets.TitleOptionWidget(
+                               // data[ 3 ][ i ] is the link for this result
+                               self.getOptionWidgetData( result, null, data[ 3 ][ i ] )
+                       ) );
+               } );
+
+               mw.track( 'mw.widgets.SearchInputWidget', {
+                       action: 'impression-results',
+                       numberOfResults: items.length,
+                       resultSetType: mw.searchSuggest.type
+               } );
+
+               return items;
+       };
+
+       /**
+        * @inheritdoc mw.widgets.TitleWidget
+        *
+        * @param {string} title
+        * @param {Object} data
+        * @param {string} url The Url to the result
+        */
+       mw.widgets.SearchInputWidget.prototype.getOptionWidgetData = function ( title, data, url ) {
+               // the values used in mw.widgets-TitleWidget doesn't exist here, that's why
+               // the values are hard-coded here
+               return {
+                       data: title,
+                       url: url,
+                       imageUrl: null,
+                       description: null,
+                       missing: false,
+                       redirect: false,
+                       disambiguation: false,
+                       query: this.getQueryValue()
+               };
+       };
+
+}( jQuery, mediaWiki ) );
index abe1228..b805e65 100644 (file)
@@ -23,7 +23,8 @@
         * @cfg {boolean} [showRedlink] Show red link to exact match if it doesn't exist
         * @cfg {boolean} [showImages] Show page images
         * @cfg {boolean} [showDescriptions] Show page descriptions
-        * @cfg {boolean} [validateTitle=true] Whether the input must be a valid title
+        * @cfg {boolean} [validateTitle=true] Whether the input must be a valid title (if set to true,
+        *  the widget will marks itself red for invalid inputs, including an empty query).
         * @cfg {Object} [cache] Result cache which implements a 'set' method, taking keyed values as an argument
         */
        mw.widgets.TitleWidget = function MwWidgetsTitleWidget( config ) {
diff --git a/resources/src/mediawiki/mediawiki.viewport.js b/resources/src/mediawiki/mediawiki.viewport.js
new file mode 100644 (file)
index 0000000..aa9dd05
--- /dev/null
@@ -0,0 +1,89 @@
+( function ( mw, $ ) {
+       'use strict';
+
+       /**
+        * Utility library for viewport-related functions
+        *
+        * Notable references:
+        * - https://github.com/tuupola/jquery_lazyload
+        * - https://github.com/luis-almeida/unveil
+        *
+        * @class mw.viewport
+        * @singleton
+        */
+       var viewport = {
+
+               /**
+                * This is a private method pulled inside the module for testing purposes.
+                *
+                * @ignore
+                * @private
+                */
+               makeViewportFromWindow: function () {
+                       var $window = $( window ),
+                               scrollTop = $window.scrollTop(),
+                               scrollLeft = $window.scrollLeft();
+
+                       return {
+                               top: scrollTop,
+                               left: scrollLeft,
+                               right: scrollLeft + $window.width(),
+                               bottom: ( window.innerHeight ? window.innerHeight : $window.height() ) + scrollTop
+                       };
+               },
+
+               /**
+                * Check if any part of a given element is in a given viewport
+                *
+                * @method
+                * @param {HTMLElement} el Element that's being tested
+                * @param {Object} [rectangle] Viewport to test against; structured as such:
+                *
+                *      var rectangle = {
+                *              top: topEdge,
+                *              left: leftEdge,
+                *              right: rightEdge,
+                *              bottom: bottomEdge
+                *      }
+                *      Defaults to viewport made from `window`.
+                *
+                * @return {boolean}
+                */
+               isElementInViewport: function ( el, rectangle ) {
+                       var elRect = el.getBoundingClientRect(),
+                               viewport = rectangle || this.makeViewportFromWindow();
+
+                       return (
+                               ( viewport.bottom >= elRect.top ) &&
+                               ( viewport.right >= elRect.left ) &&
+                               ( viewport.top <= elRect.top + elRect.height ) &&
+                               ( viewport.left <= elRect.left + elRect.width )
+                       );
+               },
+
+               /**
+                * Check if an element is a given threshold away in any direction from a given viewport
+                *
+                * @method
+                * @param {HTMLElement} el Element that's being tested
+                * @param {number} [threshold] Pixel distance considered "close". Must be a positive number.
+                *  Defaults to 50.
+                * @param {Object} [rectangle] Viewport to test against.
+                *  Defaults to viewport made from `window`.
+                * @return {boolean}
+                */
+               isElementCloseToViewport: function ( el, threshold, rectangle ) {
+                       var viewport = rectangle ? $.extend( {}, rectangle ) : this.makeViewportFromWindow();
+                       threshold = threshold || 50 ;
+
+                       viewport.top -= threshold;
+                       viewport.left -= threshold;
+                       viewport.right += threshold;
+                       viewport.bottom += threshold;
+                       return this.isElementInViewport( el, viewport );
+               }
+
+       };
+
+       mw.viewport = viewport;
+}( mediaWiki, jQuery ) );
index a2dead6..310268f 100644 (file)
@@ -80,6 +80,7 @@ return [
                        'tests/qunit/suites/resources/mediawiki/mediawiki.Uri.test.js',
                        'tests/qunit/suites/resources/mediawiki/mediawiki.user.test.js',
                        'tests/qunit/suites/resources/mediawiki/mediawiki.util.test.js',
+                       'tests/qunit/suites/resources/mediawiki/mediawiki.viewport.test.js',
                        'tests/qunit/suites/resources/mediawiki.api/mediawiki.api.test.js',
                        'tests/qunit/suites/resources/mediawiki.api/mediawiki.api.category.test.js',
                        'tests/qunit/suites/resources/mediawiki.api/mediawiki.api.messages.test.js',
@@ -130,6 +131,7 @@ return [
                        'mediawiki.template.mustache',
                        'mediawiki.template',
                        'mediawiki.util',
+                       'mediawiki.viewport',
                        'mediawiki.special.recentchanges',
                        'mediawiki.language',
                        'mediawiki.cldr',
diff --git a/tests/qunit/suites/resources/mediawiki/mediawiki.viewport.test.js b/tests/qunit/suites/resources/mediawiki/mediawiki.viewport.test.js
new file mode 100644 (file)
index 0000000..61391d8
--- /dev/null
@@ -0,0 +1,89 @@
+( function ( mw, $ ) {
+
+       // Simulate square element with 20px long edges placed at (20, 20) on the page
+       var
+               DEFAULT_VIEWPORT = {
+                       top: 0,
+                       left: 0,
+                       right: 100,
+                       bottom: 100
+               };
+
+       QUnit.module( 'mediawiki.viewport', QUnit.newMwEnvironment( {
+               setup: function () {
+                       this.el = $( '<div />' )
+                               .appendTo( '#qunit-fixture' )
+                               .width( 20 )
+                               .height( 20 )
+                               .offset( {
+                                       top: 20,
+                                       left: 20
+                               } )
+                               .get( 0 );
+                       this.sandbox.stub( mw.viewport, 'makeViewportFromWindow' )
+                               .returns( DEFAULT_VIEWPORT );
+               }
+       } ) );
+
+       QUnit.test( 'isElementInViewport', 6, function ( assert ) {
+               var viewport = $.extend( {}, DEFAULT_VIEWPORT );
+               assert.ok( mw.viewport.isElementInViewport( this.el, viewport ),
+                       'It should return true when the element is fully enclosed in the viewport' );
+
+               viewport.right = 20;
+               viewport.bottom = 20;
+               assert.ok( mw.viewport.isElementInViewport( this.el, viewport ),
+                       'It should return true when only the top-left of the element is within the viewport' );
+
+               viewport.top = 40;
+               viewport.left = 40;
+               viewport.right = 50;
+               viewport.bottom = 50;
+               assert.ok( mw.viewport.isElementInViewport( this.el, viewport ),
+                       'It should return true when only the bottom-right is within the viewport' );
+
+               viewport.top = 30;
+               viewport.left = 30;
+               viewport.right = 35;
+               viewport.bottom = 35;
+               assert.ok( mw.viewport.isElementInViewport( this.el, viewport ),
+                       'It should return true when the element encapsulates the viewport' );
+
+               viewport.top = 0;
+               viewport.left = 0;
+               viewport.right = 19;
+               viewport.bottom = 19;
+               assert.notOk( mw.viewport.isElementInViewport( this.el, viewport ),
+                       'It should return false when the element is not within the viewport' );
+
+               assert.ok( mw.viewport.isElementInViewport( this.el ),
+                       'It should default to the window object if no viewport is given' );
+       } );
+
+       QUnit.test( 'isElementCloseToViewport', 3, function ( assert ) {
+               var
+                       viewport = {
+                               top: 90,
+                               left: 90,
+                               right: 100,
+                               bottom: 100
+                       },
+                       distantElement = $( '<div />' )
+                               .appendTo( '#qunit-fixture' )
+                               .width( 20 )
+                               .height( 20 )
+                               .offset( {
+                                       top: 220,
+                                       left: 20
+                               } )
+                               .get( 0 );
+
+               assert.ok( mw.viewport.isElementCloseToViewport( this.el, 60, viewport ),
+                       'It should return true when the element is within the given threshold away' );
+               assert.notOk( mw.viewport.isElementCloseToViewport( this.el, 20, viewport ),
+                       'It should return false when the element is further than the given threshold away' );
+               assert.notOk( mw.viewport.isElementCloseToViewport( distantElement ),
+                       'It should default to a threshold of 50px and the window\'s viewport' );
+       } );
+
+}( mediaWiki, jQuery ) );